Resolve "Support browsing a specific library content"
This commit is contained in:
parent
ecc3ed3ac3
commit
b166182762
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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},
|
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) => {
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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" /> </template>{{ repr | truncate(30) }}
|
<template v-if="avatar"><actor-avatar :actor="actor" /> </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 () {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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('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 {}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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"
|
||||||
|
// )
|
||||||
|
// },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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: "-",
|
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",
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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) {
|
libraryCreated(library) {
|
||||||
this.hiddenForm = true
|
this.$router.push({name: 'library.detail', params: {id: library.uuid}})
|
||||||
this.libraries.unshift(library)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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="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">
|
||||||
|
|
|
@ -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