See #170: channels ui (listeners)

This commit is contained in:
Eliot Berriot 2020-02-05 15:06:07 +01:00
parent b74517ff33
commit 95497e76ac
79 changed files with 1768 additions and 232 deletions

View File

@ -88,6 +88,9 @@ v1_patterns += [
url(r"^token/?$", jwt_views.obtain_jwt_token, name="token"),
url(r"^token/refresh/?$", jwt_views.refresh_jwt_token, name="token_refresh"),
url(r"^rate-limit/?$", common_views.RateLimitView.as_view(), name="rate-limit"),
url(
r"^text-preview/?$", common_views.TextPreviewView.as_view(), name="text-preview"
),
]
urlpatterns = [

View File

@ -70,6 +70,8 @@ class ChannelCreateSerializer(serializers.Serializer):
@transaction.atomic
def create(self, validated_data):
from . import views
description = validated_data.get("description")
artist = music_models.Artist.objects.create(
attributed_to=validated_data["attributed_to"],
@ -99,10 +101,11 @@ class ChannelCreateSerializer(serializers.Serializer):
actor=validated_data["attributed_to"],
)
channel.save()
channel = views.ChannelViewSet.queryset.get(pk=channel.pk)
return channel
def to_representation(self, obj):
return ChannelSerializer(obj).data
return ChannelSerializer(obj, context=self.context).data
NOOP = object()
@ -181,7 +184,7 @@ class ChannelUpdateSerializer(serializers.Serializer):
return obj
def to_representation(self, obj):
return ChannelSerializer(obj).data
return ChannelSerializer(obj, context=self.context).data
class ChannelSerializer(serializers.ModelSerializer):
@ -261,7 +264,8 @@ def rss_serialize_item(upload):
"link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
"enclosure": [
{
"url": upload.listen_url,
# we enforce MP3, since it's the only format supported everywhere
"url": federation_utils.full_url(upload.get_listen_url(to="mp3")),
"length": upload.size or 0,
"type": upload.mimetype or "audio/mpeg",
}
@ -271,7 +275,6 @@ def rss_serialize_item(upload):
data["itunes:subtitle"] = [{"value": upload.track.description.truncate(255)}]
data["itunes:summary"] = [{"cdata_value": upload.track.description.rendered}]
data["description"] = [{"value": upload.track.description.as_plain_text}]
data["content:encoded"] = data["itunes:summary"]
if upload.track.attachment_cover:
data["itunes:image"] = [

View File

@ -6,7 +6,7 @@ from rest_framework import response
from rest_framework import viewsets
from django import http
from django.db.models import Prefetch
from django.db.models import Count, Prefetch
from django.db.utils import IntegrityError
from funkwhale_api.common import permissions
@ -18,6 +18,12 @@ from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, renderers, serializers
ARTIST_PREFETCH_QS = (
music_models.Artist.objects.select_related("description", "attachment_cover",)
.prefetch_related(music_views.TAG_PREFETCH)
.annotate(_tracks_count=Count("tracks"))
)
class ChannelsMixin(object):
def dispatch(self, request, *args, **kwargs):
@ -44,12 +50,7 @@ class ChannelViewSet(
"library",
"attributed_to",
"actor",
Prefetch(
"artist",
queryset=music_models.Artist.objects.select_related(
"attachment_cover", "description"
).prefetch_related(music_views.TAG_PREFETCH,),
),
Prefetch("artist", queryset=ARTIST_PREFETCH_QS),
)
.order_by("-creation_date")
)
@ -131,7 +132,12 @@ class ChannelViewSet(
def get_serializer_context(self):
context = super().get_serializer_context()
context["subscriptions_count"] = self.action in ["retrieve", "create", "update"]
context["subscriptions_count"] = self.action in [
"retrieve",
"create",
"update",
"partial_update",
]
return context
@ -148,8 +154,8 @@ class SubscriptionsViewSet(
.prefetch_related(
"target__channel__library",
"target__channel__attributed_to",
"target__channel__artist__description",
"actor",
Prefetch("target__channel__artist", queryset=ARTIST_PREFETCH_QS),
)
.order_by("-creation_date")
)
@ -171,10 +177,12 @@ class SubscriptionsViewSet(
to have a performant endpoint and avoid lots of queries just to display
subscription status in the UI
"""
subscriptions = list(self.get_queryset().values_list("uuid", flat=True))
subscriptions = list(
self.get_queryset().values_list("uuid", "target__channel__uuid")
)
payload = {
"results": [str(u) for u in subscriptions],
"results": [{"uuid": str(u[0]), "channel": u[1]} for u in subscriptions],
"count": len(subscriptions),
}
return response.Response(payload, status=200)

View File

@ -176,6 +176,8 @@ class ActorScopeFilter(filters.CharFilter):
super().__init__(*args, **kwargs)
def filter(self, queryset, value):
from funkwhale_api.federation import models as federation_models
if not value:
return queryset
@ -189,6 +191,17 @@ class ActorScopeFilter(filters.CharFilter):
qs = self.filter_me(user=user, queryset=queryset)
elif value.lower() == "all":
return queryset
elif value.lower().startswith("actor:"):
full_username = value.split("actor:", 1)[1]
username, domain = full_username.split("@")
try:
actor = federation_models.Actor.objects.get(
preferred_username=username, domain_id=domain,
)
except federation_models.Actor.DoesNotExist:
return queryset.none()
return queryset.filter(**{self.actor_field: actor})
else:
return queryset.none()

View File

@ -201,7 +201,7 @@ class AttachmentQuerySet(models.QuerySet):
class Attachment(models.Model):
# Remote URL where the attachment can be fetched
url = models.URLField(max_length=500, null=True)
url = models.URLField(max_length=500, null=True, blank=True)
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
# Actor associated with the attachment
actor = models.ForeignKey(

View File

@ -303,6 +303,7 @@ def attach_content(obj, field, content_data):
if existing:
getattr(obj, field).delete()
setattr(obj, field, None)
if not content_data:
return

View File

@ -181,3 +181,15 @@ class AttachmentViewSet(
if instance.actor is None or instance.actor != self.request.user.actor:
raise exceptions.PermissionDenied()
instance.delete()
class TextPreviewView(views.APIView):
permission_classes = []
def post(self, request, *args, **kwargs):
payload = request.data
if "text" not in payload:
return response.Response({"detail": "Invalid input"}, status=400)
data = {"rendered": utils.render_html(payload["text"], "text/markdown")}
return response.Response(data, status=200)

View File

@ -1,7 +1,10 @@
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.music import models as music_models
from funkwhale_api.users import serializers as users_serializers
from . import filters
from . import models
@ -169,3 +172,27 @@ class FetchSerializer(serializers.ModelSerializer):
"creation_date",
"fetch_date",
]
class FullActorSerializer(serializers.Serializer):
fid = serializers.URLField()
url = serializers.URLField()
domain = serializers.CharField(source="domain_id")
creation_date = serializers.DateTimeField()
last_fetch_date = serializers.DateTimeField()
name = serializers.CharField()
preferred_username = serializers.CharField()
full_username = serializers.CharField()
type = serializers.CharField()
is_local = serializers.BooleanField()
is_channel = serializers.SerializerMethodField()
manually_approves_followers = serializers.BooleanField()
user = users_serializers.UserBasicSerializer()
summary = common_serializers.ContentSerializer(source="summary_obj")
icon = common_serializers.AttachmentSerializer(source="attachment_icon")
def get_is_channel(self, o):
try:
return bool(o.channel)
except ObjectDoesNotExist:
return False

View File

@ -8,5 +8,6 @@ router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-fol
router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
router.register(r"libraries", api_views.LibraryViewSet, "libraries")
router.register(r"domains", api_views.DomainViewSet, "domains")
router.register(r"actors", api_views.ActorViewSet, "actors")
urlpatterns = router.urls

View File

@ -12,6 +12,7 @@ from rest_framework import viewsets
from funkwhale_api.common import preferences
from funkwhale_api.common.permissions import ConditionalAuthentication
from funkwhale_api.music import models as music_models
from funkwhale_api.music import views as music_views
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import activity
@ -218,3 +219,34 @@ class DomainViewSet(
if preferences.get("moderation__allow_list_enabled"):
qs = qs.filter(allowed=True)
return qs
class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
queryset = models.Actor.objects.select_related(
"user", "channel", "summary_obj", "attachment_icon"
)
permission_classes = [ConditionalAuthentication]
serializer_class = api_serializers.FullActorSerializer
lookup_field = "full_username"
lookup_value_regex = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
def get_object(self):
queryset = self.get_queryset()
username, domain = self.kwargs["full_username"].split("@", 1)
return queryset.get(preferred_username=username, domain_id=domain)
def get_queryset(self):
qs = super().get_queryset()
qs = qs.exclude(
domain__instance_policy__is_active=True,
domain__instance_policy__block_all=True,
)
if preferences.get("moderation__allow_list_enabled"):
qs = qs.filter(domain__allowed=True)
return qs
libraries = decorators.action(methods=["get"], detail=True)(
music_views.get_libraries(
filter_uploads=lambda o, uploads: uploads.filter(library__actor=o)
)
)

View File

@ -253,7 +253,6 @@ class APIActorSerializer(serializers.ModelSerializer):
class Meta:
model = models.Actor
fields = [
"id",
"fid",
"url",
"creation_date",

View File

@ -876,6 +876,12 @@ class Upload(models.Model):
def listen_url(self):
return self.track.listen_url + "?upload={}".format(self.uuid)
def get_listen_url(self, to=None):
url = self.listen_url
if to:
url += "&to={}".format(to)
return url
@property
def listen_url_no_download(self):
# Not using reverse because this is slow

View File

@ -156,6 +156,19 @@ def serialize_artist_simple(artist):
else None
)
if "attachment_cover" in artist._state.fields_cache:
data["cover"] = (
cover_field.to_representation(artist.attachment_cover)
if artist.attachment_cover
else None
)
if getattr(artist, "_tracks_count", None) is not None:
data["tracks_count"] = artist._tracks_count
if getattr(artist, "_prefetched_tagged_items", None) is not None:
data["tags"] = [ti.tag.name for ti in artist._prefetched_tagged_items]
return data

View File

@ -5,6 +5,29 @@ from rest_framework import renderers
import funkwhale_api
# from https://stackoverflow.com/a/8915039
# because I want to avoid a lxml dependency just for outputting cdata properly
# in a RSS feed
def CDATA(text=None):
element = ET.Element("![CDATA[")
element.text = text
return element
ET._original_serialize_xml = ET._serialize_xml
def _serialize_xml(write, elem, qnames, namespaces, **kwargs):
if elem.tag == "![CDATA[":
write("<%s%s]]>" % (elem.tag, elem.text))
return
return ET._original_serialize_xml(write, elem, qnames, namespaces, **kwargs)
ET._serialize_xml = ET._serialize["xml"] = _serialize_xml
# end of tweaks
def structure_payload(data):
payload = {
"funkwhaleVersion": funkwhale_api.__version__,
@ -56,7 +79,7 @@ def dict_to_xml_tree(root_tag, d, parent=None):
if key == "value":
root.text = str(value)
elif key == "cdata_value":
root.text = "<![CDATA[{}]]>".format(str(value))
root.append(CDATA(value))
else:
root.set(key, str(value))
return root

View File

@ -229,8 +229,8 @@ class User(AbstractUser):
self.last_activity = now
self.save(update_fields=["last_activity"])
def create_actor(self):
self.actor = create_actor(self)
def create_actor(self, **kwargs):
self.actor = create_actor(self, **kwargs)
self.save(update_fields=["actor"])
return self.actor
@ -264,15 +264,10 @@ class User(AbstractUser):
def full_username(self):
return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME)
@property
def avatar_path(self):
if not self.avatar:
return None
try:
return self.avatar.path
except NotImplementedError:
# external storage
return self.avatar.name
def get_avatar(self):
if not self.actor:
return
return self.actor.attachment_icon
def generate_code(length=10):
@ -399,8 +394,9 @@ def get_actor_data(username, **kwargs):
}
def create_actor(user):
def create_actor(user, **kwargs):
args = get_actor_data(user.username)
args.update(kwargs)
private, public = keys.get_key_pair()
args["private_key"] = private.decode("utf-8")
args["public_key"] = public.decode("utf-8")

View File

@ -90,17 +90,12 @@ class UserActivitySerializer(activity_serializers.ModelSerializer):
class UserBasicSerializer(serializers.ModelSerializer):
avatar = serializers.SerializerMethodField()
avatar = common_serializers.AttachmentSerializer(source="get_avatar")
class Meta:
model = models.User
fields = ["id", "username", "name", "date_joined", "avatar"]
def get_avatar(self, o):
return common_serializers.AttachmentSerializer(
o.actor.attachment_icon if o.actor else None
).data
class UserWriteSerializer(serializers.ModelSerializer):
summary = common_serializers.ContentSerializer(required=False, allow_null=True)
@ -140,19 +135,12 @@ class UserWriteSerializer(serializers.ModelSerializer):
obj.actor.save(update_fields=["attachment_icon"])
return obj
def to_representation(self, obj):
repr = super().to_representation(obj)
repr["avatar"] = common_serializers.AttachmentSerializer(
obj.actor.attachment_icon
).data
return repr
class UserReadSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
full_username = serializers.SerializerMethodField()
avatar = serializers.SerializerMethodField()
avatar = common_serializers.AttachmentSerializer(source="get_avatar")
class Meta:
model = models.User
@ -170,9 +158,6 @@ class UserReadSerializer(serializers.ModelSerializer):
"avatar",
]
def get_avatar(self, o):
return common_serializers.AttachmentSerializer(o.actor.attachment_icon).data
def get_permissions(self, o):
return o.get_permissions()

View File

@ -185,7 +185,6 @@ def test_rss_item_serializer(factories):
"itunes:subtitle": [{"value": description.truncate(255)}],
"itunes:summary": [{"cdata_value": description.rendered}],
"description": [{"value": description.as_plain_text}],
"content:encoded": [{"cdata_value": description.rendered}],
"guid": [{"cdata_value": str(upload.uuid), "isPermalink": "false"}],
"pubDate": [{"value": serializers.rss_date(upload.creation_date)}],
"itunes:duration": [{"value": serializers.rss_duration(upload.duration)}],
@ -197,7 +196,11 @@ def test_rss_item_serializer(factories):
"itunes:image": [{"href": upload.track.attachment_cover.download_url_original}],
"link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
"enclosure": [
{"url": upload.listen_url, "length": upload.size, "type": upload.mimetype}
{
"url": federation_utils.full_url(upload.get_listen_url("mp3")),
"length": upload.size,
"type": upload.mimetype,
}
],
}

View File

@ -1,8 +1,10 @@
import uuid
import pytest
from django.urls import reverse
from funkwhale_api.audio import serializers
from funkwhale_api.audio import views
def test_channel_create(logged_in_api_client):
@ -23,8 +25,10 @@ def test_channel_create(logged_in_api_client):
assert response.status_code == 201
channel = actor.owned_channels.select_related("artist__description").latest("id")
expected = serializers.ChannelSerializer(channel).data
channel = views.ChannelViewSet.queryset.get(attributed_to=actor)
expected = serializers.ChannelSerializer(
channel, context={"subscriptions_count": True}
).data
assert response.data == expected
assert channel.artist.name == data["name"]
@ -43,6 +47,9 @@ def test_channel_create(logged_in_api_client):
def test_channel_detail(factories, logged_in_api_client):
channel = factories["audio.Channel"](artist__description=None)
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
setattr(channel.artist, "_tracks_count", 0)
setattr(channel.artist, "_prefetched_tagged_items", [])
expected = serializers.ChannelSerializer(
channel, context={"subscriptions_count": True}
).data
@ -54,6 +61,8 @@ def test_channel_detail(factories, logged_in_api_client):
def test_channel_list(factories, logged_in_api_client):
channel = factories["audio.Channel"](artist__description=None)
setattr(channel.artist, "_tracks_count", 0)
setattr(channel.artist, "_prefetched_tagged_items", [])
url = reverse("api:v1:channels-list")
expected = serializers.ChannelSerializer(channel).data
response = logged_in_api_client.get(url)
@ -142,8 +151,11 @@ def test_channel_subscribe(factories, logged_in_api_client):
assert response.status_code == 201
subscription = actor.emitted_follows.select_related(
"target__channel__artist__description"
"target__channel__artist__description",
"target__channel__artist__attachment_cover",
).latest("id")
setattr(subscription.target.channel.artist, "_tracks_count", 0)
setattr(subscription.target.channel.artist, "_prefetched_tagged_items", [])
assert subscription.fid == subscription.get_federation_id()
expected = serializers.SubscriptionSerializer(subscription).data
assert response.data == expected
@ -168,6 +180,8 @@ def test_subscriptions_list(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](artist__description=None)
subscription = factories["audio.Subscription"](target=channel.actor, actor=actor)
setattr(subscription.target.channel.artist, "_tracks_count", 0)
setattr(subscription.target.channel.artist, "_prefetched_tagged_items", [])
factories["audio.Subscription"](target=channel.actor)
url = reverse("api:v1:subscriptions-list")
expected = serializers.SubscriptionSerializer(subscription).data
@ -192,7 +206,10 @@ def test_subscriptions_all(factories, logged_in_api_client):
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response.data == {"results": [subscription.uuid], "count": 1}
assert response.data == {
"results": [{"uuid": subscription.uuid, "channel": uuid.UUID(channel.uuid)}],
"count": 1,
}
def test_channel_rss_feed(factories, api_client):

View File

@ -50,6 +50,8 @@ def test_mutation_filter_is_approved(value, expected, factories):
("noop", 0, []),
("noop", 1, []),
("noop", 2, []),
("actor:actor1@domain.test", 0, [0]),
("actor:actor2@domain.test", 0, [1]),
],
)
def test_actor_scope_filter(
@ -61,8 +63,13 @@ def test_actor_scope_filter(
mocker,
anonymous_user,
):
actor1 = factories["users.User"]().create_actor()
actor2 = factories["users.User"]().create_actor()
domain = factories["federation.Domain"](name="domain.test")
actor1 = factories["users.User"]().create_actor(
preferred_username="actor1", domain=domain
)
actor2 = factories["users.User"]().create_actor(
preferred_username="actor2", domain=domain
)
users = [actor1.user, actor2.user, anonymous_user]
tracks = [
factories["music.Upload"](library__actor=actor1, playable=True).track,

View File

@ -7,6 +7,7 @@ from funkwhale_api.common import serializers
from funkwhale_api.common import signals
from funkwhale_api.common import tasks
from funkwhale_api.common import throttling
from funkwhale_api.common import utils
def test_can_detail_mutation(logged_in_api_client, factories):
@ -270,3 +271,13 @@ def test_attachment_destroy_not_owner(factories, logged_in_api_client):
assert response.status_code == 403
attachment.refresh_from_db()
def test_can_render_text_preview(api_client, db):
payload = {"text": "Hello world"}
url = reverse("api:v1:text-preview")
response = api_client.post(url, payload)
expected = {"rendered": utils.render_html(payload["text"], "text/markdown")}
assert response.status_code == 200
assert response.data == expected

View File

@ -29,6 +29,9 @@ def test_user_can_get_his_favorites(
favorite, context={"request": request}
).data
]
expected[0]["track"]["artist"].pop("cover")
expected[0]["track"]["album"]["artist"].pop("cover")
assert response.status_code == 200
assert response.data["results"] == expected

View File

@ -1,7 +1,9 @@
import pytest
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.federation import api_serializers
from funkwhale_api.federation import serializers
from funkwhale_api.users import serializers as users_serializers
def test_library_serializer(factories, to_api_date):
@ -111,3 +113,31 @@ def test_serialize_generic_relation(factory_name, factory_kwargs, expected, fact
obj = factories[factory_name](**factory_kwargs)
expected["type"] = factory_name
assert api_serializers.serialize_generic_relation({}, obj) == expected
def test_api_full_actor_serializer(factories, to_api_date):
summary = factories["common.Content"]()
icon = factories["common.Attachment"]()
user = factories["users.User"]()
actor = user.create_actor(summary_obj=summary, attachment_icon=icon)
expected = {
"fid": actor.fid,
"url": actor.url,
"creation_date": to_api_date(actor.creation_date),
"last_fetch_date": to_api_date(actor.last_fetch_date),
"user": users_serializers.UserBasicSerializer(user).data,
"is_channel": False,
"domain": actor.domain_id,
"type": actor.type,
"manually_approves_followers": actor.manually_approves_followers,
"full_username": actor.full_username,
"name": actor.name,
"preferred_username": actor.preferred_username,
"is_local": actor.is_local,
"summary": common_serializers.ContentSerializer(summary).data,
"icon": common_serializers.AttachmentSerializer(icon).data,
}
serializer = api_serializers.FullActorSerializer(actor)
assert serializer.data == expected

View File

@ -197,3 +197,15 @@ def test_user_can_list_domains(factories, api_client, preferences):
"results": [api_serializers.DomainSerializer(allowed).data],
}
assert response.data == expected
def test_can_retrieve_actor(factories, api_client, preferences):
preferences["common__api_authentication_required"] = False
actor = factories["federation.Actor"]()
url = reverse(
"api:v1:federation:actors-detail", kwargs={"full_username": actor.full_username}
)
response = api_client.get(url)
expected = api_serializers.FullActorSerializer(actor).data
assert response.data == expected

View File

@ -215,6 +215,8 @@ def test_album_serializer(factories, to_api_date):
}
serializer = serializers.AlbumSerializer(album)
for t in expected["tracks"]:
t["artist"].pop("cover")
assert serializer.data == expected

View File

@ -1249,6 +1249,13 @@ def test_search_get(use_fts, settings, logged_in_api_client, factories):
"tracks": [serializers.TrackSerializer(track).data],
"tags": [views.TagSerializer(tag).data],
}
for album in expected["albums"]:
album["artist"].pop("cover")
for track in expected["tracks"]:
track["artist"].pop("cover")
track["album"]["artist"].pop("cover")
response = logged_in_api_client.get(url, {"q": "foo"})
assert response.status_code == 200

View File

@ -157,6 +157,8 @@ def test_can_list_tracks_from_playlist(level, factories, logged_in_api_client):
url = reverse("api:v1:playlists-tracks", kwargs={"pk": plt.playlist.pk})
response = logged_in_api_client.get(url)
serialized_plt = serializers.PlaylistTrackSerializer(plt).data
serialized_plt["track"]["artist"].pop("cover")
serialized_plt["track"]["album"]["artist"].pop("cover")
assert response.data["count"] == 1
assert response.data["results"][0] == serialized_plt

View File

@ -36,6 +36,9 @@ def test_can_validate_config(logged_in_api_client, factories):
"count": candidates.count(),
"sample": TrackSerializer(candidates, many=True).data,
}
for s in expected["sample"]:
s["artist"].pop("cover")
assert payload["filters"][0]["candidates"] == expected
assert payload["filters"][0]["errors"] == []

View File

@ -14,7 +14,6 @@
},
"dependencies": {
"axios": "^0.18.0",
"dateformat": "^3.0.3",
"diff": "^4.0.1",
"django-channels": "^1.1.6",
"fomantic-ui-css": "^2.7",

View File

@ -24,7 +24,7 @@
<transition name="queue">
<queue @touch-progress="$refs.player.setCurrentTime($event)" v-if="$store.state.ui.queueFocused"></queue>
</transition>
<router-view :class="{hidden: $store.state.ui.queueFocused}" :key="$route.fullPath"></router-view>
<router-view :class="{hidden: $store.state.ui.queueFocused}"></router-view>
<player ref="player"></player>
<app-footer
:class="{hidden: $store.state.ui.queueFocused}"
@ -241,8 +241,9 @@ export default {
},
getTrackInformationText(track) {
const trackTitle = track.title
const albumArtist = (track.album) ? track.album.artist.name : null
const artistName = (
(track.artist) ? track.artist.name : track.album.artist.name)
(track.artist) ? track.artist.name : albumArtist)
const text = `${trackTitle} ${artistName}`
return text
},

View File

@ -315,7 +315,7 @@ export default {
title: t.title,
artist: t.artist,
album: t.album,
cover: self.getCover(t.album.cover),
cover: self.getCover((t.album || {}).cover),
sources: self.getSources(t.uploads)
}
})

View File

@ -6,7 +6,7 @@
<div class="ui six wide column current-track">
<div class="ui basic segment" id="player">
<template v-if="currentTrack">
<img class="ui image" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.square_crop)">
<img class="ui image" v-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.square_crop)">
<img class="ui image" v-else src="../assets/audio/default-cover.png">
<h1 class="ui header">
<div class="content">
@ -15,9 +15,9 @@
</router-link>
<div class="sub header">
<router-link class="discrete link artist" :title="currentTrack.artist.name" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
{{ currentTrack.artist.name | truncate(35) }}</router-link> /<router-link class="discrete link album" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
{{ currentTrack.artist.name | truncate(35) }}</router-link> <template v-if="currentTrack.album">/<router-link class="discrete link album" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
{{ currentTrack.album.title | truncate(35) }}
</router-link>
</router-link></template>
</div>
</div>
</h1>
@ -167,7 +167,7 @@
<i class="grip lines grey icon"></i>
</td>
<td class="image-cell" @click="$store.dispatch('queue/currentIndex', index)">
<img class="ui mini image" v-if="track.album.cover && track.album.cover.original" :src="$store.getters['instance/absoluteUrl'](track.album.cover.square_crop)">
<img class="ui mini image" v-if="currentTrack.album && track.album.cover && track.album.cover.original" :src="$store.getters['instance/absoluteUrl'](track.album.cover.square_crop)">
<img class="ui mini image" v-else src="../assets/audio/default-cover.png">
</td>
<td colspan="3" @click="$store.dispatch('queue/currentIndex', index)">

View File

@ -74,10 +74,10 @@
</router-link>
<div class="item">
<div class="ui user-dropdown dropdown" >
<img class="ui avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
<img class="ui avatar image" v-if="$store.state.auth.profile.avatar && $store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
<actor-avatar v-else :actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username}" />
<div class="menu">
<router-link class="item" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><translate translate-context="*/*/*/Noun">Profile</translate></router-link>
<router-link class="item" :to="{name: 'profile.overview', params: {username: $store.state.auth.username}}"><translate translate-context="*/*/*/Noun">Profile</translate></router-link>
<router-link class="item" :to="{path: '/settings'}"></i><translate translate-context="*/*/*/Noun">Settings</translate></router-link>
<router-link class="item" :to="{name: 'logout'}"></i><translate translate-context="Sidebar/Login/List item.Link/Verb">Logout</translate></router-link>
</div>
@ -155,7 +155,6 @@ import { mapState, mapActions, mapGetters } from "vuex"
import Logo from "@/components/Logo"
import SearchBar from "@/components/audio/SearchBar"
import backend from "@/audio/backend"
import $ from "jquery"
@ -168,7 +167,6 @@ export default {
data() {
return {
selectedTab: "library",
backend: backend,
isCollapsed: true,
fetchInterval: null,
exploreExpanded: false,

View File

@ -0,0 +1,55 @@
<template>
<div class="card app-card">
<div
@click="$router.push({name: 'channels.detail', params: {id: object.uuid}})"
:class="['ui', 'head-image', 'padded image', {'default-cover': !object.artist.cover}]" v-lazy:background-image="imageUrl">
<play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :artist="object.artist"></play-button>
</div>
<div class="content">
<strong>
<router-link class="discrete link" :title="object.artist.name" :to="{name: 'channels.detail', params: {id: object.uuid}}">
{{ object.artist.name }}
</router-link>
</strong>
<div class="description">
<tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="object.artist.tags"></tags-list>
</div>
</div>
<div class="extra content">
<translate translate-context="Content/Channel/Paragraph"
translate-plural="%{ count } episodes"
:translate-n="object.artist.tracks_count"
:translate-params="{count: object.artist.tracks_count}">
%{ count } episode
</translate>
<play-button class="right floated basic icon" :dropdown-only="true" :is-playable="true" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :artist="object.artist"></play-button>
</div>
</div>
</template>
<script>
import PlayButton from '@/components/audio/PlayButton'
import TagsList from "@/components/tags/List"
export default {
props: {
object: {type: Object},
},
components: {
PlayButton,
TagsList
},
computed: {
imageUrl () {
let url = '../../assets/audio/default-cover.png'
if (this.object.artist.cover) {
url = this.$store.getters['instance/absoluteUrl'](this.object.artist.cover.medium_square_crop)
} else {
return null
}
return url
}
}
}
</script>

View File

@ -0,0 +1,74 @@
<template>
<div>
<slot></slot>
<div class="ui hidden divider"></div>
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
</div>
<channel-entry-card v-for="entry in objects" :entry="entry" :key="entry.id" />
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
</button>
</template>
<template v-if="!isLoading && objects.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>
</div>
</template>
<script>
import _ from '@/lodash'
import axios from 'axios'
import ChannelEntryCard from '@/components/audio/ChannelEntryCard'
export default {
props: {
filters: {type: Object, required: true},
limit: {type: Number, default: 5},
},
components: {
ChannelEntryCard
},
data () {
return {
objects: [],
count: 0,
isLoading: false,
errors: null,
nextPage: null
}
},
created () {
this.fetchData('tracks/')
},
methods: {
fetchData (url) {
if (!url) {
return
}
this.isLoading = true
let self = this
let params = _.clone(this.filters)
params.page_size = this.limit
params.include_channels = true
axios.get(url, {params: params}).then((response) => {
self.nextPage = response.data.next
self.isLoading = false
self.objects = self.objects.concat(response.data.results)
self.count = response.data.count
self.$emit('fetched', response.data)
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
}
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<div class="channel-entry-card">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-else src="../../assets/audio/default-cover.png">
<div class="content">
<strong>
<router-link class="discrete ellipsis link" :title="entry.title" :to="{name: 'library.tracks.detail', params: {id: entry.id}}">
{{ entry.title|truncate(30) }}
</router-link>
</strong>
<div class="description">
<human-date :date="entry.creation_date"></human-date><template v-if="duration"> ·
<human-duration :duration="duration"></human-duration></template>
</div>
</div>
<div class="controls">
<play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'orange', 'icon', 'button']" :track="entry"></play-button>
</div>
</div>
</template>
<script>
import PlayButton from '@/components/audio/PlayButton'
export default {
props: ['entry'],
components: {
PlayButton,
},
computed: {
imageUrl () {
let url = '../../assets/audio/default-cover.png'
let cover = this.cover
if (cover && cover.original) {
url = this.$store.getters['instance/absoluteUrl'](cover.medium_square_crop)
} else {
return null
}
return url
},
cover () {
if (this.entry.album && this.entry.album.cover) {
return this.entry.album.cover
}
},
duration () {
let uploads = this.entry.uploads.filter((e) => {
return e.duration
})
if (uploads.length > 0) {
return uploads[0].duration
}
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.default-cover {
background-image: url("../../assets/audio/default-cover.png") !important;
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<div class="channel-serie-card">
<div class="two-images">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
</div>
<div class="content">
<strong>
<router-link class="discrete ellipsis link" :title="serie.title" :to="{name: 'library.albums.detail', params: {id: serie.id}}">
{{ serie.title|truncate(30) }}
</router-link>
</strong>
<div class="description">
<translate translate-context="Content/Channel/Paragraph"
translate-plural="%{ count } episodes"
:translate-n="serie.tracks.length"
:translate-params="{count: serie.tracks.length}">
%{ count } episode
</translate>
</div>
</div>
<div class="controls">
<play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'orange', 'icon', 'button']" :album="serie"></play-button>
</div>
</div>
</template>
<script>
import PlayButton from '@/components/audio/PlayButton'
export default {
props: ['serie'],
components: {
PlayButton,
},
computed: {
imageUrl () {
let url = '../../assets/audio/default-cover.png'
let cover = this.cover
if (cover && cover.original) {
url = this.$store.getters['instance/absoluteUrl'](cover.medium_square_crop)
} else {
return null
}
return url
},
cover () {
if (this.serie.cover) {
return this.serie.cover
}
},
duration () {
let uploads = this.serie.uploads.filter((e) => {
return e.duration
})
return uploads[0].duration
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.default-cover {
background-image: url("../../assets/audio/default-cover.png") !important;
}
</style>

View File

@ -0,0 +1,73 @@
<template>
<div>
<slot></slot>
<div class="ui hidden divider"></div>
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
</div>
<channel-serie-card v-for="serie in objects" :serie="serie" :key="serie.id" />
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
</button>
</template>
<template v-if="!isLoading && objects.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>
</div>
</template>
<script>
import _ from '@/lodash'
import axios from 'axios'
import ChannelSerieCard from '@/components/audio/ChannelSerieCard'
export default {
props: {
filters: {type: Object, required: true},
limit: {type: Number, default: 5},
},
components: {
ChannelSerieCard
},
data () {
return {
objects: [],
count: 0,
isLoading: false,
errors: null,
nextPage: null
}
},
created () {
this.fetchData('albums/')
},
methods: {
fetchData (url) {
if (!url) {
return
}
this.isLoading = true
let self = this
let params = _.clone(this.filters)
params.page_size = this.limit
params.include_channels = true
axios.get(url, {params: params}).then((response) => {
self.nextPage = response.data.next
self.isLoading = false
self.objects = self.objects.concat(response.data.results)
self.count = response.data.count
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
}
}
</script>

View File

@ -0,0 +1,76 @@
<template>
<div>
<slot></slot>
<div class="ui hidden divider"></div>
<div class="ui app-cards cards">
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
</div>
<channel-card v-for="object in objects" :object="object" :key="object.uuid" />
</div>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
</button>
</template>
<template v-if="!isLoading && objects.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>
</div>
</template>
<script>
import _ from '@/lodash'
import axios from 'axios'
import ChannelCard from '@/components/audio/ChannelCard'
export default {
props: {
filters: {type: Object, required: true},
limit: {type: Number, default: 5},
},
components: {
ChannelCard
},
data () {
return {
objects: [],
count: 0,
isLoading: false,
errors: null,
nextPage: null
}
},
created () {
this.fetchData('channels/')
},
methods: {
fetchData (url) {
if (!url) {
return
}
this.isLoading = true
let self = this
let params = _.clone(this.filters)
params.page_size = this.limit
params.include_channels = true
axios.get(url, {params: params}).then((response) => {
self.nextPage = response.data.next
self.isLoading = false
self.objects = self.objects.concat(response.data.results)
self.count = response.data.count
self.$emit('fetched', response.data)
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
}
}
</script>

View File

@ -10,7 +10,7 @@
<div class="controls track-controls queue-not-focused desktop-and-up">
<div @click.stop.prevent="" class="ui tiny image" @click.stop.prevent="$router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})">
<img ref="cover" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
<img ref="cover" v-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
<img v-else src="../../assets/audio/default-cover.png">
</div>
<div @click.stop.prevent="" class="middle aligned content ellipsis">
@ -21,15 +21,15 @@
</strong>
<div class="meta">
<router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.artist.name" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
{{ currentTrack.artist.name }}</router-link> /<router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
{{ currentTrack.artist.name }}</router-link><template v-if="currentTrack.album"> /<router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
{{ currentTrack.album.title }}
</router-link>
</router-link></template>
</div>
</div>
</div>
<div class="controls track-controls queue-not-focused tablet-and-below">
<div class="ui tiny image">
<img ref="cover" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
<img ref="cover" v-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
<img v-else src="../../assets/audio/default-cover.png">
</div>
<div class="middle aligned content ellipsis">
@ -37,7 +37,7 @@
{{ currentTrack.title }}
</strong>
<div class="meta">
{{ currentTrack.artist.name }} / {{ currentTrack.album.title }}
{{ currentTrack.artist.name }}<template v-if="currentTrack.album"> / {{ currentTrack.album.title }}</template>
</div>
</div>
</div>
@ -703,19 +703,22 @@ export default {
// If the session is playing as a PWA, populate the notification
// with details from the track
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: this.currentTrack.title,
artist: this.currentTrack.artist.name,
album: this.currentTrack.album.title,
artwork: [
{ src: this.currentTrack.album.cover.original, sizes: '96x96', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '128x128', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '192x192', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '256x256', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '384x384', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '512x512', type: 'image/png' },
let metatata = {
title: this.currentTrack.title,
artist: this.currentTrack.artist.name,
}
if (this.currentTrack.album) {
metadata.album = this.currentTrack.album.title
metadata.artwork = [
{ src: this.currentTrack.album.cover.original, sizes: '96x96', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '128x128', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '192x192', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '256x256', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '384x384', type: 'image/png' },
{ src: this.currentTrack.album.cover.original, sizes: '512x512', type: 'image/png' },
]
});
}
navigator.mediaSession.metadata = new MediaMetadata(metadata);
}
},
immediate: false

View File

@ -109,7 +109,11 @@ export default {
return r.title
},
getDescription (r) {
return `${r.album.artist.name} - ${r.album.title}`
if (r.album) {
return `${r.album.artist.name} - ${r.album.title}`
} else {
return r.artist.name
}
},
getId (t) {
return t.id

View File

@ -5,9 +5,6 @@
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
</h3>
<slot></slot>
<button v-if="controls" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button>
<button v-if="controls" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button>
<button v-if="controls" @click="fetchData('albums/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
<div class="ui hidden divider"></div>
<div class="ui app-cards cards">
<div v-if="isLoading" class="ui inverted active dimmer">
@ -23,6 +20,12 @@
</div>
</div>
</template>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
</button>
</template>
</div>
</template>
@ -68,7 +71,7 @@ export default {
self.previousPage = response.data.previous
self.nextPage = response.data.next
self.isLoading = false
self.albums = response.data.results
self.albums = [...self.albums, ...response.data.results]
self.count = response.data.count
}, error => {
self.isLoading = false

View File

@ -22,7 +22,6 @@
</template>
<script>
import backend from '@/audio/backend'
import PlayButton from '@/components/audio/PlayButton'
import TagsList from "@/components/tags/List"
@ -34,7 +33,6 @@ export default {
},
data () {
return {
backend: backend,
initialAlbums: 30,
showAllAlbums: true,
}

View File

@ -4,9 +4,6 @@
<slot name="title"></slot>
<span class="ui tiny circular label">{{ count }}</span>
</h3>
<button v-if="controls" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button>
<button v-if="controls" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button>
<button v-if="controls" @click="fetchData('artists/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
<div class="ui hidden divider"></div>
<div class="ui five app-cards cards">
<div v-if="isLoading" class="ui inverted active dimmer">
@ -15,6 +12,12 @@
<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>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
</button>
</template>
</div>
</template>
@ -60,7 +63,7 @@ export default {
self.previousPage = response.data.previous
self.nextPage = response.data.next
self.isLoading = false
self.objects = response.data.results
self.objects = [...self.objects, ...response.data.results]
self.count = response.data.count
}, error => {
self.isLoading = false

View File

@ -4,7 +4,7 @@
<play-button :class="['basic', {orange: currentTrack && isPlaying && track.id === currentTrack.id}, 'icon']" :discrete="true" :is-playable="playable" :track="track"></play-button>
</td>
<td>
<img class="ui mini image" v-if="track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.small_square_crop)">
<img class="ui mini image" v-if="track.album && track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.small_square_crop)">
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
</td>
<td colspan="6">
@ -30,7 +30,7 @@
</template>
</td>
<td colspan="4">
<router-link class="album discrete link" :title="track.album.title" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
<router-link v-if="track.album" class="album discrete link" :title="track.album.title" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
{{ track.album.title }}
</router-link>
</td>

View File

@ -29,7 +29,6 @@
</template>
<script>
import backend from '@/audio/backend'
import axios from 'axios'
import TrackRow from '@/components/audio/track/Row'
@ -49,7 +48,6 @@ export default {
},
data () {
return {
backend: backend,
loadMoreUrl: this.nextUrl,
isLoadingMore: false,
additionalTracks: []

View File

@ -4,13 +4,10 @@
<slot name="title"></slot>
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
</h3>
<button :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle up', 'icon']"></i></button>
<button :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle down', 'icon']"></i></button>
<button @click="fetchData(url)" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
<div v-if="count > 0" class="ui divided unstackable items">
<div :class="['item', itemClasses]" v-for="object in objects" :key="object.id">
<div class="ui tiny image">
<img v-if="object.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.medium_square_crop)">
<img v-if="object.track.album && object.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.medium_square_crop)">
<img v-else src="../../../assets/audio/default-cover.png">
<play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'tiny', 'orange', 'icon', 'button']" :track="object.track"></play-button>
</div>
@ -62,6 +59,12 @@
<div class="ui loader"></div>
</div>
</div>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
</button>
</template>
</div>
</template>
@ -112,14 +115,16 @@ export default {
self.nextPage = response.data.next
self.isLoading = false
self.count = response.data.count
let newObjects
if (self.isActivity) {
// we have listening/favorites objects, not directly tracks
self.objects = response.data.results
newObjects = response.data.results
} else {
self.objects = response.data.results.map((r) => {
newObjects = response.data.results.map((r) => {
return {track: r}
})
}
self.objects = [...self.objects, ...newObjects]
}, error => {
self.isLoading = false
self.errors = error.backendErrors

View File

@ -1,70 +0,0 @@
<template>
<main class="main pusher" v-title="labels.usernameProfile">
<div v-if="isLoading" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<template v-if="profile">
<div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']">
<h2 class="ui center aligned icon header">
<i v-if="!profile.avatar.square_crop" class="circular inverted user green icon"></i>
<img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](profile.avatar.square_crop)" />
<div class="content">
{{ profile.username }}
<div class="sub header" v-translate="{date: signupDate}" translate-context="Content/Profile/Paragraph">Member since %{ date }</div>
</div>
</h2>
<div class="ui basic green label">
<translate translate-context="Content/Profile/Button.Paragraph">This is you!</translate>
</div>
<a v-if="profile.is_staff"
class="ui yellow label"
:href="$store.getters['instance/absoluteUrl']('/api/admin')"
target="_blank">
<i class="star icon"></i>
<translate translate-context="Content/Profile/User role">Staff member</translate>
</a>
</div>
</template>
</main>
</template>
<script>
import { mapState } from "vuex"
const dateFormat = require("dateformat")
export default {
props: ["username"],
created() {
this.$store.dispatch("auth/fetchProfile")
},
computed: {
...mapState({
profile: state => state.auth.profile
}),
labels() {
let msg = this.$pgettext('Head/Profile/Title', "%{ username }'s profile")
let usernameProfile = this.$gettextInterpolate(msg, {
username: this.username
})
return {
usernameProfile
}
},
signupDate() {
let d = new Date(this.profile.date_joined)
return dateFormat(d, "longDate")
},
isLoading() {
return !this.profile
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.ui.header > img.image {
width: 8em;
}
</style>

View File

@ -23,6 +23,7 @@
<select v-if="f.type === 'dropdown'" class="ui dropdown" v-model="f.value">
<option :value="c" v-for="c in f.choices">{{ sharedLabels.fields[f.id].choices[c] }}</option>
</select>
<content-form v-if="f.type === 'content'" v-model="f.value.text"></content-form>
</div>
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit">
<translate translate-context="Content/Settings/Button.Label/Verb">Update settings</translate>
@ -331,8 +332,12 @@ export default {
settings: {
success: false,
errors: [],
order: ["privacy_level"],
order: ["summary", "privacy_level"],
fields: {
summary: {
type: "content",
initial: this.$store.state.auth.profile.summary || {text: '', content_type: 'text/markdown'},
},
privacy_level: {
type: "dropdown",
initial: this.$store.state.auth.profile.privacy_level,
@ -459,7 +464,7 @@ export default {
response => {
logger.default.info("Password successfully changed")
self.$router.push({
name: "profile",
name: "profile.overview",
params: {
username: self.$store.state.auth.username
}
@ -519,6 +524,9 @@ export default {
this.settings.order.forEach(setting => {
let conf = self.settings.fields[setting]
s[setting] = conf.value
if (setting === 'summary' && !conf.value.text) {
s[setting] = null
}
})
return s
}

View File

@ -117,7 +117,7 @@ export default {
response => {
logger.default.info("Successfully created account")
self.$router.push({
name: "profile",
name: "profile.overview",
params: {
username: this.username
}

View File

@ -0,0 +1,43 @@
<template>
<button @click.stop="toggle" :class="['ui', 'pink', {'inverted': isSubscribed}, {'favorited': isSubscribed}, 'icon', 'labeled', 'button']">
<i class="heart icon"></i>
<translate v-if="isSubscribed" translate-context="Content/Track/Button.Message">Unsubscribe</translate>
<translate v-else translate-context="Content/Track/*/Verb">Subscribe</translate>
</button>
</template>
<script>
export default {
props: {
channel: {type: Object},
},
computed: {
title () {
if (this.isSubscribed) {
return this.$pgettext('Content/Channel/Button/Verb', 'Subscribe')
} else {
return this.$pgettext('Content/Channel/Button/Verb', 'Unubscribe')
}
},
isSubscribed () {
return this.$store.getters['channels/isSubscribed'](this.channel.uuid)
}
},
methods: {
toggle () {
if (this.isSubscribed) {
this.$emit('unsubscribed')
} else {
this.$emit('subscribed')
}
this.$store.dispatch('channels/toggle', this.channel.uuid)
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -1,12 +1,7 @@
<template>
<router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: actor.full_username}}" v-if="admin" :title="actor.full_username">
<actor-avatar v-if="avatar" :actor="actor" />
&nbsp;{{ actor.full_username | truncate(30) }}
<router-link :to="url" :title="actor.full_username">
<template v-if="avatar"><actor-avatar :actor="actor" />&nbsp;</template>{{ repr | truncate(30) }}
</router-link>
<span v-else :title="actor.full_username">
<actor-avatar v-if="avatar" :actor="actor" />
&nbsp;{{ actor.full_username | truncate(30) }}
</span>
</template>
<script>
@ -17,6 +12,23 @@ export default {
actor: {type: Object},
avatar: {type: Boolean, default: true},
admin: {type: Boolean, default: false},
displayName: {type: Boolean, default: false},
},
computed: {
url () {
if (this.actor.is_local) {
return {name: 'profile.overview', params: {username: this.actor.preferred_username}}
} else {
return {name: 'profile.overview', params: {username: this.actor.full_username}}
}
},
repr () {
if (this.displayName) {
return this.actor.preferred_username
} else {
return this.actor.full_username
}
}
}
}
</script>

View File

@ -0,0 +1,118 @@
<template>
<div class="content-form ui segments">
<div class="ui segment">
<div class="ui tiny secondary pointing menu">
<button @click.prevent="isPreviewing = false" :class="[{active: !isPreviewing}, 'item']">
<translate translate-context="*/Form/Menu.item">Write</translate>
</button>
<button @click.prevent="isPreviewing = true" :class="[{active: isPreviewing}, 'item']">
<translate translate-context="*/Form/Menu.item">Preview</translate>
</button>
</div>
<template v-if="isPreviewing" >
<div class="ui placeholder" v-if="isLoadingPreview">
<div class="paragraph">
<div class="line"></div>
<div class="line"></div>
<div class="line"></div>
<div class="line"></div>
</div>
</div>
<p v-else-if="preview === null">
<translate translate-context="*/Form/Paragraph">Nothing to preview.</translate>
</p>
<div v-html="preview" v-else></div>
</template>
<template v-else>
<div class="ui transparent input">
<textarea ref="textarea" :name="fieldId" :id="fieldId" rows="5" v-model="newValue" :placeholder="labels.placeholder"></textarea>
</div>
<div class="ui very small hidden divider"></div>
</template>
</div>
<div class="ui bottom attached segment">
<span :class="['right', 'floated', {'ui red text': remainingChars < 0}]">
{{ remainingChars }}
</span>
<p>
<translate translate-context="*/Form/Paragraph">Markdown syntax is supported.</translate>
</p>
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
props: {
value: {type: String, default: ""},
fieldId: {type: String, default: "change-content"},
autofocus: {type: Boolean, default: false},
},
data () {
return {
isPreviewing: false,
preview: null,
newValue: this.value,
isLoadingPreview: false,
charLimit: 5000,
}
},
mounted () {
if (this.autofocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
},
methods: {
async loadPreview () {
this.isLoadingPreview = true
try {
let response = await axios.post('text-preview/', {text: this.value})
this.preview = response.data.rendered
} catch {
}
this.isLoadingPreview = false
}
},
computed: {
labels () {
return {
placeholder: this.$pgettext("*/Form/Placeholder", "Write a few words here…")
}
},
remainingChars () {
return this.charLimit - this.value.length
}
},
watch: {
newValue (v) {
this.$emit('input', v)
},
value: {
async handler (v) {
this.preview = null
this.newValue = v
if (this.isPreviewing) {
await this.loadPreview()
}
},
immediate: true,
},
async isPreviewing (v) {
if (v && !!this.value && this.preview === null) {
await this.loadPreview()
}
if (!v) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
}
}
}
</script>

View File

@ -0,0 +1,20 @@
<template>
<time :datetime="`${duration}s`">
<template v-if="durationObj.hours">{{ durationObj.hours|padDuration }}</template>{{ durationObj.minutes|padDuration }}:{{ durationObj.seconds|padDuration }}
</time>
</template>
<script>
import {secondsToObject} from '@/filters'
export default {
props: {
duration: {required: true},
},
computed: {
durationObj () {
return secondsToObject(this.duration)
}
}
}
</script>

View File

@ -0,0 +1,77 @@
<template>
<div>
<div v-html="content.html" v-if="content && !isUpdating"></div>
<p v-else-if="!isUpdating">
<translate translate-context="*/*/Placeholder">No description available</translate>
</p>
<template v-if="!isUpdating && canUpdate && updateUrl">
<div class="ui hidden divider"></div>
<span role="button" @click="isUpdating = true">
<i class="pencil icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</span>
</template>
<form v-if="isUpdating" class="ui form" @submit.prevent="submit()">
<div v-if="errors.length > 0" class="ui negative message">
<div class="header"><translate translate-context="Content/Channels/Error message.Title">Error while updating description</translate></div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
<content-form v-model="newText" :autofocus="true"></content-form>
<a @click.prevent="isUpdating = false" class="left floated">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</a>
<button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']" type="submit" :disabled="isLoading">
<translate translate-context="Content/Channels/Button.Label/Verb">Update description</translate>
</button>
<div class="ui clearing hidden divider"></div>
</form>
</div>
</template>
<script>
import {secondsToObject} from '@/filters'
import axios from 'axios'
export default {
props: {
content: {required: true},
fieldName: {required: false, default: 'description'},
updateUrl: {required: false, type: String},
canUpdate: {required: false, default: true, type: Boolean},
},
data () {
return {
isUpdating: false,
newText: (this.content || {text: ''}).text,
errors: null,
isLoading: false,
errors: [],
}
},
methods: {
submit () {
let self = this
this.isLoading = true
this.errors = []
let payload = {}
payload[this.fieldName] = null
if (this.newText) {
payload[this.fieldName] = {
content_type: "text/markdown",
text: this.newText,
}
}
axios.patch(this.updateUrl, payload).then((response) => {
self.$emit('updated', response.data)
self.isLoading = false
self.isUpdating = false
}, error => {
self.errors = error.backendErrors
self.isLoading = false
})
},
}
}
</script>

View File

@ -5,10 +5,6 @@
</h3>
<p v-if="!isLoading && libraries.length > 0" class="ui subtitle"><slot name="subtitle"></slot></p>
<p v-if="!isLoading && libraries.length === 0" class="ui subtitle"><translate translate-context="Content/Federation/Paragraph">No matching library.</translate></p>
<i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'angle left', 'icon']">
</i>
<i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'angle right', 'icon']">
</i>
<div class="ui hidden divider"></div>
<div class="ui cards">
<div v-if="isLoading" class="ui inverted active dimmer">
@ -22,6 +18,12 @@
v-for="library in libraries"
:key="library.uuid"></library-card>
</div>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
</button>
</template>
</div>
</template>
@ -61,7 +63,7 @@ export default {
self.previousPage = response.data.previous
self.nextPage = response.data.next
self.isLoading = false
self.libraries = response.data.results
self.libraries = [...self.libraries, ...response.data.results]
self.$emit('loaded', self.libraries)
}, error => {
self.isLoading = false

View File

@ -1,6 +1,7 @@
import Vue from 'vue'
Vue.component('human-date', () => import(/* webpackChunkName: "common" */ "@/components/common/HumanDate"))
Vue.component('human-duration', () => import(/* webpackChunkName: "common" */ "@/components/common/HumanDuration"))
Vue.component('username', () => import(/* webpackChunkName: "common" */ "@/components/common/Username"))
Vue.component('user-link', () => import(/* webpackChunkName: "common" */ "@/components/common/UserLink"))
Vue.component('actor-link', () => import(/* webpackChunkName: "common" */ "@/components/common/ActorLink"))
@ -15,5 +16,7 @@ Vue.component('empty-state', () => import(/* webpackChunkName: "common" */ "@/co
Vue.component('expandable-div', () => import(/* webpackChunkName: "common" */ "@/components/common/ExpandableDiv"))
Vue.component('collapse-link', () => import(/* webpackChunkName: "common" */ "@/components/common/CollapseLink"))
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"))
export default {}

View File

@ -172,6 +172,18 @@ export default {
var self = this
this.isLoading = true
logger.default.debug('Fetching artist "' + this.id + '"')
let artistPromise = axios.get("artists/" + this.id + "/", {params: {refresh: 'true'}}).then(response => {
if (response.data.channel) {
self.$router.replace({name: 'channels.detail', params: {id: response.data.channel.uuid}})
} else {
self.object = response.data
}
})
await artistPromise
if (!self.object) {
return
}
let trackPromise = axios.get("tracks/", { params: { artist: this.id, hidden: '' } }).then(response => {
self.tracks = response.data.results
self.nextTracksUrl = response.data.next
@ -188,13 +200,8 @@ export default {
})
})
let artistPromise = axios.get("artists/" + this.id + "/", {params: {refresh: 'true'}}).then(response => {
self.object = response.data
})
await trackPromise
await albumPromise
await artistPromise
self.isLoadingAlbums = false
self.isLoading = false
}

View File

@ -50,7 +50,6 @@
import _ from "@/lodash"
import axios from "axios"
import logger from "@/logging"
import backend from "@/audio/backend"
import AlbumCard from "@/components/audio/album/Card"
import TrackTable from "@/components/audio/track/Table"
import LibraryWidget from "@/components/federation/LibraryWidget"

View File

@ -78,7 +78,7 @@
<i class="external icon"></i>
<translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
</a>
<a :href="discogsUrl" target="_blank" rel="noreferrer noopener" class="basic item">
<a v-if="discogsUrl ":href="discogsUrl" target="_blank" rel="noreferrer noopener" class="basic item">
<i class="external icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Search on Discogs</translate>
</a>
@ -200,12 +200,15 @@ export default {
}
},
discogsUrl() {
return (
"https://discogs.com/search/?type=release&title=" +
encodeURI(this.track.album.title) + "&artist=" +
encodeURI(this.track.artist.name) + "&track=" +
encodeURI(this.track.title)
)
if (this.track.album) {
return (
"https://discogs.com/search/?type=release&title=" +
encodeURI(this.track.album.title) + "&artist=" +
encodeURI(this.track.artist.name) + "&track=" +
encodeURI(this.track.title)
)
}
},
downloadUrl() {
let u = this.$store.getters["instance/absoluteUrl"](
@ -242,8 +245,14 @@ export default {
)
},
subtitle () {
let msg = this.$pgettext('Content/Track/Paragraph', 'From album <a class="internal" href="%{ albumUrl }">%{ album }</a> by <a class="internal" href="%{ artistUrl }">%{ artist }</a>')
return this.$gettextInterpolate(msg, {album: this.track.album.title, artist: this.track.artist.name, albumUrl: this.albumUrl, artistUrl: this.artistUrl})
let msg
if (this.track.album) {
msg = this.$pgettext('Content/Track/Paragraph', 'From album <a class="internal" href="%{ albumUrl }">%{ album }</a> by <a class="internal" href="%{ artistUrl }">%{ artist }</a>')
return this.$gettextInterpolate(msg, {album: this.track.album.title, artist: this.track.artist.name, albumUrl: this.albumUrl, artistUrl: this.artistUrl})
} else {
msg = this.$pgettext('Content/Track/Paragraph', 'By <a class="internal" href="%{ artistUrl }">%{ artist }</a>')
return this.$gettextInterpolate(msg, {artist: this.track.artist.name, artistUrl: this.artistUrl})
}
}
},
watch: {

View File

@ -49,10 +49,12 @@
<router-link :to="{name: 'manage.library.tracks.detail', params: {id: scope.obj.id }}">{{ scope.obj.title }}</router-link>
</td>
<td>
<router-link :to="{name: 'manage.library.albums.detail', params: {id: scope.obj.album.id }}">
<i class="wrench icon"></i>
</router-link>
<span role="button" class="discrete link" @click="addSearchToken('album_id', scope.obj.album.id)" :title="scope.obj.album.title">{{ scope.obj.album.title }}</span>
<template v-if="scope.obj.album">
<router-link :to="{name: 'manage.library.albums.detail', params: {id: scope.obj.album.id }}">
<i class="wrench icon"></i>
</router-link>
<span role="button" class="discrete link" @click="addSearchToken('album_id', scope.obj.album.id)" :title="scope.obj.album.title">{{ scope.obj.album.title }}</span>
</template>
</td>
<td>
<router-link :to="{name: 'manage.library.artists.detail', params: {id: scope.obj.artist.id }}">

View File

@ -1,7 +1,7 @@
<script>
export default {
methods: {
getReportableObjs ({track, album, artist, playlist, account, library}) {
getReportableObjs ({track, album, artist, playlist, account, library, channel}) {
let reportableObjs = []
if (account) {
let accountLabel = this.$pgettext('*/Moderation/*/Verb', "Report @%{ username }…")

View File

@ -49,6 +49,9 @@ export default {
other: this.$pgettext("Content/Moderation/Dropdown", "Other"),
},
},
summary: {
label: this.$pgettext('Content/Account/*', 'Bio'),
},
},
filters: {
creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'),

View File

@ -64,7 +64,7 @@
<tr v-for="(plt, index) in plts" :key="plt.id">
<td class="left aligned">{{ plt.index + 1}}</td>
<td class="center aligned">
<img class="ui mini image" v-if="plt.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](plt.track.album.cover.small_square_crop)">
<img class="ui mini image" v-if="plt.track.album && plt.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](plt.track.album.cover.small_square_crop)">
<img class="ui mini image" v-else src="../../assets/audio/default-cover.png">
</td>
<td colspan="4">

View File

@ -3,9 +3,6 @@
<h3 class="ui header">
<slot name="title"></slot>
</h3>
<button :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle up', 'icon']"></i></button>
<button :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle down', 'icon']"></i></button>
<button @click="fetchData(url)" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
</div>
@ -31,6 +28,12 @@
</translate>
</button>
</div>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
</button>
</template>
</div>
</template>
@ -50,7 +53,7 @@ export default {
data () {
return {
objects: [],
limit: 3,
limit: this.filters.limit || 3,
isLoading: false,
errors: null,
previousPage: null,
@ -79,7 +82,7 @@ export default {
self.previousPage = response.data.previous
self.nextPage = response.data.next
self.isLoading = false
self.objects = response.data.results
self.objects = [...self.objects, ...response.data.results]
}, error => {
self.isLoading = false
self.errors = error.backendErrors

View File

@ -44,13 +44,22 @@ Vue.filter('ago', ago)
export function secondsToObject (seconds) {
let m = moment.duration(seconds, 'seconds')
return {
seconds: m.seconds(),
minutes: m.minutes(),
hours: parseInt(m.asHours())
hours: m.hours()
}
}
Vue.filter('secondsToObject', secondsToObject)
export function padDuration (duration) {
var s = String(duration);
while (s.length < 2) {s = "0" + s;}
return s;
}
Vue.filter('padDuration', padDuration)
export function momentFormat (date, format) {
format = format || 'lll'
return moment(date).format(format)

View File

@ -140,13 +140,33 @@ export default new Router({
),
props: true
},
{
path: "/@:username",
name: "profile",
component: () =>
import(/* webpackChunkName: "core" */ "@/components/auth/Profile"),
props: true
},
...['/@:username', '/@:username@:domain'].map((path) => {
return {
path: path,
name: "profile",
component: () =>
import(/* webpackChunkName: "core" */ "@/views/auth/ProfileBase"),
props: true,
children: [
{
path: "",
name: "profile.overview",
component: () =>
import(
/* webpackChunkName: "core" */ "@/views/auth/ProfileOverview"
)
},
{
path: "activity",
name: "profile.activity",
component: () =>
import(
/* webpackChunkName: "core" */ "@/views/auth/ProfileActivity"
)
},
]
}
}),
{
path: "/favorites",
name: "favorites",
@ -285,7 +305,7 @@ export default new Router({
props: true
},
{
path: "albums",
path: "channels",
name: "manage.library.albums",
component: () =>
import(
@ -783,6 +803,32 @@ export default new Router({
}
]
},
{
path: "/channels/:id",
props: true,
component: () =>
import(
/* webpackChunkName: "channels" */ "@/views/channels/DetailBase"
),
children: [
{
path: "",
name: "channels.detail",
component: () =>
import(
/* webpackChunkName: "channels" */ "@/views/channels/DetailOverview"
)
},
{
path: "episodes",
name: "channels.detail.episodes",
component: () =>
import(
/* webpackChunkName: "channels" */ "@/views/channels/DetailEpisodes"
)
},
]
},
{
path: "*/index.html",
redirect: "/"

View File

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

View File

@ -0,0 +1,65 @@
import axios from 'axios'
import logger from '@/logging'
export default {
namespaced: true,
state: {
subscriptions: [],
count: 0
},
mutations: {
subscriptions: (state, {uuid, value}) => {
if (value) {
if (state.subscriptions.indexOf(uuid) === -1) {
state.subscriptions.push(uuid)
}
} else {
let i = state.subscriptions.indexOf(uuid)
if (i > -1) {
state.subscriptions.splice(i, 1)
}
}
state.count = state.subscriptions.length
},
reset (state) {
state.subscriptions = []
state.count = 0
}
},
getters: {
isSubscribed: (state) => (uuid) => {
return state.subscriptions.indexOf(uuid) > -1
}
},
actions: {
set ({commit, state}, {uuid, value}) {
commit('subscriptions', {uuid, value})
if (value) {
return axios.post(`channels/${uuid}/subscribe/`).then((response) => {
logger.default.info('Successfully subscribed to channel')
}, (response) => {
logger.default.info('Error while subscribing to channel')
commit('subscriptions', {uuid, value: !value})
})
} else {
return axios.post(`channels/${uuid}/unsubscribe/`).then((response) => {
logger.default.info('Successfully unsubscribed from channel')
}, (response) => {
logger.default.info('Error while unsubscribing from channel')
commit('subscriptions', {uuid, value: !value})
})
}
},
toggle ({getters, dispatch}, uuid) {
dispatch('set', {uuid, value: !getters['isSubscribed'](uuid)})
},
fetchSubscriptions ({dispatch, state, commit, rootState}, url) {
let promise = axios.get('subscriptions/all/')
return promise.then((response) => {
response.data.results.forEach(result => {
commit('subscriptions', {uuid: result.channel, value: true})
})
})
}
}
}

View File

@ -3,6 +3,7 @@ import Vuex from 'vuex'
import createPersistedState from 'vuex-persistedstate'
import favorites from './favorites'
import channels from './channels'
import auth from './auth'
import instance from './instance'
import moderation from './moderation'
@ -18,6 +19,7 @@ export default new Vuex.Store({
modules: {
ui,
auth,
channels,
favorites,
instance,
moderation,
@ -76,21 +78,24 @@ export default new Vuex.Store({
mbid: track.artist.mbid,
name: track.artist.name
}
return {
let data = {
id: track.id,
title: track.title,
mbid: track.mbid,
uploads: track.uploads,
listen_url: track.listen_url,
album: {
artist: artist,
}
if (track.album) {
data.album = {
id: track.album.id,
title: track.album.title,
mbid: track.album.mbid,
cover: track.album.cover,
artist: artist
},
artist: artist
}
}
return data
})
}
}

View File

@ -112,6 +112,12 @@ export default {
let instanceUrl = state.instanceUrl || getDefaultUrl()
return instanceUrl + relativeUrl
},
domain: (state) => {
let url = state.instanceUrl
let parser = document.createElement("a")
parser.href = url
return parser.hostname
}
},
actions: {

View File

@ -69,6 +69,7 @@
@import "~fomantic-ui-css/components/sidebar.css";
@import "~fomantic-ui-css/components/sticky.css";
@import "~fomantic-ui-css/components/tab.css";
@import "~fomantic-ui-css/components/text.css";
@import "~fomantic-ui-css/components/transition.css";
@ -194,6 +195,16 @@ html {
}
}
.stripe.segment > .secondary.menu:last-child {
position: absolute;
bottom: 0;
left: 0;
right: 0;
border-bottom: none;
}
.center.aligned.menu {
justify-content: center;
}
.ellipsis:not(.icon) {
text-overflow: ellipsis;
white-space: nowrap;
@ -387,6 +398,9 @@ input + .help {
.ui.small.divider {
margin: 0.5rem 0;
}
.ui.very.small.divider {
margin: 0.25rem 0;
}
.queue.segment.player-focused #queue-grid #player {
@include media("<desktop") {
@ -431,6 +445,12 @@ input + .help {
display: flex;
width: $card-width;
height: $card-hight;
.content:not(.extra) {
padding: 0.5em 1em 0;
}
.content.extra {
padding: 0.5em 1em;
}
.head-image {
height: $card-width;
background-size: cover !important;
@ -449,6 +469,10 @@ input + .help {
margin: 0.5em;
}
&.padded {
margin: 0.5em;
border-radius: 0.25em !important;
}
&.squares {
display: block !important;
position: relative;
@ -490,5 +514,58 @@ input + .help {
}
}
}
// channels stuff
.channel-entry-card, .channel-serie-card {
display: flex;
width: 100%;
align-items: center;
margin: 0 auto 1em;
justify-content: space-between;
.image {
width: 3.5em;
margin-right: 1em;
}
.two-images {
width: 3.5em;
height: 3.5em;
margin-right: 1em;
position: relative;
img {
width: 2.5em;
position: absolute;
&:last-child {
bottom: 0;
left: 0;
}
&:first-child {
top: 0;
right: 0;
}
}
}
.content {
flex-grow: 1;
}
}
.channel-image {
border: 1px solid rgba(0, 0, 0, 0.5);
border-radius: 0.3em;
&.large {
width: 8em !important;
}
}
.content-form {
.segment:first-child {
min-height: 15em;
}
.ui.secondary.menu {
margin-top: -0.5em;
}
.input {
width: 100%;
}
}
@import "./themes/_light.scss";
@import "./themes/_dark.scss";

View File

@ -77,7 +77,7 @@
:class="['ui', {loading: isLoading}, 'basic button']"
:action="remove">
<translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this album?</translate></p>
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this track?</translate></p>
<div slot="modal-content">
<p><translate translate-context="Content/Moderation/Paragraph">The track will be removed, as well as associated uploads, favorites and listening history. This action is irreversible.</translate></p>
</div>
@ -109,7 +109,7 @@
{{ object.title }}
</td>
</tr>
<tr>
<tr v-if="object.album">
<td>
<router-link :to="{name: 'manage.library.albums.detail', params: {id: object.album.id }}">
<translate translate-context="*/*/*">Album</translate>
@ -130,7 +130,7 @@
{{ object.artist.name }}
</td>
</tr>
<tr>
<tr v-if="object.album">
<td>
<router-link :to="{name: 'manage.library.artists.detail', params: {id: object.album.artist.id }}">
<translate translate-context="*/*/*/Noun">Album artist</translate>

View File

@ -0,0 +1,34 @@
<template>
<section class="ui stackable three column grid">
<div class="column">
<h2 class="ui header">
<translate translate-context="Content/Home/Title">Recently listened</translate>
</h2>
<track-widget :url="'history/listenings/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}">
</track-widget>
</div>
<div class="column">
<h2 class="ui header">
<translate translate-context="Content/Home/Title">Recently favorited</translate>
</h2>
<track-widget :url="'favorites/tracks/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}"></track-widget>
</div>
<div class="column">
<h2 class="ui header">
<translate translate-context="*/*/*">Playlists</translate>
</h2>
<playlist-widget :url="'playlists/'" :filters="{scope: `actor:${object.full_username}`, playable: true, ordering: '-modification_date'}">
</playlist-widget>
</div>
</section>
</template>
<script>
import TrackWidget from "@/components/audio/track/Widget"
import PlaylistWidget from "@/components/playlists/Widget"
export default {
props: ['object'],
components: {TrackWidget, PlaylistWidget},
}
</script>

View File

@ -0,0 +1,130 @@
<template>
<main class="main pusher" v-title="labels.usernameProfile">
<div v-if="isLoading" class="ui vertical segment">
<div class="ui centered active inline loader"></div>
</div>
<template v-if="object">
<div class="ui dropdown icon small basic right floated button" ref="dropdown" v-dropdown style="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({account: 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.moderation.accounts.detail', params: {id: object.full_username}}">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link>
</div>
</div>
<div class="ui head vertical stripe segment">
<h1 class="ui center aligned icon header">
<i v-if="!object.icon" class="circular inverted user green icon"></i>
<img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](object.icon.square_crop)" />
<div class="ellispsis content">
<div class="ui very small hidden divider"></div>
<span :title="displayName">{{ displayName }}</span>
<div class="ui very small hidden divider"></div>
<span class="ui grey tiny text" :title="object.full_username">{{ object.full_username }}</span>
</div>
<template v-if="object.full_username === $store.state.auth.fullUsername">
<div class="ui very small hidden divider"></div>
<div class="ui basic green label">
<translate translate-context="Content/Profile/Button.Paragraph">This is you!</translate>
</div>
</template>
</h1>
<div class="ui container">
<div class="ui secondary pointing center aligned menu">
<router-link class="item" :exact="true" :to="{name: 'profile.overview', params: routerParams}">
<translate translate-context="Content/Profile/Link">Overview</translate>
</router-link>
<router-link class="item" :exact="true" :to="{name: 'profile.activity', params: routerParams}">
<translate translate-context="Content/Profile/*">Activity</translate>
</router-link>
</div>
<div class="ui hidden divider"></div>
<keep-alive>
<router-view @updated="fetch" :object="object"></router-view>
</keep-alive>
</div>
</div>
</template>
</main>
</template>
<script>
import { mapState } from "vuex"
import axios from 'axios'
import ReportMixin from '@/components/mixins/Report'
export default {
mixins: [ReportMixin],
props: {
username: {type: String, required: true},
domain: {type: String, required: false, default: null},
},
data () {
return {
object: null,
isLoading: false,
}
},
created() {
this.fetch()
},
methods: {
fetch () {
let self = this
self.isLoading = true
axios.get(`federation/actors/${this.fullUsername}/`).then((response) => {
self.object = response.data
self.isLoading = false
})
}
},
computed: {
labels() {
let msg = this.$pgettext('Head/Profile/Title', "%{ username }'s profile")
let usernameProfile = this.$gettextInterpolate(msg, {
username: this.username
})
return {
usernameProfile
}
},
fullUsername () {
if (this.username && this.domain) {
return `${this.username}@${this.domain}`
} else {
return `${this.username}@${this.$store.getters['instance/domain']}`
}
},
routerParams () {
if (this.domain) {
return {username: this.username, domain: this.domain}
} else {
return {username: this.username}
}
},
displayName () {
return this.object.name || this.object.preferred_username
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.ui.header > img.image {
width: 8em;
}
</style>

View File

@ -0,0 +1,35 @@
<template>
<section class="ui stackable grid">
<div class="six wide column">
<rendered-description
@updated="$emit('updated', $event)"
:content="object.summary"
:field-name="'summary'"
:update-url="`users/users/${$store.state.auth.username}/`"
:can-update="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername"></rendered-description>
</div>
<div class="ten wide column">
<h2 class="ui header">
<translate translate-context="*/*/*">Channels</translate>
</h2>
<channels-widget :filters="{scope: `actor:${object.full_username}`}"></channels-widget>
<h2 class="ui header">
<translate translate-context="Content/Profile/Header">User Libraries</translate>
</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>
</library-widget>
</div>
</section>
</template>
<script>
import LibraryWidget from "@/components/federation/LibraryWidget"
import ChannelsWidget from "@/components/audio/ChannelsWidget"
export default {
props: ['object'],
components: {ChannelsWidget, LibraryWidget},
}
</script>

View File

@ -0,0 +1,203 @@
<template>
<main class="main pusher" v-title="labels.title">
<div v-if="isLoading" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<template v-if="object && !isLoading">
<section class="ui head vertical stripe segment container" v-title="object.artist.name">
<div class="ui stackable two column grid">
<div class="column">
<div class="ui two column grid">
<div class="column">
<img class="huge channel-image" v-if="object.artist.cover" :src="$store.getters['instance/absoluteUrl'](object.artist.cover.medium_square_crop)">
<i v-else class="huge circular inverted users violet icon"></i>
</div>
<div class="ui column right aligned">
<tags-list v-if="object.artist.tags && object.artist.tags.length > 0" :tags="object.artist.tags"></tags-list>
<actor-link :avatar="false" :actor="object.attributed_to" :display-name="true"></actor-link>
<template v-if="totalTracks > 0">
<div class="ui hidden very small divider"></div>
<translate translate-context="Content/Channel/Paragraph"
translate-plural="%{ count } episodes"
:translate-n="totalTracks"
:translate-params="{count: totalTracks}">
%{ count } episode
</translate>
<template v-if="object.attributed_to.full_username === $store.state.auth.fullUsername || $store.getters['channels/isSubscribed'](object.uuid)">
· <translate translate-context="Content/Channel/Paragraph" translate-plural="%{ count } subscribers" :translate-n="object.subscriptions_count" :translate-params="{count: object.subscriptions_count}">%{ count } subscriber</translate>
</template>
</template>
<div class="ui hidden small divider"></div>
<a :href="rssUrl" target="_blank" class="ui icon small basic button">
<i class="feed icon"></i>
</a>
<div class="ui dropdown icon small basic button" ref="dropdown" v-dropdown>
<i class="ellipsis vertical icon"></i>
<div class="menu">
<div
role="button"
v-if="totalTracks > 0"
@click="showEmbedModal = !showEmbedModal"
class="basic item">
<i class="code icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Embed</translate>
</div>
<div class="divider"></div>
<div
role="button"
class="basic item"
v-for="obj in getReportableObjs({channel: 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['library']" :to="{name: 'manage.library.channels.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>
</div>
</div>
<h1 class="ui header">
<div class="left aligned content ellipsis">
{{ object.artist.name }}
<div class="ui hidden very small divider"></div>
<div class="sub header">
{{ object.actor.full_username }}
</div>
</div>
</h1>
<div class="header-buttons">
<div class="ui buttons">
<play-button :is-playable="isPlayable" class="orange" :channel="object">
<translate translate-context="Content/Channels/Button.Label/Verb">Play</translate>
</play-button>
</div>
<div class="ui buttons">
<subscribe-button @subscribed="object.subscriptions_count += 1" @unsubscribed="object.subscriptions_count -= 1" :channel="object"></subscribe-button>
</div>
<modal :show.sync="showEmbedModal" v-if="totalTracks > 0">
<div class="header">
<translate translate-context="Popup/Artist/Title/Verb">Embed this artist work on your website</translate>
</div>
<div class="content">
<div class="description">
<embed-wizard type="artist" :id="object.artist.id" />
</div>
</div>
<div class="actions">
<div class="ui deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</div>
</div>
</modal>
</div>
<div>
<rendered-description
@updated="object = $event"
:content="object.artist.description"
:update-url="`channels/${object.uuid}/`"
:can-update="$store.state.auth.authenticated && object.attributed_to.full_username === $store.state.auth.fullUsername"></rendered-description>
</div>
</div>
<div class="column">
<div class="ui secondary pointing center aligned menu">
<router-link class="item" :exact="true" :to="{name: 'channels.detail', params: {id: id}}">
<translate translate-context="Content/Channels/Link">Overview</translate>
</router-link>
<router-link class="item" :exact="true" :to="{name: 'channels.detail.episodes', params: {id: id}}">
<translate translate-context="Content/Channels/*">Episodes</translate>
</router-link>
</div>
<div class="ui hidden divider"></div>
<keep-alive>
<router-view v-if="object" :object="object" @tracks-loaded="totalTracks = $event" ></router-view>
</keep-alive>
</div>
</div>
</section>
</template>
</main>
</template>
<script>
import axios from "axios"
import PlayButton from "@/components/audio/PlayButton"
import ChannelEntries from "@/components/audio/ChannelEntries"
import ChannelSeries from "@/components/audio/ChannelSeries"
import EmbedWizard from "@/components/audio/EmbedWizard"
import Modal from '@/components/semantic/Modal'
import TagsList from "@/components/tags/List"
import ReportMixin from '@/components/mixins/Report'
import SubscribeButton from '@/components/channels/SubscribeButton'
export default {
mixins: [ReportMixin],
props: ["id"],
components: {
PlayButton,
EmbedWizard,
Modal,
TagsList,
ChannelEntries,
ChannelSeries,
SubscribeButton
},
data() {
return {
isLoading: true,
object: null,
totalTracks: 0,
latestTracks: null,
showEmbedModal: false,
}
},
async created() {
await this.fetchData()
},
methods: {
async fetchData() {
var self = this
this.isLoading = true
let channelPromise = axios.get(`channels/${this.id}`).then(response => {
self.object = response.data
})
let tracksPromise = axios.get("tracks", {params: {channel: this.id, page_size: 1, playable: true, include_channels: true}}).then(response => {
self.totalTracks = response.data.count
})
await channelPromise
await tracksPromise
self.isLoading = false
}
},
computed: {
labels() {
return {
title: this.$pgettext('*/*/*', 'Channel')
}
},
contentFilter () {
let self = this
return this.$store.getters['moderation/artistFilters']().filter((e) => {
return e.target.id === this.object.artist.id
})[0]
},
isPlayable () {
return this.totalTracks > 0
},
rssUrl () {
return this.$store.getters['instance/absoluteUrl'](`api/v1/channels/${this.id}/rss`)
}
},
watch: {
id() {
this.fetchData()
}
}
}
</script>

View File

@ -0,0 +1,18 @@
<template>
<section>
<channel-entries :limit="25" :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
</channel-entries>
</section>
</template>
<script>
import ChannelEntries from "@/components/audio/ChannelEntries"
export default {
props: ['object'],
components: {
ChannelEntries,
},
}
</script>

View File

@ -0,0 +1,29 @@
<template>
<section>
<channel-entries :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
<h2 class="ui header">
<translate translate-context="Content/Channel/Paragraph">Latest episodes</translate>
</h2>
</channel-entries>
<div class="ui hidden divider"></div>
<channel-series :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
<h2 class="ui header">
<translate translate-context="Content/Channel/Paragraph">Series</translate>
</h2>
</channel-series>
</section>
</template>
<script>
import ChannelEntries from "@/components/audio/ChannelEntries"
import ChannelSeries from "@/components/audio/ChannelSeries"
export default {
props: ['object'],
components: {
ChannelEntries,
ChannelSeries,
},
}
</script>

View File

@ -133,6 +133,7 @@
</td>
<td>
<span
v-if="scope.obj.track.album"
class="discrete link"
@click="addSearchToken('album', scope.obj.track.album.title)"
:title="scope.obj.track.album.title"