feat: add personal playlists and shareable track links
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
3a097c15c8
commit
fa79394869
46 files changed
+2307
-292
No files matched your search
@@ -277,4 +277,5 @@
|
||||
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
||||
.end { gap: 4px; }
|
||||
.like { opacity: 1; width: 44px; height: 44px; }
|
||||
.track:has([data-playlist-actions]) .end { grid-column: 3 / -1; justify-content: flex-end; }
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Fragment, useRef } from 'react';
|
||||
import { Fragment, useRef, type ReactNode } from 'react';
|
||||
import { AddToPlaylist } from './playlists';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
@@ -115,6 +116,7 @@ export function TrackList({
|
||||
rowMeta,
|
||||
lead,
|
||||
disliked,
|
||||
actions,
|
||||
}: {
|
||||
tracks: Track[];
|
||||
showAlbum?: boolean;
|
||||
@@ -126,6 +128,7 @@ export function TrackList({
|
||||
lead?: (t: Track, i: number) => string;
|
||||
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
|
||||
disliked?: boolean;
|
||||
actions?: (track: Track, index: number) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
@@ -154,7 +157,7 @@ export function TrackList({
|
||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('a, button')) play();
|
||||
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||
@@ -181,7 +184,8 @@ export function TrackList({
|
||||
{tr.album.title}
|
||||
</Link>
|
||||
)}
|
||||
<span className={s.end}>
|
||||
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
|
||||
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
|
||||
{disliked ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
||||
import { queueIcon } from '../shared/ui/icons';
|
||||
import s from './catalog.module.css';
|
||||
|
||||
export type Playlist = components['schemas']['Playlist'];
|
||||
export type PlaylistInput = components['schemas']['PlaylistInput'];
|
||||
|
||||
export function usePlaylists(enabled = true) {
|
||||
const session = useSession();
|
||||
return useQuery({ queryKey: ['playlists'], enabled,
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||
}
|
||||
|
||||
export function usePlaylist(id: string) {
|
||||
const session = useSession();
|
||||
return useQuery({ queryKey: ['playlists', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||
}
|
||||
|
||||
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
|
||||
return { name: p.name, description: p.description, track_ids: p.tracks.map(t => t.id), revision: p.revision };
|
||||
}
|
||||
|
||||
export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
|
||||
playlist?: Playlist; trackIds?: string[]; onClose: () => void; onSaved: (p: Playlist) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [original] = useState(playlist);
|
||||
const [name, setName] = useState(playlist?.name ?? '');
|
||||
const [description, setDescription] = useState(playlist?.description ?? '');
|
||||
const save = useMutation({ mutationFn: async () => {
|
||||
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? trackIds };
|
||||
return original
|
||||
? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
|
||||
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
|
||||
}, onSuccess: p => {
|
||||
cache.setQueryData(['playlists', p.id], p);
|
||||
void cache.invalidateQueries({ queryKey: ['playlists'] });
|
||||
onSaved(p);
|
||||
}, onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
|
||||
const close = useCallback(() => { if (!save.isPending) onClose(); }, [save.isPending, onClose]);
|
||||
return <Dialog title={t(playlist ? 'playlists.edit' : 'playlists.create')} onClose={close}>
|
||||
<form onSubmit={e => { e.preventDefault(); if (!save.isPending && name.trim()) save.mutate(); }}>
|
||||
<Field label={t('playlists.name')} value={name} onChange={setName} maxLength={120} required autoFocus />
|
||||
<Field label={t('playlists.description')} value={description} onChange={setDescription} maxLength={2000} />
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
<Button variant="primary" type="submit" busy={save.isPending} disabled={!name.trim()}>{t('playlists.save')}</Button>
|
||||
<Button variant="ghost" onClick={onClose} disabled={save.isPending}>{t('form.cancel')}</Button>
|
||||
</form>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
|
||||
function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const list = usePlaylists();
|
||||
const [create, setCreate] = useState(false);
|
||||
const add = useMutation({ mutationFn: async (id: string) => {
|
||||
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
|
||||
const body = playlistInput(p);
|
||||
body.track_ids = [...new Set([...body.track_ids, ...trackIds])];
|
||||
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
|
||||
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); onClose(); } });
|
||||
if (create) return <PlaylistEditor trackIds={trackIds} onClose={() => setCreate(false)} onSaved={onClose} />;
|
||||
return <Dialog title={t('playlists.add')} onClose={() => { if (!add.isPending) onClose(); }}>
|
||||
<FormError>{list.error || add.error ? errorText(list.error ?? add.error) : null}</FormError>
|
||||
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||
{list.isSuccess && list.data.items.length === 0 && <p>{t('playlists.empty')}</p>}
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{list.data?.items.map(p => <Button key={p.id} disabled={add.isPending} onClick={() => add.mutate(p.id)}>{p.name} · {p.track_count}</Button>)}
|
||||
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
|
||||
</div>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return <>
|
||||
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
|
||||
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
|
||||
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
|
||||
</>;
|
||||
}
|
||||
Reference in new issue
Block a user