ci / ts / ts (packages + web) (push) Successful in 1m53s
Labels that wrapped to two lines pushed their inputs down, the storage select had its own label style, and the checkbox sat in the middle of its cell. The section now lays label, input and hint rows out with subgrid, and the select no longer stretches past its column. Also add a web-fake launch config that runs the web app on the fake API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
641 lines
24 KiB
TypeScript
641 lines
24 KiB
TypeScript
import type { components } from '@pipu/api-client';
|
|
import { ApiError, unwrap } from '@pipu/core';
|
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useSession } from '../../app/session';
|
|
import { needsFormatAttention } from '../../features/uploads/files';
|
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
|
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
|
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
|
|
import { Button, cx, Dialog, Field, FormError, Icon } from '../../shared/ui/ui';
|
|
import s from '../page.module.css';
|
|
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
|
|
import u from '../uploads/uploads.module.css';
|
|
|
|
type Meta = components['schemas']['UploadMeta'];
|
|
type Settings = components['schemas']['UploadSettings'];
|
|
|
|
const TABS = {
|
|
review: ['pending_review'],
|
|
failed: ['import_failed'],
|
|
progress: ['uploading', 'processing', 'approved', 'importing'],
|
|
imported: ['imported'],
|
|
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
|
|
} satisfies Record<string, UploadStatus[]>;
|
|
type Tab = keyof typeof TABS;
|
|
|
|
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
|
|
export function UploadQueuePage() {
|
|
const { t } = useTranslation(['admin', 'common']);
|
|
const session = useSession();
|
|
const queryClient = useQueryClient();
|
|
const errorText = useErrorText();
|
|
const [tab, setTab] = useState<Tab>('review');
|
|
const [onlyPossible, setOnlyPossible] = useState(false);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [editing, setEditing] = useState<UploadFile | null>(null);
|
|
const [batchEdit, setBatchEdit] = useState<string | null>(null);
|
|
const [rejecting, setRejecting] = useState(false);
|
|
|
|
const list = useInfiniteQuery({
|
|
queryKey: ['admin-uploads', tab, onlyPossible],
|
|
initialPageParam: undefined as string | undefined,
|
|
queryFn: async ({ pageParam }) =>
|
|
unwrap(
|
|
await session.client.GET('/api/v1/admin/uploads', {
|
|
params: {
|
|
query: { status: [...TABS[tab]], possible_duplicate: onlyPossible || undefined, cursor: pageParam, limit: 100 },
|
|
},
|
|
}),
|
|
),
|
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
|
refetchInterval: (q) =>
|
|
q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
|
|
});
|
|
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
|
|
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
|
|
|
|
const refresh = async () => {
|
|
setSelected(new Set());
|
|
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
|
|
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
|
};
|
|
const review = useMutation({
|
|
mutationFn: async (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) =>
|
|
unwrap(
|
|
await session.client.POST('/api/v1/admin/uploads/review', {
|
|
body: { file_ids: v.ids, decision: v.decision, reason: v.reason },
|
|
}),
|
|
),
|
|
onSuccess: refresh,
|
|
});
|
|
const retry = useMutation({
|
|
mutationFn: async (id: string) =>
|
|
unwrap(await session.client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
|
|
onSuccess: refresh,
|
|
});
|
|
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
|
|
|
|
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
|
|
const batches = useMemo(() => {
|
|
const m = new Map<string, UploadFile[]>();
|
|
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
|
|
return [...m.values()];
|
|
}, [items]);
|
|
const toggle = (ids: string[], on: boolean) =>
|
|
setSelected((cur) => {
|
|
const next = new Set(cur);
|
|
for (const id of ids) {
|
|
if (on) next.add(id);
|
|
else next.delete(id);
|
|
}
|
|
return next;
|
|
});
|
|
const selectable = tab === 'review' || tab === 'failed';
|
|
const ids = [...selected];
|
|
|
|
return (
|
|
<div className={s.page}>
|
|
<div className={s.head}>
|
|
<div>
|
|
<h1>{t('uploads.queue_title')}</h1>
|
|
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
|
|
</div>
|
|
<QuotaMeter />
|
|
</div>
|
|
<div className={s.toolbar}>
|
|
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
|
|
{(Object.keys(TABS) as Tab[]).map((k) => (
|
|
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
|
|
{t(`uploads.tab_${k}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<span style={{ flex: 1 }} />
|
|
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
|
|
{t('uploads.only_possible')}
|
|
</label>
|
|
</div>
|
|
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
|
|
{failed.length > 0 && (
|
|
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
|
|
)}
|
|
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.queue_empty')}</div>}
|
|
|
|
{batches.map((files) => (
|
|
<BatchCard
|
|
key={files[0]!.batch_id}
|
|
files={files}
|
|
selectable={selectable}
|
|
selected={selected}
|
|
onToggle={toggle}
|
|
onEdit={setEditing}
|
|
onEditBatch={setBatchEdit}
|
|
onRetry={(id) => retry.mutate(id)}
|
|
/>
|
|
))}
|
|
{list.hasNextPage && (
|
|
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
|
{t('uploads.load_more')}
|
|
</Button>
|
|
)}
|
|
|
|
{ids.length > 0 && (
|
|
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
|
|
<span>{t('uploads.selected', { count: ids.length })}</span>
|
|
<span style={{ flex: 1 }} />
|
|
<Button variant="ghost" onClick={() => setSelected(new Set())}>
|
|
{t('common:form.cancel')}
|
|
</Button>
|
|
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
|
|
{t('uploads.reject')}
|
|
</Button>
|
|
{tab === 'review' && (
|
|
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
|
|
{t('uploads.approve')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{batchEdit && (
|
|
<BatchEditDialog
|
|
batchId={batchEdit}
|
|
onClose={() => setBatchEdit(null)}
|
|
onSaved={() => {
|
|
setBatchEdit(null);
|
|
void refresh();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{editing && (
|
|
<EditDialog
|
|
file={editing}
|
|
onClose={() => setEditing(null)}
|
|
onSaved={() => {
|
|
review.reset();
|
|
void refresh();
|
|
}}
|
|
/>
|
|
)}
|
|
{rejecting && (
|
|
<RejectDialog
|
|
count={ids.length}
|
|
busy={review.isPending}
|
|
onClose={() => setRejecting(false)}
|
|
onReject={(reason) => {
|
|
setRejecting(false);
|
|
review.mutate({ decision: 'reject', ids, reason });
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Problem из ответа массового решения → объект, понятный useErrorText. */
|
|
function problemError(p: components['schemas']['Problem']) {
|
|
return new ApiError(p.status, p.code, p.params ?? {});
|
|
}
|
|
|
|
function BatchCard({
|
|
files,
|
|
selectable,
|
|
selected,
|
|
onToggle,
|
|
onEdit,
|
|
onEditBatch,
|
|
onRetry,
|
|
}: {
|
|
files: UploadFile[];
|
|
selectable: boolean;
|
|
selected: Set<string>;
|
|
onToggle: (ids: string[], on: boolean) => void;
|
|
onEdit: (f: UploadFile) => void;
|
|
onEditBatch: (batchId: string) => void;
|
|
onRetry: (id: string) => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation('admin');
|
|
const first = files[0]!;
|
|
const ids = files.map((f) => f.id);
|
|
const all = ids.every((id) => selected.has(id));
|
|
const size = files.reduce((n, f) => n + f.size_bytes, 0);
|
|
return (
|
|
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
|
|
<div className={u.batchHead}>
|
|
{selectable && (
|
|
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
|
|
)}
|
|
<b>{first.author.username}</b>
|
|
<span>{formatDateTime(first.created_at, i18n.language)}</span>
|
|
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
|
|
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
|
|
<span style={{ flex: 1 }} />
|
|
{selectable && (
|
|
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
|
|
{t('uploads.edit_batch')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{files.map((f) => (
|
|
<QueueRow
|
|
key={f.id}
|
|
file={f}
|
|
selectable={selectable}
|
|
checked={selected.has(f.id)}
|
|
onCheck={(on) => onToggle([f.id], on)}
|
|
onEdit={() => onEdit(f)}
|
|
onRetry={() => onRetry(f.id)}
|
|
/>
|
|
))}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function QueueRow({
|
|
file: f,
|
|
selectable,
|
|
checked,
|
|
onCheck,
|
|
onEdit,
|
|
onRetry,
|
|
}: {
|
|
file: UploadFile;
|
|
selectable: boolean;
|
|
checked: boolean;
|
|
onCheck: (on: boolean) => void;
|
|
onEdit: () => void;
|
|
onRetry: () => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation('admin');
|
|
const reason = useReasonText()(f.status_reason);
|
|
const m = f.metadata;
|
|
const quality = [
|
|
f.format?.toUpperCase(),
|
|
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
|
|
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
|
|
f.bit_depth ? `${f.bit_depth} bit` : null,
|
|
f.duration_ms ? formatDuration(f.duration_ms) : null,
|
|
formatBytes(f.size_bytes, i18n.language),
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
const editable = f.status === 'pending_review' || f.status === 'import_failed';
|
|
return (
|
|
<div className={u.queueRow}>
|
|
{selectable ? (
|
|
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
|
|
) : (
|
|
<span />
|
|
)}
|
|
<div className={u.meta}>
|
|
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
|
|
{m && (
|
|
<span className={u.sub}>
|
|
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
|
|
.filter(Boolean)
|
|
.join(' · ')}
|
|
{f.metadata_edited && ` · ${t('uploads.edited')}`}
|
|
</span>
|
|
)}
|
|
<span className={u.sub}>{quality}</span>
|
|
<span className={u.sub} title={f.client_rel_path ?? undefined}>
|
|
{f.client_rel_path ?? f.original_name}
|
|
</span>
|
|
</div>
|
|
<div className={u.meta}>
|
|
<div className={u.flags}>
|
|
<UploadStatusBadge status={f.status} />
|
|
{needsFormatAttention(f.format, f.original_name) && (
|
|
<span className={cx(s.badge, u.tone_warn)} title={t('uploads.format_attention')}>
|
|
{t('uploads.flag_attention')}
|
|
</span>
|
|
)}
|
|
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
|
|
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
|
|
{f.matched_album_id ? (
|
|
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
|
|
) : (
|
|
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
|
|
)}
|
|
</div>
|
|
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
|
|
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
|
|
{f.duplicate_level && f.status === 'duplicate' && (
|
|
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
|
|
)}
|
|
</div>
|
|
<div className={u.rowActions}>
|
|
{f.stream_url && (
|
|
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
|
|
)}
|
|
{editable && (
|
|
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
|
|
<Icon src={editIcon} size={18} />
|
|
</Button>
|
|
)}
|
|
{f.status === 'import_failed' && (
|
|
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
|
|
{t('uploads.retry')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
|
|
|
|
function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
|
|
const { t } = useTranslation(['admin', 'common']);
|
|
const session = useSession();
|
|
const errorText = useErrorText();
|
|
const [artist, setArtist] = useState('');
|
|
const [album, setAlbum] = useState('');
|
|
const [year, setYear] = useState('');
|
|
const [cover, setCover] = useState<string | null | undefined>(undefined);
|
|
const save = useMutation({
|
|
mutationFn: async () =>
|
|
unwrap(
|
|
await session.client.PATCH('/api/v1/admin/uploads/batches/{id}', {
|
|
params: { path: { id: batchId } },
|
|
body: {
|
|
...(artist.trim() ? { artist: artist.trim() } : {}),
|
|
...(album.trim() ? { album: album.trim() } : {}),
|
|
...(year.trim() ? { year: Number.parseInt(year, 10) } : {}),
|
|
...(cover !== undefined ? { cover_base64: cover } : {}),
|
|
},
|
|
}),
|
|
),
|
|
onSuccess: onSaved,
|
|
});
|
|
const pickCover = (file: File | undefined) => {
|
|
if (!file) {
|
|
setCover(undefined);
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const data = String(reader.result ?? '');
|
|
const comma = data.indexOf(',');
|
|
setCover(comma >= 0 ? data.slice(comma + 1) : data);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
return (
|
|
<Dialog
|
|
title={t('uploads.batch_edit_title')}
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t('common:form.cancel')}
|
|
</Button>
|
|
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
|
|
{t('uploads.save')}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className={s.sessions}>
|
|
<p className={s.note}>{t('uploads.batch_edit_hint')}</p>
|
|
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
|
|
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
|
|
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
|
|
<label className={s.sessions}>
|
|
<span>{t('uploads.batch_cover')}</span>
|
|
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} />
|
|
</label>
|
|
<Button variant="ghost" onClick={() => setCover(null)}>
|
|
{t('uploads.batch_cover_clear')}
|
|
</Button>
|
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
|
</div>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
|
|
const { t } = useTranslation(['admin', 'common']);
|
|
const session = useSession();
|
|
const errorText = useErrorText();
|
|
const fieldError = useFieldError();
|
|
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
|
|
const [source, setSource] = useState(file.target_source_id ?? '');
|
|
const sources = useQuery({
|
|
queryKey: ['sources'],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
|
|
});
|
|
const save = useMutation({
|
|
mutationFn: async () =>
|
|
unwrap(
|
|
await session.client.PATCH('/api/v1/admin/uploads/files/{id}', {
|
|
params: { path: { id: file.id } },
|
|
body: { metadata: m, ...(source ? { target_source_id: source } : {}) },
|
|
}),
|
|
),
|
|
onSuccess: () => {
|
|
onSaved();
|
|
onClose();
|
|
},
|
|
});
|
|
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
|
|
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
|
|
);
|
|
const num = (k: 'year' | 'track' | 'disc') => (
|
|
<Field
|
|
label={t(`uploads.meta_${k}`)}
|
|
value={m[k] === null ? '' : String(m[k])}
|
|
inputMode="numeric"
|
|
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
|
|
/>
|
|
);
|
|
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
|
|
return (
|
|
<Dialog
|
|
title={t('uploads.edit_title')}
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t('common:form.cancel')}
|
|
</Button>
|
|
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
|
|
{t('uploads.save')}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className={s.sessions}>
|
|
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
|
|
<div className={u.grid}>
|
|
{text('artist')}
|
|
{text('albumartist')}
|
|
{text('album')}
|
|
{text('title')}
|
|
{text('genre')}
|
|
{num('year')}
|
|
{num('track')}
|
|
{num('disc')}
|
|
</div>
|
|
<label className={s.sessions}>
|
|
<span>{t('uploads.target_source')}</span>
|
|
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
|
|
<option value="">{t('uploads.target_default')}</option>
|
|
{accepting.map((x) => (
|
|
<option key={x.id} value={x.id}>
|
|
{x.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<p className={s.note}>{t('uploads.tags_note')}</p>
|
|
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
|
|
</div>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function RejectDialog({
|
|
count,
|
|
busy,
|
|
onClose,
|
|
onReject,
|
|
}: {
|
|
count: number;
|
|
busy: boolean;
|
|
onClose: () => void;
|
|
onReject: (reason: string) => void;
|
|
}) {
|
|
const { t } = useTranslation(['admin', 'common']);
|
|
const [reason, setReason] = useState('');
|
|
return (
|
|
<Dialog
|
|
title={t('uploads.reject_title', { count })}
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t('common:form.cancel')}
|
|
</Button>
|
|
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
|
|
{t('uploads.reject')}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<label className={s.sessions}>
|
|
<span>{t('uploads.reject_reason')}</span>
|
|
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
|
|
</label>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
const GB = 1024 ** 3;
|
|
|
|
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
|
|
export function UploadSettingsSection() {
|
|
const { t } = useTranslation(['admin', 'common']);
|
|
const session = useSession();
|
|
const queryClient = useQueryClient();
|
|
const errorText = useErrorText();
|
|
const settings = useQuery({
|
|
queryKey: ['upload-settings'],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/settings/uploads')),
|
|
});
|
|
const sources = useQuery({
|
|
queryKey: ['sources'],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
|
|
});
|
|
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string; upgrade: boolean } | null>(null);
|
|
useEffect(() => {
|
|
if (settings.data) setForm(toForm(settings.data));
|
|
}, [settings.data]);
|
|
|
|
const save = useMutation({
|
|
mutationFn: async () => {
|
|
const f = form!;
|
|
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
|
|
return unwrap(
|
|
await session.client.PATCH('/api/v1/admin/settings/uploads', {
|
|
body: {
|
|
queue_limit_bytes: gb(f.queue),
|
|
max_file_bytes: gb(f.maxFile),
|
|
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
|
|
default_source_id: f.source || null,
|
|
allow_quality_upgrade: f.upgrade,
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
onSuccess: async (data) => {
|
|
queryClient.setQueryData(['upload-settings'], data);
|
|
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
|
},
|
|
});
|
|
if (!form) return null;
|
|
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
|
|
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
|
|
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
|
|
return (
|
|
<section className={s.section}>
|
|
<h2>{t('uploads.settings_title')}</h2>
|
|
<div className={u.settingsGrid}>
|
|
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
|
|
<Field
|
|
label={t('uploads.set_per_user')}
|
|
value={form.perUser}
|
|
hint={t('uploads.set_per_user_hint')}
|
|
onChange={(v) => setForm({ ...form, perUser: v })}
|
|
inputMode="decimal"
|
|
/>
|
|
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
|
|
<label>
|
|
<span className={u.settingsLabel}>{t('uploads.set_source')}</span>
|
|
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
|
|
<option value="">{t('uploads.set_source_auto')}</option>
|
|
{sources.data
|
|
?.filter((x) => x.accepts_uploads)
|
|
.map((x) => (
|
|
<option key={x.id} value={x.id}>
|
|
{x.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div>
|
|
<label className={u.settingsCheck}>
|
|
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
|
|
<span>{t('uploads.set_upgrade')}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<p className={s.note}>{t('uploads.set_note')}</p>
|
|
<div className={s.toolbar}>
|
|
<span style={{ flex: 1 }} />
|
|
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
|
|
{t('uploads.save')}
|
|
</Button>
|
|
</div>
|
|
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
|
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function toForm(st: Settings) {
|
|
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
|
|
return {
|
|
queue: gb(st.queue_limit_bytes),
|
|
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
|
|
maxFile: gb(st.max_file_bytes),
|
|
source: st.default_source_id ?? '',
|
|
upgrade: st.allow_quality_upgrade,
|
|
};
|
|
}
|