ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s
196 lines
12 KiB
TypeScript
196 lines
12 KiB
TypeScript
import { unwrap } from '@pipu/core';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { Link, useNavigate } from '@tanstack/react-router';
|
||
import { useState, type MouseEvent } 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 type { components } from '@pipu/api-client';
|
||
import { usePlayer } from '../player/context';
|
||
import { useErrorText } from '../shared/lib/errors';
|
||
import { playlistShareUrl } from '@pipu/core';
|
||
import { desktopHandle } from '../desktop';
|
||
import { useMusicShare } from '../features/social/Social';
|
||
import { formatDuration } from '../shared/lib/format';
|
||
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
|
||
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon, shareIcon } 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 && <PageSkeleton />}
|
||
{list.isSuccess && !items.length && (
|
||
<EmptyState
|
||
icon={search ? searchIcon : playlistAddIcon}
|
||
action={!search ? <Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button> : undefined}
|
||
>
|
||
{t(search ? 'playlists.no_results' : 'playlists.empty')}
|
||
</EmptyState>
|
||
)}
|
||
<div className={p.grid}>{items.map(item => <PlaylistCard key={item.id} item={item} />)}</div>
|
||
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
|
||
</div>;
|
||
}
|
||
|
||
export function PlaylistPage({ id, shared = false }: { id: string; shared?: boolean }) {
|
||
const { t } = useTranslation();
|
||
const query = usePlaylist(id, shared);
|
||
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 && <PageSkeleton />}
|
||
{query.data && !query.isError && <PlaylistDetail key={id} playlist={query.data} readOnly={shared} shareId={shared ? id : undefined} />}
|
||
</div>;
|
||
}
|
||
|
||
export function ActivityPlaylistPage({ userId }: { userId: string }) {
|
||
const session = useSession();
|
||
const errorText = useErrorText();
|
||
const { t } = useTranslation();
|
||
const query = useQuery({ queryKey: ['activity-playlist', session.state.status === 'signed_in' ? session.state.user.id : '', userId], queryFn: async () => unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId } } })), staleTime: 0, refetchInterval: 15_000 });
|
||
return <div className={s.page}>
|
||
<Link to="/friends">← {t('social.title')}</Link>
|
||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||
{query.isPending && <PageSkeleton />}
|
||
{query.data && !query.isError && <PlaylistDetail key={query.data.id} playlist={query.data} readOnly activity />}
|
||
</div>;
|
||
}
|
||
|
||
function PlaylistDetail({ playlist, readOnly = false, shareId, activity = false }: { playlist: Playlist; readOnly?: boolean; shareId?: string; activity?: boolean }) {
|
||
const openShare = useMusicShare();
|
||
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 sharing = useMutation({ mutationFn: async () => {
|
||
let token = shareId ?? playlist.share_id;
|
||
if (!readOnly) {
|
||
token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })).share_id;
|
||
cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: token } : current);
|
||
}
|
||
if (!token) throw new Error('Share link unavailable');
|
||
openShare({ attachment: { kind: 'playlist', id: token, title: playlist.name }, url: playlistShareUrl(desktopHandle()?.server ?? window.location.origin, token) });
|
||
} });
|
||
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })),
|
||
onSuccess: () => { cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
|
||
|
||
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(readOnly || playlist.share_id ? 'playlists.shared' : '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>
|
||
{!activity && <Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>}
|
||
{!readOnly && <>
|
||
<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>
|
||
{playlist.share_id && <Button variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onClick={() => revoke.mutate()}>{t('playlists.revoke_share')}</Button>}
|
||
</>}
|
||
</div>
|
||
{!readOnly && <p>{t('playlists.share_hint')}</p>}
|
||
<FormError>{sharing.error ? t('player.share_error') : revoke.error ? errorText(revoke.error) : null}</FormError>
|
||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||
{!playlist.tracks.length && (
|
||
<EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}>
|
||
{t('playlists.empty_tracks')}
|
||
</EmptyState>
|
||
)}
|
||
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
|
||
actions={!readOnly && 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} />
|
||
{!readOnly && edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||
{!readOnly && 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>
|
||
)}
|
||
</>;
|
||
}
|
||
|
||
function PlaylistCard({ item }: { item: components['schemas']['PlaylistSummary'] }) {
|
||
const { t } = useTranslation();
|
||
const session = useSession();
|
||
const player = usePlayer();
|
||
const play = (e: MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
void (async () => {
|
||
const full = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id: item.id } } }));
|
||
const tracks = full.tracks.filter((x) => x.available);
|
||
if (tracks.length) player.playList(tracks, 0, { type: 'collection', id: item.id, label: item.name });
|
||
})();
|
||
};
|
||
return (
|
||
<Link to="/playlist/$id" params={{ id: item.id }} className={p.card}>
|
||
<span className={p.coverWrap}>
|
||
<Cover album={item} size="100%" />
|
||
{item.track_count > 0 && (
|
||
<button type="button" className={p.play} aria-label={t('catalog.play')} onClick={play}>
|
||
<Icon src={playIcon} size={22} />
|
||
</button>
|
||
)}
|
||
</span>
|
||
<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>
|
||
);
|
||
}
|