Resolve "Support browsing a specific library content"

This commit is contained in:
Eliot Berriot 2020-03-04 22:18:28 +01:00
parent ecc3ed3ac3
commit b166182762
47 changed files with 1020 additions and 336 deletions

View File

@ -21,6 +21,11 @@ urlpatterns = [
spa_views.library_playlist, spa_views.library_playlist,
name="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( urls.re_path(
r"^channels/(?P<uuid>[0-9a-f-]+)/?$", r"^channels/(?P<uuid>[0-9a-f-]+)/?$",
audio_spa_views.channel_detail_uuid, audio_spa_views.channel_detail_uuid,

View File

@ -98,6 +98,26 @@ class LibraryFollowViewSet(
update_follow(follow, approved=False) update_follow(follow, approved=False)
return response.Response(status=204) 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): class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
lookup_field = "uuid" lookup_field = "uuid"

View File

@ -201,3 +201,26 @@ def find_alternate(response_text):
parser.feed(response_text) parser.feed(response_text)
except StopParsing: except StopParsing:
return parser.result 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

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.conf import settings
from django.core import paginator from django.core import paginator
from django.db.models import Prefetch from django.db.models import Prefetch
from django.http import HttpResponse 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 rest_framework.decorators import action
from funkwhale_api.common import preferences 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.moderation import models as moderation_models
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import utils as music_utils 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 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): class AuthenticatedIfAllowListEnabled(permissions.BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
allow_list_enabled = preferences.get("moderation__allow_list_enabled") allow_list_enabled = preferences.get("moderation__allow_list_enabled")
@ -204,13 +212,18 @@ class MusicLibraryViewSet(
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
serializer_class = serializers.LibrarySerializer serializer_class = serializers.LibrarySerializer
queryset = ( 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" lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
lb = self.get_object() 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 = { conf = {
"id": lb.get_federation_id(), "id": lb.get_federation_id(),
"actor": lb.actor, "actor": lb.actor,

View File

@ -41,8 +41,30 @@ class ChannelFilterSet(filters.FilterSet):
return queryset.filter(pk__in=ids) 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( 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"]) 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"] hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"]
include_channels_field = "channel" include_channels_field = "channel"
library_filter_field = "track__artist"
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
@ -70,6 +93,7 @@ class ArtistFilter(
class TrackFilter( class TrackFilter(
ChannelFilterSet, ChannelFilterSet,
LibraryFilterSet,
audio_filters.IncludeChannelsFilterSet, audio_filters.IncludeChannelsFilterSet,
moderation_filters.HiddenContentFilterSet, moderation_filters.HiddenContentFilterSet,
): ):
@ -99,6 +123,7 @@ class TrackFilter(
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"] hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"]
include_channels_field = "artist__channel" include_channels_field = "artist__channel"
channel_filter_field = "track" channel_filter_field = "track"
library_filter_field = "track"
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
@ -156,6 +181,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
class AlbumFilter( class AlbumFilter(
ChannelFilterSet, ChannelFilterSet,
LibraryFilterSet,
audio_filters.IncludeChannelsFilterSet, audio_filters.IncludeChannelsFilterSet,
moderation_filters.HiddenContentFilterSet, moderation_filters.HiddenContentFilterSet,
): ):
@ -175,6 +201,7 @@ class AlbumFilter(
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"] hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"]
include_channels_field = "artist__channel" include_channels_field = "artist__channel"
channel_filter_field = "track__album" channel_filter_field = "track__album"
library_filter_field = "track__album"
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)

View File

@ -1110,6 +1110,12 @@ LIBRARY_PRIVACY_LEVEL_CHOICES = [
class LibraryQuerySet(models.QuerySet): 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): def with_follows(self, actor):
return self.prefetch_related( return self.prefetch_related(
models.Prefetch( models.Prefetch(
@ -1123,14 +1129,14 @@ class LibraryQuerySet(models.QuerySet):
from funkwhale_api.federation.models import LibraryFollow from funkwhale_api.federation.models import LibraryFollow
if actor is None: 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) me_query = models.Q(privacy_level="me", actor=actor)
instance_query = models.Q(privacy_level="instance", actor__domain=actor.domain) instance_query = models.Q(privacy_level="instance", actor__domain=actor.domain)
followed_libraries = LibraryFollow.objects.filter( followed_libraries = LibraryFollow.objects.filter(
actor=actor, approved=True actor=actor, approved=True
).values_list("target", flat=True) ).values_list("target", flat=True)
return Library.objects.filter( return self.filter(
me_query me_query
| instance_query | instance_query
| models.Q(privacy_level="everyone") | models.Q(privacy_level="everyone")
@ -1164,6 +1170,9 @@ class Library(federation_models.FederationMixin):
reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid}) reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid})
) )
def get_absolute_url(self):
return "/library/{}".format(self.uuid)
def save(self, **kwargs): def save(self, **kwargs):
if not self.pk and not self.fid and self.actor.get_user(): if not self.pk and not self.fid and self.actor.get_user():
self.fid = self.get_federation_id() self.fid = self.get_federation_id()

View File

@ -292,3 +292,33 @@ def library_playlist(request, pk):
# twitter player is also supported in various software # twitter player is also supported in various software
metas += get_twitter_card_metas(type="playlist", id=obj.pk) metas += get_twitter_card_metas(type="playlist", id=obj.pk)
return metas 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

View File

@ -286,3 +286,25 @@ def test_fetch_duplicate_bypass_with_force(
assert response.status_code == 201 assert response.status_code == 201
assert response.data == api_serializers.FetchSerializer(fetch).data assert response.data == api_serializers.FetchSerializer(fetch).data
fetch_task.assert_called_once_with(fetch_id=fetch.pk) 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,
}

View File

@ -2,7 +2,14 @@ import pytest
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.urls import reverse 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( 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"]) @pytest.mark.parametrize("privacy_level", ["me", "instance", "everyone"])
def test_music_library_retrieve(factories, api_client, privacy_level): 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 expected = serializers.LibrarySerializer(library).data
url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) 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): def test_music_library_retrieve_excludes_channel_libraries(factories, api_client):
channel = factories["audio.Channel"]() channel = factories["audio.Channel"](local=True)
library = channel.library library = channel.library
url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid}) 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): 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") upload = factories["music.Upload"](library=library, import_status="finished")
id = library.get_federation_id() id = library.get_federation_id()
expected = serializers.CollectionPageSerializer( expected = serializers.CollectionPageSerializer(
@ -253,7 +260,7 @@ def test_channel_upload_retrieve(factories, api_client):
@pytest.mark.parametrize("privacy_level", ["me", "instance"]) @pytest.mark.parametrize("privacy_level", ["me", "instance"])
def test_music_library_retrieve_page_private(factories, api_client, privacy_level): 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}) url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
response = api_client.get(url, {"page": 1}) 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( def test_music_library_retrieve_page_follow(
factories, api_client, authenticated_actor, approved, expected 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"]( factories["federation.LibraryFollow"](
actor=authenticated_actor, target=library, approved=approved actor=authenticated_actor, target=library, approved=approved
) )
@ -344,3 +351,35 @@ def test_music_upload_detail_private_approved_follow(
response = api_client.get(url) response = api_client.get(url)
assert response.status_code == 200 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

View File

@ -142,3 +142,45 @@ def test_channel_filter_album(factories, queryset_equal_list, mocker, anonymous_
) )
assert filterset.qs == [upload.track.album] 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]

