feat(plus): manage collaborators

This commit is contained in:
Phan An 2024-01-24 23:39:47 +01:00
parent 83328284d7
commit 36785b720f
49 changed files with 497 additions and 83 deletions

View file

@ -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)

View file

@ -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);
}
}

View file

@ -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',
];
}
}

View file

@ -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,
],

View 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,
];
}
}

View file

@ -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

View file

@ -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'

View file

@ -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.');
}
}

View 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,
];
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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 {

View file

@ -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()

View 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 its 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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]
}

View file

@ -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>

View file

@ -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)
}
})

View file

@ -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>

View file

@ -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()
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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')

View file

@ -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;
}

View file

@ -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>

View file

@ -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) ||

View file

@ -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;

View file

@ -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)

View file

@ -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);

View file

@ -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)
}
}

View file

@ -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)
})

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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)
},

View file

@ -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])
}
}

View file

@ -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')
}
}

View file

@ -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)
},

View file

@ -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
)
}
}

View file

@ -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) {

View file

@ -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 (

View file

@ -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)
}
}
}

View file

@ -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'
),

View file

@ -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)
},

View file

@ -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

View file

@ -5,6 +5,10 @@
outline: none;
}
h1, h2, h3, h4, h5, h6, blockquote {
text-wrap: balance;
}
*::marker {
display: none !important;
}

View file

@ -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