Files
pipu-music/apps/web/src/features/uploads/QueueRows.tsx
T
mrleo1nid e158558da3
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
refactor: isolate playback and upload use cases
2026-10-22 13:52:16 +03:00

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>
);
}