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
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:
287
src/features/subtitles/SubtitleExtractPage.tsx
Normal file
287
src/features/subtitles/SubtitleExtractPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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" />
|
||||
|
||||
10
src/routes/review/subtitles/extract.tsx
Normal file
10
src/routes/review/subtitles/extract.tsx
Normal 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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user