import type { components } from '@pipu/api-client'; import { ApiError, unwrap } from '@pipu/core'; import { useMutation, useQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession } from '../../app/session'; import { useErrorText } from '../../shared/lib/errors'; import { addIcon, arrowDownIcon, arrowUpIcon, deleteIcon } from '../../shared/ui/icons'; import { Button, cx, Field, FormError, Icon } from '../../shared/ui/ui'; import s from '../page.module.css'; import l from './library.module.css'; import { WriteTemplateSection } from './WriteTemplateSection'; type Pattern = components['schemas']['PathPattern']; type Preview = components['schemas']['PatternPreview']; /** Примеры из docs/library-and-storage.md — частые раскладки папок. */ const EXAMPLES = [ String.raw`^(?P[^/]+)/(?P\d{4}) - (?P[^/]+)/(?:(?P\d)-)?(?P\d{1,3}) - (?P[^/]+)\.\w+$`, String.raw`^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$`, String.raw`^Compilations/(?P<album>[^/]+)/(?P<track>\d+) - (?P<artist>.+?) - (?P<title>[^/]+)\.\w+$`, ]; /** Ошибка компиляции из 422: номер шаблона и причина. */ function patternError(err: unknown): { index: number; reason: string } | null { if (err instanceof ApiError && err.code === 'validation.pattern_invalid') { return { index: Number(err.params.index), reason: String(err.params.reason) }; } return null; } export function PatternsPage({ id }: { id: string }) { const { t } = useTranslation(['admin', 'common']); const session = useSession(); const errorText = useErrorText(); const source = useQuery({ queryKey: ['source', id], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources/{id}', { params: { path: { id } } })), }); const saved = useQuery({ queryKey: ['patterns', id], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources/{id}/patterns', { params: { path: { id } } })).patterns, }); const [rows, setRows] = useState<Pattern[]>([]); useEffect(() => { if (saved.data) setRows(saved.data); }, [saved.data]); const preview = useMutation({ mutationFn: async () => unwrap( await session.client.POST('/api/v1/admin/sources/{id}/patterns/preview', { params: { path: { id } }, body: { patterns: rows, sample: 50 }, }), ), }); const save = useMutation({ mutationFn: async () => unwrap( await session.client.PUT('/api/v1/admin/sources/{id}/patterns', { params: { path: { id } }, body: { patterns: rows }, }), ), onSuccess: (r) => setRows(r.patterns), }); const err = save.error ?? preview.error; const perr = patternError(err); const update = (i: number, p: Partial<Pattern>) => { save.reset(); setRows((rs) => rs.map((r, k) => (k === i ? { ...r, ...p } : r))); }; const move = (i: number, d: -1 | 1) => setRows((rs) => { const next = [...rs]; [next[i], next[i + d]] = [next[i + d]!, next[i]!]; return next; }); return ( <div className={`${s.page} ${s.narrow}`} style={{ maxWidth: 1100 }}> <div className={s.head}> <div> <h1>{t('patterns.title', { name: source.data?.name ?? '…' })}</h1> <p className={s.subtitle}> <Link to="/admin/sources">{t('sources.title')}</Link> </p> </div> </div> <section className={s.section}> <p className={s.note}>{t('patterns.intro')}</p> <p className={l.groups}>{t('patterns.groups')}</p> {rows.map((r, i) => ( <div key={i} className={l.patternRow}> <span className={l.patternNum}>{i + 1}</span> <Field label={t('patterns.regex', { n: i + 1 })} value={r.regex} onChange={(v) => update(i, { regex: v })} maxLength={1000} spellCheck={false} autoCapitalize="none" mono error={perr?.index === i ? t('patterns.error_at', { n: i + 1, reason: perr.reason }) : undefined} /> <div className={l.patternTools} style={{ marginTop: 22 }}> <label className={l.check}> <input type="checkbox" checked={r.is_enabled} onChange={(e) => update(i, { is_enabled: e.target.checked })} /> {t('patterns.enabled')} </label> <Button variant="ghost" aria-label={t('patterns.up')} disabled={i === 0} onClick={() => move(i, -1)}> <Icon src={arrowUpIcon} size={18} /> </Button> <Button variant="ghost" aria-label={t('patterns.down')} disabled={i === rows.length - 1} onClick={() => move(i, 1)} > <Icon src={arrowDownIcon} size={18} /> </Button> <Button variant="ghost" aria-label={t('patterns.remove')} onClick={() => setRows((rs) => rs.filter((_, k) => k !== i))} > <Icon src={deleteIcon} size={18} /> </Button> </div> </div> ))} <div className={s.toolbar}> <Button icon={addIcon} onClick={() => setRows((rs) => [...rs, { regex: '', is_enabled: true }])}> {t('patterns.add')} </Button> <Button variant="ghost" onClick={() => setRows((rs) => [...rs, ...EXAMPLES.map((regex) => ({ regex, is_enabled: true }))])} > {t('patterns.examples')} </Button> <span style={{ flex: 1 }} /> <Button busy={preview.isPending} onClick={() => preview.mutate()}> {t('patterns.preview')} </Button> <Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}> {t('patterns.save')} </Button> </div> {save.isSuccess && <span className={s.ok}>{t('patterns.saved')}</span>} <FormError>{err && !perr ? errorText(err) : null}</FormError> </section> {preview.data && <PreviewTable preview={preview.data} />} {source.data && <WriteTemplateSection source={source.data} />} </div> ); } function PreviewTable({ preview }: { preview: Preview }) { const { t } = useTranslation('admin'); const fields = ['artist', 'album', 'title'] as const; return ( <> <p className={s.note}>{t('patterns.unmatched', { count: preview.unmatched, total: preview.items.length })}</p> <div className={s.tableWrap} style={{ overflowX: 'auto' }}> <table className={s.table}> <thead> <tr> <th>{t('patterns.col_path')}</th> <th>{t('patterns.col_pattern')}</th> <th>{t('patterns.col_result')}</th> </tr> </thead> <tbody> {preview.items.map((it) => ( <tr key={it.rel_path}> <td className={l.match}>{it.rel_path}</td> <td className={cx(l.match, it.pattern_index === null && l.noMatch)}> {it.skipped ? t('patterns.skipped') : it.pattern_index === null ? t('patterns.no_match') : `#${it.pattern_index + 1}`} </td> <td> <div className={l.result}> {fields.map((f) => { const fromPath = f in it.path_fields; const fromTags = (f === 'artist' ? 'artist' : f) in it.tags; return ( <span key={f}> <b>{it.result[f]}</b>{' '} {!fromPath && fromTags && <span className={l.fromTags}>{t('patterns.from_tags')}</span>} </span> ); })} <span className={l.fromTags}> {[it.result.year, it.result.track && `#${it.result.track}`, it.result.genres.join(', ')] .filter(Boolean) .join(' · ')} </span> {it.error && <span className={l.scanError}>{it.error}</span>} </div> </td> </tr> ))} </tbody> </table> </div> </> ); }