kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
Merge branch '284-browse-redesign' into 'develop'
Resolve "UX, UI : Browse Library" Closes #284 See merge request funkwhale/funkwhale!317merge-requests/330/head
commit
20282539d4
|
@ -39,6 +39,7 @@ DEBUG_TOOLBAR_CONFIG = {
|
|||
"DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"],
|
||||
"SHOW_TEMPLATE_CONTEXT": True,
|
||||
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
|
||||
"JQUERY_URL": "",
|
||||
}
|
||||
|
||||
# django-extensions
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.activity import serializers as activity_serializers
|
||||
from funkwhale_api.music.serializers import TrackActivitySerializer
|
||||
from funkwhale_api.users.serializers import UserActivitySerializer
|
||||
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
|
||||
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
|
||||
|
||||
from . import models
|
||||
|
||||
|
@ -26,6 +26,15 @@ class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
|
|||
|
||||
|
||||
class UserTrackFavoriteSerializer(serializers.ModelSerializer):
|
||||
track = TrackSerializer(read_only=True)
|
||||
user = UserBasicSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.TrackFavorite
|
||||
fields = ("id", "user", "track", "creation_date")
|
||||
|
||||
|
||||
class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.TrackFavorite
|
||||
fields = ("id", "track", "creation_date")
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.decorators import list_route
|
||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||
from rest_framework.response import Response
|
||||
|
||||
from funkwhale_api.activity import record
|
||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||
from funkwhale_api.common import fields, permissions
|
||||
from funkwhale_api.music.models import Track
|
||||
|
||||
from . import models, serializers
|
||||
|
@ -18,7 +19,17 @@ class TrackFavoriteViewSet(
|
|||
|
||||
serializer_class = serializers.UserTrackFavoriteSerializer
|
||||
queryset = models.TrackFavorite.objects.all()
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
permission_classes = [
|
||||
permissions.ConditionalAuthentication,
|
||||
permissions.OwnerPermission,
|
||||
IsAuthenticatedOrReadOnly,
|
||||
]
|
||||
owner_checks = ["write"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.request.method.lower() in ["head", "get", "options"]:
|
||||
return serializers.UserTrackFavoriteSerializer
|
||||
return serializers.UserTrackFavoriteWriteSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
|
@ -32,7 +43,10 @@ class TrackFavoriteViewSet(
|
|||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(user=self.request.user)
|
||||
queryset = super().get_queryset()
|
||||
return queryset.filter(
|
||||
fields.privacy_level_query(self.request.user, "user__privacy_level")
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
track = Track.objects.get(pk=serializer.data["track"])
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.activity import serializers as activity_serializers
|
||||
from funkwhale_api.music.serializers import TrackActivitySerializer
|
||||
from funkwhale_api.users.serializers import UserActivitySerializer
|
||||
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
|
||||
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
|
||||
|
||||
from . import models
|
||||
|
||||
|
@ -25,6 +25,20 @@ class ListeningActivitySerializer(activity_serializers.ModelSerializer):
|
|||
|
||||
|
||||
class ListeningSerializer(serializers.ModelSerializer):
|
||||
track = TrackSerializer(read_only=True)
|
||||
user = UserBasicSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Listening
|
||||
fields = ("id", "user", "track", "creation_date")
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["user"] = self.context["user"]
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class ListeningWriteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Listening
|
||||
fields = ("id", "user", "track", "creation_date")
|
||||
|
|
|
@ -1,17 +1,36 @@
|
|||
from rest_framework import mixins, permissions, viewsets
|
||||
from rest_framework import mixins, viewsets
|
||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||
|
||||
from funkwhale_api.activity import record
|
||||
from funkwhale_api.common import fields, permissions
|
||||
|
||||
from . import models, serializers
|
||||
|
||||
|
||||
class ListeningViewSet(
|
||||
mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
mixins.CreateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
|
||||
serializer_class = serializers.ListeningSerializer
|
||||
queryset = models.Listening.objects.all()
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
queryset = (
|
||||
models.Listening.objects.all()
|
||||
.select_related("track__artist", "track__album__artist", "user")
|
||||
.prefetch_related("track__files")
|
||||
)
|
||||
permission_classes = [
|
||||
permissions.ConditionalAuthentication,
|
||||
permissions.OwnerPermission,
|
||||
IsAuthenticatedOrReadOnly,
|
||||
]
|
||||
owner_checks = ["write"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.request.method.lower() in ["head", "get", "options"]:
|
||||
return serializers.ListeningSerializer
|
||||
return serializers.ListeningWriteSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
r = super().perform_create(serializer)
|
||||
|
@ -20,7 +39,9 @@ class ListeningViewSet(
|
|||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
return queryset.filter(user=self.request.user)
|
||||
return queryset.filter(
|
||||
fields.privacy_level_query(self.request.user, "user__privacy_level")
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from django.db.models import Count
|
||||
from django_filters import rest_framework as filters
|
||||
|
||||
from funkwhale_api.music import utils
|
||||
|
@ -7,10 +8,23 @@ from . import models
|
|||
|
||||
class PlaylistFilter(filters.FilterSet):
|
||||
q = filters.CharFilter(name="_", method="filter_q")
|
||||
listenable = filters.BooleanFilter(name="_", method="filter_listenable")
|
||||
|
||||
class Meta:
|
||||
model = models.Playlist
|
||||
fields = {"user": ["exact"], "name": ["exact", "icontains"], "q": "exact"}
|
||||
fields = {
|
||||
"user": ["exact"],
|
||||
"name": ["exact", "icontains"],
|
||||
"q": "exact",
|
||||
"listenable": "exact",
|
||||
}
|
||||
|
||||
def filter_listenable(self, queryset, name, value):
|
||||
queryset = queryset.annotate(plts_count=Count("playlist_tracks"))
|
||||
if value:
|
||||
return queryset.filter(plts_count__gt=0)
|
||||
else:
|
||||
return queryset.filter(plts_count=0)
|
||||
|
||||
def filter_q(self, queryset, name, value):
|
||||
query = utils.get_query(value, ["name", "user__username"])
|
||||
|
|
|
@ -3,12 +3,41 @@ from django.utils import timezone
|
|||
from rest_framework import exceptions
|
||||
|
||||
from funkwhale_api.common import fields, preferences
|
||||
from funkwhale_api.music import models as music_models
|
||||
|
||||
|
||||
class PlaylistQuerySet(models.QuerySet):
|
||||
def with_tracks_count(self):
|
||||
return self.annotate(_tracks_count=models.Count("playlist_tracks"))
|
||||
|
||||
def with_duration(self):
|
||||
return self.annotate(
|
||||
duration=models.Sum("playlist_tracks__track__files__duration")
|
||||
)
|
||||
|
||||
def with_covers(self):
|
||||
album_prefetch = models.Prefetch(
|
||||
"album", queryset=music_models.Album.objects.only("cover")
|
||||
)
|
||||
track_prefetch = models.Prefetch(
|
||||
"track",
|
||||
queryset=music_models.Track.objects.prefetch_related(album_prefetch).only(
|
||||
"id", "album_id"
|
||||
),
|
||||
)
|
||||
|
||||
plt_prefetch = models.Prefetch(
|
||||
"playlist_tracks",
|
||||
queryset=PlaylistTrack.objects.all()
|
||||
.exclude(track__album__cover=None)
|
||||
.exclude(track__album__cover="")
|
||||
.order_by("index")
|
||||
.only("id", "playlist_id", "track_id")
|
||||
.prefetch_related(track_prefetch),
|
||||
to_attr="plts_for_cover",
|
||||
)
|
||||
return self.prefetch_related(plt_prefetch)
|
||||
|
||||
|
||||
class Playlist(models.Model):
|
||||
name = models.CharField(max_length=50)
|
||||
|
|
|
@ -65,6 +65,8 @@ class PlaylistTrackWriteSerializer(serializers.ModelSerializer):
|
|||
|
||||
class PlaylistSerializer(serializers.ModelSerializer):
|
||||
tracks_count = serializers.SerializerMethodField(read_only=True)
|
||||
duration = serializers.SerializerMethodField(read_only=True)
|
||||
album_covers = serializers.SerializerMethodField(read_only=True)
|
||||
user = UserBasicSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
@ -72,11 +74,13 @@ class PlaylistSerializer(serializers.ModelSerializer):
|
|||
fields = (
|
||||
"id",
|
||||
"name",
|
||||
"tracks_count",
|
||||
"user",
|
||||
"modification_date",
|
||||
"creation_date",
|
||||
"privacy_level",
|
||||
"tracks_count",
|
||||
"album_covers",
|
||||
"duration",
|
||||
)
|
||||
read_only_fields = ["id", "modification_date", "creation_date"]
|
||||
|
||||
|
@ -87,6 +91,36 @@ class PlaylistSerializer(serializers.ModelSerializer):
|
|||
# no annotation?
|
||||
return obj.playlist_tracks.count()
|
||||
|
||||
def get_duration(self, obj):
|
||||
try:
|
||||
return obj.duration
|
||||
except AttributeError:
|
||||
# no annotation?
|
||||
return 0
|
||||
|
||||
def get_album_covers(self, obj):
|
||||
try:
|
||||
plts = obj.plts_for_cover
|
||||
except AttributeError:
|
||||
return []
|
||||
|
||||
covers = []
|
||||
max_covers = 5
|
||||
for plt in plts:
|
||||
url = plt.track.album.cover.url
|
||||
if url in covers:
|
||||
continue
|
||||
covers.append(url)
|
||||
if len(covers) >= max_covers:
|
||||
break
|
||||
|
||||
full_urls = []
|
||||
for url in covers:
|
||||
if "request" in self.context:
|
||||
url = self.context["request"].build_absolute_uri(url)
|
||||
full_urls.append(url)
|
||||
return full_urls
|
||||
|
||||
|
||||
class PlaylistAddManySerializer(serializers.Serializer):
|
||||
tracks = serializers.PrimaryKeyRelatedField(
|
||||
|
|
|
@ -24,6 +24,8 @@ class PlaylistViewSet(
|
|||
models.Playlist.objects.all()
|
||||
.select_related("user")
|
||||
.annotate(tracks_count=Count("playlist_tracks"))
|
||||
.with_covers()
|
||||
.with_duration()
|
||||
)
|
||||
permission_classes = [
|
||||
permissions.ConditionalAuthentication,
|
||||
|
|
|
@ -45,12 +45,6 @@ class UserActivitySerializer(activity_serializers.ModelSerializer):
|
|||
return "Person"
|
||||
|
||||
|
||||
class UserBasicSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "username", "name", "date_joined"]
|
||||
|
||||
|
||||
avatar_field = VersatileImageFieldSerializer(
|
||||
allow_null=True,
|
||||
sizes=[
|
||||
|
@ -62,6 +56,14 @@ avatar_field = VersatileImageFieldSerializer(
|
|||
)
|
||||
|
||||
|
||||
class UserBasicSerializer(serializers.ModelSerializer):
|
||||
avatar = avatar_field
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "username", "name", "date_joined", "avatar"]
|
||||
|
||||
|
||||
class UserWriteSerializer(serializers.ModelSerializer):
|
||||
avatar = avatar_field
|
||||
|
||||
|
|
|
@ -4,6 +4,8 @@ import pytest
|
|||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.favorites.models import TrackFavorite
|
||||
from funkwhale_api.music import serializers as music_serializers
|
||||
from funkwhale_api.users import serializers as users_serializers
|
||||
|
||||
|
||||
def test_user_can_add_favorite(factories):
|
||||
|
@ -15,21 +17,25 @@ def test_user_can_add_favorite(factories):
|
|||
assert f.user == user
|
||||
|
||||
|
||||
def test_user_can_get_his_favorites(factories, logged_in_client, client):
|
||||
def test_user_can_get_his_favorites(api_request, factories, logged_in_client, client):
|
||||
r = api_request.get("/")
|
||||
favorite = factories["favorites.TrackFavorite"](user=logged_in_client.user)
|
||||
url = reverse("api:v1:favorites:tracks-list")
|
||||
response = logged_in_client.get(url)
|
||||
|
||||
expected = [
|
||||
{
|
||||
"track": favorite.track.pk,
|
||||
"user": users_serializers.UserBasicSerializer(
|
||||
favorite.user, context={"request": r}
|
||||
).data,
|
||||
"track": music_serializers.TrackSerializer(
|
||||
favorite.track, context={"request": r}
|
||||
).data,
|
||||
"id": favorite.id,
|
||||
"creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
]
|
||||
parsed_json = json.loads(response.content.decode("utf-8"))
|
||||
|
||||
assert expected == parsed_json["results"]
|
||||
assert response.status_code == 200
|
||||
assert response.data["results"] == expected
|
||||
|
||||
|
||||
def test_user_can_add_favorite_via_api(factories, logged_in_client, activity_muted):
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@pytest.mark.parametrize("level", ["instance", "me", "followers"])
|
||||
def test_privacy_filter(preferences, level, factories, api_client):
|
||||
preferences["common__api_authentication_required"] = False
|
||||
factories["favorites.TrackFavorite"](user__privacy_level=level)
|
||||
url = reverse("api:v1:favorites:tracks-list")
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 0
|
|
@ -0,0 +1,13 @@
|
|||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@pytest.mark.parametrize("level", ["instance", "me", "followers"])
|
||||
def test_privacy_filter(preferences, level, factories, api_client):
|
||||
preferences["common__api_authentication_required"] = False
|
||||
factories["history.Listening"](user__privacy_level=level)
|
||||
url = reverse("api:v1:history:listenings-list")
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 0
|
|
@ -63,3 +63,40 @@ def test_update_insert_is_called_when_index_is_provided(factories, mocker):
|
|||
insert.assert_called_once_with(playlist, plt, 0)
|
||||
assert plt.index == 0
|
||||
assert first.index == 1
|
||||
|
||||
|
||||
def test_playlist_serializer_include_covers(factories, api_request):
|
||||
playlist = factories["playlists.Playlist"]()
|
||||
t1 = factories["music.Track"]()
|
||||
t2 = factories["music.Track"]()
|
||||
t3 = factories["music.Track"](album__cover=None)
|
||||
t4 = factories["music.Track"]()
|
||||
t5 = factories["music.Track"]()
|
||||
t6 = factories["music.Track"]()
|
||||
t7 = factories["music.Track"]()
|
||||
|
||||
playlist.insert_many([t1, t2, t3, t4, t5, t6, t7])
|
||||
request = api_request.get("/")
|
||||
qs = playlist.__class__.objects.with_covers().with_tracks_count()
|
||||
|
||||
expected = [
|
||||
request.build_absolute_uri(t1.album.cover.url),
|
||||
request.build_absolute_uri(t2.album.cover.url),
|
||||
request.build_absolute_uri(t4.album.cover.url),
|
||||
request.build_absolute_uri(t5.album.cover.url),
|
||||
request.build_absolute_uri(t6.album.cover.url),
|
||||
]
|
||||
|
||||
serializer = serializers.PlaylistSerializer(qs.get(), context={"request": request})
|
||||
assert serializer.data["album_covers"] == expected
|
||||
|
||||
|
||||
def test_playlist_serializer_include_duration(factories, api_request):
|
||||
playlist = factories["playlists.Playlist"]()
|
||||
tf1 = factories["music.TrackFile"](duration=15)
|
||||
tf2 = factories["music.TrackFile"](duration=30)
|
||||
playlist.insert_many([tf1.track, tf2.track])
|
||||
qs = playlist.__class__.objects.with_duration().with_tracks_count()
|
||||
|
||||
serializer = serializers.PlaylistSerializer(qs.get())
|
||||
assert serializer.data["duration"] == 45
|
||||
|
|
|
@ -32,6 +32,9 @@
|
|||
<router-link class="item" to="/about">
|
||||
<translate>About this instance</translate>
|
||||
</router-link>
|
||||
<router-link class="item" :to="{name: 'library.request'}">
|
||||
<translate>Request music</translate>
|
||||
</router-link>
|
||||
<a href="https://funkwhale.audio" class="item" target="_blank"><translate>Official website</translate></a>
|
||||
<a href="https://docs.funkwhale.audio" class="item" target="_blank"><translate>Documentation</translate></a>
|
||||
<a href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank">
|
||||
|
|
|
@ -3,10 +3,10 @@
|
|||
<div class="ui vertical center aligned stripe segment">
|
||||
<div class="ui text container">
|
||||
<h1 class="ui huge header">
|
||||
<template v-if="instance.name.value" :template-params="{instance: instance.name}">
|
||||
<translate v-if="instance.name.value" :translate-params="{instance: instance.name.value}">
|
||||
About %{ instance }
|
||||
</template>
|
||||
<template v-else="instance.name.value"><translate>About this instance</translate></template>
|
||||
</translate>
|
||||
<translate v-else>About this instance</translate>
|
||||
</h1>
|
||||
<stats></stats>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar',]">
|
||||
<div class="ui inverted segment header-wrapper">
|
||||
<search-bar @search="isCollapsed = false">
|
||||
<router-link :title="'Funkwhale'" :to="{name: 'index'}">
|
||||
<router-link :title="'Funkwhale'" :to="{name: logoUrl}">
|
||||
<i class="logo bordered inverted orange big icon">
|
||||
<logo class="logo"></logo>
|
||||
</i>
|
||||
|
@ -39,7 +39,7 @@
|
|||
<translate :translate-params="{username: $store.state.auth.username}">
|
||||
Logged in as %{ username }
|
||||
</translate>
|
||||
<img class="ui avatar right floated circular mini 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 right floated circular tiny avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
|
||||
</router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i><translate>Logout</translate></router-link>
|
||||
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i><translate>Login</translate></router-link>
|
||||
|
@ -237,6 +237,13 @@ export default {
|
|||
set (value) {
|
||||
this.tracksChangeBuffer = value
|
||||
}
|
||||
},
|
||||
logoUrl () {
|
||||
if (this.$store.state.auth.authenticated) {
|
||||
return 'library.index'
|
||||
} else {
|
||||
return 'index'
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -433,8 +440,9 @@ $sidebar-color: #3d3e3f;
|
|||
}
|
||||
}
|
||||
}
|
||||
.avatar {
|
||||
.ui.tiny.avatar.image {
|
||||
position: relative;
|
||||
top: -0.5em;
|
||||
width: 3em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,22 +1,23 @@
|
|||
<template>
|
||||
<div :title="title" :class="['ui', {'tiny': discrete}, 'buttons']">
|
||||
<span :title="title" :class="['ui', {'tiny': discrete}, {'buttons': !dropdownOnly && !iconOnly}]">
|
||||
<button
|
||||
v-if="!dropdownOnly"
|
||||
:title="labels.addToQueue"
|
||||
@click="addNext(true)"
|
||||
:disabled="!playable"
|
||||
:class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']">
|
||||
<i class="ui play icon"></i>
|
||||
<template v-if="!discrete"><slot><translate>Play</translate></slot></template>
|
||||
:class="buttonClasses.concat(['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}])">
|
||||
<i :class="[playIconClass, 'icon']"></i>
|
||||
<template v-if="!discrete && !iconOnly"><slot><translate>Play</translate></slot></template>
|
||||
</button>
|
||||
<div v-if="!discrete" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', 'icon', 'button']">
|
||||
<i class="dropdown icon"></i>
|
||||
<div v-if="!discrete && !iconOnly" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]">
|
||||
<i :class="dropdownIconClasses.concat(['icon'])"></i>
|
||||
<div class="menu">
|
||||
<div class="item" :disabled="!playable" @click="add"><i class="plus icon"></i><translate>Add to queue</translate></div>
|
||||
<div class="item" :disabled="!playable" @click="addNext()"><i class="step forward icon"></i><translate>Play next</translate></div>
|
||||
<div class="item" :disabled="!playable" @click="addNext(true)"><i class="arrow down icon"></i><translate>Play now</translate></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -28,8 +29,13 @@ export default {
|
|||
// we can either have a single or multiple tracks to play when clicked
|
||||
tracks: {type: Array, required: false},
|
||||
track: {type: Object, required: false},
|
||||
dropdownIconClasses: {type: Array, required: false, default: () => { return ['dropdown'] }},
|
||||
playIconClass: {type: String, required: false, default: 'play icon'},
|
||||
buttonClasses: {type: Array, required: false, default: () => { return ['button'] }},
|
||||
playlist: {type: Object, required: false},
|
||||
discrete: {type: Boolean, default: false},
|
||||
dropdownOnly: {type: Boolean, default: false},
|
||||
iconOnly: {type: Boolean, default: false},
|
||||
artist: {type: Number, required: false},
|
||||
album: {type: Number, required: false}
|
||||
},
|
||||
|
|
|
@ -0,0 +1,134 @@
|
|||
<template>
|
||||
<div>
|
||||
<h3 class="ui header">
|
||||
<slot name="title"></slot>
|
||||
</h3>
|
||||
<i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle left', 'icon']">
|
||||
</i>
|
||||
<i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle right', 'icon']">
|
||||
</i>
|
||||
<div class="ui hidden divider"></div>
|
||||
<div class="ui five cards">
|
||||
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||
<div class="ui loader"></div>
|
||||
</div>
|
||||
<div class="card" v-for="album in albums" :key="album.id">
|
||||
<div :class="['ui', 'image', 'with-overlay', {'default-cover': !album.cover}]" :style="getImageStyle(album)">
|
||||
<play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album.id"></play-button>
|
||||
</div>
|
||||
<div class="content">
|
||||
<router-link :title="album.title" :to="{name: 'library.albums.detail', params: {id: album.id}}">
|
||||
{{ album.title|truncate(25) }}
|
||||
</router-link>
|
||||
<div class="description">
|
||||
<span>
|
||||
<router-link :title="album.artist.name" class="discrete link" :to="{name: 'library.artists.detail', params: {id: album.artist.id}}">
|
||||
{{ album.artist.name|truncate(23) }}
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<human-date class="left floated" :date="album.creation_date"></human-date>
|
||||
<play-button class="right floated basic icon" :dropdown-only="true" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :album="album.id"></play-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
import axios from 'axios'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
filters: {type: Object, required: true}
|
||||
},
|
||||
components: {
|
||||
PlayButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
albums: [],
|
||||
limit: 12,
|
||||
isLoading: false,
|
||||
errors: null,
|
||||
previousPage: 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.offset = this.offset
|
||||
axios.get(url, {params: params}).then((response) => {
|
||||
self.previousPage = response.data.previous
|
||||
self.nextPage = response.data.next
|
||||
self.isLoading = false
|
||||
self.albums = response.data.results
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
},
|
||||
updateOffset (increment) {
|
||||
if (increment) {
|
||||
this.offset += this.limit
|
||||
} else {
|
||||
this.offset = Math.max(this.offset - this.limit, 0)
|
||||
}
|
||||
},
|
||||
getImageStyle (album) {
|
||||
let url = '../../../assets/audio/default-cover.png'
|
||||
|
||||
if (album.cover) {
|
||||
url = this.$store.getters['instance/absoluteUrl'](album.cover)
|
||||
} else {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
'background-image': `url("${url}")`
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
offset () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@import '../../../style/vendor/media';
|
||||
|
||||
.default-cover {
|
||||
background-image: url('../../../assets/audio/default-cover.png') !important;
|
||||
}
|
||||
|
||||
.ui.cards {
|
||||
justify-content: center;
|
||||
}
|
||||
.ui.cards > .card {
|
||||
width: 15em;
|
||||
}
|
||||
.with-overlay {
|
||||
background-size: cover !important;
|
||||
background-position: center !important;
|
||||
height: 15em;
|
||||
width: 15em;
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
</style>
|
|
@ -5,7 +5,7 @@
|
|||
</td>
|
||||
<td>
|
||||
<img class="ui mini image" v-if="track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover)">
|
||||
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
|
||||
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="track" :to="{name: 'library.tracks.detail', params: {id: track.id }}">
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
<template>
|
||||
<div>
|
||||
<h3 class="ui header">
|
||||
<slot name="title"></slot>
|
||||
</h3>
|
||||
<i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle up', 'icon']">
|
||||
</i>
|
||||
<i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle down', 'icon']">
|
||||
</i>
|
||||
<div class="ui divided unstackable items">
|
||||
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||
<div class="ui loader"></div>
|
||||
</div>
|
||||
<div class="item" v-for="object in objects" :key="object.id">
|
||||
<div class="ui tiny image">
|
||||
<img v-if="object.track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover)">
|
||||
<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>
|
||||
<div class="middle aligned content">
|
||||
<div class="ui unstackable grid">
|
||||
<div class="thirteen wide stretched column">
|
||||
<div>
|
||||
<router-link :title="object.track.title" :to="{name: 'library.tracks.detail', params: {id: object.track.id}}">
|
||||
{{ object.track.title|truncate(25) }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span>
|
||||
<router-link :title="object.track.artist.name" class="discrete link" :to="{name: 'library.artists.detail', params: {id: object.track.artist.id}}">
|
||||
{{ object.track.artist.name|truncate(25) }}
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
<div class="extra">
|
||||
<span class="left floated">@{{ object.user.username }}</span>
|
||||
<span class="right floated"><human-date :date="object.creation_date" /></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="one wide stretched column">
|
||||
<play-button class="basic icon" :dropdown-only="true" :dropdown-icon-classes="['ellipsis', 'vertical', 'large', 'grey']" :track="object.track"></play-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
import axios from 'axios'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
filters: {type: Object, required: true},
|
||||
url: {type: String, required: true}
|
||||
},
|
||||
components: {
|
||||
PlayButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
objects: [],
|
||||
limit: 5,
|
||||
isLoading: false,
|
||||
errors: null,
|
||||
previousPage: null,
|
||||
nextPage: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData(this.url)
|
||||
},
|
||||
methods: {
|
||||
fetchData (url) {
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
this.isLoading = true
|
||||
let self = this
|
||||
let params = _.clone(this.filters)
|
||||
params.page_size = this.limit
|
||||
params.offset = this.offset
|
||||
axios.get(url, {params: params}).then((response) => {
|
||||
self.previousPage = response.data.previous
|
||||
self.nextPage = response.data.next
|
||||
self.isLoading = false
|
||||
self.objects = response.data.results
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
},
|
||||
updateOffset (increment) {
|
||||
if (increment) {
|
||||
this.offset += this.limit
|
||||
} else {
|
||||
this.offset = Math.max(this.offset - this.limit, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
offset () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '../../../style/vendor/media';
|
||||
|
||||
.play-overlay {
|
||||
position: absolute;
|
||||
top: 4em;
|
||||
left: 4em;
|
||||
@include media(">tablet") {
|
||||
top: 2.5em;
|
||||
left: 2.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -65,7 +65,7 @@ import PasswordInput from '@/components/forms/PasswordInput'
|
|||
|
||||
export default {
|
||||
props: {
|
||||
invitation: {type: String, required: false, default: null},
|
||||
defaultInvitation: {type: String, required: false, default: null},
|
||||
next: {type: String, default: '/'}
|
||||
},
|
||||
components: {
|
||||
|
@ -78,7 +78,8 @@ export default {
|
|||
password: '',
|
||||
isLoadingInstanceSetting: true,
|
||||
errors: [],
|
||||
isLoading: false
|
||||
isLoading: false,
|
||||
invitation: this.defaultInvitation
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<span>
|
||||
<translate
|
||||
v-if="durationData.hours > 0"
|
||||
:translate-params="{minutes: durationData.minutes, hours: durationData.hours}">%{ hours } h %{ minutes } min</translate>
|
||||
<translate
|
||||
v-else
|
||||
:translate-params="{minutes: durationData.minutes}">%{ minutes } min</translate>
|
||||
</span>
|
||||
</template>
|
||||
<script>
|
||||
import {secondsToObject} from '@/filters'
|
||||
|
||||
export default {
|
||||
props: ['seconds'],
|
||||
computed: {
|
||||
durationData () {
|
||||
return secondsToObject(this.seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<span>
|
||||
<img
|
||||
class="ui tiny circular avatar"
|
||||
v-if="user.avatar && user.avatar.small_square_crop"
|
||||
:src="$store.getters['instance/absoluteUrl'](user.avatar.small_square_crop)" />
|
||||
<span v-else :style="defaultAvatarStyle" class="ui circular label">{{ user.username[0]}}</span>
|
||||
@{{ user.username }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {hashCode, intToRGB} from '@/utils/color'
|
||||
|
||||
export default {
|
||||
props: ['user'],
|
||||
computed: {
|
||||
userColor () {
|
||||
return intToRGB(hashCode(this.user.username + String(this.user.id)))
|
||||
},
|
||||
defaultAvatarStyle () {
|
||||
return {
|
||||
'background-color': `#${this.userColor}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.tiny.circular.avatar {
|
||||
width: 1.7em;
|
||||
height: 1.7em;
|
||||
}
|
||||
</style>
|
|
@ -8,6 +8,14 @@ import Username from '@/components/common/Username'
|
|||
|
||||
Vue.component('username', Username)
|
||||
|
||||
import UserLink from '@/components/common/UserLink'
|
||||
|
||||
Vue.component('user-link', UserLink)
|
||||
|
||||
import Duration from '@/components/common/Duration'
|
||||
|
||||
Vue.component('duration', Duration)
|
||||
|
||||
import DangerousButton from '@/components/common/DangerousButton'
|
||||
|
||||
Vue.component('dangerous-button', DangerousButton)
|
||||
|
|
|
@ -1,32 +1,29 @@
|
|||
<template>
|
||||
<div v-title="labels.title">
|
||||
<div class="ui vertical stripe segment">
|
||||
<search :autofocus="true"></search>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui stackable three column grid">
|
||||
<div class="column">
|
||||
<h2 class="ui header">
|
||||
<translate>Latest artists</translate>
|
||||
</h2>
|
||||
<div :class="['ui', {'active': isLoadingArtists}, 'inline', 'loader']"></div>
|
||||
<div v-if="artists.length > 0" v-for="artist in artists.slice(0, 3)" :key="artist.id" class="ui cards">
|
||||
<artist-card :artist="artist"></artist-card>
|
||||
</div>
|
||||
<track-widget :url="'history/listenings/'" :filters="{scope: 'user', ordering: '-creation_date'}">
|
||||
<template slot="title"><translate>Recently listened</translate></template>
|
||||
</track-widget>
|
||||
</div>
|
||||
<div class="column">
|
||||
<h2 class="ui header">
|
||||
<translate>Radios</translate>
|
||||
</h2>
|
||||
<radio-card :type="'favorites'"></radio-card>
|
||||
<radio-card :type="'random'"></radio-card>
|
||||
<radio-card :type="'less-listened'"></radio-card>
|
||||
<track-widget :url="'favorites/tracks/'" :filters="{scope: 'user', ordering: '-creation_date'}">
|
||||
<template slot="title"><translate>Recently favorited</translate></template>
|
||||
</track-widget>
|
||||
</div>
|
||||
<div class="column">
|
||||
<h2 class="ui header">
|
||||
<translate>Music requests</translate>
|
||||
</h2>
|
||||
<request-form v-if="$store.state.auth.authenticated"></request-form>
|
||||
<playlist-widget :url="'playlists/'" :filters="{scope: 'user', listenable: true, ordering: '-creation_date'}">
|
||||
<template slot="title"><translate>Playlists</translate></template>
|
||||
</playlist-widget>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui section hidden divider"></div>
|
||||
<div class="ui grid">
|
||||
<div class="ui row">
|
||||
<album-widget :filters="{ordering: '-creation_date'}">
|
||||
<template slot="title"><translate>Recently added</translate></template>
|
||||
</album-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -38,8 +35,9 @@ import axios from 'axios'
|
|||
import Search from '@/components/audio/Search'
|
||||
import logger from '@/logging'
|
||||
import ArtistCard from '@/components/audio/artist/Card'
|
||||
import RadioCard from '@/components/radios/Card'
|
||||
import RequestForm from '@/components/requests/Form'
|
||||
import TrackWidget from '@/components/audio/track/Widget'
|
||||
import AlbumWidget from '@/components/audio/album/Widget'
|
||||
import PlaylistWidget from '@/components/playlists/Widget'
|
||||
|
||||
const ARTISTS_URL = 'artists/'
|
||||
|
||||
|
@ -48,8 +46,9 @@ export default {
|
|||
components: {
|
||||
Search,
|
||||
ArtistCard,
|
||||
RadioCard,
|
||||
RequestForm
|
||||
TrackWidget,
|
||||
AlbumWidget,
|
||||
PlaylistWidget
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
|
|
@ -4,6 +4,22 @@
|
|||
<h2 class="ui header">
|
||||
<translate>Browsing radios</translate>
|
||||
</h2>
|
||||
<div class="ui hidden divider"></div>
|
||||
<div class="ui row">
|
||||
<h3 class="ui header">
|
||||
<translate>Instance radios</translate>
|
||||
</h3>
|
||||
<div class="ui cards">
|
||||
<radio-card :type="'favorites'"></radio-card>
|
||||
<radio-card :type="'random'"></radio-card>
|
||||
<radio-card :type="'less-listened'"></radio-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui hidden divider"></div>
|
||||
<h3 class="ui header">
|
||||
<translate>User radios</translate>
|
||||
</h3>
|
||||
<router-link class="ui green basic button" to="/library/radios/build" exact>
|
||||
<translate>Create your own radio</translate>
|
||||
</router-link>
|
||||
|
|
|
@ -1,30 +1,35 @@
|
|||
<template>
|
||||
<div class="ui card">
|
||||
<div class="ui playlist card">
|
||||
<div class="ui top attached icon button" :style="coversStyle">
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<router-link class="discrete link" :to="{name: 'library.playlists.detail', params: {id: playlist.id }}">
|
||||
{{ playlist.name }}
|
||||
<div class="right floated">
|
||||
<play-button :icon-only="true" class="ui inline" :button-classes="['ui', 'circular', 'large', {orange: playlist.tracks_count > 0}, 'icon', 'button', {disabled: playlist.tracks_count === 0}]" :playlist="playlist"></play-button>
|
||||
<play-button class="basic inline icon" :dropdown-only="true" :dropdown-icon-classes="['ellipsis', 'vertical', 'large', {disabled: playlist.tracks_count === 0}, 'grey']" :playlist="playlist"></play-button>
|
||||
</div>
|
||||
<router-link :title="playlist.name" class="discrete link" :to="{name: 'library.playlists.detail', params: {id: playlist.id }}">
|
||||
{{ playlist.name | truncate(30) }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<i class="user icon"></i> {{ playlist.user.username }}
|
||||
</div>
|
||||
<div class="meta">
|
||||
<i class="clock icon"></i>
|
||||
<human-date :date="playlist.modification_date" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<span>
|
||||
<duration :seconds="playlist.duration" />
|
||||
|
|
||||
<i class="sound icon"></i>
|
||||
<translate
|
||||
translate-plural="%{ count } tracks"
|
||||
:translate-n="playlist.tracks_count"
|
||||
:translate-params="{count: playlist.tracks_count}">
|
||||
%{ count} track
|
||||
</translate>
|
||||
</translate>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<user-link :user="playlist.user" class="left floated" />
|
||||
<span class="right floated">
|
||||
<i class="clock outline icon" />
|
||||
<human-date :date="playlist.creation_date" />
|
||||
</span>
|
||||
<play-button class="mini basic orange right floated" :playlist="playlist"><translate>Play all</translate></play-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -36,11 +41,46 @@ export default {
|
|||
props: ['playlist'],
|
||||
components: {
|
||||
PlayButton
|
||||
},
|
||||
computed: {
|
||||
coversStyle () {
|
||||
let self = this
|
||||
let urls = this.playlist.album_covers.map((url) => {
|
||||
url = self.$store.getters['instance/absoluteUrl'](url)
|
||||
return `url("${url}")`
|
||||
}).slice(0, 4)
|
||||
return {
|
||||
'background-image': urls.join(', ')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
<style>
|
||||
|
||||
.playlist.card .header .ellipsis.vertical.large.grey {
|
||||
font-size: 1.2em;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
<style scoped>
|
||||
.card .header {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.attached.button {
|
||||
background-color: rgb(243, 244, 245);
|
||||
background-size: 25% ;
|
||||
background-repeat: no-repeat;
|
||||
background-origin: border-box;
|
||||
background-position: 0 0, 33.33% 0, 66.67% 0, 100% 0;
|
||||
/* background-position: 0 0, 50% 0, 100% 0; */
|
||||
/* background-position: 0 0, 25% 0, 50% 0, 75% 0, 100% 0; */
|
||||
font-size: 4em;
|
||||
box-shadow: 0px 0px 0px 1px rgba(34, 36, 38, 0.15) inset !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<div>
|
||||
<h3 class="ui header">
|
||||
<slot name="title"></slot>
|
||||
</h3>
|
||||
<i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle up', 'icon']">
|
||||
</i>
|
||||
<i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle down', 'icon']">
|
||||
</i>
|
||||
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||
<div class="ui loader"></div>
|
||||
</div>
|
||||
<playlist-card class="fluid" v-for="playlist in objects" :key="playlist.id" :playlist="playlist"></playlist-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
import axios from 'axios'
|
||||
import PlaylistCard from '@/components/playlists/Card'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
filters: {type: Object, required: true},
|
||||
url: {type: String, required: true}
|
||||
},
|
||||
components: {
|
||||
PlaylistCard
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
objects: [],
|
||||
limit: 3,
|
||||
isLoading: false,
|
||||
errors: null,
|
||||
previousPage: null,
|
||||
nextPage: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData(this.url)
|
||||
},
|
||||
methods: {
|
||||
fetchData (url) {
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
this.isLoading = true
|
||||
let self = this
|
||||
let params = _.clone(this.filters)
|
||||
params.page_size = this.limit
|
||||
params.offset = this.offset
|
||||
axios.get(url, {params: params}).then((response) => {
|
||||
self.previousPage = response.data.previous
|
||||
self.nextPage = response.data.next
|
||||
self.isLoading = false
|
||||
self.objects = response.data.results
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
},
|
||||
updateOffset (increment) {
|
||||
if (increment) {
|
||||
this.offset += this.limit
|
||||
} else {
|
||||
this.offset = Math.max(this.offset - this.limit, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
offset () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -28,6 +28,16 @@ export function ago (date) {
|
|||
|
||||
Vue.filter('ago', ago)
|
||||
|
||||
export function secondsToObject (seconds) {
|
||||
let m = moment.duration(seconds, 'seconds')
|
||||
return {
|
||||
minutes: m.minutes(),
|
||||
hours: parseInt(m.asHours())
|
||||
}
|
||||
}
|
||||
|
||||
Vue.filter('secondsToObject', secondsToObject)
|
||||
|
||||
export function momentFormat (date, format) {
|
||||
format = format || 'lll'
|
||||
return moment(date).format(format)
|
||||
|
|
|
@ -35,6 +35,7 @@ import AdminUsersBase from '@/views/admin/users/Base'
|
|||
import AdminUsersDetail from '@/views/admin/users/UsersDetail'
|
||||
import AdminUsersList from '@/views/admin/users/UsersList'
|
||||
import AdminInvitationsList from '@/views/admin/users/InvitationsList'
|
||||
import MusicRequest from '@/views/library/MusicRequest'
|
||||
import FederationBase from '@/views/federation/Base'
|
||||
import FederationScan from '@/views/federation/Scan'
|
||||
import FederationLibraryDetail from '@/views/federation/LibraryDetail'
|
||||
|
@ -218,7 +219,12 @@ export default new Router({
|
|||
path: '/library',
|
||||
component: Library,
|
||||
children: [
|
||||
{ path: '', component: LibraryHome },
|
||||
{ path: '', component: LibraryHome, name: 'library.index' },
|
||||
{
|
||||
path: 'requests/',
|
||||
name: 'library.request',
|
||||
component: MusicRequest
|
||||
},
|
||||
{
|
||||
path: 'artists/',
|
||||
name: 'library.artists.browse',
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
export function hashCode (str) { // java String#hashCode
|
||||
var hash = 0
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
export function intToRGB (i) {
|
||||
var c = (i & 0x00FFFFFF).toString(16).toUpperCase()
|
||||
return '00000'.substring(0, 6 - c.length) + c
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<div class="ui vertical stripe segment" v-title="labels.title">
|
||||
<div class="ui small text container">
|
||||
<h2 class="ui header">
|
||||
<translate>Request some music</translate>
|
||||
</h2>
|
||||
<request-form v-if="$store.state.auth.authenticated"></request-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RequestForm from '@/components/requests/Form'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
RequestForm
|
||||
},
|
||||
computed: {
|
||||
labels () {
|
||||
let title = this.$gettext('Request some music')
|
||||
return {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -9,14 +9,15 @@
|
|||
<i class="circular inverted list yellow icon"></i>
|
||||
<div class="content">
|
||||
{{ playlist.name }}
|
||||
<translate
|
||||
tag="div"
|
||||
class="sub header"
|
||||
translate-plural="Playlist containing %{ count } tracks, by %{ username }"
|
||||
:translate-n="playlistTracks.length"
|
||||
:translate-params="{count: playlistTracks.length, username: playlist.user.username}">
|
||||
Playlist containing %{ count } track, by %{ username }
|
||||
</translate>
|
||||
<div class="sub header">
|
||||
<translate
|
||||
translate-plural="Playlist containing %{ count } tracks, by %{ username }"
|
||||
:translate-n="playlistTracks.length"
|
||||
:translate-params="{count: playlistTracks.length, username: playlist.user.username}">
|
||||
Playlist containing %{ count } track, by %{ username }
|
||||
</translate><br>
|
||||
<duration :seconds="playlist.duration" />
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="ui hidden divider"></div>
|
||||
|
|
|
@ -118,7 +118,7 @@ export default {
|
|||
ordering: this.getOrderingAsString()
|
||||
}
|
||||
})
|
||||
}, 500),
|
||||
}, 250),
|
||||
fetchData: _.debounce(function () {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
|
|
Ładowanie…
Reference in New Issue