216 lines
10 KiB
TypeScript
216 lines
10 KiB
TypeScript
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
||
import { chevronRightIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
|
||
import { useId, useState } from 'react';
|
||
import { needsFormatAttention } from './files';
|
||
import { AudioPreview } from './AudioPreview';
|
||
import { batchSummary } from './batchSummary';
|
||
import { BatchRelease, type ReleaseGroup } from './BatchRelease';
|
||
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 [collapsed, setCollapsed] = useState(true);
|
||
const [releaseGroups, setReleaseGroups] = useState<ReleaseGroup[]>([]);
|
||
const contentId = useId();
|
||
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);
|
||
const summary = batchSummary(files);
|
||
const metadata = first.metadata;
|
||
const albumArtist = metadata?.albumartist || metadata?.artist || '';
|
||
const singleAlbum = releaseGroups.length === 1 ? releaseGroups[0]
|
||
: releaseGroups.length === 0 && metadata?.album && files.every((file) =>
|
||
file.metadata?.album.trim().toLowerCase() === metadata.album.trim().toLowerCase()
|
||
&& (file.metadata.albumartist || file.metadata.artist || '').trim().toLowerCase() === albumArtist.trim().toLowerCase())
|
||
? { artist: albumArtist, album: metadata.album } : undefined;
|
||
const albumTitle = singleAlbum?.album && singleAlbum.album.trim().toLowerCase() !== 'unknown album'
|
||
? [singleAlbum.artist.trim().toLowerCase() === 'unknown artist' ? '' : singleAlbum.artist, singleAlbum.album].filter(Boolean).join(' — ')
|
||
: undefined;
|
||
return (
|
||
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
|
||
<div className={u.batchHead}>
|
||
<Button variant="ghost" className={u.batchToggle} aria-label={t(collapsed ? 'uploads.expand_batch' : 'uploads.collapse_batch')} title={t(collapsed ? 'uploads.expand_batch' : 'uploads.collapse_batch')} aria-expanded={!collapsed} aria-controls={contentId} onClick={() => setCollapsed((value) => !value)}>
|
||
<Icon src={chevronRightIcon} size={20} className={!collapsed ? u.batchExpanded : undefined} />
|
||
</Button>
|
||
{selectable && (
|
||
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
|
||
)}
|
||
{albumTitle ? <div className={u.batchIdentity}>
|
||
<strong title={albumTitle}>{albumTitle}</strong>
|
||
<span>{first.author.username}</span>
|
||
</div> : <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>
|
||
<span>{summary.formats.map(([format, count]) => `${count} × ${format || t('uploads.format_unknown')}`).join(' · ')}</span>
|
||
{summary.quality && <span>{summary.quality}</span>}
|
||
<span className={cx(s.badge, first.batch_has_cover ? u.tone_info : u.tone_muted)}>
|
||
{t(first.batch_has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}
|
||
</span>
|
||
<span style={{ flex: 1 }} />
|
||
{selectable && <BatchRelease files={files} onGroupsChange={setReleaseGroups} />}
|
||
{selectable && (
|
||
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
|
||
{t('uploads.edit_batch')}
|
||
</Button>
|
||
)}
|
||
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
|
||
</div>
|
||
<div id={contentId} hidden={collapsed}>
|
||
{(releaseGroups.length > 1 ? [
|
||
...releaseGroups.map((group) => ({ group, rows: files.filter((f) => group.file_ids.includes(f.id)) })),
|
||
{ group: undefined, rows: files.filter((f) => !releaseGroups.some((g) => g.file_ids.includes(f.id))) },
|
||
] : [{ group: undefined, rows: files }]).filter((part) => part.rows.length > 0).map((part) => <div key={part.group?.id ?? 'other'}>
|
||
{part.group && <div className={u.releaseHead}>
|
||
{selectable && <input type="checkbox" aria-label={t('release_lookup.select_group', { album: part.group.album })} checked={part.rows.every((f) => selected.has(f.id))} onChange={(e) => onToggle(part.rows.map((f) => f.id), e.target.checked)} />}
|
||
<strong>{part.group.artist} — {part.group.album}</strong>
|
||
<span>{t('release_lookup.tracks_total', { total: part.group.file_ids.length })}</span>
|
||
<span>{t(part.group.has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}</span>
|
||
</div>}
|
||
{part.rows.map((f) => (
|
||
<QueueRow
|
||
key={f.id}
|
||
file={f}
|
||
active={!collapsed}
|
||
selectable={selectable}
|
||
checked={selected.has(f.id)}
|
||
onCheck={(on) => onToggle([f.id], on)}
|
||
onEdit={() => onEdit(f)}
|
||
onRetry={() => onRetry(f.id)}
|
||
/>
|
||
))}
|
||
</div>)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function QueueRow({
|
||
file: f,
|
||
active,
|
||
selectable,
|
||
checked,
|
||
onCheck,
|
||
onEdit,
|
||
onRetry,
|
||
}: {
|
||
file: UploadFile;
|
||
active: boolean;
|
||
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>
|
||
{reason && (!editable || f.target_rel_path) && <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 && (
|
||
<AudioPreview url={f.stream_url} name={f.original_name} active={active} />
|
||
)}
|
||
{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>
|
||
{(editable || f.target_rel_path) && (
|
||
<div className={u.placement} role="group" aria-label={t('uploads.destination')}>
|
||
<span className={u.placementLabel}>{t(f.status === 'imported' ? 'uploads.destination_imported' : 'uploads.destination')}</span>
|
||
<strong>{f.target_source_name ?? t(f.target_source_id ? 'uploads.target_selected' : 'uploads.target_not_selected')}</strong>
|
||
{f.target_rel_path ? (
|
||
<code className={u.target} title={f.target_storage_path ?? undefined}>{f.target_rel_path}</code>
|
||
) : (
|
||
<>
|
||
<span className={u.warn}>{t('uploads.target_path_missing')}</span>
|
||
{reason && <span className={u.warn}>{reason}</span>}
|
||
<Button variant="secondary" onClick={onEdit}>
|
||
{t(f.target_source_id ? 'uploads.fix_destination' : 'uploads.choose_destination')}
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|