diff --git a/api/config/spa_urls.py b/api/config/spa_urls.py index 730fcc298c7225e1ed7c2b55afff1aa152864a8d..8c9fe51494418b848d8b4eb513a8c6829089cfaf 100644 --- a/api/config/spa_urls.py +++ b/api/config/spa_urls.py @@ -21,6 +21,11 @@ urlpatterns = [ spa_views.library_playlist, name="library_playlist", ), + urls.re_path( + r"^library/(?P<uuid>[0-9a-f-]+)/?$", + spa_views.library_library, + name="library_library", + ), urls.re_path( r"^channels/(?P<uuid>[0-9a-f-]+)/?$", audio_spa_views.channel_detail_uuid, diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index db06e31978a59053163d136c59ab64dbe713fdff..39e6e584a6adba955388df835f0da200ef15fcd2 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -98,6 +98,26 @@ class LibraryFollowViewSet( update_follow(follow, approved=False) return response.Response(status=204) + @decorators.action(methods=["get"], detail=False) + def all(self, request, *args, **kwargs): + """ + Return all the subscriptions of the current user, with only limited data + to have a performant endpoint and avoid lots of queries just to display + subscription status in the UI + """ + follows = list( + self.get_queryset().values_list("uuid", "target__uuid", "approved") + ) + + payload = { + "results": [ + {"uuid": str(u[0]), "library": str(u[1]), "approved": u[2]} + for u in follows + ], + "count": len(follows), + } + return response.Response(payload, status=200) + class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): lookup_field = "uuid" diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index cab3baf6df9a87bebb15658cf96a8371fe0b6103..8070b1310306acf04d2902e9aa6363b5895d66a9 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -201,3 +201,26 @@ def find_alternate(response_text): parser.feed(response_text) except StopParsing: return parser.result + + +def should_redirect_ap_to_html(accept_header): + if not accept_header: + return False + + redirect_headers = [ + "text/html", + ] + no_redirect_headers = [ + "application/json", + "application/activity+json", + "application/ld+json", + ] + + parsed_header = [ct.lower().strip() for ct in accept_header.split(",")] + for ct in parsed_header: + if ct in redirect_headers: + return True + if ct in no_redirect_headers: + return False + + return True diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py index c20d792bc547069e24569d73cf186849b256859c..5efebf1ecf2871d2d188b1a175c77686bcaac6fb 100644 --- a/api/funkwhale_api/federation/views.py +++ b/api/funkwhale_api/federation/views.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.core import paginator from django.db.models import Prefetch from django.http import HttpResponse @@ -7,6 +8,7 @@ from rest_framework import exceptions, mixins, permissions, response, viewsets from rest_framework.decorators import action from funkwhale_api.common import preferences +from funkwhale_api.common import utils as common_utils from funkwhale_api.moderation import models as moderation_models from funkwhale_api.music import models as music_models from funkwhale_api.music import utils as music_utils @@ -14,6 +16,12 @@ from funkwhale_api.music import utils as music_utils from . import activity, authentication, models, renderers, serializers, utils, webfinger +def redirect_to_html(public_url): + response = HttpResponse(status=302) + response["Location"] = common_utils.join_url(settings.FUNKWHALE_URL, public_url) + return response + + class AuthenticatedIfAllowListEnabled(permissions.BasePermission): def has_permission(self, request, view): allow_list_enabled = preferences.get("moderation__allow_list_enabled") @@ -204,13 +212,18 @@ class MusicLibraryViewSet( renderer_classes = renderers.get_ap_renderers() serializer_class = serializers.LibrarySerializer queryset = ( - music_models.Library.objects.all().select_related("actor").filter(channel=None) + music_models.Library.objects.all() + .local() + .select_related("actor") + .filter(channel=None) ) lookup_field = "uuid" def retrieve(self, request, *args, **kwargs): lb = self.get_object() - + if utils.should_redirect_ap_to_html(request.headers.get("accept")): + # XXX: implement this for actors, albums, tracks, artists + return redirect_to_html(lb.get_absolute_url()) conf = { "id": lb.get_federation_id(), "actor": lb.actor, diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py index 2395a8381442f16a2f7c8eaf9459c65d47633cfe..60a9e5f865968f7d14b2719fe3b2ee29810dd5b9 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -41,8 +41,30 @@ class ChannelFilterSet(filters.FilterSet): return queryset.filter(pk__in=ids) +class LibraryFilterSet(filters.FilterSet): + + library = filters.CharFilter(field_name="_", method="filter_library") + + def filter_library(self, queryset, name, value): + if not value: + return queryset + + actor = utils.get_actor_from_request(self.request) + library = models.Library.objects.filter(uuid=value).viewable_by(actor).first() + + if not library: + return queryset.none() + + uploads = models.Upload.objects.filter(library=library) + uploads = uploads.playable_by(actor) + ids = uploads.values_list(self.Meta.library_filter_field, flat=True) + return queryset.filter(pk__in=ids) + + class ArtistFilter( - audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet + LibraryFilterSet, + audio_filters.IncludeChannelsFilterSet, + moderation_filters.HiddenContentFilterSet, ): q = fields.SearchFilter(search_fields=["name"], fts_search_fields=["body_text"]) @@ -62,6 +84,7 @@ class ArtistFilter( } hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"] include_channels_field = "channel" + library_filter_field = "track__artist" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) @@ -70,6 +93,7 @@ class ArtistFilter( class TrackFilter( ChannelFilterSet, + LibraryFilterSet, audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet, ): @@ -99,6 +123,7 @@ class TrackFilter( hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"] include_channels_field = "artist__channel" channel_filter_field = "track" + library_filter_field = "track" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) @@ -156,6 +181,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet): class AlbumFilter( ChannelFilterSet, + LibraryFilterSet, audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet, ): @@ -175,6 +201,7 @@ class AlbumFilter( hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"] include_channels_field = "artist__channel" channel_filter_field = "track__album" + library_filter_field = "track__album" def filter_playable(self, queryset, name, value): actor = utils.get_actor_from_request(self.request) diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index f27ed3d501ef2c6ef4a0ed8014642aa447011ea0..e0adfe86b480de3c0e340923e171fe03223bcd4f 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -1110,6 +1110,12 @@ LIBRARY_PRIVACY_LEVEL_CHOICES = [ class LibraryQuerySet(models.QuerySet): + def local(self, include=True): + query = models.Q(actor__domain_id=settings.FEDERATION_HOSTNAME) + if not include: + query = ~query + return self.filter(query) + def with_follows(self, actor): return self.prefetch_related( models.Prefetch( @@ -1123,14 +1129,14 @@ class LibraryQuerySet(models.QuerySet): from funkwhale_api.federation.models import LibraryFollow if actor is None: - return Library.objects.filter(privacy_level="everyone") + return self.filter(privacy_level="everyone") me_query = models.Q(privacy_level="me", actor=actor) instance_query = models.Q(privacy_level="instance", actor__domain=actor.domain) followed_libraries = LibraryFollow.objects.filter( actor=actor, approved=True ).values_list("target", flat=True) - return Library.objects.filter( + return self.filter( me_query | instance_query | models.Q(privacy_level="everyone") @@ -1164,6 +1170,9 @@ class Library(federation_models.FederationMixin): reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid}) ) + def get_absolute_url(self): + return "/library/{}".format(self.uuid) + def save(self, **kwargs): if not self.pk and not self.fid and self.actor.get_user(): self.fid = self.get_federation_id() diff --git a/api/funkwhale_api/music/spa_views.py b/api/funkwhale_api/music/spa_views.py index 0619166a664d7d028cce756c959f1d5fb48cfa54..073f5bb965d4391930d5358327fc80d68a02c77b 100644 --- a/api/funkwhale_api/music/spa_views.py +++ b/api/funkwhale_api/music/spa_views.py @@ -292,3 +292,33 @@ def library_playlist(request, pk): # twitter player is also supported in various software metas += get_twitter_card_metas(type="playlist", id=obj.pk) return metas + + +def library_library(request, uuid): + queryset = models.Library.objects.filter(uuid=uuid) + try: + obj = queryset.get() + except models.Library.DoesNotExist: + return [] + library_url = utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_library", kwargs={"uuid": obj.uuid}), + ) + metas = [ + {"tag": "meta", "property": "og:url", "content": library_url}, + {"tag": "meta", "property": "og:type", "content": "website"}, + {"tag": "meta", "property": "og:title", "content": obj.name}, + {"tag": "meta", "property": "og:description", "content": obj.description}, + ] + + if preferences.get("federation__enabled"): + metas.append( + { + "tag": "link", + "rel": "alternate", + "type": "application/activity+json", + "href": obj.fid, + } + ) + + return metas diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py index ab74689bf07c6a3fce1c7f460d56f539afd4e450..e3f76e849c4eb7758899a6430df5081ebb09e035 100644 --- a/api/tests/federation/test_api_views.py +++ b/api/tests/federation/test_api_views.py @@ -286,3 +286,25 @@ def test_fetch_duplicate_bypass_with_force( assert response.status_code == 201 assert response.data == api_serializers.FetchSerializer(fetch).data fetch_task.assert_called_once_with(fetch_id=fetch.pk) + + +def test_library_follow_get_all(factories, logged_in_api_client): + actor = logged_in_api_client.user.create_actor() + library = factories["music.Library"]() + follow = factories["federation.LibraryFollow"](target=library, actor=actor) + factories["federation.LibraryFollow"]() + factories["music.Library"]() + url = reverse("api:v1:federation:library-follows-all") + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == { + "results": [ + { + "uuid": str(follow.uuid), + "library": str(library.uuid), + "approved": follow.approved, + } + ], + "count": 1, + } diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py index 800b737897567c76db443ab45198121e6ae4b76c..0b5759937445be16c6547cf055c641368d926c48 100644 --- a/api/tests/federation/test_views.py +++ b/api/tests/federation/test_views.py @@ -2,7 +2,14 @@ import pytest from django.core.paginator import Paginator from django.urls import reverse -from funkwhale_api.federation import actors, serializers, webfinger +from funkwhale_api.common import utils + +from funkwhale_api.federation import ( + actors, + serializers, + webfinger, + utils as federation_utils, +) def test_authenticate_skips_anonymous_fetch_when_allow_list_enabled( @@ -159,7 +166,7 @@ def test_wellknown_webfinger_local(factories, api_client, settings, mocker): @pytest.mark.parametrize("privacy_level", ["me", "instance", "everyone"]) def test_music_library_retrieve(factories, api_client, privacy_level): - library = factories["music.Library"](privacy_level=privacy_level) + library = factories["music.Library"](privacy_level=privacy_level, actor__local=True) expected = serializers.LibrarySerializer(library).data url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) @@ -170,7 +177,7 @@ def test_music_library_retrieve(factories, api_client, privacy_level): def test_music_library_retrieve_excludes_channel_libraries(factories, api_client): - channel = factories["audio.Channel"]() + channel = factories["audio.Channel"](local=True) library = channel.library url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) @@ -180,7 +187,7 @@ def test_music_library_retrieve_excludes_channel_libraries(factories, api_client def test_music_library_retrieve_page_public(factories, api_client): - library = factories["music.Library"](privacy_level="everyone") + library = factories["music.Library"](privacy_level="everyone", actor__local=True) upload = factories["music.Upload"](library=library, import_status="finished") id = library.get_federation_id() expected = serializers.CollectionPageSerializer( @@ -253,7 +260,7 @@ def test_channel_upload_retrieve(factories, api_client): @pytest.mark.parametrize("privacy_level", ["me", "instance"]) def test_music_library_retrieve_page_private(factories, api_client, privacy_level): - library = factories["music.Library"](privacy_level=privacy_level) + library = factories["music.Library"](privacy_level=privacy_level, actor__local=True) url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) response = api_client.get(url, {"page": 1}) @@ -264,7 +271,7 @@ def test_music_library_retrieve_page_private(factories, api_client, privacy_leve def test_music_library_retrieve_page_follow( factories, api_client, authenticated_actor, approved, expected ): - library = factories["music.Library"](privacy_level="me") + library = factories["music.Library"](privacy_level="me", actor__local=True) factories["federation.LibraryFollow"]( actor=authenticated_actor, target=library, approved=approved ) @@ -344,3 +351,35 @@ def test_music_upload_detail_private_approved_follow( response = api_client.get(url) assert response.status_code == 200 + + +@pytest.mark.parametrize( + "accept_header,expected", + [ + ("text/html,application/xhtml+xml", True), + ("text/html,application/json", True), + ("", False), + (None, False), + ("application/json", False), + ("application/activity+json", False), + ("application/json,text/html", False), + ("application/activity+json,text/html", False), + ], +) +def test_should_redirect_ap_to_html(accept_header, expected): + assert federation_utils.should_redirect_ap_to_html(accept_header) is expected + + +def test_music_library_retrieve_redirects_to_html_if_header_set( + factories, api_client, settings +): + library = factories["music.Library"](actor__local=True) + + url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) + response = api_client.get(url, HTTP_ACCEPT="text/html") + expected_url = utils.join_url( + settings.FUNKWHALE_URL, + utils.spa_reverse("library_library", kwargs={"uuid": library.uuid}), + ) + assert response.status_code == 302 + assert response["Location"] == expected_url diff --git a/api/tests/music/test_filters.py b/api/tests/music/test_filters.py index ea4543ddb2e7902b4f29414820dffd0ea4ee2481..30f8f58b9ce275f7691a70aa5f444a7eb51893e2 100644 --- a/api/tests/music/test_filters.py +++ b/api/tests/music/test_filters.py @@ -142,3 +142,45 @@ def test_channel_filter_album(factories, queryset_equal_list, mocker, anonymous_ ) assert filterset.qs == [upload.track.album] + + +def test_library_filter_track(factories, queryset_equal_list, mocker, anonymous_user): + library = factories["music.Library"](privacy_level="everyone") + upload = factories["music.Upload"](library=library, playable=True) + factories["music.Track"]() + qs = upload.track.__class__.objects.all() + filterset = filters.TrackFilter( + {"library": library.uuid}, + request=mocker.Mock(user=anonymous_user, actor=None), + queryset=qs, + ) + + assert filterset.qs == [upload.track] + + +def test_library_filter_album(factories, queryset_equal_list, mocker, anonymous_user): + library = factories["music.Library"](privacy_level="everyone") + upload = factories["music.Upload"](library=library, playable=True) + factories["music.Album"]() + qs = upload.track.album.__class__.objects.all() + filterset = filters.AlbumFilter( + {"library": library.uuid}, + request=mocker.Mock(user=anonymous_user, actor=None), + queryset=qs, + ) + + assert filterset.qs == [upload.track.album] + + +def test_library_filter_artist(factories, queryset_equal_list, mocker, anonymous_user): + library = factories["music.Library"](privacy_level="everyone") + upload = factories["music.Upload"](library=library, playable=True) + factories["music.Artist"]() + qs = upload.track.artist.__class__.objects.all() + filterset = filters.ArtistFilter( + {"library": library.uuid}, + request=mocker.Mock(user=anonymous_user, actor=None), + queryset=qs, + ) + + assert filterset.qs == [upload.track.artist] diff --git a/api/tests/music/test_spa_views.py b/api/tests/music/test_spa_views.py index d5deb8ce685a1782c5934b49436daf628dc1e4a2..ffb763a1b42e0e73b702f4c23849e517a949e099 100644 --- a/api/tests/music/test_spa_views.py +++ b/api/tests/music/test_spa_views.py @@ -282,3 +282,32 @@ def test_library_playlist_empty(spa_html, no_api_auth, client, factories, settin # we only test our custom metas, not the default ones assert metas[: len(expected_metas)] == expected_metas + + +def test_library_library(spa_html, no_api_auth, client, factories, settings): + library = factories["music.Library"]() + url = "/library/{}".format(library.uuid) + + response = client.get(url) + + expected_metas = [ + { + "tag": "meta", + "property": "og:url", + "content": utils.join_url(settings.FUNKWHALE_URL, url), + }, + {"tag": "meta", "property": "og:type", "content": "website"}, + {"tag": "meta", "property": "og:title", "content": library.name}, + {"tag": "meta", "property": "og:description", "content": library.description}, + { + "tag": "link", + "rel": "alternate", + "type": "application/activity+json", + "href": library.fid, + }, + ] + + metas = utils.parse_meta(response.content.decode()) + + # we only test our custom metas, not the default ones + assert metas[: len(expected_metas)] == expected_metas diff --git a/changes/changelog.d/926.feature b/changes/changelog.d/926.feature new file mode 100644 index 0000000000000000000000000000000000000000..00d3f7073788ec4ebb057db090727b39bfe8f3ee --- /dev/null +++ b/changes/changelog.d/926.feature @@ -0,0 +1 @@ +Can now browse a library content through the UI (#926) diff --git a/front/src/components/audio/LibraryFollowButton.vue b/front/src/components/audio/LibraryFollowButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..2b418edc48f55c1355ca944b05c8d5ffd74e1921 --- /dev/null +++ b/front/src/components/audio/LibraryFollowButton.vue @@ -0,0 +1,43 @@ + <template> + <button @click.stop="toggle" :class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']"> + <i class="heart icon"></i> + <translate v-if="isApproved" translate-context="Content/Library/Card.Button.Label/Verb">Unfollow</translate> + <translate v-else-if="isPending" translate-context="Content/Library/Card.Button.Label/Verb">Cancel follow request</translate> + <translate v-else translate-context="Content/Library/Card.Button.Label/Verb">Follow</translate> + </button> +</template> + +<script> +export default { + props: { + library: {type: Object}, + }, + computed: { + isPending () { + return this.follow && this.follow.approved === null + }, + isApproved () { + return this.follow && (this.follow.approved === true || (this.follow.approved === null && this.library.privacy_level === 'everyone')) + }, + follow () { + return this.$store.getters['libraries/follow'](this.library.uuid) + } + }, + methods: { + toggle () { + if (this.isApproved || this.isPending) { + this.$emit('unfollowed') + } else { + this.$emit('followed') + } + this.$store.dispatch('libraries/toggle', this.library.uuid) + } + } + + +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index a4bb95692803e415a1e43ca683c364de6d2af30d..b8c1e42fe2a3c19dce5393586a3493e902d25018 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -68,6 +68,7 @@ export default { iconOnly: {type: Boolean, default: false}, artist: {type: Object, required: false}, album: {type: Object, required: false}, + library: {type: Object, required: false}, isPlayable: {type: Boolean, required: false, default: null} }, data () { @@ -196,6 +197,9 @@ export default { } else if (self.album) { let params = {'album': self.album.id, include_channels: 'true', 'ordering': 'disc_number,position'} self.getTracksPage(1, params, resolve) + } else if (self.library) { + let params = {'library': self.library.uuid, 'ordering': '-creation_date'} + self.getTracksPage(1, params, resolve) } }) return getTracks.then((tracks) => { diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue index fc9cebaeb0c51366360bd7b78ddf357979606855..33bc447323fcdbd49612b63daaea2800e315c6ce 100644 --- a/front/src/components/audio/album/Widget.vue +++ b/front/src/components/audio/album/Widget.vue @@ -5,6 +5,7 @@ <span v-if="showCount" class="ui tiny circular label">{{ count }}</span> </h3> <slot></slot> + <inline-search-bar v-model="query" v-if="search" @search="albums = []; fetchData()"></inline-search-bar> <div class="ui hidden divider"></div> <div class="ui app-cards cards"> <div v-if="isLoading" class="ui inverted active dimmer"> @@ -12,14 +13,9 @@ </div> <album-card v-for="album in albums" :album="album" :key="album.id" /> </div> - <template v-if="!isLoading && albums.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> + <slot v-if="!isLoading && albums.length === 0" name="empty-state"> + <empty-state @refresh="fetchData" :refresh="true"></empty-state> + </slot> <template v-if="nextPage"> <div class="ui hidden divider"></div> <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']"> @@ -30,7 +26,6 @@ </template> <script> -import _ from '@/lodash' import axios from 'axios' import AlbumCard from '@/components/audio/album/Card' @@ -39,6 +34,7 @@ export default { filters: {type: Object, required: true}, controls: {type: Boolean, default: true}, showCount: {type: Boolean, default: false}, + search: {type: Boolean, default: false}, limit: {type: Number, default: 12}, }, components: { @@ -51,20 +47,19 @@ export default { isLoading: false, errors: null, previousPage: null, - nextPage: null + nextPage: null, + query: '', } }, created () { - this.fetchData('albums/') + this.fetchData() }, methods: { fetchData (url) { - if (!url) { - return - } + url = url || 'albums/' this.isLoading = true let self = this - let params = _.clone(this.filters) + let params = {q: this.query, ...this.filters} params.page_size = this.limit params.offset = this.offset axios.get(url, {params: params}).then((response) => { @@ -91,7 +86,7 @@ export default { this.fetchData() }, "$store.state.moderation.lastUpdate": function () { - this.fetchData('albums/') + this.fetchData() } } } diff --git a/front/src/components/audio/artist/Widget.vue b/front/src/components/audio/artist/Widget.vue index d3e946a841dfe1bd80b4c0cdc9f43be063fdc367..733bb9b2b05c5196a773e907911c82f531c07e1f 100644 --- a/front/src/components/audio/artist/Widget.vue +++ b/front/src/components/audio/artist/Widget.vue @@ -4,6 +4,7 @@ <slot name="title"></slot> <span class="ui tiny circular label">{{ count }}</span> </h3> + <inline-search-bar v-model="query" v-if="search" @search="objects = []; fetchData()"></inline-search-bar> <div class="ui hidden divider"></div> <div class="ui five app-cards cards"> <div v-if="isLoading" class="ui inverted active dimmer"> @@ -11,7 +12,9 @@ </div> <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> + <slot v-if="!isLoading && objects.length === 0" name="empty-state"> + <empty-state @refresh="fetchData" :refresh="true"></empty-state> + </slot> <template v-if="nextPage"> <div class="ui hidden divider"></div> <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']"> @@ -22,7 +25,6 @@ </template> <script> -import _ from '@/lodash' import axios from 'axios' import ArtistCard from "@/components/audio/artist/Card" @@ -31,6 +33,7 @@ export default { filters: {type: Object, required: true}, controls: {type: Boolean, default: true}, header: {type: Boolean, default: true}, + search: {type: Boolean, default: false}, }, components: { ArtistCard, @@ -43,20 +46,19 @@ export default { isLoading: false, errors: null, previousPage: null, - nextPage: null + nextPage: null, + query: '', } }, created () { - this.fetchData('artists/') + this.fetchData() }, methods: { fetchData (url) { - if (!url) { - return - } + url = url || 'artists/' this.isLoading = true let self = this - let params = _.clone(this.filters) + let params = {q: this.query, ...this.filters} params.page_size = this.limit params.offset = this.offset axios.get(url, {params: params}).then((response) => { @@ -83,7 +85,7 @@ export default { this.fetchData() }, "$store.state.moderation.lastUpdate": function () { - this.fetchData('objects/') + this.fetchData() } } } diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue index 5bbd046c23a0e5af7cfd1126d3075330b42929f2..9afd67dd384625c213521262afd065b3d339b29f 100644 --- a/front/src/components/audio/track/Row.vue +++ b/front/src/components/audio/track/Row.vue @@ -31,7 +31,7 @@ <td colspan="4" v-else> <translate translate-context="*/*/*">N/A</translate> </td> - <td colspan="2" class="align right"> + <td colspan="2" v-if="displayActions" class="align right"> <track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon> <play-button class="play-button basic icon" @@ -59,6 +59,7 @@ export default { track: {type: Object, required: true}, artist: {type: Object, required: false}, displayPosition: {type: Boolean, default: false}, + displayActions: {type: Boolean, default: true}, playable: {type: Boolean, required: false, default: false}, }, components: { diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue index 14515b4c324ece2abe16db9052b2cb0fb3e95b57..ed91b79a26353f74664bf727ccff0a3a8a339ddc 100644 --- a/front/src/components/audio/track/Table.vue +++ b/front/src/components/audio/track/Table.vue @@ -1,6 +1,10 @@ <template> <div class="table-wrapper"> - <table class="ui compact very basic unstackable table"> + <inline-search-bar v-model="query" v-if="search" @search="additionalTracks = []; loadMore()"></inline-search-bar> + <slot v-if="!isLoading && allTracks.length === 0" name="empty-state"> + <empty-state @refresh="fetchData" :refresh="true"></empty-state> + </slot> + <table v-else :class="['ui', 'compact', 'very', 'basic', {loading: isLoading}, 'unstackable', 'table']"> <thead> <tr> <th></th> @@ -9,20 +13,21 @@ <th colspan="4"><translate translate-context="*/*/*/Noun">Artist</translate></th> <th colspan="4"><translate translate-context="*/*/*">Album</translate></th> <th colspan="4"><translate translate-context="Content/*/*">Duration</translate></th> - <th colspan="2"></th> + <th colspan="2" v-if="displayActions"></th> </tr> </thead> <tbody> <track-row :playable="playable" :display-position="displayPosition" + :display-actions="displayActions" :track="track" :artist="artist" :key="index + '-' + track.id" v-for="(track, index) in allTracks"></track-row> </tbody> </table> - <button :class="['ui', {loading: isLoadingMore}, 'button']" v-if="loadMoreUrl" @click="loadMore(loadMoreUrl)"> + <button :class="['ui', {loading: isLoading}, 'button']" v-if="loadMoreUrl" @click="loadMore(loadMoreUrl)" :disabled="isLoading"> <translate translate-context="Content/*/Button.Label">Load more…</translate> </button> </div> @@ -36,38 +41,49 @@ import Modal from '@/components/semantic/Modal' export default { props: { - tracks: {type: Array, required: true}, + tracks: {type: Array, required: false}, playable: {type: Boolean, required: false, default: false}, + search: {type: Boolean, required: false, default: false}, nextUrl: {type: String, required: false, default: null}, artist: {type: Object, required: false}, - displayPosition: {type: Boolean, default: false} + filters: {type: Object, required: false, default: () => { return {}}}, + displayPosition: {type: Boolean, default: false}, + displayActions: {type: Boolean, default: true}, }, components: { Modal, TrackRow }, + created () { + if (!this.tracks) { + this.loadMore('tracks/') + } + }, data () { return { loadMoreUrl: this.nextUrl, - isLoadingMore: false, - additionalTracks: [] + isLoading: false, + additionalTracks: [], + query: '', } }, computed: { allTracks () { - return this.tracks.concat(this.additionalTracks) + return (this.tracks || []).concat(this.additionalTracks) } }, methods: { loadMore (url) { + url = url || 'tracks/' let self = this - self.isLoadingMore = true - axios.get(url).then((response) => { + let params = {q: this.query, ...this.filters} + self.isLoading = true + axios.get(url, {params}).then((response) => { self.additionalTracks = self.additionalTracks.concat(response.data.results) self.loadMoreUrl = response.data.next - self.isLoadingMore = false + self.isLoading = false }, (error) => { - self.isLoadingMore = false + self.isLoading = false }) } diff --git a/front/src/components/common/ActorLink.vue b/front/src/components/common/ActorLink.vue index 8c6c092febc76ea3cc2dc0d935435fddbba221e1..ce2be46a294732ba3ca88217cd0aafaf00d65581 100644 --- a/front/src/components/common/ActorLink.vue +++ b/front/src/components/common/ActorLink.vue @@ -1,6 +1,6 @@ <template> <router-link :to="url" :title="actor.full_username"> - <template v-if="avatar"><actor-avatar :actor="actor" /> </template>{{ repr | truncate(30) }} + <template v-if="avatar"><actor-avatar :actor="actor" /> </template><slot>{{ repr | truncate(truncateLength) }}</slot> </router-link> </template> @@ -13,6 +13,7 @@ export default { avatar: {type: Boolean, default: true}, admin: {type: Boolean, default: false}, displayName: {type: Boolean, default: false}, + truncateLength: {type: Number, default: 30}, }, computed: { url () { diff --git a/front/src/components/common/CopyInput.vue b/front/src/components/common/CopyInput.vue index 989bb0346cc0fef0548922b629c94ea575b26c68..f6637297e54ef259b02be94859f1c00b73fae6c2 100644 --- a/front/src/components/common/CopyInput.vue +++ b/front/src/components/common/CopyInput.vue @@ -3,7 +3,7 @@ <p class="message" v-if="copied"> <translate translate-context="Content/*/Paragraph">Text copied to clipboard!</translate> </p> - <input ref="input" :value="value" type="text" readonly> + <input :id="id" :name="id" ref="input" :value="value" type="text" readonly> <button @click="copy" :class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']"> <i class="copy icon"></i> <translate translate-context="*/*/Button.Label/Short, Verb">Copy</translate> @@ -14,7 +14,8 @@ export default { props: { value: {type: String}, - buttonClasses: {type: String, default: 'teal'} + buttonClasses: {type: String, default: 'teal'}, + id: {type: String, default: 'copy-input'}, }, data () { return { diff --git a/front/src/components/common/EmptyState.vue b/front/src/components/common/EmptyState.vue index 862700679e2a5e9db60d756d26f6081fdb7d9b7a..360ef58f4dea75f815658b930039be11718d8a71 100644 --- a/front/src/components/common/EmptyState.vue +++ b/front/src/components/common/EmptyState.vue @@ -11,7 +11,7 @@ </slot> </div> </div> - <div class="inline"> + <div class="inline center aligned text"> <slot></slot> <button v-if="refresh" class="ui button" @click="$emit('refresh')"> <translate translate-context="Content/*/Button.Label/Short, Verb"> diff --git a/front/src/components/common/InlineSearchBar.vue b/front/src/components/common/InlineSearchBar.vue new file mode 100644 index 0000000000000000000000000000000000000000..0ba6d85a9685d1743d8d3b00852afbf1c319c722 --- /dev/null +++ b/front/src/components/common/InlineSearchBar.vue @@ -0,0 +1,32 @@ +<template> + <form class="ui inline form" @submit.stop.prevent="$emit('search', value)"> + <div :class="['ui', 'action', {icon: isClearable}, 'input']"> + <label for="search-query" class="hidden"> + <translate translate-context="Content/Search/Input.Label/Noun">Search</translate> + </label> + <input id="search-query" name="search-query" type="text" :placeholder="labels.searchPlaceholder" :value="value" @input="$emit('input', $event.target.value)"> + <i v-if="isClearable" class="x link icon" :title="labels.clear" @click="$emit('input', ''); $emit('search', value)"></i> + <button type="submit" class="ui icon basic button"> + <i class="search icon"></i> + </button> + </div> + </form> +</template> +<script> +export default { + props: { + value: {type: String, required: true} + }, + computed: { + labels () { + return { + searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search…'), + clear: this.$pgettext("Content/Library/Button.Label", 'Clear'), + } + }, + isClearable () { + return !!this.value + } + } +} +</script> diff --git a/front/src/components/globals.js b/front/src/components/globals.js index 289fd625e5c53246c61f5f6e5798638fff557d96..e3eac9abb7418808b65a20074caf7613d4941949 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -18,5 +18,6 @@ Vue.component('collapse-link', () => import(/* webpackChunkName: "common" */ "@/ 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")) +Vue.component('inline-search-bar', () => import(/* webpackChunkName: "common" */ "@/components/common/InlineSearchBar")) export default {} diff --git a/front/src/components/library/FileUpload.vue b/front/src/components/library/FileUpload.vue index 7aed6ade5b8190aff63d6fe62e8222ee7725d49b..2086f62917bb3272ba8f4d5e4baba4c31edcb45c 100644 --- a/front/src/components/library/FileUpload.vue +++ b/front/src/components/library/FileUpload.vue @@ -45,17 +45,17 @@ </ul> </div> - <div class="ui form"> + <form class="ui form" @submit.prevent="currentTab = 'uploads'"> <div class="fields"> - <div class="ui four wide field"> + <div class="ui field"> <label><translate translate-context="Content/Library/Input.Label/Noun">Import reference</translate></label> <p><translate translate-context="Content/Library/Paragraph">This reference will be used to group imported files together.</translate></p> <input name="import-ref" type="text" v-model="importReference" /> </div> </div> - </div> - <div class="ui green button" @click="currentTab = 'uploads'"><translate translate-context="Content/Library/Button.Label">Proceed</translate></div> + <button type="submit" class="ui green button"><translate translate-context="Content/Library/Button.Label">Proceed</translate></button> + </form> </div> <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]"> <div :class="['ui', {loading: isLoadingQuota}, 'container']"> @@ -149,6 +149,7 @@ <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'processing'}]"> <library-files-table :needs-refresh="needsRefresh" + ordering-config-name="library.detail.upload" @fetch-start="needsRefresh = false" :filters="{import_reference: importReference}" :custom-objects="Object.values(uploads.objects)"></library-files-table> @@ -253,14 +254,6 @@ export default { }); }); }, - updateProgressBar() { - $(this.$el) - .find(".progress") - .progress({ - total: this.uploads.length * 2, - value: this.uploadedFilesCount + this.finishedJobs - }); - }, handleImportEvent(event) { let self = this; if (event.upload.import_reference != self.importReference) { @@ -387,12 +380,6 @@ export default { } }, watch: { - uploadedFilesCount() { - this.updateProgressBar(); - }, - finishedJobs() { - this.updateProgressBar(); - }, importReference: _.debounce(function() { this.$router.replace({ query: { import: this.importReference } }); }, 500), @@ -400,6 +387,11 @@ export default { if (newValue <= 0) { this.$refs.upload.active = false; } + }, + 'uploads.finished' (v, o) { + if (v > o) { + this.$emit('uploads-finished', v - o) + } } } }; diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index ed528cd9656a50737eac7042f66d8f7c0de767bd..2b22a4ca6eda90366737f5c83ebbbcf0f6debbbe 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -1,6 +1,6 @@ <template> <div class="main library pusher"> - <router-view :key="$route.fullPath"></router-view> + <router-view></router-view> </div> </template> diff --git a/front/src/components/mixins/Ordering.vue b/front/src/components/mixins/Ordering.vue index ec11b0b00c6b002bdfa3fc0a9a8355faa79c35d5..d91f28db54cbf49f926bd08dec55540d19aa7457 100644 --- a/front/src/components/mixins/Ordering.vue +++ b/front/src/components/mixins/Ordering.vue @@ -1,11 +1,12 @@ <script> export default { props: { - defaultOrdering: {type: String, required: false} + defaultOrdering: {type: String, required: false}, + orderingConfigName: {type: String, required: false}, }, computed: { orderingConfig () { - return this.$store.state.ui.routePreferences[this.$route.name] + return this.$store.state.ui.routePreferences[this.orderingConfigName || this.$route.name] }, paginateBy: { set(paginateBy) { diff --git a/front/src/filters.js b/front/src/filters.js index 91d4e455ca94661a3daa97daa55c423a6fa5c7c2..6465e973a8f5754853132dd6075504b87b7fe858 100644 --- a/front/src/filters.js +++ b/front/src/filters.js @@ -3,6 +3,9 @@ import Vue from 'vue' import moment from 'moment' export function truncate (str, max, ellipsis, middle) { + if (max === 0) { + return + } max = max || 100 ellipsis = ellipsis || '…' if (str.length <= max) { diff --git a/front/src/router/index.js b/front/src/router/index.js index 15350a4fff8a012682df3eb3ffe4e0fea72fb473..07bb210e32dfef63518e2b33d52e29e29951b805 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -233,27 +233,6 @@ export default new Router({ import( /* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Home" ) - }, - { - path: ":id/upload", - name: "content.libraries.detail.upload", - component: () => - import( - /* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Upload" - ), - props: route => ({ - id: route.params.id, - defaultImportReference: route.query.import - }) - }, - { - path: ":id", - name: "content.libraries.detail", - component: () => - import( - /* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Detail" - ), - props: true } ] }, @@ -812,6 +791,68 @@ export default new Router({ props: true } ] + }, + { + // browse a single library via it's uuid + path: ":id([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})", + props: true, + component: () => + import( + /* webpackChunkName: "library" */ "@/views/library/DetailBase" + ), + children: [ + { + path: "", + name: "library.detail", + component: () => + import( + /* webpackChunkName: "library" */ "@/views/library/DetailOverview" + ) + }, + { + path: "albums", + name: "library.detail.albums", + component: () => + import( + /* webpackChunkName: "library" */ "@/views/library/DetailAlbums" + ) + }, + { + path: "tracks", + name: "library.detail.tracks", + component: () => + import( + /* webpackChunkName: "library" */ "@/views/library/DetailTracks" + ) + }, + { + path: "edit", + name: "library.detail.edit", + component: () => + import( + /* webpackChunkName: "auth-libraries" */ "@/views/library/Edit" + ) + }, + { + path: "upload", + name: "library.detail.upload", + component: () => + import( + /* webpackChunkName: "auth-libraries" */ "@/views/library/Upload" + ), + props: route => ({ + defaultImportReference: route.query.import + }) + }, + // { + // path: "episodes", + // name: "library.detail.episodes", + // component: () => + // import( + // /* webpackChunkName: "library" */ "@/views/library/DetailEpisodes" + // ) + // }, + ] } ] }, diff --git a/front/src/store/auth.js b/front/src/store/auth.js index 49f8ddde54121a51b09a7d9aaa14ad56966654d0..f11f8ee7faf1664b5b1ea65e1abd03d09e421a40 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -143,6 +143,7 @@ export default { } dispatch('favorites/fetch', null, { root: true }) dispatch('channels/fetchSubscriptions', null, { root: true }) + dispatch('libraries/fetchFollows', null, { root: true }) dispatch('moderation/fetchContentFilters', null, { root: true }) dispatch('playlists/fetchOwn', null, { root: true }) }, (response) => { diff --git a/front/src/store/index.js b/front/src/store/index.js index c098aa1ec5436ff88e867a7e359efed0a8f21336..33323749f6a78d10def462cbb0eb47799ded3b71 100644 --- a/front/src/store/index.js +++ b/front/src/store/index.js @@ -4,6 +4,7 @@ import createPersistedState from 'vuex-persistedstate' import favorites from './favorites' import channels from './channels' +import libraries from './libraries' import auth from './auth' import instance from './instance' import moderation from './moderation' @@ -20,6 +21,7 @@ export default new Vuex.Store({ ui, auth, channels, + libraries, favorites, instance, moderation, diff --git a/front/src/store/libraries.js b/front/src/store/libraries.js new file mode 100644 index 0000000000000000000000000000000000000000..77632de8be1afbbb588db4ac3094db714dba0c4d --- /dev/null +++ b/front/src/store/libraries.js @@ -0,0 +1,73 @@ +import axios from 'axios' +import logger from '@/logging' + +export default { + namespaced: true, + state: { + followedLibraries: [], + followsByLibrary: {}, + count: 0, + }, + mutations: { + follows: (state, {library, follow}) => { + let replacement = {...state.followsByLibrary} + if (follow) { + if (state.followedLibraries.indexOf(library) === -1) { + state.followedLibraries.push(library) + replacement[library] = follow + } + } else { + let i = state.followedLibraries.indexOf(library) + if (i > -1) { + state.followedLibraries.splice(i, 1) + replacement[library] = null + } + } + state.followsByLibrary = replacement + state.count = state.followedLibraries.length + }, + reset (state) { + state.followedLibraries = [] + state.followsByLibrary = {} + state.count = 0 + }, + }, + getters: { + follow: (state) => (library) => { + return state.followsByLibrary[library] + } + }, + actions: { + set ({commit, state}, {uuid, value}) { + if (value) { + return axios.post(`federation/follows/library/`, {target: uuid}).then((response) => { + logger.default.info('Successfully subscribed to library') + commit('follows', {library: uuid, follow: response.data}) + }, (response) => { + logger.default.info('Error while subscribing to library') + commit('follows', {library: uuid, follow: null}) + }) + } else { + let follow = state.followsByLibrary[uuid] + return axios.delete(`federation/follows/library/${follow.uuid}/`).then((response) => { + logger.default.info('Successfully unsubscribed from library') + commit('follows', {library: uuid, follow: null}) + }, (response) => { + logger.default.info('Error while unsubscribing from library') + commit('follows', {library: uuid, follow: follow}) + }) + } + }, + toggle ({getters, dispatch}, uuid) { + dispatch('set', {uuid, value: !getters['follow'](uuid)}) + }, + fetchFollows ({dispatch, state, commit, rootState}, url) { + let promise = axios.get('federation/follows/library/all/') + return promise.then((response) => { + response.data.results.forEach(result => { + commit('follows', {library: result.library, follow: result}) + }) + }) + } + } +} diff --git a/front/src/store/ui.js b/front/src/store/ui.js index e2ca0e636a52485ba22ee74ae95ffe6dc07cb2e3..a666af1ee089c62fb4e84789e0709b542c105b6c 100644 --- a/front/src/store/ui.js +++ b/front/src/store/ui.js @@ -77,12 +77,17 @@ export default { orderingDirection: "-", ordering: "creation_date", }, - "content.libraries.detail": { + "library.detail.upload": { paginateBy: 50, orderingDirection: "-", ordering: "creation_date", }, - "content.libraries.detail.upload": { + "library.detail.edit": { + paginateBy: 50, + orderingDirection: "-", + ordering: "creation_date", + }, + "library.detail": { paginateBy: 50, orderingDirection: "-", ordering: "creation_date", diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index 435851ea9c706d8ff13b06290840d36fde4edc89..76bbc35cb0272cdd3f53a42e07edbf866c5fd036 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -174,9 +174,6 @@ html { } } -.main-pusher { - padding: 1.5rem 0; -} .ui.stripe.segment, #footer { padding: 1em; @@ -198,6 +195,9 @@ html { .center.aligned.menu { justify-content: center; } +.text.center.aligned { + text-align: center; +} .ellipsis:not(.icon) { text-overflow: ellipsis; white-space: nowrap; @@ -659,5 +659,8 @@ input + .help { .modal > .header { text-align: center; } +.ui.header .content { + display: block; +} @import "./themes/_light.scss"; @import "./themes/_dark.scss"; diff --git a/front/src/views/auth/ProfileOverview.vue b/front/src/views/auth/ProfileOverview.vue index 5c524a107abe206cceab53fcd11903499a94297b..12275d1a651765ed60017fa243feed0fd292965d 100644 --- a/front/src/views/auth/ProfileOverview.vue +++ b/front/src/views/auth/ProfileOverview.vue @@ -20,8 +20,15 @@ </div> </h2> <channels-widget :filters="{scope: `actor:${object.full_username}`}"></channels-widget> - <h2 class="ui header"> + <h2 class="ui with-actions header"> <translate translate-context="Content/Profile/Header">User Libraries</translate> + <div class="actions" v-if="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername"> + <router-link :to="{name: 'content.libraries.index'}"> + <i class="plus icon"></i> + <translate translate-context="Content/Profile/Button">Add new</translate> + </router-link> + </div> + </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> diff --git a/front/src/views/content/libraries/Card.vue b/front/src/views/content/libraries/Card.vue index 4e1b497df25fa85b98bf9ad514782b601765c770..6ca0f4a5aedbdda4144be63f3dbcc33d5ada7775 100644 --- a/front/src/views/content/libraries/Card.vue +++ b/front/src/views/content/libraries/Card.vue @@ -42,10 +42,10 @@ </div> </div> <div class="ui bottom basic attached buttons"> - <router-link :to="{name: 'content.libraries.detail.upload', params: {id: library.uuid}}" class="ui button"> + <router-link :to="{name: 'library.detail.upload', params: {id: library.uuid}}" class="ui button"> <translate translate-context="Content/Library/Card.Button.Label/Verb">Upload</translate> </router-link> - <router-link :to="{name: 'content.libraries.detail', params: {id: library.uuid}}" exact class="ui button"> + <router-link :to="{name: 'library.detail', params: {id: library.uuid}}" exact class="ui button"> <translate translate-context="Content/Library/Card.Button.Label/Noun">Details</translate> </router-link> </div> diff --git a/front/src/views/content/libraries/Detail.vue b/front/src/views/content/libraries/Detail.vue deleted file mode 100644 index c936baa8256ba2deb248465e324dfafd6bbec2aa..0000000000000000000000000000000000000000 --- a/front/src/views/content/libraries/Detail.vue +++ /dev/null @@ -1,129 +0,0 @@ -<template> - <section class="ui vertical aligned stripe segment"> - <div v-if="isLoadingLibrary" :class="['ui', {'active': isLoadingLibrary}, 'inverted', 'dimmer']"> - <div class="ui text loader"><translate translate-context="Content/Library/Paragraph">Loading library data…</translate></div> - </div> - <detail-area v-else :library="library"> - <div class="ui top attached tabular menu"> - <a :class="['item', {active: currentTab === 'follows'}]" @click="currentTab = 'follows'"><translate translate-context="Content/Federation/*/Noun">Followers</translate></a> - <a :class="['item', {active: currentTab === 'tracks'}]" @click="currentTab = 'tracks'"><translate translate-context="*/*/*">Tracks</translate></a> - <a :class="['item', {active: currentTab === 'edit'}]" @click="currentTab = 'edit'"><translate translate-context="Content/*/Button.Label/Verb">Edit</translate></a> - </div> - <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'follows'}]"> - <div class="ui form"> - <div class="field"> - <label><translate translate-context="Content/Library/Title">Sharing link</translate></label> - <p><translate translate-context="Content/Library/Paragraph">Share this link with other users so they can request access to your library.</translate></p> - <copy-input :value="library.fid" /> - </div> - </div> - <div class="ui hidden divider"></div> - <div v-if="isLoadingFollows" :class="['ui', {'active': isLoadingFollows}, 'inverted', 'dimmer']"> - <div class="ui text loader"><translate translate-context="Content/Library/Paragraph">Loading followers…</translate></div> - </div> - <table v-else-if="follows && follows.count > 0" class="ui table"> - <thead> - <tr> - <th><translate translate-context="Content/Library/Table.Label">User</translate></th> - <th><translate translate-context="Content/Library/Table.Label">Date</translate></th> - <th><translate translate-context="*/*/*">Status</translate></th> - <th><translate translate-context="Content/Library/Table.Label">Action</translate></th> - </tr> - </thead> - <tr v-for="follow in follows.results" :key="follow.fid"> - <td><actor-link :actor="follow.actor" /></td> - <td><human-date :date="follow.creation_date" /></td> - <td> - <span :class="['ui', 'yellow', 'basic', 'label']" v-if="follow.approved === null"> - <translate translate-context="Content/Library/Table/Short">Pending approval</translate> - </span> - <span :class="['ui', 'green', 'basic', 'label']" v-else-if="follow.approved === true"> - <translate translate-context="Content/Library/Table/Short">Accepted</translate> - </span> - <span :class="['ui', 'red', 'basic', 'label']" v-else-if="follow.approved === false"> - <translate translate-context="Content/Library/*/Short">Rejected</translate> - </span> - </td> - <td> - <div @click="updateApproved(follow, true)" :class="['ui', 'mini', 'icon', 'labeled', 'green', 'button']" v-if="follow.approved === null || follow.approved === false"> - <i class="ui check icon"></i> <translate translate-context="Content/Library/Button.Label">Accept</translate> - </div> - <div @click="updateApproved(follow, false)" :class="['ui', 'mini', 'icon', 'labeled', 'red', 'button']" v-if="follow.approved === null || follow.approved === true"> - <i class="ui x icon"></i> <translate translate-context="Content/Library/Button.Label">Reject</translate> - </div> - </td> - </tr> - - </table> - <p v-else><translate translate-context="Content/Library/Paragraph">Nobody is following this library</translate></p> - </div> - <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'tracks'}]"> - <library-files-table :filters="{library: library.uuid}"></library-files-table> - </div> - <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'edit'}]"> - <library-form :library="library" @updated="libraryUpdated" @deleted="libraryDeleted" /> - </div> - </detail-area> - </section> -</template> - -<script> -import axios from "axios" -import DetailMixin from "./DetailMixin" -import DetailArea from "./DetailArea" -import LibraryForm from "./Form" -import LibraryFilesTable from "./FilesTable" - -export default { - mixins: [DetailMixin], - components: { - DetailArea, - LibraryForm, - LibraryFilesTable - }, - data() { - return { - currentTab: "follows", - isLoadingFollows: false, - follows: null - } - }, - created() { - this.fetchFollows() - }, - methods: { - libraryUpdated() { - this.hiddenForm = true - this.fetch() - }, - libraryDeleted() { - this.$router.push({ - name: "content.libraries.index" - }) - }, - fetchFollows() { - let self = this - self.isLoadingLibrary = true - axios.get(`libraries/${this.id}/follows/`).then(response => { - self.follows = response.data - self.isLoadingFollows = false - }) - }, - updateApproved(follow, value) { - let self = this - let action - if (value) { - action = "accept" - } else { - action = "reject" - } - axios - .post(`federation/follows/library/${follow.uuid}/${action}/`) - .then(response => { - follow.isLoading = false - follow.approved = value - }) - } - } -} -</script> diff --git a/front/src/views/content/libraries/DetailArea.vue b/front/src/views/content/libraries/DetailArea.vue deleted file mode 100644 index 62928c05c1123bb2413047f01807275849e0fcbb..0000000000000000000000000000000000000000 --- a/front/src/views/content/libraries/DetailArea.vue +++ /dev/null @@ -1,37 +0,0 @@ -<template> - <div> - <div class="ui two column row"> - <div class="column"> - <h3 class="ui header"><translate translate-context="Content/Library/Title">Current library</translate></h3> - <library-card :library="library" /> - <radio-button :type="'library'" :object-id="library.uuid"></radio-button> - </div> - </div> - <div class="ui hidden divider"></div> - <slot></slot> - </div> -</template> - -<script> -import RadioButton from '@/components/radios/Button' -import LibraryCard from './Card' - -export default { - props: ['library'], - components: { - LibraryCard, - RadioButton, - }, - computed: { - links () { - let upload = this.$pgettext('Content/Library/Card.Button.Label/Verb', 'Upload') - return [ - { - name: 'libraries.detail.upload', - label: upload - } - ] - } - } -} -</script> diff --git a/front/src/views/content/libraries/DetailMixin.vue b/front/src/views/content/libraries/DetailMixin.vue deleted file mode 100644 index 92ff8452d88bbe026c413b92de9012c9a3401e8c..0000000000000000000000000000000000000000 --- a/front/src/views/content/libraries/DetailMixin.vue +++ /dev/null @@ -1,26 +0,0 @@ -<script> -import axios from 'axios' - -export default { - props: ['id'], - created () { - this.fetch() - }, - data () { - return { - isLoadingLibrary: false, - library: null - } - }, - methods: { - fetch () { - let self = this - self.isLoadingLibrary = true - axios.get(`libraries/${this.id}/`).then((response) => { - self.library = response.data - self.isLoadingLibrary = false - }) - } - } -} -</script> diff --git a/front/src/views/content/libraries/Home.vue b/front/src/views/content/libraries/Home.vue index 69b4ce3a2863ed59aad101f3ccc2881c968bbdda..2e5e394988bc5be8c117544ca513750662e93dc4 100644 --- a/front/src/views/content/libraries/Home.vue +++ b/front/src/views/content/libraries/Home.vue @@ -62,8 +62,7 @@ export default { }) }, libraryCreated(library) { - this.hiddenForm = true - this.libraries.unshift(library) + this.$router.push({name: 'library.detail', params: {id: library.uuid}}) } } } diff --git a/front/src/views/content/libraries/Upload.vue b/front/src/views/content/libraries/Upload.vue deleted file mode 100644 index 5fc7234ecd03fe82dd3019ba18d2997c55673aeb..0000000000000000000000000000000000000000 --- a/front/src/views/content/libraries/Upload.vue +++ /dev/null @@ -1,38 +0,0 @@ -<template> - <div class="ui vertical aligned stripe segment"> - <div v-if="isLoadingLibrary" :class="['ui', {'active': isLoadingLibrary}, 'inverted', 'dimmer']"> - <div class="ui text loader"><translate translate-context="Content/Library/Paragraph">Loading library data…</translate></div> - </div> - <detail-area v-else :library="library"> - <file-upload ref="fileupload" :default-import-reference="defaultImportReference" :library="library" /> - </detail-area> - </div> -</template> - -<script> -import DetailMixin from './DetailMixin' -import DetailArea from './DetailArea' - -import FileUpload from '@/components/library/FileUpload' -export default { - mixins: [DetailMixin], - props: ['defaultImportReference'], - components: { - DetailArea, - FileUpload - }, - beforeRouteLeave (to, from, next){ - if (this.$refs.fileupload.hasActiveUploads){ - const answer = window.confirm('This page is asking you to confirm that you want to leave - data you have entered may not be saved.') - if (answer) { - next() - } else { - next(false) - } - } - else{ - next() - } - } -} -</script> diff --git a/front/src/views/content/remote/Card.vue b/front/src/views/content/remote/Card.vue index 2ad16c03f0fe71db69b0e8e9c140d2b3ea87a9e6..decddc8fc97060e07436aad8b70584e0d5a2962d 100644 --- a/front/src/views/content/remote/Card.vue +++ b/front/src/views/content/remote/Card.vue @@ -2,7 +2,9 @@ <div class="ui card"> <div class="content"> <div class="header"> - {{ library.name }} + <router-link :to="{name: 'library.detail', params: {id: library.uuid}}"> + {{ library.name }} + </router-link> <div class="ui right floated dropdown"> <i class="ellipsis vertical grey large icon nomargin"></i> <div class="menu"> diff --git a/front/src/views/library/DetailAlbums.vue b/front/src/views/library/DetailAlbums.vue new file mode 100644 index 0000000000000000000000000000000000000000..35dec511be654b3f2d0459d34e13c45849fd1500 --- /dev/null +++ b/front/src/views/library/DetailAlbums.vue @@ -0,0 +1,28 @@ +<template> + <section> + <album-widget + :key="String(object.uploads_count)" + :header="false" + :search="true" + :controls="false" + :filters="{playable: true, ordering: '-creation_date', library: object.uuid}"> + <empty-state slot="empty-state"> + <p> + <translate key="1" v-if="isOwner" translate-context="*/*/*">This library is empty, you should upload something in it!</translate> + <translate key="2" v-else translate-context="*/*/*">You may need to follow this library to see its content.</translate> + </p> + </empty-state> + </album-widget> + </section> +</template> + +<script> +import AlbumWidget from "@/components/audio/album/Widget" + +export default { + props: ['object', 'isOwner'], + components: { + AlbumWidget, + }, +} +</script> diff --git a/front/src/views/library/DetailBase.vue b/front/src/views/library/DetailBase.vue new file mode 100644 index 0000000000000000000000000000000000000000..b480d189e49c9a5072be0ee416e69aec64a4e3b1 --- /dev/null +++ b/front/src/views/library/DetailBase.vue @@ -0,0 +1,197 @@ +<template> + <main v-title="labels.title"> + <div class="ui vertical stripe segment container"> + <div v-if="isLoading" class="ui centered active inline loader"></div> + <div class="ui stackable grid" v-else-if="object"> + <div class="ui five wide column"> + <div class="ui pointing dropdown icon small basic right floated button" ref="dropdown" v-dropdown="{direction: 'downward'}" style="position: absolute; 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({library: 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.library.libraries.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> + <h1 class="ui header"> + <div class="ui hidden divider"></div> + <div class="ellipsis content"> + <i class="layer group small icon"></i> + <span :title="object.name">{{ object.name }}</span> + <div class="ui very small hidden divider"></div> + <div class="sub header ellipsis" :title="object.full_username"> + <actor-link :avatar="false" :actor="object.actor" :truncate-length="0"> + <translate translate-context="*/*/*" :translate-params="{username: object.actor.full_username}">Owned by %{ username }</translate> + </actor-link> + </div> + </div> + </h1> + <p> + <span v-if="object.privacy_level === 'me'" :title="labels.tooltips.me"> + <i class="lock icon"></i> + {{ labels.visibility.me }} + </span> + <span + v-else-if="object.privacy_level === 'instance'" :title="labels.tooltips.instance"> + <i class="lock open icon"></i> + {{ labels.visibility.instance }} + </span> + <span v-else-if="object.privacy_level === 'everyone'" :title="labels.tooltips.everyone"> + <i class="globe icon"></i> + {{ labels.visibility.everyone }} + </span> · + <i class="music icon"></i> + <translate translate-context="*/*/*" :translate-params="{count: object.uploads_count}" :translate-n="object.uploads_count" translate-plural="%{ count } tracks">%{ count } track</translate> + <span v-if="object.size"> + · <i class="database icon"></i> + {{ object.size | humanSize }} + </span> + </p> + + <div class="header-buttons"> + <div class="ui small buttons"> + <radio-button :disabled="!isPlayable" type="library" :object-id="object.uuid"></radio-button> + </div> + <div class="ui small buttons" v-if="!isOwner"> + <library-follow-button v-if="$store.state.auth.authenticated" :library="object"></library-follow-button> + </div> + </div> + + <template v-if="$store.getters['ui/layoutVersion'] === 'large'"> + <rendered-description + :content="object.description ? {html: object.description} : null" + :update-url="`channels/${object.uuid}/`" + :can-update="false"></rendered-description> + <div class="ui hidden divider"></div> + </template> + <h5 class="ui header"> + <label for="copy-input"> + <translate translate-context="Content/Library/Title">Sharing link</translate> + </label> + </h5> + <p><translate translate-context="Content/Library/Paragraph">Share this link with other users so they can request access to this library by copy-pasting it in their pod search bar.</translate></p> + <copy-input :value="object.fid" /> + </div> + <div class="ui eleven wide column"> + <div class="ui head vertical stripe segment"> + <div class="ui container"> + <div class="ui secondary pointing center aligned menu"> + <router-link class="item" :exact="true" :to="{name: 'library.detail'}"> + <translate translate-context="*/*/*">Artists</translate> + </router-link> + <router-link class="item" :exact="true" :to="{name: 'library.detail.albums'}"> + <translate translate-context="*/*/*">Albums</translate> + </router-link> + <router-link class="item" :exact="true" :to="{name: 'library.detail.tracks'}"> + <translate translate-context="*/*/*">Tracks</translate> + </router-link> + <router-link v-if="isOwner" class="item" :exact="true" :to="{name: 'library.detail.upload'}"> + <i class="upload icon"></i> + <translate translate-context="Content/Library/Card.Button.Label/Verb">Upload</translate> + </router-link> + <router-link v-if="isOwner" class="item" :exact="true" :to="{name: 'library.detail.edit'}"> + <i class="pencil icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> + </router-link> + </div> + <div class="ui hidden divider"></div> + <keep-alive> + <router-view + @updated="fetchData" + @uploads-finished="object.uploads_count += $event" + :is-owner="isOwner" + :object="object"></router-view> + </keep-alive> + </div> + </div> + </div> + </div> + </div> + </main> +</template> + +<script> +import axios from "axios" +import PlayButton from "@/components/audio/PlayButton" +import LibraryFollowButton from "@/components/audio/LibraryFollowButton" +import ReportMixin from '@/components/mixins/Report' +import RadioButton from '@/components/radios/Button' + +export default { + mixins: [ReportMixin], + props: ["id"], + components: { + PlayButton, + RadioButton, + LibraryFollowButton + }, + data() { + return { + isLoading: true, + object: null, + latestTracks: null, + } + }, + beforeRouteUpdate (to, from, next) { + to.meta.preserveScrollPosition = true + next() + }, + async created() { + await this.fetchData() + }, + methods: { + async fetchData() { + var self = this + this.isLoading = true + let libraryPromise = axios.get(`libraries/${this.id}`).then(response => { + self.object = response.data + }) + await libraryPromise + self.isLoading = false + }, + }, + computed: { + isOwner () { + return this.$store.state.auth.authenticated && this.object.actor.full_username === this.$store.state.auth.fullUsername + }, + labels () { + return { + title: this.$pgettext('*/*/*', 'Library'), + visibility: { + me: this.$pgettext('Content/Library/Card.Help text', 'Private'), + instance: this.$pgettext('Content/Library/Card.Help text', 'Restricted'), + everyone: this.$pgettext('Content/Library/Card.Help text', 'Public'), + }, + tooltips: { + me: this.$pgettext('Content/Library/Card.Help text', 'This library is private and your approval from its owner is needed to access its content'), + instance: this.$pgettext('Content/Library/Card.Help text', 'This library is restricted to users on this pod only'), + everyone: this.$pgettext('Content/Library/Card.Help text', 'This library is public and you can access its content freely'), + } + } + }, + isPlayable () { + return this.object.uploads_count > 0 && ( + this.isOwner || + this.object.privacy_level === 'public' || + (this.object.privacy_level === 'instance' && this.$store.state.auth.authenticated && this.object.actor.domain === this.$store.getters['instance/domain']) || + (this.$store.getters['libraries/follow'](this.object.uuid) || {}).approved === true + ) + }, + }, + watch: { + id() { + this.fetchData() + } + } +} +</script> diff --git a/front/src/views/library/DetailOverview.vue b/front/src/views/library/DetailOverview.vue new file mode 100644 index 0000000000000000000000000000000000000000..facbfb708d9fb7d168e7536d5fbd497863ad7098 --- /dev/null +++ b/front/src/views/library/DetailOverview.vue @@ -0,0 +1,41 @@ +<template> + <section> + <template v-if="$store.getters['ui/layoutVersion'] === 'small'"> + <rendered-description + :content="object.description ? {html: object.description} : null" + :update-url="`channels/${object.uuid}/`" + :can-update="false"></rendered-description> + <div class="ui hidden divider"></div> + </template> + <artist-widget + :key="object.uploads_count" + ref="artists" + :header="false" + :search="true" + :controls="false" + :filters="{playable: true, ordering: '-creation_date', library: object.uuid}"> + <empty-state slot="empty-state"> + <p> + <translate key="1" v-if="isOwner" translate-context="*/*/*">This library is empty, you should upload something in it!</translate> + <translate key="2" v-else translate-context="*/*/*">You may need to follow this library to see its content.</translate> + </p> + </empty-state> + </artist-widget> + </section> +</template> + +<script> +import ArtistWidget from "@/components/audio/artist/Widget" + +export default { + props: ['object', 'isOwner'], + components: { + ArtistWidget, + }, + data () { + return { + query: '' + } + } +} +</script> diff --git a/front/src/views/library/DetailTracks.vue b/front/src/views/library/DetailTracks.vue new file mode 100644 index 0000000000000000000000000000000000000000..0fa1869a9ebcb7a1d6f00f4af0ed0a7df7b693c6 --- /dev/null +++ b/front/src/views/library/DetailTracks.vue @@ -0,0 +1,27 @@ +<template> + <section> + <track-table + :key="object.uploads_count" + :display-actions="false" + :search="true" + :filters="{playable: true, library: object.uuid, ordering: '-creation_date'}"> + <empty-state slot="empty-state"> + <p> + <translate key="1" v-if="isOwner" translate-context="*/*/*">This library is empty, you should upload something in it!</translate> + <translate key="2" v-else translate-context="*/*/*">You may need to follow this library to see its content.</translate> + </p> + </empty-state> + </track-table> + </section> +</template> + +<script> +import TrackTable from '@/components/audio/track/Table' + +export default { + props: ['object', 'isOwner'], + components: { + TrackTable, + }, +} +</script> diff --git a/front/src/views/library/Edit.vue b/front/src/views/library/Edit.vue new file mode 100644 index 0000000000000000000000000000000000000000..e14ffcca0da7be1fc8c33592caa54ce26bb0e052 --- /dev/null +++ b/front/src/views/library/Edit.vue @@ -0,0 +1,101 @@ +<template> + <section> + <library-form :library="object" @updated="$emit('updated')" @deleted="$router.push({name: 'profile.overview', params: {username: $store.state.auth.username}})" /> + <div class="ui hidden divider"></div> + <h2 class="ui header"> + <translate translate-context="*/*/*">Library contents</translate> + </h2> + <library-files-table :filters="{library: object.uuid}"></library-files-table> + + <div class="ui hidden divider"></div> + <h2 class="ui header"> + <translate translate-context="Content/Federation/*/Noun">Followers</translate> + </h2> + <div v-if="isLoadingFollows" :class="['ui', {'active': isLoadingFollows}, 'inverted', 'dimmer']"> + <div class="ui text loader"><translate translate-context="Content/Library/Paragraph">Loading followers…</translate></div> + </div> + <table v-else-if="follows && follows.count > 0" class="ui table"> + <thead> + <tr> + <th><translate translate-context="Content/Library/Table.Label">User</translate></th> + <th><translate translate-context="Content/Library/Table.Label">Date</translate></th> + <th><translate translate-context="*/*/*">Status</translate></th> + <th><translate translate-context="Content/Library/Table.Label">Action</translate></th> + </tr> + </thead> + <tr v-for="follow in follows.results" :key="follow.fid"> + <td><actor-link :actor="follow.actor" /></td> + <td><human-date :date="follow.creation_date" /></td> + <td> + <span :class="['ui', 'yellow', 'basic', 'label']" v-if="follow.approved === null"> + <translate translate-context="Content/Library/Table/Short">Pending approval</translate> + </span> + <span :class="['ui', 'green', 'basic', 'label']" v-else-if="follow.approved === true"> + <translate translate-context="Content/Library/Table/Short">Accepted</translate> + </span> + <span :class="['ui', 'red', 'basic', 'label']" v-else-if="follow.approved === false"> + <translate translate-context="Content/Library/*/Short">Rejected</translate> + </span> + </td> + <td> + <div @click="updateApproved(follow, true)" :class="['ui', 'mini', 'icon', 'labeled', 'green', 'button']" v-if="follow.approved === null || follow.approved === false"> + <i class="ui check icon"></i> <translate translate-context="Content/Library/Button.Label">Accept</translate> + </div> + <div @click="updateApproved(follow, false)" :class="['ui', 'mini', 'icon', 'labeled', 'red', 'button']" v-if="follow.approved === null || follow.approved === true"> + <i class="ui x icon"></i> <translate translate-context="Content/Library/Button.Label">Reject</translate> + </div> + </td> + </tr> + + </table> + <p v-else><translate translate-context="Content/Library/Paragraph">Nobody is following this library</translate></p> + </section> +</template> + +<script> +import LibraryFilesTable from "@/views/content/libraries/FilesTable" +import LibraryForm from "@/views/content/libraries/Form" +import axios from "axios" + +export default { + props: ['object'], + components: { + LibraryForm, + LibraryFilesTable + }, + data () { + return { + isLoadingFollows: false, + follows: null + } + }, + created() { + this.fetchFollows() + }, + methods: { + fetchFollows() { + let self = this + self.isLoadingLibrary = true + axios.get(`libraries/${this.object.uuid}/follows/`).then(response => { + self.follows = response.data + self.isLoadingFollows = false + }) + }, + updateApproved(follow, value) { + let self = this + let action + if (value) { + action = "accept" + } else { + action = "reject" + } + axios + .post(`federation/follows/library/${follow.uuid}/${action}/`) + .then(response => { + follow.isLoading = false + follow.approved = value + }) + } + } +} +</script> diff --git a/front/src/views/library/Upload.vue b/front/src/views/library/Upload.vue new file mode 100644 index 0000000000000000000000000000000000000000..66442c43a5e0e66c50a5bdf9766398af0bdadd0b --- /dev/null +++ b/front/src/views/library/Upload.vue @@ -0,0 +1,35 @@ +<template> + <section> + <file-upload ref="fileupload" + :default-import-reference="defaultImportReference" + :library="object" + @uploads-finished="$emit('uploads-finished', $event)" /> + + </section> +</template> + +<script> + +import FileUpload from '@/components/library/FileUpload' + +export default { + props: ['object', 'defaultImportReference'], + components: { + FileUpload, + }, + + beforeRouteLeave (to, from, next){ + if (this.$refs.fileupload.hasActiveUploads){ + const answer = window.confirm('This page is asking you to confirm that you want to leave - data you have entered may not be saved.') + if (answer) { + next() + } else { + next(false) + } + } + else{ + next() + } + } +} +</script>