split subtitles tab into ST Extract (browse/extract items) and ST Manager (language summary, title harmonization)
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m1s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 17:01:50 +01:00
parent 38b0faf55a
commit d422b0a79b
5 changed files with 301 additions and 302 deletions

View File

@@ -0,0 +1,287 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { FilterTabs } from '~/shared/components/ui/filter-tabs';
import { langName } from '~/shared/lib/lang';
import type React from 'react';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SubListItem {
id: number; name: string; type: string; series_name: string | null;
season_number: number | null; episode_number: number | null;
year: number | null; original_language: string | null;
subs_extracted: number | null; sub_count: number; file_count: number;
}
interface SubSeriesGroup {
series_key: string; series_name: string; original_language: string | null;
season_count: number; episode_count: number;
not_extracted_count: number; extracted_count: number; no_subs_count: number;
}
interface SubListData {
movies: SubListItem[];
series: SubSeriesGroup[];
filter: string;
totalCounts: Record<string, number>;
}
interface SeasonGroup {
season: number | null;
episodes: SubListItem[];
extractedCount: number; notExtractedCount: number; noSubsCount: number;
}
const FILTER_TABS = [
{ key: 'all', label: 'All' },
{ key: 'not_extracted', label: 'Not Extracted' },
{ key: 'extracted', label: 'Extracted' },
{ key: 'no_subs', label: 'No Subtitles' },
];
// ─── Table helpers ────────────────────────────────────────────────────────────
const Th = ({ children }: { children?: React.ReactNode }) => (
<th className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">
{children}
</th>
);
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ''}`}>{children}</td>
);
function subStatus(item: SubListItem): 'extracted' | 'not_extracted' | 'no_subs' {
if (item.sub_count === 0) return 'no_subs';
return item.subs_extracted ? 'extracted' : 'not_extracted';
}
function StatusBadge({ item }: { item: SubListItem }) {
const s = subStatus(item);
if (s === 'extracted') return <Badge variant="keep">extracted</Badge>;
if (s === 'not_extracted') return <Badge variant="pending">pending</Badge>;
return <Badge variant="noop">no subs</Badge>;
}
function StatusPills({ g }: { g: SubSeriesGroup }) {
return (
<span className="inline-flex flex-wrap gap-1 items-center">
{g.extracted_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">{g.extracted_count} extracted</span>}
{g.not_extracted_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">{g.not_extracted_count} pending</span>}
{g.no_subs_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{g.no_subs_count} no subs</span>}
</span>
);
}
// ─── Extraction bar ──────────────────────────────────────────────────────────
function ExtractionBar({ count, onExtract }: { count: number; onExtract: () => void }) {
const [extracting, setExtracting] = useState(false);
const [result, setResult] = useState('');
if (count === 0) return null;
const handleExtract = async () => {
setExtracting(true);
setResult('');
try {
const r = await api.post<{ ok: boolean; queued: number }>('/api/subtitles/extract-all');
setResult(`Queued ${r.queued} extraction job${r.queued !== 1 ? 's' : ''}.`);
onExtract();
} catch (e) { setResult(`Error: ${e}`); }
setExtracting(false);
};
return (
<div className="flex items-center gap-3 px-4 py-3 mb-4 rounded-lg border border-amber-200 bg-amber-50">
<span className="text-sm font-medium text-amber-900">
{count} item{count !== 1 ? 's have' : ' has'} embedded subtitles ready to extract
</span>
<Button size="sm" onClick={handleExtract} disabled={extracting}>
{extracting ? 'Queuing...' : 'Extract All'}
</Button>
{result && <span className="text-sm text-gray-600">{result}</span>}
</div>
);
}
// ─── Series row (collapsible) ─────────────────────────────────────────────────
function SeriesRow({ g }: { g: SubSeriesGroup }) {
const [open, setOpen] = useState(false);
const urlKey = encodeURIComponent(g.series_key);
const [seasons, setSeasons] = useState<SeasonGroup[] | null>(null);
const toggle = async () => {
if (!open && seasons === null) {
const data = await api.get<{ seasons: SeasonGroup[] }>(`/api/subtitles/series/${urlKey}/episodes`);
setSeasons(data.seasons);
}
setOpen((v) => !v);
};
return (
<tbody>
<tr className="cursor-pointer hover:bg-gray-50" onClick={toggle}>
<td className="py-1.5 px-2 border-b border-gray-100 font-medium">
<span className={`inline-flex items-center justify-center w-4 h-4 rounded text-[0.65rem] text-gray-500 transition-transform ${open ? 'rotate-90' : ''}`}></span>
{' '}<strong>{g.series_name}</strong>
</td>
<Td>{langName(g.original_language)}</Td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.season_count}</td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_count}</td>
<Td><StatusPills g={g} /></Td>
</tr>
{open && seasons && (
<tr>
<td colSpan={5} className="p-0 border-b border-gray-100">
<table className="w-full border-collapse">
<tbody>
{seasons.map((s) => (
<>
<tr key={`season-${s.season}`} className="bg-gray-50">
<td colSpan={5} className="text-[0.7rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-0.5 px-8 border-b border-gray-100">
Season {s.season ?? '?'}
<span className="ml-3 inline-flex gap-1">
{s.extractedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">{s.extractedCount} extracted</span>}
{s.notExtractedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">{s.notExtractedCount} pending</span>}
{s.noSubsCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">{s.noSubsCount} no subs</span>}
</span>
</td>
</tr>
{s.episodes.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] pl-10">
<span className="text-gray-400 font-mono text-xs">E{String(item.episode_number ?? 0).padStart(2, '0')}</span>
{' '}
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
<span className="truncate inline-block max-w-xs align-bottom">{item.name}</span>
</Link>
</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] font-mono text-xs">{item.sub_count}</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] font-mono text-xs">{item.file_count}</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
<StatusBadge item={item} />
</td>
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap">
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
Detail
</Link>
</td>
</tr>
))}
</>
))}
</tbody>
</table>
</td>
</tr>
)}
</tbody>
);
}
// ─── Cache ────────────────────────────────────────────────────────────────────
const cache = new Map<string, SubListData>();
// ─── Main page ────────────────────────────────────────────────────────────────
export function SubtitleExtractPage() {
const { filter } = useSearch({ from: '/review/subtitles/extract' });
const navigate = useNavigate();
const [data, setData] = useState<SubListData | null>(cache.get(filter) ?? null);
const [loading, setLoading] = useState(!cache.has(filter));
const [embeddedCount, setEmbeddedCount] = useState<number | null>(null);
const load = () => {
if (!cache.has(filter)) setLoading(true);
api.get<SubListData>(`/api/subtitles?filter=${filter}`)
.then((d) => { cache.set(filter, d); setData(d); })
.catch(() => {})
.finally(() => setLoading(false));
};
const loadEmbedded = () => {
api.get<{ embeddedCount: number }>('/api/subtitles/summary')
.then((d) => setEmbeddedCount(d.embeddedCount))
.catch(() => {});
};
useEffect(() => { load(); loadEmbedded(); }, [filter]);
const refresh = () => { cache.clear(); load(); loadEmbedded(); };
return (
<div>
<h1 className="text-xl font-bold mb-4">Subtitle Extraction</h1>
{embeddedCount !== null && (
<ExtractionBar count={embeddedCount} onExtract={refresh} />
)}
<FilterTabs
tabs={FILTER_TABS}
filter={filter}
totalCounts={data?.totalCounts ?? {}}
onFilterChange={(key) => navigate({ to: '/review/subtitles/extract', search: { filter: key } as never })}
/>
{loading && !data && <div className="text-gray-400 py-4 text-center text-sm">Loading...</div>}
{data && !loading && (
<>
{data.movies.length === 0 && data.series.length === 0 && (
<p className="text-gray-500 text-sm">No items match this filter.</p>
)}
{data.movies.length > 0 && (
<>
<div className="flex items-center gap-2 mt-3 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500">
Movies <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{data.movies.length}</span>
</div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]">
<thead><tr><Th>Name</Th><Th>Lang</Th><Th>Subs</Th><Th>Files</Th><Th>Status</Th></tr></thead>
<tbody>
{data.movies.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<Td>
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>{item.name}</span>
</Link>
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
</Td>
<Td>{langName(item.original_language)}</Td>
<Td className="font-mono text-xs">{item.sub_count}</Td>
<Td className="font-mono text-xs">{item.file_count}</Td>
<Td><StatusBadge item={item} /></Td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{data.series.length > 0 && (
<>
<div className={`flex items-center gap-2 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500 ${data.movies.length > 0 ? 'mt-5' : 'mt-0'}`}>
TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{data.series.length}</span>
</div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]">
<thead><tr><Th>Series</Th><Th>Lang</Th><Th>S</Th><Th>Ep</Th><Th>Status</Th></tr></thead>
{data.series.map((g) => <SeriesRow key={g.series_key} g={g} />)}
</table>
</div>
</>
)}
</>
)}
</div>
);
}

View File

@@ -1,10 +1,8 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate, useSearch } from '@tanstack/react-router';
import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { FilterTabs } from '~/shared/components/ui/filter-tabs';
import { langName } from '~/shared/lib/lang';
import type React from 'react';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -29,39 +27,6 @@ interface SummaryData {
keepLanguages: string[];
}
interface SubListItem {
id: number; name: string; type: string; series_name: string | null;
season_number: number | null; episode_number: number | null;
year: number | null; original_language: string | null;
subs_extracted: number | null; sub_count: number; file_count: number;
}
interface SubSeriesGroup {
series_key: string; series_name: string; original_language: string | null;
season_count: number; episode_count: number;
not_extracted_count: number; extracted_count: number; no_subs_count: number;
}
interface SubListData {
movies: SubListItem[];
series: SubSeriesGroup[];
filter: string;
totalCounts: Record<string, number>;
}
interface SeasonGroup {
season: number | null;
episodes: SubListItem[];
extractedCount: number; notExtractedCount: number; noSubsCount: number;
}
const FILTER_TABS = [
{ key: 'all', label: 'All' },
{ key: 'not_extracted', label: 'Not Extracted' },
{ key: 'extracted', label: 'Extracted' },
{ key: 'no_subs', label: 'No Subtitles' },
];
// ─── Table helpers ────────────────────────────────────────────────────────────
const Th = ({ children }: { children?: React.ReactNode }) => (
@@ -74,60 +39,6 @@ const Td = ({ children, className }: { children?: React.ReactNode; className?: s
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ''}`}>{children}</td>
);
function subStatus(item: SubListItem): 'extracted' | 'not_extracted' | 'no_subs' {
if (item.sub_count === 0) return 'no_subs';
return item.subs_extracted ? 'extracted' : 'not_extracted';
}
function StatusBadge({ item }: { item: SubListItem }) {
const s = subStatus(item);
if (s === 'extracted') return <Badge variant="keep">extracted</Badge>;
if (s === 'not_extracted') return <Badge variant="pending">pending</Badge>;
return <Badge variant="noop">no subs</Badge>;
}
function StatusPills({ g }: { g: SubSeriesGroup }) {
return (
<span className="inline-flex flex-wrap gap-1 items-center">
{g.extracted_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">{g.extracted_count} extracted</span>}
{g.not_extracted_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">{g.not_extracted_count} pending</span>}
{g.no_subs_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{g.no_subs_count} no subs</span>}
</span>
);
}
// ─── Extraction status bar ────────────────────────────────────────────────────
function ExtractionBar({ count, onExtract }: { count: number; onExtract: () => void }) {
const [extracting, setExtracting] = useState(false);
const [result, setResult] = useState('');
if (count === 0) return null;
const handleExtract = async () => {
setExtracting(true);
setResult('');
try {
const r = await api.post<{ ok: boolean; queued: number }>('/api/subtitles/extract-all');
setResult(`Queued ${r.queued} extraction job${r.queued !== 1 ? 's' : ''}.`);
onExtract();
} catch (e) { setResult(`Error: ${e}`); }
setExtracting(false);
};
return (
<div className="flex items-center gap-3 px-4 py-3 mb-4 rounded-lg border border-amber-200 bg-amber-50">
<span className="text-sm font-medium text-amber-900">
{count} item{count !== 1 ? 's have' : ' has'} embedded subtitles
</span>
<Button size="sm" onClick={handleExtract} disabled={extracting}>
{extracting ? 'Queuing...' : 'Extract All'}
</Button>
{result && <span className="text-sm text-gray-600">{result}</span>}
</div>
);
}
// ─── Language summary table ───────────────────────────────────────────────────
function variantLabel(v: string): string {
@@ -143,7 +54,6 @@ function LanguageSummary({ categories, keepLanguages, onDelete }: {
}) {
const keepSet = new Set(keepLanguages);
// Initialize checked state: languages in keepLanguages are checked
const [checked, setChecked] = useState<Record<string, boolean>>(() => {
const init: Record<string, boolean> = {};
for (const cat of categories) {
@@ -318,199 +228,9 @@ function TitleHarmonization({ titles, onNormalize }: {
);
}
// ─── Series row (collapsible) ─────────────────────────────────────────────────
function SeriesRow({ g }: { g: SubSeriesGroup }) {
const [open, setOpen] = useState(false);
const urlKey = encodeURIComponent(g.series_key);
const [seasons, setSeasons] = useState<SeasonGroup[] | null>(null);
const toggle = async () => {
if (!open && seasons === null) {
const data = await api.get<{ seasons: SeasonGroup[] }>(`/api/subtitles/series/${urlKey}/episodes`);
setSeasons(data.seasons);
}
setOpen((v) => !v);
};
return (
<tbody>
<tr className="cursor-pointer hover:bg-gray-50" onClick={toggle}>
<td className="py-1.5 px-2 border-b border-gray-100 font-medium">
<span className={`inline-flex items-center justify-center w-4 h-4 rounded text-[0.65rem] text-gray-500 transition-transform ${open ? 'rotate-90' : ''}`}></span>
{' '}<strong>{g.series_name}</strong>
</td>
<Td>{langName(g.original_language)}</Td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.season_count}</td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_count}</td>
<Td><StatusPills g={g} /></Td>
</tr>
{open && seasons && (
<tr>
<td colSpan={5} className="p-0 border-b border-gray-100">
<table className="w-full border-collapse">
<tbody>
{seasons.map((s) => (
<>
<tr key={`season-${s.season}`} className="bg-gray-50">
<td colSpan={5} className="text-[0.7rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-0.5 px-8 border-b border-gray-100">
Season {s.season ?? '?'}
<span className="ml-3 inline-flex gap-1">
{s.extractedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">{s.extractedCount} extracted</span>}
{s.notExtractedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">{s.notExtractedCount} pending</span>}
{s.noSubsCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">{s.noSubsCount} no subs</span>}
</span>
</td>
</tr>
{s.episodes.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] pl-10">
<span className="text-gray-400 font-mono text-xs">E{String(item.episode_number ?? 0).padStart(2, '0')}</span>
{' '}
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
<span className="truncate inline-block max-w-xs align-bottom">{item.name}</span>
</Link>
</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] font-mono text-xs">{item.sub_count}</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] font-mono text-xs">{item.file_count}</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
<StatusBadge item={item} />
</td>
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap">
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
Detail
</Link>
</td>
</tr>
))}
</>
))}
</tbody>
</table>
</td>
</tr>
)}
</tbody>
);
}
// ─── Browse items (collapsible) ───────────────────────────────────────────────
function BrowseItems({ movieCount, seriesCount }: { movieCount: number; seriesCount: number }) {
const [open, setOpen] = useState(false);
const { filter } = useSearch({ from: '/review/subtitles/' });
const navigate = useNavigate();
const [data, setData] = useState<SubListData | null>(browseCache.get(filter) ?? null);
const [loading, setLoading] = useState(false);
const load = () => {
setLoading(true);
api.get<SubListData>(`/api/subtitles?filter=${filter}`)
.then((d) => { browseCache.set(filter, d); setData(d); })
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => {
const cached = browseCache.get(filter);
if (cached) setData(cached);
if (open) load();
}, [filter, open]);
const toggle = () => {
if (!open && !data) load();
setOpen((v) => !v);
};
return (
<div className="mb-4">
<button
type="button"
onClick={toggle}
className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-gray-500 cursor-pointer bg-transparent border-0 p-0 hover:text-gray-700"
>
<span className={`inline-flex items-center justify-center w-4 h-4 rounded text-[0.65rem] text-gray-500 transition-transform ${open ? 'rotate-90' : ''}`}></span>
Browse Individual Items
<span className="text-xs font-normal normal-case text-gray-400">
({movieCount} movie{movieCount !== 1 ? 's' : ''}, {seriesCount} series)
</span>
</button>
{open && (
<div className="mt-3">
{/* Filter tabs */}
<FilterTabs
tabs={FILTER_TABS}
filter={filter}
totalCounts={data?.totalCounts ?? {}}
onFilterChange={(key) => navigate({ to: '/review/subtitles', search: { filter: key } as never })}
/>
{loading && <div className="text-gray-400 py-4 text-center text-sm">Loading...</div>}
{data && !loading && (
<>
{data.movies.length === 0 && data.series.length === 0 && (
<p className="text-gray-500 text-sm">No items match this filter.</p>
)}
{/* Movies */}
{data.movies.length > 0 && (
<>
<div className="flex items-center gap-2 mt-3 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500">
Movies <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{data.movies.length}</span>
</div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]">
<thead><tr><Th>Name</Th><Th>Lang</Th><Th>Subs</Th><Th>Files</Th><Th>Status</Th></tr></thead>
<tbody>
{data.movies.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<Td>
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>{item.name}</span>
</Link>
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
</Td>
<Td>{langName(item.original_language)}</Td>
<Td className="font-mono text-xs">{item.sub_count}</Td>
<Td className="font-mono text-xs">{item.file_count}</Td>
<Td><StatusBadge item={item} /></Td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{/* TV Series */}
{data.series.length > 0 && (
<>
<div className={`flex items-center gap-2 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500 ${data.movies.length > 0 ? 'mt-5' : 'mt-0'}`}>
TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{data.series.length}</span>
</div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]">
<thead><tr><Th>Series</Th><Th>Lang</Th><Th>S</Th><Th>Ep</Th><Th>Status</Th></tr></thead>
{data.series.map((g) => <SeriesRow key={g.series_key} g={g} />)}
</table>
</div>
</>
)}
</>
)}
</div>
)}
</div>
);
}
// ─── Caches ───────────────────────────────────────────────────────────────────
// ─── Cache ────────────────────────────────────────────────────────────────────
let summaryCache: SummaryData | null = null;
const browseCache = new Map<string, SubListData>();
// ─── Main page ────────────────────────────────────────────────────────────────
@@ -530,38 +250,23 @@ export function SubtitleListPage() {
const refresh = () => {
summaryCache = null;
browseCache.clear();
loadSummary();
};
if (loading && !summary) return <div className="text-gray-400 py-8 text-center">Loading...</div>;
if (!summary) return <div className="text-red-600">Failed to load subtitle summary.</div>;
// Count movies/series for browse section header
const totalMovies = summary.categories.reduce((sum, c) => sum + c.streamCount, 0);
const totalSeries = summary.categories.length;
return (
<div>
<h1 className="text-xl font-bold mb-4">Subtitle Manager</h1>
{/* Phase 1: Extraction */}
<ExtractionBar count={summary.embeddedCount} onExtract={refresh} />
{/* Phase 2: Language summary */}
<LanguageSummary
categories={summary.categories}
keepLanguages={summary.keepLanguages}
onDelete={refresh}
/>
{/* Title harmonization */}
<TitleHarmonization titles={summary.titles} onNormalize={refresh} />
{/* Browse items */}
<BrowseItems movieCount={totalMovies} seriesCount={totalSeries} />
</div>
);
}
import type React from 'react';

View File

@@ -52,7 +52,8 @@ function RootLayout() {
<NavLink to="/scan">Scan</NavLink>
<NavLink to="/paths">Paths</NavLink>
<NavLink to="/review/audio">Audio</NavLink>
<NavLink to="/review/subtitles">Subtitles</NavLink>
<NavLink to="/review/subtitles/extract">ST Extract</NavLink>
<NavLink to="/review/subtitles">ST Manager</NavLink>
<NavLink to="/execute">Execute</NavLink>
</div>
<div className="flex-1" />

View File

@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { SubtitleExtractPage } from '~/features/subtitles/SubtitleExtractPage';
export const Route = createFileRoute('/review/subtitles/extract')({
validateSearch: z.object({
filter: z.enum(['all', 'not_extracted', 'extracted', 'no_subs']).default('not_extracted'),
}),
component: SubtitleExtractPage,
});

View File

@@ -1,10 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { SubtitleListPage } from '~/features/subtitles/SubtitleListPage';
export const Route = createFileRoute('/review/subtitles/')({
validateSearch: z.object({
filter: z.enum(['all', 'not_extracted', 'extracted', 'no_subs']).default('all'),
}),
component: SubtitleListPage,
});