Files
pipu-music/apps/web/src/entities/playlists.tsx
T
mrleo1nidandCursor fa79394869
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m8s
feat: add personal playlists and shareable track links
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:37:53 +03:00

98 lines
5.5 KiB
TypeScript

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