import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { Badge, Button, Callout, Card, IconButton, ScrollArea, Spinner, TextField } from '@olly/modern-sk'; import { useApplyMetadataMutation, useEnrichTrackMutation, useGetTrackQuery, useLazyGetMetadataMatchesQuery, } from '../../api/endpoints/library'; import type { MetadataMatch } from '../../api/types'; import { LoadingSkeleton } from '../../components/common/LoadingSkeleton'; import { ErrorState } from '../../components/common/ErrorState'; import { Placeholder } from '../../components/common/Placeholder'; interface Props { /** Single-track editor vs. batch editor — both A7, same scaffold. */ batch?: boolean; } /** Editable fields, kept as strings while in the form — parsed on save. */ interface FormState { title: string; artistName: string; albumTitle: string; year: string; genre: string; trackNumber: string; } const EMPTY_FORM: FormState = { title: '', artistName: '', albumTitle: '', year: '', genre: '', trackNumber: '', }; const labelStyle: React.CSSProperties = { display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)', }; function fieldStyle(): React.CSSProperties { return { width: '100%' }; } /** * `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID * match picker with a current-vs-proposed diff. `/metadata/batch` is deferred. */ export function MetadataEditorPage({ batch = false }: Props) { const { t } = useTranslation(); if (batch) { return ; } return ; } function SingleTrackEditor() { const { t } = useTranslation(); const navigate = useNavigate(); const { trackId } = useParams<{ trackId: string }>(); const trackQuery = useGetTrackQuery(trackId ?? '', { skip: !trackId }); const [findMatches, matchesResult] = useLazyGetMetadataMatchesQuery(); const [applyMetadata, applyResult] = useApplyMetadataMutation(); const [enrichTrack, enrichResult] = useEnrichTrackMutation(); const [form, setForm] = useState(EMPTY_FORM); const [initialized, setInitialized] = useState(false); const [selectedMatch, setSelectedMatch] = useState(null); // Seed the form from the loaded track exactly once — afterwards it's the // user's edit buffer and shouldn't be clobbered by refetches. useEffect(() => { if (initialized || !trackQuery.data) return; const track = trackQuery.data; setForm({ title: track.title, artistName: track.artistName, albumTitle: track.albumTitle, year: track.year != null ? String(track.year) : '', genre: track.genre ?? '', trackNumber: track.trackNumber != null ? String(track.trackNumber) : '', }); setInitialized(true); }, [initialized, trackQuery.data]); if (!trackId) { return ; } if (trackQuery.isLoading || !initialized) { return (
); } if (trackQuery.isError || !trackQuery.data) { return ( trackQuery.refetch()} /> ); } const track = trackQuery.data; const updateField = (key: keyof FormState) => (value: string) => setForm((prev) => ({ ...prev, [key]: value })); const applyMatch = (match: MetadataMatch) => { setForm((prev) => ({ ...prev, title: match.title ?? prev.title, artistName: match.artist ?? prev.artistName, albumTitle: match.album ?? prev.albumTitle, year: match.year != null ? String(match.year) : prev.year, })); setSelectedMatch(null); }; const handleSave = async () => { await applyMetadata({ trackId, edit: { title: form.title.trim() || undefined, artistName: form.artistName.trim() || undefined, albumTitle: form.albumTitle.trim() || undefined, year: form.year.trim() ? Number(form.year) : undefined, genre: form.genre.trim() || undefined, trackNumber: form.trackNumber.trim() ? Number(form.trackNumber) : undefined, }, }).unwrap(); }; return (
navigate(-1)} aria-label={t('common.back')} > ←

{t('pages.metadata')}

{track.artistName} · {track.title}

{applyResult.isSuccess && ( {t('metadataEditor.saved')} )} {applyResult.isError && ( {t('metadataEditor.saveError')} )}
updateField('title')(e.target.value)} />
updateField('artistName')(e.target.value)} />
updateField('albumTitle')(e.target.value)} />
updateField('year')(e.target.value)} />
updateField('trackNumber')(e.target.value)} />
updateField('genre')(e.target.value)} />
{t('metadataEditor.autoEnrich.title')}
{t('metadataEditor.autoEnrich.hint')}
{enrichResult.isSuccess && ( {t('metadataEditor.autoEnrich.enqueued')} )} {matchesResult.isError && ( {t('metadataEditor.autoEnrich.error')} )} {matchesResult.isSuccess && matchesResult.data && ( matchesResult.data.length === 0 ? ( {t('metadataEditor.autoEnrich.noMatches')} ) : (
{matchesResult.data.map((match) => ( setSelectedMatch(match)} /> ))}
) )} {selectedMatch && ( applyMatch(selectedMatch)} onCancel={() => setSelectedMatch(null)} /> )}
); } function MatchRow({ match, onUse }: { match: MetadataMatch; onUse: () => void }) { const { t } = useTranslation(); const pct = Math.round(match.score * 100); return (
= 80 ? 'lime' : pct >= 50 ? 'outline' : 'neutral'}> {pct}%
{match.title ?? t('metadataEditor.matches.unknownTitle')}
{[match.artist, match.album, match.year] .filter((v) => v !== undefined && v !== null && v !== '') .join(' · ')}
); } interface DiffRowDef { key: 'title' | 'artistName' | 'albumTitle' | 'year'; label: string; current: string; proposed?: string; } function DiffView({ current, proposed, onApply, onCancel, }: { current: FormState; proposed: MetadataMatch; onApply: () => void; onCancel: () => void; }) { const { t } = useTranslation(); const rows: DiffRowDef[] = [ { key: 'title', label: t('metadataEditor.fields.title'), current: current.title, proposed: proposed.title, }, { key: 'artistName', label: t('metadataEditor.fields.artist'), current: current.artistName, proposed: proposed.artist, }, { key: 'albumTitle', label: t('metadataEditor.fields.album'), current: current.albumTitle, proposed: proposed.album, }, { key: 'year', label: t('metadataEditor.fields.year'), current: current.year, proposed: proposed.year != null ? String(proposed.year) : undefined, }, ]; const changed = rows.filter( (row) => row.proposed !== undefined && row.proposed !== row.current, ); return (
{t('metadataEditor.diff.title')}
{changed.length === 0 ? (
{t('metadataEditor.diff.noChanges')}
) : (
{changed.map((row) => (
{row.label}: {row.current || '—'} {' → '} {row.proposed || '—'}
))}
)}
); }