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:
+12
@@ -0,0 +1,12 @@
|
||||
.main-column {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
+15
-58
@@ -1,65 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const APP_NAME = "Renamr";
|
||||
const APP_VERSION = "0.1.0";
|
||||
|
||||
const DEFAULT_EXPRESSION = "{n} - {s00e00} - {t}";
|
||||
const DEFAULT_BINDINGS: Record<string, string> = {
|
||||
n: "Example Show",
|
||||
s00e00: "S01E01",
|
||||
t: "Pilot",
|
||||
};
|
||||
import { useState } from "react";
|
||||
import Sidebar, { PANELS } from "./components/Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import RenamePanel from "./components/RenamePanel";
|
||||
import PanelStub from "./components/PanelStub";
|
||||
|
||||
export default function App() {
|
||||
const [expression, setExpression] = useState(DEFAULT_EXPRESSION);
|
||||
const [preview, setPreview] = useState<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
invoke<string>("render_expression", { expression, bindings: DEFAULT_BINDINGS })
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setPreview(result);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setPreview("");
|
||||
setError(String(err));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [expression]);
|
||||
const [selected, setSelected] = useState(PANELS[0].id);
|
||||
const current = PANELS.find((p) => p.id === selected) ?? PANELS[0];
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<header>
|
||||
<h1>{APP_NAME}</h1>
|
||||
<p className="tagline">A fast, cross-platform media renamer.</p>
|
||||
<p className="version">v{APP_VERSION}</p>
|
||||
</header>
|
||||
|
||||
<section className="panel">
|
||||
<label htmlFor="expression">Naming scheme</label>
|
||||
<input
|
||||
id="expression"
|
||||
value={expression}
|
||||
onChange={(e) => setExpression(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
<div className="preview">
|
||||
<span className="preview-label">Preview</span>
|
||||
{error ? <span className="preview-error">{error}</span> : <span className="preview-value">{preview}</span>}
|
||||
<div className="app-shell">
|
||||
<Sidebar selected={selected} onSelect={setSelected} />
|
||||
<div className="main-column">
|
||||
<Header title={current.name} />
|
||||
<div className="main-content">
|
||||
{selected === "rename" ? <RenamePanel /> : <PanelStub name={current.name} />}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.header {
|
||||
height: var(--header-height);
|
||||
flex: 0 0 var(--header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #111e26 0%, var(--surface-alt) 62%, #0a161c 100%);
|
||||
border-bottom: 1px solid var(--dock-line);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--aurora-cyan), transparent 70%);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function Header({ title }: { title: string }) {
|
||||
return (
|
||||
<header className="header">
|
||||
<h1>{title}</h1>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Small line-icon set for the sidebar panels. The original app used bitmap icon
|
||||
// resources (ResourceManager.getIcon("panel.rename") etc.) that aren't available to
|
||||
// port directly, so these are fresh SVGs matching the same one-glyph-per-panel idea.
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const base: SVGProps<SVGSVGElement> = {
|
||||
width: 18,
|
||||
height: 18,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: "round",
|
||||
strokeLinejoin: "round",
|
||||
};
|
||||
|
||||
export function RenameIcon() {
|
||||
return (
|
||||
<svg {...base}>
|
||||
<path d="M4 7h9l3 3-3 3H4z" />
|
||||
<path d="M20 17h-9l-3-3 3-3h9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EpisodesIcon() {
|
||||
return (
|
||||
<svg {...base}>
|
||||
<rect x="3.5" y="5" width="17" height="15" rx="2" />
|
||||
<path d="M3.5 9.5h17M8 3v4M16 3v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubtitlesIcon() {
|
||||
return (
|
||||
<svg {...base}>
|
||||
<rect x="3" y="5.5" width="18" height="13" rx="2" />
|
||||
<path d="M7 14h4M13 14h4M7 10.5h10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SfvIcon() {
|
||||
return (
|
||||
<svg {...base}>
|
||||
<path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z" />
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterIcon() {
|
||||
return (
|
||||
<svg {...base}>
|
||||
<path d="M4 5h16l-6 7.5V19l-4 2v-8.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListIcon() {
|
||||
return (
|
||||
<svg {...base}>
|
||||
<path d="M8 6h12M8 12h12M8 18h12" />
|
||||
<circle cx="4" cy="6" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="4" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="4" cy="18" r="1.2" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GearIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 16, height: 16 }}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 3v2.5M12 18.5V21M21 12h-2.5M5.5 12H3M18.4 5.6l-1.8 1.8M7.4 16.6l-1.8 1.8M18.4 18.4l-1.8-1.8M7.4 7.4 5.6 5.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 15, height: 15 }}>
|
||||
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3M12 3v12M7 10l5 5 5-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 13, height: 13 }}>
|
||||
<path d="M6 15l6-6 6 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 13, height: 13 }}>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RemoveIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 15, height: 15 }}>
|
||||
<path d="M5 5l14 14M19 5L5 19" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClearIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 15, height: 15 }}>
|
||||
<path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 15, height: 15 }}>
|
||||
<path d="M4 12a8 8 0 1 0 2.6-5.9" />
|
||||
<path d="M4 4v4h4M12 8v4l3 2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PresetsIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 15, height: 15 }}>
|
||||
<path d="M4 6h16M4 12h10M4 18h7" />
|
||||
<circle cx="19" cy="15" r="2.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FetchIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 15, height: 15 }}>
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="M20 20l-4.3-4.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.panel-stub {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.panel-stub-name {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.panel-stub-note {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function PanelStub({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="panel-stub">
|
||||
<p className="panel-stub-name">{name}</p>
|
||||
<p className="panel-stub-note">Not built yet — see the porting roadmap in the README.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
.rename-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rename-lists {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 14px;
|
||||
padding: 18px 18px 18px 16px;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-card-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--muted-text);
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.list-card-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--surface-deep);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.list-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.list-row.selected {
|
||||
background: var(--list-selection);
|
||||
}
|
||||
|
||||
.list-row.muted {
|
||||
color: var(--muted-text);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.list-row-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.list-row-badge {
|
||||
flex: 0 0 auto;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-text);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.list-button-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.list-button-gap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.list-icon-button {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--surface-deep);
|
||||
background: #172a31;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-icon-button:hover {
|
||||
background: #203941;
|
||||
}
|
||||
|
||||
.list-icon-button:active {
|
||||
background: #28515a;
|
||||
}
|
||||
|
||||
.rename-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
width: 116px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--surface-deep);
|
||||
background: #172a31;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: #203941;
|
||||
}
|
||||
|
||||
.action-button.primary {
|
||||
background: var(--aurora-green);
|
||||
color: var(--dock-deep);
|
||||
border-color: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.action-button.primary:hover {
|
||||
background: #70e3bb;
|
||||
}
|
||||
|
||||
.format-editor {
|
||||
margin: 0 18px 18px;
|
||||
padding: 14px 16px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--surface-deep);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.format-editor label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--muted-text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.format-editor input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--surface-deep);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-family: ui-monospace, "SF Mono", monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.format-error {
|
||||
margin: 8px 0 0;
|
||||
color: #ff8a8a;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.format-hint {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted-text);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
bottom: 14px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(19, 34, 41, 0.95);
|
||||
border: 1px solid var(--surface-deep);
|
||||
border-radius: 8px;
|
||||
font-size: 11.5px;
|
||||
color: var(--sunset);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
flex: 0 0 var(--sidebar-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 64px 6px 14px;
|
||||
position: relative;
|
||||
/* Approximates the original's diagonal LinearGradientPaint(0,0 -> width,height,
|
||||
[dock-deep, dock, dock-mist] at [0, 0.72, 1]). */
|
||||
background: linear-gradient(165deg, var(--dock-deep) 0%, var(--dock) 72%, var(--dock-mist) 100%);
|
||||
}
|
||||
|
||||
.sidebar::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, var(--aurora-green) 0%, var(--aurora-cyan) 48%, var(--aurora-lilac) 100%);
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 8px 9px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 54px;
|
||||
padding: 0 10px;
|
||||
border-radius: 14px;
|
||||
color: var(--muted-text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-item.selected {
|
||||
background: rgba(0, 0, 0, 0.13);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-item-accent {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 11px;
|
||||
bottom: 11px;
|
||||
width: 3px;
|
||||
border-radius: 6px;
|
||||
background: var(--aurora-cyan);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.sidebar-wordmark {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
background: linear-gradient(135deg, var(--aurora-cyan), var(--aurora-lilac));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.sidebar-version {
|
||||
font-size: 10px;
|
||||
color: var(--muted-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { EpisodesIcon, FilterIcon, ListIcon, RenameIcon, SfvIcon, SubtitlesIcon } from "./Icons";
|
||||
|
||||
export interface PanelDef {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: () => JSX.Element;
|
||||
}
|
||||
|
||||
// Same panel set, same order, as the original's PanelBuilder.defaultSequence().
|
||||
export const PANELS: PanelDef[] = [
|
||||
{ id: "rename", name: "Rename", icon: RenameIcon },
|
||||
{ id: "episodes", name: "Episodes", icon: EpisodesIcon },
|
||||
{ id: "subtitles", name: "Subtitles", icon: SubtitlesIcon },
|
||||
{ id: "sfv", name: "SFV", icon: SfvIcon },
|
||||
{ id: "filter", name: "Filter", icon: FilterIcon },
|
||||
{ id: "list", name: "List", icon: ListIcon },
|
||||
];
|
||||
|
||||
const APP_VERSION = "0.1.0";
|
||||
|
||||
export default function Sidebar({
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
selected: string;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="sidebar">
|
||||
<ul className="sidebar-list">
|
||||
{PANELS.map((panel) => {
|
||||
const Icon = panel.icon;
|
||||
const isSelected = panel.id === selected;
|
||||
return (
|
||||
<li key={panel.id}>
|
||||
<button
|
||||
className={`sidebar-item${isSelected ? " selected" : ""}`}
|
||||
onClick={() => onSelect(panel.id)}
|
||||
>
|
||||
{isSelected && <span className="sidebar-item-accent" />}
|
||||
<Icon />
|
||||
<span>{panel.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<span className="sidebar-wordmark">Renamr</span>
|
||||
<span className="sidebar-version">v{APP_VERSION}</span>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
+6
-1
@@ -1,7 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
import "./theme.css";
|
||||
import "./App.css";
|
||||
import "./components/Sidebar.css";
|
||||
import "./components/Header.css";
|
||||
import "./components/RenamePanel.css";
|
||||
import "./components/PanelStub.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: "Poppins", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0f1222;
|
||||
color: #f4f5fb;
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2.25rem;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #5b4cf5, #38bdb2);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin: 4px 0 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.version {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 32px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: inherit;
|
||||
font-size: 1rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.preview-value {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.preview-error {
|
||||
color: #ff8080;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Renamr "Aurora" theme — ported 1:1 (hex values) from the original FB-Mod fork's
|
||||
AuroraTheme.java / HeaderPanel.java / MainFrame.java so the rewrite looks like a
|
||||
continuation of that design rather than a random restyle. */
|
||||
:root {
|
||||
--dock: #08252b;
|
||||
--dock-deep: #051115;
|
||||
--dock-mist: #123a42;
|
||||
--dock-line: #255660;
|
||||
--surface: #0b151a;
|
||||
--surface-alt: #101f25;
|
||||
--surface-deep: #223940;
|
||||
--card: #132229;
|
||||
--text: #e8f4f2;
|
||||
--muted-text: #9aafb0;
|
||||
--aurora-green: #55d7a9;
|
||||
--aurora-cyan: #52c7db;
|
||||
--aurora-lilac: #a99cff;
|
||||
--sunset: #f0b66b;
|
||||
--list-selection: #1e4d54;
|
||||
--dock-selection: #1d4850;
|
||||
--sidebar-width: 158px;
|
||||
--header-height: 68px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-family: "Poppins", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
Reference in New Issue
Block a user