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 { useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import Sidebar, { PANELS } from "./components/Sidebar";
|
||||||
|
import Header from "./components/Header";
|
||||||
const APP_NAME = "Renamr";
|
import RenamePanel from "./components/RenamePanel";
|
||||||
const APP_VERSION = "0.1.0";
|
import PanelStub from "./components/PanelStub";
|
||||||
|
|
||||||
const DEFAULT_EXPRESSION = "{n} - {s00e00} - {t}";
|
|
||||||
const DEFAULT_BINDINGS: Record<string, string> = {
|
|
||||||
n: "Example Show",
|
|
||||||
s00e00: "S01E01",
|
|
||||||
t: "Pilot",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [expression, setExpression] = useState(DEFAULT_EXPRESSION);
|
const [selected, setSelected] = useState(PANELS[0].id);
|
||||||
const [preview, setPreview] = useState<string>("");
|
const current = PANELS.find((p) => p.id === selected) ?? PANELS[0];
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<div className="app-shell">
|
||||||
<header>
|
<Sidebar selected={selected} onSelect={setSelected} />
|
||||||
<h1>{APP_NAME}</h1>
|
<div className="main-column">
|
||||||
<p className="tagline">A fast, cross-platform media renamer.</p>
|
<Header title={current.name} />
|
||||||
<p className="version">v{APP_VERSION}</p>
|
<div className="main-content">
|
||||||
</header>
|
{selected === "rename" ? <RenamePanel /> : <PanelStub name={current.name} />}
|
||||||
|
</div>
|
||||||
<section className="panel">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
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(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<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