From 99a37dcb7a531947ade4b164314ab29b79196b7e Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Tue, 17 Jul 2018 11:09:13 +0000 Subject: [PATCH] Resolve "UX, UI : Browse Library" --- api/config/settings/local.py | 1 + api/funkwhale_api/favorites/serializers.py | 13 +- api/funkwhale_api/favorites/views.py | 20 ++- api/funkwhale_api/history/serializers.py | 18 ++- api/funkwhale_api/history/views.py | 31 ++++- api/funkwhale_api/playlists/filters.py | 16 ++- api/funkwhale_api/playlists/models.py | 29 +++++ api/funkwhale_api/playlists/serializers.py | 36 +++++- api/funkwhale_api/playlists/views.py | 2 + api/funkwhale_api/users/serializers.py | 14 +- api/tests/favorites/test_favorites.py | 18 ++- api/tests/favorites/test_views.py | 13 ++ api/tests/history/test_views.py | 13 ++ api/tests/playlists/test_serializers.py | 37 ++++++ front/src/App.vue | 3 + front/src/components/About.vue | 6 +- front/src/components/Sidebar.vue | 14 +- front/src/components/audio/PlayButton.vue | 20 ++- front/src/components/audio/album/Widget.vue | 134 ++++++++++++++++++++ front/src/components/audio/track/Row.vue | 2 +- front/src/components/audio/track/Widget.vue | 124 ++++++++++++++++++ front/src/components/auth/Signup.vue | 5 +- front/src/components/common/Duration.vue | 22 ++++ front/src/components/common/UserLink.vue | 34 +++++ front/src/components/globals.js | 8 ++ front/src/components/library/Home.vue | 47 ++++--- front/src/components/library/Radios.vue | 16 +++ front/src/components/playlists/Card.vue | 68 ++++++++-- front/src/components/playlists/Widget.vue | 77 +++++++++++ front/src/filters.js | 10 ++ front/src/router/index.js | 8 +- front/src/utils/color.js | 12 ++ front/src/views/library/MusicRequest.vue | 32 +++++ front/src/views/playlists/Detail.vue | 17 +-- front/src/views/playlists/List.vue | 2 +- 35 files changed, 832 insertions(+), 90 deletions(-) create mode 100644 api/tests/favorites/test_views.py create mode 100644 api/tests/history/test_views.py create mode 100644 front/src/components/audio/album/Widget.vue create mode 100644 front/src/components/audio/track/Widget.vue create mode 100644 front/src/components/common/Duration.vue create mode 100644 front/src/components/common/UserLink.vue create mode 100644 front/src/components/playlists/Widget.vue create mode 100644 front/src/utils/color.js create mode 100644 front/src/views/library/MusicRequest.vue diff --git a/api/config/settings/local.py b/api/config/settings/local.py index 9f0119ce..b8df4bdb 100644 --- a/api/config/settings/local.py +++ b/api/config/settings/local.py @@ -39,6 +39,7 @@ DEBUG_TOOLBAR_CONFIG = { "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"], "SHOW_TEMPLATE_CONTEXT": True, "SHOW_TOOLBAR_CALLBACK": lambda request: True, + "JQUERY_URL": "", } # django-extensions diff --git a/api/funkwhale_api/favorites/serializers.py b/api/funkwhale_api/favorites/serializers.py index 3cafb80f..16171aa3 100644 --- a/api/funkwhale_api/favorites/serializers.py +++ b/api/funkwhale_api/favorites/serializers.py @@ -2,8 +2,8 @@ from rest_framework import serializers from funkwhale_api.activity import serializers as activity_serializers -from funkwhale_api.music.serializers import TrackActivitySerializer -from funkwhale_api.users.serializers import UserActivitySerializer +from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer +from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer from . import models @@ -26,6 +26,15 @@ class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer): class UserTrackFavoriteSerializer(serializers.ModelSerializer): + track = TrackSerializer(read_only=True) + user = UserBasicSerializer(read_only=True) + + class Meta: + model = models.TrackFavorite + fields = ("id", "user", "track", "creation_date") + + +class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer): class Meta: model = models.TrackFavorite fields = ("id", "track", "creation_date") diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py index 4d1c1e75..61b5bee6 100644 --- a/api/funkwhale_api/favorites/views.py +++ b/api/funkwhale_api/favorites/views.py @@ -1,9 +1,10 @@ from rest_framework import mixins, status, viewsets from rest_framework.decorators import list_route +from rest_framework.permissions import IsAuthenticatedOrReadOnly from rest_framework.response import Response from funkwhale_api.activity import record -from funkwhale_api.common.permissions import ConditionalAuthentication +from funkwhale_api.common import fields, permissions from funkwhale_api.music.models import Track from . import models, serializers @@ -18,7 +19,17 @@ class TrackFavoriteViewSet( serializer_class = serializers.UserTrackFavoriteSerializer queryset = models.TrackFavorite.objects.all() - permission_classes = [ConditionalAuthentication] + permission_classes = [ + permissions.ConditionalAuthentication, + permissions.OwnerPermission, + IsAuthenticatedOrReadOnly, + ] + owner_checks = ["write"] + + def get_serializer_class(self): + if self.request.method.lower() in ["head", "get", "options"]: + return serializers.UserTrackFavoriteSerializer + return serializers.UserTrackFavoriteWriteSerializer def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) @@ -32,7 +43,10 @@ class TrackFavoriteViewSet( ) def get_queryset(self): - return self.queryset.filter(user=self.request.user) + queryset = super().get_queryset() + return queryset.filter( + fields.privacy_level_query(self.request.user, "user__privacy_level") + ) def perform_create(self, serializer): track = Track.objects.get(pk=serializer.data["track"]) diff --git a/api/funkwhale_api/history/serializers.py b/api/funkwhale_api/history/serializers.py index e4932279..2254aee8 100644 --- a/api/funkwhale_api/history/serializers.py +++ b/api/funkwhale_api/history/serializers.py @@ -1,8 +1,8 @@ from rest_framework import serializers from funkwhale_api.activity import serializers as activity_serializers -from funkwhale_api.music.serializers import TrackActivitySerializer -from funkwhale_api.users.serializers import UserActivitySerializer +from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer +from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer from . import models @@ -25,6 +25,20 @@ class ListeningActivitySerializer(activity_serializers.ModelSerializer): class ListeningSerializer(serializers.ModelSerializer): + track = TrackSerializer(read_only=True) + user = UserBasicSerializer(read_only=True) + + class Meta: + model = models.Listening + fields = ("id", "user", "track", "creation_date") + + def create(self, validated_data): + validated_data["user"] = self.context["user"] + + return super().create(validated_data) + + +class ListeningWriteSerializer(serializers.ModelSerializer): class Meta: model = models.Listening fields = ("id", "user", "track", "creation_date") diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py index e104a2aa..6c7ef399 100644 --- a/api/funkwhale_api/history/views.py +++ b/api/funkwhale_api/history/views.py @@ -1,17 +1,36 @@ -from rest_framework import mixins, permissions, viewsets +from rest_framework import mixins, viewsets +from rest_framework.permissions import IsAuthenticatedOrReadOnly from funkwhale_api.activity import record +from funkwhale_api.common import fields, permissions from . import models, serializers class ListeningViewSet( - mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, ): serializer_class = serializers.ListeningSerializer - queryset = models.Listening.objects.all() - permission_classes = [permissions.IsAuthenticated] + queryset = ( + models.Listening.objects.all() + .select_related("track__artist", "track__album__artist", "user") + .prefetch_related("track__files") + ) + permission_classes = [ + permissions.ConditionalAuthentication, + permissions.OwnerPermission, + IsAuthenticatedOrReadOnly, + ] + owner_checks = ["write"] + + def get_serializer_class(self): + if self.request.method.lower() in ["head", "get", "options"]: + return serializers.ListeningSerializer + return serializers.ListeningWriteSerializer def perform_create(self, serializer): r = super().perform_create(serializer) @@ -20,7 +39,9 @@ class ListeningViewSet( def get_queryset(self): queryset = super().get_queryset() - return queryset.filter(user=self.request.user) + return queryset.filter( + fields.privacy_level_query(self.request.user, "user__privacy_level") + ) def get_serializer_context(self): context = super().get_serializer_context() diff --git a/api/funkwhale_api/playlists/filters.py b/api/funkwhale_api/playlists/filters.py index ae9f0226..144b0f04 100644 --- a/api/funkwhale_api/playlists/filters.py +++ b/api/funkwhale_api/playlists/filters.py @@ -1,3 +1,4 @@ +from django.db.models import Count from django_filters import rest_framework as filters from funkwhale_api.music import utils @@ -7,10 +8,23 @@ from . import models class PlaylistFilter(filters.FilterSet): q = filters.CharFilter(name="_", method="filter_q") + listenable = filters.BooleanFilter(name="_", method="filter_listenable") class Meta: model = models.Playlist - fields = {"user": ["exact"], "name": ["exact", "icontains"], "q": "exact"} + fields = { + "user": ["exact"], + "name": ["exact", "icontains"], + "q": "exact", + "listenable": "exact", + } + + def filter_listenable(self, queryset, name, value): + queryset = queryset.annotate(plts_count=Count("playlist_tracks")) + if value: + return queryset.filter(plts_count__gt=0) + else: + return queryset.filter(plts_count=0) def filter_q(self, queryset, name, value): query = utils.get_query(value, ["name", "user__username"]) diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py index e9df4624..d2504d84 100644 --- a/api/funkwhale_api/playlists/models.py +++ b/api/funkwhale_api/playlists/models.py @@ -3,12 +3,41 @@ from django.utils import timezone from rest_framework import exceptions from funkwhale_api.common import fields, preferences +from funkwhale_api.music import models as music_models class PlaylistQuerySet(models.QuerySet): def with_tracks_count(self): return self.annotate(_tracks_count=models.Count("playlist_tracks")) + def with_duration(self): + return self.annotate( + duration=models.Sum("playlist_tracks__track__files__duration") + ) + + def with_covers(self): + album_prefetch = models.Prefetch( + "album", queryset=music_models.Album.objects.only("cover") + ) + track_prefetch = models.Prefetch( + "track", + queryset=music_models.Track.objects.prefetch_related(album_prefetch).only( + "id", "album_id" + ), + ) + + plt_prefetch = models.Prefetch( + "playlist_tracks", + queryset=PlaylistTrack.objects.all() + .exclude(track__album__cover=None) + .exclude(track__album__cover="") + .order_by("index") + .only("id", "playlist_id", "track_id") + .prefetch_related(track_prefetch), + to_attr="plts_for_cover", + ) + return self.prefetch_related(plt_prefetch) + class Playlist(models.Model): name = models.CharField(max_length=50) diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py index 17cc06b1..71b8f315 100644 --- a/api/funkwhale_api/playlists/serializers.py +++ b/api/funkwhale_api/playlists/serializers.py @@ -65,6 +65,8 @@ class PlaylistTrackWriteSerializer(serializers.ModelSerializer): class PlaylistSerializer(serializers.ModelSerializer): tracks_count = serializers.SerializerMethodField(read_only=True) + duration = serializers.SerializerMethodField(read_only=True) + album_covers = serializers.SerializerMethodField(read_only=True) user = UserBasicSerializer(read_only=True) class Meta: @@ -72,11 +74,13 @@ class PlaylistSerializer(serializers.ModelSerializer): fields = ( "id", "name", - "tracks_count", "user", "modification_date", "creation_date", "privacy_level", + "tracks_count", + "album_covers", + "duration", ) read_only_fields = ["id", "modification_date", "creation_date"] @@ -87,6 +91,36 @@ class PlaylistSerializer(serializers.ModelSerializer): # no annotation? return obj.playlist_tracks.count() + def get_duration(self, obj): + try: + return obj.duration + except AttributeError: + # no annotation? + return 0 + + def get_album_covers(self, obj): + try: + plts = obj.plts_for_cover + except AttributeError: + return [] + + covers = [] + max_covers = 5 + for plt in plts: + url = plt.track.album.cover.url + if url in covers: + continue + covers.append(url) + if len(covers) >= max_covers: + break + + full_urls = [] + for url in covers: + if "request" in self.context: + url = self.context["request"].build_absolute_uri(url) + full_urls.append(url) + return full_urls + class PlaylistAddManySerializer(serializers.Serializer): tracks = serializers.PrimaryKeyRelatedField( diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py index 21e35f50..8db076a8 100644 --- a/api/funkwhale_api/playlists/views.py +++ b/api/funkwhale_api/playlists/views.py @@ -24,6 +24,8 @@ class PlaylistViewSet( models.Playlist.objects.all() .select_related("user") .annotate(tracks_count=Count("playlist_tracks")) + .with_covers() + .with_duration() ) permission_classes = [ permissions.ConditionalAuthentication, diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index fd007e23..a13a44c8 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -45,12 +45,6 @@ class UserActivitySerializer(activity_serializers.ModelSerializer): return "Person" -class UserBasicSerializer(serializers.ModelSerializer): - class Meta: - model = models.User - fields = ["id", "username", "name", "date_joined"] - - avatar_field = VersatileImageFieldSerializer( allow_null=True, sizes=[ @@ -62,6 +56,14 @@ avatar_field = VersatileImageFieldSerializer( ) +class UserBasicSerializer(serializers.ModelSerializer): + avatar = avatar_field + + class Meta: + model = models.User + fields = ["id", "username", "name", "date_joined", "avatar"] + + class UserWriteSerializer(serializers.ModelSerializer): avatar = avatar_field diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py index cd75b0d2..6ef323db 100644 --- a/api/tests/favorites/test_favorites.py +++ b/api/tests/favorites/test_favorites.py @@ -4,6 +4,8 @@ import pytest from django.urls import reverse from funkwhale_api.favorites.models import TrackFavorite +from funkwhale_api.music import serializers as music_serializers +from funkwhale_api.users import serializers as users_serializers def test_user_can_add_favorite(factories): @@ -15,21 +17,25 @@ def test_user_can_add_favorite(factories): assert f.user == user -def test_user_can_get_his_favorites(factories, logged_in_client, client): +def test_user_can_get_his_favorites(api_request, factories, logged_in_client, client): + r = api_request.get("/") favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user) url = reverse("api:v1:favorites:tracks-list") response = logged_in_client.get(url) - expected = [ { - "track": favorite.track.pk, + "user": users_serializers.UserBasicSerializer( + favorite.user, context={"request": r} + ).data, + "track": music_serializers.TrackSerializer( + favorite.track, context={"request": r} + ).data, "id": favorite.id, "creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"), } ] - parsed_json = json.loads(response.content.decode("utf-8")) - - assert expected == parsed_json["results"] + assert response.status_code == 200 + assert response.data["results"] == expected def test_user_can_add_favorite_via_api(factories, logged_in_client, activity_muted): diff --git a/api/tests/favorites/test_views.py b/api/tests/favorites/test_views.py new file mode 100644 index 00000000..7c3aed40 --- /dev/null +++ b/api/tests/favorites/test_views.py @@ -0,0 +1,13 @@ +import pytest + +from django.urls import reverse + + +@pytest.mark.parametrize("level", ["instance", "me", "followers"]) +def test_privacy_filter(preferences, level, factories, api_client): + preferences["common__api_authentication_required"] = False + factories["favorites.TrackFavorite"](user__privacy_level=level) + url = reverse("api:v1:favorites:tracks-list") + response = api_client.get(url) + assert response.status_code == 200 + assert response.data["count"] == 0 diff --git a/api/tests/history/test_views.py b/api/tests/history/test_views.py new file mode 100644 index 00000000..8ec92771 --- /dev/null +++ b/api/tests/history/test_views.py @@ -0,0 +1,13 @@ +import pytest + +from django.urls import reverse + + +@pytest.mark.parametrize("level", ["instance", "me", "followers"]) +def test_privacy_filter(preferences, level, factories, api_client): + preferences["common__api_authentication_required"] = False + factories["history.Listening"](user__privacy_level=level) + url = reverse("api:v1:history:listenings-list") + response = api_client.get(url) + assert response.status_code == 200 + assert response.data["count"] == 0 diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py index 67728807..42569f7a 100644 --- a/api/tests/playlists/test_serializers.py +++ b/api/tests/playlists/test_serializers.py @@ -63,3 +63,40 @@ def test_update_insert_is_called_when_index_is_provided(factories, mocker): insert.assert_called_once_with(playlist, plt, 0) assert plt.index == 0 assert first.index == 1 + + +def test_playlist_serializer_include_covers(factories, api_request): + playlist = factories["playlists.Playlist"]() + t1 = factories["music.Track"]() + t2 = factories["music.Track"]() + t3 = factories["music.Track"](album__cover=None) + t4 = factories["music.Track"]() + t5 = factories["music.Track"]() + t6 = factories["music.Track"]() + t7 = factories["music.Track"]() + + playlist.insert_many([t1, t2, t3, t4, t5, t6, t7]) + request = api_request.get("/") + qs = playlist.__class__.objects.with_covers().with_tracks_count() + + expected = [ + request.build_absolute_uri(t1.album.cover.url), + request.build_absolute_uri(t2.album.cover.url), + request.build_absolute_uri(t4.album.cover.url), + request.build_absolute_uri(t5.album.cover.url), + request.build_absolute_uri(t6.album.cover.url), + ] + + serializer = serializers.PlaylistSerializer(qs.get(), context={"request": request}) + assert serializer.data["album_covers"] == expected + + +def test_playlist_serializer_include_duration(factories, api_request): + playlist = factories["playlists.Playlist"]() + tf1 = factories["music.TrackFile"](duration=15) + tf2 = factories["music.TrackFile"](duration=30) + playlist.insert_many([tf1.track, tf2.track]) + qs = playlist.__class__.objects.with_duration().with_tracks_count() + + serializer = serializers.PlaylistSerializer(qs.get()) + assert serializer.data["duration"] == 45 diff --git a/front/src/App.vue b/front/src/App.vue index 11ef9f13..b53b36f1 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -32,6 +32,9 @@ <router-link class="item" to="/about"> <translate>About this instance</translate> </router-link> + <router-link class="item" :to="{name: 'library.request'}"> + <translate>Request music</translate> + </router-link> <a href="https://funkwhale.audio" class="item" target="_blank"><translate>Official website</translate></a> <a href="https://docs.funkwhale.audio" class="item" target="_blank"><translate>Documentation</translate></a> <a href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank"> diff --git a/front/src/components/About.vue b/front/src/components/About.vue index c9e1e23c..438fed67 100644 --- a/front/src/components/About.vue +++ b/front/src/components/About.vue @@ -3,10 +3,10 @@ <div class="ui vertical center aligned stripe segment"> <div class="ui text container"> <h1 class="ui huge header"> - <template v-if="instance.name.value" :template-params="{instance: instance.name}"> + <translate v-if="instance.name.value" :translate-params="{instance: instance.name.value}"> About %{ instance } - </template> - <template v-else="instance.name.value"><translate>About this instance</translate></template> + </translate> + <translate v-else>About this instance</translate> </h1> <stats></stats> </div> diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 938a4807..6f744d74 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -2,7 +2,7 @@ <div :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar',]"> <div class="ui inverted segment header-wrapper"> <search-bar @search="isCollapsed = false"> - <router-link :title="'Funkwhale'" :to="{name: 'index'}"> + <router-link :title="'Funkwhale'" :to="{name: logoUrl}"> <i class="logo bordered inverted orange big icon"> <logo class="logo"></logo> </i> @@ -39,7 +39,7 @@ <translate :translate-params="{username: $store.state.auth.username}"> Logged in as %{ username } </translate> - <img class="ui avatar right floated circular mini 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 right floated circular tiny avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" /> </router-link> <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i><translate>Logout</translate></router-link> <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i><translate>Login</translate></router-link> @@ -237,6 +237,13 @@ export default { set (value) { this.tracksChangeBuffer = value } + }, + logoUrl () { + if (this.$store.state.auth.authenticated) { + return 'library.index' + } else { + return 'index' + } } }, methods: { @@ -433,8 +440,9 @@ $sidebar-color: #3d3e3f; } } } -.avatar { +.ui.tiny.avatar.image { position: relative; top: -0.5em; + width: 3em; } </style> diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 6c5ebbc2..ad85e72c 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -1,22 +1,23 @@ <template> - <div :title="title" :class="['ui', {'tiny': discrete}, 'buttons']"> + <span :title="title" :class="['ui', {'tiny': discrete}, {'buttons': !dropdownOnly && !iconOnly}]"> <button + v-if="!dropdownOnly" :title="labels.addToQueue" @click="addNext(true)" :disabled="!playable" - :class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']"> - <i class="ui play icon"></i> - <template v-if="!discrete"><slot><translate>Play</translate></slot></template> + :class="buttonClasses.concat(['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}])"> + <i :class="[playIconClass, 'icon']"></i> + <template v-if="!discrete && !iconOnly"><slot><translate>Play</translate></slot></template> </button> - <div v-if="!discrete" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', 'icon', 'button']"> - <i class="dropdown icon"></i> + <div v-if="!discrete && !iconOnly" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]"> + <i :class="dropdownIconClasses.concat(['icon'])"></i> <div class="menu"> <div class="item" :disabled="!playable" @click="add"><i class="plus icon"></i><translate>Add to queue</translate></div> <div class="item" :disabled="!playable" @click="addNext()"><i class="step forward icon"></i><translate>Play next</translate></div> <div class="item" :disabled="!playable" @click="addNext(true)"><i class="arrow down icon"></i><translate>Play now</translate></div> </div> </div> - </div> + </span> </template> <script> @@ -28,8 +29,13 @@ export default { // we can either have a single or multiple tracks to play when clicked tracks: {type: Array, required: false}, track: {type: Object, required: false}, + dropdownIconClasses: {type: Array, required: false, default: () => { return ['dropdown'] }}, + playIconClass: {type: String, required: false, default: 'play icon'}, + buttonClasses: {type: Array, required: false, default: () => { return ['button'] }}, playlist: {type: Object, required: false}, discrete: {type: Boolean, default: false}, + dropdownOnly: {type: Boolean, default: false}, + iconOnly: {type: Boolean, default: false}, artist: {type: Number, required: false}, album: {type: Number, required: false} }, diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue new file mode 100644 index 00000000..37cddd50 --- /dev/null +++ b/front/src/components/audio/album/Widget.vue @@ -0,0 +1,134 @@ +<template> + <div> + <h3 class="ui header"> + <slot name="title"></slot> + </h3> + <i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle left', 'icon']"> + </i> + <i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle right', 'icon']"> + </i> + <div class="ui hidden divider"></div> + <div class="ui five cards"> + <div v-if="isLoading" class="ui inverted active dimmer"> + <div class="ui loader"></div> + </div> + <div class="card" v-for="album in albums" :key="album.id"> + <div :class="['ui', 'image', 'with-overlay', {'default-cover': !album.cover}]" :style="getImageStyle(album)"> + <play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album.id"></play-button> + </div> + <div class="content"> + <router-link :title="album.title" :to="{name: 'library.albums.detail', params: {id: album.id}}"> + {{ album.title|truncate(25) }} + </router-link> + <div class="description"> + <span> + <router-link :title="album.artist.name" class="discrete link" :to="{name: 'library.artists.detail', params: {id: album.artist.id}}"> + {{ album.artist.name|truncate(23) }} + </router-link> + </span> + </div> + </div> + <div class="extra content"> + <human-date class="left floated" :date="album.creation_date"></human-date> + <play-button class="right floated basic icon" :dropdown-only="true" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :album="album.id"></play-button> + </div> + </div> + </div> + </div> +</template> + +<script> +import _ from 'lodash' +import axios from 'axios' +import PlayButton from '@/components/audio/PlayButton' + +export default { + props: { + filters: {type: Object, required: true} + }, + components: { + PlayButton + }, + data () { + return { + albums: [], + limit: 12, + isLoading: false, + errors: null, + previousPage: 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.offset = this.offset + axios.get(url, {params: params}).then((response) => { + self.previousPage = response.data.previous + self.nextPage = response.data.next + self.isLoading = false + self.albums = response.data.results + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + updateOffset (increment) { + if (increment) { + this.offset += this.limit + } else { + this.offset = Math.max(this.offset - this.limit, 0) + } + }, + getImageStyle (album) { + let url = '../../../assets/audio/default-cover.png' + + if (album.cover) { + url = this.$store.getters['instance/absoluteUrl'](album.cover) + } else { + return {} + } + return { + 'background-image': `url("${url}")` + } + } + }, + watch: { + offset () { + this.fetchData() + } + } +} +</script> +<style scoped lang="scss"> +@import '../../../style/vendor/media'; + +.default-cover { + background-image: url('../../../assets/audio/default-cover.png') !important; +} + +.ui.cards { + justify-content: center; +} +.ui.cards > .card { + width: 15em; +} +.with-overlay { + background-size: cover !important; + background-position: center !important; + height: 15em; + width: 15em; + display: flex !important; + justify-content: center !important; + align-items: center !important; +} +</style> diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue index 5870ac79..ef3660ee 100644 --- a/front/src/components/audio/track/Row.vue +++ b/front/src/components/audio/track/Row.vue @@ -5,7 +5,7 @@ </td> <td> <img class="ui mini image" v-if="track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover)"> - <img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png"> + <img class="ui mini image" v-else src="../../../assets/audio/default-cover.png"> </td> <td colspan="6"> <router-link class="track" :to="{name: 'library.tracks.detail', params: {id: track.id }}"> diff --git a/front/src/components/audio/track/Widget.vue b/front/src/components/audio/track/Widget.vue new file mode 100644 index 00000000..7c727b40 --- /dev/null +++ b/front/src/components/audio/track/Widget.vue @@ -0,0 +1,124 @@ +<template> + <div> + <h3 class="ui header"> + <slot name="title"></slot> + </h3> + <i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle up', 'icon']"> + </i> + <i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle down', 'icon']"> + </i> + <div class="ui divided unstackable items"> + <div v-if="isLoading" class="ui inverted active dimmer"> + <div class="ui loader"></div> + </div> + <div class="item" v-for="object in objects" :key="object.id"> + <div class="ui tiny image"> + <img v-if="object.track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover)"> + <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> + <div class="middle aligned content"> + <div class="ui unstackable grid"> + <div class="thirteen wide stretched column"> + <div> + <router-link :title="object.track.title" :to="{name: 'library.tracks.detail', params: {id: object.track.id}}"> + {{ object.track.title|truncate(25) }} + </router-link> + </div> + <div class="meta"> + <span> + <router-link :title="object.track.artist.name" class="discrete link" :to="{name: 'library.artists.detail', params: {id: object.track.artist.id}}"> + {{ object.track.artist.name|truncate(25) }} + </router-link> + </span> + </div> + <div class="extra"> + <span class="left floated">@{{ object.user.username }}</span> + <span class="right floated"><human-date :date="object.creation_date" /></span> + </div> + </div> + <div class="one wide stretched column"> + <play-button class="basic icon" :dropdown-only="true" :dropdown-icon-classes="['ellipsis', 'vertical', 'large', 'grey']" :track="object.track"></play-button> + </div> + </div> + </div> + </div> + </div> + </div> +</template> + +<script> +import _ from 'lodash' +import axios from 'axios' +import PlayButton from '@/components/audio/PlayButton' + +export default { + props: { + filters: {type: Object, required: true}, + url: {type: String, required: true} + }, + components: { + PlayButton + }, + data () { + return { + objects: [], + limit: 5, + isLoading: false, + errors: null, + previousPage: null, + nextPage: null + } + }, + created () { + this.fetchData(this.url) + }, + methods: { + fetchData (url) { + if (!url) { + return + } + this.isLoading = true + let self = this + let params = _.clone(this.filters) + params.page_size = this.limit + params.offset = this.offset + axios.get(url, {params: params}).then((response) => { + self.previousPage = response.data.previous + self.nextPage = response.data.next + self.isLoading = false + self.objects = response.data.results + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + updateOffset (increment) { + if (increment) { + this.offset += this.limit + } else { + this.offset = Math.max(this.offset - this.limit, 0) + } + } + }, + watch: { + offset () { + this.fetchData() + } + } +} +</script> + +<style scoped lang="scss"> +@import '../../../style/vendor/media'; + +.play-overlay { + position: absolute; + top: 4em; + left: 4em; + @include media(">tablet") { + top: 2.5em; + left: 2.5em; + } +} +</style> diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue index ae3c47e5..8d2e8047 100644 --- a/front/src/components/auth/Signup.vue +++ b/front/src/components/auth/Signup.vue @@ -65,7 +65,7 @@ import PasswordInput from '@/components/forms/PasswordInput' export default { props: { - invitation: {type: String, required: false, default: null}, + defaultInvitation: {type: String, required: false, default: null}, next: {type: String, default: '/'} }, components: { @@ -78,7 +78,8 @@ export default { password: '', isLoadingInstanceSetting: true, errors: [], - isLoading: false + isLoading: false, + invitation: this.defaultInvitation } }, created () { diff --git a/front/src/components/common/Duration.vue b/front/src/components/common/Duration.vue new file mode 100644 index 00000000..85b070fc --- /dev/null +++ b/front/src/components/common/Duration.vue @@ -0,0 +1,22 @@ +<template> + <span> + <translate + v-if="durationData.hours > 0" + :translate-params="{minutes: durationData.minutes, hours: durationData.hours}">%{ hours } h %{ minutes } min</translate> + <translate + v-else + :translate-params="{minutes: durationData.minutes}">%{ minutes } min</translate> + </span> +</template> +<script> +import {secondsToObject} from '@/filters' + +export default { + props: ['seconds'], + computed: { + durationData () { + return secondsToObject(this.seconds) + } + } +} +</script> diff --git a/front/src/components/common/UserLink.vue b/front/src/components/common/UserLink.vue new file mode 100644 index 00000000..0ae4d4ec --- /dev/null +++ b/front/src/components/common/UserLink.vue @@ -0,0 +1,34 @@ +<template> + <span> + <img + class="ui tiny circular avatar" + v-if="user.avatar && user.avatar.small_square_crop" + :src="$store.getters['instance/absoluteUrl'](user.avatar.small_square_crop)" /> + <span v-else :style="defaultAvatarStyle" class="ui circular label">{{ user.username[0]}}</span> + @{{ user.username }} + </span> +</template> + +<script> +import {hashCode, intToRGB} from '@/utils/color' + +export default { + props: ['user'], + computed: { + userColor () { + return intToRGB(hashCode(this.user.username + String(this.user.id))) + }, + defaultAvatarStyle () { + return { + 'background-color': `#${this.userColor}` + } + } + } +} +</script> +<style scoped> +.tiny.circular.avatar { + width: 1.7em; + height: 1.7em; +} +</style> diff --git a/front/src/components/globals.js b/front/src/components/globals.js index 4ad09f70..6865ac1b 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -8,6 +8,14 @@ import Username from '@/components/common/Username' Vue.component('username', Username) +import UserLink from '@/components/common/UserLink' + +Vue.component('user-link', UserLink) + +import Duration from '@/components/common/Duration' + +Vue.component('duration', Duration) + import DangerousButton from '@/components/common/DangerousButton' Vue.component('dangerous-button', DangerousButton) diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue index ce6627e1..0bb16e1d 100644 --- a/front/src/components/library/Home.vue +++ b/front/src/components/library/Home.vue @@ -1,32 +1,29 @@ <template> <div v-title="labels.title"> - <div class="ui vertical stripe segment"> - <search :autofocus="true"></search> - </div> <div class="ui vertical stripe segment"> <div class="ui stackable three column grid"> <div class="column"> - <h2 class="ui header"> - <translate>Latest artists</translate> - </h2> - <div :class="['ui', {'active': isLoadingArtists}, 'inline', 'loader']"></div> - <div v-if="artists.length > 0" v-for="artist in artists.slice(0, 3)" :key="artist.id" class="ui cards"> - <artist-card :artist="artist"></artist-card> - </div> + <track-widget :url="'history/listenings/'" :filters="{scope: 'user', ordering: '-creation_date'}"> + <template slot="title"><translate>Recently listened</translate></template> + </track-widget> </div> <div class="column"> - <h2 class="ui header"> - <translate>Radios</translate> - </h2> - <radio-card :type="'favorites'"></radio-card> - <radio-card :type="'random'"></radio-card> - <radio-card :type="'less-listened'"></radio-card> + <track-widget :url="'favorites/tracks/'" :filters="{scope: 'user', ordering: '-creation_date'}"> + <template slot="title"><translate>Recently favorited</translate></template> + </track-widget> </div> <div class="column"> - <h2 class="ui header"> - <translate>Music requests</translate> - </h2> - <request-form v-if="$store.state.auth.authenticated"></request-form> + <playlist-widget :url="'playlists/'" :filters="{scope: 'user', listenable: true, ordering: '-creation_date'}"> + <template slot="title"><translate>Playlists</translate></template> + </playlist-widget> + </div> + </div> + <div class="ui section hidden divider"></div> + <div class="ui grid"> + <div class="ui row"> + <album-widget :filters="{ordering: '-creation_date'}"> + <template slot="title"><translate>Recently added</translate></template> + </album-widget> </div> </div> </div> @@ -38,8 +35,9 @@ import axios from 'axios' import Search from '@/components/audio/Search' import logger from '@/logging' import ArtistCard from '@/components/audio/artist/Card' -import RadioCard from '@/components/radios/Card' -import RequestForm from '@/components/requests/Form' +import TrackWidget from '@/components/audio/track/Widget' +import AlbumWidget from '@/components/audio/album/Widget' +import PlaylistWidget from '@/components/playlists/Widget' const ARTISTS_URL = 'artists/' @@ -48,8 +46,9 @@ export default { components: { Search, ArtistCard, - RadioCard, - RequestForm + TrackWidget, + AlbumWidget, + PlaylistWidget }, data () { return { diff --git a/front/src/components/library/Radios.vue b/front/src/components/library/Radios.vue index 3c3ac95b..2b542a71 100644 --- a/front/src/components/library/Radios.vue +++ b/front/src/components/library/Radios.vue @@ -4,6 +4,22 @@ <h2 class="ui header"> <translate>Browsing radios</translate> </h2> + <div class="ui hidden divider"></div> + <div class="ui row"> + <h3 class="ui header"> + <translate>Instance radios</translate> + </h3> + <div class="ui cards"> + <radio-card :type="'favorites'"></radio-card> + <radio-card :type="'random'"></radio-card> + <radio-card :type="'less-listened'"></radio-card> + </div> + </div> + + <div class="ui hidden divider"></div> + <h3 class="ui header"> + <translate>User radios</translate> + </h3> <router-link class="ui green basic button" to="/library/radios/build" exact> <translate>Create your own radio</translate> </router-link> diff --git a/front/src/components/playlists/Card.vue b/front/src/components/playlists/Card.vue index a480975e..956a543a 100644 --- a/front/src/components/playlists/Card.vue +++ b/front/src/components/playlists/Card.vue @@ -1,30 +1,35 @@ <template> - <div class="ui card"> + <div class="ui playlist card"> + <div class="ui top attached icon button" :style="coversStyle"> + </div> <div class="content"> <div class="header"> - <router-link class="discrete link" :to="{name: 'library.playlists.detail', params: {id: playlist.id }}"> - {{ playlist.name }} + <div class="right floated"> + <play-button :icon-only="true" class="ui inline" :button-classes="['ui', 'circular', 'large', {orange: playlist.tracks_count > 0}, 'icon', 'button', {disabled: playlist.tracks_count === 0}]" :playlist="playlist"></play-button> + <play-button class="basic inline icon" :dropdown-only="true" :dropdown-icon-classes="['ellipsis', 'vertical', 'large', {disabled: playlist.tracks_count === 0}, 'grey']" :playlist="playlist"></play-button> + </div> + <router-link :title="playlist.name" class="discrete link" :to="{name: 'library.playlists.detail', params: {id: playlist.id }}"> + {{ playlist.name | truncate(30) }} </router-link> </div> <div class="meta"> - <i class="user icon"></i> {{ playlist.user.username }} - </div> - <div class="meta"> - <i class="clock icon"></i> - <human-date :date="playlist.modification_date" /> - </div> - </div> - <div class="extra content"> - <span> + <duration :seconds="playlist.duration" /> + | <i class="sound icon"></i> <translate translate-plural="%{ count } tracks" :translate-n="playlist.tracks_count" :translate-params="{count: playlist.tracks_count}"> %{ count} track - </translate> + </translate> + </div> + </div> + <div class="extra content"> + <user-link :user="playlist.user" class="left floated" /> + <span class="right floated"> + <i class="clock outline icon" /> + <human-date :date="playlist.creation_date" /> </span> - <play-button class="mini basic orange right floated" :playlist="playlist"><translate>Play all</translate></play-button> </div> </div> </template> @@ -36,11 +41,46 @@ export default { props: ['playlist'], components: { PlayButton + }, + computed: { + coversStyle () { + let self = this + let urls = this.playlist.album_covers.map((url) => { + url = self.$store.getters['instance/absoluteUrl'](url) + return `url("${url}")` + }).slice(0, 4) + return { + 'background-image': urls.join(', ') + } + } } } </script> <!-- Add "scoped" attribute to limit CSS to this component only --> +<style> + +.playlist.card .header .ellipsis.vertical.large.grey { + font-size: 1.2em; + margin-right: 0; +} + +</style> <style scoped> +.card .header { + margin-bottom: 0.25em; +} + +.attached.button { + background-color: rgb(243, 244, 245); + background-size: 25% ; + background-repeat: no-repeat; + background-origin: border-box; + background-position: 0 0, 33.33% 0, 66.67% 0, 100% 0; + /* background-position: 0 0, 50% 0, 100% 0; */ + /* background-position: 0 0, 25% 0, 50% 0, 75% 0, 100% 0; */ + font-size: 4em; + box-shadow: 0px 0px 0px 1px rgba(34, 36, 38, 0.15) inset !important; +} </style> diff --git a/front/src/components/playlists/Widget.vue b/front/src/components/playlists/Widget.vue new file mode 100644 index 00000000..72c54420 --- /dev/null +++ b/front/src/components/playlists/Widget.vue @@ -0,0 +1,77 @@ +<template> + <div> + <h3 class="ui header"> + <slot name="title"></slot> + </h3> + <i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle up', 'icon']"> + </i> + <i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle down', 'icon']"> + </i> + <div v-if="isLoading" class="ui inverted active dimmer"> + <div class="ui loader"></div> + </div> + <playlist-card class="fluid" v-for="playlist in objects" :key="playlist.id" :playlist="playlist"></playlist-card> + </div> +</template> + +<script> +import _ from 'lodash' +import axios from 'axios' +import PlaylistCard from '@/components/playlists/Card' + +export default { + props: { + filters: {type: Object, required: true}, + url: {type: String, required: true} + }, + components: { + PlaylistCard + }, + data () { + return { + objects: [], + limit: 3, + isLoading: false, + errors: null, + previousPage: null, + nextPage: null + } + }, + created () { + this.fetchData(this.url) + }, + methods: { + fetchData (url) { + if (!url) { + return + } + this.isLoading = true + let self = this + let params = _.clone(this.filters) + params.page_size = this.limit + params.offset = this.offset + axios.get(url, {params: params}).then((response) => { + self.previousPage = response.data.previous + self.nextPage = response.data.next + self.isLoading = false + self.objects = response.data.results + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + updateOffset (increment) { + if (increment) { + this.offset += this.limit + } else { + this.offset = Math.max(this.offset - this.limit, 0) + } + } + }, + watch: { + offset () { + this.fetchData() + } + } +} +</script> diff --git a/front/src/filters.js b/front/src/filters.js index 11751559..878b3c9f 100644 --- a/front/src/filters.js +++ b/front/src/filters.js @@ -28,6 +28,16 @@ export function ago (date) { Vue.filter('ago', ago) +export function secondsToObject (seconds) { + let m = moment.duration(seconds, 'seconds') + return { + minutes: m.minutes(), + hours: parseInt(m.asHours()) + } +} + +Vue.filter('secondsToObject', secondsToObject) + 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 bb59b534..1747c885 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -35,6 +35,7 @@ import AdminUsersBase from '@/views/admin/users/Base' import AdminUsersDetail from '@/views/admin/users/UsersDetail' import AdminUsersList from '@/views/admin/users/UsersList' import AdminInvitationsList from '@/views/admin/users/InvitationsList' +import MusicRequest from '@/views/library/MusicRequest' import FederationBase from '@/views/federation/Base' import FederationScan from '@/views/federation/Scan' import FederationLibraryDetail from '@/views/federation/LibraryDetail' @@ -218,7 +219,12 @@ export default new Router({ path: '/library', component: Library, children: [ - { path: '', component: LibraryHome }, + { path: '', component: LibraryHome, name: 'library.index' }, + { + path: 'requests/', + name: 'library.request', + component: MusicRequest + }, { path: 'artists/', name: 'library.artists.browse', diff --git a/front/src/utils/color.js b/front/src/utils/color.js new file mode 100644 index 00000000..8066abd3 --- /dev/null +++ b/front/src/utils/color.js @@ -0,0 +1,12 @@ +export function hashCode (str) { // java String#hashCode + var hash = 0 + for (var i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash) + } + return hash +} + +export function intToRGB (i) { + var c = (i & 0x00FFFFFF).toString(16).toUpperCase() + return '00000'.substring(0, 6 - c.length) + c +} diff --git a/front/src/views/library/MusicRequest.vue b/front/src/views/library/MusicRequest.vue new file mode 100644 index 00000000..ac2aeafb --- /dev/null +++ b/front/src/views/library/MusicRequest.vue @@ -0,0 +1,32 @@ +<template> + <div class="ui vertical stripe segment" v-title="labels.title"> + <div class="ui small text container"> + <h2 class="ui header"> + <translate>Request some music</translate> + </h2> + <request-form v-if="$store.state.auth.authenticated"></request-form> + </div> + </div> +</template> + +<script> +import RequestForm from '@/components/requests/Form' + +export default { + components: { + RequestForm + }, + computed: { + labels () { + let title = this.$gettext('Request some music') + return { + title + } + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue index 3fd4730b..f9d23277 100644 --- a/front/src/views/playlists/Detail.vue +++ b/front/src/views/playlists/Detail.vue @@ -9,14 +9,15 @@ <i class="circular inverted list yellow icon"></i> <div class="content"> {{ playlist.name }} - <translate - tag="div" - class="sub header" - translate-plural="Playlist containing %{ count } tracks, by %{ username }" - :translate-n="playlistTracks.length" - :translate-params="{count: playlistTracks.length, username: playlist.user.username}"> - Playlist containing %{ count } track, by %{ username } - </translate> + <div class="sub header"> + <translate + translate-plural="Playlist containing %{ count } tracks, by %{ username }" + :translate-n="playlistTracks.length" + :translate-params="{count: playlistTracks.length, username: playlist.user.username}"> + Playlist containing %{ count } track, by %{ username } + </translate><br> + <duration :seconds="playlist.duration" /> + </div> </div> </h2> <div class="ui hidden divider"></div> diff --git a/front/src/views/playlists/List.vue b/front/src/views/playlists/List.vue index c28f9956..a831dfe7 100644 --- a/front/src/views/playlists/List.vue +++ b/front/src/views/playlists/List.vue @@ -118,7 +118,7 @@ export default { ordering: this.getOrderingAsString() } }) - }, 500), + }, 250), fetchData: _.debounce(function () { var self = this this.isLoading = true -- GitLab