ci / api / api (push) Successful in 55s
release / release (версия и заметки) (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 2m19s
release / android (APK в релиз) (push) Successful in 4m45s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m58s
release / prune (старые файлы и прогоны) (push) Successful in 9s
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m35s
ci / db / db (push) Successful in 59s
102 lines
5.9 KiB
TypeScript
102 lines
5.9 KiB
TypeScript
import type { components } from '@pipu/api-client';
|
|
import { unwrap } from '@pipu/core';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { memo, useCallback, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useSession, useUser } from '../app/session';
|
|
import { useErrorText } from '../shared/lib/errors';
|
|
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
|
import { playlistAddIcon } 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, shared = false) {
|
|
const session = useSession();
|
|
const user = useUser();
|
|
return useQuery({ queryKey: shared ? ['shared-playlists', id, user.id] : ['playlists', id], staleTime: 0,
|
|
queryFn: async () => shared
|
|
? unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }))
|
|
: 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. */
|
|
const PlaylistPicker = memo(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, className }: { trackIds: string[]; compact?: boolean; className?: string }) {
|
|
const { t } = useTranslation();
|
|
const [selectedIds, setSelectedIds] = useState<string[] | null>(null);
|
|
const close = useCallback(() => setSelectedIds(null), []);
|
|
return <>
|
|
{compact ? <button type="button" className={className ?? s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setSelectedIds([...trackIds])}><Icon src={playlistAddIcon} size={20} /></button>
|
|
: <Button icon={playlistAddIcon} disabled={trackIds.length === 0} onClick={() => setSelectedIds([...trackIds])}>{t('playlists.add')}</Button>}
|
|
{selectedIds && <PlaylistPicker trackIds={selectedIds} onClose={close} />}
|
|
</>;
|
|
}
|