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/refresh/?$", jwt_views.refresh_jwt_token, name="token_refresh"),
|
||||
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 = [
|
||||
|
|
|
@ -70,6 +70,8 @@ class ChannelCreateSerializer(serializers.Serializer):
|
|||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
from . import views
|
||||
|
||||
description = validated_data.get("description")
|
||||
artist = music_models.Artist.objects.create(
|
||||
attributed_to=validated_data["attributed_to"],
|
||||
|
@ -99,10 +101,11 @@ class ChannelCreateSerializer(serializers.Serializer):
|
|||
actor=validated_data["attributed_to"],
|
||||
)
|
||||
channel.save()
|
||||
channel = views.ChannelViewSet.queryset.get(pk=channel.pk)
|
||||
return channel
|
||||
|
||||
def to_representation(self, obj):
|
||||
return ChannelSerializer(obj).data
|
||||
return ChannelSerializer(obj, context=self.context).data
|
||||
|
||||
|
||||
NOOP = object()
|
||||
|
@ -181,7 +184,7 @@ class ChannelUpdateSerializer(serializers.Serializer):
|
|||
return obj
|
||||
|
||||
def to_representation(self, obj):
|
||||
return ChannelSerializer(obj).data
|
||||
return ChannelSerializer(obj, context=self.context).data
|
||||
|
||||
|
||||
class ChannelSerializer(serializers.ModelSerializer):
|
||||
|
@ -261,7 +264,8 @@ def rss_serialize_item(upload):
|
|||
"link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
|
||||
"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,
|
||||
"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:summary"] = [{"cdata_value": upload.track.description.rendered}]
|
||||
data["description"] = [{"value": upload.track.description.as_plain_text}]
|
||||
data["content:encoded"] = data["itunes:summary"]
|
||||
|
||||
if upload.track.attachment_cover:
|
||||
data["itunes:image"] = [
|
||||
|
|
|
@ -6,7 +6,7 @@ from rest_framework import response
|
|||
from rest_framework import viewsets
|
||||
|
||||
from django import http
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models import Count, Prefetch
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
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
|
||||
|
||||
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):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
@ -44,12 +50,7 @@ class ChannelViewSet(
|
|||
"library",
|
||||
"attributed_to",
|
||||
"actor",
|
||||
Prefetch(
|
||||
"artist",
|
||||
queryset=music_models.Artist.objects.select_related(
|
||||
"attachment_cover", "description"
|
||||
).prefetch_related(music_views.TAG_PREFETCH,),
|
||||
),
|
||||
Prefetch("artist", queryset=ARTIST_PREFETCH_QS),
|
||||
)
|
||||
.order_by("-creation_date")
|
||||
)
|
||||
|
@ -131,7 +132,12 @@ class ChannelViewSet(
|
|||
|
||||
def get_serializer_context(self):
|
||||
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
|
||||
|
||||
|
||||
|
@ -148,8 +154,8 @@ class SubscriptionsViewSet(
|
|||
.prefetch_related(
|
||||
"target__channel__library",
|
||||
"target__channel__attributed_to",
|
||||
"target__channel__artist__description",
|
||||
"actor",
|
||||
Prefetch("target__channel__artist", queryset=ARTIST_PREFETCH_QS),
|
||||
)
|
||||
.order_by("-creation_date")
|
||||
)
|
||||
|
@ -171,10 +177,12 @@ class SubscriptionsViewSet(
|
|||
to have a performant endpoint and avoid lots of queries just to display
|
||||
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 = {
|
||||
"results": [str(u) for u in subscriptions],
|
||||
"results": [{"uuid": str(u[0]), "channel": u[1]} for u in subscriptions],
|
||||
"count": len(subscriptions),
|
||||
}
|
||||
return response.Response(payload, status=200)
|
||||
|
|
|
@ -176,6 +176,8 @@ class ActorScopeFilter(filters.CharFilter):
|
|||
super().__init__(*args, **kwargs)
|
||||
|
||||
def filter(self, queryset, value):
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
|
@ -189,6 +191,17 @@ class ActorScopeFilter(filters.CharFilter):
|
|||
qs = self.filter_me(user=user, queryset=queryset)
|
||||
elif value.lower() == "all":
|
||||
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:
|
||||
return queryset.none()
|
||||
|
||||
|
|
|
@ -201,7 +201,7 @@ class AttachmentQuerySet(models.QuerySet):
|
|||
|
||||
class Attachment(models.Model):
|
||||
# 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)
|
||||
# Actor associated with the attachment
|
||||
actor = models.ForeignKey(
|
||||
|
|
|
@ -303,6 +303,7 @@ def attach_content(obj, field, content_data):
|
|||
|
||||
if existing:
|
||||
getattr(obj, field).delete()
|
||||
setattr(obj, field, None)
|
||||
|
||||
if not content_data:
|
||||
return
|
||||
|
|
|
@ -181,3 +181,15 @@ class AttachmentViewSet(
|
|||
if instance.actor is None or instance.actor != self.request.user.actor:
|
||||
raise exceptions.PermissionDenied()
|
||||
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 funkwhale_api.common import serializers as common_serializers
|
||||
from funkwhale_api.music import models as music_models
|
||||
from funkwhale_api.users import serializers as users_serializers
|
||||
|
||||
from . import filters
|
||||
from . import models
|
||||
|
@ -169,3 +172,27 @@ class FetchSerializer(serializers.ModelSerializer):
|
|||
"creation_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"libraries", api_views.LibraryViewSet, "libraries")
|
||||
router.register(r"domains", api_views.DomainViewSet, "domains")
|
||||
router.register(r"actors", api_views.ActorViewSet, "actors")
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
|
|
@ -12,6 +12,7 @@ from rest_framework import viewsets
|
|||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||
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 . import activity
|
||||
|
@ -218,3 +219,34 @@ class DomainViewSet(
|
|||
if preferences.get("moderation__allow_list_enabled"):
|
||||
qs = qs.filter(allowed=True)
|
||||
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:
|
||||
model = models.Actor
|
||||
fields = [
|
||||
"id",
|
||||
"fid",
|
||||
"url",
|
||||
"creation_date",
|
||||
|
|
|
@ -876,6 +876,12 @@ class Upload(models.Model):
|
|||
def listen_url(self):
|
||||
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
|
||||
def listen_url_no_download(self):
|
||||
# Not using reverse because this is slow
|
||||
|
|
|
@ -156,6 +156,19 @@ def serialize_artist_simple(artist):
|
|||
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
|
||||
|
||||
|
||||
|
|
|
@ -5,6 +5,29 @@ from rest_framework import renderers
|
|||
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):
|
||||
payload = {
|
||||
"funkwhaleVersion": funkwhale_api.__version__,
|
||||
|
@ -56,7 +79,7 @@ def dict_to_xml_tree(root_tag, d, parent=None):
|
|||
if key == "value":
|
||||
root.text = str(value)
|
||||
elif key == "cdata_value":
|
||||
root.text = "<![CDATA[{}]]>".format(str(value))
|
||||
root.append(CDATA(value))
|
||||
else:
|
||||
root.set(key, str(value))
|
||||
return root
|
||||
|
|
|
@ -229,8 +229,8 @@ class User(AbstractUser):
|
|||
self.last_activity = now
|
||||
self.save(update_fields=["last_activity"])
|
||||
|
||||
def create_actor(self):
|
||||
self.actor = create_actor(self)
|
||||
def create_actor(self, **kwargs):
|
||||
self.actor = create_actor(self, **kwargs)
|
||||
self.save(update_fields=["actor"])
|
||||
return self.actor
|
||||
|
||||
|
@ -264,15 +264,10 @@ class User(AbstractUser):
|
|||
def full_username(self):
|
||||
return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME)
|
||||
|
||||
@property
|
||||
def avatar_path(self):
|
||||
if not self.avatar:
|
||||
return None
|
||||
try:
|
||||
return self.avatar.path
|
||||
except NotImplementedError:
|
||||
# external storage
|
||||
return self.avatar.name
|
||||
def get_avatar(self):
|
||||
if not self.actor:
|
||||
return
|
||||
return self.actor.attachment_icon
|
||||
|
||||
|
||||
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.update(kwargs)
|
||||
private, public = keys.get_key_pair()
|
||||
args["private_key"] = private.decode("utf-8")
|
||||
args["public_key"] = public.decode("utf-8")
|
||||
|
|
|
@ -90,17 +90,12 @@ class UserActivitySerializer(activity_serializers.ModelSerializer):
|
|||
|
||||
|
||||
class UserBasicSerializer(serializers.ModelSerializer):
|
||||
avatar = serializers.SerializerMethodField()
|
||||
avatar = common_serializers.AttachmentSerializer(source="get_avatar")
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
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):
|
||||
summary = common_serializers.ContentSerializer(required=False, allow_null=True)
|
||||
|
@ -140,19 +135,12 @@ class UserWriteSerializer(serializers.ModelSerializer):
|
|||
obj.actor.save(update_fields=["attachment_icon"])
|
||||
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):
|
||||
|
||||
permissions = serializers.SerializerMethodField()
|
||||
full_username = serializers.SerializerMethodField()
|
||||
avatar = serializers.SerializerMethodField()
|
||||
avatar = common_serializers.AttachmentSerializer(source="get_avatar")
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
|
@ -170,9 +158,6 @@ class UserReadSerializer(serializers.ModelSerializer):
|
|||
"avatar",
|
||||
]
|
||||
|
||||
def get_avatar(self, o):
|
||||
return common_serializers.AttachmentSerializer(o.actor.attachment_icon).data
|
||||
|
||||
def get_permissions(self, o):
|
||||
return o.get_permissions()
|
||||
|
||||
|
|
|
@ -185,7 +185,6 @@ def test_rss_item_serializer(factories):
|
|||
"itunes:subtitle": [{"value": description.truncate(255)}],
|
||||
"itunes:summary": [{"cdata_value": description.rendered}],
|
||||
"description": [{"value": description.as_plain_text}],
|
||||
"content:encoded": [{"cdata_value": description.rendered}],
|
||||
"guid": [{"cdata_value": str(upload.uuid), "isPermalink": "false"}],
|
||||
"pubDate": [{"value": serializers.rss_date(upload.creation_date)}],
|
||||
"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}],
|
||||
"link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
|
||||
"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
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.audio import serializers
|
||||
from funkwhale_api.audio import views
|
||||
|
||||
|
||||
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
|
||||
|
||||
channel = actor.owned_channels.select_related("artist__description").latest("id")
|
||||
expected = serializers.ChannelSerializer(channel).data
|
||||
channel = views.ChannelViewSet.queryset.get(attributed_to=actor)
|
||||
expected = serializers.ChannelSerializer(
|
||||
channel, context={"subscriptions_count": True}
|
||||
).data
|
||||
|
||||
assert response.data == expected
|
||||
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):
|
||||
channel = factories["audio.Channel"](artist__description=None)
|
||||
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(
|
||||
channel, context={"subscriptions_count": True}
|
||||
).data
|
||||
|
@ -54,6 +61,8 @@ def test_channel_detail(factories, logged_in_api_client):
|
|||
|
||||
def test_channel_list(factories, logged_in_api_client):
|
||||
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")
|
||||
expected = serializers.ChannelSerializer(channel).data
|
||||
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
|
||||
|
||||
subscription = actor.emitted_follows.select_related(
|
||||
"target__channel__artist__description"
|
||||
"target__channel__artist__description",
|
||||
"target__channel__artist__attachment_cover",
|
||||
).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()
|
||||
expected = serializers.SubscriptionSerializer(subscription).data
|
||||
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()
|
||||
channel = factories["audio.Channel"](artist__description=None)
|
||||
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)
|
||||
url = reverse("api:v1:subscriptions-list")
|
||||
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)
|
||||
|
||||
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):
|
||||
|
|
|
@ -50,6 +50,8 @@ def test_mutation_filter_is_approved(value, expected, factories):
|
|||
("noop", 0, []),
|
||||
("noop", 1, []),
|
||||
("noop", 2, []),
|
||||
("actor:actor1@domain.test", 0, [0]),
|
||||
("actor:actor2@domain.test", 0, [1]),
|
||||
],
|
||||
)
|
||||
def test_actor_scope_filter(
|
||||
|
@ -61,8 +63,13 @@ def test_actor_scope_filter(
|
|||
mocker,
|
||||
anonymous_user,
|
||||
):
|
||||
actor1 = factories["users.User"]().create_actor()
|
||||
actor2 = factories["users.User"]().create_actor()
|
||||
domain = factories["federation.Domain"](name="domain.test")
|
||||
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]
|
||||
tracks = [
|
||||
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 tasks
|
||||
from funkwhale_api.common import throttling
|
||||
from funkwhale_api.common import utils
|
||||
|
||||
|
||||
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
|
||||
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}
|
||||
).data
|
||||
]
|
||||
expected[0]["track"]["artist"].pop("cover")
|
||||
expected[0]["track"]["album"]["artist"].pop("cover")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["results"] == expected
|
||||
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import pytest
|
||||
|
||||
from funkwhale_api.common import serializers as common_serializers
|
||||
from funkwhale_api.federation import api_serializers
|
||||
from funkwhale_api.federation import serializers
|
||||
from funkwhale_api.users import serializers as users_serializers
|
||||
|
||||
|
||||
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)
|
||||
expected["type"] = factory_name
|
||||
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],
|
||||
}
|
||||
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)
|
||||
|
||||
for t in expected["tracks"]:
|
||||
t["artist"].pop("cover")
|
||||
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],
|
||||
"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"})
|
||||
|
||||
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})
|
||||
response = logged_in_api_client.get(url)
|
||||
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["results"][0] == serialized_plt
|
||||
|
|
|
@ -36,6 +36,9 @@ def test_can_validate_config(logged_in_api_client, factories):
|
|||
"count": candidates.count(),
|
||||
"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]["errors"] == []
|
||||
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.18.0",
|
||||
"dateformat": "^3.0.3",
|
||||
"diff": "^4.0.1",
|
||||
"django-channels": "^1.1.6",
|
||||
"fomantic-ui-css": "^2.7",
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
<transition name="queue">
|
||||
<queue @touch-progress="$refs.player.setCurrentTime($event)" v-if="$store.state.ui.queueFocused"></queue>
|
||||
</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>
|
||||
<app-footer
|
||||
:class="{hidden: $store.state.ui.queueFocused}"
|
||||
|
@ -241,8 +241,9 @@ export default {
|
|||
},
|
||||
getTrackInformationText(track) {
|
||||
const trackTitle = track.title
|
||||
const albumArtist = (track.album) ? track.album.artist.name : null
|
||||
const artistName = (
|
||||
(track.artist) ? track.artist.name : track.album.artist.name)
|
||||
(track.artist) ? track.artist.name : albumArtist)
|
||||
const text = `♫ ${trackTitle} – ${artistName} ♫`
|
||||
return text
|
||||
},
|
||||
|
|
|
@ -315,7 +315,7 @@ export default {
|
|||
title: t.title,
|
||||
artist: t.artist,
|
||||
album: t.album,
|
||||
cover: self.getCover(t.album.cover),
|
||||
cover: self.getCover((t.album || {}).cover),
|
||||
sources: self.getSources(t.uploads)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<div class="ui six wide column current-track">
|
||||
<div class="ui basic segment" id="player">
|
||||
<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">
|
||||
<h1 class="ui header">
|
||||
<div class="content">
|
||||
|
@ -15,9 +15,9 @@
|
|||
</router-link>
|
||||
<div class="sub header">
|
||||
<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) }}
|
||||
</router-link>
|
||||
</router-link></template>
|
||||
</div>
|
||||
</div>
|
||||
</h1>
|
||||
|
@ -167,7 +167,7 @@
|
|||
<i class="grip lines grey icon"></i>
|
||||
</td>
|
||||
<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">
|
||||
</td>
|
||||
<td colspan="3" @click="$store.dispatch('queue/currentIndex', index)">
|
||||
|
|
|
@ -74,10 +74,10 @@
|
|||
</router-link>
|
||||
<div class="item">
|
||||
<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}" />
|
||||
<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="{name: 'logout'}"></i><translate translate-context="Sidebar/Login/List item.Link/Verb">Logout</translate></router-link>
|
||||
</div>
|
||||
|
@ -155,7 +155,6 @@ import { mapState, mapActions, mapGetters } from "vuex"
|
|||
|
||||
import Logo from "@/components/Logo"
|
||||
import SearchBar from "@/components/audio/SearchBar"
|
||||
import backend from "@/audio/backend"
|
||||
|
||||
import $ from "jquery"
|
||||
|
||||
|
@ -168,7 +167,6 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
selectedTab: "library",
|
||||
backend: backend,
|
||||
isCollapsed: true,
|
||||
fetchInterval: null,
|
||||
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 @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">
|
||||
</div>
|
||||
<div @click.stop.prevent="" class="middle aligned content ellipsis">
|
||||
|
@ -21,15 +21,15 @@
|
|||
</strong>
|
||||
<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 }}">
|
||||
{{ 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 }}
|
||||
</router-link>
|
||||
</router-link></template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls track-controls queue-not-focused tablet-and-below">
|
||||
<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">
|
||||
</div>
|
||||
<div class="middle aligned content ellipsis">
|
||||
|
@ -37,7 +37,7 @@
|
|||
{{ currentTrack.title }}
|
||||
</strong>
|
||||
<div class="meta">
|
||||
{{ currentTrack.artist.name }} / {{ currentTrack.album.title }}
|
||||
{{ currentTrack.artist.name }}<template v-if="currentTrack.album"> / {{ currentTrack.album.title }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -703,11 +703,13 @@ export default {
|
|||
// If the session is playing as a PWA, populate the notification
|
||||
// with details from the track
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
let metatata = {
|
||||
title: this.currentTrack.title,
|
||||
artist: this.currentTrack.artist.name,
|
||||
album: this.currentTrack.album.title,
|
||||
artwork: [
|
||||
}
|
||||
if (this.currentTrack.album) {
|
||||
metadata.album = this.currentTrack.album.title
|
||||
metadata.artwork = [
|
||||
{ src: this.currentTrack.album.cover.original, sizes: '96x96', type: 'image/png' },
|
||||
{ src: this.currentTrack.album.cover.original, sizes: '128x128', type: 'image/png' },
|
||||
{ src: this.currentTrack.album.cover.original, sizes: '192x192', type: 'image/png' },
|
||||
|
@ -715,7 +717,8 @@ export default {
|
|||
{ 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
|
||||
|
|
|
@ -109,7 +109,11 @@ export default {
|
|||
return r.title
|
||||
},
|
||||
getDescription (r) {
|
||||
if (r.album) {
|
||||
return `${r.album.artist.name} - ${r.album.title}`
|
||||
} else {
|
||||
return r.artist.name
|
||||
}
|
||||
},
|
||||
getId (t) {
|
||||
return t.id
|
||||
|
|
|
@ -5,9 +5,6 @@
|
|||
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
|
||||
</h3>
|
||||
<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 app-cards cards">
|
||||
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||
|
@ -23,6 +20,12 @@
|
|||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
@ -68,7 +71,7 @@ export default {
|
|||
self.previousPage = response.data.previous
|
||||
self.nextPage = response.data.next
|
||||
self.isLoading = false
|
||||
self.albums = response.data.results
|
||||
self.albums = [...self.albums, ...response.data.results]
|
||||
self.count = response.data.count
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
|
|
|
@ -22,7 +22,6 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import backend from '@/audio/backend'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
import TagsList from "@/components/tags/List"
|
||||
|
||||
|
@ -34,7 +33,6 @@ export default {
|
|||
},
|
||||
data () {
|
||||
return {
|
||||
backend: backend,
|
||||
initialAlbums: 30,
|
||||
showAllAlbums: true,
|
||||
}
|
||||
|
|
|
@ -4,9 +4,6 @@
|
|||
<slot name="title"></slot>
|
||||
<span class="ui tiny circular label">{{ count }}</span>
|
||||
</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 five app-cards cards">
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
@ -60,7 +63,7 @@ export default {
|
|||
self.previousPage = response.data.previous
|
||||
self.nextPage = response.data.next
|
||||
self.isLoading = false
|
||||
self.objects = response.data.results
|
||||
self.objects = [...self.objects, ...response.data.results]
|
||||
self.count = response.data.count
|
||||
}, error => {
|
||||
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>
|
||||
</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">
|
||||
</td>
|
||||
<td colspan="6">
|
||||
|
@ -30,7 +30,7 @@
|
|||
</template>
|
||||
</td>
|
||||
<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 }}
|
||||
</router-link>
|
||||
</td>
|
||||
|
|
|
@ -29,7 +29,6 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import backend from '@/audio/backend'
|
||||
import axios from 'axios'
|
||||
|
||||
import TrackRow from '@/components/audio/track/Row'
|
||||
|
@ -49,7 +48,6 @@ export default {
|
|||
},
|
||||
data () {
|
||||
return {
|
||||
backend: backend,
|
||||
loadMoreUrl: this.nextUrl,
|
||||
isLoadingMore: false,
|
||||
additionalTracks: []
|
||||
|
|
|
@ -4,13 +4,10 @@
|
|||
<slot name="title"></slot>
|
||||
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
|
||||
</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 :class="['item', itemClasses]" v-for="object in objects" :key="object.id">
|
||||
<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">
|
||||
<play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'tiny', 'orange', 'icon', 'button']" :track="object.track"></play-button>
|
||||
</div>
|
||||
|
@ -62,6 +59,12 @@
|
|||
<div class="ui loader"></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>
|
||||
</template>
|
||||
|
||||
|
@ -112,14 +115,16 @@ export default {
|
|||
self.nextPage = response.data.next
|
||||
self.isLoading = false
|
||||
self.count = response.data.count
|
||||
let newObjects
|
||||
if (self.isActivity) {
|
||||
// we have listening/favorites objects, not directly tracks
|
||||
self.objects = response.data.results
|
||||
newObjects = response.data.results
|
||||
} else {
|
||||
self.objects = response.data.results.map((r) => {
|
||||
newObjects = response.data.results.map((r) => {
|
||||
return {track: r}
|
||||
})
|
||||
}
|
||||
self.objects = [...self.objects, ...newObjects]
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
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">
|
||||
<option :value="c" v-for="c in f.choices">{{ sharedLabels.fields[f.id].choices[c] }}</option>
|
||||
</select>
|
||||
<content-form v-if="f.type === 'content'" v-model="f.value.text"></content-form>
|
||||
</div>
|
||||
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit">
|
||||
<translate translate-context="Content/Settings/Button.Label/Verb">Update settings</translate>
|
||||
|
@ -331,8 +332,12 @@ export default {
|
|||
settings: {
|
||||
success: false,
|
||||
errors: [],
|
||||
order: ["privacy_level"],
|
||||
order: ["summary", "privacy_level"],
|
||||
fields: {
|
||||
summary: {
|
||||
type: "content",
|
||||
initial: this.$store.state.auth.profile.summary || {text: '', content_type: 'text/markdown'},
|
||||
},
|
||||
privacy_level: {
|
||||
type: "dropdown",
|
||||
initial: this.$store.state.auth.profile.privacy_level,
|
||||
|
@ -459,7 +464,7 @@ export default {
|
|||
response => {
|
||||
logger.default.info("Password successfully changed")
|
||||
self.$router.push({
|
||||
name: "profile",
|
||||
name: "profile.overview",
|
||||
params: {
|
||||
username: self.$store.state.auth.username
|
||||
}
|
||||
|
@ -519,6 +524,9 @@ export default {
|
|||
this.settings.order.forEach(setting => {
|
||||
let conf = self.settings.fields[setting]
|
||||
s[setting] = conf.value
|
||||
if (setting === 'summary' && !conf.value.text) {
|
||||
s[setting] = null
|
||||
}
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
|
|
@ -117,7 +117,7 @@ export default {
|
|||
response => {
|
||||
logger.default.info("Successfully created account")
|
||||
self.$router.push({
|
||||
name: "profile",
|
||||
name: "profile.overview",
|
||||
params: {
|
||||
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>
|
||||
<router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: actor.full_username}}" v-if="admin" :title="actor.full_username">
|
||||
<actor-avatar v-if="avatar" :actor="actor" />
|
||||
{{ actor.full_username | truncate(30) }}
|
||||
<router-link :to="url" :title="actor.full_username">
|
||||
<template v-if="avatar"><actor-avatar :actor="actor" /> </template>{{ repr | truncate(30) }}
|
||||
</router-link>
|
||||
<span v-else :title="actor.full_username">
|
||||
<actor-avatar v-if="avatar" :actor="actor" />
|
||||
{{ actor.full_username | truncate(30) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -17,6 +12,23 @@ export default {
|
|||
actor: {type: Object},
|
||||
avatar: {type: Boolean, default: true},
|
||||
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>
|
||||
|
|
|
@ -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>
|
||||
<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>
|
||||
<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 cards">
|
||||
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||
|
@ -22,6 +18,12 @@
|
|||
v-for="library in libraries"
|
||||
:key="library.uuid"></library-card>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
@ -61,7 +63,7 @@ export default {
|
|||
self.previousPage = response.data.previous
|
||||
self.nextPage = response.data.next
|
||||
self.isLoading = false
|
||||
self.libraries = response.data.results
|
||||
self.libraries = [...self.libraries, ...response.data.results]
|
||||
self.$emit('loaded', self.libraries)
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import Vue from 'vue'
|
||||
|
||||
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('user-link', () => import(/* webpackChunkName: "common" */ "@/components/common/UserLink"))
|
||||
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('collapse-link', () => import(/* webpackChunkName: "common" */ "@/components/common/CollapseLink"))
|
||||
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 {}
|
||||
|
|
|
@ -172,6 +172,18 @@ export default {
|
|||
var self = this
|
||||
this.isLoading = true
|
||||
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 => {
|
||||
self.tracks = response.data.results
|
||||
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 albumPromise
|
||||
await artistPromise
|
||||
self.isLoadingAlbums = false
|
||||
self.isLoading = false
|
||||
}
|
||||
|
|
|
@ -50,7 +50,6 @@
|
|||
import _ from "@/lodash"
|
||||
import axios from "axios"
|
||||
import logger from "@/logging"
|
||||
import backend from "@/audio/backend"
|
||||
import AlbumCard from "@/components/audio/album/Card"
|
||||
import TrackTable from "@/components/audio/track/Table"
|
||||
import LibraryWidget from "@/components/federation/LibraryWidget"
|
||||
|
|
|
@ -78,7 +78,7 @@
|
|||
<i class="external icon"></i>
|
||||
<translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
|
||||
</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>
|
||||
<translate translate-context="Content/*/Button.Label/Verb">Search on Discogs</translate>
|
||||
</a>
|
||||
|
@ -200,12 +200,15 @@ export default {
|
|||
}
|
||||
},
|
||||
discogsUrl() {
|
||||
if (this.track.album) {
|
||||
return (
|
||||
"https://discogs.com/search/?type=release&title=" +
|
||||
encodeURI(this.track.album.title) + "&artist=" +
|
||||
encodeURI(this.track.artist.name) + "&track=" +
|
||||
encodeURI(this.track.title)
|
||||
)
|
||||
|
||||
}
|
||||
},
|
||||
downloadUrl() {
|
||||
let u = this.$store.getters["instance/absoluteUrl"](
|
||||
|
@ -242,8 +245,14 @@ export default {
|
|||
)
|
||||
},
|
||||
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
|
||||
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: {
|
||||
|
|
|
@ -49,10 +49,12 @@
|
|||
<router-link :to="{name: 'manage.library.tracks.detail', params: {id: scope.obj.id }}">{{ scope.obj.title }}</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="scope.obj.album">
|
||||
<router-link :to="{name: 'manage.library.albums.detail', params: {id: scope.obj.album.id }}">
|
||||
<i class="wrench icon"></i>
|
||||
</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>
|
||||
<router-link :to="{name: 'manage.library.artists.detail', params: {id: scope.obj.artist.id }}">
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
export default {
|
||||
methods: {
|
||||
getReportableObjs ({track, album, artist, playlist, account, library}) {
|
||||
getReportableObjs ({track, album, artist, playlist, account, library, channel}) {
|
||||
let reportableObjs = []
|
||||
if (account) {
|
||||
let accountLabel = this.$pgettext('*/Moderation/*/Verb', "Report @%{ username }…")
|
||||
|
|
|
@ -49,6 +49,9 @@ export default {
|
|||
other: this.$pgettext("Content/Moderation/Dropdown", "Other"),
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
label: this.$pgettext('Content/Account/*', 'Bio'),
|
||||
},
|
||||
},
|
||||
filters: {
|
||||
creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'),
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
<tr v-for="(plt, index) in plts" :key="plt.id">
|
||||
<td class="left aligned">{{ plt.index + 1}}</td>
|
||||
<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">
|
||||
</td>
|
||||
<td colspan="4">
|
||||
|
|
|
@ -3,9 +3,6 @@
|
|||
<h3 class="ui header">
|
||||
<slot name="title"></slot>
|
||||
</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 class="ui loader"></div>
|
||||
</div>
|
||||
|
@ -31,6 +28,12 @@
|
|||
</translate>
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
@ -50,7 +53,7 @@ export default {
|
|||
data () {
|
||||
return {
|
||||
objects: [],
|
||||
limit: 3,
|
||||
limit: this.filters.limit || 3,
|
||||
isLoading: false,
|
||||
errors: null,
|
||||
previousPage: null,
|
||||
|
@ -79,7 +82,7 @@ export default {
|
|||
self.previousPage = response.data.previous
|
||||
self.nextPage = response.data.next
|
||||
self.isLoading = false
|
||||
self.objects = response.data.results
|
||||
self.objects = [...self.objects, ...response.data.results]
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
|
|
|
@ -44,13 +44,22 @@ Vue.filter('ago', ago)
|
|||
export function secondsToObject (seconds) {
|
||||
let m = moment.duration(seconds, 'seconds')
|
||||
return {
|
||||
seconds: m.seconds(),
|
||||
minutes: m.minutes(),
|
||||
hours: parseInt(m.asHours())
|
||||
hours: m.hours()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
format = format || 'lll'
|
||||
return moment(date).format(format)
|
||||
|
|
|
@ -140,13 +140,33 @@ export default new Router({
|
|||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: "/@:username",
|
||||
...['/@:username', '/@:username@:domain'].map((path) => {
|
||||
return {
|
||||
path: path,
|
||||
name: "profile",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "core" */ "@/components/auth/Profile"),
|
||||
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",
|
||||
name: "favorites",
|
||||
|
@ -285,7 +305,7 @@ export default new Router({
|
|||
props: true
|
||||
},
|
||||
{
|
||||
path: "albums",
|
||||
path: "channels",
|
||||
name: "manage.library.albums",
|
||||
component: () =>
|
||||
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",
|
||||
redirect: "/"
|
||||
|
|
|
@ -140,6 +140,7 @@ export default {
|
|||
dispatch('ui/fetchPendingReviewReports', null, { root: true })
|
||||
}
|
||||
dispatch('favorites/fetch', null, { root: true })
|
||||
dispatch('channels/fetchSubscriptions', null, { root: true })
|
||||
dispatch('moderation/fetchContentFilters', null, { root: true })
|
||||
dispatch('playlists/fetchOwn', null, { root: true })
|
||||
}, (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 favorites from './favorites'
|
||||
import channels from './channels'
|
||||
import auth from './auth'
|
||||
import instance from './instance'
|
||||
import moderation from './moderation'
|
||||
|
@ -18,6 +19,7 @@ export default new Vuex.Store({
|
|||
modules: {
|
||||
ui,
|
||||
auth,
|
||||
channels,
|
||||
favorites,
|
||||
instance,
|
||||
moderation,
|
||||
|
@ -76,21 +78,24 @@ export default new Vuex.Store({
|
|||
mbid: track.artist.mbid,
|
||||
name: track.artist.name
|
||||
}
|
||||
return {
|
||||
let data = {
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
mbid: track.mbid,
|
||||
uploads: track.uploads,
|
||||
listen_url: track.listen_url,
|
||||
album: {
|
||||
artist: artist,
|
||||
}
|
||||
if (track.album) {
|
||||
data.album = {
|
||||
id: track.album.id,
|
||||
title: track.album.title,
|
||||
mbid: track.album.mbid,
|
||||
cover: track.album.cover,
|
||||
artist: artist
|
||||
},
|
||||
artist: artist
|
||||
}
|
||||
}
|
||||
return data
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -112,6 +112,12 @@ export default {
|
|||
|
||||
let instanceUrl = state.instanceUrl || getDefaultUrl()
|
||||
return instanceUrl + relativeUrl
|
||||
},
|
||||
domain: (state) => {
|
||||
let url = state.instanceUrl
|
||||
let parser = document.createElement("a")
|
||||
parser.href = url
|
||||
return parser.hostname
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
|
|
|
@ -69,6 +69,7 @@
|
|||
@import "~fomantic-ui-css/components/sidebar.css";
|
||||
@import "~fomantic-ui-css/components/sticky.css";
|
||||
@import "~fomantic-ui-css/components/tab.css";
|
||||
@import "~fomantic-ui-css/components/text.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) {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
@ -387,6 +398,9 @@ input + .help {
|
|||
.ui.small.divider {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.ui.very.small.divider {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.queue.segment.player-focused #queue-grid #player {
|
||||
@include media("<desktop") {
|
||||
|
@ -431,6 +445,12 @@ input + .help {
|
|||
display: flex;
|
||||
width: $card-width;
|
||||
height: $card-hight;
|
||||
.content:not(.extra) {
|
||||
padding: 0.5em 1em 0;
|
||||
}
|
||||
.content.extra {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
.head-image {
|
||||
height: $card-width;
|
||||
background-size: cover !important;
|
||||
|
@ -449,6 +469,10 @@ input + .help {
|
|||
margin: 0.5em;
|
||||
|
||||
}
|
||||
&.padded {
|
||||
margin: 0.5em;
|
||||
border-radius: 0.25em !important;
|
||||
}
|
||||
&.squares {
|
||||
display: block !important;
|
||||
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/_dark.scss";
|
||||
|
|
|
@ -77,7 +77,7 @@
|
|||
:class="['ui', {loading: isLoading}, 'basic button']"
|
||||
:action="remove">
|
||||
<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">
|
||||
<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>
|
||||
|
@ -109,7 +109,7 @@
|
|||
{{ object.title }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr v-if="object.album">
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.library.albums.detail', params: {id: object.album.id }}">
|
||||
<translate translate-context="*/*/*">Album</translate>
|
||||
|
@ -130,7 +130,7 @@
|
|||
{{ object.artist.name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr v-if="object.album">
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.library.artists.detail', params: {id: object.album.artist.id }}">
|
||||
<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>
|
||||
<span
|
||||
v-if="scope.obj.track.album"
|
||||
class="discrete link"
|
||||
@click="addSearchToken('album', scope.obj.track.album.title)"
|
||||
:title="scope.obj.track.album.title"
|
||||
|
|
Loading…
Reference in New Issue