Files
pipu-music/apps/web/src/features/uploads/QueueRows.tsx
T
mrleo1nid 5876ae29cc
ci / module boundaries / boundaries (push) Successful in 19s
ci / ts / ts (packages + web) (push) Failing after 6m0s
feat(uploads): show artist and album in batch headers
2026-10-29 22:28:42 +03:00

216 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}