Files
aurora-curator/src/components/RenamePanel.tsx
T

883 lines
32 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { invoke } from "@tauri-apps/api/core";
import {
ClearIcon,
DownIcon,
FetchIcon,
GearIcon,
HistoryIcon,
KeyIcon,
LoadIcon,
PresetsIcon,
RemoveIcon,
UpIcon,
} from "./Icons";
interface Item {
id: string;
originalName: string;
ext: string;
bindings: Record<string, string> | null; // null = no data to preview with (rendered muted)
isSample: boolean;
}
interface FileEntry {
path: string;
name: string; // filename without extension
ext: string;
}
// Result of the local, filename-only season/episode matcher (renamr-core's
// `matching` module, exposed via the `guess_episode` Tauri command). No network calls -
// this only recognizes patterns already present in the filename text itself.
interface EpisodeGuess {
seriesName: string;
season: number;
episode: number;
}
function pad2(n: number): string {
return n.toString().padStart(2, "0");
}
// One row of the on-disk rename plan shown in the confirmation modal before anything
// touches the filesystem. `id`/`source` are the file's current full path.
interface RenamePlanEntry {
id: string;
source: string;
currentName: string;
newName: string;
}
// Mirrors renamr-core's `RenameOutcome` (serde-tagged on "status"). Conflict policy is
// fixed at skip - a colliding target is never overwritten, just reported.
type RenameOutcome =
| { status: "renamed"; newPath: string }
| { status: "unchanged" }
| { status: "skippedConflict"; existingPath: string }
| { status: "failed"; error: string };
interface RenameResult {
source: string;
outcome: RenameOutcome;
}
// Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against
// real TMDB data. Needs a series name + season/episode to search with - exactly what
// Match already produces offline, which is why Fetch Data requires Match to have run
// first rather than doing its own from-scratch identification.
interface FetchQuery {
id: string;
seriesQuery: string;
season: number;
episode: number;
}
// Mirrors renamr-core's `FetchOutcome` (serde-tagged on "status").
type FetchOutcome =
| { status: "found"; seriesName: string; episodeTitle: string; airDate: string | null }
| { status: "notFound" }
| { status: "failed"; error: string };
interface FetchResult {
id: string;
outcome: FetchOutcome;
}
function fileNameFromPath(path: string): string {
const parts = path.split(/[\\/]/);
return parts[parts.length - 1] ?? path;
}
// Shown until the user loads real files, so the two-pane layout and live naming-scheme
// preview are visible immediately. Item 4 demonstrates the "no data" muted row style.
// This is what full metadata-matched renaming will look like once the matching engine
// (see the porting roadmap) is ported — real loaded files use their own filename fields
// instead (see realFileBindings below) since there's no online identification yet.
const SAMPLE_ITEMS: Item[] = [
{
id: "sample-1",
originalName: "Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv",
ext: "mkv",
bindings: { n: "Breaking Bad", s00e00: "S05E14", t: "Ozymandias" },
isSample: true,
},
{
id: "sample-2",
originalName: "Breaking.Bad.S05E15.720p.HDTV.x264-KILLERS.mkv",
ext: "mkv",
bindings: { n: "Breaking Bad", s00e00: "S05E15", t: "Granite State" },
isSample: true,
},
{
id: "sample-3",
originalName: "The.Office.US.S03E01.DVDRip.XviD-SAiNTS.avi",
ext: "avi",
bindings: { n: "The Office (US)", s00e00: "S03E01", t: "Gay Witch Hunt" },
isSample: true,
},
{
id: "sample-4",
originalName: "random_video_file.mp4",
ext: "mp4",
bindings: null,
isSample: true,
},
];
const SAMPLE_EXPRESSION = "{n} - {s00e00} - {t}";
// The matching engine isn't ported yet, so real loaded files only have their own
// filename-derived fields to preview with (mirrors the original's plain FileNameFormatter,
// used whenever a file has no online match).
const REAL_FILE_EXPRESSION = "{name}";
function realFileToItem(entry: FileEntry): Item {
return {
id: entry.path,
originalName: entry.ext ? `${entry.name}.${entry.ext}` : entry.name,
ext: entry.ext,
bindings: { name: entry.name, ext: entry.ext },
isSample: false,
};
}
function ListButton({
icon,
label,
onClick,
disabled,
}: {
icon: JSX.Element;
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button className="list-icon-button" title={label} onClick={onClick} disabled={disabled}>
{icon}
</button>
);
}
function LoadMenuButton({ onFiles, onFolder }: { onFiles: () => void; onFolder: () => void }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onOutside);
return () => document.removeEventListener("mousedown", onOutside);
}, [open]);
return (
<div className="load-menu" ref={ref}>
<ListButton icon={<LoadIcon />} label="Load Files" onClick={() => setOpen((v) => !v)} />
{open && (
<div className="load-menu-popup">
<button
onClick={() => {
setOpen(false);
onFiles();
}}
>
Select Files…
</button>
<button
onClick={() => {
setOpen(false);
onFolder();
}}
>
Select Folder…
</button>
</div>
)}
</div>
);
}
export default function RenamePanel() {
const [expression, setExpression] = useState(SAMPLE_EXPRESSION);
const [editingFormat, setEditingFormat] = useState(false);
const [items, setItems] = useState<Item[]>(SAMPLE_ITEMS);
const [selected, setSelected] = useState<string | null>(SAMPLE_ITEMS[0].id);
const [computedNames, setComputedNames] = useState<Record<string, string>>({});
const [computeError, setComputeError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
// Non-null while the "confirm before touching disk" modal is open.
const [renamePlan, setRenamePlan] = useState<RenamePlanEntry[] | null>(null);
const [renamePlanSkipped, setRenamePlanSkipped] = useState(0);
const [renaming, setRenaming] = useState(false);
// TMDB token state for "Fetch Data". `null` = not checked yet, `false` = checked and
// nothing saved, `true` = a token is on file (the value itself never comes back to
// the frontend after saving - see Settings modal below).
const [hasTmdbToken, setHasTmdbToken] = useState<boolean | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
const [savingSettings, setSavingSettings] = useState(false);
const [fetching, setFetching] = useState(false);
useEffect(() => {
invoke<string | null>("get_tmdb_token")
.then((token) => setHasTmdbToken(!!token))
.catch(() => setHasTmdbToken(false));
}, []);
// Refs so the drag-and-drop listener (registered once on mount) always sees the
// latest expression/items instead of the stale values from its first render.
const expressionRef = useRef(expression);
useEffect(() => {
expressionRef.current = expression;
}, [expression]);
useEffect(() => {
let cancelled = false;
Promise.all(
items.map(async (item) => {
if (!item.bindings) return [item.id, null] as const;
const rendered = await invoke<string>("render_expression", {
expression,
bindings: item.bindings,
});
return [item.id, `${rendered}${item.ext ? `.${item.ext}` : ""}`] as const;
})
)
.then((entries) => {
if (cancelled) return;
const next: Record<string, string> = {};
for (const [id, name] of entries) {
if (name) next[id] = name;
}
setComputedNames(next);
setComputeError(null);
})
.catch((err) => {
if (cancelled) return;
setComputeError(String(err));
});
return () => {
cancelled = true;
};
}, [expression, items]);
const notImplemented = (feature: string) => {
setStatus(`${feature} isn't implemented yet — see the porting roadmap in the README.`);
window.clearTimeout((notImplemented as any)._t);
(notImplemented as any)._t = window.setTimeout(() => setStatus(null), 3200);
};
const addRealFiles = (entries: FileEntry[]) => {
if (entries.length === 0) return;
// Only auto-switch the naming scheme the first time real files replace the demo,
// and only if the user hasn't already customized it.
if (expressionRef.current === SAMPLE_EXPRESSION) {
setExpression(REAL_FILE_EXPRESSION);
}
setItems((prev) => {
const real = prev.filter((i) => !i.isSample);
const existing = new Set(real.map((i) => i.id));
const added = entries.map(realFileToItem).filter((i) => !existing.has(i.id));
const next = [...real, ...added];
if (added.length > 0) setSelected(added[0].id);
return next;
});
};
const loadFiles = () => {
invoke<FileEntry[]>("pick_files")
.then(addRealFiles)
.catch((err) => setStatus(`Couldn't open the file picker: ${err}`));
};
const loadFolder = () => {
invoke<FileEntry[]>("pick_folder")
.then(addRealFiles)
.catch((err) => setStatus(`Couldn't open the folder picker: ${err}`));
};
// OS drag-and-drop of files/folders onto the window.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
import("@tauri-apps/api/webview")
.then(({ getCurrentWebview }) =>
getCurrentWebview().onDragDropEvent((event) => {
if (event.payload.type === "drop") {
invoke<FileEntry[]>("expand_dropped_paths", { paths: event.payload.paths })
.then(addRealFiles)
.catch((err) => setStatus(`Couldn't read dropped files: ${err}`));
}
})
)
.then((fn) => {
if (cancelled) fn();
else unlisten = fn;
})
.catch(() => {
// Not running inside Tauri (e.g. a plain browser preview) - drag-and-drop
// just won't be available there; the Load button still works via invoke.
});
return () => {
cancelled = true;
unlisten?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Local, filename-based season/episode detection (renamr-core's `matching` module).
// Populates `{n}`/`{s00e00}`/`{season}`/`{episode}` bindings from patterns already
// present in the filename text - no network calls, no metadata lookup, so it's
// distinct from (and much cheaper than) "Fetch Data" online matching, which still
// isn't built.
const runMatching = async () => {
const targets = items.filter((i) => !i.isSample);
if (targets.length === 0) {
setStatus("Load some files first, then Match will scan their names for season/episode info.");
window.clearTimeout((runMatching as any)._t);
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 3200);
return;
}
const results = await Promise.all(
targets.map(async (item) => {
try {
const guess = await invoke<EpisodeGuess | null>("guess_episode", {
filename: item.originalName,
});
return [item.id, guess] as const;
} catch {
return [item.id, null] as const;
}
})
);
const guesses = new Map(results);
// Count matches from the results directly, not inside the setItems updater below:
// React doesn't run a functional updater synchronously, so a counter mutated in
// there wouldn't be ready yet for the status message/auto-upgrade check right after.
const matched = results.filter(([, guess]) => guess !== null).length;
setItems((prev) =>
prev.map((item) => {
const guess = guesses.get(item.id);
if (!guess) return item;
return {
...item,
bindings: {
...item.bindings,
n: guess.seriesName || item.bindings?.name || item.originalName,
s00e00: `S${pad2(guess.season)}E${pad2(guess.episode)}`,
season: String(guess.season),
episode: String(guess.episode),
},
};
})
);
// Showcase the newly-populated fields, but only the first time and only if the
// user hasn't already customized the naming scheme away from the plain-filename
// default.
if (expressionRef.current === REAL_FILE_EXPRESSION && matched > 0) {
setExpression("{n} - {s00e00}");
}
setStatus(
matched > 0
? `Matched season/episode on ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (filename-based, local only).`
: "No season/episode pattern recognized in the loaded filenames."
);
window.clearTimeout((runMatching as any)._t);
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 4000);
};
// Step 1 of Rename: build the plan (old path -> new filename) from the live preview
// that's already on screen, and open the confirmation modal. Nothing on disk is
// touched here - only `confirmRename` below actually calls the Rust rename command,
// and only after the user explicitly approves the plan they can see.
const prepareRename = () => {
const targets = items.filter((i) => !i.isSample);
if (targets.length === 0) {
setStatus("Load some real files first — Rename needs files on disk to work with.");
window.clearTimeout((prepareRename as any)._t);
(prepareRename as any)._t = window.setTimeout(() => setStatus(null), 3200);
return;
}
const plan: RenamePlanEntry[] = [];
let skippedNoPreview = 0;
for (const item of targets) {
const newName = computedNames[item.id];
if (!newName) {
skippedNoPreview += 1;
continue;
}
if (newName === item.originalName) continue; // already correct, nothing to do
plan.push({ id: item.id, source: item.id, currentName: item.originalName, newName });
}
if (plan.length === 0) {
setStatus(
skippedNoPreview > 0
? "Nothing to rename yet — the naming scheme couldn't be computed for those files (check the naming scheme for errors)."
: "Nothing to rename — the computed names already match the current filenames."
);
window.clearTimeout((prepareRename as any)._t);
(prepareRename as any)._t = window.setTimeout(() => setStatus(null), 4000);
return;
}
setRenamePlanSkipped(skippedNoPreview);
setRenamePlan(plan);
};
// Step 2: the user approved the plan shown in the modal - actually rename the files
// on disk via the Rust `execute_rename` command, then reconcile local state (a
// renamed file's id, which is its path, changes) and report the outcome.
const confirmRename = async () => {
if (!renamePlan) return;
setRenaming(true);
try {
const results = await invoke<RenameResult[]>("execute_rename", {
items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })),
});
const bySource = new Map(results.map((r) => [r.source, r] as const));
// Compute counts from `results` directly, not inside the setItems updater below:
// React doesn't run a functional updater synchronously, so counters mutated in
// there aren't ready yet when building the status message right after (this bit
// runMatching earlier - same fix applies here).
const renamed = results.filter((r) => r.outcome.status === "renamed").length;
const skipped = results.filter((r) => r.outcome.status === "skippedConflict").length;
const failed = results.filter((r) => r.outcome.status === "failed").length;
setItems((prev) =>
prev.map((item) => {
const result = bySource.get(item.id);
if (!result) return item;
if (result.outcome.status === "renamed") {
const newFileName = fileNameFromPath(result.outcome.newPath);
const [base, ext] = splitExt(newFileName);
return {
...item,
id: result.outcome.newPath,
originalName: newFileName,
ext,
bindings: item.bindings ? { ...item.bindings, name: base, ext } : item.bindings,
};
}
return item;
})
);
// Keep the current selection pointing at the same logical file if its id (path)
// changed underneath it.
setSelected((prevSelected) => {
if (!prevSelected) return prevSelected;
const result = bySource.get(prevSelected);
return result?.outcome.status === "renamed" ? result.outcome.newPath : prevSelected;
});
const parts = [`Renamed ${renamed} file${renamed === 1 ? "" : "s"}`];
if (skipped > 0) parts.push(`${skipped} skipped (name already exists)`);
if (failed > 0) parts.push(`${failed} failed`);
setStatus(parts.join(", ") + ".");
} catch (err) {
setStatus(`Rename failed: ${err}`);
} finally {
setRenaming(false);
setRenamePlan(null);
window.clearTimeout((confirmRename as any)._t);
(confirmRename as any)._t = window.setTimeout(() => setStatus(null), 5000);
}
};
const cancelRename = () => {
if (renaming) return;
setRenamePlan(null);
};
const openSettings = () => {
setTmdbTokenInput("");
setSettingsOpen(true);
};
const saveTmdbToken = async () => {
const trimmed = tmdbTokenInput.trim();
if (!trimmed) {
// Nothing typed - just close, leaving whatever was already saved untouched.
setSettingsOpen(false);
return;
}
setSavingSettings(true);
try {
await invoke("save_tmdb_token", { token: trimmed });
setHasTmdbToken(true);
setTmdbTokenInput("");
setSettingsOpen(false);
setStatus("TMDB token saved.");
window.clearTimeout((saveTmdbToken as any)._t);
(saveTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
} catch (err) {
setStatus(`Couldn't save the token: ${err}`);
} finally {
setSavingSettings(false);
}
};
const clearTmdbToken = async () => {
setSavingSettings(true);
try {
await invoke("save_tmdb_token", { token: "" });
setHasTmdbToken(false);
setTmdbTokenInput("");
setStatus("TMDB token removed.");
window.clearTimeout((clearTmdbToken as any)._t);
(clearTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
} catch (err) {
setStatus(`Couldn't remove the token: ${err}`);
} finally {
setSavingSettings(false);
}
};
// "Fetch Data": resolve each already-Matched file's local guess against real TMDB
// metadata. Requires Match to have already populated a series name + season/episode
// (that's the search query) - this is deliberately layered on top of local matching,
// not a replacement for it.
const runFetch = async () => {
if (hasTmdbToken === false) {
setStatus("Add a free TMDB API token in Settings first, then Fetch Data will work.");
setSettingsOpen(true);
window.clearTimeout((runFetch as any)._t);
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
return;
}
const targets = items.filter((i) => !i.isSample && i.bindings?.n && i.bindings?.season && i.bindings?.episode);
if (targets.length === 0) {
setStatus("Run Match first — Fetch Data needs a guessed series name and season/episode to look up.");
window.clearTimeout((runFetch as any)._t);
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
return;
}
setFetching(true);
try {
const query: FetchQuery[] = targets.map((item) => ({
id: item.id,
seriesQuery: item.bindings!.n,
season: Number(item.bindings!.season),
episode: Number(item.bindings!.episode),
}));
const results = await invoke<FetchResult[]>("fetch_metadata", { items: query });
const byId = new Map(results.map((r) => [r.id, r] as const));
// Compute counts from `results` directly, not inside the setItems updater below -
// same rule as runMatching/confirmRename above (React doesn't run a functional
// updater synchronously, so a counter mutated in there wouldn't be ready yet).
const found = results.filter((r) => r.outcome.status === "found").length;
const notFound = results.filter((r) => r.outcome.status === "notFound").length;
const failed = results.filter((r) => r.outcome.status === "failed").length;
setItems((prev) =>
prev.map((item) => {
const result = byId.get(item.id);
if (!result || result.outcome.status !== "found") return item;
const { seriesName, episodeTitle, airDate } = result.outcome;
return {
...item,
bindings: {
...item.bindings,
n: seriesName,
t: episodeTitle,
...(airDate ? { airdate: airDate } : {}),
},
};
})
);
// Showcase the episode title field, but only the first time and only if the user
// hasn't already customized the naming scheme away from the Match-set default.
if (expressionRef.current === "{n} - {s00e00}" && found > 0) {
setExpression("{n} - {s00e00} - {t}");
}
const parts = [`Fetched real metadata for ${found} of ${targets.length} file${targets.length === 1 ? "" : "s"}`];
if (notFound > 0) parts.push(`${notFound} not found on TMDB`);
if (failed > 0) parts.push(`${failed} failed`);
setStatus(parts.join(", ") + ".");
} catch (err) {
setStatus(`Fetch failed: ${err}`);
} finally {
setFetching(false);
window.clearTimeout((runFetch as any)._t);
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 5000);
}
};
const removeSelected = () => {
if (!selected) return notImplemented("Excluding items");
setItems((prev) => {
const idx = prev.findIndex((i) => i.id === selected);
const next = prev.filter((i) => i.id !== selected);
const fallback = next[Math.min(idx, next.length - 1)];
setSelected(fallback ? fallback.id : null);
return next;
});
};
const clearAll = () => {
setItems([]);
setSelected(null);
};
const matchedCount = useMemo(() => items.filter((i) => i.bindings).length, [items]);
const renderOriginalRow = (item: Item) => (
<Row
key={item.id}
selected={selected === item.id}
muted={!item.bindings}
onClick={() => setSelected(item.id)}
text={item.originalName}
badge={item.ext}
/>
);
const renderNameRow = (item: Item) => {
const name = computedNames[item.id];
const [base, ext] = name ? splitExt(name) : [null, item.ext];
return (
<Row
key={item.id}
selected={selected === item.id}
muted={!item.bindings}
onClick={() => setSelected(item.id)}
text={item.bindings ? base ?? "…" : "No data"}
badge={ext}
/>
);
};
return (
<div className="rename-panel">
<div className="rename-lists">
<ListCard
title="Original Files"
items={items}
renderRow={renderOriginalRow}
emptyHint="Drop files or folders here, or click Load"
buttonRow={
<>
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
<span className="list-button-gap" />
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={removeSelected} />
<ListButton icon={<ClearIcon />} label="Clear" onClick={clearAll} />
<ListButton icon={<HistoryIcon />} label="History" onClick={() => notImplemented("Rename history")} />
<ListButton icon={<PresetsIcon />} label="Presets" onClick={() => notImplemented("Presets")} />
</>
}
/>
<div className="rename-actions">
<button className="action-button" onClick={runMatching}>
Match
</button>
<button className="action-button primary" onClick={prepareRename}>
Rename
</button>
</div>
<ListCard
title="New Names"
items={items}
renderRow={renderNameRow}
emptyHint="Naming-scheme preview appears here"
buttonRow={
<>
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
<span className="list-button-gap" />
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={runFetch} disabled={fetching} />
<ListButton icon={<KeyIcon />} label="TMDB Settings" onClick={openSettings} />
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
</>
}
/>
</div>
{editingFormat && (
<div className="format-editor">
<label htmlFor="expression">Naming scheme</label>
<input
id="expression"
value={expression}
spellCheck={false}
onChange={(e) => setExpression(e.target.value)}
/>
{computeError && <p className="format-error">{computeError}</p>}
<p className="format-hint">
Live preview against {matchedCount} of {items.length} file{items.length === 1 ? "" : "s"}. Runs the real
Rust naming-expression engine (Rhai-backed) through Tauri. Loaded files start with only{" "}
<code>{"{name}"}</code>/<code>{"{ext}"}</code> (their own filename) — click <strong>Match</strong> to scan
filenames locally for season/episode patterns and populate <code>{"{n}"}</code>/<code>{"{s00e00}"}</code>/
<code>{"{season}"}</code>/<code>{"{episode}"}</code> (no network calls), then <strong>Fetch Data</strong> to
resolve those guesses against real TMDB metadata and populate <code>{"{t}"}</code> (episode title) and{" "}
<code>{"{airdate}"}</code> too — needs a free TMDB API token set in <strong>TMDB Settings</strong> first.
</p>
</div>
)}
{status && <div className="status-bar">{status}</div>}
{renamePlan && (
<div className="modal-overlay" onClick={cancelRename}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Confirm Rename</h2>
<p className="modal-subtitle">
This will rename {renamePlan.length} file{renamePlan.length === 1 ? "" : "s"} on disk right now.
There's no undo yet — review the list below before continuing.
{renamePlanSkipped > 0 &&
` (${renamePlanSkipped} other file${renamePlanSkipped === 1 ? "" : "s"} excluded: no valid preview.)`}
</p>
<div className="modal-plan-list">
{renamePlan.map((p) => (
<div className="modal-plan-row" key={p.id}>
<span className="modal-plan-old" title={p.currentName}>
{p.currentName}
</span>
<span className="modal-plan-arrow">→</span>
<span className="modal-plan-new" title={p.newName}>
{p.newName}
</span>
</div>
))}
</div>
<p className="modal-note">
If a computed name already exists on disk, that file is skipped rather than overwritten.
</p>
<div className="modal-actions">
<button className="action-button" onClick={cancelRename} disabled={renaming}>
Cancel
</button>
<button className="action-button primary" onClick={confirmRename} disabled={renaming}>
{renaming ? "Renaming…" : `Rename ${renamePlan.length} File${renamePlan.length === 1 ? "" : "s"}`}
</button>
</div>
</div>
</div>
)}
{settingsOpen && (
<div className="modal-overlay" onClick={() => !savingSettings && setSettingsOpen(false)}>
<div className="modal-card modal-card-compact" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">TMDB Settings</h2>
<p className="modal-subtitle">
Fetch Data uses{" "}
<a href="https://www.themoviedb.org/" target="_blank" rel="noreferrer">
TheMovieDB
</a>{" "}
to resolve local guesses into real episode titles and air dates. Get a free{" "}
<strong>API Read Access Token</strong> from your TMDB account under Settings → API, then paste it below.
The token is stored locally on this machine and never leaves it except to call TMDB directly.
</p>
<label htmlFor="tmdb-token" className="modal-field-label">
API Read Access Token
</label>
<input
id="tmdb-token"
className="modal-text-input"
type="password"
autoComplete="off"
spellCheck={false}
value={tmdbTokenInput}
onChange={(e) => setTmdbTokenInput(e.target.value)}
placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"}
/>
<div className="modal-actions modal-actions-split">
{hasTmdbToken ? (
<button className="action-button" onClick={clearTmdbToken} disabled={savingSettings}>
Remove Token
</button>
) : (
<span />
)}
<div className="modal-actions">
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
Cancel
</button>
<button className="action-button primary" onClick={saveTmdbToken} disabled={savingSettings}>
{savingSettings ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
function ListCard({
title,
items,
renderRow,
emptyHint,
buttonRow,
}: {
title: string;
items: Item[];
renderRow: (item: Item) => ReactNode;
emptyHint: string;
buttonRow: ReactNode;
}) {
return (
<div className="list-card">
<span className="list-card-title">{title}</span>
<div className="list-card-body">
{items.length === 0 ? <div className="list-empty-hint">{emptyHint}</div> : items.map(renderRow)}
</div>
<div className="list-button-row">{buttonRow}</div>
</div>
);
}
function Row({
text,
badge,
selected,
muted,
onClick,
}: {
text: string;
badge: string | null;
selected: boolean;
muted: boolean;
onClick: () => void;
}) {
return (
<div className={`list-row${selected ? " selected" : ""}${muted ? " muted" : ""}`} onClick={onClick}>
<span className="list-row-text">{text}</span>
{badge && <span className="list-row-badge">{badge}</span>}
</div>
);
}
function splitExt(name: string): [string, string] {
const i = name.lastIndexOf(".");
if (i <= 0) return [name, ""];
return [name.slice(0, i), name.slice(i + 1)];
}