110 lines
7.4 KiB
TypeScript
110 lines
7.4 KiB
TypeScript
import { unwrap } from '@pipu/core';
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { Link, useNavigate } from '@tanstack/react-router';
|
||
import { useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { useSession } from '../app/session';
|
||
import { Cover, TrackList } from '../entities/catalog';
|
||
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
|
||
import { usePlayer } from '../player/context';
|
||
import { useErrorText } from '../shared/lib/errors';
|
||
import { formatDuration } from '../shared/lib/format';
|
||
import { Button, Dialog, Field, FormError } from '../shared/ui/ui';
|
||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||
import c from './catalog.module.css';
|
||
import s from './page.module.css';
|
||
import p from './playlists.module.css';
|
||
|
||
export function PlaylistsPage() {
|
||
const { t } = useTranslation();
|
||
const list = usePlaylists();
|
||
const errorText = useErrorText();
|
||
const navigate = useNavigate();
|
||
const [create, setCreate] = useState(false);
|
||
const [search, setSearch] = useState('');
|
||
const items = list.data?.items.filter(x => x.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? [];
|
||
return <div className={s.page}>
|
||
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
|
||
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
|
||
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>}
|
||
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}>
|
||
<Cover album={item} size="100%" />
|
||
<strong>{item.name}</strong><span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
|
||
{item.description && <span className={p.description}>{item.description}</span>}
|
||
</Link>)}</div>
|
||
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
|
||
</div>;
|
||
}
|
||
|
||
export function PlaylistPage({ id }: { id: string }) {
|
||
const { t } = useTranslation();
|
||
const query = usePlaylist(id);
|
||
const errorText = useErrorText();
|
||
return <div className={s.page}>
|
||
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
|
||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||
{query.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
|
||
</div>;
|
||
}
|
||
|
||
function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||
const { t } = useTranslation();
|
||
const session = useSession();
|
||
const cache = useQueryClient();
|
||
const player = usePlayer();
|
||
const navigate = useNavigate();
|
||
const errorText = useErrorText();
|
||
const [edit, setEdit] = useState(false);
|
||
const [remove, setRemove] = useState(false);
|
||
const [manage, setManage] = useState(false);
|
||
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
|
||
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
|
||
params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
|
||
})), onSuccess: data => { cache.setQueryData(['playlists', playlist.id], data); void cache.invalidateQueries({ queryKey: ['playlists'] }); },
|
||
onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
|
||
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id: playlist.id } } })),
|
||
onSuccess: () => { cache.removeQueries({ queryKey: ['playlists', playlist.id] }); void cache.invalidateQueries({ queryKey: ['playlists'] }); void navigate({ to: '/collection/playlists' }); } });
|
||
const play = (shuffle: boolean) => {
|
||
const tracks = playlist.tracks.filter(x => x.available);
|
||
if (shuffle) for (let i = tracks.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [tracks[i], tracks[j]] = [tracks[j]!, tracks[i]!]; }
|
||
if (tracks.length) player.playList(tracks, 0, context);
|
||
};
|
||
const move = (index: number, delta: number) => {
|
||
const ids = playlist.tracks.map(x => x.id);
|
||
[ids[index], ids[index + delta]] = [ids[index + delta]!, ids[index]!];
|
||
save.mutate(ids);
|
||
};
|
||
return <>
|
||
<header className={c.colHero}>
|
||
<Cover album={playlist.tracks[0]?.album ?? playlist} size={144} />
|
||
<div className={c.heroText}><span className={c.kicker}>{t('playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
|
||
<p className={p.description}>{playlist.description}</p>
|
||
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
|
||
</div>
|
||
</header>
|
||
<div className={s.toolbar}>
|
||
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
|
||
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
|
||
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
|
||
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
|
||
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
|
||
</div>
|
||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||
{!playlist.tracks.length && <div className={s.empty}><p>{t('playlists.empty_tracks')}</p><Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link></div>}
|
||
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
|
||
actions={manage ? (track, i) => <span className={p.actions}>
|
||
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
|
||
<button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button>
|
||
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
|
||
</span> : undefined} />
|
||
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||
{remove && <Dialog title={t('playlists.delete')} onClose={() => { if (!deletion.isPending) setRemove(false); }} footer={<>
|
||
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
|
||
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
|
||
</>}><p>{t('playlists.delete_confirm', { name: playlist.name })}</p><FormError>{deletion.error ? errorText(deletion.error) : null}</FormError></Dialog>}
|
||
</>;
|
||
}
|