Merge branch '926-browse-library' into 'develop'
Resolve "Support browsing a specific library content" Closes #926 See merge request funkwhale/funkwhale!1048
This commit is contained in:
commit
b0162c40bf
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Can now browse a library content through the UI (#926)
|
|
@ -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>
|
|
@ -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) => {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
||||
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
|
@ -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 {}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="main library pusher">
|
||||
<router-view :key="$route.fullPath"></router-view>
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
// )
|
||||
// },
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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}})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in New Issue