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

259 lines
8.0 KiB
TypeScript
Raw Normal View History

import { useEffect, useMemo, useState, type ReactNode } from "react";
import { invoke } from "@tauri-apps/api/core";
import {
ClearIcon,
DownIcon,
FetchIcon,
GearIcon,
HistoryIcon,
LoadIcon,
PresetsIcon,
RemoveIcon,
UpIcon,
} from "./Icons";
interface SampleItem {
id: string;
originalName: string;
ext: string;
bindings: Record<string, string> | null; // null = no match (rendered muted, like the original)
}
// Placeholder sample batch standing in for a real folder scan + online match, which
// aren't ported yet (see the porting roadmap). This is enough to show the two-list
// layout, selection sync, and live naming-scheme preview working end-to-end.
const SAMPLE_ITEMS: SampleItem[] = [
{
id: "1",
originalName: "Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv",
ext: "mkv",
bindings: { n: "Breaking Bad", s00e00: "S05E14", t: "Ozymandias" },
},
{
id: "2",
originalName: "Breaking.Bad.S05E15.720p.HDTV.x264-KILLERS.mkv",
ext: "mkv",
bindings: { n: "Breaking Bad", s00e00: "S05E15", t: "Granite State" },
},
{
id: "3",
originalName: "The.Office.US.S03E01.DVDRip.XviD-SAiNTS.avi",
ext: "avi",
bindings: { n: "The Office (US)", s00e00: "S03E01", t: "Gay Witch Hunt" },
},
{
id: "4",
originalName: "random_video_file.mp4",
ext: "mp4",
bindings: null,
},
];
const DEFAULT_EXPRESSION = "{n} - {s00e00} - {t}";
function ListButton({
icon,
label,
onClick,
}: {
icon: JSX.Element;
label: string;
onClick: () => void;
}) {
return (
<button className="list-icon-button" title={label} onClick={onClick}>
{icon}
</button>
);
}
export default function RenamePanel() {
const [expression, setExpression] = useState(DEFAULT_EXPRESSION);
const [editingFormat, setEditingFormat] = useState(false);
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);
useEffect(() => {
let cancelled = false;
Promise.all(
SAMPLE_ITEMS.map(async (item) => {
if (!item.bindings) return [item.id, null] as const;
try {
const rendered = await invoke<string>("render_expression", {
expression,
bindings: item.bindings,
});
return [item.id, `${rendered}.${item.ext}`] as const;
} catch (err) {
throw err;
}
})
)
.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]);
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 matchedCount = useMemo(() => SAMPLE_ITEMS.filter((i) => i.bindings).length, []);
return (
<div className="rename-panel">
<div className="rename-lists">
<ListCard
title="Original Files"
rows={SAMPLE_ITEMS.map((item) => (
<Row
key={item.id}
selected={selected === item.id}
muted={!item.bindings}
onClick={() => setSelected(item.id)}
text={item.originalName}
badge={item.ext}
/>
))}
buttonRow={
<>
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
<ListButton icon={<LoadIcon />} label="Load Files" onClick={() => notImplemented("Loading files")} />
<span className="list-button-gap" />
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={() => notImplemented("Excluding items")} />
<ListButton icon={<ClearIcon />} label="Clear" onClick={() => notImplemented("Clearing the list")} />
<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={() => notImplemented("Matching")}>
Match
</button>
<button className="action-button primary" onClick={() => notImplemented("Renaming")}>
Rename
</button>
</div>
<ListCard
title="New Names"
rows={SAMPLE_ITEMS.map((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 match"}
badge={ext}
/>
);
})}
buttonRow={
<>
<ListButton icon={<DownIcon />} label="Align Down" onClick={() => notImplemented("Reordering")} />
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
<ListButton icon={<LoadIcon />} label="Load Files" onClick={() => notImplemented("Loading files")} />
<span className="list-button-gap" />
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={() => notImplemented("Online matching")} />
<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} matched sample file{matchedCount === 1 ? "" : "s"}. This runs the
real Rust naming-expression engine (Rhai-backed) through Tauri — the only piece of the renamer that's
fully implemented so far.
</p>
</div>
)}
{status && <div className="status-bar">{status}</div>}
</div>
);
}
function ListCard({
title,
rows,
buttonRow,
}: {
title: string;
rows: ReactNode;
buttonRow: ReactNode;
}) {
return (
<div className="list-card">
<span className="list-card-title">{title}</span>
<div className="list-card-body">{rows}</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)];
}