Rebuild UI as a faithful recreation of the original Aurora-themed layout: sidebar panel nav, two-pane rename list, live naming-scheme preview
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
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)];
|
||||
}
|
||||
Reference in New Issue
Block a user