mirror of
https://github.com/koel/koel
synced 2024-11-10 14:44:13 +00:00
feat(plus): manage collaborators
This commit is contained in:
parent
83328284d7
commit
36785b720f
49 changed files with 497 additions and 83 deletions
|
@ -8,7 +8,7 @@ use App\Models\User;
|
|||
use App\Services\PlaylistCollaborationService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class AcceptPlaylistCollaborationController extends Controller
|
||||
class AcceptPlaylistCollaborationInviteController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __invoke(PlaylistCollaborationService $service, Authenticatable $user)
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API\PlaylistCollaboration;
|
||||
|
||||
use App\Facades\License;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\API\PlaylistCollaboration\PlaylistCollaboratorDestroyRequest;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\User;
|
||||
use App\Repositories\UserRepository;
|
||||
use App\Services\PlaylistCollaborationService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class PlaylistCollaboratorController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __construct(
|
||||
private PlaylistCollaborationService $service,
|
||||
private UserRepository $userRepository,
|
||||
private ?Authenticatable $user
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Playlist $playlist)
|
||||
{
|
||||
$this->authorize('collaborate', $playlist);
|
||||
|
||||
return $this->service->getCollaborators($playlist);
|
||||
}
|
||||
|
||||
public function destroy(Playlist $playlist, PlaylistCollaboratorDestroyRequest $request)
|
||||
{
|
||||
$this->authorize('own', $playlist);
|
||||
|
||||
/** @var User $collaborator */
|
||||
$collaborator = $this->userRepository->getOne($request->collaborator);
|
||||
|
||||
abort_unless(License::isPlus(), Response::HTTP_FORBIDDEN, 'This feature is only available for Plus users.');
|
||||
|
||||
abort_if(
|
||||
$collaborator->is($this->user),
|
||||
Response::HTTP_FORBIDDEN,
|
||||
'You cannot remove yourself from your own playlist.'
|
||||
);
|
||||
|
||||
abort_unless(
|
||||
$playlist->hasCollaborator($collaborator),
|
||||
Response::HTTP_NOT_FOUND,
|
||||
'This user is not a collaborator of this playlist.'
|
||||
);
|
||||
|
||||
$this->service->removeCollaborator($playlist, $collaborator);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\API\PlaylistCollaboration;
|
||||
|
||||
use App\Http\Requests\API\Request;
|
||||
|
||||
/**
|
||||
* @property-read int $collaborator
|
||||
*/
|
||||
class PlaylistCollaboratorDestroyRequest extends Request
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'collaborator' => 'required|exists:users,id',
|
||||
];
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Values\PlaylistCollaborator;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class CollaborativeSongResource extends SongResource
|
||||
|
@ -11,10 +12,13 @@ class CollaborativeSongResource extends SongResource
|
|||
{
|
||||
return array_merge(parent::toArray($request), [
|
||||
'collaboration' => [
|
||||
'user' => [
|
||||
'avatar' => gravatar($this->song->collaborator_email),
|
||||
'name' => $this->song->collaborator_name,
|
||||
],
|
||||
'user' => PlaylistCollaboratorResource::make(
|
||||
PlaylistCollaborator::make(
|
||||
$this->song->collaborator_id,
|
||||
$this->song->collaborator_name,
|
||||
gravatar($this->song->collaborator_email),
|
||||
),
|
||||
),
|
||||
'added_at' => $this->song->added_at,
|
||||
'fmt_added_at' => $this->song->added_at ? Carbon::make($this->song->added_at)->diffForHumans() : null,
|
||||
],
|
||||
|
|
25
app/Http/Resources/PlaylistCollaboratorResource.php
Normal file
25
app/Http/Resources/PlaylistCollaboratorResource.php
Normal file
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Values\PlaylistCollaborator;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PlaylistCollaboratorResource extends JsonResource
|
||||
{
|
||||
public function __construct(private PlaylistCollaborator $collaborator)
|
||||
{
|
||||
parent::__construct($collaborator);
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'type' => 'playlist_collaborators',
|
||||
'id' => $this->collaborator->id,
|
||||
'name' => $this->collaborator->name,
|
||||
'avatar' => $this->collaborator->avatar,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -41,6 +41,7 @@ use Laravel\Scout\Searchable;
|
|||
* // The following are only available for collaborative playlists
|
||||
* @property-read ?string $collaborator_email The email of the user who added the song to the playlist
|
||||
* @property-read ?string $collaborator_name The name of the user who added the song to the playlist
|
||||
* @property-read ?int $collaborator_id The ID of the user who added the song to the playlist
|
||||
* @property-read ?string $added_at The date the song was added to the playlist
|
||||
*/
|
||||
class Song extends Model
|
||||
|
|
|
@ -187,6 +187,7 @@ class SongRepository extends Repository
|
|||
return
|
||||
$query->join('users as collaborators', 'playlist_song.user_id', '=', 'collaborators.id')
|
||||
->addSelect(
|
||||
'collaborators.id as collaborator_id',
|
||||
'collaborators.name as collaborator_name',
|
||||
'collaborators.email as collaborator_email',
|
||||
'playlist_song.created_at as added_at'
|
||||
|
|
|
@ -6,13 +6,16 @@ use App\Facades\License;
|
|||
use App\Models\Playlist;
|
||||
use App\Models\PlaylistCollaborationToken;
|
||||
use App\Models\User;
|
||||
use App\Values\PlaylistCollaborator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
class PlaylistCollaborationService
|
||||
{
|
||||
public function createToken(Playlist $playlist): PlaylistCollaborationToken
|
||||
{
|
||||
Assert::true(License::isPlus(), 'Playlist collaboration is only available with Koel Plus.');
|
||||
self::assertKoelPlus();
|
||||
Assert::false($playlist->is_smart, 'Smart playlists are not collaborative.');
|
||||
|
||||
return $playlist->collaborationTokens()->create();
|
||||
|
@ -20,7 +23,7 @@ class PlaylistCollaborationService
|
|||
|
||||
public function acceptUsingToken(string $token, User $user): Playlist
|
||||
{
|
||||
Assert::true(License::isPlus(), 'Playlist collaboration is only available with Koel Plus.');
|
||||
self::assertKoelPlus();
|
||||
|
||||
/** @var PlaylistCollaborationToken $collaborationToken */
|
||||
$collaborationToken = PlaylistCollaborationToken::query()->where('token', $token)->firstOrFail();
|
||||
|
@ -35,4 +38,31 @@ class PlaylistCollaborationService
|
|||
|
||||
return $collaborationToken->playlist;
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, PlaylistCollaborator> */
|
||||
public function getCollaborators(Playlist $playlist): Collection
|
||||
{
|
||||
self::assertKoelPlus();
|
||||
|
||||
return $playlist->collaborators->unless(
|
||||
$playlist->collaborators->contains($playlist->user), // The owner is always a collaborator
|
||||
static fn (Collection $collaborators) => $collaborators->push($playlist->user)
|
||||
)
|
||||
->map(static fn (User $user) => PlaylistCollaborator::fromUser($user));
|
||||
}
|
||||
|
||||
public function removeCollaborator(Playlist $playlist, User $user): void
|
||||
{
|
||||
self::assertKoelPlus();
|
||||
|
||||
DB::transaction(static function () use ($playlist, $user): void {
|
||||
$playlist->collaborators()->detach($user);
|
||||
$playlist->songs()->wherePivot('user_id', $user->id)->detach();
|
||||
});
|
||||
}
|
||||
|
||||
private static function assertKoelPlus(): void
|
||||
{
|
||||
Assert::true(License::isPlus(), 'Playlist collaboration is only available with Koel Plus.');
|
||||
}
|
||||
}
|
||||
|
|
34
app/Values/PlaylistCollaborator.php
Normal file
34
app/Values/PlaylistCollaborator.php
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Values;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
|
||||
final class PlaylistCollaborator implements Arrayable
|
||||
{
|
||||
private function __construct(public int $id, public string $name, public string $avatar)
|
||||
{
|
||||
}
|
||||
|
||||
public static function make(int $id, string $name, string $avatar): self
|
||||
{
|
||||
return new self($id, $name, $avatar);
|
||||
}
|
||||
|
||||
public static function fromUser(User $user): self
|
||||
{
|
||||
return new self($user->id, $user->name, gravatar($user->email));
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'playlist_collaborators',
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'avatar' => $this->avatar,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -47,8 +47,8 @@ const viewAlbumDetails = () => trigger(() => go(`album/${album.value!.id}`))
|
|||
const viewArtistDetails = () => trigger(() => go(`artist/${album.value!.artist_id}`))
|
||||
const download = () => trigger(() => downloadService.fromAlbum(album.value!))
|
||||
|
||||
eventBus.on('ALBUM_CONTEXT_MENU_REQUESTED', async (e, _album) => {
|
||||
eventBus.on('ALBUM_CONTEXT_MENU_REQUESTED', async ({ pageX, pageY }, _album) => {
|
||||
album.value = _album
|
||||
await open(e.pageY, e.pageX)
|
||||
await open(pageY, pageX)
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -46,8 +46,8 @@ const shuffle = () => trigger(async () => {
|
|||
const viewArtistDetails = () => trigger(() => go(`artist/${artist.value!.id}`))
|
||||
const download = () => trigger(() => downloadService.fromArtist(artist.value!))
|
||||
|
||||
eventBus.on('ARTIST_CONTEXT_MENU_REQUESTED', async (e, _artist) => {
|
||||
eventBus.on('ARTIST_CONTEXT_MENU_REQUESTED', async ({ pageX, pageY }, _artist) => {
|
||||
artist.value = _artist
|
||||
await open(e.pageY, e.pageX)
|
||||
await open(pageY, pageX)
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -20,6 +20,7 @@ const modalNameToComponentMap = {
|
|||
'edit-song-form': defineAsyncComponent(() => import('@/components/song/EditSongForm.vue')),
|
||||
'create-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/CreatePlaylistFolderForm.vue')),
|
||||
'edit-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/EditPlaylistFolderForm.vue')),
|
||||
'playlist-collaboration': defineAsyncComponent(() => import('@/components/playlist/CollaborationModal.vue')),
|
||||
'about-koel': defineAsyncComponent(() => import('@/components/meta/AboutKoelModal.vue')),
|
||||
'koel-plus': defineAsyncComponent(() => import('@/components/koel-plus/KoelPlusModal.vue')),
|
||||
'equalizer': defineAsyncComponent(() => import('@/components/ui/Equalizer.vue'))
|
||||
|
@ -77,6 +78,10 @@ eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'
|
|||
context.value = { folder }
|
||||
activeModalName.value = 'edit-playlist-folder-form'
|
||||
})
|
||||
.on('MODAL_SHOW_PLAYLIST_COLLABORATION', playlist => {
|
||||
context.value = { playlist }
|
||||
activeModalName.value = 'playlist-collaboration'
|
||||
})
|
||||
.on('MODAL_SHOW_EQUALIZER', () => (activeModalName.value = 'equalizer'))
|
||||
</script>
|
||||
|
||||
|
@ -97,7 +102,7 @@ dialog {
|
|||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
:deep(form) {
|
||||
:deep(form), :deep(>div) {
|
||||
position: relative;
|
||||
|
||||
> header, > main, > footer {
|
||||
|
@ -106,6 +111,8 @@ dialog {
|
|||
|
||||
> footer {
|
||||
margin-top: 0;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-top: 1px solid rgba(255, 255, 255, .05);
|
||||
}
|
||||
|
||||
[type=text], [type=number], [type=email], [type=password], [type=url], [type=date], textarea, select {
|
||||
|
|
|
@ -35,7 +35,7 @@ const folders = toRef(playlistFolderStore.state, 'folders')
|
|||
const playlists = toRef(playlistStore.state, 'playlists')
|
||||
const favorites = toRef(favoriteStore.state, 'songs')
|
||||
|
||||
const orphanPlaylists = computed(() => playlists.value.filter(playlist => playlist.folder_id === null))
|
||||
const orphanPlaylists = computed(() => playlists.value.filter(({ folder_id }) => folder_id === null))
|
||||
|
||||
const requestContextMenu = () => {
|
||||
const clientRect = createBtnEl.value!.getBoundingClientRect()
|
||||
|
|
204
resources/assets/js/components/playlist/CollaborationModal.vue
Normal file
204
resources/assets/js/components/playlist/CollaborationModal.vue
Normal file
|
@ -0,0 +1,204 @@
|
|||
<template>
|
||||
<div class="collaboration-modal" tabindex="0" @keydown.esc="close">
|
||||
<header>
|
||||
<h1>Playlist Collaboration</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<p class="intro text-secondary">
|
||||
Collaborative playlists allow multiple users to contribute. Please note that songs added to a collaborative
|
||||
playlist are made accessible to all users, and you cannot make a song private as long as it’s still a part of a
|
||||
collaborative playlist.
|
||||
</p>
|
||||
|
||||
<section class="collaborators">
|
||||
<h2>
|
||||
<span>Current Collaborators</span>
|
||||
<span v-if="canManageCollaborators">
|
||||
<Btn v-if="shouldShowInviteButton" green small @click.prevent="inviteCollaborators">Invite</Btn>
|
||||
<span v-if="justCreatedInviteLink" class="text-secondary copied">
|
||||
<Icon :icon="faCheckCircle" class="text-green" />
|
||||
Invite link copied to clipboard!
|
||||
</span>
|
||||
<Icon v-if="creatingInviteLink" :icon="faCircleNotch" class="text-green" spin />
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<ul v-koel-overflow-fade>
|
||||
<li v-for="user in collaborators" :key="user.id">
|
||||
<span class="avatar">
|
||||
<UserAvatar :user="user" width="32" />
|
||||
</span>
|
||||
<span class="name">
|
||||
{{ user.name }}
|
||||
<Icon
|
||||
v-if="user.id === currentUser.id"
|
||||
:icon="faCircleCheck"
|
||||
class="you text-highlight"
|
||||
title="This is you!"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="user.id === playlist.user_id" class="role text-secondary">Owner</span>
|
||||
<span v-else class="role text-secondary">Contributor</span>
|
||||
<span v-if="canManageCollaborators" class="actions">
|
||||
<Btn v-if="user.id !== playlist.user_id" small red @click.prevent="removeCollaborator(user)">
|
||||
Remove
|
||||
</Btn>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Btn @click.prevent="close">Close</Btn>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { faCheckCircle, faCircleCheck, faCircleNotch } from '@fortawesome/free-solid-svg-icons'
|
||||
import { sortBy } from 'lodash'
|
||||
import { computed, onMounted, ref, Ref } from 'vue'
|
||||
import { copyText, eventBus, logger } from '@/utils'
|
||||
import { playlistCollaborationService } from '@/services'
|
||||
import { useAuthorization, useModal, useDialogBox } from '@/composables'
|
||||
|
||||
import UserAvatar from '@/components/user/UserAvatar.vue'
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
|
||||
const playlist = useModal().getFromContext<Playlist>('playlist')
|
||||
const { currentUser } = useAuthorization()
|
||||
const { showConfirmDialog } = useDialogBox()
|
||||
|
||||
let collaborators: Ref<PlaylistCollaborator[]> = ref([])
|
||||
const creatingInviteLink = ref(false)
|
||||
const justCreatedInviteLink = ref(false)
|
||||
const shouldShowInviteButton = computed(() => !creatingInviteLink.value && !justCreatedInviteLink.value)
|
||||
|
||||
const canManageCollaborators = computed(() => currentUser.value?.id === playlist.user_id)
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const close = () => emit('close')
|
||||
|
||||
const fetchCollaborators = async () => {
|
||||
collaborators.value = sortBy(
|
||||
await playlistCollaborationService.getCollaborators(playlist),
|
||||
({ id }) => {
|
||||
if (id === currentUser.value.id) return 0
|
||||
if (id === playlist.user_id) return 1
|
||||
return 2
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const inviteCollaborators = async () => {
|
||||
creatingInviteLink.value = true
|
||||
|
||||
try {
|
||||
await copyText(await playlistCollaborationService.createInviteLink(playlist))
|
||||
justCreatedInviteLink.value = true
|
||||
setTimeout(() => (justCreatedInviteLink.value = false), 5_000)
|
||||
} finally {
|
||||
creatingInviteLink.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeCollaborator = async (collaborator: PlaylistCollaborator) => {
|
||||
const deadSure = await showConfirmDialog(
|
||||
`Remove ${collaborator.name} as a collaborator? This will remove their contributed songs as well.`
|
||||
)
|
||||
|
||||
if (!deadSure) return
|
||||
|
||||
try {
|
||||
collaborators.value = collaborators.value.filter(({ id }) => id !== collaborator.id)
|
||||
await playlistCollaborationService.removeCollaborator(playlist, collaborator)
|
||||
eventBus.emit('PLAYLIST_COLLABORATOR_REMOVED', playlist)
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => await fetchCollaborators())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.collaboration-modal {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
|
||||
span:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.copied {
|
||||
font-size: .95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.collaborators {
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
margin: 1rem 0;
|
||||
gap: .5rem;
|
||||
max-height: calc(100vh - 8rem);
|
||||
overflow-y: auto;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
padding: .5rem .8rem;
|
||||
border-radius: 5px;
|
||||
transition: border-color .2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, .15);
|
||||
}
|
||||
|
||||
.you {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.role {
|
||||
text-align: right;
|
||||
flex: 0 0 96px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex: 0 0 72px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<ul>
|
||||
<li v-for="user in displayedCollaborators">
|
||||
<li v-for="user in displayedCollaborators" :key="user.id">
|
||||
<UserAvatar :user="user" width="24" />
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
<li @click="play">Play</li>
|
||||
<li @click="shuffle">Shuffle</li>
|
||||
<li @click="addToQueue">Add to Queue</li>
|
||||
<template v-if="canInviteCollaborators">
|
||||
<template v-if="canShowCollaboration">
|
||||
<li class="separator"></li>
|
||||
<li @click="inviteCollaborators">Invite Collaborators</li>
|
||||
<li @click="showCollaborationModal">Collaborate…</li>
|
||||
<li class="separator"></li>
|
||||
</template>
|
||||
<li v-if="ownedByCurrentUser" @click="edit">Edit…</li>
|
||||
|
@ -29,7 +29,7 @@ const { currentUser } = useAuthorization()
|
|||
const playlist = ref<Playlist>()
|
||||
|
||||
const ownedByCurrentUser = computed(() => playlist.value?.user_id === currentUser.value?.id)
|
||||
const canInviteCollaborators = computed(() => ownedByCurrentUser.value && isPlus.value && !playlist.value?.is_smart)
|
||||
const canShowCollaboration = computed(() => isPlus.value && !playlist.value?.is_smart)
|
||||
|
||||
const edit = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist.value!))
|
||||
const destroy = () => trigger(() => eventBus.emit('PLAYLIST_DELETE', playlist.value!))
|
||||
|
@ -67,14 +67,10 @@ const addToQueue = () => trigger(async () => {
|
|||
}
|
||||
})
|
||||
|
||||
const inviteCollaborators = () => trigger(async () => {
|
||||
const link = await playlistCollaborationService.createInviteLink(playlist.value!)
|
||||
await copyText(link)
|
||||
toastSuccess('Link copied to clipboard. Share it with your friends!')
|
||||
})
|
||||
const showCollaborationModal = () => trigger(() => eventBus.emit('MODAL_SHOW_PLAYLIST_COLLABORATION', playlist.value!))
|
||||
|
||||
eventBus.on('PLAYLIST_CONTEXT_MENU_REQUESTED', async (event, _playlist) => {
|
||||
eventBus.on('PLAYLIST_CONTEXT_MENU_REQUESTED', async ({ pageX, pageY }, _playlist) => {
|
||||
playlist.value = _playlist
|
||||
await open(event.pageY, event.pageX)
|
||||
await open(pageY, pageX)
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -58,8 +58,8 @@ const createSmartPlaylist = () => trigger(() => eventBus.emit('MODAL_SHOW_CREATE
|
|||
const rename = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM', folder.value!))
|
||||
const destroy = () => trigger(() => eventBus.emit('PLAYLIST_FOLDER_DELETE', folder.value!))
|
||||
|
||||
eventBus.on('PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED', async (e, _folder) => {
|
||||
eventBus.on('PLAYLIST_FOLDER_CONTEXT_MENU_REQUESTED', async ({ pageX, pageY }, _folder) => {
|
||||
folder.value = _folder
|
||||
await open(e.pageY, e.pageX)
|
||||
await open(pageY, pageX)
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -44,7 +44,7 @@ const mutatedRule = Object.assign({}, rule.value) as SmartPlaylistRule
|
|||
const selectedModel = ref<SmartPlaylistModel>()
|
||||
const selectedOperator = ref<SmartPlaylistOperator>()
|
||||
|
||||
const model = models.find(m => m.name === mutatedRule.model.name)
|
||||
const model = models.find(({ name }) => name === mutatedRule.model.name)
|
||||
|
||||
if (!model) {
|
||||
throw new Error(`Invalid smart playlist model: ${mutatedRule.model.name}`)
|
||||
|
@ -56,7 +56,7 @@ const availableOperators = computed<SmartPlaylistOperator[]>(() => {
|
|||
return selectedModel.value ? inputTypes[selectedModel.value.type] : []
|
||||
})
|
||||
|
||||
const operator = availableOperators.value.find(o => o.operator === mutatedRule.operator)
|
||||
const operator = availableOperators.value.find(({ operator }) => operator === mutatedRule.operator)
|
||||
|
||||
if (!operator) {
|
||||
throw new Error(`Invalid smart playlist operator: ${mutatedRule.operator}`)
|
||||
|
@ -88,7 +88,7 @@ const availableInputs = computed<{ id: string, value: any }[]>(() => {
|
|||
|
||||
watch(availableOperators, () => {
|
||||
if (selectedModel.value?.name === mutatedRule.model.name) {
|
||||
selectedOperator.value = availableOperators.value.find(o => o.operator === mutatedRule.operator)!
|
||||
selectedOperator.value = availableOperators.value.find(({ operator }) => operator === mutatedRule.operator)!
|
||||
} else {
|
||||
selectedOperator.value = availableOperators.value[0]
|
||||
}
|
||||
|
|
|
@ -44,12 +44,12 @@ const notifyParentForUpdate = () => emit('input', mutatedGroup)
|
|||
const addRule = () => mutatedGroup.rules.push(playlistStore.createEmptySmartPlaylistRule())
|
||||
|
||||
const onRuleChanged = (data: SmartPlaylistRule) => {
|
||||
Object.assign(mutatedGroup.rules.find(r => r.id === data.id)!, data)
|
||||
Object.assign(mutatedGroup.rules.find(({ id }) => id === data.id)!, data)
|
||||
notifyParentForUpdate()
|
||||
}
|
||||
|
||||
const removeRule = (rule: SmartPlaylistRule) => {
|
||||
mutatedGroup.rules = mutatedGroup.rules.filter(r => r.id !== rule.id)
|
||||
mutatedGroup.rules = mutatedGroup.rules.filter(({ id }) => id !== rule.id)
|
||||
notifyParentForUpdate()
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -149,7 +149,7 @@ const download = () => downloadService.fromAlbum(album.value!)
|
|||
watch(activeTab, async tab => {
|
||||
if (tab === 'OtherAlbums' && !otherAlbums.value) {
|
||||
const albums = await albumStore.fetchForArtist(album.value!.artist_id)
|
||||
otherAlbums.value = albums.filter(a => a.id !== album.value!.id)
|
||||
otherAlbums.value = albums.filter(({ id }) => id !== album.value!.id)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -104,7 +104,7 @@ const {
|
|||
onScrollBreakpoint,
|
||||
sort,
|
||||
config: listConfig
|
||||
} = useSongList(ref<Song[]>([]))
|
||||
} = useSongList(ref<Song[] | CollaborativeSong[]>([]))
|
||||
|
||||
const { SongListControls, config: controlsConfig } = useSongListControls('Playlist')
|
||||
const { removeSongsFromPlaylist } = usePlaylistManagement()
|
||||
|
@ -145,9 +145,11 @@ watch(playlistId, async id => {
|
|||
|
||||
onScreenActivated('Playlist', () => (playlistId.value = getRouteParam('id')!))
|
||||
|
||||
eventBus.on('PLAYLIST_UPDATED', async updated => updated.id === playlistId.value && await fetchSongs())
|
||||
.on('PLAYLIST_SONGS_REMOVED', async (playlist, removed) => {
|
||||
if (playlist.id !== playlistId.value) return
|
||||
eventBus
|
||||
.on('PLAYLIST_UPDATED', async ({ id }) => id === playlistId.value && await fetchSongs())
|
||||
.on('PLAYLIST_COLLABORATOR_REMOVED', async ({ id }) => id === playlistId.value && await fetchSongs())
|
||||
.on('PLAYLIST_SONGS_REMOVED', async ({ id }, removed) => {
|
||||
if (id !== playlistId.value) return
|
||||
songs.value = differenceBy(songs.value, removed, 'id')
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -115,7 +115,7 @@ const removeSelected = () => {
|
|||
const currentSongId = queueStore.current?.id
|
||||
queueStore.unqueue(selectedSongs.value)
|
||||
|
||||
if (currentSongId && selectedSongs.value.find(song => song.id === currentSongId)) {
|
||||
if (currentSongId && selectedSongs.value.find(({ id }) => id === currentSongId)) {
|
||||
playbackService.playNext()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -80,7 +80,7 @@ const { allowsUpload, mediaPathSetUp, queueFilesForUpload, handleDropEvent } = u
|
|||
const files = toRef(uploadService.state, 'files')
|
||||
const droppable = ref(false)
|
||||
|
||||
const hasUploadFailures = computed(() => files.value.filter((file) => file.status === 'Errored').length > 0)
|
||||
const hasUploadFailures = computed(() => files.value.filter(({ status }) => status === 'Errored').length > 0)
|
||||
|
||||
const onDragEnter = () => (droppable.value = allowsUpload.value)
|
||||
const onDragLeave = () => (droppable.value = false)
|
||||
|
|
|
@ -54,8 +54,8 @@ const Btn = defineAsyncComponent(() => import('@/components/ui/Btn.vue'))
|
|||
const BtnGroup = defineAsyncComponent(() => import('@/components/ui/BtnGroup.vue'))
|
||||
|
||||
const allUsers = toRef(userStore.state, 'users')
|
||||
const users = computed(() => allUsers.value.filter(user => !user.is_prospect))
|
||||
const prospects = computed(() => allUsers.value.filter(user => user.is_prospect))
|
||||
const users = computed(() => allUsers.value.filter(({ is_prospect }) => !is_prospect))
|
||||
const prospects = computed(() => allUsers.value.filter(({ is_prospect }) => is_prospect))
|
||||
|
||||
const isPhone = isMobile.phone
|
||||
const showingControls = ref(false)
|
||||
|
|
|
@ -65,7 +65,7 @@ const queue = toRef(queueStore.state, 'songs')
|
|||
const currentSong = queueStore.current
|
||||
|
||||
const allPlaylists = toRef(playlistStore.state, 'playlists')
|
||||
const playlists = computed(() => allPlaylists.value.filter(playlist => !playlist.is_smart))
|
||||
const playlists = computed(() => allPlaylists.value.filter(({ is_smart }) => !is_smart))
|
||||
|
||||
const emit = defineEmits<{ (e: 'closing'): void }>()
|
||||
const close = () => emit('closing')
|
||||
|
|
|
@ -64,6 +64,7 @@ article {
|
|||
border: 1px solid var(--color-bg-secondary);
|
||||
border-radius: 5px;
|
||||
align-items: center;
|
||||
transition: border-color .2s ease-in-out;
|
||||
|
||||
&:focus, &:focus-within {
|
||||
box-shadow: 0 0 1px 1px var(--color-accent);
|
||||
|
@ -79,6 +80,8 @@ article {
|
|||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, .15);
|
||||
|
||||
button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
|
@ -105,13 +105,13 @@ const queue = toRef(queueStore.state, 'songs')
|
|||
const currentSong = toRef(queueStore, 'current')
|
||||
|
||||
const canModify = computed(() => {
|
||||
if (isPlus.value) return songs.value.every(song => song.owner_id === currentUser.value?.id)
|
||||
if (isPlus.value) return songs.value.every(({ owner_id }) => owner_id === currentUser.value?.id)
|
||||
return isAdmin.value
|
||||
})
|
||||
|
||||
const onlyOneSongSelected = computed(() => songs.value.length === 1)
|
||||
const firstSongPlaying = computed(() => songs.value.length ? songs.value[0].playback_state === 'Playing' : false)
|
||||
const normalPlaylists = computed(() => playlists.value.filter(playlist => !playlist.is_smart))
|
||||
const normalPlaylists = computed(() => playlists.value.filter(({ is_smart }) => !is_smart))
|
||||
|
||||
const makePublic = () => trigger(async () => {
|
||||
await songStore.publicize(songs.value)
|
||||
|
@ -129,7 +129,7 @@ const visibilityActions = computed(() => {
|
|||
if (!isPlus.value) return []
|
||||
|
||||
// If some songs don't belong to the current user, no actions are available.
|
||||
if (songs.value.some(song => song.owner_id !== currentUser.value?.id)) return []
|
||||
if (songs.value.some(({ owner_id }) => owner_id !== currentUser.value?.id)) return []
|
||||
|
||||
const visibilities = Array.from(new Set(songs.value.map(song => song.is_public)))
|
||||
|
||||
|
@ -207,9 +207,9 @@ const deleteFromFilesystem = () => trigger(async () => {
|
|||
}
|
||||
})
|
||||
|
||||
eventBus.on('SONG_CONTEXT_MENU_REQUESTED', async (e, _songs) => {
|
||||
eventBus.on('SONG_CONTEXT_MENU_REQUESTED', async ({ pageX, pageY }, _songs) => {
|
||||
songs.value = arrayify(_songs)
|
||||
await open(e.pageY, e.pageX)
|
||||
await open(pageY, pageX)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -139,7 +139,11 @@ const lastSelectedRow = ref<SongRow>()
|
|||
const sortFields = ref<SongListSortField[]>([])
|
||||
const songRows = ref<SongRow[]>([])
|
||||
|
||||
watch(songRows, () => setSelectedSongs(songRows.value.filter(row => row.selected).map(row => row.song)), { deep: true })
|
||||
watch(
|
||||
songRows,
|
||||
() => setSelectedSongs(songRows.value.filter(({ selected }) => selected).map(({ song }) => song)),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const filteredSongRows = computed<SongRow[]>(() => {
|
||||
const keywords = filterKeywords.value.trim().toLowerCase()
|
||||
|
@ -148,9 +152,7 @@ const filteredSongRows = computed<SongRow[]>(() => {
|
|||
return songRows.value
|
||||
}
|
||||
|
||||
return songRows.value.filter(row => {
|
||||
const song = row.song
|
||||
|
||||
return songRows.value.filter(({ song }) => {
|
||||
return (
|
||||
song.title.toLowerCase().includes(keywords) ||
|
||||
song.artist_name.toLowerCase().includes(keywords) ||
|
||||
|
|
|
@ -50,6 +50,11 @@ const onContextMenu = (e: MouseEvent) => emit('contextmenu', e)
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
transition: border-color .2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, .15);
|
||||
}
|
||||
|
||||
.name {
|
||||
display: flex;
|
||||
|
|
|
@ -22,9 +22,7 @@ const addMessage = (type: 'info' | 'success' | 'warning' | 'danger', content: st
|
|||
})
|
||||
}
|
||||
|
||||
const removeMessage = (message: ToastMessage) => {
|
||||
messages.value = messages.value.filter(m => m.id !== message.id)
|
||||
}
|
||||
const removeMessage = (message: ToastMessage) => (messages.value = messages.value.filter(({ id }) => id !== message.id))
|
||||
|
||||
const info = (content: string, timeout?: number) => addMessage('info', content, timeout)
|
||||
const success = (content: string, timeout?: number) => addMessage('success', content, timeout)
|
||||
|
|
|
@ -91,6 +91,11 @@ const revokeInvite = async () => {
|
|||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-bg-secondary);
|
||||
gap: 1rem;
|
||||
transition: border-color .2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, .15);
|
||||
}
|
||||
|
||||
.anonymous {
|
||||
font-weight: var(--font-weight-light);
|
||||
|
|
|
@ -12,11 +12,11 @@ export const useSmartPlaylistForm = (initialRuleGroups: SmartPlaylistRuleGroup[]
|
|||
const addGroup = () => collectedRuleGroups.value.push(playlistStore.createEmptySmartPlaylistRuleGroup())
|
||||
|
||||
const onGroupChanged = (data: SmartPlaylistRuleGroup) => {
|
||||
const changedGroup = Object.assign(collectedRuleGroups.value.find(g => g.id === data.id)!, data)
|
||||
const changedGroup = Object.assign(collectedRuleGroups.value.find(({ id }) => id === data.id)!, data)
|
||||
|
||||
// Remove empty group
|
||||
if (changedGroup.rules.length === 0) {
|
||||
collectedRuleGroups.value = collectedRuleGroups.value.filter(group => group.id !== changedGroup.id)
|
||||
collectedRuleGroups.value = collectedRuleGroups.value.filter(({ id }) => id !== changedGroup.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -41,8 +41,8 @@ export const useSongList = (
|
|||
const duration = computed(() => songStore.getFormattedLength(songs.value))
|
||||
|
||||
const thumbnails = computed(() => {
|
||||
const songsWithCover = songs.value.filter(song => song.album_cover)
|
||||
const sampleCovers = sampleSize(songsWithCover, 20).map(song => song.album_cover)
|
||||
const songsWithCover = songs.value.filter(({ album_cover }) => album_cover)
|
||||
const sampleCovers = sampleSize(songsWithCover, 20).map(({ album_cover }) => album_cover)
|
||||
return take(Array.from(new Set(sampleCovers)), 4)
|
||||
})
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ export const useUpload = () => {
|
|||
|
||||
const queueFilesForUpload = (files: Array<File>) => {
|
||||
const uploadCandidates = files
|
||||
.filter(file => acceptedMediaTypes.includes(file.type))
|
||||
.filter(({ type }) => acceptedMediaTypes.includes(type))
|
||||
.map((file): UploadFile => ({
|
||||
file,
|
||||
id: `${file.name}-${file.size}`, // for simplicity, a file's identity is determined by its name and size
|
||||
|
|
|
@ -26,6 +26,7 @@ export interface Events {
|
|||
MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM: (folder?: PlaylistFolder | null) => void
|
||||
MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM: () => void
|
||||
MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM: (playlistFolder: PlaylistFolder) => void
|
||||
MODAL_SHOW_PLAYLIST_COLLABORATION: (playlist: Playlist) => void
|
||||
MODAL_SHOW_ABOUT_KOEL: () => void
|
||||
MODAL_SHOW_KOEL_PLUS: () => void
|
||||
MODAL_SHOW_EQUALIZER: () => void
|
||||
|
@ -34,6 +35,7 @@ export interface Events {
|
|||
PLAYLIST_FOLDER_DELETE: (playlistFolder: PlaylistFolder) => void
|
||||
PLAYLIST_SONGS_REMOVED: (playlist: Playlist, songs: Song[]) => void
|
||||
PLAYLIST_UPDATED: (playlist: Playlist) => void
|
||||
PLAYLIST_COLLABORATOR_REMOVED: (playlist: Playlist) => void
|
||||
|
||||
SONGS_UPDATED: () => void
|
||||
SONGS_DELETED: (songs: Song[]) => void
|
||||
|
|
|
@ -27,8 +27,8 @@ export default class Router {
|
|||
|
||||
constructor (routes: Route[]) {
|
||||
this.routes = routes
|
||||
this.homeRoute = routes.find(route => route.screen === 'Home')!
|
||||
this.notFoundRoute = routes.find(route => route.screen === '404')!
|
||||
this.homeRoute = routes.find(({ screen }) => screen === 'Home')!
|
||||
this.notFoundRoute = routes.find(({ screen }) => screen === '404')!
|
||||
this.$currentRoute = ref(this.homeRoute)
|
||||
|
||||
watch(
|
||||
|
|
|
@ -74,7 +74,7 @@ export const audioService = {
|
|||
},
|
||||
|
||||
changeFilterGain (node: BiquadFilterNode, db: number) {
|
||||
this.bands.find(band => band.filter === node)!.db = db
|
||||
this.bands.find(({ filter }) => filter === node)!.db = db
|
||||
node.gain.value = dbToGain(db)
|
||||
},
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { http } from '@/services'
|
||||
import { cache, http } from '@/services'
|
||||
|
||||
export const playlistCollaborationService = {
|
||||
async createInviteLink (playlist: Playlist) {
|
||||
|
@ -12,5 +12,15 @@ export const playlistCollaborationService = {
|
|||
|
||||
async acceptInvite (token: string) {
|
||||
return http.post<Playlist>(`playlists/collaborators/accept`, { token })
|
||||
},
|
||||
|
||||
async getCollaborators (playlist: Playlist) {
|
||||
return http.get<PlaylistCollaborator[]>(`playlists/${playlist.id}/collaborators`)
|
||||
},
|
||||
|
||||
async removeCollaborator (playlist: Playlist, collaborator: PlaylistCollaborator) {
|
||||
await http.delete(`playlists/${playlist.id}/collaborators`, { collaborator: collaborator.id })
|
||||
// invalidate the playlist cache
|
||||
cache.remove(['playlist.songs', playlist.id])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,11 +56,11 @@ export const uploadService = {
|
|||
},
|
||||
|
||||
getUploadingFiles () {
|
||||
return this.state.files.filter(file => file.status === 'Uploading')
|
||||
return this.state.files.filter(({ status }) => status === 'Uploading')
|
||||
},
|
||||
|
||||
getUploadCandidate () {
|
||||
return this.state.files.find(file => file.status === 'Ready')
|
||||
return this.state.files.find(({ status }) => status === 'Ready')
|
||||
},
|
||||
|
||||
shouldWarnUponWindowUnload () {
|
||||
|
@ -117,6 +117,6 @@ export const uploadService = {
|
|||
},
|
||||
|
||||
removeFailed () {
|
||||
this.state.files = this.state.files.filter(file => file.status !== 'Errored')
|
||||
this.state.files = this.state.files.filter(({ status }) => status !== 'Errored')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { preferenceStore as preferences } from '@/stores'
|
|||
import { equalizerPresets as presets } from '@/config'
|
||||
|
||||
export const equalizerStore = {
|
||||
getPresetByName (name: EqualizerPreset['name']) {
|
||||
getPresetByName (name: string) {
|
||||
return presets.find(preset => preset.name === name)
|
||||
},
|
||||
|
||||
|
|
|
@ -45,7 +45,7 @@ export const overviewStore = {
|
|||
// All album/artist stats are simply ignored.
|
||||
this.state.mostPlayedSongs = songStore.getMostPlayed(7)
|
||||
this.state.recentlyPlayed = recentlyPlayedStore.excerptState.songs.filter(
|
||||
song => !song.deleted && song.play_count > 0
|
||||
({ deleted, play_count }) => !deleted && play_count > 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,8 +4,8 @@ import { differenceBy, orderBy } from 'lodash'
|
|||
import { playlistStore } from '@/stores/playlistStore'
|
||||
|
||||
export const playlistFolderStore = {
|
||||
state: reactive({
|
||||
folders: [] as PlaylistFolder[]
|
||||
state: reactive<{ folders: PlaylistFolder [] }>({
|
||||
folders: []
|
||||
}),
|
||||
|
||||
init (folders: PlaylistFolder[]) {
|
||||
|
@ -33,7 +33,7 @@ export const playlistFolderStore = {
|
|||
|
||||
async rename (folder: PlaylistFolder, name: string) {
|
||||
await http.put(`playlist-folders/${folder.id}`, { name })
|
||||
this.byId(folder.id).name = name
|
||||
this.byId(folder.id)!.name = name
|
||||
},
|
||||
|
||||
async addPlaylistToFolder (folder: PlaylistFolder, playlist: Playlist) {
|
||||
|
|
|
@ -57,7 +57,7 @@ export const playlistStore = {
|
|||
},
|
||||
|
||||
byFolder (folder: PlaylistFolder) {
|
||||
return this.state.playlists.filter(playlist => playlist.folder_id === folder.id)
|
||||
return this.state.playlists.filter(({ folder_id }) => folder_id === folder.id)
|
||||
},
|
||||
|
||||
async store (
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { reactive } from 'vue'
|
||||
import { differenceBy, unionBy } from 'lodash'
|
||||
import { arrayify } from '@/utils'
|
||||
import { arrayify, logger } from '@/utils'
|
||||
import { http } from '@/services'
|
||||
import { songStore } from '@/stores'
|
||||
|
||||
|
@ -140,7 +140,7 @@ export const queueStore = {
|
|||
},
|
||||
|
||||
get current () {
|
||||
return this.all.find(song => song.playback_state !== 'Stopped')
|
||||
return this.all.find(({ playback_state }) => playback_state !== 'Stopped')
|
||||
},
|
||||
|
||||
async fetchRandom (limit = 500) {
|
||||
|
@ -155,9 +155,9 @@ export const queueStore = {
|
|||
|
||||
saveState () {
|
||||
try {
|
||||
http.silently.put('queue/state', { songs: this.state.songs.map(song => song.id) })
|
||||
http.silently.put('queue/state', { songs: this.state.songs.map(({ id }) => id) })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
logger.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,7 +63,7 @@ export const songStore = {
|
|||
},
|
||||
|
||||
byAlbum (album: Album) {
|
||||
return Array.from(this.vault.values()).filter(song => song.album_id === album.id)
|
||||
return Array.from(this.vault.values()).filter(({ album_id }) => album_id === album.id)
|
||||
},
|
||||
|
||||
async resolve (id: string) {
|
||||
|
@ -154,7 +154,7 @@ export const songStore = {
|
|||
watch(() => song.play_count, () => overviewStore.refresh())
|
||||
},
|
||||
|
||||
ensureNotDeleted: (songs: Song | Song[]) => arrayify(songs).filter(song => !song.deleted),
|
||||
ensureNotDeleted: (songs: Song | Song[]) => arrayify(songs).filter(({ deleted }) => !deleted),
|
||||
|
||||
async fetchForAlbum (album: Album | number) {
|
||||
const id = typeof album === 'number' ? album : album.id
|
||||
|
@ -223,7 +223,7 @@ export const songStore = {
|
|||
getMostPlayed (count: number) {
|
||||
return take(
|
||||
orderBy(
|
||||
Array.from(this.vault.values()).filter(song => !song.deleted && song.play_count > 0),
|
||||
Array.from(this.vault.values()).filter(({ deleted, play_count })=> !deleted && play_count > 0),
|
||||
'play_count',
|
||||
'desc'
|
||||
),
|
||||
|
|
|
@ -40,7 +40,7 @@ export const themeStore = {
|
|||
this.state.themes.forEach(t => (t.selected = t.id === theme.id))
|
||||
},
|
||||
|
||||
getThemeById (id: string) {
|
||||
getThemeById (id: Theme['id']) {
|
||||
return this.state.themes.find(theme => theme.id === id)
|
||||
},
|
||||
|
||||
|
|
6
resources/assets/js/types.d.ts
vendored
6
resources/assets/js/types.d.ts
vendored
|
@ -157,7 +157,7 @@ interface Song {
|
|||
|
||||
interface CollaborativeSong extends Song {
|
||||
collaboration: {
|
||||
user: Pick<User, 'name' | 'avatar'>
|
||||
user: PlaylistCollaborator
|
||||
added_at: string | null
|
||||
fmt_added_at: string | null
|
||||
}
|
||||
|
@ -223,6 +223,8 @@ interface PlaylistFolder {
|
|||
// we don't need to keep track of the playlists here, as they can be computed using their folder_id value
|
||||
}
|
||||
|
||||
type PlaylistCollaborator = Pick<User, 'id' | 'name' | 'avatar'>
|
||||
|
||||
interface Playlist {
|
||||
type: 'playlists'
|
||||
readonly id: string
|
||||
|
@ -232,7 +234,7 @@ interface Playlist {
|
|||
is_smart: boolean
|
||||
rules: SmartPlaylistRuleGroup[]
|
||||
own_songs_only: boolean
|
||||
collaborators: User[]
|
||||
collaborators: PlaylistCollaborator[]
|
||||
}
|
||||
|
||||
type PlaylistLike = Playlist | FavoriteList | RecentlyPlayedList
|
||||
|
|
|
@ -5,6 +5,10 @@
|
|||
outline: none;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, blockquote {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
*::marker {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
@ -24,8 +24,9 @@ use App\Http\Controllers\API\GenreController;
|
|||
use App\Http\Controllers\API\GenreSongController;
|
||||
use App\Http\Controllers\API\LikeMultipleSongsController;
|
||||
use App\Http\Controllers\API\ObjectStorage\S3\SongController as S3SongController;
|
||||
use App\Http\Controllers\API\PlaylistCollaboration\AcceptPlaylistCollaborationController;
|
||||
use App\Http\Controllers\API\PlaylistCollaboration\AcceptPlaylistCollaborationInviteController;
|
||||
use App\Http\Controllers\API\PlaylistCollaboration\CreatePlaylistCollaborationTokenController;
|
||||
use App\Http\Controllers\API\PlaylistCollaboration\PlaylistCollaboratorController;
|
||||
use App\Http\Controllers\API\PlaylistController;
|
||||
use App\Http\Controllers\API\PlaylistFolderController;
|
||||
use App\Http\Controllers\API\PlaylistFolderPlaylistController;
|
||||
|
@ -172,7 +173,9 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
|
|||
|
||||
// Playlist collaboration routes
|
||||
Route::post('playlists/{playlist}/collaborators/invite', CreatePlaylistCollaborationTokenController::class);
|
||||
Route::post('playlists/collaborators/accept', AcceptPlaylistCollaborationController::class);
|
||||
Route::post('playlists/collaborators/accept', AcceptPlaylistCollaborationInviteController::class);
|
||||
Route::get('playlists/{playlist}/collaborators', [PlaylistCollaboratorController::class, 'index']);
|
||||
Route::delete('playlists/{playlist}/collaborators', [PlaylistCollaboratorController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// Object-storage (S3) routes
|
||||
|
|
Loading…
Reference in a new issue