ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
154 lines
5.7 KiB
TypeScript
154 lines
5.7 KiB
TypeScript
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
|
import { editIcon, refreshIcon } from '../../shared/ui/icons';
|
|
import { needsFormatAttention } from './files';
|
|
import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button, cx, Icon } from '../../shared/ui/ui';
|
|
import s from '../../pages/page.module.css';
|
|
import u from '../../pages/uploads/uploads.module.css';
|
|
export 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>
|
|
);
|
|
}
|