Files
pipu-music/apps/web/src/pages/admin/WriteTemplateSection.tsx
T
mrleo1nidandClaude Opus 5.5 f66be2ba0a
ci / api / api (push) Successful in 33s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
ci / ts / ts (packages + web) (push) Successful in 1m10s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 51s
feat: storage writes via storage-agent, accepts_uploads and write_template (M5.5)
- storage-agent: POST /internal/files copies a staged file into a source
  (temp file, fsync, SHA-256 check, hard link so nothing is overwritten,
  " (2)" suffix on conflict, owner PIPU_FILE_UID/GID) and
  POST /internal/files/test. NFS sources that accept uploads are mounted
  rw in an agent-only /library-rw and bind-mounted read-only for others;
  local sources are written through /library-host-rw.
- meta: write templates ({track:02}, {disc:1-}, [optional]) with name
  sanitising and length limits, suggestion from the source's regexes
  and a reversibility check against 50 catalogue tracks.
- API and admin UI: accepts_uploads / write_template on sources,
  write-template suggest and check endpoints, write test result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:46:56 +03:00

155 lines
6.0 KiB
TypeScript

import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { Button, cx, Field, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import l from './library.module.css';
type Source = components['schemas']['Source'];
type Check = components['schemas']['WriteTemplateCheck'];
/**
* Приём загрузок и шаблон пути новых файлов (docs/uploads.md#7): автоподбор по существующей структуре
* и проверка обратимости регэкспами хранилища. Сохранить можно и с расхождениями — админ видит предупреждение.
*/
export function WriteTemplateSection({ source }: { source: Source }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const fieldError = useFieldError();
const id = source.id;
const [accepts, setAccepts] = useState(source.accepts_uploads);
const [template, setTemplate] = useState(source.write_template ?? '');
const [check, setCheck] = useState<Check | null>(null);
useEffect(() => {
setAccepts(source.accepts_uploads);
setTemplate(source.write_template ?? '');
}, [source.accepts_uploads, source.write_template]);
const runCheck = useMutation({
mutationFn: async (tpl: string) =>
unwrap(
await session.client.POST('/api/v1/admin/sources/{id}/write-template/check', {
params: { path: { id } },
body: { template: tpl },
}),
),
onSuccess: setCheck,
});
const suggest = useMutation({
mutationFn: async () =>
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/write-template/suggest', { params: { path: { id } } }))
.template,
onSuccess: (tpl) => {
setTemplate(tpl);
runCheck.mutate(tpl);
},
});
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PATCH('/api/v1/admin/sources/{id}', {
params: { path: { id } },
body: { accepts_uploads: accepts, write_template: template },
}),
),
onSuccess: async (src) => {
queryClient.setQueryData(['source', id], src);
await queryClient.invalidateQueries({ queryKey: ['sources'] });
},
});
const err = save.error ?? suggest.error ?? runCheck.error;
const tplError = fieldError(err, 'write_template') ?? fieldError(err, 'template');
const dirty = accepts !== source.accepts_uploads || template !== (source.write_template ?? '');
return (
<section className={s.section}>
<h2>{t('write_template.title')}</h2>
<p className={s.note}>{t('write_template.intro')}</p>
<label className={l.checkRow}>
<input type="checkbox" checked={accepts} onChange={(e) => setAccepts(e.target.checked)} />
<span>{t('write_template.accepts')}</span>
</label>
{accepts && source.type === 'nfs' && <p className={s.note}>{t('write_template.nfs_rw_hint')}</p>}
<Field
label={t('write_template.template')}
value={template}
onChange={(v) => {
setTemplate(v);
setCheck(null);
save.reset();
}}
hint={t('write_template.hint')}
error={tplError}
mono
/>
<div className={s.toolbar}>
<Button busy={suggest.isPending} onClick={() => suggest.mutate()}>
{t('write_template.suggest')}
</Button>
<Button busy={runCheck.isPending} disabled={!template.trim()} onClick={() => runCheck.mutate(template)}>
{t('write_template.check')}
</Button>
<span style={{ flex: 1 }} />
<Button variant="primary" busy={save.isPending} disabled={!dirty || (accepts && !template.trim())} onClick={() => save.mutate()}>
{t('write_template.save')}
</Button>
</div>
{accepts && !template.trim() && <p className={s.note}>{t('write_template.required')}</p>}
{save.isSuccess && !dirty && <span className={s.ok}>{t('write_template.saved')}</span>}
<FormError>{err && !tplError ? errorText(err) : null}</FormError>
{check && <CheckResult check={check} />}
</section>
);
}
function CheckResult({ check }: { check: Check }) {
const { t } = useTranslation('admin');
if (check.total === 0) return <p className={s.note}>{t('write_template.no_tracks')}</p>;
return (
<>
<p className={cx(s.note, check.ok ? s.ok : l.scanError)} role="status">
{t(check.ok ? 'write_template.check_ok' : 'write_template.check_warn', {
round_trip: check.round_trip,
same_path: check.same_path,
total: check.total,
})}
</p>
{check.mismatches.length > 0 && (
<div className={s.tableWrap} style={{ overflowX: 'auto' }}>
<table className={s.table}>
<thead>
<tr>
<th>{t('write_template.col_real')}</th>
<th>{t('write_template.col_rendered')}</th>
<th>{t('write_template.col_problem')}</th>
</tr>
</thead>
<tbody>
{check.mismatches.map((m) => (
<tr key={m.rel_path}>
<td className={l.match}>{m.rel_path}</td>
<td className={l.match}>{m.rendered ?? '—'}</td>
<td>
{m.error ??
(m.fields.length
? m.fields.map((f) => (f === 'pattern' ? t('write_template.no_pattern') : f)).join(', ')
: t('write_template.other_path'))}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}