diff --git a/api/config/api_urls.py b/api/config/api_urls.py index 7339df445d43653e7580220e528dd89a9b24b125..b50066f3dbdada8e01c80a909a264bea13bf34d0 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -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 = [ diff --git a/api/funkwhale_api/audio/serializers.py b/api/funkwhale_api/audio/serializers.py index 0c9732efeed361263393c6837d1e72b245243995..2d46b8cce4115507ec61a3035c50c68e7c11191c 100644 --- a/api/funkwhale_api/audio/serializers.py +++ b/api/funkwhale_api/audio/serializers.py @@ -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"] = [ diff --git a/api/funkwhale_api/audio/views.py b/api/funkwhale_api/audio/views.py index 1f40dd0a613943cd60b9c91375461f79ff62c953..09ae6d0cfafd1e644ccab0078f8be3ef41f9ddef 100644 --- a/api/funkwhale_api/audio/views.py +++ b/api/funkwhale_api/audio/views.py @@ -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) diff --git a/api/funkwhale_api/common/filters.py b/api/funkwhale_api/common/filters.py index 953904bfa24480e0368def211ac4acbebea6e23b..df27a312aa6b48424814b30ab7cd2c031d2f30f9 100644 --- a/api/funkwhale_api/common/filters.py +++ b/api/funkwhale_api/common/filters.py @@ -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() diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py index 902fb0f5c5b132384582985772cf7112e542a14f..ec2c1d1f83975210d0e2698ecf40ad96622c33ac 100644 --- a/api/funkwhale_api/common/models.py +++ b/api/funkwhale_api/common/models.py @@ -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( diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index 51efce01933c5ec6a8dcfb6ce8b7a7345875fca7..5b9b5bf2d77c1af1125609c7bd785427230243a1 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -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 diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py index 58758773de02be354442f3e686b1f766cf8b687c..1611d8e63edad9d8b5b28efc25954947b824893a 100644 --- a/api/funkwhale_api/common/views.py +++ b/api/funkwhale_api/common/views.py @@ -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) diff --git a/api/funkwhale_api/federation/api_serializers.py b/api/funkwhale_api/federation/api_serializers.py index 2a1143ec70bc63c2cc10408643d60aa588431ffb..bd8bfcf01375a311f26303f46e8c09f1a1257386 100644 --- a/api/funkwhale_api/federation/api_serializers.py +++ b/api/funkwhale_api/federation/api_serializers.py @@ -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 diff --git a/api/funkwhale_api/federation/api_urls.py b/api/funkwhale_api/federation/api_urls.py index 4f0471b171b2a699200352babb1fcfd0e27b908d..df5bfb2f0396af3da21357aba967d51d941cad9a 100644 --- a/api/funkwhale_api/federation/api_urls.py +++ b/api/funkwhale_api/federation/api_urls.py @@ -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 diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index 395290be96bd5e56c616dd3a710f5f1e01329166..7a39218f9f06343c5cecc2b6187c3af50662e396 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -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) + ) + ) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index c057799a6f7f6ab4239fd3cf574dd9749eb082b1..6501e93cf8c9bf13c36803a3b2281d2c042e3892 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -253,7 +253,6 @@ class APIActorSerializer(serializers.ModelSerializer): class Meta: model = models.Actor fields = [ - "id", "fid", "url", "creation_date", diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index c44175e35a6b6c3429e4fa6c3fe0089d6d7d1d0f..a5bc37275ccaa9aa1a7587a7dc28c4c03b329581 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -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 diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 7b367857ef7418ca9767f6648c68043bd6014bc0..4113c3d7a56f3eee70cc73628bb0341eb45ac6f0 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -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 diff --git a/api/funkwhale_api/subsonic/renderers.py b/api/funkwhale_api/subsonic/renderers.py index 92c4508d4d7016a8a22e4d15719c2c7b3e13bc5b..7390def8b54c39e734ead6a625e7de837ad25ba7 100644 --- a/api/funkwhale_api/subsonic/renderers.py +++ b/api/funkwhale_api/subsonic/renderers.py @@ -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 diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 72ab2afd669ccf8d1ec18e6428f9abb99d40a087..16212e97527ee87b4eaa082fdedd27d2739d5f7e 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -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") diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 59986fdaa84ac107ce7102a39f1de9c4217fe64a..1e919f7b711eb139b688601e1ef8d7a4da1828f1 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -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() diff --git a/api/tests/audio/test_serializers.py b/api/tests/audio/test_serializers.py index f0979c0e08035bc796a776ba3313e564a47abaf2..9ef01c908aadd657eee08b19268315e4aa53107e 100644 --- a/api/tests/audio/test_serializers.py +++ b/api/tests/audio/test_serializers.py @@ -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, + } ], } diff --git a/api/tests/audio/test_views.py b/api/tests/audio/test_views.py index 7a9fb477b122282274995321a7a03d6d11a56041..9b9cf51f105c1e49023d39ec3f1286cf5295f926 100644 --- a/api/tests/audio/test_views.py +++ b/api/tests/audio/test_views.py @@ -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): diff --git a/api/tests/common/test_filters.py b/api/tests/common/test_filters.py index 138f6ca5d6d51b284658be015440d1bdbb9c2388..b45dcf1115063dafe3dc57f8427e9d8fe4763f03 100644 --- a/api/tests/common/test_filters.py +++ b/api/tests/common/test_filters.py @@ -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, diff --git a/api/tests/common/test_views.py b/api/tests/common/test_views.py index 358d85736bdcb37251093acb6e1d33dcd1603da1..761a2940e6275f00e6c94dff2a07e42d897209a8 100644 --- a/api/tests/common/test_views.py +++ b/api/tests/common/test_views.py @@ -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 diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py index b81006386ac86b79a3ac34f37e19ffb89c153c40..06a309e15ac1882ff895248d326d02960b6543f5 100644 --- a/api/tests/favorites/test_favorites.py +++ b/api/tests/favorites/test_favorites.py @@ -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 diff --git a/api/tests/federation/test_api_serializers.py b/api/tests/federation/test_api_serializers.py index ab621abde4f764739c1c6b7c69654a1acbb7fb7e..5ac4d278b74494249b8d8b03f7af0a6cc8154a2a 100644 --- a/api/tests/federation/test_api_serializers.py +++ b/api/tests/federation/test_api_serializers.py @@ -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 diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py index 1795a65e418d91c68c33604434733ddfe71d99c7..73dd6b80ad8ec057437ceda9e859aff7ad602478 100644 --- a/api/tests/federation/test_api_views.py +++ b/api/tests/federation/test_api_views.py @@ -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 diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index ca7d47d6fd8e656a117d7e5040defbf394b51862..f70313f3f98b19f1f917d4437e6fef9f3991812e 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -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 diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 6ecea6306a65c7321d893fb32befbb27a36b7c3c..6df25ea634abba206b3965293474357c3d5e838f 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -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 diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py index 2be64b2bb864ef4b4fbbb5bb98ede9fd25b9ac7a..1b7fca928da379fc946bcb114836a556fb0a7ff2 100644 --- a/api/tests/playlists/test_views.py +++ b/api/tests/playlists/test_views.py @@ -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 diff --git a/api/tests/radios/test_api.py b/api/tests/radios/test_api.py index 02d0dc954a72608f7e3da90a5de0af07120a1e2b..0b0ee618231b61cfcca707765974321705840ef0 100644 --- a/api/tests/radios/test_api.py +++ b/api/tests/radios/test_api.py @@ -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"] == [] diff --git a/front/package.json b/front/package.json index b16b83de0061bc98cec209178ebbb949f38f9f0f..d05c57d10861009e373e32b1b090ad1b8da1a6e6 100644 --- a/front/package.json +++ b/front/package.json @@ -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", diff --git a/front/src/App.vue b/front/src/App.vue index b2de3597e83fa9308c75a4631685773d7ccd257f..b5b6eaa0f015be889accd0a75faff76bdb482647 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -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 }, diff --git a/front/src/EmbedFrame.vue b/front/src/EmbedFrame.vue index 8c684d9225344d5877af09165f0665660427d438..b302c7e2d5af212109f19e95bb8b1b913e293893 100644 --- a/front/src/EmbedFrame.vue +++ b/front/src/EmbedFrame.vue @@ -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) } }) diff --git a/front/src/components/Queue.vue b/front/src/components/Queue.vue index f03b12c6273793a9b596cebd628b5f5c3fb4ef27..23f5539b0a2867b0582a6549ce53251297c4aeb0 100644 --- a/front/src/components/Queue.vue +++ b/front/src/components/Queue.vue @@ -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)"> diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index fdf36f88a36f75c08654f3cc407dd76ca09ac795..fc6fb648cfec0a1546494e3bec42811a5895c5ff 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -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, diff --git a/front/src/components/audio/ChannelCard.vue b/front/src/components/audio/ChannelCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..6cb1ae4165ede5954a005eb0e081d853e1366bad --- /dev/null +++ b/front/src/components/audio/ChannelCard.vue @@ -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> diff --git a/front/src/components/audio/ChannelEntries.vue b/front/src/components/audio/ChannelEntries.vue new file mode 100644 index 0000000000000000000000000000000000000000..99ff45942063991d4ce5bdb521c0b4ea9aa2636c --- /dev/null +++ b/front/src/components/audio/ChannelEntries.vue @@ -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> diff --git a/front/src/components/audio/ChannelEntryCard.vue b/front/src/components/audio/ChannelEntryCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..1bdbd6839420f55a63586ec8806a04d663bb08c3 --- /dev/null +++ b/front/src/components/audio/ChannelEntryCard.vue @@ -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> diff --git a/front/src/components/audio/ChannelSerieCard.vue b/front/src/components/audio/ChannelSerieCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..7d4e246e510c31850e335ae4699dc4fb63c9072e --- /dev/null +++ b/front/src/components/audio/ChannelSerieCard.vue @@ -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> diff --git a/front/src/components/audio/ChannelSeries.vue b/front/src/components/audio/ChannelSeries.vue new file mode 100644 index 0000000000000000000000000000000000000000..0de82fbd3538b97f3f23e4589f8f6284059694d0 --- /dev/null +++ b/front/src/components/audio/ChannelSeries.vue @@ -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> diff --git a/front/src/components/audio/ChannelsWidget.vue b/front/src/components/audio/ChannelsWidget.vue new file mode 100644 index 0000000000000000000000000000000000000000..39fe5ffe1139a90506f498d4bc9ed6d1cef114f5 --- /dev/null +++ b/front/src/components/audio/ChannelsWidget.vue @@ -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> diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 4c9127f4ef3a7bb937c7124883e8922e8c1aca99..822593abae9fcf57de2c17b07237227954f94312 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -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,19 +703,22 @@ 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({ - title: this.currentTrack.title, - artist: this.currentTrack.artist.name, - album: this.currentTrack.album.title, - 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' }, - { src: this.currentTrack.album.cover.original, sizes: '256x256', type: 'image/png' }, - { src: this.currentTrack.album.cover.original, sizes: '384x384', type: 'image/png' }, - { src: this.currentTrack.album.cover.original, sizes: '512x512', type: 'image/png' }, + let metatata = { + title: this.currentTrack.title, + artist: this.currentTrack.artist.name, + } + 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' }, + { src: this.currentTrack.album.cover.original, sizes: '256x256', type: 'image/png' }, + { src: this.currentTrack.album.cover.original, sizes: '384x384', type: 'image/png' }, + { src: this.currentTrack.album.cover.original, sizes: '512x512', type: 'image/png' }, ] - }); + } + navigator.mediaSession.metadata = new MediaMetadata(metadata); } }, immediate: false diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue index ed18805aa83972406f73d04c909ea1db644db031..51e78f1a3faac90688c6026a0d18ec69dcca7296 100644 --- a/front/src/components/audio/SearchBar.vue +++ b/front/src/components/audio/SearchBar.vue @@ -109,7 +109,11 @@ export default { return r.title }, getDescription (r) { - return `${r.album.artist.name} - ${r.album.title}` + if (r.album) { + return `${r.album.artist.name} - ${r.album.title}` + } else { + return r.artist.name + } }, getId (t) { return t.id diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue index b0d381a68ec907c1e7f97272db622e202abf2766..fc9cebaeb0c51366360bd7b78ddf357979606855 100644 --- a/front/src/components/audio/album/Widget.vue +++ b/front/src/components/audio/album/Widget.vue @@ -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 diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue index bcb66e95f941a4670591c85b5d8e7e28a940e7dd..f73b7dbb121baf214a5a278759a232d2c07304b7 100644 --- a/front/src/components/audio/artist/Card.vue +++ b/front/src/components/audio/artist/Card.vue @@ -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, } diff --git a/front/src/components/audio/artist/Widget.vue b/front/src/components/audio/artist/Widget.vue index 4b80d3f079e9fd99a143f37c0dba4b20c6571259..d3e946a841dfe1bd80b4c0cdc9f43be063fdc367 100644 --- a/front/src/components/audio/artist/Widget.vue +++ b/front/src/components/audio/artist/Widget.vue @@ -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 diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue index 1f5b23a6c44b5d056dee78b34649a7ca156451c2..00c55c696ac1e2d41d0036e1c79938136b034672 100644 --- a/front/src/components/audio/track/Row.vue +++ b/front/src/components/audio/track/Row.vue @@ -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> diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue index b70d84c62f594b49265fb8b6aff116c234bc27eb..14515b4c324ece2abe16db9052b2cb0fb3e95b57 100644 --- a/front/src/components/audio/track/Table.vue +++ b/front/src/components/audio/track/Table.vue @@ -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: [] diff --git a/front/src/components/audio/track/Widget.vue b/front/src/components/audio/track/Widget.vue index 235659564cb63f539bab5e7e4400148743f74386..6895a8712730b2ad52d5951ae19fda4c788954e0 100644 --- a/front/src/components/audio/track/Widget.vue +++ b/front/src/components/audio/track/Widget.vue @@ -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 diff --git a/front/src/components/auth/Profile.vue b/front/src/components/auth/Profile.vue deleted file mode 100644 index 278f62ec0092492c733d524f6c4773c5b7575b9e..0000000000000000000000000000000000000000 --- a/front/src/components/auth/Profile.vue +++ /dev/null @@ -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> diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 269e7ac29d2d183b4aa8d5502c866ced9aa66e1b..ff1e78b3d09070f68f4a0edc4879fd916e36e026 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -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 } diff --git a/front/src/components/auth/SignupForm.vue b/front/src/components/auth/SignupForm.vue index 6374ef3a7106cde1c442ccc1c5a8ef9b6c6f23d9..a9c60f6b988e69ec58916c8f26a17650dea9b00c 100644 --- a/front/src/components/auth/SignupForm.vue +++ b/front/src/components/auth/SignupForm.vue @@ -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 } diff --git a/front/src/components/channels/SubscribeButton.vue b/front/src/components/channels/SubscribeButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..69eef503367203d67092d6620d9c1993da2849c8 --- /dev/null +++ b/front/src/components/channels/SubscribeButton.vue @@ -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> diff --git a/front/src/components/common/ActorLink.vue b/front/src/components/common/ActorLink.vue index 7a3eefd33283e373a9c0695c4f7d8132ac3c0d84..04eff700a44cda523e0d17e0b458542b4a6ec3c5 100644 --- a/front/src/components/common/ActorLink.vue +++ b/front/src/components/common/ActorLink.vue @@ -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> diff --git a/front/src/components/common/ContentForm.vue b/front/src/components/common/ContentForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..0ec2fa941320dcbaf3cad921c7aafe84c55be227 --- /dev/null +++ b/front/src/components/common/ContentForm.vue @@ -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> diff --git a/front/src/components/common/HumanDuration.vue b/front/src/components/common/HumanDuration.vue new file mode 100644 index 0000000000000000000000000000000000000000..ef70ff400f0aed661667edda03f84ba8fb70f35e --- /dev/null +++ b/front/src/components/common/HumanDuration.vue @@ -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> diff --git a/front/src/components/common/RenderedDescription.vue b/front/src/components/common/RenderedDescription.vue new file mode 100644 index 0000000000000000000000000000000000000000..8d8e1db43269615c1faeaa5e310ea661e0b38962 --- /dev/null +++ b/front/src/components/common/RenderedDescription.vue @@ -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> diff --git a/front/src/components/federation/LibraryWidget.vue b/front/src/components/federation/LibraryWidget.vue index 3bdc5208937819ff97d851be32476db1bcd65522..bab61858ae57dfd862972f07679e23c027e0390d 100644 --- a/front/src/components/federation/LibraryWidget.vue +++ b/front/src/components/federation/LibraryWidget.vue @@ -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 diff --git a/front/src/components/globals.js b/front/src/components/globals.js index 2644324696b46570a3e60a024ba79620fc8f86f4..289fd625e5c53246c61f5f6e5798638fff557d96 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -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 {} diff --git a/front/src/components/library/ArtistBase.vue b/front/src/components/library/ArtistBase.vue index 6d9b971ac6ba5cc016c301dac11c7188c80b9f03..d7ff13a6615f7ef66f8c9c005c81f661efbab17d 100644 --- a/front/src/components/library/ArtistBase.vue +++ b/front/src/components/library/ArtistBase.vue @@ -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 } diff --git a/front/src/components/library/ArtistDetail.vue b/front/src/components/library/ArtistDetail.vue index 725bedac0e7c67d6b83a32a3314de41892867cc7..5b2021f98c413b09a51a431bd0267c0e3b28b3c0 100644 --- a/front/src/components/library/ArtistDetail.vue +++ b/front/src/components/library/ArtistDetail.vue @@ -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" diff --git a/front/src/components/library/TrackBase.vue b/front/src/components/library/TrackBase.vue index 82f3aa8a7b4c548b9cc7a38ac43f3f1f0f5684e3..ea20dc44ef67fee91ade50328ee44b0d6d65affc 100644 --- a/front/src/components/library/TrackBase.vue +++ b/front/src/components/library/TrackBase.vue @@ -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() { - return ( - "https://discogs.com/search/?type=release&title=" + - encodeURI(this.track.album.title) + "&artist=" + - encodeURI(this.track.artist.name) + "&track=" + - encodeURI(this.track.title) - ) + 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>') - return this.$gettextInterpolate(msg, {album: this.track.album.title, artist: this.track.artist.name, albumUrl: this.albumUrl, artistUrl: this.artistUrl}) + 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: { diff --git a/front/src/components/manage/library/TracksTable.vue b/front/src/components/manage/library/TracksTable.vue index 91c07c81b66d22fd892dde3a10e7b2b366a4b3fb..4fec4c500fb84ae9ee254248e020dde13d91a529 100644 --- a/front/src/components/manage/library/TracksTable.vue +++ b/front/src/components/manage/library/TracksTable.vue @@ -49,10 +49,12 @@ <router-link :to="{name: 'manage.library.tracks.detail', params: {id: scope.obj.id }}">{{ scope.obj.title }}</router-link> </td> <td> - <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 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 }}"> diff --git a/front/src/components/mixins/Report.vue b/front/src/components/mixins/Report.vue index 29eabb996160b30918ee3fe46e25418f18628753..8746fa008adedbf98af223508bec17da4d55490d 100644 --- a/front/src/components/mixins/Report.vue +++ b/front/src/components/mixins/Report.vue @@ -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 }…") diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue index c56d6667aa45e4e270fcebc137add133bcfaad93..8b3d3a2a564c4aed2fc346e5993a11001ba6964d 100644 --- a/front/src/components/mixins/Translations.vue +++ b/front/src/components/mixins/Translations.vue @@ -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'), diff --git a/front/src/components/playlists/Editor.vue b/front/src/components/playlists/Editor.vue index 2ebd9614babde74840fc2b49e9594941de424a79..59f5ea809a6424e830f9044d77dd1ea82aa8064b 100644 --- a/front/src/components/playlists/Editor.vue +++ b/front/src/components/playlists/Editor.vue @@ -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"> diff --git a/front/src/components/playlists/Widget.vue b/front/src/components/playlists/Widget.vue index 3d58ef1a9b040fe76070d96098b836096af2e99e..22c607f03f233587dd4c0555c580543c75ac4afa 100644 --- a/front/src/components/playlists/Widget.vue +++ b/front/src/components/playlists/Widget.vue @@ -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 diff --git a/front/src/filters.js b/front/src/filters.js index 9667426191e6fae0aa76ba9eba47dcad023730fe..91d4e455ca94661a3daa97daa55c423a6fa5c7c2 100644 --- a/front/src/filters.js +++ b/front/src/filters.js @@ -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) diff --git a/front/src/router/index.js b/front/src/router/index.js index 3871f7fe7103ddb17bb6931a0eebf5fc8058fe7e..9a573b2b759c347acc27522d7c39546912131002 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -140,13 +140,33 @@ export default new Router({ ), props: true }, - { - path: "/@:username", - name: "profile", - component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/Profile"), - props: true - }, + ...['/@:username', '/@:username@:domain'].map((path) => { + return { + path: path, + name: "profile", + component: () => + 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: "/" diff --git a/front/src/store/auth.js b/front/src/store/auth.js index 93ba3b4318d2d708d8560bc9a1a3edd9701232db..a87d8ba7820ca35fb74c5f7ae7799a9615bfb39b 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -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) => { diff --git a/front/src/store/channels.js b/front/src/store/channels.js new file mode 100644 index 0000000000000000000000000000000000000000..0f2c8b23c0f9898fa900320201e2079164e6fa31 --- /dev/null +++ b/front/src/store/channels.js @@ -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}) + }) + }) + } + } +} diff --git a/front/src/store/index.js b/front/src/store/index.js index e10f97b51afea73078d845a82871284cbf48731f..c098aa1ec5436ff88e867a7e359efed0a8f21336 100644 --- a/front/src/store/index.js +++ b/front/src/store/index.js @@ -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 }) } } diff --git a/front/src/store/instance.js b/front/src/store/instance.js index 8fa74afb4c558a73b2e92a097ec186c1993ec77d..b98dc54a65a699227f5b33276a5c8ca7c4fc6788 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -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: { diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index c48bb0dc8481eba9066340ded79411cadfbd4dcc..3ffea35286c322ba6916310fc2ee2b44358ef23c 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -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"; diff --git a/front/src/views/admin/library/TrackDetail.vue b/front/src/views/admin/library/TrackDetail.vue index 54223543ac4832e6bab9c08ed82232a3d9ca5a74..f01457b1246da444c79a1b16f065ad50ff488d28 100644 --- a/front/src/views/admin/library/TrackDetail.vue +++ b/front/src/views/admin/library/TrackDetail.vue @@ -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> diff --git a/front/src/views/auth/ProfileActivity.vue b/front/src/views/auth/ProfileActivity.vue new file mode 100644 index 0000000000000000000000000000000000000000..1db7ea2a0fa807814110b46f1e18906110d208e8 --- /dev/null +++ b/front/src/views/auth/ProfileActivity.vue @@ -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> diff --git a/front/src/views/auth/ProfileBase.vue b/front/src/views/auth/ProfileBase.vue new file mode 100644 index 0000000000000000000000000000000000000000..927446649304225179b835f91786bb926aa6eb75 --- /dev/null +++ b/front/src/views/auth/ProfileBase.vue @@ -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> diff --git a/front/src/views/auth/ProfileOverview.vue b/front/src/views/auth/ProfileOverview.vue new file mode 100644 index 0000000000000000000000000000000000000000..cf65e42b9fd230d68a99f0b6e09b70ecc5db5d4f --- /dev/null +++ b/front/src/views/auth/ProfileOverview.vue @@ -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> diff --git a/front/src/views/channels/DetailBase.vue b/front/src/views/channels/DetailBase.vue new file mode 100644 index 0000000000000000000000000000000000000000..e61aab28ed6c54642a6818812a118726cf3c9bfe --- /dev/null +++ b/front/src/views/channels/DetailBase.vue @@ -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> diff --git a/front/src/views/channels/DetailEpisodes.vue b/front/src/views/channels/DetailEpisodes.vue new file mode 100644 index 0000000000000000000000000000000000000000..ab5920eb462ca520122d7f6f301c7e82120267ae --- /dev/null +++ b/front/src/views/channels/DetailEpisodes.vue @@ -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> diff --git a/front/src/views/channels/DetailOverview.vue b/front/src/views/channels/DetailOverview.vue new file mode 100644 index 0000000000000000000000000000000000000000..9596e28d4d3a18c2d37e1d81e990f1fe06938865 --- /dev/null +++ b/front/src/views/channels/DetailOverview.vue @@ -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> diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue index 736421e57ef44bb0916cbe68187d954aeb91a498..a00442d1a73974adfe0bf0c35548ee0b0cc4a75c 100644 --- a/front/src/views/content/libraries/FilesTable.vue +++ b/front/src/views/content/libraries/FilesTable.vue @@ -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"