Merge branch '926-browse-library' into 'develop'

Resolve "Support browsing a specific library content"

Closes #926

See merge request funkwhale/funkwhale!1048
This commit is contained in:
Eliot Berriot 2020-03-04 22:18:28 +01:00
commit b0162c40bf
47 changed files with 1020 additions and 336 deletions

View File

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

View File

@ -98,6 +98,26 @@ class LibraryFollowViewSet(
update_follow(follow, approved=False)
return response.Response(status=204)
@decorators.action(methods=["get"], detail=False)
def all(self, request, *args, **kwargs):
"""
Return all the subscriptions of the current user, with only limited data
to have a performant endpoint and avoid lots of queries just to display
subscription status in the UI
"""
follows = list(
self.get_queryset().values_list("uuid", "target__uuid", "approved")
)
payload = {
"results": [
{"uuid": str(u[0]), "library": str(u[1]), "approved": u[2]}
for u in follows
],
"count": len(follows),
}
return response.Response(payload, status=200)
class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
lookup_field = "uuid"

View File

@ -201,3 +201,26 @@ def find_alternate(response_text):
parser.feed(response_text)
except StopParsing:
return parser.result
def should_redirect_ap_to_html(accept_header):
if not accept_header:
return False
redirect_headers = [
"text/html",
]
no_redirect_headers = [
"application/json",
"application/activity+json",
"application/ld+json",
]
parsed_header = [ct.lower().strip() for ct in accept_header.split(",")]
for ct in parsed_header:
if ct in redirect_headers:
return True
if ct in no_redirect_headers:
return False
return True

View File

@ -1,4 +1,5 @@
from django import forms
from django.conf import settings
from django.core import paginator
from django.db.models import Prefetch
from django.http import HttpResponse
@ -7,6 +8,7 @@ from rest_framework import exceptions, mixins, permissions, response, viewsets
from rest_framework.decorators import action
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.music import models as music_models
from funkwhale_api.music import utils as music_utils
@ -14,6 +16,12 @@ from funkwhale_api.music import utils as music_utils
from . import activity, authentication, models, renderers, serializers, utils, webfinger
def redirect_to_html(public_url):
response = HttpResponse(status=302)
response["Location"] = common_utils.join_url(settings.FUNKWHALE_URL, public_url)
return response
class AuthenticatedIfAllowListEnabled(permissions.BasePermission):
def has_permission(self, request, view):
allow_list_enabled = preferences.get("moderation__allow_list_enabled")
@ -204,13 +212,18 @@ class MusicLibraryViewSet(
renderer_classes = renderers.get_ap_renderers()
serializer_class = serializers.LibrarySerializer
queryset = (
music_models.Library.objects.all().select_related("actor").filter(channel=None)
music_models.Library.objects.all()
.local()
.select_related("actor")
.filter(channel=None)
)
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
lb = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
# XXX: implement this for actors, albums, tracks, artists
return redirect_to_html(lb.get_absolute_url())
conf = {
"id": lb.get_federation_id(),
"actor": lb.actor,

View File

@ -41,8 +41,30 @@ class ChannelFilterSet(filters.FilterSet):
return queryset.filter(pk__in=ids)
class LibraryFilterSet(filters.FilterSet):
library = filters.CharFilter(field_name="_", method="filter_library")
def filter_library(self, queryset, name, value):
if not value:
return queryset
actor = utils.get_actor_from_request(self.request)
library = models.Library.objects.filter(uuid=value).viewable_by(actor).first()
if not library:
return queryset.none()
uploads = models.Upload.objects.filter(library=library)
uploads = uploads.playable_by(actor)
ids = uploads.values_list(self.Meta.library_filter_field, flat=True)
return queryset.filter(pk__in=ids)
class ArtistFilter(
audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet
LibraryFilterSet,
audio_filters.IncludeChannelsFilterSet,
moderation_filters.HiddenContentFilterSet,
):
q = fields.SearchFilter(search_fields=["name"], fts_search_fields=["body_text"])
@ -62,6 +84,7 @@ class ArtistFilter(
}
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"]
include_channels_field = "channel"
library_filter_field = "track__artist"
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
@ -70,6 +93,7 @@ class ArtistFilter(
class TrackFilter(
ChannelFilterSet,
LibraryFilterSet,
audio_filters.IncludeChannelsFilterSet,
moderation_filters.HiddenContentFilterSet,
):
@ -99,6 +123,7 @@ class TrackFilter(
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"]
include_channels_field = "artist__channel"
channel_filter_field = "track"
library_filter_field = "track"
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
@ -156,6 +181,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
class AlbumFilter(
ChannelFilterSet,
LibraryFilterSet,
audio_filters.IncludeChannelsFilterSet,
moderation_filters.HiddenContentFilterSet,
):
@ -175,6 +201,7 @@ class AlbumFilter(
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"]
include_channels_field = "artist__channel"
channel_filter_field = "track__album"
library_filter_field = "track__album"
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)

View File

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

View File

@ -292,3 +292,33 @@ def library_playlist(request, pk):
# twitter player is also supported in various software
metas += get_twitter_card_metas(type="playlist", id=obj.pk)
return metas
def library_library(request, uuid):
queryset = models.Library.objects.filter(uuid=uuid)
try:
obj = queryset.get()
except models.Library.DoesNotExist:
return []
library_url = utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_library", kwargs={"uuid": obj.uuid}),
)
metas = [
{"tag": "meta", "property": "og:url", "content": library_url},
{"tag": "meta", "property": "og:type", "content": "website"},
{"tag": "meta", "property": "og:title", "content": obj.name},
{"tag": "meta", "property": "og:description", "content": obj.description},
]
if preferences.get("federation__enabled"):
metas.append(
{
"tag": "link",
"rel": "alternate",
"type": "application/activity+json",
"href": obj.fid,
}
)
return metas

View File

@ -286,3 +286,25 @@ def test_fetch_duplicate_bypass_with_force(
assert response.status_code == 201
assert response.data == api_serializers.FetchSerializer(fetch).data
fetch_task.assert_called_once_with(fetch_id=fetch.pk)
def test_library_follow_get_all(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
library = factories["music.Library"]()
follow = factories["federation.LibraryFollow"](target=library, actor=actor)
factories["federation.LibraryFollow"]()
factories["music.Library"]()
url = reverse("api:v1:federation:library-follows-all")
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response.data == {
"results": [
{
"uuid": str(follow.uuid),
"library": str(library.uuid),
"approved": follow.approved,
}
],
"count": 1,
}

View File

@ -2,7 +2,14 @@ import pytest
from django.core.paginator import Paginator
from django.urls import reverse
from funkwhale_api.federation import actors, serializers, webfinger
from funkwhale_api.common import utils
from funkwhale_api.federation import (
actors,
serializers,
webfinger,
utils as federation_utils,
)
def test_authenticate_skips_anonymous_fetch_when_allow_list_enabled(
@ -159,7 +166,7 @@ def test_wellknown_webfinger_local(factories, api_client, settings, mocker):
@pytest.mark.parametrize("privacy_level", ["me", "instance", "everyone"])
def test_music_library_retrieve(factories, api_client, privacy_level):
library = factories["music.Library"](privacy_level=privacy_level)
library = factories["music.Library"](privacy_level=privacy_level, actor__local=True)
expected = serializers.LibrarySerializer(library).data
url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
@ -170,7 +177,7 @@ def test_music_library_retrieve(factories, api_client, privacy_level):
def test_music_library_retrieve_excludes_channel_libraries(factories, api_client):
channel = factories["audio.Channel"]()
channel = factories["audio.Channel"](local=True)
library = channel.library
url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
@ -180,7 +187,7 @@ def test_music_library_retrieve_excludes_channel_libraries(factories, api_client
def test_music_library_retrieve_page_public(factories, api_client):
library = factories["music.Library"](privacy_level="everyone")
library = factories["music.Library"](privacy_level="everyone", actor__local=True)
upload = factories["music.Upload"](library=library, import_status="finished")
id = library.get_federation_id()
expected = serializers.CollectionPageSerializer(
@ -253,7 +260,7 @@ def test_channel_upload_retrieve(factories, api_client):
@pytest.mark.parametrize("privacy_level", ["me", "instance"])
def test_music_library_retrieve_page_private(factories, api_client, privacy_level):
library = factories["music.Library"](privacy_level=privacy_level)
library = factories["music.Library"](privacy_level=privacy_level, actor__local=True)
url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
response = api_client.get(url, {"page": 1})
@ -264,7 +271,7 @@ def test_music_library_retrieve_page_private(factories, api_client, privacy_leve
def test_music_library_retrieve_page_follow(
factories, api_client, authenticated_actor, approved, expected
):
library = factories["music.Library"](privacy_level="me")
library = factories["music.Library"](privacy_level="me", actor__local=True)
factories["federation.LibraryFollow"](
actor=authenticated_actor, target=library, approved=approved
)
@ -344,3 +351,35 @@ def test_music_upload_detail_private_approved_follow(
response = api_client.get(url)
assert response.status_code == 200
@pytest.mark.parametrize(
"accept_header,expected",
[
("text/html,application/xhtml+xml", True),
("text/html,application/json", True),
("", False),
(None, False),
("application/json", False),
("application/activity+json", False),
("application/json,text/html", False),
("application/activity+json,text/html", False),
],
)
def test_should_redirect_ap_to_html(accept_header, expected):
assert federation_utils.should_redirect_ap_to_html(accept_header) is expected
def test_music_library_retrieve_redirects_to_html_if_header_set(
factories, api_client, settings
):
library = factories["music.Library"](actor__local=True)
url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
response = api_client.get(url, HTTP_ACCEPT="text/html")
expected_url = utils.join_url(
settings.FUNKWHALE_URL,
utils.spa_reverse("library_library", kwargs={"uuid": library.uuid}),
)
assert response.status_code == 302
assert response["Location"] == expected_url

View File

@ -142,3 +142,45 @@ def test_channel_filter_album(factories, queryset_equal_list, mocker, anonymous_
)
assert filterset.qs == [upload.track.album]
def test_library_filter_track(factories, queryset_equal_list, mocker, anonymous_user):
library = factories["music.Library"](privacy_level="everyone")
upload = factories["music.Upload"](library=library, playable=True)
factories["music.Track"]()
qs = upload.track.__class__.objects.all()
filterset = filters.TrackFilter(
{"library": library.uuid},
request=mocker.Mock(user=anonymous_user, actor=None),
queryset=qs,
)
assert filterset.qs == [upload.track]
def test_library_filter_album(factories, queryset_equal_list, mocker, anonymous_user):
library = factories["music.Library"](privacy_level="everyone")
upload = factories["music.Upload"](library=library, playable=True)
factories["music.Album"]()
qs = upload.track.album.__class__.objects.all()
filterset = filters.AlbumFilter(
{"library": library.uuid},
request=mocker.Mock(user=anonymous_user, actor=None),
queryset=qs,
)
assert filterset.qs == [upload.track.album]
def test_library_filter_artist(factories, queryset_equal_list, mocker, anonymous_user):
library = factories["music.Library"](privacy_level="everyone")
upload = factories["music.Upload"](library=library, playable=True)
factories["music.Artist"]()
qs = upload.track.artist.__class__.objects.all()
filterset = filters.ArtistFilter(
{"library": library.uuid},
request=mocker.Mock(user=anonymous_user, actor=None),
queryset=qs,
)
assert filterset.qs == [upload.track.artist]

View File

@ -282,3 +282,32 @@ def test_library_playlist_empty(spa_html, no_api_auth, client, factories, settin
# we only test our custom metas, not the default ones
assert metas[: len(expected_metas)] == expected_metas
def test_library_library(spa_html, no_api_auth, client, factories, settings):
library = factories["music.Library"]()
url = "/library/{}".format(library.uuid)
response = client.get(url)
expected_metas = [
{
"tag": "meta",
"property": "og:url",
"content": utils.join_url(settings.FUNKWHALE_URL, url),
},
{"tag": "meta", "property": "og:type", "content": "website"},
{"tag": "meta", "property": "og:title", "content": library.name},
{"tag": "meta", "property": "og:description", "content": library.description},
{
"tag": "link",
"rel": "alternate",
"type": "application/activity+json",
"href": library.fid,
},
]
metas = utils.parse_meta(response.content.decode())
# we only test our custom metas, not the default ones
assert metas[: len(expected_metas)] == expected_metas

View File

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

View File

@ -0,0 +1,43 @@
<template>
<button @click.stop="toggle" :class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']">
<i class="heart icon"></i>
<translate v-if="isApproved" translate-context="Content/Library/Card.Button.Label/Verb">Unfollow</translate>
<translate v-else-if="isPending" translate-context="Content/Library/Card.Button.Label/Verb">Cancel follow request</translate>
<translate v-else translate-context="Content/Library/Card.Button.Label/Verb">Follow</translate>
</button>
</template>
<script>
export default {
props: {
library: {type: Object},
},
computed: {
isPending () {
return this.follow && this.follow.approved === null
},
isApproved () {
return this.follow && (this.follow.approved === true || (this.follow.approved === null && this.library.privacy_level === 'everyone'))
},
follow () {
return this.$store.getters['libraries/follow'](this.library.uuid)
}
},
methods: {
toggle () {
if (this.isApproved || this.isPending) {
this.$emit('unfollowed')
} else {
this.$emit('followed')
}
this.$store.dispatch('libraries/toggle', this.library.uuid)
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,32 @@
<template>
<form class="ui inline form" @submit.stop.prevent="$emit('search', value)">
<div :class="['ui', 'action', {icon: isClearable}, 'input']">
<label for="search-query" class="hidden">
<translate translate-context="Content/Search/Input.Label/Noun">Search</translate>
</label>
<input id="search-query" name="search-query" type="text" :placeholder="labels.searchPlaceholder" :value="value" @input="$emit('input', $event.target.value)">
<i v-if="isClearable" class="x link icon" :title="labels.clear" @click="$emit('input', ''); $emit('search', value)"></i>
<button type="submit" class="ui icon basic button">
<i class="search icon"></i>
</button>
</div>
</form>
</template>
<script>
export default {
props: {
value: {type: String, required: true}
},
computed: {
labels () {
return {
searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search…'),
clear: this.$pgettext("Content/Library/Button.Label", 'Clear'),
}
},
isClearable () {
return !!this.value
}
}
}
</script>

View File

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

View File

@ -45,17 +45,17 @@
</ul>
</div>
<div class="ui form">
<form class="ui form" @submit.prevent="currentTab = 'uploads'">
<div class="fields">
<div class="ui four wide field">
<div class="ui field">
<label><translate translate-context="Content/Library/Input.Label/Noun">Import reference</translate></label>
<p><translate translate-context="Content/Library/Paragraph">This reference will be used to group imported files together.</translate></p>
<input name="import-ref" type="text" v-model="importReference" />
</div>
</div>
</div>
<div class="ui green button" @click="currentTab = 'uploads'"><translate translate-context="Content/Library/Button.Label">Proceed</translate></div>
<button type="submit" class="ui green button"><translate translate-context="Content/Library/Button.Label">Proceed</translate></button>
</form>
</div>
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]">
<div :class="['ui', {loading: isLoadingQuota}, 'container']">
@ -149,6 +149,7 @@
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'processing'}]">
<library-files-table
:needs-refresh="needsRefresh"
ordering-config-name="library.detail.upload"
@fetch-start="needsRefresh = false"
:filters="{import_reference: importReference}"
:custom-objects="Object.values(uploads.objects)"></library-files-table>
@ -253,14 +254,6 @@ export default {
});
});
},
updateProgressBar() {
$(this.$el)
.find(".progress")
.progress({
total: this.uploads.length * 2,
value: this.uploadedFilesCount + this.finishedJobs
});
},
handleImportEvent(event) {
let self = this;
if (event.upload.import_reference != self.importReference) {
@ -387,12 +380,6 @@ export default {
}
},
watch: {
uploadedFilesCount() {
this.updateProgressBar();
},
finishedJobs() {
this.updateProgressBar();
},
importReference: _.debounce(function() {
this.$router.replace({ query: { import: this.importReference } });
}, 500),
@ -400,6 +387,11 @@ export default {
if (newValue <= 0) {
this.$refs.upload.active = false;
}
},
'uploads.finished' (v, o) {
if (v > o) {
this.$emit('uploads-finished', v - o)
}
}
}
};

View File

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

View File

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

View File

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

View File

@ -233,27 +233,6 @@ export default new Router({
import(
/* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Home"
)
},
{
path: ":id/upload",
name: "content.libraries.detail.upload",
component: () =>
import(
/* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Upload"
),
props: route => ({
id: route.params.id,
defaultImportReference: route.query.import
})
},
{
path: ":id",
name: "content.libraries.detail",
component: () =>
import(
/* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Detail"
),
props: true
}
]
},
@ -812,6 +791,68 @@ export default new Router({
props: true
}
]
},
{
// browse a single library via it's uuid
path: ":id([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})",
props: true,
component: () =>
import(
/* webpackChunkName: "library" */ "@/views/library/DetailBase"
),
children: [
{
path: "",
name: "library.detail",
component: () =>
import(
/* webpackChunkName: "library" */ "@/views/library/DetailOverview"
)
},
{
path: "albums",
name: "library.detail.albums",
component: () =>
import(
/* webpackChunkName: "library" */ "@/views/library/DetailAlbums"
)
},
{
path: "tracks",
name: "library.detail.tracks",
component: () =>
import(
/* webpackChunkName: "library" */ "@/views/library/DetailTracks"
)
},
{
path: "edit",
name: "library.detail.edit",
component: () =>
import(
/* webpackChunkName: "auth-libraries" */ "@/views/library/Edit"
)
},
{
path: "upload",
name: "library.detail.upload",
component: () =>
import(
/* webpackChunkName: "auth-libraries" */ "@/views/library/Upload"
),
props: route => ({
defaultImportReference: route.query.import
})
},
// {
// path: "episodes",
// name: "library.detail.episodes",
// component: () =>
// import(
// /* webpackChunkName: "library" */ "@/views/library/DetailEpisodes"
// )
// },
]
}
]
},

View File

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

View File

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

View File

@ -0,0 +1,73 @@
import axios from 'axios'
import logger from '@/logging'
export default {
namespaced: true,
state: {
followedLibraries: [],
followsByLibrary: {},
count: 0,
},
mutations: {
follows: (state, {library, follow}) => {
let replacement = {...state.followsByLibrary}
if (follow) {
if (state.followedLibraries.indexOf(library) === -1) {
state.followedLibraries.push(library)
replacement[library] = follow
}
} else {
let i = state.followedLibraries.indexOf(library)
if (i > -1) {
state.followedLibraries.splice(i, 1)
replacement[library] = null
}
}
state.followsByLibrary = replacement
state.count = state.followedLibraries.length
},
reset (state) {
state.followedLibraries = []
state.followsByLibrary = {}
state.count = 0
},
},
getters: {
follow: (state) => (library) => {
return state.followsByLibrary[library]
}
},
actions: {
set ({commit, state}, {uuid, value}) {
if (value) {
return axios.post(`federation/follows/library/`, {target: uuid}).then((response) => {
logger.default.info('Successfully subscribed to library')
commit('follows', {library: uuid, follow: response.data})
}, (response) => {
logger.default.info('Error while subscribing to library')
commit('follows', {library: uuid, follow: null})
})
} else {
let follow = state.followsByLibrary[uuid]
return axios.delete(`federation/follows/library/${follow.uuid}/`).then((response) => {
logger.default.info('Successfully unsubscribed from library')
commit('follows', {library: uuid, follow: null})
}, (response) => {
logger.default.info('Error while unsubscribing from library')
commit('follows', {library: uuid, follow: follow})
})
}
},
toggle ({getters, dispatch}, uuid) {
dispatch('set', {uuid, value: !getters['follow'](uuid)})
},
fetchFollows ({dispatch, state, commit, rootState}, url) {
let promise = axios.get('federation/follows/library/all/')
return promise.then((response) => {
response.data.results.forEach(result => {
commit('follows', {library: result.library, follow: result})
})
})
}
}
}

View File

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

View File

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

View File

@ -20,8 +20,15 @@
</div>
</h2>
<channels-widget :filters="{scope: `actor:${object.full_username}`}"></channels-widget>
<h2 class="ui header">
<h2 class="ui with-actions header">
<translate translate-context="Content/Profile/Header">User Libraries</translate>
<div class="actions" v-if="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername">
<router-link :to="{name: 'content.libraries.index'}">
<i class="plus icon"></i>
<translate translate-context="Content/Profile/Button">Add new</translate>
</router-link>
</div>
</h2>
<library-widget :url="`federation/actors/${object.full_username}/libraries/`">
<translate translate-context="Content/Profile/Paragraph" slot="subtitle">This user shared the following libraries.</translate>

View File

@ -42,10 +42,10 @@
</div>
</div>
<div class="ui bottom basic attached buttons">
<router-link :to="{name: 'content.libraries.detail.upload', params: {id: library.uuid}}" class="ui button">
<router-link :to="{name: 'library.detail.upload', params: {id: library.uuid}}" class="ui button">
<translate translate-context="Content/Library/Card.Button.Label/Verb">Upload</translate>
</router-link>
<router-link :to="{name: 'content.libraries.detail', params: {id: library.uuid}}" exact class="ui button">
<router-link :to="{name: 'library.detail', params: {id: library.uuid}}" exact class="ui button">
<translate translate-context="Content/Library/Card.Button.Label/Noun">Details</translate>
</router-link>
</div>

View File

@ -1,129 +0,0 @@
<template>
<section class="ui vertical aligned stripe segment">
<div v-if="isLoadingLibrary" :class="['ui', {'active': isLoadingLibrary}, 'inverted', 'dimmer']">
<div class="ui text loader"><translate translate-context="Content/Library/Paragraph">Loading library data</translate></div>
</div>
<detail-area v-else :library="library">
<div class="ui top attached tabular menu">
<a :class="['item', {active: currentTab === 'follows'}]" @click="currentTab = 'follows'"><translate translate-context="Content/Federation/*/Noun">Followers</translate></a>
<a :class="['item', {active: currentTab === 'tracks'}]" @click="currentTab = 'tracks'"><translate translate-context="*/*/*">Tracks</translate></a>
<a :class="['item', {active: currentTab === 'edit'}]" @click="currentTab = 'edit'"><translate translate-context="Content/*/Button.Label/Verb">Edit</translate></a>
</div>
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'follows'}]">
<div class="ui form">
<div class="field">
<label><translate translate-context="Content/Library/Title">Sharing link</translate></label>
<p><translate translate-context="Content/Library/Paragraph">Share this link with other users so they can request access to your library.</translate></p>
<copy-input :value="library.fid" />
</div>
</div>
<div class="ui hidden divider"></div>
<div v-if="isLoadingFollows" :class="['ui', {'active': isLoadingFollows}, 'inverted', 'dimmer']">
<div class="ui text loader"><translate translate-context="Content/Library/Paragraph">Loading followers</translate></div>
</div>
<table v-else-if="follows && follows.count > 0" class="ui table">
<thead>
<tr>
<th><translate translate-context="Content/Library/Table.Label">User</translate></th>
<th><translate translate-context="Content/Library/Table.Label">Date</translate></th>
<th><translate translate-context="*/*/*">Status</translate></th>
<th><translate translate-context="Content/Library/Table.Label">Action</translate></th>
</tr>
</thead>
<tr v-for="follow in follows.results" :key="follow.fid">
<td><actor-link :actor="follow.actor" /></td>
<td><human-date :date="follow.creation_date" /></td>
<td>
<span :class="['ui', 'yellow', 'basic', 'label']" v-if="follow.approved === null">
<translate translate-context="Content/Library/Table/Short">Pending approval</translate>
</span>
<span :class="['ui', 'green', 'basic', 'label']" v-else-if="follow.approved === true">
<translate translate-context="Content/Library/Table/Short">Accepted</translate>
</span>
<span :class="['ui', 'red', 'basic', 'label']" v-else-if="follow.approved === false">
<translate translate-context="Content/Library/*/Short">Rejected</translate>
</span>
</td>
<td>
<div @click="updateApproved(follow, true)" :class="['ui', 'mini', 'icon', 'labeled', 'green', 'button']" v-if="follow.approved === null || follow.approved === false">
<i class="ui check icon"></i> <translate translate-context="Content/Library/Button.Label">Accept</translate>
</div>
<div @click="updateApproved(follow, false)" :class="['ui', 'mini', 'icon', 'labeled', 'red', 'button']" v-if="follow.approved === null || follow.approved === true">
<i class="ui x icon"></i> <translate translate-context="Content/Library/Button.Label">Reject</translate>
</div>
</td>
</tr>
</table>
<p v-else><translate translate-context="Content/Library/Paragraph">Nobody is following this library</translate></p>
</div>
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'tracks'}]">
<library-files-table :filters="{library: library.uuid}"></library-files-table>
</div>
<div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'edit'}]">
<library-form :library="library" @updated="libraryUpdated" @deleted="libraryDeleted" />
</div>
</detail-area>
</section>
</template>
<script>
import axios from "axios"
import DetailMixin from "./DetailMixin"
import DetailArea from "./DetailArea"
import LibraryForm from "./Form"
import LibraryFilesTable from "./FilesTable"
export default {
mixins: [DetailMixin],
components: {
DetailArea,
LibraryForm,
LibraryFilesTable
},
data() {
return {
currentTab: "follows",
isLoadingFollows: false,
follows: null
}
},
created() {
this.fetchFollows()
},
methods: {
libraryUpdated() {
this.hiddenForm = true
this.fetch()
},
libraryDeleted() {
this.$router.push({
name: "content.libraries.index"
})
},
fetchFollows() {
let self = this
self.isLoadingLibrary = true
axios.get(`libraries/${this.id}/follows/`).then(response => {
self.follows = response.data
self.isLoadingFollows = false
})
},
updateApproved(follow, value) {
let self = this
let action
if (value) {
action = "accept"
} else {
action = "reject"
}
axios
.post(`federation/follows/library/${follow.uuid}/${action}/`)
.then(response => {
follow.isLoading = false
follow.approved = value
})
}
}
}
</script>

View File

@ -1,37 +0,0 @@
<template>
<div>
<div class="ui two column row">
<div class="column">
<h3 class="ui header"><translate translate-context="Content/Library/Title">Current library</translate></h3>
<library-card :library="library" />
<radio-button :type="'library'" :object-id="library.uuid"></radio-button>
</div>
</div>
<div class="ui hidden divider"></div>
<slot></slot>
</div>
</template>
<script>
import RadioButton from '@/components/radios/Button'
import LibraryCard from './Card'
export default {
props: ['library'],
components: {
LibraryCard,
RadioButton,
},
computed: {
links () {
let upload = this.$pgettext('Content/Library/Card.Button.Label/Verb', 'Upload')
return [
{
name: 'libraries.detail.upload',
label: upload
}
]
}
}
}
</script>

View File

@ -1,26 +0,0 @@
<script>
import axios from 'axios'
export default {
props: ['id'],
created () {
this.fetch()
},
data () {
return {
isLoadingLibrary: false,
library: null
}
},
methods: {
fetch () {
let self = this
self.isLoadingLibrary = true
axios.get(`libraries/${this.id}/`).then((response) => {
self.library = response.data
self.isLoadingLibrary = false
})
}
}
}
</script>

View File

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

View File

@ -1,38 +0,0 @@
<template>
<div class="ui vertical aligned stripe segment">
<div v-if="isLoadingLibrary" :class="['ui', {'active': isLoadingLibrary}, 'inverted', 'dimmer']">
<div class="ui text loader"><translate translate-context="Content/Library/Paragraph">Loading library data</translate></div>
</div>
<detail-area v-else :library="library">
<file-upload ref="fileupload" :default-import-reference="defaultImportReference" :library="library" />
</detail-area>
</div>
</template>
<script>
import DetailMixin from './DetailMixin'
import DetailArea from './DetailArea'
import FileUpload from '@/components/library/FileUpload'
export default {
mixins: [DetailMixin],
props: ['defaultImportReference'],
components: {
DetailArea,
FileUpload
},
beforeRouteLeave (to, from, next){
if (this.$refs.fileupload.hasActiveUploads){
const answer = window.confirm('This page is asking you to confirm that you want to leave - data you have entered may not be saved.')
if (answer) {
next()
} else {
next(false)
}
}
else{
next()
}
}
}
</script>

View File

@ -2,7 +2,9 @@
<div class="ui card">
<div class="content">
<div class="header">
{{ library.name }}
<router-link :to="{name: 'library.detail', params: {id: library.uuid}}">
{{ library.name }}
</router-link>
<div class="ui right floated dropdown">
<i class="ellipsis vertical grey large icon nomargin"></i>
<div class="menu">

View File

@ -0,0 +1,28 @@
<template>
<section>
<album-widget
:key="String(object.uploads_count)"
:header="false"
:search="true"
:controls="false"
:filters="{playable: true, ordering: '-creation_date', library: object.uuid}">
<empty-state slot="empty-state">
<p>
<translate key="1" v-if="isOwner" translate-context="*/*/*">This library is empty, you should upload something in it!</translate>
<translate key="2" v-else translate-context="*/*/*">You may need to follow this library to see its content.</translate>
</p>
</empty-state>
</album-widget>
</section>
</template>
<script>
import AlbumWidget from "@/components/audio/album/Widget"
export default {
props: ['object', 'isOwner'],
components: {
AlbumWidget,
},
}
</script>

View File

@ -0,0 +1,197 @@
<template>
<main v-title="labels.title">
<div class="ui vertical stripe segment container">
<div v-if="isLoading" class="ui centered active inline loader"></div>
<div class="ui stackable grid" v-else-if="object">
<div class="ui five wide column">
<div class="ui pointing dropdown icon small basic right floated button" ref="dropdown" v-dropdown="{direction: 'downward'}" style="position: absolute; right: 1em; top: 1em; z-index: 5">
<i class="ellipsis vertical icon"></i>
<div class="menu">
<div
role="button"
class="basic item"
v-for="obj in getReportableObjs({library: object})"
:key="obj.target.type + obj.target.id"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
<i class="share icon" /> {{ obj.label }}
</div>
<div class="divider"></div>
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['moderation']" :to="{name: 'manage.library.libraries.detail', params: {id: object.uuid}}">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link>
</div>
</div>
<h1 class="ui header">
<div class="ui hidden divider"></div>
<div class="ellipsis content">
<i class="layer group small icon"></i>
<span :title="object.name">{{ object.name }}</span>
<div class="ui very small hidden divider"></div>
<div class="sub header ellipsis" :title="object.full_username">
<actor-link :avatar="false" :actor="object.actor" :truncate-length="0">
<translate translate-context="*/*/*" :translate-params="{username: object.actor.full_username}">Owned by %{ username }</translate>
</actor-link>
</div>
</div>
</h1>
<p>
<span v-if="object.privacy_level === 'me'" :title="labels.tooltips.me">
<i class="lock icon"></i>
{{ labels.visibility.me }}
</span>
<span
v-else-if="object.privacy_level === 'instance'" :title="labels.tooltips.instance">
<i class="lock open icon"></i>
{{ labels.visibility.instance }}
</span>
<span v-else-if="object.privacy_level === 'everyone'" :title="labels.tooltips.everyone">
<i class="globe icon"></i>
{{ labels.visibility.everyone }}
</span> ·
<i class="music icon"></i>
<translate translate-context="*/*/*" :translate-params="{count: object.uploads_count}" :translate-n="object.uploads_count" translate-plural="%{ count } tracks">%{ count } track</translate>
<span v-if="object.size">
· <i class="database icon"></i>
{{ object.size | humanSize }}
</span>
</p>
<div class="header-buttons">
<div class="ui small buttons">
<radio-button :disabled="!isPlayable" type="library" :object-id="object.uuid"></radio-button>
</div>
<div class="ui small buttons" v-if="!isOwner">
<library-follow-button v-if="$store.state.auth.authenticated" :library="object"></library-follow-button>
</div>
</div>
<template v-if="$store.getters['ui/layoutVersion'] === 'large'">
<rendered-description
:content="object.description ? {html: object.description} : null"
:update-url="`channels/${object.uuid}/`"
:can-update="false"></rendered-description>
<div class="ui hidden divider"></div>
</template>
<h5 class="ui header">
<label for="copy-input">
<translate translate-context="Content/Library/Title">Sharing link</translate>
</label>
</h5>
<p><translate translate-context="Content/Library/Paragraph">Share this link with other users so they can request access to this library by copy-pasting it in their pod search bar.</translate></p>
<copy-input :value="object.fid" />
</div>
<div class="ui eleven wide column">
<div class="ui head vertical stripe segment">
<div class="ui container">
<div class="ui secondary pointing center aligned menu">
<router-link class="item" :exact="true" :to="{name: 'library.detail'}">
<translate translate-context="*/*/*">Artists</translate>
</router-link>
<router-link class="item" :exact="true" :to="{name: 'library.detail.albums'}">
<translate translate-context="*/*/*">Albums</translate>
</router-link>
<router-link class="item" :exact="true" :to="{name: 'library.detail.tracks'}">
<translate translate-context="*/*/*">Tracks</translate>
</router-link>
<router-link v-if="isOwner" class="item" :exact="true" :to="{name: 'library.detail.upload'}">
<i class="upload icon"></i>
<translate translate-context="Content/Library/Card.Button.Label/Verb">Upload</translate>
</router-link>
<router-link v-if="isOwner" class="item" :exact="true" :to="{name: 'library.detail.edit'}">
<i class="pencil icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</router-link>
</div>
<div class="ui hidden divider"></div>
<keep-alive>
<router-view
@updated="fetchData"
@uploads-finished="object.uploads_count += $event"
:is-owner="isOwner"
:object="object"></router-view>
</keep-alive>
</div>
</div>
</div>
</div>
</div>
</main>
</template>
<script>
import axios from "axios"
import PlayButton from "@/components/audio/PlayButton"
import LibraryFollowButton from "@/components/audio/LibraryFollowButton"
import ReportMixin from '@/components/mixins/Report'
import RadioButton from '@/components/radios/Button'
export default {
mixins: [ReportMixin],
props: ["id"],
components: {
PlayButton,
RadioButton,
LibraryFollowButton
},
data() {
return {
isLoading: true,
object: null,
latestTracks: null,
}
},
beforeRouteUpdate (to, from, next) {
to.meta.preserveScrollPosition = true
next()
},
async created() {
await this.fetchData()
},
methods: {
async fetchData() {
var self = this
this.isLoading = true
let libraryPromise = axios.get(`libraries/${this.id}`).then(response => {
self.object = response.data
})
await libraryPromise
self.isLoading = false
},
},
computed: {
isOwner () {
return this.$store.state.auth.authenticated && this.object.actor.full_username === this.$store.state.auth.fullUsername
},
labels () {
return {
title: this.$pgettext('*/*/*', 'Library'),
visibility: {
me: this.$pgettext('Content/Library/Card.Help text', 'Private'),
instance: this.$pgettext('Content/Library/Card.Help text', 'Restricted'),
everyone: this.$pgettext('Content/Library/Card.Help text', 'Public'),
},
tooltips: {
me: this.$pgettext('Content/Library/Card.Help text', 'This library is private and your approval from its owner is needed to access its content'),
instance: this.$pgettext('Content/Library/Card.Help text', 'This library is restricted to users on this pod only'),
everyone: this.$pgettext('Content/Library/Card.Help text', 'This library is public and you can access its content freely'),
}
}
},
isPlayable () {
return this.object.uploads_count > 0 && (
this.isOwner ||
this.object.privacy_level === 'public' ||
(this.object.privacy_level === 'instance' && this.$store.state.auth.authenticated && this.object.actor.domain === this.$store.getters['instance/domain']) ||
(this.$store.getters['libraries/follow'](this.object.uuid) || {}).approved === true
)
},
},
watch: {
id() {
this.fetchData()
}
}
}
</script>

View File

@ -0,0 +1,41 @@
<template>
<section>
<template v-if="$store.getters['ui/layoutVersion'] === 'small'">
<rendered-description
:content="object.description ? {html: object.description} : null"
:update-url="`channels/${object.uuid}/`"
:can-update="false"></rendered-description>
<div class="ui hidden divider"></div>
</template>
<artist-widget
:key="object.uploads_count"
ref="artists"
:header="false"
:search="true"
:controls="false"
:filters="{playable: true, ordering: '-creation_date', library: object.uuid}">
<empty-state slot="empty-state">
<p>
<translate key="1" v-if="isOwner" translate-context="*/*/*">This library is empty, you should upload something in it!</translate>
<translate key="2" v-else translate-context="*/*/*">You may need to follow this library to see its content.</translate>
</p>
</empty-state>
</artist-widget>
</section>
</template>
<script>
import ArtistWidget from "@/components/audio/artist/Widget"
export default {
props: ['object', 'isOwner'],
components: {
ArtistWidget,
},
data () {
return {
query: ''
}
}
}
</script>

View File

@ -0,0 +1,27 @@
<template>
<section>
<track-table
:key="object.uploads_count"
:display-actions="false"
:search="true"
:filters="{playable: true, library: object.uuid, ordering: '-creation_date'}">
<empty-state slot="empty-state">
<p>
<translate key="1" v-if="isOwner" translate-context="*/*/*">This library is empty, you should upload something in it!</translate>
<translate key="2" v-else translate-context="*/*/*">You may need to follow this library to see its content.</translate>
</p>
</empty-state>
</track-table>
</section>
</template>
<script>
import TrackTable from '@/components/audio/track/Table'
export default {
props: ['object', 'isOwner'],
components: {
TrackTable,
},
}
</script>

View File

@ -0,0 +1,101 @@
<template>
<section>
<library-form :library="object" @updated="$emit('updated')" @deleted="$router.push({name: 'profile.overview', params: {username: $store.state.auth.username}})" />
<div class="ui hidden divider"></div>
<h2 class="ui header">
<translate translate-context="*/*/*">Library contents</translate>
</h2>
<library-files-table :filters="{library: object.uuid}"></library-files-table>
<div class="ui hidden divider"></div>
<h2 class="ui header">
<translate translate-context="Content/Federation/*/Noun">Followers</translate>
</h2>
<div v-if="isLoadingFollows" :class="['ui', {'active': isLoadingFollows}, 'inverted', 'dimmer']">
<div class="ui text loader"><translate translate-context="Content/Library/Paragraph">Loading followers</translate></div>
</div>
<table v-else-if="follows && follows.count > 0" class="ui table">
<thead>
<tr>
<th><translate translate-context="Content/Library/Table.Label">User</translate></th>
<th><translate translate-context="Content/Library/Table.Label">Date</translate></th>
<th><translate translate-context="*/*/*">Status</translate></th>
<th><translate translate-context="Content/Library/Table.Label">Action</translate></th>
</tr>
</thead>
<tr v-for="follow in follows.results" :key="follow.fid">
<td><actor-link :actor="follow.actor" /></td>
<td><human-date :date="follow.creation_date" /></td>
<td>
<span :class="['ui', 'yellow', 'basic', 'label']" v-if="follow.approved === null">
<translate translate-context="Content/Library/Table/Short">Pending approval</translate>
</span>
<span :class="['ui', 'green', 'basic', 'label']" v-else-if="follow.approved === true">
<translate translate-context="Content/Library/Table/Short">Accepted</translate>
</span>
<span :class="['ui', 'red', 'basic', 'label']" v-else-if="follow.approved === false">
<translate translate-context="Content/Library/*/Short">Rejected</translate>
</span>
</td>
<td>
<div @click="updateApproved(follow, true)" :class="['ui', 'mini', 'icon', 'labeled', 'green', 'button']" v-if="follow.approved === null || follow.approved === false">
<i class="ui check icon"></i> <translate translate-context="Content/Library/Button.Label">Accept</translate>
</div>
<div @click="updateApproved(follow, false)" :class="['ui', 'mini', 'icon', 'labeled', 'red', 'button']" v-if="follow.approved === null || follow.approved === true">
<i class="ui x icon"></i> <translate translate-context="Content/Library/Button.Label">Reject</translate>
</div>
</td>
</tr>
</table>
<p v-else><translate translate-context="Content/Library/Paragraph">Nobody is following this library</translate></p>
</section>
</template>
<script>
import LibraryFilesTable from "@/views/content/libraries/FilesTable"
import LibraryForm from "@/views/content/libraries/Form"
import axios from "axios"
export default {
props: ['object'],
components: {
LibraryForm,
LibraryFilesTable
},
data () {
return {
isLoadingFollows: false,
follows: null
}
},
created() {
this.fetchFollows()
},
methods: {
fetchFollows() {
let self = this
self.isLoadingLibrary = true
axios.get(`libraries/${this.object.uuid}/follows/`).then(response => {
self.follows = response.data
self.isLoadingFollows = false
})
},
updateApproved(follow, value) {
let self = this
let action
if (value) {
action = "accept"
} else {
action = "reject"
}
axios
.post(`federation/follows/library/${follow.uuid}/${action}/`)
.then(response => {
follow.isLoading = false
follow.approved = value
})
}
}
}
</script>

View File

@ -0,0 +1,35 @@
<template>
<section>
<file-upload ref="fileupload"
:default-import-reference="defaultImportReference"
:library="object"
@uploads-finished="$emit('uploads-finished', $event)" />
</section>
</template>
<script>
import FileUpload from '@/components/library/FileUpload'
export default {
props: ['object', 'defaultImportReference'],
components: {
FileUpload,
},
beforeRouteLeave (to, from, next){
if (this.$refs.fileupload.hasActiveUploads){
const answer = window.confirm('This page is asking you to confirm that you want to leave - data you have entered may not be saved.')
if (answer) {
next()
} else {
next(false)
}
}
else{
next()
}
}
}
</script>