initial project scaffold
Rust workspace with nomina-core (rename engine) and nomina-app (Tauri v2 shell). React/TypeScript frontend with tabbed rule panels, virtual-scrolled file list, and Zustand state management. All 9 rule types implemented with 25 passing tests.
This commit is contained in:
7
ui/src/App.tsx
Normal file
7
ui/src/App.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AppShell } from "./components/layout/AppShell";
|
||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
|
||||
export default function App() {
|
||||
useKeyboardShortcuts();
|
||||
return <AppShell />;
|
||||
}
|
||||
127
ui/src/components/browser/FileList.tsx
Normal file
127
ui/src/components/browser/FileList.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useRef } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useFileStore } from "../../stores/fileStore";
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
export function FileList() {
|
||||
const files = useFileStore((s) => s.files);
|
||||
const previewResults = useFileStore((s) => s.previewResults);
|
||||
const selectedFiles = useFileStore((s) => s.selectedFiles);
|
||||
const toggleFileSelection = useFileStore((s) => s.toggleFileSelection);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const previewMap = new Map(previewResults.map((r) => [r.original_path, r]));
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: files.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 28,
|
||||
overscan: 20,
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center h-full text-sm"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Navigate to a folder to see files
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* header */}
|
||||
<div
|
||||
className="flex text-xs font-medium border-b px-2 py-1 shrink-0"
|
||||
style={{
|
||||
background: "var(--bg-tertiary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<div className="w-8" />
|
||||
<div className="flex-1 min-w-0 px-2">Original Name</div>
|
||||
<div className="flex-1 min-w-0 px-2">New Name</div>
|
||||
<div className="w-20 px-2 text-right">Size</div>
|
||||
<div className="w-16 px-2 text-center">Status</div>
|
||||
</div>
|
||||
|
||||
{/* virtual rows */}
|
||||
<div ref={parentRef} className="flex-1 overflow-auto">
|
||||
<div style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: "relative" }}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const file = files[virtualRow.index];
|
||||
const preview = previewMap.get(file.path);
|
||||
const isSelected = selectedFiles.has(file.path);
|
||||
const changed = preview && preview.new_name !== preview.original_name;
|
||||
const hasError = preview?.has_error;
|
||||
const hasConflict = preview?.has_conflict;
|
||||
|
||||
let rowBg = virtualRow.index % 2 === 0 ? "var(--row-even)" : "var(--row-odd)";
|
||||
if (hasConflict) rowBg = "rgba(220, 38, 38, 0.1)";
|
||||
else if (hasError) rowBg = "rgba(217, 119, 6, 0.1)";
|
||||
else if (changed) rowBg = "rgba(22, 163, 74, 0.06)";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-center text-xs px-2 absolute w-full"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
background: rowBg,
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="w-8 flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleFileSelection(file.path)}
|
||||
className="accent-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 min-w-0 px-2 truncate"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 min-w-0 px-2 truncate"
|
||||
style={{
|
||||
color: changed ? "var(--success)" : "var(--text-secondary)",
|
||||
fontWeight: changed ? 500 : 400,
|
||||
}}
|
||||
>
|
||||
{preview?.new_name || file.name}
|
||||
</div>
|
||||
<div
|
||||
className="w-20 px-2 text-right"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
{file.is_dir ? "-" : formatSize(file.size)}
|
||||
</div>
|
||||
<div className="w-16 px-2 text-center">
|
||||
{hasConflict && <span style={{ color: "var(--error)" }}>Conflict</span>}
|
||||
{hasError && !hasConflict && (
|
||||
<span style={{ color: "var(--warning)" }}>Error</span>
|
||||
)}
|
||||
{changed && !hasError && <span style={{ color: "var(--success)" }}>OK</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
ui/src/components/layout/AppShell.tsx
Normal file
26
ui/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useState } from "react";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { Toolbar } from "./Toolbar";
|
||||
import { FileList } from "../browser/FileList";
|
||||
import { RulePanel } from "../rules/RulePanel";
|
||||
|
||||
export function AppShell() {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(240);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<Toolbar />
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<Sidebar width={sidebarWidth} onResize={setSidebarWidth} />
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<FileList />
|
||||
</div>
|
||||
<RulePanel />
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
ui/src/components/layout/Sidebar.tsx
Normal file
151
ui/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useFileStore } from "../../stores/fileStore";
|
||||
|
||||
interface SidebarProps {
|
||||
width: number;
|
||||
onResize: (width: number) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ width }: SidebarProps) {
|
||||
const [pathInput, setPathInput] = useState("");
|
||||
const [drives, setDrives] = useState<string[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const scanDirectory = useFileStore((s) => s.scanDirectory);
|
||||
const currentPath = useFileStore((s) => s.currentPath);
|
||||
|
||||
useEffect(() => {
|
||||
// detect windows drives
|
||||
const detected: string[] = [];
|
||||
for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZ") {
|
||||
detected.push(`${letter}:\\`);
|
||||
}
|
||||
setDrives(detected);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPath) {
|
||||
setPathInput(currentPath);
|
||||
loadFolders(currentPath);
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
async function loadFolders(path: string) {
|
||||
try {
|
||||
const entries = await invoke<Array<{ path: string; name: string; is_dir: boolean }>>(
|
||||
"scan_directory",
|
||||
{
|
||||
path,
|
||||
filters: {
|
||||
mask: "*",
|
||||
regex_filter: null,
|
||||
min_size: null,
|
||||
max_size: null,
|
||||
include_files: false,
|
||||
include_folders: true,
|
||||
include_hidden: false,
|
||||
subfolder_depth: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
setFolders(entries.filter((e) => e.is_dir).map((e) => e.path));
|
||||
} catch {
|
||||
setFolders([]);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePathSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (pathInput.trim()) {
|
||||
scanDirectory(pathInput.trim());
|
||||
}
|
||||
}
|
||||
|
||||
function handleDriveClick(drive: string) {
|
||||
scanDirectory(drive);
|
||||
}
|
||||
|
||||
function handleFolderClick(path: string) {
|
||||
scanDirectory(path);
|
||||
}
|
||||
|
||||
function folderName(path: string): string {
|
||||
const parts = path.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col border-r overflow-hidden select-none"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
minWidth: "180px",
|
||||
maxWidth: "400px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handlePathSubmit} className="p-2 border-b" style={{ borderColor: "var(--border)" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
placeholder="Enter path..."
|
||||
className="w-full px-2 py-1 text-xs rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="flex-1 overflow-y-auto text-xs">
|
||||
{!currentPath && (
|
||||
<div className="p-2">
|
||||
<div className="text-[10px] uppercase tracking-wide mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
Drives
|
||||
</div>
|
||||
{drives.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => handleDriveClick(d)}
|
||||
className="block w-full text-left px-2 py-1 rounded hover:opacity-80"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPath && (
|
||||
<div className="p-2">
|
||||
{currentPath.includes("\\") || currentPath.includes("/") ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
const parent = currentPath.replace(/\\/g, "/").replace(/\/[^/]+\/?$/, "");
|
||||
if (parent) scanDirectory(parent.replace(/\//g, "\\") || currentPath.slice(0, 3));
|
||||
}}
|
||||
className="block w-full text-left px-2 py-1 rounded mb-1 hover:opacity-80"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
..
|
||||
</button>
|
||||
) : null}
|
||||
{folders.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => handleFolderClick(f)}
|
||||
className="block w-full text-left px-2 py-1 rounded truncate hover:opacity-80"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{folderName(f)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
ui/src/components/layout/StatusBar.tsx
Normal file
35
ui/src/components/layout/StatusBar.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useFileStore } from "../../stores/fileStore";
|
||||
|
||||
export function StatusBar() {
|
||||
const files = useFileStore((s) => s.files);
|
||||
const selectedFiles = useFileStore((s) => s.selectedFiles);
|
||||
const previewResults = useFileStore((s) => s.previewResults);
|
||||
const loading = useFileStore((s) => s.loading);
|
||||
|
||||
const conflicts = previewResults.filter((r) => r.has_conflict).length;
|
||||
const changes = previewResults.filter(
|
||||
(r) => r.original_name !== r.new_name && !r.has_error,
|
||||
).length;
|
||||
|
||||
const status = loading ? "Scanning..." : changes > 0 ? "Preview ready" : "Ready";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-4 px-4 py-1 text-xs border-t select-none"
|
||||
style={{
|
||||
background: "var(--bg-secondary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<span>{files.length} files</span>
|
||||
<span>{selectedFiles.size} selected</span>
|
||||
{changes > 0 && <span>{changes} to rename</span>}
|
||||
{conflicts > 0 && (
|
||||
<span style={{ color: "var(--error)" }}>{conflicts} conflicts</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
ui/src/components/layout/Toolbar.tsx
Normal file
98
ui/src/components/layout/Toolbar.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useFileStore } from "../../stores/fileStore";
|
||||
import { useRuleStore } from "../../stores/ruleStore";
|
||||
|
||||
export function Toolbar() {
|
||||
const previewResults = useFileStore((s) => s.previewResults);
|
||||
const currentPath = useFileStore((s) => s.currentPath);
|
||||
const scanDirectory = useFileStore((s) => s.scanDirectory);
|
||||
const requestPreview = useRuleStore((s) => s.requestPreview);
|
||||
const resetAllRules = useRuleStore((s) => s.resetAllRules);
|
||||
|
||||
async function handleRename() {
|
||||
const ops = previewResults.filter(
|
||||
(r) => !r.has_conflict && !r.has_error && r.original_name !== r.new_name,
|
||||
);
|
||||
if (ops.length === 0) return;
|
||||
|
||||
try {
|
||||
const report = await invoke<{ succeeded: number; failed: string[] }>(
|
||||
"execute_rename",
|
||||
{ operations: ops },
|
||||
);
|
||||
if (report.failed.length > 0) {
|
||||
console.error("Some renames failed:", report.failed);
|
||||
}
|
||||
// refresh
|
||||
if (currentPath) {
|
||||
await scanDirectory(currentPath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Rename failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUndo() {
|
||||
try {
|
||||
await invoke("undo_last");
|
||||
if (currentPath) {
|
||||
await scanDirectory(currentPath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Undo failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges = previewResults.some(
|
||||
(r) => r.original_name !== r.new_name && !r.has_conflict && !r.has_error,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2 border-b select-none"
|
||||
style={{
|
||||
background: "var(--bg-secondary)",
|
||||
borderColor: "var(--border)",
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold text-sm mr-4" style={{ color: "var(--accent)" }}>
|
||||
Nomina
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handleRename}
|
||||
disabled={!hasChanges}
|
||||
className="px-4 py-1.5 rounded text-sm font-medium text-white disabled:opacity-40"
|
||||
style={{ background: hasChanges ? "var(--accent)" : "var(--border)" }}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => requestPreview(currentPath)}
|
||||
className="px-3 py-1.5 rounded text-sm border"
|
||||
style={{ borderColor: "var(--border)", color: "var(--text-primary)" }}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
className="px-3 py-1.5 rounded text-sm border"
|
||||
style={{ borderColor: "var(--border)", color: "var(--text-primary)" }}
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={resetAllRules}
|
||||
className="px-3 py-1.5 rounded text-sm border"
|
||||
style={{ borderColor: "var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
Clear Rules
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
ui/src/components/rules/AddTab.tsx
Normal file
102
ui/src/components/rules/AddTab.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useRuleStore } from "../../stores/ruleStore";
|
||||
import type { AddConfig, StepMode } from "../../types/rules";
|
||||
|
||||
export function AddTab() {
|
||||
const rule = useRuleStore((s) => s.rules.add) as AddConfig;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
|
||||
const update = (changes: Partial<AddConfig>) => updateRule("add", changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-xs">
|
||||
<div className="flex gap-3">
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Prefix</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.prefix}
|
||||
onChange={(e) => update({ prefix: e.target.value })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Suffix</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.suffix}
|
||||
onChange={(e) => update({ suffix: e.target.value })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Insert</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.insert}
|
||||
onChange={(e) => update({ insert: e.target.value })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 w-24">
|
||||
<span style={{ color: "var(--text-secondary)" }}>At position</span>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.insert_at}
|
||||
onChange={(e) => update({ insert_at: parseInt(e.target.value) || 0 })}
|
||||
min={0}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.word_space}
|
||||
onChange={(e) => update({ word_space: e.target.checked })}
|
||||
/>
|
||||
<span>Word space</span>
|
||||
</label>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="add-mode"
|
||||
checked={rule.step_mode === mode}
|
||||
onChange={() => update({ step_mode: mode })}
|
||||
/>
|
||||
<span>{mode}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
ui/src/components/rules/CaseTab.tsx
Normal file
69
ui/src/components/rules/CaseTab.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useRuleStore } from "../../stores/ruleStore";
|
||||
import type { CaseConfig, StepMode } from "../../types/rules";
|
||||
|
||||
const caseModes = ["Same", "Upper", "Lower", "Title", "Sentence", "Invert", "Random"] as const;
|
||||
|
||||
export function CaseTab() {
|
||||
const rule = useRuleStore((s) => s.rules.case) as CaseConfig;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
|
||||
const update = (changes: Partial<CaseConfig>) => updateRule("case", changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-xs">
|
||||
<div className="flex gap-3 items-end">
|
||||
<label className="flex flex-col gap-1 w-40">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Case mode</span>
|
||||
<select
|
||||
value={rule.mode}
|
||||
onChange={(e) => update({ mode: e.target.value as CaseConfig["mode"] })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{caseModes.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{rule.mode === "Title" && (
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Exceptions (comma-separated)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.exceptions}
|
||||
onChange={(e) => update({ exceptions: e.target.value })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
placeholder="the, a, an, of..."
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1" />
|
||||
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="case-mode"
|
||||
checked={rule.step_mode === mode}
|
||||
onChange={() => update({ step_mode: mode })}
|
||||
/>
|
||||
<span>{mode}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
ui/src/components/rules/ExtensionTab.tsx
Normal file
71
ui/src/components/rules/ExtensionTab.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useRuleStore } from "../../stores/ruleStore";
|
||||
import type { ExtensionConfig, StepMode } from "../../types/rules";
|
||||
|
||||
const extModes = ["Same", "Lower", "Upper", "Title", "Extra", "Remove", "Fixed"] as const;
|
||||
|
||||
export function ExtensionTab() {
|
||||
const rule = useRuleStore((s) => s.rules.extension) as ExtensionConfig;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
|
||||
const update = (changes: Partial<ExtensionConfig>) => updateRule("extension", changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-xs">
|
||||
<div className="flex gap-3 items-end">
|
||||
<label className="flex flex-col gap-1 w-40">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Extension mode</span>
|
||||
<select
|
||||
value={rule.mode}
|
||||
onChange={(e) => update({ mode: e.target.value as ExtensionConfig["mode"] })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{extModes.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{(rule.mode === "Fixed" || rule.mode === "Extra") && (
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span style={{ color: "var(--text-secondary)" }}>
|
||||
{rule.mode === "Extra" ? "Extra extension" : "New extension"}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.fixed_value}
|
||||
onChange={(e) => update({ fixed_value: e.target.value })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
placeholder="e.g. bak, txt..."
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1" />
|
||||
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="ext-mode"
|
||||
checked={rule.step_mode === mode}
|
||||
onChange={() => update({ step_mode: mode })}
|
||||
/>
|
||||
<span>{mode}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
ui/src/components/rules/NumberingTab.tsx
Normal file
136
ui/src/components/rules/NumberingTab.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useRuleStore } from "../../stores/ruleStore";
|
||||
import type { NumberingConfig, StepMode } from "../../types/rules";
|
||||
|
||||
const numberModes = ["None", "Prefix", "Suffix", "Both", "Insert"] as const;
|
||||
const bases = ["Decimal", "Hex", "Octal", "Binary", "Alpha"] as const;
|
||||
|
||||
export function NumberingTab() {
|
||||
const rule = useRuleStore((s) => s.rules.numbering) as NumberingConfig;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
|
||||
const update = (changes: Partial<NumberingConfig>) => updateRule("numbering", changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-xs">
|
||||
<div className="flex gap-3">
|
||||
<label className="flex flex-col gap-1 w-28">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Position</span>
|
||||
<select
|
||||
value={rule.mode}
|
||||
onChange={(e) => update({ mode: e.target.value as NumberingConfig["mode"] })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{numberModes.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 w-20">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Start</span>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.start}
|
||||
onChange={(e) => update({ start: parseInt(e.target.value) || 0 })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 w-20">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Step</span>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.increment}
|
||||
onChange={(e) => update({ increment: parseInt(e.target.value) || 1 })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 w-20">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Padding</span>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.padding}
|
||||
onChange={(e) => update({ padding: parseInt(e.target.value) || 1 })}
|
||||
min={1}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 w-20">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Separator</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.separator}
|
||||
onChange={(e) => update({ separator: e.target.value })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 w-24">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Base</span>
|
||||
<select
|
||||
value={rule.base}
|
||||
onChange={(e) => update({ base: e.target.value as NumberingConfig["base"] })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{bases.map((b) => (
|
||||
<option key={b} value={b}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.per_folder}
|
||||
onChange={(e) => update({ per_folder: e.target.checked })}
|
||||
/>
|
||||
<span>Reset per folder</span>
|
||||
</label>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="numbering-mode"
|
||||
checked={rule.step_mode === mode}
|
||||
onChange={() => update({ step_mode: mode })}
|
||||
/>
|
||||
<span>{mode}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
ui/src/components/rules/RegexTab.tsx
Normal file
74
ui/src/components/rules/RegexTab.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useRuleStore } from "../../stores/ruleStore";
|
||||
import type { RegexConfig, StepMode } from "../../types/rules";
|
||||
|
||||
export function RegexTab() {
|
||||
const rule = useRuleStore((s) => s.rules.regex) as RegexConfig;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
|
||||
const update = (changes: Partial<RegexConfig>) => updateRule("regex", changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-xs">
|
||||
<div className="flex gap-3">
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Pattern</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.pattern}
|
||||
onChange={(e) => update({ pattern: e.target.value })}
|
||||
className="px-2 py-1.5 rounded border font-mono"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
placeholder="Regex pattern..."
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Replace with</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.replace_with}
|
||||
onChange={(e) => update({ replace_with: e.target.value })}
|
||||
className="px-2 py-1.5 rounded border font-mono"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
placeholder="$1, $2 for capture groups..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.case_insensitive}
|
||||
onChange={(e) => update({ case_insensitive: e.target.checked })}
|
||||
/>
|
||||
<span>Case insensitive</span>
|
||||
</label>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="regex-mode"
|
||||
checked={rule.step_mode === mode}
|
||||
onChange={() => update({ step_mode: mode })}
|
||||
/>
|
||||
<span>{mode}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
ui/src/components/rules/RemoveTab.tsx
Normal file
123
ui/src/components/rules/RemoveTab.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useRuleStore } from "../../stores/ruleStore";
|
||||
import type { RemoveConfig, StepMode } from "../../types/rules";
|
||||
|
||||
export function RemoveTab() {
|
||||
const rule = useRuleStore((s) => s.rules.remove) as RemoveConfig;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
|
||||
const update = (changes: Partial<RemoveConfig>) => updateRule("remove", changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-xs">
|
||||
<div className="flex gap-3">
|
||||
<label className="flex flex-col gap-1 w-24">
|
||||
<span style={{ color: "var(--text-secondary)" }}>First N</span>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.first_n}
|
||||
onChange={(e) => update({ first_n: parseInt(e.target.value) || 0 })}
|
||||
min={0}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 w-24">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Last N</span>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.last_n}
|
||||
onChange={(e) => update({ last_n: parseInt(e.target.value) || 0 })}
|
||||
min={0}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 w-24">
|
||||
<span style={{ color: "var(--text-secondary)" }}>From</span>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.from}
|
||||
onChange={(e) => update({ from: parseInt(e.target.value) || 0 })}
|
||||
min={0}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 w-24">
|
||||
<span style={{ color: "var(--text-secondary)" }}>To</span>
|
||||
<input
|
||||
type="number"
|
||||
value={rule.to}
|
||||
onChange={(e) => update({ to: parseInt(e.target.value) || 0 })}
|
||||
min={0}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Crop before</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.crop_before || ""}
|
||||
onChange={(e) => update({ crop_before: e.target.value || null })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Crop after</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.crop_after || ""}
|
||||
onChange={(e) => update({ crop_after: e.target.value || null })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1" />
|
||||
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="remove-mode"
|
||||
checked={rule.step_mode === mode}
|
||||
onChange={() => update({ step_mode: mode })}
|
||||
/>
|
||||
<span>{mode}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
ui/src/components/rules/ReplaceTab.tsx
Normal file
82
ui/src/components/rules/ReplaceTab.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useRuleStore } from "../../stores/ruleStore";
|
||||
import type { ReplaceConfig, StepMode } from "../../types/rules";
|
||||
|
||||
export function ReplaceTab() {
|
||||
const rule = useRuleStore((s) => s.rules.replace) as ReplaceConfig;
|
||||
const updateRule = useRuleStore((s) => s.updateRule);
|
||||
|
||||
const update = (changes: Partial<ReplaceConfig>) => updateRule("replace", changes);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-xs">
|
||||
<div className="flex gap-3">
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Find</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.search}
|
||||
onChange={(e) => update({ search: e.target.value })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
placeholder="Text to find..."
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col flex-1 gap-1">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Replace with</span>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.replace_with}
|
||||
onChange={(e) => update({ replace_with: e.target.value })}
|
||||
className="px-2 py-1.5 rounded border"
|
||||
style={{
|
||||
background: "var(--bg-primary)",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
placeholder="Replacement text..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.match_case}
|
||||
onChange={(e) => update({ match_case: e.target.checked })}
|
||||
/>
|
||||
<span>Match case</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.first_only}
|
||||
onChange={(e) => update({ first_only: e.target.checked })}
|
||||
/>
|
||||
<span>First only</span>
|
||||
</label>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="replace-mode"
|
||||
checked={rule.step_mode === mode}
|
||||
onChange={() => update({ step_mode: mode })}
|
||||
/>
|
||||
<span>{mode}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
ui/src/components/rules/RulePanel.tsx
Normal file
113
ui/src/components/rules/RulePanel.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRuleStore } from "../../stores/ruleStore";
|
||||
import { useFileStore } from "../../stores/fileStore";
|
||||
import { ReplaceTab } from "./ReplaceTab";
|
||||
import { RegexTab } from "./RegexTab";
|
||||
import { RemoveTab } from "./RemoveTab";
|
||||
import { AddTab } from "./AddTab";
|
||||
import { CaseTab } from "./CaseTab";
|
||||
import { NumberingTab } from "./NumberingTab";
|
||||
import { ExtensionTab } from "./ExtensionTab";
|
||||
|
||||
const tabs = [
|
||||
{ id: "replace", label: "Replace" },
|
||||
{ id: "regex", label: "Regex" },
|
||||
{ id: "remove", label: "Remove" },
|
||||
{ id: "add", label: "Add" },
|
||||
{ id: "case", label: "Case" },
|
||||
{ id: "numbering", label: "Number" },
|
||||
{ id: "extension", label: "Extension" },
|
||||
];
|
||||
|
||||
export function RulePanel() {
|
||||
const activeTab = useRuleStore((s) => s.activeTab);
|
||||
const setActiveTab = useRuleStore((s) => s.setActiveTab);
|
||||
const rules = useRuleStore((s) => s.rules);
|
||||
const requestPreview = useRuleStore((s) => s.requestPreview);
|
||||
const currentPath = useFileStore((s) => s.currentPath);
|
||||
|
||||
// auto-preview when rules change
|
||||
useEffect(() => {
|
||||
if (currentPath) {
|
||||
requestPreview(currentPath);
|
||||
}
|
||||
}, [rules, currentPath, requestPreview]);
|
||||
|
||||
function isTabActive(id: string): boolean {
|
||||
const rule = rules[id];
|
||||
if (!rule || !rule.enabled) return false;
|
||||
// check if rule has any non-default values set
|
||||
switch (id) {
|
||||
case "replace":
|
||||
return !!(rule as any).search;
|
||||
case "regex":
|
||||
return !!(rule as any).pattern;
|
||||
case "remove":
|
||||
return (rule as any).first_n > 0 || (rule as any).last_n > 0 || (rule as any).from !== (rule as any).to;
|
||||
case "add":
|
||||
return !!(rule as any).prefix || !!(rule as any).suffix || !!(rule as any).insert;
|
||||
case "case":
|
||||
return (rule as any).mode !== "Same";
|
||||
case "numbering":
|
||||
return (rule as any).mode !== "None";
|
||||
case "extension":
|
||||
return (rule as any).mode !== "Same";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-t flex flex-col"
|
||||
style={{
|
||||
borderColor: "var(--border)",
|
||||
background: "var(--bg-secondary)",
|
||||
height: "240px",
|
||||
minHeight: "160px",
|
||||
}}
|
||||
>
|
||||
{/* tab bar */}
|
||||
<div
|
||||
className="flex border-b shrink-0"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = activeTab === tab.id;
|
||||
const hasContent = isTabActive(tab.id);
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="px-4 py-2 text-xs font-medium relative"
|
||||
style={{
|
||||
color: active ? "var(--accent)" : "var(--text-secondary)",
|
||||
background: active ? "var(--bg-primary)" : "transparent",
|
||||
borderBottom: active ? "2px solid var(--accent)" : "2px solid transparent",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
{hasContent && (
|
||||
<span
|
||||
className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: "var(--accent)" }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* tab content */}
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
{activeTab === "replace" && <ReplaceTab />}
|
||||
{activeTab === "regex" && <RegexTab />}
|
||||
{activeTab === "remove" && <RemoveTab />}
|
||||
{activeTab === "add" && <AddTab />}
|
||||
{activeTab === "case" && <CaseTab />}
|
||||
{activeTab === "numbering" && <NumberingTab />}
|
||||
{activeTab === "extension" && <ExtensionTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
ui/src/hooks/useDebounce.ts
Normal file
12
ui/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
36
ui/src/hooks/useKeyboardShortcuts.ts
Normal file
36
ui/src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useEffect } from "react";
|
||||
import { useFileStore } from "../stores/fileStore";
|
||||
import { useRuleStore } from "../stores/ruleStore";
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const selectAll = useFileStore((s) => s.selectAll);
|
||||
const deselectAll = useFileStore((s) => s.deselectAll);
|
||||
const resetAllRules = useRuleStore((s) => s.resetAllRules);
|
||||
const setActiveTab = useRuleStore((s) => s.setActiveTab);
|
||||
|
||||
useEffect(() => {
|
||||
const tabs = ["replace", "regex", "remove", "add", "case", "numbering", "extension"];
|
||||
|
||||
function handler(e: KeyboardEvent) {
|
||||
if (e.ctrlKey && e.key === "a" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectAll();
|
||||
}
|
||||
if (e.ctrlKey && e.shiftKey && e.key === "A") {
|
||||
e.preventDefault();
|
||||
deselectAll();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
resetAllRules();
|
||||
}
|
||||
if (e.ctrlKey && e.key >= "1" && e.key <= "7") {
|
||||
e.preventDefault();
|
||||
const idx = parseInt(e.key) - 1;
|
||||
if (idx < tabs.length) setActiveTab(tabs[idx]);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [selectAll, deselectAll, resetAllRules, setActiveTab]);
|
||||
}
|
||||
78
ui/src/index.css
Normal file
78
ui/src/index.css
Normal file
@@ -0,0 +1,78 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--bg-tertiary: #e9ecef;
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #6c757d;
|
||||
--border: #dee2e6;
|
||||
--accent: #4f46e5;
|
||||
--accent-hover: #4338ca;
|
||||
--success: #16a34a;
|
||||
--warning: #d97706;
|
||||
--error: #dc2626;
|
||||
--row-even: #ffffff;
|
||||
--row-odd: #f8f9fb;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-tertiary: #0f3460;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--border: #334155;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #818cf8;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--row-even: #1a1a2e;
|
||||
--row-odd: #1e2240;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
input, select, button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
10
ui/src/main.tsx
Normal file
10
ui/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
85
ui/src/stores/fileStore.ts
Normal file
85
ui/src/stores/fileStore.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { create } from "zustand";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { FileEntry, FilterConfig, PreviewResult } from "../types/files";
|
||||
|
||||
interface FileState {
|
||||
currentPath: string;
|
||||
files: FileEntry[];
|
||||
previewResults: PreviewResult[];
|
||||
selectedFiles: Set<string>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
filters: FilterConfig;
|
||||
|
||||
setCurrentPath: (path: string) => void;
|
||||
scanDirectory: (path: string) => Promise<void>;
|
||||
setPreviewResults: (results: PreviewResult[]) => void;
|
||||
toggleFileSelection: (path: string) => void;
|
||||
selectAll: () => void;
|
||||
deselectAll: () => void;
|
||||
setFilters: (filters: Partial<FilterConfig>) => void;
|
||||
}
|
||||
|
||||
export const useFileStore = create<FileState>((set, get) => ({
|
||||
currentPath: "",
|
||||
files: [],
|
||||
previewResults: [],
|
||||
selectedFiles: new Set(),
|
||||
loading: false,
|
||||
error: null,
|
||||
filters: {
|
||||
mask: "*",
|
||||
regex_filter: null,
|
||||
min_size: null,
|
||||
max_size: null,
|
||||
include_files: true,
|
||||
include_folders: false,
|
||||
include_hidden: false,
|
||||
subfolder_depth: 0,
|
||||
},
|
||||
|
||||
setCurrentPath: (path) => set({ currentPath: path }),
|
||||
|
||||
scanDirectory: async (path) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const files = await invoke<FileEntry[]>("scan_directory", {
|
||||
path,
|
||||
filters: get().filters,
|
||||
});
|
||||
set({
|
||||
files,
|
||||
currentPath: path,
|
||||
loading: false,
|
||||
selectedFiles: new Set(files.map((f) => f.path)),
|
||||
previewResults: [],
|
||||
});
|
||||
} catch (e) {
|
||||
set({ error: String(e), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setPreviewResults: (results) => set({ previewResults: results }),
|
||||
|
||||
toggleFileSelection: (path) => {
|
||||
const selected = new Set(get().selectedFiles);
|
||||
if (selected.has(path)) {
|
||||
selected.delete(path);
|
||||
} else {
|
||||
selected.add(path);
|
||||
}
|
||||
set({ selectedFiles: selected });
|
||||
},
|
||||
|
||||
selectAll: () => {
|
||||
set({ selectedFiles: new Set(get().files.map((f) => f.path)) });
|
||||
},
|
||||
|
||||
deselectAll: () => {
|
||||
set({ selectedFiles: new Set() });
|
||||
},
|
||||
|
||||
setFilters: (filters) => {
|
||||
set({ filters: { ...get().filters, ...filters } });
|
||||
},
|
||||
}));
|
||||
88
ui/src/stores/ruleStore.ts
Normal file
88
ui/src/stores/ruleStore.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { create } from "zustand";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { PreviewResult } from "../types/files";
|
||||
import type { RuleConfig } from "../types/rules";
|
||||
import { useFileStore } from "./fileStore";
|
||||
import {
|
||||
defaultReplace,
|
||||
defaultRegex,
|
||||
defaultRemove,
|
||||
defaultAdd,
|
||||
defaultCase,
|
||||
defaultNumbering,
|
||||
defaultExtension,
|
||||
} from "../types/rules";
|
||||
|
||||
interface RuleState {
|
||||
rules: Record<string, RuleConfig>;
|
||||
activeTab: string;
|
||||
previewDebounceTimer: ReturnType<typeof setTimeout> | null;
|
||||
|
||||
setActiveTab: (tab: string) => void;
|
||||
updateRule: (type: string, updates: Partial<RuleConfig>) => void;
|
||||
resetRule: (type: string) => void;
|
||||
resetAllRules: () => void;
|
||||
requestPreview: (directory: string) => void;
|
||||
}
|
||||
|
||||
function getDefaults(): Record<string, RuleConfig> {
|
||||
return {
|
||||
replace: defaultReplace(),
|
||||
regex: defaultRegex(),
|
||||
remove: defaultRemove(),
|
||||
add: defaultAdd(),
|
||||
case: defaultCase(),
|
||||
numbering: defaultNumbering(),
|
||||
extension: defaultExtension(),
|
||||
};
|
||||
}
|
||||
|
||||
export const useRuleStore = create<RuleState>((set, get) => ({
|
||||
rules: getDefaults(),
|
||||
activeTab: "replace",
|
||||
previewDebounceTimer: null,
|
||||
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
|
||||
updateRule: (type, updates) => {
|
||||
const rules = { ...get().rules };
|
||||
rules[type] = { ...rules[type], ...updates };
|
||||
set({ rules });
|
||||
},
|
||||
|
||||
resetRule: (type) => {
|
||||
const defaults = getDefaults();
|
||||
const rules = { ...get().rules };
|
||||
rules[type] = defaults[type];
|
||||
set({ rules });
|
||||
},
|
||||
|
||||
resetAllRules: () => {
|
||||
set({ rules: getDefaults() });
|
||||
},
|
||||
|
||||
requestPreview: (directory) => {
|
||||
const timer = get().previewDebounceTimer;
|
||||
if (timer) clearTimeout(timer);
|
||||
|
||||
const newTimer = setTimeout(async () => {
|
||||
const { rules } = get();
|
||||
const activeRules = Object.values(rules).filter((r) => r.enabled);
|
||||
|
||||
if (activeRules.length === 0 || !directory) return;
|
||||
|
||||
try {
|
||||
const results = await invoke<PreviewResult[]>("preview_rename", {
|
||||
rules: activeRules,
|
||||
directory,
|
||||
});
|
||||
|
||||
useFileStore.getState().setPreviewResults(results);
|
||||
} catch (e) {
|
||||
console.error("Preview failed:", e);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
set({ previewDebounceTimer: newTimer });
|
||||
},
|
||||
}));
|
||||
13
ui/src/stores/settingsStore.ts
Normal file
13
ui/src/stores/settingsStore.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface SettingsState {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set) => ({
|
||||
theme: "system",
|
||||
setTheme: (theme) => set({ theme }),
|
||||
}));
|
||||
32
ui/src/types/files.ts
Normal file
32
ui/src/types/files.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface FileEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
stem: string;
|
||||
extension: string;
|
||||
size: number;
|
||||
is_dir: boolean;
|
||||
is_hidden: boolean;
|
||||
created: string | null;
|
||||
modified: string | null;
|
||||
accessed: string | null;
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
original_path: string;
|
||||
original_name: string;
|
||||
new_name: string;
|
||||
has_conflict: boolean;
|
||||
has_error: boolean;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export interface FilterConfig {
|
||||
mask: string;
|
||||
regex_filter: string | null;
|
||||
min_size: number | null;
|
||||
max_size: number | null;
|
||||
include_files: boolean;
|
||||
include_folders: boolean;
|
||||
include_hidden: boolean;
|
||||
subfolder_depth: number | null;
|
||||
}
|
||||
17
ui/src/types/presets.ts
Normal file
17
ui/src/types/presets.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { FilterConfig } from "./files";
|
||||
import type { RuleConfig } from "./rules";
|
||||
|
||||
export interface NominaPreset {
|
||||
version: number;
|
||||
name: string;
|
||||
description: string;
|
||||
created: string;
|
||||
rules: RuleConfig[];
|
||||
filters: FilterConfig | null;
|
||||
}
|
||||
|
||||
export interface PresetInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
}
|
||||
154
ui/src/types/rules.ts
Normal file
154
ui/src/types/rules.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
export type StepMode = "Simultaneous" | "Sequential";
|
||||
|
||||
export interface RuleConfig {
|
||||
type: string;
|
||||
step_mode: StepMode;
|
||||
enabled: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ReplaceConfig extends RuleConfig {
|
||||
type: "replace";
|
||||
search: string;
|
||||
replace_with: string;
|
||||
match_case: boolean;
|
||||
first_only: boolean;
|
||||
}
|
||||
|
||||
export interface RegexConfig extends RuleConfig {
|
||||
type: "regex";
|
||||
pattern: string;
|
||||
replace_with: string;
|
||||
case_insensitive: boolean;
|
||||
}
|
||||
|
||||
export interface RemoveConfig extends RuleConfig {
|
||||
type: "remove";
|
||||
first_n: number;
|
||||
last_n: number;
|
||||
from: number;
|
||||
to: number;
|
||||
crop_before: string | null;
|
||||
crop_after: string | null;
|
||||
}
|
||||
|
||||
export interface AddConfig extends RuleConfig {
|
||||
type: "add";
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
insert: string;
|
||||
insert_at: number;
|
||||
word_space: boolean;
|
||||
}
|
||||
|
||||
export interface CaseConfig extends RuleConfig {
|
||||
type: "case";
|
||||
mode: "Same" | "Upper" | "Lower" | "Title" | "Sentence" | "Invert" | "Random";
|
||||
exceptions: string;
|
||||
}
|
||||
|
||||
export interface NumberingConfig extends RuleConfig {
|
||||
type: "numbering";
|
||||
mode: "None" | "Prefix" | "Suffix" | "Both" | "Insert";
|
||||
start: number;
|
||||
increment: number;
|
||||
padding: number;
|
||||
separator: string;
|
||||
break_at: number;
|
||||
base: "Decimal" | "Hex" | "Octal" | "Binary" | "Alpha";
|
||||
per_folder: boolean;
|
||||
insert_at: number;
|
||||
}
|
||||
|
||||
export interface ExtensionConfig extends RuleConfig {
|
||||
type: "extension";
|
||||
mode: "Same" | "Lower" | "Upper" | "Title" | "Extra" | "Remove" | "Fixed";
|
||||
fixed_value: string;
|
||||
}
|
||||
|
||||
export function defaultReplace(): ReplaceConfig {
|
||||
return {
|
||||
type: "replace",
|
||||
step_mode: "Simultaneous",
|
||||
enabled: true,
|
||||
search: "",
|
||||
replace_with: "",
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultRegex(): RegexConfig {
|
||||
return {
|
||||
type: "regex",
|
||||
step_mode: "Simultaneous",
|
||||
enabled: true,
|
||||
pattern: "",
|
||||
replace_with: "",
|
||||
case_insensitive: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultRemove(): RemoveConfig {
|
||||
return {
|
||||
type: "remove",
|
||||
step_mode: "Simultaneous",
|
||||
enabled: true,
|
||||
first_n: 0,
|
||||
last_n: 0,
|
||||
from: 0,
|
||||
to: 0,
|
||||
crop_before: null,
|
||||
crop_after: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultAdd(): AddConfig {
|
||||
return {
|
||||
type: "add",
|
||||
step_mode: "Simultaneous",
|
||||
enabled: true,
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
insert: "",
|
||||
insert_at: 0,
|
||||
word_space: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultCase(): CaseConfig {
|
||||
return {
|
||||
type: "case",
|
||||
step_mode: "Simultaneous",
|
||||
enabled: true,
|
||||
mode: "Same",
|
||||
exceptions: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultNumbering(): NumberingConfig {
|
||||
return {
|
||||
type: "numbering",
|
||||
step_mode: "Sequential",
|
||||
enabled: true,
|
||||
mode: "None",
|
||||
start: 1,
|
||||
increment: 1,
|
||||
padding: 1,
|
||||
separator: "_",
|
||||
break_at: 0,
|
||||
base: "Decimal",
|
||||
per_folder: false,
|
||||
insert_at: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultExtension(): ExtensionConfig {
|
||||
return {
|
||||
type: "extension",
|
||||
step_mode: "Simultaneous",
|
||||
enabled: true,
|
||||
mode: "Same",
|
||||
fixed_value: "",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user