From 87e150f31b12ac69f6365c6aeeedfaea12f15e75 Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 01:55:54 +0000 Subject: [PATCH] Rebuild UI as a faithful recreation of the original Aurora-themed layout: sidebar panel nav, two-pane rename list, live naming-scheme preview --- src/App.css | 12 ++ src/App.tsx | 73 ++-------- src/components/Header.css | 27 ++++ src/components/Header.tsx | 7 + src/components/Icons.tsx | 146 +++++++++++++++++++ src/components/PanelStub.css | 21 +++ src/components/PanelStub.tsx | 8 + src/components/RenamePanel.css | 218 ++++++++++++++++++++++++++++ src/components/RenamePanel.tsx | 258 +++++++++++++++++++++++++++++++++ src/components/Sidebar.css | 88 +++++++++++ src/components/Sidebar.tsx | 55 +++++++ src/main.tsx | 7 +- src/styles.css | 86 ----------- src/theme.css | 53 +++++++ 14 files changed, 914 insertions(+), 145 deletions(-) create mode 100644 src/App.css create mode 100644 src/components/Header.css create mode 100644 src/components/Header.tsx create mode 100644 src/components/Icons.tsx create mode 100644 src/components/PanelStub.css create mode 100644 src/components/PanelStub.tsx create mode 100644 src/components/RenamePanel.css create mode 100644 src/components/RenamePanel.tsx create mode 100644 src/components/Sidebar.css create mode 100644 src/components/Sidebar.tsx delete mode 100644 src/styles.css create mode 100644 src/theme.css diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..7f94811 --- /dev/null +++ b/src/App.css @@ -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; +} diff --git a/src/App.tsx b/src/App.tsx index 2c305cb..4fa80fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = { - 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(""); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - - invoke("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 ( -
-
-

{APP_NAME}

-

A fast, cross-platform media renamer.

-

v{APP_VERSION}

-
- -
- - setExpression(e.target.value)} - spellCheck={false} - /> - -
- Preview - {error ? {error} : {preview}} +
+ +
+
+
+ {selected === "rename" ? : }
-
-
+ + ); } diff --git a/src/components/Header.css b/src/components/Header.css new file mode 100644 index 0000000..468107d --- /dev/null +++ b/src/components/Header.css @@ -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); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..b3f2309 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,7 @@ +export default function Header({ title }: { title: string }) { + return ( +
+

{title}

+
+ ); +} diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx new file mode 100644 index 0000000..f61b190 --- /dev/null +++ b/src/components/Icons.tsx @@ -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 = { + width: 18, + height: 18, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.8, + strokeLinecap: "round", + strokeLinejoin: "round", +}; + +export function RenameIcon() { + return ( + + + + + ); +} + +export function EpisodesIcon() { + return ( + + + + + ); +} + +export function SubtitlesIcon() { + return ( + + + + + ); +} + +export function SfvIcon() { + return ( + + + + + ); +} + +export function FilterIcon() { + return ( + + + + ); +} + +export function ListIcon() { + return ( + + + + + + + ); +} + +export function GearIcon() { + return ( + + + + + ); +} + +export function LoadIcon() { + return ( + + + + ); +} + +export function UpIcon() { + return ( + + + + ); +} + +export function DownIcon() { + return ( + + + + ); +} + +export function RemoveIcon() { + return ( + + + + ); +} + +export function ClearIcon() { + return ( + + + + ); +} + +export function HistoryIcon() { + return ( + + + + + ); +} + +export function PresetsIcon() { + return ( + + + + + ); +} + +export function FetchIcon() { + return ( + + + + + ); +} diff --git a/src/components/PanelStub.css b/src/components/PanelStub.css new file mode 100644 index 0000000..fcac516 --- /dev/null +++ b/src/components/PanelStub.css @@ -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; +} diff --git a/src/components/PanelStub.tsx b/src/components/PanelStub.tsx new file mode 100644 index 0000000..f533726 --- /dev/null +++ b/src/components/PanelStub.tsx @@ -0,0 +1,8 @@ +export default function PanelStub({ name }: { name: string }) { + return ( +
+

{name}

+

Not built yet — see the porting roadmap in the README.

+
+ ); +} diff --git a/src/components/RenamePanel.css b/src/components/RenamePanel.css new file mode 100644 index 0000000..69be025 --- /dev/null +++ b/src/components/RenamePanel.css @@ -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; +} diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx new file mode 100644 index 0000000..20629f9 --- /dev/null +++ b/src/components/RenamePanel.tsx @@ -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 | 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 ( + + ); +} + +export default function RenamePanel() { + const [expression, setExpression] = useState(DEFAULT_EXPRESSION); + const [editingFormat, setEditingFormat] = useState(false); + const [selected, setSelected] = useState(SAMPLE_ITEMS[0].id); + const [computedNames, setComputedNames] = useState>({}); + const [computeError, setComputeError] = useState(null); + const [status, setStatus] = useState(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("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 = {}; + 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 ( +
+
+ ( + setSelected(item.id)} + text={item.originalName} + badge={item.ext} + /> + ))} + buttonRow={ + <> + } label="Align Down" onClick={() => notImplemented("Reordering")} /> + } label="Align Up" onClick={() => notImplemented("Reordering")} /> + } label="Load Files" onClick={() => notImplemented("Loading files")} /> + + } label="Exclude Selected" onClick={() => notImplemented("Excluding items")} /> + } label="Clear" onClick={() => notImplemented("Clearing the list")} /> + } label="History" onClick={() => notImplemented("Rename history")} /> + } label="Presets" onClick={() => notImplemented("Presets")} /> + + } + /> + +
+ + +
+ + { + const name = computedNames[item.id]; + const [base, ext] = name ? splitExt(name) : [null, item.ext]; + return ( + setSelected(item.id)} + text={item.bindings ? base ?? "…" : "No match"} + badge={ext} + /> + ); + })} + buttonRow={ + <> + } label="Align Down" onClick={() => notImplemented("Reordering")} /> + } label="Align Up" onClick={() => notImplemented("Reordering")} /> + } label="Load Files" onClick={() => notImplemented("Loading files")} /> + + } label="Fetch Data" onClick={() => notImplemented("Online matching")} /> + } label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} /> + + } + /> +
+ + {editingFormat && ( +
+ + setExpression(e.target.value)} + /> + {computeError &&

{computeError}

} +

+ 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. +

+
+ )} + + {status &&
{status}
} +
+ ); +} + +function ListCard({ + title, + rows, + buttonRow, +}: { + title: string; + rows: ReactNode; + buttonRow: ReactNode; +}) { + return ( +
+ {title} +
{rows}
+
{buttonRow}
+
+ ); +} + +function Row({ + text, + badge, + selected, + muted, + onClick, +}: { + text: string; + badge: string | null; + selected: boolean; + muted: boolean; + onClick: () => void; +}) { + return ( +
+ {text} + {badge && {badge}} +
+ ); +} + +function splitExt(name: string): [string, string] { + const i = name.lastIndexOf("."); + if (i <= 0) return [name, ""]; + return [name.slice(0, i), name.slice(i + 1)]; +} diff --git a/src/components/Sidebar.css b/src/components/Sidebar.css new file mode 100644 index 0000000..e1f39a9 --- /dev/null +++ b/src/components/Sidebar.css @@ -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; +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..734024b --- /dev/null +++ b/src/components/Sidebar.tsx @@ -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 ( + + ); +} diff --git a/src/main.tsx b/src/main.tsx index fa1b97e..6688f9f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( diff --git a/src/styles.css b/src/styles.css deleted file mode 100644 index 61585f2..0000000 --- a/src/styles.css +++ /dev/null @@ -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; -} diff --git a/src/theme.css b/src/theme.css new file mode 100644 index 0000000..f6b3e15 --- /dev/null +++ b/src/theme.css @@ -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; +}