See #170: channels ui (listeners)
This commit is contained in:
parent
b74517ff33
commit
95497e76ac
|
@ -88,6 +88,9 @@ v1_patterns += [
|
||||||
url(r"^token/?$", jwt_views.obtain_jwt_token, name="token"),
|
url(r"^token/?$", jwt_views.obtain_jwt_token, name="token"),
|
||||||
url(r"^token/refresh/?$", jwt_views.refresh_jwt_token, name="token_refresh"),
|
url(r"^token/refresh/?$", jwt_views.refresh_jwt_token, name="token_refresh"),
|
||||||
url(r"^rate-limit/?$", common_views.RateLimitView.as_view(), name="rate-limit"),
|
url(r"^rate-limit/?$", common_views.RateLimitView.as_view(), name="rate-limit"),
|
||||||
|
url(
|
||||||
|
r"^text-preview/?$", common_views.TextPreviewView.as_view(), name="text-preview"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
|
@ -70,6 +70,8 @@ class ChannelCreateSerializer(serializers.Serializer):
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
|
from . import views
|
||||||
|
|
||||||
description = validated_data.get("description")
|
description = validated_data.get("description")
|
||||||
artist = music_models.Artist.objects.create(
|
artist = music_models.Artist.objects.create(
|
||||||
attributed_to=validated_data["attributed_to"],
|
attributed_to=validated_data["attributed_to"],
|
||||||
|
@ -99,10 +101,11 @@ class ChannelCreateSerializer(serializers.Serializer):
|
||||||
actor=validated_data["attributed_to"],
|
actor=validated_data["attributed_to"],
|
||||||
)
|
)
|
||||||
channel.save()
|
channel.save()
|
||||||
|
channel = views.ChannelViewSet.queryset.get(pk=channel.pk)
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
def to_representation(self, obj):
|
def to_representation(self, obj):
|
||||||
return ChannelSerializer(obj).data
|
return ChannelSerializer(obj, context=self.context).data
|
||||||
|
|
||||||
|
|
||||||
NOOP = object()
|
NOOP = object()
|
||||||
|
@ -181,7 +184,7 @@ class ChannelUpdateSerializer(serializers.Serializer):
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def to_representation(self, obj):
|
def to_representation(self, obj):
|
||||||
return ChannelSerializer(obj).data
|
return ChannelSerializer(obj, context=self.context).data
|
||||||
|
|
||||||
|
|
||||||
class ChannelSerializer(serializers.ModelSerializer):
|
class ChannelSerializer(serializers.ModelSerializer):
|
||||||
|
@ -261,7 +264,8 @@ def rss_serialize_item(upload):
|
||||||
"link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
|
"link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
|
||||||
"enclosure": [
|
"enclosure": [
|
||||||
{
|
{
|
||||||
"url": upload.listen_url,
|
# we enforce MP3, since it's the only format supported everywhere
|
||||||
|
"url": federation_utils.full_url(upload.get_listen_url(to="mp3")),
|
||||||
"length": upload.size or 0,
|
"length": upload.size or 0,
|
||||||
"type": upload.mimetype or "audio/mpeg",
|
"type": upload.mimetype or "audio/mpeg",
|
||||||
}
|
}
|
||||||
|
@ -271,7 +275,6 @@ def rss_serialize_item(upload):
|
||||||
data["itunes:subtitle"] = [{"value": upload.track.description.truncate(255)}]
|
data["itunes:subtitle"] = [{"value": upload.track.description.truncate(255)}]
|
||||||
data["itunes:summary"] = [{"cdata_value": upload.track.description.rendered}]
|
data["itunes:summary"] = [{"cdata_value": upload.track.description.rendered}]
|
||||||
data["description"] = [{"value": upload.track.description.as_plain_text}]
|
data["description"] = [{"value": upload.track.description.as_plain_text}]
|
||||||
data["content:encoded"] = data["itunes:summary"]
|
|
||||||
|
|
||||||
if upload.track.attachment_cover:
|
if upload.track.attachment_cover:
|
||||||
data["itunes:image"] = [
|
data["itunes:image"] = [
|
||||||
|
|
|
@ -6,7 +6,7 @@ from rest_framework import response
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
|
|
||||||
from django import http
|
from django import http
|
||||||
from django.db.models import Prefetch
|
from django.db.models import Count, Prefetch
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
|
|
||||||
from funkwhale_api.common import permissions
|
from funkwhale_api.common import permissions
|
||||||
|
@ -18,6 +18,12 @@ from funkwhale_api.users.oauth import permissions as oauth_permissions
|
||||||
|
|
||||||
from . import filters, models, renderers, serializers
|
from . import filters, models, renderers, serializers
|
||||||
|
|
||||||
|
ARTIST_PREFETCH_QS = (
|
||||||
|
music_models.Artist.objects.select_related("description", "attachment_cover",)
|
||||||
|
.prefetch_related(music_views.TAG_PREFETCH)
|
||||||
|
.annotate(_tracks_count=Count("tracks"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChannelsMixin(object):
|
class ChannelsMixin(object):
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
@ -44,12 +50,7 @@ class ChannelViewSet(
|
||||||
"library",
|
"library",
|
||||||
"attributed_to",
|
"attributed_to",
|
||||||
"actor",
|
"actor",
|
||||||
Prefetch(
|
Prefetch("artist", queryset=ARTIST_PREFETCH_QS),
|
||||||
"artist",
|
|
||||||
queryset=music_models.Artist.objects.select_related(
|
|
||||||
"attachment_cover", "description"
|
|
||||||
).prefetch_related(music_views.TAG_PREFETCH,),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.order_by("-creation_date")
|
.order_by("-creation_date")
|
||||||
)
|
)
|
||||||
|
@ -131,7 +132,12 @@ class ChannelViewSet(
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
context = super().get_serializer_context()
|
context = super().get_serializer_context()
|
||||||
context["subscriptions_count"] = self.action in ["retrieve", "create", "update"]
|
context["subscriptions_count"] = self.action in [
|
||||||
|
"retrieve",
|
||||||
|
"create",
|
||||||
|
"update",
|
||||||
|
"partial_update",
|
||||||
|
]
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@ -148,8 +154,8 @@ class SubscriptionsViewSet(
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
"target__channel__library",
|
"target__channel__library",
|
||||||
"target__channel__attributed_to",
|
"target__channel__attributed_to",
|
||||||
"target__channel__artist__description",
|
|
||||||
"actor",
|
"actor",
|
||||||
|
Prefetch("target__channel__artist", queryset=ARTIST_PREFETCH_QS),
|
||||||
)
|
)
|
||||||
.order_by("-creation_date")
|
.order_by("-creation_date")
|
||||||
)
|
)
|
||||||
|
@ -171,10 +177,12 @@ class SubscriptionsViewSet(
|
||||||
to have a performant endpoint and avoid lots of queries just to display
|
to have a performant endpoint and avoid lots of queries just to display
|
||||||
subscription status in the UI
|
subscription status in the UI
|
||||||
"""
|
"""
|
||||||
subscriptions = list(self.get_queryset().values_list("uuid", flat=True))
|
subscriptions = list(
|
||||||
|
self.get_queryset().values_list("uuid", "target__channel__uuid")
|
||||||
|
)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"results": [str(u) for u in subscriptions],
|
"results": [{"uuid": str(u[0]), "channel": u[1]} for u in subscriptions],
|
||||||
"count": len(subscriptions),
|
"count": len(subscriptions),
|
||||||
}
|
}
|
||||||
return response.Response(payload, status=200)
|
return response.Response(payload, status=200)
|
||||||
|
|
|
@ -176,6 +176,8 @@ class ActorScopeFilter(filters.CharFilter):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def filter(self, queryset, value):
|
def filter(self, queryset, value):
|
||||||
|
from funkwhale_api.federation import models as federation_models
|
||||||
|
|
||||||
if not value:
|
if not value:
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
@ -189,6 +191,17 @@ class ActorScopeFilter(filters.CharFilter):
|
||||||
qs = self.filter_me(user=user, queryset=queryset)
|
qs = self.filter_me(user=user, queryset=queryset)
|
||||||
elif value.lower() == "all":
|
elif value.lower() == "all":
|
||||||
return queryset
|
return queryset
|
||||||
|
elif value.lower().startswith("actor:"):
|
||||||
|
full_username = value.split("actor:", 1)[1]
|
||||||
|
username, domain = full_username.split("@")
|
||||||
|
try:
|
||||||
|
actor = federation_models.Actor.objects.get(
|
||||||
|
preferred_username=username, domain_id=domain,
|
||||||
|
)
|
||||||
|
except federation_models.Actor.DoesNotExist:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
return queryset.filter(**{self.actor_field: actor})
|
||||||
else:
|
else:
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
|
|
|
@ -201,7 +201,7 @@ class AttachmentQuerySet(models.QuerySet):
|
||||||
|
|
||||||
class Attachment(models.Model):
|
class Attachment(models.Model):
|
||||||
# Remote URL where the attachment can be fetched
|
# Remote URL where the attachment can be fetched
|
||||||
url = models.URLField(max_length=500, null=True)
|
url = models.URLField(max_length=500, null=True, blank=True)
|
||||||
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
|
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
|
||||||
# Actor associated with the attachment
|
# Actor associated with the attachment
|
||||||
actor = models.ForeignKey(
|
actor = models.ForeignKey(
|
||||||
|
|
|
@ -303,6 +303,7 @@ def attach_content(obj, field, content_data):
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
getattr(obj, field).delete()
|
getattr(obj, field).delete()
|
||||||
|
setattr(obj, field, None)
|
||||||
|
|
||||||
if not content_data:
|
if not content_data:
|
||||||
return
|
return
|
||||||
|
|
|
@ -181,3 +181,15 @@ class AttachmentViewSet(
|
||||||
if instance.actor is None or instance.actor != self.request.user.actor:
|
if instance.actor is None or instance.actor != self.request.user.actor:
|
||||||
raise exceptions.PermissionDenied()
|
raise exceptions.PermissionDenied()
|
||||||
instance.delete()
|
instance.delete()
|
||||||
|
|
||||||
|
|
||||||
|
class TextPreviewView(views.APIView):
|
||||||
|
permission_classes = []
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
payload = request.data
|
||||||
|
if "text" not in payload:
|
||||||
|
return response.Response({"detail": "Invalid input"}, status=400)
|
||||||
|
|
||||||
|
data = {"rendered": utils.render_html(payload["text"], "text/markdown")}
|
||||||
|
return response.Response(data, status=200)
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from funkwhale_api.common import serializers as common_serializers
|
from funkwhale_api.common import serializers as common_serializers
|
||||||
from funkwhale_api.music import models as music_models
|
from funkwhale_api.music import models as music_models
|
||||||
|
from funkwhale_api.users import serializers as users_serializers
|
||||||
|
|
||||||
from . import filters
|
from . import filters
|
||||||
from . import models
|
from . import models
|
||||||
|
@ -169,3 +172,27 @@ class FetchSerializer(serializers.ModelSerializer):
|
||||||
"creation_date",
|
"creation_date",
|
||||||
"fetch_date",
|
"fetch_date",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FullActorSerializer(serializers.Serializer):
|
||||||
|
fid = serializers.URLField()
|
||||||
|
url = serializers.URLField()
|
||||||
|
domain = serializers.CharField(source="domain_id")
|
||||||
|
creation_date = serializers.DateTimeField()
|
||||||
|
last_fetch_date = serializers.DateTimeField()
|
||||||
|
name = serializers.CharField()
|
||||||
|
preferred_username = serializers.CharField()
|
||||||
|
full_username = serializers.CharField()
|
||||||
|
type = serializers.CharField()
|
||||||
|
is_local = serializers.BooleanField()
|
||||||
|
is_channel = serializers.SerializerMethodField()
|
||||||
|
manually_approves_followers = serializers.BooleanField()
|
||||||
|
user = users_serializers.UserBasicSerializer()
|
||||||
|
summary = common_serializers.ContentSerializer(source="summary_obj")
|
||||||
|
icon = common_serializers.AttachmentSerializer(source="attachment_icon")
|
||||||
|
|
||||||
|
def get_is_channel(self, o):
|
||||||
|
try:
|
||||||
|
return bool(o.channel)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return False
|
||||||
|
|
|
@ -8,5 +8,6 @@ router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-fol
|
||||||
router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
|
router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
|
||||||
router.register(r"libraries", api_views.LibraryViewSet, "libraries")
|
router.register(r"libraries", api_views.LibraryViewSet, "libraries")
|
||||||
router.register(r"domains", api_views.DomainViewSet, "domains")
|
router.register(r"domains", api_views.DomainViewSet, "domains")
|
||||||
|
router.register(r"actors", api_views.ActorViewSet, "actors")
|
||||||
|
|
||||||
urlpatterns = router.urls
|
urlpatterns = router.urls
|
||||||
|
|
|
@ -12,6 +12,7 @@ from rest_framework import viewsets
|
||||||
from funkwhale_api.common import preferences
|
from funkwhale_api.common import preferences
|
||||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||||
from funkwhale_api.music import models as music_models
|
from funkwhale_api.music import models as music_models
|
||||||
|
from funkwhale_api.music import views as music_views
|
||||||
from funkwhale_api.users.oauth import permissions as oauth_permissions
|
from funkwhale_api.users.oauth import permissions as oauth_permissions
|
||||||
|
|
||||||
from . import activity
|
from . import activity
|
||||||
|
@ -218,3 +219,34 @@ class DomainViewSet(
|
||||||
if preferences.get("moderation__allow_list_enabled"):
|
if preferences.get("moderation__allow_list_enabled"):
|
||||||
qs = qs.filter(allowed=True)
|
qs = qs.filter(allowed=True)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||||||
|
queryset = models.Actor.objects.select_related(
|
||||||
|
"user", "channel", "summary_obj", "attachment_icon"
|
||||||
|
)
|
||||||
|
permission_classes = [ConditionalAuthentication]
|
||||||
|
serializer_class = api_serializers.FullActorSerializer
|
||||||
|
lookup_field = "full_username"
|
||||||
|
lookup_value_regex = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
username, domain = self.kwargs["full_username"].split("@", 1)
|
||||||
|
return queryset.get(preferred_username=username, domain_id=domain)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = super().get_queryset()
|
||||||
|
qs = qs.exclude(
|
||||||
|
domain__instance_policy__is_active=True,
|
||||||
|
domain__instance_policy__block_all=True,
|
||||||
|
)
|
||||||
|
if preferences.get("moderation__allow_list_enabled"):
|
||||||
|
qs = qs.filter(domain__allowed=True)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
libraries = decorators.action(methods=["get"], detail=True)(
|
||||||
|
music_views.get_libraries(
|
||||||
|
filter_uploads=lambda o, uploads: uploads.filter(library__actor=o)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
|
@ -253,7 +253,6 @@ class APIActorSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Actor
|
model = models.Actor
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
|
||||||
"fid",
|
"fid",
|
||||||
"url",
|
"url",
|
||||||
"creation_date",
|
"creation_date",
|
||||||
|
|
|
@ -876,6 +876,12 @@ class Upload(models.Model):
|
||||||
def listen_url(self):
|
def listen_url(self):
|
||||||
return self.track.listen_url + "?upload={}".format(self.uuid)
|
return self.track.listen_url + "?upload={}".format(self.uuid)
|
||||||
|
|
||||||
|
def get_listen_url(self, to=None):
|
||||||
|
url = self.listen_url
|
||||||
|
if to:
|
||||||
|
url += "&to={}".format(to)
|
||||||
|
return url
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def listen_url_no_download(self):
|
def listen_url_no_download(self):
|
||||||
# Not using reverse because this is slow
|
# Not using reverse because this is slow
|
||||||
|
|
|
@ -156,6 +156,19 @@ def serialize_artist_simple(artist):
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "attachment_cover" in artist._state.fields_cache:
|
||||||
|
data["cover"] = (
|
||||||
|
cover_field.to_representation(artist.attachment_cover)
|
||||||
|
if artist.attachment_cover
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if getattr(artist, "_tracks_count", None) is not None:
|
||||||
|
data["tracks_count"] = artist._tracks_count
|
||||||
|
|
||||||
|
if getattr(artist, "_prefetched_tagged_items", None) is not None:
|
||||||
|
data["tags"] = [ti.tag.name for ti in artist._prefetched_tagged_items]
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,29 @@ from rest_framework import renderers
|
||||||
import funkwhale_api
|
import funkwhale_api
|
||||||
|
|
||||||
|
|
||||||
|
# from https://stackoverflow.com/a/8915039
|
||||||
|
# because I want to avoid a lxml dependency just for outputting cdata properly
|
||||||
|
# in a RSS feed
|
||||||
|
def CDATA(text=None):
|
||||||
|
element = ET.Element("![CDATA[")
|
||||||
|
element.text = text
|
||||||
|
return element
|
||||||
|
|
||||||
|
|
||||||
|
ET._original_serialize_xml = ET._serialize_xml
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_xml(write, elem, qnames, namespaces, **kwargs):
|
||||||
|
if elem.tag == "![CDATA[":
|
||||||
|
write("<%s%s]]>" % (elem.tag, elem.text))
|
||||||
|
return
|
||||||
|
return ET._original_serialize_xml(write, elem, qnames, namespaces, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
ET._serialize_xml = ET._serialize["xml"] = _serialize_xml
|
||||||
|
# end of tweaks
|
||||||
|
|
||||||
|
|
||||||
def structure_payload(data):
|
def structure_payload(data):
|
||||||
payload = {
|
payload = {
|
||||||
"funkwhaleVersion": funkwhale_api.__version__,
|
"funkwhaleVersion": funkwhale_api.__version__,
|
||||||
|
@ -56,7 +79,7 @@ def dict_to_xml_tree(root_tag, d, parent=None):
|
||||||
if key == "value":
|
if key == "value":
|
||||||
root.text = str(value)
|
root.text = str(value)
|
||||||
elif key == "cdata_value":
|
elif key == "cdata_value":
|
||||||
root.text = "<![CDATA[{}]]>".format(str(value))
|
root.append(CDATA(value))
|
||||||
else:
|
else:
|
||||||
root.set(key, str(value))
|
root.set(key, str(value))
|
||||||
return root
|
return root
|
||||||
|
|
|
@ -229,8 +229,8 @@ class User(AbstractUser):
|
||||||
self.last_activity = now
|
self.last_activity = now
|
||||||
self.save(update_fields=["last_activity"])
|
self.save(update_fields=["last_activity"])
|
||||||
|
|
||||||
def create_actor(self):
|
def create_actor(self, **kwargs):
|
||||||
self.actor = create_actor(self)
|
self.actor = create_actor(self, **kwargs)
|
||||||
self.save(update_fields=["actor"])
|
self.save(update_fields=["actor"])
|
||||||
return self.actor
|
return self.actor
|
||||||
|
|
||||||
|
@ -264,15 +264,10 @@ class User(AbstractUser):
|
||||||
def full_username(self):
|
def full_username(self):
|
||||||
return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME)
|
return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME)
|
||||||
|
|
||||||
@property
|
def get_avatar(self):
|
||||||
def avatar_path(self):
|
if not self.actor:
|
||||||
if not self.avatar:
|
return
|
||||||
return None
|
return self.actor.attachment_icon
|
||||||
try:
|
|
||||||
return self.avatar.path
|
|
||||||
except NotImplementedError:
|
|
||||||
# external storage
|
|
||||||
return self.avatar.name
|
|
||||||
|
|
||||||
|
|
||||||
def generate_code(length=10):
|
def generate_code(length=10):
|
||||||
|
@ -399,8 +394,9 @@ def get_actor_data(username, **kwargs):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_actor(user):
|
def create_actor(user, **kwargs):
|
||||||
args = get_actor_data(user.username)
|
args = get_actor_data(user.username)
|
||||||
|
args.update(kwargs)
|
||||||
private, public = keys.get_key_pair()
|
private, public = keys.get_key_pair()
|
||||||
args["private_key"] = private.decode("utf-8")
|
args["private_key"] = private.decode("utf-8")
|
||||||
args["public_key"] = public.decode("utf-8")
|
args["public_key"] = public.decode("utf-8")
|
||||||
|
|
|
@ -90,17 +90,12 @@ class UserActivitySerializer(activity_serializers.ModelSerializer):
|
||||||
|
|
||||||
|
|
||||||
class UserBasicSerializer(serializers.ModelSerializer):
|
class UserBasicSerializer(serializers.ModelSerializer):
|
||||||
avatar = serializers.SerializerMethodField()
|
avatar = common_serializers.AttachmentSerializer(source="get_avatar")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["id", "username", "name", "date_joined", "avatar"]
|
fields = ["id", "username", "name", "date_joined", "avatar"]
|
||||||
|
|
||||||
def get_avatar(self, o):
|
|
||||||
return common_serializers.AttachmentSerializer(
|
|
||||||
o.actor.attachment_icon if o.actor else None
|
|
||||||
).data
|
|
||||||
|
|
||||||
|
|
||||||
class UserWriteSerializer(serializers.ModelSerializer):
|
class UserWriteSerializer(serializers.ModelSerializer):
|
||||||
summary = common_serializers.ContentSerializer(required=False, allow_null=True)
|
summary = common_serializers.ContentSerializer(required=False, allow_null=True)
|
||||||
|
@ -140,19 +135,12 @@ class UserWriteSerializer(serializers.ModelSerializer):
|
||||||
obj.actor.save(update_fields=["attachment_icon"])
|
obj.actor.save(update_fields=["attachment_icon"])
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def to_representation(self, obj):
|
|
||||||
repr = super().to_representation(obj)
|
|
||||||
repr["avatar"] = common_serializers.AttachmentSerializer(
|
|
||||||
obj.actor.attachment_icon
|
|
||||||
).data
|
|
||||||
return repr
|
|
||||||
|
|
||||||
|
|
||||||
class UserReadSerializer(serializers.ModelSerializer):
|
class UserReadSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
permissions = serializers.SerializerMethodField()
|
permissions = serializers.SerializerMethodField()
|
||||||
full_username = serializers.SerializerMethodField()
|
full_username = serializers.SerializerMethodField()
|
||||||
avatar = serializers.SerializerMethodField()
|
avatar = common_serializers.AttachmentSerializer(source="get_avatar")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
|
@ -170,9 +158,6 @@ class UserReadSerializer(serializers.ModelSerializer):
|
||||||
"avatar",
|
"avatar",
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_avatar(self, o):
|
|
||||||
return common_serializers.AttachmentSerializer(o.actor.attachment_icon).data
|
|
||||||
|
|
||||||
def get_permissions(self, o):
|
def get_permissions(self, o):
|
||||||
return o.get_permissions()
|
return o.get_permissions()
|
||||||
|
|
||||||
|
|
|
@ -185,7 +185,6 @@ def test_rss_item_serializer(factories):
|
||||||
"itunes:subtitle": [{"value": description.truncate(255)}],
|
"itunes:subtitle": [{"value": description.truncate(255)}],
|
||||||
"itunes:summary": [{"cdata_value": description.rendered}],
|
"itunes:summary": [{"cdata_value": description.rendered}],
|
||||||
"description": [{"value": description.as_plain_text}],
|
"description": [{"value": description.as_plain_text}],
|
||||||
"content:encoded": [{"cdata_value": description.rendered}],
|
|
||||||
"guid": [{"cdata_value": str(upload.uuid), "isPermalink": "false"}],
|
"guid": [{"cdata_value": str(upload.uuid), "isPermalink": "false"}],
|
||||||
"pubDate": [{"value": serializers.rss_date(upload.creation_date)}],
|
"pubDate": [{"value": serializers.rss_date(upload.creation_date)}],
|
||||||
"itunes:duration": [{"value": serializers.rss_duration(upload.duration)}],
|
"itunes:duration": [{"value": serializers.rss_duration(upload.duration)}],
|
||||||
|
@ -197,7 +196,11 @@ def test_rss_item_serializer(factories):
|
||||||
"itunes:image": [{"href": upload.track.attachment_cover.download_url_original}],
|
"itunes:image": [{"href": upload.track.attachment_cover.download_url_original}],
|
||||||
"link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
|
"link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
|
||||||
"enclosure": [
|
"enclosure": [
|
||||||
{"url": upload.listen_url, "length": upload.size, "type": upload.mimetype}
|
{
|
||||||
|
"url": federation_utils.full_url(upload.get_listen_url("mp3")),
|
||||||
|
"length": upload.size,
|
||||||
|
"type": upload.mimetype,
|
||||||
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
|
import uuid
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from funkwhale_api.audio import serializers
|
from funkwhale_api.audio import serializers
|
||||||
|
from funkwhale_api.audio import views
|
||||||
|
|
||||||
|
|
||||||
def test_channel_create(logged_in_api_client):
|
def test_channel_create(logged_in_api_client):
|
||||||
|
@ -23,8 +25,10 @@ def test_channel_create(logged_in_api_client):
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
channel = actor.owned_channels.select_related("artist__description").latest("id")
|
channel = views.ChannelViewSet.queryset.get(attributed_to=actor)
|
||||||
expected = serializers.ChannelSerializer(channel).data
|
expected = serializers.ChannelSerializer(
|
||||||
|
channel, context={"subscriptions_count": True}
|
||||||
|
).data
|
||||||
|
|
||||||
assert response.data == expected
|
assert response.data == expected
|
||||||
assert channel.artist.name == data["name"]
|
assert channel.artist.name == data["name"]
|
||||||
|
@ -43,6 +47,9 @@ def test_channel_create(logged_in_api_client):
|
||||||
def test_channel_detail(factories, logged_in_api_client):
|
def test_channel_detail(factories, logged_in_api_client):
|
||||||
channel = factories["audio.Channel"](artist__description=None)
|
channel = factories["audio.Channel"](artist__description=None)
|
||||||
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
|
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
|
||||||
|
setattr(channel.artist, "_tracks_count", 0)
|
||||||
|
setattr(channel.artist, "_prefetched_tagged_items", [])
|
||||||
|
|
||||||
expected = serializers.ChannelSerializer(
|
expected = serializers.ChannelSerializer(
|
||||||
channel, context={"subscriptions_count": True}
|
channel, context={"subscriptions_count": True}
|
||||||
).data
|
).data
|
||||||
|
@ -54,6 +61,8 @@ def test_channel_detail(factories, logged_in_api_client):
|
||||||
|
|
||||||
def test_channel_list(factories, logged_in_api_client):
|
def test_channel_list(factories, logged_in_api_client):
|
||||||
channel = factories["audio.Channel"](artist__description=None)
|
channel = factories["audio.Channel"](artist__description=None)
|
||||||
|
setattr(channel.artist, "_tracks_count", 0)
|
||||||
|
setattr(channel.artist, "_prefetched_tagged_items", [])
|
||||||
url = reverse("api:v1:channels-list")
|
url = reverse("api:v1:channels-list")
|
||||||
expected = serializers.ChannelSerializer(channel).data
|
expected = serializers.ChannelSerializer(channel).data
|
||||||
response = logged_in_api_client.get(url)
|
response = logged_in_api_client.get(url)
|
||||||
|
@ -142,8 +151,11 @@ def test_channel_subscribe(factories, logged_in_api_client):
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
subscription = actor.emitted_follows.select_related(
|
subscription = actor.emitted_follows.select_related(
|
||||||
"target__channel__artist__description"
|
"target__channel__artist__description",
|
||||||
|
"target__channel__artist__attachment_cover",
|
||||||
).latest("id")
|
).latest("id")
|
||||||
|
setattr(subscription.target.channel.artist, "_tracks_count", 0)
|
||||||
|
setattr(subscription.target.channel.artist, "_prefetched_tagged_items", [])
|
||||||
assert subscription.fid == subscription.get_federation_id()
|
assert subscription.fid == subscription.get_federation_id()
|
||||||
expected = serializers.SubscriptionSerializer(subscription).data
|
expected = serializers.SubscriptionSerializer(subscription).data
|
||||||
assert response.data == expected
|
assert response.data == expected
|
||||||
|
@ -168,6 +180,8 @@ def test_subscriptions_list(factories, logged_in_api_client):
|
||||||
actor = logged_in_api_client.user.create_actor()
|
actor = logged_in_api_client.user.create_actor()
|
||||||
channel = factories["audio.Channel"](artist__description=None)
|
channel = factories["audio.Channel"](artist__description=None)
|
||||||
subscription = factories["audio.Subscription"](target=channel.actor, actor=actor)
|
subscription = factories["audio.Subscription"](target=channel.actor, actor=actor)
|
||||||
|
setattr(subscription.target.channel.artist, "_tracks_count", 0)
|
||||||
|
setattr(subscription.target.channel.artist, "_prefetched_tagged_items", [])
|
||||||
factories["audio.Subscription"](target=channel.actor)
|
factories["audio.Subscription"](target=channel.actor)
|
||||||
url = reverse("api:v1:subscriptions-list")
|
url = reverse("api:v1:subscriptions-list")
|
||||||
expected = serializers.SubscriptionSerializer(subscription).data
|
expected = serializers.SubscriptionSerializer(subscription).data
|
||||||
|
@ -192,7 +206,10 @@ def test_subscriptions_all(factories, logged_in_api_client):
|
||||||
response = logged_in_api_client.get(url)
|
response = logged_in_api_client.get(url)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data == {"results": [subscription.uuid], "count": 1}
|
assert response.data == {
|
||||||
|
"results": [{"uuid": subscription.uuid, "channel": uuid.UUID(channel.uuid)}],
|
||||||
|
"count": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_channel_rss_feed(factories, api_client):
|
def test_channel_rss_feed(factories, api_client):
|
||||||
|
|
|
@ -50,6 +50,8 @@ def test_mutation_filter_is_approved(value, expected, factories):
|
||||||
("noop", 0, []),
|
("noop", 0, []),
|
||||||
("noop", 1, []),
|
("noop", 1, []),
|
||||||
("noop", 2, []),
|
("noop", 2, []),
|
||||||
|
("actor:actor1@domain.test", 0, [0]),
|
||||||
|
("actor:actor2@domain.test", 0, [1]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_actor_scope_filter(
|
def test_actor_scope_filter(
|
||||||
|
@ -61,8 +63,13 @@ def test_actor_scope_filter(
|
||||||
mocker,
|
mocker,
|
||||||
anonymous_user,
|
anonymous_user,
|
||||||
):
|
):
|
||||||
actor1 = factories["users.User"]().create_actor()
|
domain = factories["federation.Domain"](name="domain.test")
|
||||||
actor2 = factories["users.User"]().create_actor()
|
actor1 = factories["users.User"]().create_actor(
|
||||||
|
preferred_username="actor1", domain=domain
|
||||||
|
)
|
||||||
|
actor2 = factories["users.User"]().create_actor(
|
||||||
|
preferred_username="actor2", domain=domain
|
||||||
|
)
|
||||||
users = [actor1.user, actor2.user, anonymous_user]
|
users = [actor1.user, actor2.user, anonymous_user]
|
||||||
tracks = [
|
tracks = [
|
||||||
factories["music.Upload"](library__actor=actor1, playable=True).track,
|
factories["music.Upload"](library__actor=actor1, playable=True).track,
|
||||||
|
|
|
@ -7,6 +7,7 @@ from funkwhale_api.common import serializers
|
||||||
from funkwhale_api.common import signals
|
from funkwhale_api.common import signals
|
||||||
from funkwhale_api.common import tasks
|
from funkwhale_api.common import tasks
|
||||||
from funkwhale_api.common import throttling
|
from funkwhale_api.common import throttling
|
||||||
|
from funkwhale_api.common import utils
|
||||||
|
|
||||||
|
|
||||||
def test_can_detail_mutation(logged_in_api_client, factories):
|
def test_can_detail_mutation(logged_in_api_client, factories):
|
||||||
|
@ -270,3 +271,13 @@ def test_attachment_destroy_not_owner(factories, logged_in_api_client):
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
attachment.refresh_from_db()
|
attachment.refresh_from_db()
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_render_text_preview(api_client, db):
|
||||||
|
payload = {"text": "Hello world"}
|
||||||
|
url = reverse("api:v1:text-preview")
|
||||||
|
response = api_client.post(url, payload)
|
||||||
|
|
||||||
|
expected = {"rendered": utils.render_html(payload["text"], "text/markdown")}
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == expected
|
||||||
|
|
|
@ -29,6 +29,9 @@ def test_user_can_get_his_favorites(
|
||||||
favorite, context={"request": request}
|
favorite, context={"request": request}
|
||||||
).data
|
).data
|
||||||
]
|
]
|
||||||
|
expected[0]["track"]["artist"].pop("cover")
|
||||||
|
expected[0]["track"]["album"]["artist"].pop("cover")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data["results"] == expected
|
assert response.data["results"] == expected
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from funkwhale_api.common import serializers as common_serializers
|
||||||
from funkwhale_api.federation import api_serializers
|
from funkwhale_api.federation import api_serializers
|
||||||
from funkwhale_api.federation import serializers
|
from funkwhale_api.federation import serializers
|
||||||
|
from funkwhale_api.users import serializers as users_serializers
|
||||||
|
|
||||||
|
|
||||||
def test_library_serializer(factories, to_api_date):
|
def test_library_serializer(factories, to_api_date):
|
||||||
|
@ -111,3 +113,31 @@ def test_serialize_generic_relation(factory_name, factory_kwargs, expected, fact
|
||||||
obj = factories[factory_name](**factory_kwargs)
|
obj = factories[factory_name](**factory_kwargs)
|
||||||
expected["type"] = factory_name
|
expected["type"] = factory_name
|
||||||
assert api_serializers.serialize_generic_relation({}, obj) == expected
|
assert api_serializers.serialize_generic_relation({}, obj) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_full_actor_serializer(factories, to_api_date):
|
||||||
|
summary = factories["common.Content"]()
|
||||||
|
icon = factories["common.Attachment"]()
|
||||||
|
user = factories["users.User"]()
|
||||||
|
actor = user.create_actor(summary_obj=summary, attachment_icon=icon)
|
||||||
|
expected = {
|
||||||
|
"fid": actor.fid,
|
||||||
|
"url": actor.url,
|
||||||
|
"creation_date": to_api_date(actor.creation_date),
|
||||||
|
"last_fetch_date": to_api_date(actor.last_fetch_date),
|
||||||
|
"user": users_serializers.UserBasicSerializer(user).data,
|
||||||
|
"is_channel": False,
|
||||||
|
"domain": actor.domain_id,
|
||||||
|
"type": actor.type,
|
||||||
|
"manually_approves_followers": actor.manually_approves_followers,
|
||||||
|
"full_username": actor.full_username,
|
||||||
|
"name": actor.name,
|
||||||
|
"preferred_username": actor.preferred_username,
|
||||||
|
"is_local": actor.is_local,
|
||||||
|
"summary": common_serializers.ContentSerializer(summary).data,
|
||||||
|
"icon": common_serializers.AttachmentSerializer(icon).data,
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = api_serializers.FullActorSerializer(actor)
|
||||||
|
|
||||||
|
assert serializer.data == expected
|
||||||
|
|
|
@ -197,3 +197,15 @@ def test_user_can_list_domains(factories, api_client, preferences):
|
||||||
"results": [api_serializers.DomainSerializer(allowed).data],
|
"results": [api_serializers.DomainSerializer(allowed).data],
|
||||||
}
|
}
|
||||||
assert response.data == expected
|
assert response.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_retrieve_actor(factories, api_client, preferences):
|
||||||
|
preferences["common__api_authentication_required"] = False
|
||||||
|
actor = factories["federation.Actor"]()
|
||||||
|
url = reverse(
|
||||||
|
"api:v1:federation:actors-detail", kwargs={"full_username": actor.full_username}
|
||||||
|
)
|
||||||
|
response = api_client.get(url)
|
||||||
|
|
||||||
|
expected = api_serializers.FullActorSerializer(actor).data
|
||||||
|
assert response.data == expected
|
||||||
|
|
|
@ -215,6 +215,8 @@ def test_album_serializer(factories, to_api_date):
|
||||||
}
|
}
|
||||||
serializer = serializers.AlbumSerializer(album)
|
serializer = serializers.AlbumSerializer(album)
|
||||||
|
|
||||||
|
for t in expected["tracks"]:
|
||||||
|
t["artist"].pop("cover")
|
||||||
assert serializer.data == expected
|
assert serializer.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1249,6 +1249,13 @@ def test_search_get(use_fts, settings, logged_in_api_client, factories):
|
||||||
"tracks": [serializers.TrackSerializer(track).data],
|
"tracks": [serializers.TrackSerializer(track).data],
|
||||||
"tags": [views.TagSerializer(tag).data],
|
"tags": [views.TagSerializer(tag).data],
|
||||||
}
|
}
|
||||||
|
for album in expected["albums"]:
|
||||||
|
album["artist"].pop("cover")
|
||||||
|
|
||||||
|
for track in expected["tracks"]:
|
||||||
|
track["artist"].pop("cover")
|
||||||
|
track["album"]["artist"].pop("cover")
|
||||||
|
|
||||||
response = logged_in_api_client.get(url, {"q": "foo"})
|
response = logged_in_api_client.get(url, {"q": "foo"})
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
|
@ -157,6 +157,8 @@ def test_can_list_tracks_from_playlist(level, factories, logged_in_api_client):
|
||||||
url = reverse("api:v1:playlists-tracks", kwargs={"pk": plt.playlist.pk})
|
url = reverse("api:v1:playlists-tracks", kwargs={"pk": plt.playlist.pk})
|
||||||
response = logged_in_api_client.get(url)
|
response = logged_in_api_client.get(url)
|
||||||
serialized_plt = serializers.PlaylistTrackSerializer(plt).data
|
serialized_plt = serializers.PlaylistTrackSerializer(plt).data
|
||||||
|
serialized_plt["track"]["artist"].pop("cover")
|
||||||
|
serialized_plt["track"]["album"]["artist"].pop("cover")
|
||||||
|
|
||||||
assert response.data["count"] == 1
|
assert response.data["count"] == 1
|
||||||
assert response.data["results"][0] == serialized_plt
|
assert response.data["results"][0] == serialized_plt
|
||||||
|
|
|
@ -36,6 +36,9 @@ def test_can_validate_config(logged_in_api_client, factories):
|
||||||
"count": candidates.count(),
|
"count": candidates.count(),
|
||||||
"sample": TrackSerializer(candidates, many=True).data,
|
"sample": TrackSerializer(candidates, many=True).data,
|
||||||
}
|
}
|
||||||
|
for s in expected["sample"]:
|
||||||
|
s["artist"].pop("cover")
|
||||||
|
|
||||||
assert payload["filters"][0]["candidates"] == expected
|
assert payload["filters"][0]["candidates"] == expected
|
||||||
assert payload["filters"][0]["errors"] == []
|
assert payload["filters"][0]["errors"] == []
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,6 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.18.0",
|
"axios": "^0.18.0",
|
||||||
"dateformat": "^3.0.3",
|
|
||||||
"diff": "^4.0.1",
|
"diff": "^4.0.1",
|
||||||
"django-channels": "^1.1.6",
|
"django-channels": "^1.1.6",
|
||||||
"fomantic-ui-css": "^2.7",
|
"fomantic-ui-css": "^2.7",
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
<transition name="queue">
|
<transition name="queue">
|
||||||
<queue @touch-progress="$refs.player.setCurrentTime($event)" v-if="$store.state.ui.queueFocused"></queue>
|
<queue @touch-progress="$refs.player.setCurrentTime($event)" v-if="$store.state.ui.queueFocused"></queue>
|
||||||
</transition>
|
</transition>
|
||||||
<router-view :class="{hidden: $store.state.ui.queueFocused}" :key="$route.fullPath"></router-view>
|
<router-view :class="{hidden: $store.state.ui.queueFocused}"></router-view>
|
||||||
<player ref="player"></player>
|
<player ref="player"></player>
|
||||||
<app-footer
|
<app-footer
|
||||||
:class="{hidden: $store.state.ui.queueFocused}"
|
:class="{hidden: $store.state.ui.queueFocused}"
|
||||||
|
@ -241,8 +241,9 @@ export default {
|
||||||
},
|
},
|
||||||
getTrackInformationText(track) {
|
getTrackInformationText(track) {
|
||||||
const trackTitle = track.title
|
const trackTitle = track.title
|
||||||
|
const albumArtist = (track.album) ? track.album.artist.name : null
|
||||||
const artistName = (
|
const artistName = (
|
||||||
(track.artist) ? track.artist.name : track.album.artist.name)
|
(track.artist) ? track.artist.name : albumArtist)
|
||||||
const text = `♫ ${trackTitle} – ${artistName} ♫`
|
const text = `♫ ${trackTitle} – ${artistName} ♫`
|
||||||
return text
|
return text
|
||||||
},
|
},
|
||||||
|
|
|
@ -315,7 +315,7 @@ export default {
|
||||||
title: t.title,
|
title: t.title,
|
||||||
artist: t.artist,
|
artist: t.artist,
|
||||||
album: t.album,
|
album: t.album,
|
||||||
cover: self.getCover(t.album.cover),
|
cover: self.getCover((t.album || {}).cover),
|
||||||
sources: self.getSources(t.uploads)
|
sources: self.getSources(t.uploads)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<div class="ui six wide column current-track">
|
<div class="ui six wide column current-track">
|
||||||
<div class="ui basic segment" id="player">
|
<div class="ui basic segment" id="player">
|
||||||
<template v-if="currentTrack">
|
<template v-if="currentTrack">
|
||||||
<img class="ui image" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.square_crop)">
|
<img class="ui image" v-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.square_crop)">
|
||||||
<img class="ui image" v-else src="../assets/audio/default-cover.png">
|
<img class="ui image" v-else src="../assets/audio/default-cover.png">
|
||||||
<h1 class="ui header">
|
<h1 class="ui header">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
@ -15,9 +15,9 @@
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="sub header">
|
<div class="sub header">
|
||||||
<router-link class="discrete link artist" :title="currentTrack.artist.name" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
|
<router-link class="discrete link artist" :title="currentTrack.artist.name" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
|
||||||
{{ currentTrack.artist.name | truncate(35) }}</router-link> /<router-link class="discrete link album" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
|
{{ currentTrack.artist.name | truncate(35) }}</router-link> <template v-if="currentTrack.album">/<router-link class="discrete link album" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
|
||||||
{{ currentTrack.album.title | truncate(35) }}
|
{{ currentTrack.album.title | truncate(35) }}
|
||||||
</router-link>
|
</router-link></template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</h1>
|
</h1>
|
||||||
|
@ -167,7 +167,7 @@
|
||||||
<i class="grip lines grey icon"></i>
|
<i class="grip lines grey icon"></i>
|
||||||
</td>
|
</td>
|
||||||
<td class="image-cell" @click="$store.dispatch('queue/currentIndex', index)">
|
<td class="image-cell" @click="$store.dispatch('queue/currentIndex', index)">
|
||||||
<img class="ui mini image" v-if="track.album.cover && track.album.cover.original" :src="$store.getters['instance/absoluteUrl'](track.album.cover.square_crop)">
|
<img class="ui mini image" v-if="currentTrack.album && track.album.cover && track.album.cover.original" :src="$store.getters['instance/absoluteUrl'](track.album.cover.square_crop)">
|
||||||
<img class="ui mini image" v-else src="../assets/audio/default-cover.png">
|
<img class="ui mini image" v-else src="../assets/audio/default-cover.png">
|
||||||
</td>
|
</td>
|
||||||
<td colspan="3" @click="$store.dispatch('queue/currentIndex', index)">
|
<td colspan="3" @click="$store.dispatch('queue/currentIndex', index)">
|
||||||
|
|
|
@ -74,10 +74,10 @@
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="ui user-dropdown dropdown" >
|
<div class="ui user-dropdown dropdown" >
|
||||||
<img class="ui avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
|
<img class="ui avatar image" v-if="$store.state.auth.profile.avatar && $store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
|
||||||
<actor-avatar v-else :actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username}" />
|
<actor-avatar v-else :actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username}" />
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
<router-link class="item" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><translate translate-context="*/*/*/Noun">Profile</translate></router-link>
|
<router-link class="item" :to="{name: 'profile.overview', params: {username: $store.state.auth.username}}"><translate translate-context="*/*/*/Noun">Profile</translate></router-link>
|
||||||
<router-link class="item" :to="{path: '/settings'}"></i><translate translate-context="*/*/*/Noun">Settings</translate></router-link>
|
<router-link class="item" :to="{path: '/settings'}"></i><translate translate-context="*/*/*/Noun">Settings</translate></router-link>
|
||||||
<router-link class="item" :to="{name: 'logout'}"></i><translate translate-context="Sidebar/Login/List item.Link/Verb">Logout</translate></router-link>
|
<router-link class="item" :to="{name: 'logout'}"></i><translate translate-context="Sidebar/Login/List item.Link/Verb">Logout</translate></router-link>
|
||||||
</div>
|
</div>
|
||||||
|
@ -155,7 +155,6 @@ import { mapState, mapActions, mapGetters } from "vuex"
|
||||||
|
|
||||||
import Logo from "@/components/Logo"
|
import Logo from "@/components/Logo"
|
||||||
import SearchBar from "@/components/audio/SearchBar"
|
import SearchBar from "@/components/audio/SearchBar"
|
||||||
import backend from "@/audio/backend"
|
|
||||||
|
|
||||||
import $ from "jquery"
|
import $ from "jquery"
|
||||||
|
|
||||||
|
@ -168,7 +167,6 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selectedTab: "library",
|
selectedTab: "library",
|
||||||
backend: backend,
|
|
||||||
isCollapsed: true,
|
isCollapsed: true,
|
||||||
fetchInterval: null,
|
fetchInterval: null,
|
||||||
exploreExpanded: false,
|
exploreExpanded: false,
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
<template>
|
||||||
|
<div class="card app-card">
|
||||||
|
<div
|
||||||
|
@click="$router.push({name: 'channels.detail', params: {id: object.uuid}})"
|
||||||
|
:class="['ui', 'head-image', 'padded image', {'default-cover': !object.artist.cover}]" v-lazy:background-image="imageUrl">
|
||||||
|
<play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :artist="object.artist"></play-button>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<strong>
|
||||||
|
<router-link class="discrete link" :title="object.artist.name" :to="{name: 'channels.detail', params: {id: object.uuid}}">
|
||||||
|
{{ object.artist.name }}
|
||||||
|
</router-link>
|
||||||
|
</strong>
|
||||||
|
<div class="description">
|
||||||
|
<tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="object.artist.tags"></tags-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="extra content">
|
||||||
|
<translate translate-context="Content/Channel/Paragraph"
|
||||||
|
translate-plural="%{ count } episodes"
|
||||||
|
:translate-n="object.artist.tracks_count"
|
||||||
|
:translate-params="{count: object.artist.tracks_count}">
|
||||||
|
%{ count } episode
|
||||||
|
</translate>
|
||||||
|
<play-button class="right floated basic icon" :dropdown-only="true" :is-playable="true" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :artist="object.artist"></play-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import PlayButton from '@/components/audio/PlayButton'
|
||||||
|
import TagsList from "@/components/tags/List"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
object: {type: Object},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
PlayButton,
|
||||||
|
TagsList
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
imageUrl () {
|
||||||
|
let url = '../../assets/audio/default-cover.png'
|
||||||
|
|
||||||
|
if (this.object.artist.cover) {
|
||||||
|
url = this.$store.getters['instance/absoluteUrl'](this.object.artist.cover.medium_square_crop)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,74 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<slot></slot>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||||
|
<div class="ui loader"></div>
|
||||||
|
</div>
|
||||||
|
<channel-entry-card v-for="entry in objects" :entry="entry" :key="entry.id" />
|
||||||
|
<template v-if="nextPage">
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
|
||||||
|
<translate translate-context="*/*/Button,Label">Show more</translate>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-if="!isLoading && objects.length === 0">
|
||||||
|
<div class="ui placeholder segment">
|
||||||
|
<div class="ui icon header">
|
||||||
|
<i class="compact disc icon"></i>
|
||||||
|
No results matching your query
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import _ from '@/lodash'
|
||||||
|
import axios from 'axios'
|
||||||
|
import ChannelEntryCard from '@/components/audio/ChannelEntryCard'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
filters: {type: Object, required: true},
|
||||||
|
limit: {type: Number, default: 5},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
ChannelEntryCard
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
objects: [],
|
||||||
|
count: 0,
|
||||||
|
isLoading: false,
|
||||||
|
errors: null,
|
||||||
|
nextPage: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.fetchData('tracks/')
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchData (url) {
|
||||||
|
if (!url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.isLoading = true
|
||||||
|
let self = this
|
||||||
|
let params = _.clone(this.filters)
|
||||||
|
params.page_size = this.limit
|
||||||
|
params.include_channels = true
|
||||||
|
axios.get(url, {params: params}).then((response) => {
|
||||||
|
self.nextPage = response.data.next
|
||||||
|
self.isLoading = false
|
||||||
|
self.objects = self.objects.concat(response.data.results)
|
||||||
|
self.count = response.data.count
|
||||||
|
self.$emit('fetched', response.data)
|
||||||
|
}, error => {
|
||||||
|
self.isLoading = false
|
||||||
|
self.errors = error.backendErrors
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,63 @@
|
||||||
|
<template>
|
||||||
|
<div class="channel-entry-card">
|
||||||
|
<img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
|
||||||
|
<img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-else src="../../assets/audio/default-cover.png">
|
||||||
|
<div class="content">
|
||||||
|
<strong>
|
||||||
|
<router-link class="discrete ellipsis link" :title="entry.title" :to="{name: 'library.tracks.detail', params: {id: entry.id}}">
|
||||||
|
{{ entry.title|truncate(30) }}
|
||||||
|
</router-link>
|
||||||
|
</strong>
|
||||||
|
<div class="description">
|
||||||
|
<human-date :date="entry.creation_date"></human-date><template v-if="duration"> ·
|
||||||
|
<human-duration :duration="duration"></human-duration></template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'orange', 'icon', 'button']" :track="entry"></play-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import PlayButton from '@/components/audio/PlayButton'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['entry'],
|
||||||
|
components: {
|
||||||
|
PlayButton,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
imageUrl () {
|
||||||
|
let url = '../../assets/audio/default-cover.png'
|
||||||
|
let cover = this.cover
|
||||||
|
if (cover && cover.original) {
|
||||||
|
url = this.$store.getters['instance/absoluteUrl'](cover.medium_square_crop)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
},
|
||||||
|
cover () {
|
||||||
|
if (this.entry.album && this.entry.album.cover) {
|
||||||
|
return this.entry.album.cover
|
||||||
|
}
|
||||||
|
},
|
||||||
|
duration () {
|
||||||
|
let uploads = this.entry.uploads.filter((e) => {
|
||||||
|
return e.duration
|
||||||
|
})
|
||||||
|
if (uploads.length > 0) {
|
||||||
|
return uploads[0].duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
.default-cover {
|
||||||
|
background-image: url("../../assets/audio/default-cover.png") !important;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,69 @@
|
||||||
|
<template>
|
||||||
|
<div class="channel-serie-card">
|
||||||
|
<div class="two-images">
|
||||||
|
<img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
|
||||||
|
<img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
|
||||||
|
<img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
|
||||||
|
<img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<strong>
|
||||||
|
<router-link class="discrete ellipsis link" :title="serie.title" :to="{name: 'library.albums.detail', params: {id: serie.id}}">
|
||||||
|
{{ serie.title|truncate(30) }}
|
||||||
|
</router-link>
|
||||||
|
</strong>
|
||||||
|
<div class="description">
|
||||||
|
<translate translate-context="Content/Channel/Paragraph"
|
||||||
|
translate-plural="%{ count } episodes"
|
||||||
|
:translate-n="serie.tracks.length"
|
||||||
|
:translate-params="{count: serie.tracks.length}">
|
||||||
|
%{ count } episode
|
||||||
|
</translate>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'orange', 'icon', 'button']" :album="serie"></play-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import PlayButton from '@/components/audio/PlayButton'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['serie'],
|
||||||
|
components: {
|
||||||
|
PlayButton,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
imageUrl () {
|
||||||
|
let url = '../../assets/audio/default-cover.png'
|
||||||
|
let cover = this.cover
|
||||||
|
if (cover && cover.original) {
|
||||||
|
url = this.$store.getters['instance/absoluteUrl'](cover.medium_square_crop)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
},
|
||||||
|
cover () {
|
||||||
|
if (this.serie.cover) {
|
||||||
|
return this.serie.cover
|
||||||
|
}
|
||||||
|
},
|
||||||
|
duration () {
|
||||||
|
let uploads = this.serie.uploads.filter((e) => {
|
||||||
|
return e.duration
|
||||||
|
})
|
||||||
|
return uploads[0].duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
.default-cover {
|
||||||
|
background-image: url("../../assets/audio/default-cover.png") !important;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,73 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<slot></slot>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||||
|
<div class="ui loader"></div>
|
||||||
|
</div>
|
||||||
|
<channel-serie-card v-for="serie in objects" :serie="serie" :key="serie.id" />
|
||||||
|
<template v-if="nextPage">
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
|
||||||
|
<translate translate-context="*/*/Button,Label">Show more</translate>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-if="!isLoading && objects.length === 0">
|
||||||
|
<div class="ui placeholder segment">
|
||||||
|
<div class="ui icon header">
|
||||||
|
<i class="compact disc icon"></i>
|
||||||
|
No results matching your query
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import _ from '@/lodash'
|
||||||
|
import axios from 'axios'
|
||||||
|
import ChannelSerieCard from '@/components/audio/ChannelSerieCard'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
filters: {type: Object, required: true},
|
||||||
|
limit: {type: Number, default: 5},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
ChannelSerieCard
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
objects: [],
|
||||||
|
count: 0,
|
||||||
|
isLoading: false,
|
||||||
|
errors: null,
|
||||||
|
nextPage: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.fetchData('albums/')
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchData (url) {
|
||||||
|
if (!url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.isLoading = true
|
||||||
|
let self = this
|
||||||
|
let params = _.clone(this.filters)
|
||||||
|
params.page_size = this.limit
|
||||||
|
params.include_channels = true
|
||||||
|
axios.get(url, {params: params}).then((response) => {
|
||||||
|
self.nextPage = response.data.next
|
||||||
|
self.isLoading = false
|
||||||
|
self.objects = self.objects.concat(response.data.results)
|
||||||
|
self.count = response.data.count
|
||||||
|
}, error => {
|
||||||
|
self.isLoading = false
|
||||||
|
self.errors = error.backendErrors
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,76 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<slot></slot>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<div class="ui app-cards cards">
|
||||||
|
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||||
|
<div class="ui loader"></div>
|
||||||
|
</div>
|
||||||
|
<channel-card v-for="object in objects" :object="object" :key="object.uuid" />
|
||||||
|
</div>
|
||||||
|
<template v-if="nextPage">
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
|
||||||
|
<translate translate-context="*/*/Button,Label">Show more</translate>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-if="!isLoading && objects.length === 0">
|
||||||
|
<div class="ui placeholder segment">
|
||||||
|
<div class="ui icon header">
|
||||||
|
<i class="compact disc icon"></i>
|
||||||
|
No results matching your query
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import _ from '@/lodash'
|
||||||
|
import axios from 'axios'
|
||||||
|
import ChannelCard from '@/components/audio/ChannelCard'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
filters: {type: Object, required: true},
|
||||||
|
limit: {type: Number, default: 5},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
ChannelCard
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
objects: [],
|
||||||
|
count: 0,
|
||||||
|
isLoading: false,
|
||||||
|
errors: null,
|
||||||
|
nextPage: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.fetchData('channels/')
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchData (url) {
|
||||||
|
if (!url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.isLoading = true
|
||||||
|
let self = this
|
||||||
|
let params = _.clone(this.filters)
|
||||||
|
params.page_size = this.limit
|
||||||
|
params.include_channels = true
|
||||||
|
axios.get(url, {params: params}).then((response) => {
|
||||||
|
self.nextPage = response.data.next
|
||||||
|
self.isLoading = false
|
||||||
|
self.objects = self.objects.concat(response.data.results)
|
||||||
|
self.count = response.data.count
|
||||||
|
self.$emit('fetched', response.data)
|
||||||
|
}, error => {
|
||||||
|
self.isLoading = false
|
||||||
|
self.errors = error.backendErrors
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
<div class="controls track-controls queue-not-focused desktop-and-up">
|
<div class="controls track-controls queue-not-focused desktop-and-up">
|
||||||
<div @click.stop.prevent="" class="ui tiny image" @click.stop.prevent="$router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})">
|
<div @click.stop.prevent="" class="ui tiny image" @click.stop.prevent="$router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})">
|
||||||
<img ref="cover" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
|
<img ref="cover" v-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
|
||||||
<img v-else src="../../assets/audio/default-cover.png">
|
<img v-else src="../../assets/audio/default-cover.png">
|
||||||
</div>
|
</div>
|
||||||
<div @click.stop.prevent="" class="middle aligned content ellipsis">
|
<div @click.stop.prevent="" class="middle aligned content ellipsis">
|
||||||
|
@ -21,15 +21,15 @@
|
||||||
</strong>
|
</strong>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.artist.name" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
|
<router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.artist.name" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
|
||||||
{{ currentTrack.artist.name }}</router-link> /<router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
|
{{ currentTrack.artist.name }}</router-link><template v-if="currentTrack.album"> /<router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
|
||||||
{{ currentTrack.album.title }}
|
{{ currentTrack.album.title }}
|
||||||
</router-link>
|
</router-link></template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls track-controls queue-not-focused tablet-and-below">
|
<div class="controls track-controls queue-not-focused tablet-and-below">
|
||||||
<div class="ui tiny image">
|
<div class="ui tiny image">
|
||||||
<img ref="cover" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
|
<img ref="cover" v-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
|
||||||
<img v-else src="../../assets/audio/default-cover.png">
|
<img v-else src="../../assets/audio/default-cover.png">
|
||||||
</div>
|
</div>
|
||||||
<div class="middle aligned content ellipsis">
|
<div class="middle aligned content ellipsis">
|
||||||
|
@ -37,7 +37,7 @@
|
||||||
{{ currentTrack.title }}
|
{{ currentTrack.title }}
|
||||||
</strong>
|
</strong>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
{{ currentTrack.artist.name }} / {{ currentTrack.album.title }}
|
{{ currentTrack.artist.name }}<template v-if="currentTrack.album"> / {{ currentTrack.album.title }}</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -703,19 +703,22 @@ export default {
|
||||||
// If the session is playing as a PWA, populate the notification
|
// If the session is playing as a PWA, populate the notification
|
||||||
// with details from the track
|
// with details from the track
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
let metatata = {
|
||||||
title: this.currentTrack.title,
|
title: this.currentTrack.title,
|
||||||
artist: this.currentTrack.artist.name,
|
artist: this.currentTrack.artist.name,
|
||||||
album: this.currentTrack.album.title,
|
}
|
||||||
artwork: [
|
if (this.currentTrack.album) {
|
||||||
{ src: this.currentTrack.album.cover.original, sizes: '96x96', type: 'image/png' },
|
metadata.album = this.currentTrack.album.title
|
||||||
{ src: this.currentTrack.album.cover.original, sizes: '128x128', type: 'image/png' },
|
metadata.artwork = [
|
||||||
{ src: this.currentTrack.album.cover.original, sizes: '192x192', type: 'image/png' },
|
{ src: this.currentTrack.album.cover.original, sizes: '96x96', type: 'image/png' },
|
||||||
{ src: this.currentTrack.album.cover.original, sizes: '256x256', type: 'image/png' },
|
{ src: this.currentTrack.album.cover.original, sizes: '128x128', type: 'image/png' },
|
||||||
{ src: this.currentTrack.album.cover.original, sizes: '384x384', type: 'image/png' },
|
{ src: this.currentTrack.album.cover.original, sizes: '192x192', type: 'image/png' },
|
||||||
{ src: this.currentTrack.album.cover.original, sizes: '512x512', type: 'image/png' },
|
{ src: this.currentTrack.album.cover.original, sizes: '256x256', type: 'image/png' },
|
||||||
|
{ src: this.currentTrack.album.cover.original, sizes: '384x384', type: 'image/png' },
|
||||||
|
{ src: this.currentTrack.album.cover.original, sizes: '512x512', type: 'image/png' },
|
||||||
]
|
]
|
||||||
});
|
}
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata(metadata);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
immediate: false
|
immediate: false
|
||||||
|
|
|
@ -109,7 +109,11 @@ export default {
|
||||||
return r.title
|
return r.title
|
||||||
},
|
},
|
||||||
getDescription (r) {
|
getDescription (r) {
|
||||||
return `${r.album.artist.name} - ${r.album.title}`
|
if (r.album) {
|
||||||
|
return `${r.album.artist.name} - ${r.album.title}`
|
||||||
|
} else {
|
||||||
|
return r.artist.name
|
||||||
|
}
|
||||||
},
|
},
|
||||||
getId (t) {
|
getId (t) {
|
||||||
return t.id
|
return t.id
|
||||||
|
|
|
@ -5,9 +5,6 @@
|
||||||
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
|
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
<button v-if="controls" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button>
|
|
||||||
<button v-if="controls" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button>
|
|
||||||
<button v-if="controls" @click="fetchData('albums/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
|
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<div class="ui app-cards cards">
|
<div class="ui app-cards cards">
|
||||||
<div v-if="isLoading" class="ui inverted active dimmer">
|
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||||
|
@ -23,6 +20,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="nextPage">
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
|
||||||
|
<translate translate-context="*/*/Button,Label">Show more</translate>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -68,7 +71,7 @@ export default {
|
||||||
self.previousPage = response.data.previous
|
self.previousPage = response.data.previous
|
||||||
self.nextPage = response.data.next
|
self.nextPage = response.data.next
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.albums = response.data.results
|
self.albums = [...self.albums, ...response.data.results]
|
||||||
self.count = response.data.count
|
self.count = response.data.count
|
||||||
}, error => {
|
}, error => {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
|
|
|
@ -22,7 +22,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import backend from '@/audio/backend'
|
|
||||||
import PlayButton from '@/components/audio/PlayButton'
|
import PlayButton from '@/components/audio/PlayButton'
|
||||||
import TagsList from "@/components/tags/List"
|
import TagsList from "@/components/tags/List"
|
||||||
|
|
||||||
|
@ -34,7 +33,6 @@ export default {
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
backend: backend,
|
|
||||||
initialAlbums: 30,
|
initialAlbums: 30,
|
||||||
showAllAlbums: true,
|
showAllAlbums: true,
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,9 +4,6 @@
|
||||||
<slot name="title"></slot>
|
<slot name="title"></slot>
|
||||||
<span class="ui tiny circular label">{{ count }}</span>
|
<span class="ui tiny circular label">{{ count }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<button v-if="controls" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button>
|
|
||||||
<button v-if="controls" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button>
|
|
||||||
<button v-if="controls" @click="fetchData('artists/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
|
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<div class="ui five app-cards cards">
|
<div class="ui five app-cards cards">
|
||||||
<div v-if="isLoading" class="ui inverted active dimmer">
|
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||||
|
@ -15,6 +12,12 @@
|
||||||
<artist-card :artist="artist" v-for="artist in objects" :key="artist.id"></artist-card>
|
<artist-card :artist="artist" v-for="artist in objects" :key="artist.id"></artist-card>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isLoading && objects.length === 0">No results matching your query.</div>
|
<div v-if="!isLoading && objects.length === 0">No results matching your query.</div>
|
||||||
|
<template v-if="nextPage">
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
|
||||||
|
<translate translate-context="*/*/Button,Label">Show more</translate>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -60,7 +63,7 @@ export default {
|
||||||
self.previousPage = response.data.previous
|
self.previousPage = response.data.previous
|
||||||
self.nextPage = response.data.next
|
self.nextPage = response.data.next
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.objects = response.data.results
|
self.objects = [...self.objects, ...response.data.results]
|
||||||
self.count = response.data.count
|
self.count = response.data.count
|
||||||
}, error => {
|
}, error => {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<play-button :class="['basic', {orange: currentTrack && isPlaying && track.id === currentTrack.id}, 'icon']" :discrete="true" :is-playable="playable" :track="track"></play-button>
|
<play-button :class="['basic', {orange: currentTrack && isPlaying && track.id === currentTrack.id}, 'icon']" :discrete="true" :is-playable="playable" :track="track"></play-button>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<img class="ui mini image" v-if="track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.small_square_crop)">
|
<img class="ui mini image" v-if="track.album && track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.small_square_crop)">
|
||||||
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
|
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
|
||||||
</td>
|
</td>
|
||||||
<td colspan="6">
|
<td colspan="6">
|
||||||
|
@ -30,7 +30,7 @@
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<td colspan="4">
|
<td colspan="4">
|
||||||
<router-link class="album discrete link" :title="track.album.title" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
|
<router-link v-if="track.album" class="album discrete link" :title="track.album.title" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
|
||||||
{{ track.album.title }}
|
{{ track.album.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -29,7 +29,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import backend from '@/audio/backend'
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import TrackRow from '@/components/audio/track/Row'
|
import TrackRow from '@/components/audio/track/Row'
|
||||||
|
@ -49,7 +48,6 @@ export default {
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
backend: backend,
|
|
||||||
loadMoreUrl: this.nextUrl,
|
loadMoreUrl: this.nextUrl,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
additionalTracks: []
|
additionalTracks: []
|
||||||
|
|
|
@ -4,13 +4,10 @@
|
||||||
<slot name="title"></slot>
|
<slot name="title"></slot>
|
||||||
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
|
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<button :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle up', 'icon']"></i></button>
|
|
||||||
<button :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle down', 'icon']"></i></button>
|
|
||||||
<button @click="fetchData(url)" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
|
|
||||||
<div v-if="count > 0" class="ui divided unstackable items">
|
<div v-if="count > 0" class="ui divided unstackable items">
|
||||||
<div :class="['item', itemClasses]" v-for="object in objects" :key="object.id">
|
<div :class="['item', itemClasses]" v-for="object in objects" :key="object.id">
|
||||||
<div class="ui tiny image">
|
<div class="ui tiny image">
|
||||||
<img v-if="object.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.medium_square_crop)">
|
<img v-if="object.track.album && object.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.medium_square_crop)">
|
||||||
<img v-else src="../../../assets/audio/default-cover.png">
|
<img v-else src="../../../assets/audio/default-cover.png">
|
||||||
<play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'tiny', 'orange', 'icon', 'button']" :track="object.track"></play-button>
|
<play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'tiny', 'orange', 'icon', 'button']" :track="object.track"></play-button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -62,6 +59,12 @@
|
||||||
<div class="ui loader"></div>
|
<div class="ui loader"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="nextPage">
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
|
||||||
|
<translate translate-context="*/*/Button,Label">Show more</translate>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -112,14 +115,16 @@ export default {
|
||||||
self.nextPage = response.data.next
|
self.nextPage = response.data.next
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.count = response.data.count
|
self.count = response.data.count
|
||||||
|
let newObjects
|
||||||
if (self.isActivity) {
|
if (self.isActivity) {
|
||||||
// we have listening/favorites objects, not directly tracks
|
// we have listening/favorites objects, not directly tracks
|
||||||
self.objects = response.data.results
|
newObjects = response.data.results
|
||||||
} else {
|
} else {
|
||||||
self.objects = response.data.results.map((r) => {
|
newObjects = response.data.results.map((r) => {
|
||||||
return {track: r}
|
return {track: r}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
self.objects = [...self.objects, ...newObjects]
|
||||||
}, error => {
|
}, error => {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.errors = error.backendErrors
|
self.errors = error.backendErrors
|
||||||
|
|
|
@ -1,70 +0,0 @@
|
||||||
<template>
|
|
||||||
<main class="main pusher" v-title="labels.usernameProfile">
|
|
||||||
<div v-if="isLoading" class="ui vertical segment">
|
|
||||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
|
||||||
</div>
|
|
||||||
<template v-if="profile">
|
|
||||||
<div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']">
|
|
||||||
<h2 class="ui center aligned icon header">
|
|
||||||
<i v-if="!profile.avatar.square_crop" class="circular inverted user green icon"></i>
|
|
||||||
<img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](profile.avatar.square_crop)" />
|
|
||||||
<div class="content">
|
|
||||||
{{ profile.username }}
|
|
||||||
<div class="sub header" v-translate="{date: signupDate}" translate-context="Content/Profile/Paragraph">Member since %{ date }</div>
|
|
||||||
</div>
|
|
||||||
</h2>
|
|
||||||
<div class="ui basic green label">
|
|
||||||
<translate translate-context="Content/Profile/Button.Paragraph">This is you!</translate>
|
|
||||||
</div>
|
|
||||||
<a v-if="profile.is_staff"
|
|
||||||
class="ui yellow label"
|
|
||||||
:href="$store.getters['instance/absoluteUrl']('/api/admin')"
|
|
||||||
target="_blank">
|
|
||||||
<i class="star icon"></i>
|
|
||||||
<translate translate-context="Content/Profile/User role">Staff member</translate>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapState } from "vuex"
|
|
||||||
|
|
||||||
const dateFormat = require("dateformat")
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: ["username"],
|
|
||||||
created() {
|
|
||||||
this.$store.dispatch("auth/fetchProfile")
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapState({
|
|
||||||
profile: state => state.auth.profile
|
|
||||||
}),
|
|
||||||
labels() {
|
|
||||||
let msg = this.$pgettext('Head/Profile/Title', "%{ username }'s profile")
|
|
||||||
let usernameProfile = this.$gettextInterpolate(msg, {
|
|
||||||
username: this.username
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
usernameProfile
|
|
||||||
}
|
|
||||||
},
|
|
||||||
signupDate() {
|
|
||||||
let d = new Date(this.profile.date_joined)
|
|
||||||
return dateFormat(d, "longDate")
|
|
||||||
},
|
|
||||||
isLoading() {
|
|
||||||
return !this.profile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
|
||||||
<style scoped>
|
|
||||||
.ui.header > img.image {
|
|
||||||
width: 8em;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -23,6 +23,7 @@
|
||||||
<select v-if="f.type === 'dropdown'" class="ui dropdown" v-model="f.value">
|
<select v-if="f.type === 'dropdown'" class="ui dropdown" v-model="f.value">
|
||||||
<option :value="c" v-for="c in f.choices">{{ sharedLabels.fields[f.id].choices[c] }}</option>
|
<option :value="c" v-for="c in f.choices">{{ sharedLabels.fields[f.id].choices[c] }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
<content-form v-if="f.type === 'content'" v-model="f.value.text"></content-form>
|
||||||
</div>
|
</div>
|
||||||
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit">
|
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit">
|
||||||
<translate translate-context="Content/Settings/Button.Label/Verb">Update settings</translate>
|
<translate translate-context="Content/Settings/Button.Label/Verb">Update settings</translate>
|
||||||
|
@ -331,8 +332,12 @@ export default {
|
||||||
settings: {
|
settings: {
|
||||||
success: false,
|
success: false,
|
||||||
errors: [],
|
errors: [],
|
||||||
order: ["privacy_level"],
|
order: ["summary", "privacy_level"],
|
||||||
fields: {
|
fields: {
|
||||||
|
summary: {
|
||||||
|
type: "content",
|
||||||
|
initial: this.$store.state.auth.profile.summary || {text: '', content_type: 'text/markdown'},
|
||||||
|
},
|
||||||
privacy_level: {
|
privacy_level: {
|
||||||
type: "dropdown",
|
type: "dropdown",
|
||||||
initial: this.$store.state.auth.profile.privacy_level,
|
initial: this.$store.state.auth.profile.privacy_level,
|
||||||
|
@ -459,7 +464,7 @@ export default {
|
||||||
response => {
|
response => {
|
||||||
logger.default.info("Password successfully changed")
|
logger.default.info("Password successfully changed")
|
||||||
self.$router.push({
|
self.$router.push({
|
||||||
name: "profile",
|
name: "profile.overview",
|
||||||
params: {
|
params: {
|
||||||
username: self.$store.state.auth.username
|
username: self.$store.state.auth.username
|
||||||
}
|
}
|
||||||
|
@ -519,6 +524,9 @@ export default {
|
||||||
this.settings.order.forEach(setting => {
|
this.settings.order.forEach(setting => {
|
||||||
let conf = self.settings.fields[setting]
|
let conf = self.settings.fields[setting]
|
||||||
s[setting] = conf.value
|
s[setting] = conf.value
|
||||||
|
if (setting === 'summary' && !conf.value.text) {
|
||||||
|
s[setting] = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
|
@ -117,7 +117,7 @@ export default {
|
||||||
response => {
|
response => {
|
||||||
logger.default.info("Successfully created account")
|
logger.default.info("Successfully created account")
|
||||||
self.$router.push({
|
self.$router.push({
|
||||||
name: "profile",
|
name: "profile.overview",
|
||||||
params: {
|
params: {
|
||||||
username: this.username
|
username: this.username
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
<template>
|
||||||
|
<button @click.stop="toggle" :class="['ui', 'pink', {'inverted': isSubscribed}, {'favorited': isSubscribed}, 'icon', 'labeled', 'button']">
|
||||||
|
<i class="heart icon"></i>
|
||||||
|
<translate v-if="isSubscribed" translate-context="Content/Track/Button.Message">Unsubscribe</translate>
|
||||||
|
<translate v-else translate-context="Content/Track/*/Verb">Subscribe</translate>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
channel: {type: Object},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
title () {
|
||||||
|
if (this.isSubscribed) {
|
||||||
|
return this.$pgettext('Content/Channel/Button/Verb', 'Subscribe')
|
||||||
|
} else {
|
||||||
|
return this.$pgettext('Content/Channel/Button/Verb', 'Unubscribe')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isSubscribed () {
|
||||||
|
return this.$store.getters['channels/isSubscribed'](this.channel.uuid)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggle () {
|
||||||
|
if (this.isSubscribed) {
|
||||||
|
this.$emit('unsubscribed')
|
||||||
|
} else {
|
||||||
|
this.$emit('subscribed')
|
||||||
|
}
|
||||||
|
this.$store.dispatch('channels/toggle', this.channel.uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
|
@ -1,12 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: actor.full_username}}" v-if="admin" :title="actor.full_username">
|
<router-link :to="url" :title="actor.full_username">
|
||||||
<actor-avatar v-if="avatar" :actor="actor" />
|
<template v-if="avatar"><actor-avatar :actor="actor" /> </template>{{ repr | truncate(30) }}
|
||||||
{{ actor.full_username | truncate(30) }}
|
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-else :title="actor.full_username">
|
|
||||||
<actor-avatar v-if="avatar" :actor="actor" />
|
|
||||||
{{ actor.full_username | truncate(30) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -17,6 +12,23 @@ export default {
|
||||||
actor: {type: Object},
|
actor: {type: Object},
|
||||||
avatar: {type: Boolean, default: true},
|
avatar: {type: Boolean, default: true},
|
||||||
admin: {type: Boolean, default: false},
|
admin: {type: Boolean, default: false},
|
||||||
|
displayName: {type: Boolean, default: false},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
url () {
|
||||||
|
if (this.actor.is_local) {
|
||||||
|
return {name: 'profile.overview', params: {username: this.actor.preferred_username}}
|
||||||
|
} else {
|
||||||
|
return {name: 'profile.overview', params: {username: this.actor.full_username}}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
repr () {
|
||||||
|
if (this.displayName) {
|
||||||
|
return this.actor.preferred_username
|
||||||
|
} else {
|
||||||
|
return this.actor.full_username
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -0,0 +1,118 @@
|
||||||
|
<template>
|
||||||
|
<div class="content-form ui segments">
|
||||||
|
<div class="ui segment">
|
||||||
|
<div class="ui tiny secondary pointing menu">
|
||||||
|
<button @click.prevent="isPreviewing = false" :class="[{active: !isPreviewing}, 'item']">
|
||||||
|
<translate translate-context="*/Form/Menu.item">Write</translate>
|
||||||
|
</button>
|
||||||
|
<button @click.prevent="isPreviewing = true" :class="[{active: isPreviewing}, 'item']">
|
||||||
|
<translate translate-context="*/Form/Menu.item">Preview</translate>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="isPreviewing" >
|
||||||
|
|
||||||
|
<div class="ui placeholder" v-if="isLoadingPreview">
|
||||||
|
<div class="paragraph">
|
||||||
|
<div class="line"></div>
|
||||||
|
<div class="line"></div>
|
||||||
|
<div class="line"></div>
|
||||||
|
<div class="line"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="preview === null">
|
||||||
|
<translate translate-context="*/Form/Paragraph">Nothing to preview.</translate>
|
||||||
|
</p>
|
||||||
|
<div v-html="preview" v-else></div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="ui transparent input">
|
||||||
|
<textarea ref="textarea" :name="fieldId" :id="fieldId" rows="5" v-model="newValue" :placeholder="labels.placeholder"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="ui very small hidden divider"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="ui bottom attached segment">
|
||||||
|
<span :class="['right', 'floated', {'ui red text': remainingChars < 0}]">
|
||||||
|
{{ remainingChars }}
|
||||||
|
</span>
|
||||||
|
<p>
|
||||||
|
<translate translate-context="*/Form/Paragraph">Markdown syntax is supported.</translate>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: {type: String, default: ""},
|
||||||
|
fieldId: {type: String, default: "change-content"},
|
||||||
|
autofocus: {type: Boolean, default: false},
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
isPreviewing: false,
|
||||||
|
preview: null,
|
||||||
|
newValue: this.value,
|
||||||
|
isLoadingPreview: false,
|
||||||
|
charLimit: 5000,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
if (this.autofocus) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$refs.textarea.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadPreview () {
|
||||||
|
this.isLoadingPreview = true
|
||||||
|
try {
|
||||||
|
let response = await axios.post('text-preview/', {text: this.value})
|
||||||
|
this.preview = response.data.rendered
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
}
|
||||||
|
this.isLoadingPreview = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
labels () {
|
||||||
|
return {
|
||||||
|
placeholder: this.$pgettext("*/Form/Placeholder", "Write a few words here…")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remainingChars () {
|
||||||
|
return this.charLimit - this.value.length
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
newValue (v) {
|
||||||
|
this.$emit('input', v)
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
async handler (v) {
|
||||||
|
this.preview = null
|
||||||
|
this.newValue = v
|
||||||
|
if (this.isPreviewing) {
|
||||||
|
await this.loadPreview()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
async isPreviewing (v) {
|
||||||
|
if (v && !!this.value && this.preview === null) {
|
||||||
|
await this.loadPreview()
|
||||||
|
}
|
||||||
|
if (!v) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$refs.textarea.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,20 @@
|
||||||
|
<template>
|
||||||
|
<time :datetime="`${duration}s`">
|
||||||
|
<template v-if="durationObj.hours">{{ durationObj.hours|padDuration }}</template>{{ durationObj.minutes|padDuration }}:{{ durationObj.seconds|padDuration }}
|
||||||
|
</time>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import {secondsToObject} from '@/filters'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
duration: {required: true},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
durationObj () {
|
||||||
|
return secondsToObject(this.duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,77 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-html="content.html" v-if="content && !isUpdating"></div>
|
||||||
|
<p v-else-if="!isUpdating">
|
||||||
|
<translate translate-context="*/*/Placeholder">No description available</translate>
|
||||||
|
</p>
|
||||||
|
<template v-if="!isUpdating && canUpdate && updateUrl">
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<span role="button" @click="isUpdating = true">
|
||||||
|
<i class="pencil icon"></i>
|
||||||
|
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<form v-if="isUpdating" class="ui form" @submit.prevent="submit()">
|
||||||
|
<div v-if="errors.length > 0" class="ui negative message">
|
||||||
|
<div class="header"><translate translate-context="Content/Channels/Error message.Title">Error while updating description</translate></div>
|
||||||
|
<ul class="list">
|
||||||
|
<li v-for="error in errors">{{ error }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<content-form v-model="newText" :autofocus="true"></content-form>
|
||||||
|
<a @click.prevent="isUpdating = false" class="left floated">
|
||||||
|
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||||
|
</a>
|
||||||
|
<button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']" type="submit" :disabled="isLoading">
|
||||||
|
<translate translate-context="Content/Channels/Button.Label/Verb">Update description</translate>
|
||||||
|
</button>
|
||||||
|
<div class="ui clearing hidden divider"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {secondsToObject} from '@/filters'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
content: {required: true},
|
||||||
|
fieldName: {required: false, default: 'description'},
|
||||||
|
updateUrl: {required: false, type: String},
|
||||||
|
canUpdate: {required: false, default: true, type: Boolean},
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
isUpdating: false,
|
||||||
|
newText: (this.content || {text: ''}).text,
|
||||||
|
errors: null,
|
||||||
|
isLoading: false,
|
||||||
|
errors: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
submit () {
|
||||||
|
let self = this
|
||||||
|
this.isLoading = true
|
||||||
|
this.errors = []
|
||||||
|
let payload = {}
|
||||||
|
payload[this.fieldName] = null
|
||||||
|
if (this.newText) {
|
||||||
|
payload[this.fieldName] = {
|
||||||
|
content_type: "text/markdown",
|
||||||
|
text: this.newText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
axios.patch(this.updateUrl, payload).then((response) => {
|
||||||
|
self.$emit('updated', response.data)
|
||||||
|
self.isLoading = false
|
||||||
|
self.isUpdating = false
|
||||||
|
}, error => {
|
||||||
|
self.errors = error.backendErrors
|
||||||
|
self.isLoading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -5,10 +5,6 @@
|
||||||
</h3>
|
</h3>
|
||||||
<p v-if="!isLoading && libraries.length > 0" class="ui subtitle"><slot name="subtitle"></slot></p>
|
<p v-if="!isLoading && libraries.length > 0" class="ui subtitle"><slot name="subtitle"></slot></p>
|
||||||
<p v-if="!isLoading && libraries.length === 0" class="ui subtitle"><translate translate-context="Content/Federation/Paragraph">No matching library.</translate></p>
|
<p v-if="!isLoading && libraries.length === 0" class="ui subtitle"><translate translate-context="Content/Federation/Paragraph">No matching library.</translate></p>
|
||||||
<i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'angle left', 'icon']">
|
|
||||||
</i>
|
|
||||||
<i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'angle right', 'icon']">
|
|
||||||
</i>
|
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<div class="ui cards">
|
<div class="ui cards">
|
||||||
<div v-if="isLoading" class="ui inverted active dimmer">
|
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||||
|
@ -22,6 +18,12 @@
|
||||||
v-for="library in libraries"
|
v-for="library in libraries"
|
||||||
:key="library.uuid"></library-card>
|
:key="library.uuid"></library-card>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="nextPage">
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
|
||||||
|
<translate translate-context="*/*/Button,Label">Show more</translate>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -61,7 +63,7 @@ export default {
|
||||||
self.previousPage = response.data.previous
|
self.previousPage = response.data.previous
|
||||||
self.nextPage = response.data.next
|
self.nextPage = response.data.next
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.libraries = response.data.results
|
self.libraries = [...self.libraries, ...response.data.results]
|
||||||
self.$emit('loaded', self.libraries)
|
self.$emit('loaded', self.libraries)
|
||||||
}, error => {
|
}, error => {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
|
||||||
Vue.component('human-date', () => import(/* webpackChunkName: "common" */ "@/components/common/HumanDate"))
|
Vue.component('human-date', () => import(/* webpackChunkName: "common" */ "@/components/common/HumanDate"))
|
||||||
|
Vue.component('human-duration', () => import(/* webpackChunkName: "common" */ "@/components/common/HumanDuration"))
|
||||||
Vue.component('username', () => import(/* webpackChunkName: "common" */ "@/components/common/Username"))
|
Vue.component('username', () => import(/* webpackChunkName: "common" */ "@/components/common/Username"))
|
||||||
Vue.component('user-link', () => import(/* webpackChunkName: "common" */ "@/components/common/UserLink"))
|
Vue.component('user-link', () => import(/* webpackChunkName: "common" */ "@/components/common/UserLink"))
|
||||||
Vue.component('actor-link', () => import(/* webpackChunkName: "common" */ "@/components/common/ActorLink"))
|
Vue.component('actor-link', () => import(/* webpackChunkName: "common" */ "@/components/common/ActorLink"))
|
||||||
|
@ -15,5 +16,7 @@ Vue.component('empty-state', () => import(/* webpackChunkName: "common" */ "@/co
|
||||||
Vue.component('expandable-div', () => import(/* webpackChunkName: "common" */ "@/components/common/ExpandableDiv"))
|
Vue.component('expandable-div', () => import(/* webpackChunkName: "common" */ "@/components/common/ExpandableDiv"))
|
||||||
Vue.component('collapse-link', () => import(/* webpackChunkName: "common" */ "@/components/common/CollapseLink"))
|
Vue.component('collapse-link', () => import(/* webpackChunkName: "common" */ "@/components/common/CollapseLink"))
|
||||||
Vue.component('action-feedback', () => import(/* webpackChunkName: "common" */ "@/components/common/ActionFeedback"))
|
Vue.component('action-feedback', () => import(/* webpackChunkName: "common" */ "@/components/common/ActionFeedback"))
|
||||||
|
Vue.component('rendered-description', () => import(/* webpackChunkName: "common" */ "@/components/common/RenderedDescription"))
|
||||||
|
Vue.component('content-form', () => import(/* webpackChunkName: "common" */ "@/components/common/ContentForm"))
|
||||||
|
|
||||||
export default {}
|
export default {}
|
||||||
|
|
|
@ -172,6 +172,18 @@ export default {
|
||||||
var self = this
|
var self = this
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
logger.default.debug('Fetching artist "' + this.id + '"')
|
logger.default.debug('Fetching artist "' + this.id + '"')
|
||||||
|
|
||||||
|
let artistPromise = axios.get("artists/" + this.id + "/", {params: {refresh: 'true'}}).then(response => {
|
||||||
|
if (response.data.channel) {
|
||||||
|
self.$router.replace({name: 'channels.detail', params: {id: response.data.channel.uuid}})
|
||||||
|
} else {
|
||||||
|
self.object = response.data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await artistPromise
|
||||||
|
if (!self.object) {
|
||||||
|
return
|
||||||
|
}
|
||||||
let trackPromise = axios.get("tracks/", { params: { artist: this.id, hidden: '' } }).then(response => {
|
let trackPromise = axios.get("tracks/", { params: { artist: this.id, hidden: '' } }).then(response => {
|
||||||
self.tracks = response.data.results
|
self.tracks = response.data.results
|
||||||
self.nextTracksUrl = response.data.next
|
self.nextTracksUrl = response.data.next
|
||||||
|
@ -188,13 +200,8 @@ export default {
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let artistPromise = axios.get("artists/" + this.id + "/", {params: {refresh: 'true'}}).then(response => {
|
|
||||||
self.object = response.data
|
|
||||||
})
|
|
||||||
await trackPromise
|
await trackPromise
|
||||||
await albumPromise
|
await albumPromise
|
||||||
await artistPromise
|
|
||||||
self.isLoadingAlbums = false
|
self.isLoadingAlbums = false
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,7 +50,6 @@
|
||||||
import _ from "@/lodash"
|
import _ from "@/lodash"
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import logger from "@/logging"
|
import logger from "@/logging"
|
||||||
import backend from "@/audio/backend"
|
|
||||||
import AlbumCard from "@/components/audio/album/Card"
|
import AlbumCard from "@/components/audio/album/Card"
|
||||||
import TrackTable from "@/components/audio/track/Table"
|
import TrackTable from "@/components/audio/track/Table"
|
||||||
import LibraryWidget from "@/components/federation/LibraryWidget"
|
import LibraryWidget from "@/components/federation/LibraryWidget"
|
||||||
|
|
|
@ -78,7 +78,7 @@
|
||||||
<i class="external icon"></i>
|
<i class="external icon"></i>
|
||||||
<translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
|
<translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
|
||||||
</a>
|
</a>
|
||||||
<a :href="discogsUrl" target="_blank" rel="noreferrer noopener" class="basic item">
|
<a v-if="discogsUrl ":href="discogsUrl" target="_blank" rel="noreferrer noopener" class="basic item">
|
||||||
<i class="external icon"></i>
|
<i class="external icon"></i>
|
||||||
<translate translate-context="Content/*/Button.Label/Verb">Search on Discogs</translate>
|
<translate translate-context="Content/*/Button.Label/Verb">Search on Discogs</translate>
|
||||||
</a>
|
</a>
|
||||||
|
@ -200,12 +200,15 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
discogsUrl() {
|
discogsUrl() {
|
||||||
return (
|
if (this.track.album) {
|
||||||
"https://discogs.com/search/?type=release&title=" +
|
return (
|
||||||
encodeURI(this.track.album.title) + "&artist=" +
|
"https://discogs.com/search/?type=release&title=" +
|
||||||
encodeURI(this.track.artist.name) + "&track=" +
|
encodeURI(this.track.album.title) + "&artist=" +
|
||||||
encodeURI(this.track.title)
|
encodeURI(this.track.artist.name) + "&track=" +
|
||||||
)
|
encodeURI(this.track.title)
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
},
|
},
|
||||||
downloadUrl() {
|
downloadUrl() {
|
||||||
let u = this.$store.getters["instance/absoluteUrl"](
|
let u = this.$store.getters["instance/absoluteUrl"](
|
||||||
|
@ -242,8 +245,14 @@ export default {
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
subtitle () {
|
subtitle () {
|
||||||
let msg = this.$pgettext('Content/Track/Paragraph', 'From album <a class="internal" href="%{ albumUrl }">%{ album }</a> by <a class="internal" href="%{ artistUrl }">%{ artist }</a>')
|
let msg
|
||||||
return this.$gettextInterpolate(msg, {album: this.track.album.title, artist: this.track.artist.name, albumUrl: this.albumUrl, artistUrl: this.artistUrl})
|
if (this.track.album) {
|
||||||
|
msg = this.$pgettext('Content/Track/Paragraph', 'From album <a class="internal" href="%{ albumUrl }">%{ album }</a> by <a class="internal" href="%{ artistUrl }">%{ artist }</a>')
|
||||||
|
return this.$gettextInterpolate(msg, {album: this.track.album.title, artist: this.track.artist.name, albumUrl: this.albumUrl, artistUrl: this.artistUrl})
|
||||||
|
} else {
|
||||||
|
msg = this.$pgettext('Content/Track/Paragraph', 'By <a class="internal" href="%{ artistUrl }">%{ artist }</a>')
|
||||||
|
return this.$gettextInterpolate(msg, {artist: this.track.artist.name, artistUrl: this.artistUrl})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
|
@ -49,10 +49,12 @@
|
||||||
<router-link :to="{name: 'manage.library.tracks.detail', params: {id: scope.obj.id }}">{{ scope.obj.title }}</router-link>
|
<router-link :to="{name: 'manage.library.tracks.detail', params: {id: scope.obj.id }}">{{ scope.obj.title }}</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="{name: 'manage.library.albums.detail', params: {id: scope.obj.album.id }}">
|
<template v-if="scope.obj.album">
|
||||||
<i class="wrench icon"></i>
|
<router-link :to="{name: 'manage.library.albums.detail', params: {id: scope.obj.album.id }}">
|
||||||
</router-link>
|
<i class="wrench icon"></i>
|
||||||
<span role="button" class="discrete link" @click="addSearchToken('album_id', scope.obj.album.id)" :title="scope.obj.album.title">{{ scope.obj.album.title }}</span>
|
</router-link>
|
||||||
|
<span role="button" class="discrete link" @click="addSearchToken('album_id', scope.obj.album.id)" :title="scope.obj.album.title">{{ scope.obj.album.title }}</span>
|
||||||
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="{name: 'manage.library.artists.detail', params: {id: scope.obj.artist.id }}">
|
<router-link :to="{name: 'manage.library.artists.detail', params: {id: scope.obj.artist.id }}">
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
methods: {
|
methods: {
|
||||||
getReportableObjs ({track, album, artist, playlist, account, library}) {
|
getReportableObjs ({track, album, artist, playlist, account, library, channel}) {
|
||||||
let reportableObjs = []
|
let reportableObjs = []
|
||||||
if (account) {
|
if (account) {
|
||||||
let accountLabel = this.$pgettext('*/Moderation/*/Verb', "Report @%{ username }…")
|
let accountLabel = this.$pgettext('*/Moderation/*/Verb', "Report @%{ username }…")
|
||||||
|
|
|
@ -49,6 +49,9 @@ export default {
|
||||||
other: this.$pgettext("Content/Moderation/Dropdown", "Other"),
|
other: this.$pgettext("Content/Moderation/Dropdown", "Other"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
summary: {
|
||||||
|
label: this.$pgettext('Content/Account/*', 'Bio'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'),
|
creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'),
|
||||||
|
|
|
@ -64,7 +64,7 @@
|
||||||
<tr v-for="(plt, index) in plts" :key="plt.id">
|
<tr v-for="(plt, index) in plts" :key="plt.id">
|
||||||
<td class="left aligned">{{ plt.index + 1}}</td>
|
<td class="left aligned">{{ plt.index + 1}}</td>
|
||||||
<td class="center aligned">
|
<td class="center aligned">
|
||||||
<img class="ui mini image" v-if="plt.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](plt.track.album.cover.small_square_crop)">
|
<img class="ui mini image" v-if="plt.track.album && plt.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](plt.track.album.cover.small_square_crop)">
|
||||||
<img class="ui mini image" v-else src="../../assets/audio/default-cover.png">
|
<img class="ui mini image" v-else src="../../assets/audio/default-cover.png">
|
||||||
</td>
|
</td>
|
||||||
<td colspan="4">
|
<td colspan="4">
|
||||||
|
|
|
@ -3,9 +3,6 @@
|
||||||
<h3 class="ui header">
|
<h3 class="ui header">
|
||||||
<slot name="title"></slot>
|
<slot name="title"></slot>
|
||||||
</h3>
|
</h3>
|
||||||
<button :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle up', 'icon']"></i></button>
|
|
||||||
<button :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle down', 'icon']"></i></button>
|
|
||||||
<button @click="fetchData(url)" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
|
|
||||||
<div v-if="isLoading" class="ui inverted active dimmer">
|
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||||
<div class="ui loader"></div>
|
<div class="ui loader"></div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -31,6 +28,12 @@
|
||||||
</translate>
|
</translate>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="nextPage">
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
|
||||||
|
<translate translate-context="*/*/Button,Label">Show more</translate>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -50,7 +53,7 @@ export default {
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
objects: [],
|
objects: [],
|
||||||
limit: 3,
|
limit: this.filters.limit || 3,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
errors: null,
|
errors: null,
|
||||||
previousPage: null,
|
previousPage: null,
|
||||||
|
@ -79,7 +82,7 @@ export default {
|
||||||
self.previousPage = response.data.previous
|
self.previousPage = response.data.previous
|
||||||
self.nextPage = response.data.next
|
self.nextPage = response.data.next
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.objects = response.data.results
|
self.objects = [...self.objects, ...response.data.results]
|
||||||
}, error => {
|
}, error => {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.errors = error.backendErrors
|
self.errors = error.backendErrors
|
||||||
|
|
|
@ -44,13 +44,22 @@ Vue.filter('ago', ago)
|
||||||
export function secondsToObject (seconds) {
|
export function secondsToObject (seconds) {
|
||||||
let m = moment.duration(seconds, 'seconds')
|
let m = moment.duration(seconds, 'seconds')
|
||||||
return {
|
return {
|
||||||
|
seconds: m.seconds(),
|
||||||
minutes: m.minutes(),
|
minutes: m.minutes(),
|
||||||
hours: parseInt(m.asHours())
|
hours: m.hours()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.filter('secondsToObject', secondsToObject)
|
Vue.filter('secondsToObject', secondsToObject)
|
||||||
|
|
||||||
|
export function padDuration (duration) {
|
||||||
|
var s = String(duration);
|
||||||
|
while (s.length < 2) {s = "0" + s;}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.filter('padDuration', padDuration)
|
||||||
|
|
||||||
export function momentFormat (date, format) {
|
export function momentFormat (date, format) {
|
||||||
format = format || 'lll'
|
format = format || 'lll'
|
||||||
return moment(date).format(format)
|
return moment(date).format(format)
|
||||||
|
|
|
@ -140,13 +140,33 @@ export default new Router({
|
||||||
),
|
),
|
||||||
props: true
|
props: true
|
||||||
},
|
},
|
||||||
{
|
...['/@:username', '/@:username@:domain'].map((path) => {
|
||||||
path: "/@:username",
|
return {
|
||||||
name: "profile",
|
path: path,
|
||||||
component: () =>
|
name: "profile",
|
||||||
import(/* webpackChunkName: "core" */ "@/components/auth/Profile"),
|
component: () =>
|
||||||
props: true
|
import(/* webpackChunkName: "core" */ "@/views/auth/ProfileBase"),
|
||||||
},
|
props: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
name: "profile.overview",
|
||||||
|
component: () =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "core" */ "@/views/auth/ProfileOverview"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "activity",
|
||||||
|
name: "profile.activity",
|
||||||
|
component: () =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "core" */ "@/views/auth/ProfileActivity"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
path: "/favorites",
|
path: "/favorites",
|
||||||
name: "favorites",
|
name: "favorites",
|
||||||
|
@ -285,7 +305,7 @@ export default new Router({
|
||||||
props: true
|
props: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "albums",
|
path: "channels",
|
||||||
name: "manage.library.albums",
|
name: "manage.library.albums",
|
||||||
component: () =>
|
component: () =>
|
||||||
import(
|
import(
|
||||||
|
@ -783,6 +803,32 @@ export default new Router({
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/channels/:id",
|
||||||
|
props: true,
|
||||||
|
component: () =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "channels" */ "@/views/channels/DetailBase"
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
name: "channels.detail",
|
||||||
|
component: () =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "channels" */ "@/views/channels/DetailOverview"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "episodes",
|
||||||
|
name: "channels.detail.episodes",
|
||||||
|
component: () =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "channels" */ "@/views/channels/DetailEpisodes"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "*/index.html",
|
path: "*/index.html",
|
||||||
redirect: "/"
|
redirect: "/"
|
||||||
|
|
|
@ -140,6 +140,7 @@ export default {
|
||||||
dispatch('ui/fetchPendingReviewReports', null, { root: true })
|
dispatch('ui/fetchPendingReviewReports', null, { root: true })
|
||||||
}
|
}
|
||||||
dispatch('favorites/fetch', null, { root: true })
|
dispatch('favorites/fetch', null, { root: true })
|
||||||
|
dispatch('channels/fetchSubscriptions', null, { root: true })
|
||||||
dispatch('moderation/fetchContentFilters', null, { root: true })
|
dispatch('moderation/fetchContentFilters', null, { root: true })
|
||||||
dispatch('playlists/fetchOwn', null, { root: true })
|
dispatch('playlists/fetchOwn', null, { root: true })
|
||||||
}, (response) => {
|
}, (response) => {
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
import logger from '@/logging'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state: {
|
||||||
|
subscriptions: [],
|
||||||
|
count: 0
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
subscriptions: (state, {uuid, value}) => {
|
||||||
|
if (value) {
|
||||||
|
if (state.subscriptions.indexOf(uuid) === -1) {
|
||||||
|
state.subscriptions.push(uuid)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let i = state.subscriptions.indexOf(uuid)
|
||||||
|
if (i > -1) {
|
||||||
|
state.subscriptions.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.count = state.subscriptions.length
|
||||||
|
},
|
||||||
|
reset (state) {
|
||||||
|
state.subscriptions = []
|
||||||
|
state.count = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
isSubscribed: (state) => (uuid) => {
|
||||||
|
return state.subscriptions.indexOf(uuid) > -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
set ({commit, state}, {uuid, value}) {
|
||||||
|
commit('subscriptions', {uuid, value})
|
||||||
|
if (value) {
|
||||||
|
return axios.post(`channels/${uuid}/subscribe/`).then((response) => {
|
||||||
|
logger.default.info('Successfully subscribed to channel')
|
||||||
|
}, (response) => {
|
||||||
|
logger.default.info('Error while subscribing to channel')
|
||||||
|
commit('subscriptions', {uuid, value: !value})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return axios.post(`channels/${uuid}/unsubscribe/`).then((response) => {
|
||||||
|
logger.default.info('Successfully unsubscribed from channel')
|
||||||
|
}, (response) => {
|
||||||
|
logger.default.info('Error while unsubscribing from channel')
|
||||||
|
commit('subscriptions', {uuid, value: !value})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggle ({getters, dispatch}, uuid) {
|
||||||
|
dispatch('set', {uuid, value: !getters['isSubscribed'](uuid)})
|
||||||
|
},
|
||||||
|
fetchSubscriptions ({dispatch, state, commit, rootState}, url) {
|
||||||
|
let promise = axios.get('subscriptions/all/')
|
||||||
|
return promise.then((response) => {
|
||||||
|
response.data.results.forEach(result => {
|
||||||
|
commit('subscriptions', {uuid: result.channel, value: true})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ import Vuex from 'vuex'
|
||||||
import createPersistedState from 'vuex-persistedstate'
|
import createPersistedState from 'vuex-persistedstate'
|
||||||
|
|
||||||
import favorites from './favorites'
|
import favorites from './favorites'
|
||||||
|
import channels from './channels'
|
||||||
import auth from './auth'
|
import auth from './auth'
|
||||||
import instance from './instance'
|
import instance from './instance'
|
||||||
import moderation from './moderation'
|
import moderation from './moderation'
|
||||||
|
@ -18,6 +19,7 @@ export default new Vuex.Store({
|
||||||
modules: {
|
modules: {
|
||||||
ui,
|
ui,
|
||||||
auth,
|
auth,
|
||||||
|
channels,
|
||||||
favorites,
|
favorites,
|
||||||
instance,
|
instance,
|
||||||
moderation,
|
moderation,
|
||||||
|
@ -76,21 +78,24 @@ export default new Vuex.Store({
|
||||||
mbid: track.artist.mbid,
|
mbid: track.artist.mbid,
|
||||||
name: track.artist.name
|
name: track.artist.name
|
||||||
}
|
}
|
||||||
return {
|
let data = {
|
||||||
id: track.id,
|
id: track.id,
|
||||||
title: track.title,
|
title: track.title,
|
||||||
mbid: track.mbid,
|
mbid: track.mbid,
|
||||||
uploads: track.uploads,
|
uploads: track.uploads,
|
||||||
listen_url: track.listen_url,
|
listen_url: track.listen_url,
|
||||||
album: {
|
artist: artist,
|
||||||
|
}
|
||||||
|
if (track.album) {
|
||||||
|
data.album = {
|
||||||
id: track.album.id,
|
id: track.album.id,
|
||||||
title: track.album.title,
|
title: track.album.title,
|
||||||
mbid: track.album.mbid,
|
mbid: track.album.mbid,
|
||||||
cover: track.album.cover,
|
cover: track.album.cover,
|
||||||
artist: artist
|
artist: artist
|
||||||
},
|
}
|
||||||
artist: artist
|
|
||||||
}
|
}
|
||||||
|
return data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -112,6 +112,12 @@ export default {
|
||||||
|
|
||||||
let instanceUrl = state.instanceUrl || getDefaultUrl()
|
let instanceUrl = state.instanceUrl || getDefaultUrl()
|
||||||
return instanceUrl + relativeUrl
|
return instanceUrl + relativeUrl
|
||||||
|
},
|
||||||
|
domain: (state) => {
|
||||||
|
let url = state.instanceUrl
|
||||||
|
let parser = document.createElement("a")
|
||||||
|
parser.href = url
|
||||||
|
return parser.hostname
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
|
|
@ -69,6 +69,7 @@
|
||||||
@import "~fomantic-ui-css/components/sidebar.css";
|
@import "~fomantic-ui-css/components/sidebar.css";
|
||||||
@import "~fomantic-ui-css/components/sticky.css";
|
@import "~fomantic-ui-css/components/sticky.css";
|
||||||
@import "~fomantic-ui-css/components/tab.css";
|
@import "~fomantic-ui-css/components/tab.css";
|
||||||
|
@import "~fomantic-ui-css/components/text.css";
|
||||||
@import "~fomantic-ui-css/components/transition.css";
|
@import "~fomantic-ui-css/components/transition.css";
|
||||||
|
|
||||||
|
|
||||||
|
@ -194,6 +195,16 @@ html {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stripe.segment > .secondary.menu:last-child {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.center.aligned.menu {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
.ellipsis:not(.icon) {
|
.ellipsis:not(.icon) {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
@ -387,6 +398,9 @@ input + .help {
|
||||||
.ui.small.divider {
|
.ui.small.divider {
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
.ui.very.small.divider {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.queue.segment.player-focused #queue-grid #player {
|
.queue.segment.player-focused #queue-grid #player {
|
||||||
@include media("<desktop") {
|
@include media("<desktop") {
|
||||||
|
@ -431,6 +445,12 @@ input + .help {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: $card-width;
|
width: $card-width;
|
||||||
height: $card-hight;
|
height: $card-hight;
|
||||||
|
.content:not(.extra) {
|
||||||
|
padding: 0.5em 1em 0;
|
||||||
|
}
|
||||||
|
.content.extra {
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
}
|
||||||
.head-image {
|
.head-image {
|
||||||
height: $card-width;
|
height: $card-width;
|
||||||
background-size: cover !important;
|
background-size: cover !important;
|
||||||
|
@ -449,6 +469,10 @@ input + .help {
|
||||||
margin: 0.5em;
|
margin: 0.5em;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
&.padded {
|
||||||
|
margin: 0.5em;
|
||||||
|
border-radius: 0.25em !important;
|
||||||
|
}
|
||||||
&.squares {
|
&.squares {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -490,5 +514,58 @@ input + .help {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// channels stuff
|
||||||
|
|
||||||
|
.channel-entry-card, .channel-serie-card {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 auto 1em;
|
||||||
|
justify-content: space-between;
|
||||||
|
.image {
|
||||||
|
width: 3.5em;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
.two-images {
|
||||||
|
width: 3.5em;
|
||||||
|
height: 3.5em;
|
||||||
|
margin-right: 1em;
|
||||||
|
position: relative;
|
||||||
|
img {
|
||||||
|
width: 2.5em;
|
||||||
|
position: absolute;
|
||||||
|
&:last-child {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
&:first-child {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.channel-image {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 0.3em;
|
||||||
|
&.large {
|
||||||
|
width: 8em !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.content-form {
|
||||||
|
.segment:first-child {
|
||||||
|
min-height: 15em;
|
||||||
|
}
|
||||||
|
.ui.secondary.menu {
|
||||||
|
margin-top: -0.5em;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@import "./themes/_light.scss";
|
@import "./themes/_light.scss";
|
||||||
@import "./themes/_dark.scss";
|
@import "./themes/_dark.scss";
|
||||||
|
|
|
@ -77,7 +77,7 @@
|
||||||
:class="['ui', {loading: isLoading}, 'basic button']"
|
:class="['ui', {loading: isLoading}, 'basic button']"
|
||||||
:action="remove">
|
:action="remove">
|
||||||
<translate translate-context="*/*/*/Verb">Delete</translate>
|
<translate translate-context="*/*/*/Verb">Delete</translate>
|
||||||
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this album?</translate></p>
|
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this track?</translate></p>
|
||||||
<div slot="modal-content">
|
<div slot="modal-content">
|
||||||
<p><translate translate-context="Content/Moderation/Paragraph">The track will be removed, as well as associated uploads, favorites and listening history. This action is irreversible.</translate></p>
|
<p><translate translate-context="Content/Moderation/Paragraph">The track will be removed, as well as associated uploads, favorites and listening history. This action is irreversible.</translate></p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -109,7 +109,7 @@
|
||||||
{{ object.title }}
|
{{ object.title }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr v-if="object.album">
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="{name: 'manage.library.albums.detail', params: {id: object.album.id }}">
|
<router-link :to="{name: 'manage.library.albums.detail', params: {id: object.album.id }}">
|
||||||
<translate translate-context="*/*/*">Album</translate>
|
<translate translate-context="*/*/*">Album</translate>
|
||||||
|
@ -130,7 +130,7 @@
|
||||||
{{ object.artist.name }}
|
{{ object.artist.name }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr v-if="object.album">
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="{name: 'manage.library.artists.detail', params: {id: object.album.artist.id }}">
|
<router-link :to="{name: 'manage.library.artists.detail', params: {id: object.album.artist.id }}">
|
||||||
<translate translate-context="*/*/*/Noun">Album artist</translate>
|
<translate translate-context="*/*/*/Noun">Album artist</translate>
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
<template>
|
||||||
|
<section class="ui stackable three column grid">
|
||||||
|
<div class="column">
|
||||||
|
<h2 class="ui header">
|
||||||
|
<translate translate-context="Content/Home/Title">Recently listened</translate>
|
||||||
|
</h2>
|
||||||
|
<track-widget :url="'history/listenings/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}">
|
||||||
|
</track-widget>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<h2 class="ui header">
|
||||||
|
<translate translate-context="Content/Home/Title">Recently favorited</translate>
|
||||||
|
</h2>
|
||||||
|
<track-widget :url="'favorites/tracks/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}"></track-widget>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<h2 class="ui header">
|
||||||
|
<translate translate-context="*/*/*">Playlists</translate>
|
||||||
|
</h2>
|
||||||
|
<playlist-widget :url="'playlists/'" :filters="{scope: `actor:${object.full_username}`, playable: true, ordering: '-modification_date'}">
|
||||||
|
</playlist-widget>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import TrackWidget from "@/components/audio/track/Widget"
|
||||||
|
import PlaylistWidget from "@/components/playlists/Widget"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['object'],
|
||||||
|
components: {TrackWidget, PlaylistWidget},
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,130 @@
|
||||||
|
<template>
|
||||||
|
<main class="main pusher" v-title="labels.usernameProfile">
|
||||||
|
<div v-if="isLoading" class="ui vertical segment">
|
||||||
|
<div class="ui centered active inline loader"></div>
|
||||||
|
</div>
|
||||||
|
<template v-if="object">
|
||||||
|
<div class="ui dropdown icon small basic right floated button" ref="dropdown" v-dropdown style="right: 1em; top: 1em; z-index: 5">
|
||||||
|
<i class="ellipsis vertical icon"></i>
|
||||||
|
<div class="menu">
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
class="basic item"
|
||||||
|
v-for="obj in getReportableObjs({account: object})"
|
||||||
|
:key="obj.target.type + obj.target.id"
|
||||||
|
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
|
||||||
|
<i class="share icon" /> {{ obj.label }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['moderation']" :to="{name: 'manage.moderation.accounts.detail', params: {id: object.full_username}}">
|
||||||
|
<i class="wrench icon"></i>
|
||||||
|
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui head vertical stripe segment">
|
||||||
|
<h1 class="ui center aligned icon header">
|
||||||
|
<i v-if="!object.icon" class="circular inverted user green icon"></i>
|
||||||
|
<img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](object.icon.square_crop)" />
|
||||||
|
<div class="ellispsis content">
|
||||||
|
<div class="ui very small hidden divider"></div>
|
||||||
|
<span :title="displayName">{{ displayName }}</span>
|
||||||
|
<div class="ui very small hidden divider"></div>
|
||||||
|
<span class="ui grey tiny text" :title="object.full_username">{{ object.full_username }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="object.full_username === $store.state.auth.fullUsername">
|
||||||
|
<div class="ui very small hidden divider"></div>
|
||||||
|
<div class="ui basic green label">
|
||||||
|
<translate translate-context="Content/Profile/Button.Paragraph">This is you!</translate>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</h1>
|
||||||
|
<div class="ui container">
|
||||||
|
<div class="ui secondary pointing center aligned menu">
|
||||||
|
<router-link class="item" :exact="true" :to="{name: 'profile.overview', params: routerParams}">
|
||||||
|
<translate translate-context="Content/Profile/Link">Overview</translate>
|
||||||
|
</router-link>
|
||||||
|
<router-link class="item" :exact="true" :to="{name: 'profile.activity', params: routerParams}">
|
||||||
|
<translate translate-context="Content/Profile/*">Activity</translate>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<keep-alive>
|
||||||
|
<router-view @updated="fetch" :object="object"></router-view>
|
||||||
|
</keep-alive>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapState } from "vuex"
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import ReportMixin from '@/components/mixins/Report'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [ReportMixin],
|
||||||
|
props: {
|
||||||
|
username: {type: String, required: true},
|
||||||
|
domain: {type: String, required: false, default: null},
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
object: null,
|
||||||
|
isLoading: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.fetch()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetch () {
|
||||||
|
let self = this
|
||||||
|
self.isLoading = true
|
||||||
|
axios.get(`federation/actors/${this.fullUsername}/`).then((response) => {
|
||||||
|
self.object = response.data
|
||||||
|
self.isLoading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
labels() {
|
||||||
|
let msg = this.$pgettext('Head/Profile/Title', "%{ username }'s profile")
|
||||||
|
let usernameProfile = this.$gettextInterpolate(msg, {
|
||||||
|
username: this.username
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
usernameProfile
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fullUsername () {
|
||||||
|
if (this.username && this.domain) {
|
||||||
|
return `${this.username}@${this.domain}`
|
||||||
|
} else {
|
||||||
|
return `${this.username}@${this.$store.getters['instance/domain']}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
routerParams () {
|
||||||
|
if (this.domain) {
|
||||||
|
return {username: this.username, domain: this.domain}
|
||||||
|
} else {
|
||||||
|
return {username: this.username}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
displayName () {
|
||||||
|
return this.object.name || this.object.preferred_username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
.ui.header > img.image {
|
||||||
|
width: 8em;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,35 @@
|
||||||
|
<template>
|
||||||
|
<section class="ui stackable grid">
|
||||||
|
<div class="six wide column">
|
||||||
|
<rendered-description
|
||||||
|
@updated="$emit('updated', $event)"
|
||||||
|
:content="object.summary"
|
||||||
|
:field-name="'summary'"
|
||||||
|
:update-url="`users/users/${$store.state.auth.username}/`"
|
||||||
|
:can-update="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername"></rendered-description>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="ten wide column">
|
||||||
|
<h2 class="ui header">
|
||||||
|
<translate translate-context="*/*/*">Channels</translate>
|
||||||
|
</h2>
|
||||||
|
<channels-widget :filters="{scope: `actor:${object.full_username}`}"></channels-widget>
|
||||||
|
<h2 class="ui header">
|
||||||
|
<translate translate-context="Content/Profile/Header">User Libraries</translate>
|
||||||
|
</h2>
|
||||||
|
<library-widget :url="`federation/actors/${object.full_username}/libraries/`">
|
||||||
|
<translate translate-context="Content/Profile/Paragraph" slot="subtitle">This user shared the following libraries.</translate>
|
||||||
|
</library-widget>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import LibraryWidget from "@/components/federation/LibraryWidget"
|
||||||
|
import ChannelsWidget from "@/components/audio/ChannelsWidget"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['object'],
|
||||||
|
components: {ChannelsWidget, LibraryWidget},
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,203 @@
|
||||||
|
<template>
|
||||||
|
<main class="main pusher" v-title="labels.title">
|
||||||
|
<div v-if="isLoading" class="ui vertical segment">
|
||||||
|
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
|
</div>
|
||||||
|
<template v-if="object && !isLoading">
|
||||||
|
<section class="ui head vertical stripe segment container" v-title="object.artist.name">
|
||||||
|
<div class="ui stackable two column grid">
|
||||||
|
<div class="column">
|
||||||
|
<div class="ui two column grid">
|
||||||
|
<div class="column">
|
||||||
|
<img class="huge channel-image" v-if="object.artist.cover" :src="$store.getters['instance/absoluteUrl'](object.artist.cover.medium_square_crop)">
|
||||||
|
<i v-else class="huge circular inverted users violet icon"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ui column right aligned">
|
||||||
|
<tags-list v-if="object.artist.tags && object.artist.tags.length > 0" :tags="object.artist.tags"></tags-list>
|
||||||
|
<actor-link :avatar="false" :actor="object.attributed_to" :display-name="true"></actor-link>
|
||||||
|
<template v-if="totalTracks > 0">
|
||||||
|
<div class="ui hidden very small divider"></div>
|
||||||
|
<translate translate-context="Content/Channel/Paragraph"
|
||||||
|
translate-plural="%{ count } episodes"
|
||||||
|
:translate-n="totalTracks"
|
||||||
|
:translate-params="{count: totalTracks}">
|
||||||
|
%{ count } episode
|
||||||
|
</translate>
|
||||||
|
<template v-if="object.attributed_to.full_username === $store.state.auth.fullUsername || $store.getters['channels/isSubscribed'](object.uuid)">
|
||||||
|
· <translate translate-context="Content/Channel/Paragraph" translate-plural="%{ count } subscribers" :translate-n="object.subscriptions_count" :translate-params="{count: object.subscriptions_count}">%{ count } subscriber</translate>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<div class="ui hidden small divider"></div>
|
||||||
|
<a :href="rssUrl" target="_blank" class="ui icon small basic button">
|
||||||
|
<i class="feed icon"></i>
|
||||||
|
</a>
|
||||||
|
<div class="ui dropdown icon small basic button" ref="dropdown" v-dropdown>
|
||||||
|
<i class="ellipsis vertical icon"></i>
|
||||||
|
<div class="menu">
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
v-if="totalTracks > 0"
|
||||||
|
@click="showEmbedModal = !showEmbedModal"
|
||||||
|
class="basic item">
|
||||||
|
<i class="code icon"></i>
|
||||||
|
<translate translate-context="Content/*/Button.Label/Verb">Embed</translate>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
class="basic item"
|
||||||
|
v-for="obj in getReportableObjs({channel: object})"
|
||||||
|
:key="obj.target.type + obj.target.id"
|
||||||
|
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
|
||||||
|
<i class="share icon" /> {{ obj.label }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.channels.detail', params: {id: object.uuid}}">
|
||||||
|
<i class="wrench icon"></i>
|
||||||
|
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 class="ui header">
|
||||||
|
<div class="left aligned content ellipsis">
|
||||||
|
{{ object.artist.name }}
|
||||||
|
<div class="ui hidden very small divider"></div>
|
||||||
|
<div class="sub header">
|
||||||
|
{{ object.actor.full_username }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</h1>
|
||||||
|
<div class="header-buttons">
|
||||||
|
<div class="ui buttons">
|
||||||
|
<play-button :is-playable="isPlayable" class="orange" :channel="object">
|
||||||
|
<translate translate-context="Content/Channels/Button.Label/Verb">Play</translate>
|
||||||
|
</play-button>
|
||||||
|
</div>
|
||||||
|
<div class="ui buttons">
|
||||||
|
<subscribe-button @subscribed="object.subscriptions_count += 1" @unsubscribed="object.subscriptions_count -= 1" :channel="object"></subscribe-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<modal :show.sync="showEmbedModal" v-if="totalTracks > 0">
|
||||||
|
<div class="header">
|
||||||
|
<translate translate-context="Popup/Artist/Title/Verb">Embed this artist work on your website</translate>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="description">
|
||||||
|
<embed-wizard type="artist" :id="object.artist.id" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="ui deny button">
|
||||||
|
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modal>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<rendered-description
|
||||||
|
@updated="object = $event"
|
||||||
|
:content="object.artist.description"
|
||||||
|
:update-url="`channels/${object.uuid}/`"
|
||||||
|
:can-update="$store.state.auth.authenticated && object.attributed_to.full_username === $store.state.auth.fullUsername"></rendered-description>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="ui secondary pointing center aligned menu">
|
||||||
|
<router-link class="item" :exact="true" :to="{name: 'channels.detail', params: {id: id}}">
|
||||||
|
<translate translate-context="Content/Channels/Link">Overview</translate>
|
||||||
|
</router-link>
|
||||||
|
<router-link class="item" :exact="true" :to="{name: 'channels.detail.episodes', params: {id: id}}">
|
||||||
|
<translate translate-context="Content/Channels/*">Episodes</translate>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<keep-alive>
|
||||||
|
<router-view v-if="object" :object="object" @tracks-loaded="totalTracks = $event" ></router-view>
|
||||||
|
</keep-alive>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from "axios"
|
||||||
|
import PlayButton from "@/components/audio/PlayButton"
|
||||||
|
import ChannelEntries from "@/components/audio/ChannelEntries"
|
||||||
|
import ChannelSeries from "@/components/audio/ChannelSeries"
|
||||||
|
import EmbedWizard from "@/components/audio/EmbedWizard"
|
||||||
|
import Modal from '@/components/semantic/Modal'
|
||||||
|
import TagsList from "@/components/tags/List"
|
||||||
|
import ReportMixin from '@/components/mixins/Report'
|
||||||
|
|
||||||
|
import SubscribeButton from '@/components/channels/SubscribeButton'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [ReportMixin],
|
||||||
|
props: ["id"],
|
||||||
|
components: {
|
||||||
|
PlayButton,
|
||||||
|
EmbedWizard,
|
||||||
|
Modal,
|
||||||
|
TagsList,
|
||||||
|
ChannelEntries,
|
||||||
|
ChannelSeries,
|
||||||
|
SubscribeButton
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isLoading: true,
|
||||||
|
object: null,
|
||||||
|
totalTracks: 0,
|
||||||
|
latestTracks: null,
|
||||||
|
showEmbedModal: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
await this.fetchData()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async fetchData() {
|
||||||
|
var self = this
|
||||||
|
this.isLoading = true
|
||||||
|
let channelPromise = axios.get(`channels/${this.id}`).then(response => {
|
||||||
|
self.object = response.data
|
||||||
|
})
|
||||||
|
let tracksPromise = axios.get("tracks", {params: {channel: this.id, page_size: 1, playable: true, include_channels: true}}).then(response => {
|
||||||
|
self.totalTracks = response.data.count
|
||||||
|
})
|
||||||
|
await channelPromise
|
||||||
|
await tracksPromise
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
labels() {
|
||||||
|
return {
|
||||||
|
title: this.$pgettext('*/*/*', 'Channel')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentFilter () {
|
||||||
|
let self = this
|
||||||
|
return this.$store.getters['moderation/artistFilters']().filter((e) => {
|
||||||
|
return e.target.id === this.object.artist.id
|
||||||
|
})[0]
|
||||||
|
},
|
||||||
|
isPlayable () {
|
||||||
|
return this.totalTracks > 0
|
||||||
|
},
|
||||||
|
rssUrl () {
|
||||||
|
return this.$store.getters['instance/absoluteUrl'](`api/v1/channels/${this.id}/rss`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
id() {
|
||||||
|
this.fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,18 @@
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<channel-entries :limit="25" :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
|
||||||
|
</channel-entries>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ChannelEntries from "@/components/audio/ChannelEntries"
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['object'],
|
||||||
|
components: {
|
||||||
|
ChannelEntries,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,29 @@
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<channel-entries :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
|
||||||
|
<h2 class="ui header">
|
||||||
|
<translate translate-context="Content/Channel/Paragraph">Latest episodes</translate>
|
||||||
|
</h2>
|
||||||
|
</channel-entries>
|
||||||
|
<div class="ui hidden divider"></div>
|
||||||
|
<channel-series :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
|
||||||
|
<h2 class="ui header">
|
||||||
|
<translate translate-context="Content/Channel/Paragraph">Series</translate>
|
||||||
|
</h2>
|
||||||
|
</channel-series>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import ChannelEntries from "@/components/audio/ChannelEntries"
|
||||||
|
import ChannelSeries from "@/components/audio/ChannelSeries"
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ['object'],
|
||||||
|
components: {
|
||||||
|
ChannelEntries,
|
||||||
|
ChannelSeries,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -133,6 +133,7 @@
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
|
v-if="scope.obj.track.album"
|
||||||
class="discrete link"
|
class="discrete link"
|
||||||
@click="addSearchToken('album', scope.obj.track.album.title)"
|
@click="addSearchToken('album', scope.obj.track.album.title)"
|
||||||
:title="scope.obj.track.album.title"
|
:title="scope.obj.track.album.title"
|
||||||
|
|
Loading…
Reference in New Issue