View File

@ -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 # we only test our custom metas, not the default ones
assert metas[: len(expected_metas)] == expected_metas 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

View File

@ -0,0 +1 @@
Can now browse a library content through the UI (#926)

View File

@ -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>

View File

@ -68,6 +68,7 @@ export default {
iconOnly: {type: Boolean, default: false}, iconOnly: {type: Boolean, default: false},
artist: {type: Object, required: false}, artist: {type: Object, required: false},
album: {type: Object, required: false}, album: {type: Object, required: false},
library: {type: Object, required: false},
isPlayable: {type: Boolean, required: false, default: null} isPlayable: {type: Boolean, required: false, default: null}
}, },
data () { data () {
@ -196,6 +197,9 @@ export default {
} else if (self.album) { } else if (self.album) {
let params = {'album': self.album.id, include_channels: 'true', 'ordering': 'disc_number,position'} let params = {'album': self.album.id, include_channels: 'true', 'ordering': 'disc_number,position'}
self.getTracksPage(1, params, resolve) 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) => { return getTracks.then((tracks) => {

View File

@ -5,6 +5,7 @@
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span> <span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
</h3> </h3>
<slot></slot> <slot></slot>
<inline-search-bar v-model="query" v-if="search" @search="albums = []; fetchData()"></inline-search-bar>
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<div class="ui app-cards cards"> <div class="ui app-cards cards">
<div v-if="isLoading" class="ui inverted active dimmer"> <div v-if="isLoading" class="ui inverted active dimmer">
@ -12,14 +13,9 @@
</div> </div>
<album-card v-for="album in albums" :album="album" :key="album.id" /> <album-card v-for="album in albums" :album="album" :key="album.id" />
</div> </div>
<template v-if="!isLoading && albums.length === 0"> <slot v-if="!isLoading && albums.length === 0" name="empty-state">
<div class="ui placeholder segment"> <empty-state @refresh="fetchData" :refresh="true"></empty-state>
<div class="ui icon header"> </slot>
<i class="compact disc icon"></i>
No results matching your query
</div>
</div>
</template>
<template v-if="nextPage"> <template v-if="nextPage">
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']"> <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
@ -30,7 +26,6 @@
</template> </template>
<script> <script>
import _ from '@/lodash'
import axios from 'axios' import axios from 'axios'
import AlbumCard from '@/components/audio/album/Card' import AlbumCard from '@/components/audio/album/Card'
@ -39,6 +34,7 @@ export default {
filters: {type: Object, required: true}, filters: {type: Object, required: true},
controls: {type: Boolean, default: true}, controls: {type: Boolean, default: true},
showCount: {type: Boolean, default: false}, showCount: {type: Boolean, default: false},
search: {type: Boolean, default: false},
limit: {type: Number, default: 12}, limit: {type: Number, default: 12},
}, },
components: { components: {
@ -51,20 +47,19 @@ export default {
isLoading: false, isLoading: false,
errors: null, errors: null,
previousPage: null, previousPage: null,
nextPage: null nextPage: null,
query: '',
} }
}, },
created () { created () {
this.fetchData('albums/') this.fetchData()
}, },
methods: { methods: {
fetchData (url) { fetchData (url) {
if (!url) { url = url || 'albums/'
return
}
this.isLoading = true this.isLoading = true
let self = this let self = this
let params = _.clone(this.filters) let params = {q: this.query, ...this.filters}
params.page_size = this.limit params.page_size = this.limit
params.offset = this.offset params.offset = this.offset
axios.get(url, {params: params}).then((response) => { axios.get(url, {params: params}).then((response) => {
@ -91,7 +86,7 @@ export default {
this.fetchData() this.fetchData()
}, },
"$store.state.moderation.lastUpdate": function () { "$store.state.moderation.lastUpdate": function () {
this.fetchData('albums/') this.fetchData()
} }
} }
} }

View File

@ -4,6 +4,7 @@
<slot name="title"></slot> <slot name="title"></slot>
<span class="ui tiny circular label">{{ count }}</span> <span class="ui tiny circular label">{{ count }}</span>
</h3> </h3>
<inline-search-bar v-model="query" v-if="search" @search="objects = []; fetchData()"></inline-search-bar>
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<div class="ui five app-cards cards"> <div class="ui five app-cards cards">
<div v-if="isLoading" class="ui inverted active dimmer"> <div v-if="isLoading" class="ui inverted active dimmer">
@ -11,7 +12,9 @@
</div> </div>
<artist-card :artist="artist" v-for="artist in objects" :key="artist.id"></artist-card> <artist-card :artist="artist" v-for="artist in objects" :key="artist.id"></artist-card>
</div> </div>
<div v-if="!isLoading && objects.length === 0">No results matching your query.</div> <slot v-if="!isLoading && objects.length === 0" name="empty-state">
<empty-state @refresh="fetchData" :refresh="true"></empty-state>
</slot>
<template v-if="nextPage"> <template v-if="nextPage">
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']"> <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
@ -22,7 +25,6 @@
</template> </template>
<script> <script>
import _ from '@/lodash'
import axios from 'axios' import axios from 'axios'
import ArtistCard from "@/components/audio/artist/Card" import ArtistCard from "@/components/audio/artist/Card"
@ -31,6 +33,7 @@ export default {
filters: {type: Object, required: true}, filters: {type: Object, required: true},
controls: {type: Boolean, default: true}, controls: {type: Boolean, default: true},
header: {type: Boolean, default: true}, header: {type: Boolean, default: true},
search: {type: Boolean, default: false},
}, },
components: { components: {
ArtistCard, ArtistCard,
@ -43,20 +46,19 @@ export default {
isLoading: false, isLoading: false,
errors: null, errors: null,
previousPage: null, previousPage: null,
nextPage: null nextPage: null,
query: '',
} }
}, },
created () { created () {
this.fetchData('artists/') this.fetchData()
}, },
methods: { methods: {
fetchData (url) { fetchData (url) {
if (!url) { url = url || 'artists/'
return
}
this.isLoading = true this.isLoading = true
let self = this let self = this
let params = _.clone(this.filters) let params = {q: this.query, ...this.filters}
params.page_size = this.limit params.page_size = this.limit
params.offset = this.offset params.offset = this.offset
axios.get(url, {params: params}).then((response) => { axios.get(url, {params: params}).then((response) => {
@ -83,7 +85,7 @@ export default {
this.fetchData() this.fetchData()
}, },
"$store.state.moderation.lastUpdate": function () { "$store.state.moderation.lastUpdate": function () {
this.fetchData('objects/') this.fetchData()
} }
} }
} }

View File

@ -31,7 +31,7 @@
<td colspan="4" v-else> <td colspan="4" v-else>
<translate translate-context="*/*/*">N/A</translate> <translate translate-context="*/*/*">N/A</translate>
</td> </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> <track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon>
<play-button <play-button
class="play-button basic icon" class="play-button basic icon"
@ -59,6 +59,7 @@ export default {
track: {type: Object, required: true}, track: {type: Object, required: true},
artist: {type: Object, required: false}, artist: {type: Object, required: false},
displayPosition: {type: Boolean, default: false}, displayPosition: {type: Boolean, default: false},
displayActions: {type: Boolean, default: true},
playable: {type: Boolean, required: false, default: false}, playable: {type: Boolean, required: false, default: false},
}, },
components: { components: {

View File

@ -1,6 +1,10 @@
<template> <template>
<div class="table-wrapper"> <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> <thead>
<tr> <tr>
<th></th> <th></th>
@ -9,20 +13,21 @@
<th colspan="4"><translate translate-context="*/*/*/Noun">Artist</translate></th> <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="*/*/*">Album</translate></th>
<th colspan="4"><translate translate-context="Content/*/*">Duration</translate></th> <th colspan="4"><translate translate-context="Content/*/*">Duration</translate></th>
<th colspan="2"></th> <th colspan="2" v-if="displayActions"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<track-row <track-row
:playable="playable" :playable="playable"
:display-position="displayPosition" :display-position="displayPosition"
:display-actions="displayActions"
:track="track" :track="track"
:artist="artist" :artist="artist"
:key="index + '-' + track.id" :key="index + '-' + track.id"
v-for="(track, index) in allTracks"></track-row> v-for="(track, index) in allTracks"></track-row>
</tbody> </tbody>
</table> </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> <translate translate-context="Content/*/Button.Label">Load more</translate>
</button> </button>
</div> </div>
@ -36,38 +41,49 @@ import Modal from '@/components/semantic/Modal'
export default { export default {
props: { props: {
tracks: {type: Array, required: true}, tracks: {type: Array, required: false},
playable: {type: Boolean, required: false, default: false}, playable: {type: Boolean, required: false, default: false},
search: {type: Boolean, required: false, default: false},
nextUrl: {type: String, required: false, default: null}, nextUrl: {type: String, required: false, default: null},
artist: {type: Object, required: false}, 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: { components: {
Modal, Modal,
TrackRow TrackRow
}, },
created () {
if (!this.tracks) {
this.loadMore('tracks/')
}
},
data () { data () {
return { return {
loadMoreUrl: this.nextUrl, loadMoreUrl: this.nextUrl,
isLoadingMore: false, isLoading: false,
additionalTracks: [] additionalTracks: [],
query: '',
} }
}, },
computed: { computed: {
allTracks () { allTracks () {
return this.tracks.concat(this.additionalTracks) return (this.tracks || []).concat(this.additionalTracks)
} }
}, },
methods: { methods: {
loadMore (url) { loadMore (url) {
url = url || 'tracks/'
let self = this let self = this
self.isLoadingMore = true let params = {q: this.query, ...this.filters}
axios.get(url).then((response) => { self.isLoading = true
axios.get(url, {params}).then((response) => {
self.additionalTracks = self.additionalTracks.concat(response.data.results) self.additionalTracks = self.additionalTracks.concat(response.data.results)
self.loadMoreUrl = response.data.next self.loadMoreUrl = response.data.next
self.isLoadingMore = false self.isLoading = false
}, (error) => { }, (error) => {
self.isLoadingMore = false self.isLoading = false
}) })
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<router-link :to="url" :title="actor.full_username"> <router-link :to="url" :title="actor.full_username">
<template v-if="avatar"><actor-avatar :actor="actor" />&nbsp;</template>{{ repr | truncate(30) }} <template v-if="avatar"><actor-avatar :actor="actor" />&nbsp;</template><slot>{{ repr | truncate(truncateLength) }}</slot>
</router-link> </router-link>
</template> </template>
@ -13,6 +13,7 @@ export default {
avatar: {type: Boolean, default: true}, avatar: {type: Boolean, default: true},
admin: {type: Boolean, default: false}, admin: {type: Boolean, default: false},
displayName: {type: Boolean, default: false}, displayName: {type: Boolean, default: false},
truncateLength: {type: Number, default: 30},
}, },
computed: { computed: {
url () { url () {

View File

@ -3,7 +3,7 @@
<p class="message" v-if="copied"> <p class="message" v-if="copied">
<translate translate-context="Content/*/Paragraph">Text copied to clipboard!</translate> <translate translate-context="Content/*/Paragraph">Text copied to clipboard!</translate>
</p> </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']"> <button @click="copy" :class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']">
<i class="copy icon"></i> <i class="copy icon"></i>
<translate translate-context="*/*/Button.Label/Short, Verb">Copy</translate> <translate translate-context="*/*/Button.Label/Short, Verb">Copy</translate>
@ -14,7 +14,8 @@
export default { export default {
props: { props: {
value: {type: String}, value: {type: String},
buttonClasses: {type: String, default: 'teal'} buttonClasses: {type: String, default: 'teal'},
id: {type: String, default: 'copy-input'},
}, },
data () { data () {
return { return {

View File

@ -11,7 +11,7 @@
</slot> </slot>
</div> </div>
</div> </div>
<div class="inline"> <div class="inline center aligned text">
<slot></slot> <slot></slot>
<button v-if="refresh" class="ui button" @click="$emit('refresh')"> <button v-if="refresh" class="ui button" @click="$emit('refresh')">
<translate translate-context="Content/*/Button.Label/Short, Verb"> <translate translate-context="Content/*/Button.Label/Short, Verb">

View File

@ -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>

View File

@ -18,5 +18,6 @@ Vue.component('collapse-link', () => import(/* webpackChunkName: "common" */ "@/
Vue.component('action-feedback', () => import(/* webpackChunkName: "common" */ "@/components/common/ActionFeedback")) Vue.component('action-feedback', () => import(/* webpackChunkName: "common" */ "@/components/common/ActionFeedback"))
Vue.component('rendered-description', () => import(/* webpackChunkName: "common" */ "@/components/common/RenderedDescription")) Vue.component('rendered-description', () => import(/* webpackChunkName: "common" */ "@/components/common/RenderedDescription"))
Vue.component('content-form', () => import(/* webpackChunkName: "common" */ "@/components/common/ContentForm")) Vue.component('content-form', () => import(/* webpackChunkName: "common" */ "@/components/common/ContentForm"))
Vue.component('inline-search-bar', () => import(/* webpackChunkName: "common" */ "@/components/common/InlineSearchBar"))
export default {} export default {}

View File

@ -45,17 +45,17 @@
</ul> </ul>
</div> </div>
<div class="ui form"> <form class="ui form" @submit.prevent="currentTab = 'uploads'">
<div class="fields"> <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> <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> <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" /> <input name="import-ref" type="text" v-model="importReference" />
</div> </div>
</div> </div>
</div> <button type="submit" class="ui green button"><translate translate-context="Content/Library/Button.Label">Proceed</translate></button>
<div class="ui green button" @click="currentTab = 'uploads'"><translate translate-context="Content/Library/Button.Label">Proceed</translate></div> </form>
</div> </div>
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]"> <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]">
<div :class="['ui', {loading: isLoadingQuota}, 'container']"> <div :class="['ui', {loading: isLoadingQuota}, 'container']">
@ -149,6 +149,7 @@
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'processing'}]"> <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'processing'}]">
<library-files-table <library-files-table
:needs-refresh="needsRefresh" :needs-refresh="needsRefresh"
ordering-config-name="library.detail.upload"
@fetch-start="needsRefresh = false" @fetch-start="needsRefresh = false"
:filters="{import_reference: importReference}" :filters="{import_reference: importReference}"
:custom-objects="Object.values(uploads.objects)"></library-files-table> :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) { handleImportEvent(event) {
let self = this; let self = this;
if (event.upload.import_reference != self.importReference) { if (event.upload.import_reference != self.importReference) {
@ -387,12 +380,6 @@ export default {
} }
}, },
watch: { watch: {
uploadedFilesCount() {
this.updateProgressBar();
},
finishedJobs() {
this.updateProgressBar();
},
importReference: _.debounce(function() { importReference: _.debounce(function() {
this.$router.replace({ query: { import: this.importReference } }); this.$router.replace({ query: { import: this.importReference } });
}, 500), }, 500),
@ -400,6 +387,11 @@ export default {
if (newValue <= 0) { if (newValue <= 0) {
this.$refs.upload.active = false; this.$refs.upload.active = false;
} }
},
'uploads.finished' (v, o) {
if (v > o) {
this.$emit('uploads-finished', v - o)
}
} }
} }
}; };

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="main library pusher"> <div class="main library pusher">
<router-view :key="$route.fullPath"></router-view> <router-view></router-view>
</div> </div>
</template> </template>

View File

@ -1,11 +1,12 @@
<script> <script>
export default { export default {
props: { props: {
defaultOrdering: {type: String, required: false} defaultOrdering: {type: String, required: false},
orderingConfigName: {type: String, required: false},
}, },
computed: { computed: {
orderingConfig () { orderingConfig () {
return this.$store.state.ui.routePreferences[this.$route.name] return this.$store.state.ui.routePreferences[this.orderingConfigName || this.$route.name]
}, },
paginateBy: { paginateBy: {
set(paginateBy) { set(paginateBy) {

View File

@ -3,6 +3,9 @@ import Vue from 'vue'
import moment from 'moment' import moment from 'moment'
export function truncate (str, max, ellipsis, middle) { export function truncate (str, max, ellipsis, middle) {
if (max === 0) {
return
}
max = max || 100 max = max || 100
ellipsis = ellipsis || '…' ellipsis = ellipsis || '…'
if (str.length <= max) { if (str.length <= max) {

View File

@ -233,27 +233,6 @@ export default new Router({
import( import(
/* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Home" /* 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 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"
// )
// },
]
} }
] ]
}, },

View File

@ -143,6 +143,7 @@ export default {
} }
dispatch('favorites/fetch', null, { root: true }) dispatch('favorites/fetch', null, { root: true })
dispatch('channels/fetchSubscriptions', null, { root: true }) dispatch('channels/fetchSubscriptions', null, { root: true })
dispatch('libraries/fetchFollows', null, { root: true })
dispatch('moderation/fetchContentFilters', null, { root: true }) dispatch('moderation/fetchContentFilters', null, { root: true })
dispatch('playlists/fetchOwn', null, { root: true }) dispatch('playlists/fetchOwn', null, { root: true })
}, (response) => { }, (response) => {

View File

@ -4,6 +4,7 @@ import createPersistedState from 'vuex-persistedstate'
import favorites from './favorites' import favorites from './favorites'
import channels from './channels' import channels from './channels'
import libraries from './libraries'
import auth from './auth' import auth from './auth'
import instance from './instance' import instance from './instance'
import moderation from './moderation' import moderation from './moderation'
@ -20,6 +21,7 @@ export default new Vuex.Store({
ui, ui,
auth, auth,
channels, channels,
libraries,
favorites, favorites,
instance, instance,
moderation, moderation,

View File

@ -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})
})
})
}
}
}

View File

@ -77,12 +77,17 @@ export default {
orderingDirection: "-", orderingDirection: "-",
ordering: "creation_date", ordering: "creation_date",
}, },
"content.libraries.detail": { "library.detail.upload": {
paginateBy: 50, paginateBy: 50,
orderingDirection: "-", orderingDirection: "-",
ordering: "creation_date", ordering: "creation_date",
}, },
"content.libraries.detail.upload": { "library.detail.edit": {
paginateBy: 50,
orderingDirection: "-",
ordering: "creation_date",
},
"library.detail": {
paginateBy: 50, paginateBy: 50,
orderingDirection: "-", orderingDirection: "-",
ordering: "creation_date", ordering: "creation_date",

View File

@ -174,9 +174,6 @@ html {
} }
} }
.main-pusher {
padding: 1.5rem 0;
}
.ui.stripe.segment, .ui.stripe.segment,
#footer { #footer {
padding: 1em; padding: 1em;
@ -198,6 +195,9 @@ html {
.center.aligned.menu { .center.aligned.menu {
justify-content: center; justify-content: center;
} }
.text.center.aligned {
text-align: center;
}
.ellipsis:not(.icon) { .ellipsis:not(.icon) {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -659,5 +659,8 @@ input + .help {
.modal > .header { .modal > .header {
text-align: center; text-align: center;
} }
.ui.header .content {
display: block;
}
@import "./themes/_light.scss"; @import "./themes/_light.scss";
@import "./themes/_dark.scss"; @import "./themes/_dark.scss";

View File

@ -20,8 +20,15 @@
</div> </div>
</h2> </h2>
<channels-widget :filters="{scope: `actor:${object.full_username}`}"></channels-widget> <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> <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> </h2>
<library-widget :url="`federation/actors/${object.full_username}/libraries/`"> <library-widget :url="`federation/actors/${object.full_username}/libraries/`">
<translate translate-context="Content/Profile/Paragraph" slot="subtitle">This user shared the following libraries.</translate> <translate translate-context="Content/Profile/Paragraph" slot="subtitle">This user shared the following libraries.</translate>

View File

@ -42,10 +42,10 @@
</div> </div>
</div> </div>
<div class="ui bottom basic attached buttons"> <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> <translate translate-context="Content/Library/Card.Button.Label/Verb">Upload</translate>
</router-link> </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> <translate translate-context="Content/Library/Card.Button.Label/Noun">Details</translate>
</router-link> </router-link>
</div> </div>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -62,8 +62,7 @@ export default {
}) })
}, },
libraryCreated(library) { libraryCreated(library) {
this.hiddenForm = true this.$router.push({name: 'library.detail', params: {id: library.uuid}})
this.libraries.unshift(library)
} }
} }
} }

View File

@ -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>

View File

@ -2,7 +2,9 @@
<div class="ui card"> <div class="ui card">
<div class="content"> <div class="content">
<div class="header"> <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"> <div class="ui right floated dropdown">
<i class="ellipsis vertical grey large icon nomargin"></i> <i class="ellipsis vertical grey large icon nomargin"></i>
<div class="menu"> <div class="menu">

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>