pipeline cards, context menus, presets, settings overhaul

rewrote pipeline as draggable card strip with per-rule config popovers,
added right-click menus to pipeline cards, sidebar tree, and file list,
preset import/export with BRU format support, new rules (hash, swap,
truncate, sanitize, padding, randomize, text editor, folder name,
transliterate), settings dialog with all sections, overlay collision
containment, tooltips on icon buttons, empty pipeline default
This commit is contained in:
2026-03-14 19:04:35 +02:00
parent 9dca2bedfa
commit 6f5b862234
105 changed files with 17257 additions and 1369 deletions

25
ui/components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "tabler",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

5778
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,14 +9,32 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.8",
"@tabler/icons-react": "^3.40.0",
"@tanstack/react-virtual": "^3.13.22",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-shell": "^2.3.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.36.0",
"overlayscrollbars": "^2.14.0",
"overlayscrollbars-react": "^0.5.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"shadcn": "^4.0.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/api": "^2.10.1",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",

View File

@@ -1,7 +1,16 @@
import { AppShell } from "./components/layout/AppShell";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import { AppShell } from "@/components/layout/AppShell";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
import { useTheme } from "@/hooks/useTheme";
import { useWindowState } from "@/hooks/useWindowState";
import { TooltipProvider } from "@/components/ui/tooltip";
export default function App() {
useKeyboardShortcuts();
return <AppShell />;
useTheme();
useWindowState();
return (
<TooltipProvider delayDuration={300}>
<AppShell />
</TooltipProvider>
);
}

View File

@@ -1,6 +1,40 @@
import { useRef } from "react";
import { useRef, useState, useCallback, useMemo, useEffect } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useFileStore } from "../../stores/fileStore";
import { useOverlayScrollbars } from "overlayscrollbars-react";
import { invoke } from "@tauri-apps/api/core";
import { useFileStore } from "@/stores/fileStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { Checkbox } from "@/components/ui/checkbox";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
IconCheck,
IconAlertTriangle,
IconX,
IconFileDescription,
IconArrowRight,
IconSelect,
IconDeselect,
IconCopy,
IconClipboard,
IconEye,
IconEyeOff,
IconArrowsSort,
IconFolder,
IconFile,
IconArrowUp,
IconChevronUp,
IconChevronDown,
IconExternalLink,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "framer-motion";
import { announce } from "@/hooks/useAnnounce";
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -9,119 +43,844 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function formatDateAbsolute(iso: string | null): string {
if (!iso) return "-";
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function formatDateRelative(iso: string | null): string {
if (!iso) return "-";
const d = new Date(iso);
const now = Date.now();
const diff = now - d.getTime();
const secs = Math.floor(diff / 1000);
if (secs < 60) return "just now";
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
const DEFAULT_COL_WIDTHS = {
original: 0, // flex
renamed: 0, // flex
size: 72,
date: 120,
status: 80,
};
export function FileList() {
const files = useFileStore((s) => s.files);
const allEntries = useFileStore((s) => s.files);
const previewResults = useFileStore((s) => s.previewResults);
const selectedFiles = useFileStore((s) => s.selectedFiles);
const toggleFileSelection = useFileStore((s) => s.toggleFileSelection);
const currentPath = useFileStore((s) => s.currentPath);
const scanDirectory = useFileStore((s) => s.scanDirectory);
const zoom = useSettingsStore((s) => s.zoom);
const doubleClickAction = useSettingsStore((s) => s.doubleClickAction);
const showFullPath = useSettingsStore((s) => s.showFullPath);
const zebraStriping = useSettingsStore((s) => s.zebraStriping);
const dateFormat = useSettingsStore((s) => s.dateFormat);
const parentRef = useRef<HTMLDivElement>(null);
const [initOverlayScrollbars] = useOverlayScrollbars({
options: { scrollbars: { autoHide: "move", autoHideDelay: 600 } },
defer: true,
});
useEffect(() => {
if (parentRef.current) initOverlayScrollbars(parentRef.current);
}, [initOverlayScrollbars]);
const [contextFile, setContextFile] = useState<string | null>(null);
const lastClickedIdx = useRef<number>(-1);
const previewMap = new Map(previewResults.map((r) => [r.original_path, r]));
type SortKey = "original" | "renamed" | "size" | "status" | "date" | "type";
type SortDir = "asc" | "desc";
const defaultSort = useSettingsStore((s) => s.defaultSortOrder);
const locale = useSettingsStore((s) => s.locale);
const sortLocale = locale === "system" ? undefined : locale;
const sortKeyMap: Record<string, SortKey> = { name: "original", size: "size", date: "date", type: "type" };
const [sortKey, setSortKey] = useState<SortKey | null>(sortKeyMap[defaultSort] ?? null);
const [sortDir, setSortDir] = useState<SortDir>("asc");
// column widths (fixed columns only; original/renamed use flex)
const [colWidths, setColWidths] = useState({ ...DEFAULT_COL_WIDTHS });
const resizingCol = useRef<{ col: string; startX: number; startW: number } | null>(null);
function toggleSort(key: SortKey) {
if (sortKey === key) {
if (sortDir === "asc") {
setSortDir("desc");
announce(`Sorted by ${key}, descending`);
} else {
setSortKey(null);
setSortDir("asc");
announce("Sort cleared");
}
} else {
setSortKey(key);
setSortDir("asc");
announce(`Sorted by ${key}, ascending`);
}
}
const onResizePointerDown = useCallback((e: React.PointerEvent, col: string) => {
e.stopPropagation();
e.preventDefault();
resizingCol.current = { col, startX: e.clientX, startW: (colWidths as any)[col] };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [colWidths]);
const onResizePointerMove = useCallback((e: React.PointerEvent) => {
if (!resizingCol.current) return;
const { col, startX, startW } = resizingCol.current;
const z = useSettingsStore.getState().zoom;
const delta = (e.clientX - startX) / z;
const newW = Math.max(40, startW + delta);
setColWidths((prev) => ({ ...prev, [col]: newW }));
}, []);
const onResizePointerUp = useCallback(() => {
resizingCol.current = null;
}, []);
// marquee
const dragRef = useRef(false);
const didDragRef = useRef(false);
const startRef = useRef({ x: 0, y: 0 });
const [marqueeBox, setMarqueeBox] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const previewMap = useMemo(
() => new Map(previewResults.map((r) => [r.original_path, r])),
[previewResults],
);
// separate folders and files, folders first
const folders = useMemo(() => allEntries.filter((f) => f.is_dir), [allEntries]);
const unsortedFiles = useMemo(() => allEntries.filter((f) => !f.is_dir), [allEntries]);
const sortFn = useCallback((a: typeof allEntries[0], b: typeof allEntries[0]) => {
if (!sortKey) return 0;
const dir = sortDir === "asc" ? 1 : -1;
switch (sortKey) {
case "original":
return dir * a.name.localeCompare(b.name, sortLocale, { sensitivity: "base" });
case "renamed": {
const aPreview = previewMap.get(a.path);
const bPreview = previewMap.get(b.path);
return dir * (aPreview?.new_name || a.name).localeCompare(bPreview?.new_name || b.name, sortLocale, { sensitivity: "base" });
}
case "size":
return dir * (a.size - b.size);
case "date": {
const aTime = a.modified ? new Date(a.modified).getTime() : 0;
const bTime = b.modified ? new Date(b.modified).getTime() : 0;
return dir * (aTime - bTime);
}
case "type":
return dir * (a.extension || "").localeCompare(b.extension || "", sortLocale, { sensitivity: "base" });
case "status": {
const aPreview = previewMap.get(a.path);
const bPreview = previewMap.get(b.path);
const aScore = aPreview?.has_conflict ? 3 : aPreview?.has_error ? 2 : (aPreview && aPreview.new_name !== aPreview.original_name) ? 1 : 0;
const bScore = bPreview?.has_conflict ? 3 : bPreview?.has_error ? 2 : (bPreview && bPreview.new_name !== bPreview.original_name) ? 1 : 0;
return dir * (aScore - bScore);
}
default:
return 0;
}
}, [sortKey, sortDir, previewMap, sortLocale]);
const sortedFolders = useMemo(() => {
if (!sortKey) return folders;
return [...folders].sort(sortFn);
}, [folders, sortFn, sortKey]);
const files = useMemo(() => {
if (!sortKey) return unsortedFiles;
return [...unsortedFiles].sort(sortFn);
}, [unsortedFiles, sortFn, sortKey]);
const setSortedFilePaths = useFileStore((s) => s.setSortedFilePaths);
const prevFileCountRef = useRef(0);
useEffect(() => {
const allPaths = [...sortedFolders, ...files].map((f) => f.path);
setSortedFilePaths(allPaths);
const total = sortedFolders.length + files.length;
if (total !== prevFileCountRef.current && total > 0) {
announce(`${files.length} files and ${sortedFolders.length} folders loaded`);
}
prevFileCountRef.current = total;
}, [sortedFolders, files, setSortedFilePaths]);
// build display rows: parent nav + folders + files
const hasParent = currentPath && currentPath.length > 3; // more than just "C:\"
const parentRow = hasParent ? 1 : 0;
const totalRows = parentRow + sortedFolders.length + files.length;
function getEntry(displayIndex: number) {
if (displayIndex < parentRow) return null; // parent row
const idx = displayIndex - parentRow;
if (idx < sortedFolders.length) return sortedFolders[idx];
return files[idx - sortedFolders.length];
}
const compactMode = useSettingsStore((s) => s.compactMode);
const rowHeight = compactMode ? 26 : 32;
const rowVirtualizer = useVirtualizer({
count: files.length,
count: totalRows,
getScrollElement: () => parentRef.current,
estimateSize: () => 28,
estimateSize: () => rowHeight,
overscan: 20,
});
if (files.length === 0) {
const formatDate = useCallback(
(iso: string | null) => dateFormat === "relative" ? formatDateRelative(iso) : formatDateAbsolute(iso),
[dateFormat],
);
function handleFileDoubleClick(path: string) {
if (doubleClickAction === "open") {
invoke("reveal_in_explorer", { path }).catch(() => {});
}
}
function navigateUp() {
if (!currentPath) return;
const normalized = currentPath.replace(/\\/g, "/").replace(/\/$/, "");
const parent = normalized.replace(/\/[^/]+$/, "");
if (!parent) return;
// ensure drive roots keep trailing backslash (e.g. "D:\")
let result = parent.replace(/\//g, "\\");
if (/^[A-Za-z]:$/.test(result)) result += "\\";
scanDirectory(result);
}
function navigateToFolder(path: string) {
scanDirectory(path);
}
const onPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest("button") || target.closest("label") || target.closest("input") || target.tagName === "BUTTON") return;
if (target.closest("[role='checkbox']")) return;
dragRef.current = true;
didDragRef.current = false;
startRef.current = { x: e.clientX, y: e.clientY };
setMarqueeBox(null);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragRef.current) return;
const sx = startRef.current.x;
const sy = startRef.current.y;
const cx = e.clientX;
const cy = e.clientY;
const w = Math.abs(cx - sx);
const h = Math.abs(cy - sy);
if (w > 4 || h > 4) {
didDragRef.current = true;
// account for root padding and zoom - fixed positioning is relative
// to the nearest transformed ancestor (the window-frame)
const pad = document.documentElement.classList.contains("maximized") ? 0 : 20;
setMarqueeBox({
x: (Math.min(sx, cx) - pad) / zoom,
y: (Math.min(sy, cy) - pad) / zoom,
w: w / zoom,
h: h / zoom,
});
}
}, [zoom]);
const onPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragRef.current) return;
dragRef.current = false;
const sx = startRef.current.x;
const sy = startRef.current.y;
const x1 = Math.min(sx, e.clientX);
const y1 = Math.min(sy, e.clientY);
const x2 = Math.max(sx, e.clientX);
const y2 = Math.max(sy, e.clientY);
setMarqueeBox(null);
if (!didDragRef.current) return; // handled by onClick instead
const scrollEl = parentRef.current;
if (!scrollEl) return;
const newSelected = new Set<string>();
const rows = scrollEl.querySelectorAll<HTMLElement>("[data-file-path], [data-folder-path]");
rows.forEach((row) => {
const r = row.getBoundingClientRect();
if (r.left < x2 && r.right > x1 && r.top < y2 && r.bottom > y1) {
const path = row.getAttribute("data-file-path") || row.getAttribute("data-folder-path");
if (path) newSelected.add(path);
}
});
// if marquee selected nothing, deselect all
useFileStore.setState({ selectedFiles: newSelected });
}, []);
// all entries in display order for shift-select across folders and files
const allDisplayEntries = useMemo(() => [...sortedFolders, ...files], [sortedFolders, files]);
function handleRowClick(e: React.MouseEvent, filePath: string, index: number) {
// don't handle if it was a drag
if (didDragRef.current) return;
const target = e.target as HTMLElement;
if (target.closest("[role='checkbox']") || target.closest("button") || target.closest("label")) return;
if (e.shiftKey && lastClickedIdx.current >= 0) {
const from = Math.min(lastClickedIdx.current, index);
const to = Math.max(lastClickedIdx.current, index);
const rangeSelected = new Set(e.ctrlKey ? selectedFiles : new Set<string>());
for (let i = from; i <= to; i++) {
rangeSelected.add(allDisplayEntries[i].path);
}
useFileStore.setState({ selectedFiles: rangeSelected });
} else if (e.ctrlKey) {
toggleFileSelection(filePath);
} else {
if (selectedFiles.size === 1 && selectedFiles.has(filePath)) {
useFileStore.setState({ selectedFiles: new Set<string>() });
} else {
useFileStore.setState({ selectedFiles: new Set([filePath]) });
}
}
lastClickedIdx.current = index;
}
function handleFolderDoubleClick(path: string) {
navigateToFolder(path);
}
function handleBackgroundClick(e: React.MouseEvent) {
if (didDragRef.current) return;
// only deselect when clicking actual empty space (the scroll container itself, not a row)
const target = e.target as HTMLElement;
if (target.closest("[data-file-path]") || target.closest("[data-folder-path]") || target.closest("[role='checkbox']")) return;
useFileStore.setState({ selectedFiles: new Set<string>() });
}
function handleSelectAll() {
useFileStore.getState().selectAll();
}
function handleDeselectAll() {
useFileStore.getState().deselectAll();
}
function handleSelectChanged() {
const changed = new Set<string>();
previewResults.forEach((r) => {
if (r.original_name !== r.new_name && !r.has_error && !r.has_conflict) {
changed.add(r.original_path);
}
});
useFileStore.setState({ selectedFiles: changed });
}
function handleInvertSelection() {
const inverted = new Set<string>();
allDisplayEntries.forEach((f) => {
if (!selectedFiles.has(f.path)) inverted.add(f.path);
});
useFileStore.setState({ selectedFiles: inverted });
}
function handleCopyNames() {
const names = files
.filter((f) => selectedFiles.has(f.path))
.map((f) => f.name)
.join("\n");
navigator.clipboard.writeText(names);
}
function handleCopyNewNames() {
const names = files
.filter((f) => selectedFiles.has(f.path))
.map((f) => {
const preview = previewMap.get(f.path);
return preview?.new_name || f.name;
})
.join("\n");
navigator.clipboard.writeText(names);
}
if (allEntries.length === 0 && !currentPath) {
return (
<div
className="flex items-center justify-center h-full text-sm"
style={{ color: "var(--text-secondary)" }}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground"
>
Navigate to a folder to see files
</div>
<motion.div
animate={{ y: [0, -6, 0], rotate: [0, -3, 3, 0] }}
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
>
<IconFileDescription size={40} stroke={1} className="opacity-40" />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.3 }}
className="text-sm"
>
Navigate to a folder to see files
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.6 }}
transition={{ delay: 0.4, duration: 0.3 }}
className="text-xs"
>
Use the sidebar to browse directories
</motion.div>
</motion.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)",
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex flex-col h-full relative" role="grid" aria-label="File list" aria-rowcount={totalRows + 1} aria-colcount={7}>
{/* header */}
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
role="row"
aria-label="Column headers"
className="flex items-center text-[11px] font-medium border-b px-1 py-1.5 shrink-0 bg-muted/50 text-muted-foreground uppercase tracking-wider"
>
<div role="columnheader" aria-colindex={1} className="w-8 flex items-center justify-center">
<Checkbox
checked={allDisplayEntries.length > 0 && selectedFiles.size === allDisplayEntries.length}
onCheckedChange={(checked) => {
if (checked) handleSelectAll();
else handleDeselectAll();
}}
>
<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>
);
})}
aria-label="Select all files"
/>
</div>
<button role="columnheader" aria-colindex={2} aria-sort={sortKey === "original" ? sortDir === "asc" ? "ascending" : "descending" : "none"} onClick={() => toggleSort("original")} aria-label={`Sort by original name${sortKey === "original" ? (sortDir === "asc" ? ", ascending" : ", descending") : ""}`} className="flex-[2] min-w-0 px-2 flex items-center gap-0.5 hover:text-foreground transition-colors cursor-pointer">
Original
{sortKey === "original" && (sortDir === "asc" ? <IconChevronUp size={10} stroke={2} /> : <IconChevronDown size={10} stroke={2} />)}
</button>
<div role="columnheader" aria-colindex={3} className="w-6 flex items-center justify-center" aria-hidden="true">
<IconArrowRight size={12} stroke={1.5} className="opacity-40" />
</div>
<button role="columnheader" aria-colindex={4} aria-sort={sortKey === "renamed" ? sortDir === "asc" ? "ascending" : "descending" : "none"} onClick={() => toggleSort("renamed")} aria-label={`Sort by renamed${sortKey === "renamed" ? (sortDir === "asc" ? ", ascending" : ", descending") : ""}`} className="flex-[2] min-w-0 px-2 flex items-center gap-0.5 hover:text-foreground transition-colors cursor-pointer">
Renamed
{sortKey === "renamed" && (sortDir === "asc" ? <IconChevronUp size={10} stroke={2} /> : <IconChevronDown size={10} stroke={2} />)}
</button>
<ColResizeHandle col="size" onPointerDown={(e) => onResizePointerDown(e, "size")} onPointerMove={onResizePointerMove} onPointerUp={onResizePointerUp} colWidths={colWidths} setColWidths={setColWidths} />
<button role="columnheader" aria-colindex={5} aria-sort={sortKey === "size" ? sortDir === "asc" ? "ascending" : "descending" : "none"} onClick={() => toggleSort("size")} aria-label={`Sort by size${sortKey === "size" ? (sortDir === "asc" ? ", ascending" : ", descending") : ""}`} style={{ width: colWidths.size }} className="px-2 flex items-center justify-end gap-0.5 hover:text-foreground transition-colors cursor-pointer shrink-0">
Size
{sortKey === "size" && (sortDir === "asc" ? <IconChevronUp size={10} stroke={2} /> : <IconChevronDown size={10} stroke={2} />)}
</button>
<ColResizeHandle col="date" onPointerDown={(e) => onResizePointerDown(e, "date")} onPointerMove={onResizePointerMove} onPointerUp={onResizePointerUp} colWidths={colWidths} setColWidths={setColWidths} />
<button role="columnheader" aria-colindex={6} aria-sort={sortKey === "date" ? sortDir === "asc" ? "ascending" : "descending" : "none"} onClick={() => toggleSort("date")} aria-label={`Sort by date${sortKey === "date" ? (sortDir === "asc" ? ", ascending" : ", descending") : ""}`} style={{ width: colWidths.date }} className="px-2 flex items-center justify-end gap-0.5 hover:text-foreground transition-colors cursor-pointer shrink-0">
Modified
{sortKey === "date" && (sortDir === "asc" ? <IconChevronUp size={10} stroke={2} /> : <IconChevronDown size={10} stroke={2} />)}
</button>
<ColResizeHandle col="status" onPointerDown={(e) => onResizePointerDown(e, "status")} onPointerMove={onResizePointerMove} onPointerUp={onResizePointerUp} colWidths={colWidths} setColWidths={setColWidths} />
<button role="columnheader" aria-colindex={7} aria-sort={sortKey === "status" ? sortDir === "asc" ? "ascending" : "descending" : "none"} onClick={() => toggleSort("status")} aria-label={`Sort by status${sortKey === "status" ? (sortDir === "asc" ? ", ascending" : ", descending") : ""}`} style={{ width: colWidths.status }} className="px-2 flex items-center justify-center gap-0.5 hover:text-foreground transition-colors cursor-pointer shrink-0">
Status
{sortKey === "status" && (sortDir === "asc" ? <IconChevronUp size={10} stroke={2} /> : <IconChevronDown size={10} stroke={2} />)}
</button>
</motion.div>
{/* virtual rows */}
<div
ref={parentRef}
className="flex-1 overflow-auto relative"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={handleBackgroundClick}
style={{ touchAction: "none" }}
>
<div style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: "relative" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
// parent directory row
if (virtualRow.index < parentRow) {
return (
<div
key="__parent__"
role="row"
aria-rowindex={2}
aria-label="Go to parent directory"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); navigateUp(); } }}
className="flex items-center text-[13px] px-1 absolute w-full border-b border-border/50 hover:bg-muted/50 cursor-pointer"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
onClick={navigateUp}
>
<div className="w-8 flex items-center justify-center">
<IconArrowUp size={14} stroke={1.5} className="text-muted-foreground" />
</div>
<div className="flex-1 min-w-0 px-2 text-[12px] text-muted-foreground">..</div>
</div>
);
}
const entry = getEntry(virtualRow.index)!;
const isDir = entry.is_dir;
const preview = previewMap.get(entry.path);
const isSelected = selectedFiles.has(entry.path);
const changed = preview && preview.new_name !== preview.original_name;
const hasError = preview?.has_error;
const hasConflict = preview?.has_conflict;
// entry index relative to allDisplayEntries for shift-select
const entryIndex = isDir
? virtualRow.index - parentRow
: virtualRow.index - parentRow;
const isOdd = virtualRow.index % 2 === 1;
// folder row - single click selects, double click navigates
if (isDir) {
return (
<div
key={entry.path}
data-folder-path={entry.path}
role="row"
aria-rowindex={virtualRow.index + 2}
aria-selected={isSelected}
aria-label={`Folder: ${entry.name}${changed ? ", will be renamed to " + preview?.new_name : ""}`}
onContextMenu={() => setContextFile(entry.path)}
className={cn(
"flex items-center text-[13px] px-1 absolute w-full border-b border-border/50 transition-colors",
isSelected && "bg-primary/[0.06]",
hasConflict && "bg-destructive/5",
hasError && !hasConflict && "bg-warning/5",
changed && !hasError && !hasConflict && !isSelected && "bg-primary/[0.03]",
!changed && !hasError && !hasConflict && !isSelected && "hover:bg-muted/50",
zebraStriping && isOdd && !isSelected && !hasConflict && !hasError && !changed && "bg-muted/30",
)}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
onClick={(e) => handleRowClick(e, entry.path, entryIndex)}
onDoubleClick={() => handleFolderDoubleClick(entry.path)}
>
<div className="w-8 flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleFileSelection(entry.path)}
aria-label={`Select folder ${entry.name}`}
/>
</div>
<IconFolder size={14} stroke={1.5} className="text-primary/70 shrink-0" />
<div className="flex-[2] min-w-0 px-2 truncate font-mono text-[12px] font-medium">
{entry.name}
</div>
<div className="w-6 flex items-center justify-center">
<AnimatePresence>
{changed && (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
>
<IconArrowRight size={12} stroke={1.5} className="text-primary/60" />
</motion.div>
)}
</AnimatePresence>
</div>
<div className={cn(
"flex-[2] min-w-0 px-2 truncate font-mono text-[12px]",
changed ? "text-primary font-medium" : "text-muted-foreground/50"
)}>
{preview?.new_name || entry.name}
</div>
<div style={{ width: colWidths.size }} className="px-2 text-right text-[11px] text-muted-foreground shrink-0">-</div>
<div style={{ width: colWidths.date }} className="px-2 text-right text-[11px] text-muted-foreground tabular-nums shrink-0">
{formatDate(entry.modified)}
</div>
<div style={{ width: colWidths.status }} className="px-2 flex items-center justify-center gap-1 shrink-0">
<AnimatePresence mode="wait">
{hasConflict && (
<motion.span key="conflict" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} className="inline-flex items-center gap-1 text-[11px] text-destructive">
<IconX size={12} stroke={2} /> Conflict
</motion.span>
)}
{changed && !hasError && !hasConflict && (
<motion.span key="ok" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} className="inline-flex items-center gap-1 text-[11px] text-primary">
<IconCheck size={12} stroke={2} /> OK
</motion.span>
)}
</AnimatePresence>
</div>
</div>
);
}
// file row
return (
<div
key={entry.path}
data-file-path={entry.path}
role="row"
aria-rowindex={virtualRow.index + 2}
aria-selected={isSelected}
aria-label={`File: ${entry.name}${changed ? ", will be renamed to " + preview?.new_name : ""}${hasConflict ? ", has conflict" : ""}${hasError ? ", has error" : ""}`}
onContextMenu={() => setContextFile(entry.path)}
className={cn(
"flex items-center text-[13px] px-1 absolute w-full border-b border-border/50 transition-colors",
isSelected && "bg-primary/[0.06]",
hasConflict && "bg-destructive/5",
hasError && !hasConflict && "bg-warning/5",
changed && !hasError && !hasConflict && !isSelected && "bg-primary/[0.03]",
!changed && !hasError && !hasConflict && !isSelected && "hover:bg-muted/50",
zebraStriping && isOdd && !isSelected && !hasConflict && !hasError && !changed && "bg-muted/30",
)}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
onClick={(e) => handleRowClick(e, entry.path, entryIndex)}
onDoubleClick={() => handleFileDoubleClick(entry.path)}
>
<div className="w-8 flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleFileSelection(entry.path)}
aria-label={`Select file ${entry.name}`}
/>
</div>
<IconFile size={14} stroke={1.5} className="text-muted-foreground/50 shrink-0" />
<div className="flex-[2] min-w-0 px-2 truncate font-mono text-[12px]" title={showFullPath ? entry.path : undefined}>
{showFullPath ? entry.path : entry.name}
</div>
<div className="w-6 flex items-center justify-center">
<AnimatePresence>
{changed && (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
>
<IconArrowRight size={12} stroke={1.5} className="text-primary/60" />
</motion.div>
)}
</AnimatePresence>
</div>
<div
className={cn(
"flex-[2] min-w-0 px-2 truncate font-mono text-[12px]",
changed ? "text-primary font-medium" : "text-muted-foreground/50"
)}
>
{preview?.new_name || entry.name}
</div>
<div style={{ width: colWidths.size }} className="px-2 text-right text-[11px] text-muted-foreground tabular-nums shrink-0">
{formatSize(entry.size)}
</div>
<div style={{ width: colWidths.date }} className="px-2 text-right text-[11px] text-muted-foreground tabular-nums shrink-0">
{formatDate(entry.modified)}
</div>
<div style={{ width: colWidths.status }} className="px-2 flex items-center justify-center gap-1 shrink-0">
<AnimatePresence mode="wait">
{hasConflict && (
<motion.span
key="conflict"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="inline-flex items-center gap-1 text-[11px] text-destructive"
>
<IconX size={12} stroke={2} />
Conflict
</motion.span>
)}
{hasError && !hasConflict && (
<motion.span
key="error"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="inline-flex items-center gap-1 text-[11px] text-warning"
>
<IconAlertTriangle size={12} stroke={2} />
Error
</motion.span>
)}
{changed && !hasError && !hasConflict && (
<motion.span
key="ok"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="inline-flex items-center gap-1 text-[11px] text-primary"
>
<IconCheck size={12} stroke={2} />
OK
</motion.span>
)}
</AnimatePresence>
</div>
</div>
);
})}
</div>
</div>
{/* marquee selection overlay */}
{marqueeBox && (
<div
className="marquee-selection"
style={{
left: marqueeBox.x,
top: marqueeBox.y,
width: marqueeBox.w,
height: marqueeBox.h,
}}
/>
)}
</div>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuItem onClick={handleSelectAll} className="gap-2 text-[13px]">
<IconSelect size={15} stroke={1.5} />
Select all
</ContextMenuItem>
<ContextMenuItem onClick={handleDeselectAll} className="gap-2 text-[13px]">
<IconDeselect size={15} stroke={1.5} />
Deselect all
</ContextMenuItem>
<ContextMenuItem onClick={handleInvertSelection} className="gap-2 text-[13px]">
<IconArrowsSort size={15} stroke={1.5} />
Invert selection
</ContextMenuItem>
<ContextMenuItem onClick={handleSelectChanged} className="gap-2 text-[13px]">
<IconCheck size={15} stroke={1.5} />
Select changed only
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={handleCopyNames} className="gap-2 text-[13px]">
<IconCopy size={15} stroke={1.5} />
Copy original names
</ContextMenuItem>
<ContextMenuItem onClick={handleCopyNewNames} className="gap-2 text-[13px]">
<IconClipboard size={15} stroke={1.5} />
Copy new names
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
if (contextFile) {
const isSelected = selectedFiles.has(contextFile);
if (isSelected) {
const s = new Set(selectedFiles);
s.delete(contextFile);
useFileStore.setState({ selectedFiles: s });
} else {
const s = new Set(selectedFiles);
s.add(contextFile);
useFileStore.setState({ selectedFiles: s });
}
}
}}
className="gap-2 text-[13px]"
>
{contextFile && selectedFiles.has(contextFile) ? (
<>
<IconEyeOff size={15} stroke={1.5} />
Exclude from rename
</>
) : (
<>
<IconEye size={15} stroke={1.5} />
Include in rename
</>
)}
</ContextMenuItem>
{contextFile && (
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => invoke("reveal_in_explorer", { path: contextFile }).catch(() => {})}
className="gap-2 text-[13px]"
>
<IconExternalLink size={15} stroke={1.5} />
Open in file explorer
</ContextMenuItem>
<ContextMenuItem
onClick={() => navigator.clipboard.writeText(contextFile)}
className="gap-2 text-[13px]"
>
<IconCopy size={15} stroke={1.5} />
Copy path
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);
}
function ColResizeHandle({
col,
onPointerDown,
onPointerMove,
onPointerUp,
colWidths,
setColWidths,
}: {
col: string;
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: (e: React.PointerEvent) => void;
colWidths: typeof DEFAULT_COL_WIDTHS;
setColWidths: React.Dispatch<React.SetStateAction<typeof DEFAULT_COL_WIDTHS>>;
}) {
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
const step = e.shiftKey ? 40 : 10;
if (e.key === "ArrowRight") {
e.preventDefault();
setColWidths((prev) => ({ ...prev, [col]: Math.max(40, ((prev as any)[col] || 80) + step) }));
} else if (e.key === "ArrowLeft") {
e.preventDefault();
setColWidths((prev) => ({ ...prev, [col]: Math.max(40, ((prev as any)[col] || 80) - step) }));
}
}, [col, setColWidths]);
return (
<div
role="separator"
aria-orientation="vertical"
aria-valuenow={(colWidths as any)[col] || 80}
aria-label={`Resize ${col} column`}
tabIndex={0}
className="w-1 shrink-0 self-stretch cursor-col-resize hover:bg-primary/30 active:bg-primary/50 focus-visible:bg-primary/40 transition-colors"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={handleKeyDown}
/>
);
}

View File

@@ -1,26 +1,153 @@
import { useState } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Sidebar } from "./Sidebar";
import { StatusBar } from "./StatusBar";
import { Toolbar } from "./Toolbar";
import { FileList } from "../browser/FileList";
import { RulePanel } from "../rules/RulePanel";
import { FileList } from "@/components/browser/FileList";
import { PipelineStrip } from "@/components/pipeline/PipelineStrip";
import { Toaster } from "@/components/ui/sonner";
import { useSettingsStore } from "@/stores/settingsStore";
import { motion, AnimatePresence, MotionConfig } from "framer-motion";
import { PortalContainerProvider } from "@/lib/portal";
import { ResizeEdges } from "./ResizeEdges";
import { toast } from "sonner";
import { useAnnounceStore } from "@/hooks/useAnnounce";
export function AppShell() {
const [sidebarWidth, setSidebarWidth] = useState(240);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [sidebarWidth, setSidebarWidth] = useState(220);
const [portalContainer, setPortalContainer] = useState<HTMLDivElement | null>(null);
const zoom = useSettingsStore((s) => s.zoom);
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const announceMessage = useAnnounceStore((s) => s.message);
const [maximized, setMaximized] = useState(() => document.documentElement.classList.contains("maximized"));
const dragging = useRef(false);
useEffect(() => {
const observer = new MutationObserver(() => {
setMaximized(document.documentElement.classList.contains("maximized"));
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
// suppress default browser context menu everywhere
useEffect(() => {
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest("[data-slot='context-menu-trigger']")) return;
e.preventDefault();
};
document.addEventListener("contextmenu", handler);
return () => document.removeEventListener("contextmenu", handler);
}, []);
// prevent scroll events on overlay menus from leaking to the app
useEffect(() => {
const handler = (e: WheelEvent) => {
const target = e.target as HTMLElement;
const overlay = target.closest(
"[data-slot='context-menu-content'], [data-slot='dropdown-menu-content'], [data-slot='popover-content']"
);
if (!overlay) return;
const el = overlay as HTMLElement;
const atTop = el.scrollTop <= 0 && e.deltaY < 0;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight && e.deltaY > 0;
const notScrollable = el.scrollHeight <= el.clientHeight;
if (atTop || atBottom || notScrollable) {
e.preventDefault();
}
};
document.addEventListener("wheel", handler, { passive: false });
return () => document.removeEventListener("wheel", handler);
}, []);
// startup update check
useEffect(() => {
const { checkForUpdates } = useSettingsStore.getState();
if (!checkForUpdates) return;
invoke<{ available: boolean; latest_version: string; url: string }>("check_for_updates")
.then((info) => {
if (info.available) {
toast.info(`Update available: v${info.latest_version}`, {
description: "A new version of Nomina is available.",
duration: 8000,
});
}
})
.catch(() => {});
}, []);
const onDividerPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
dragging.current = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const onDividerPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragging.current) return;
const z = useSettingsStore.getState().zoom;
const x = e.clientX / z;
setSidebarWidth(Math.max(140, Math.min(450, x)));
}, []);
const onDividerPointerUp = useCallback(() => {
dragging.current = false;
}, []);
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 />
<MotionConfig reducedMotion={animationsEnabled ? "never" : "always"}>
<PortalContainerProvider value={portalContainer}>
<div
ref={setPortalContainer}
className="window-frame select-none"
style={{
transform: `scale(${zoom})`,
transformOrigin: "top left",
width: maximized ? `calc(100vw / ${zoom})` : `calc((100vw - 40px) / ${zoom})`,
height: maximized ? `calc(100vh / ${zoom})` : `calc((100vh - 40px) / ${zoom})`,
}}
>
<a href="#file-list" className="skip-nav">Skip to file list</a>
{/* live region for screen reader announcements */}
<div aria-live="assertive" aria-atomic="true" className="sr-only">{announceMessage}</div>
<ResizeEdges />
<Toolbar sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} />
<div className="flex flex-1 min-h-0 relative">
<AnimatePresence mode="popLayout">
{sidebarOpen && (
<motion.div
key="sidebar"
initial={{ width: 0, opacity: 0 }}
animate={{ width: sidebarWidth, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="overflow-hidden shrink-0"
>
<Sidebar />
</motion.div>
)}
</AnimatePresence>
{sidebarOpen && (
<div
className="absolute top-0 bottom-0 z-20 w-2 -ml-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/30 transition-colors"
style={{ left: sidebarWidth }}
onPointerDown={onDividerPointerDown}
onPointerMove={onDividerPointerMove}
onPointerUp={onDividerPointerUp}
/>
)}
<div className="flex flex-col flex-1 min-w-0">
<div id="file-list" className="flex-1 min-h-0">
<FileList />
</div>
<PipelineStrip />
</div>
<RulePanel />
</div>
<StatusBar />
</div>
<StatusBar />
</div>
</PortalContainerProvider>
<Toaster />
</MotionConfig>
);
}

View File

@@ -0,0 +1,95 @@
import { useRef } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
type Dir = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
const edges: { dir: Dir; className: string }[] = [
{ dir: "n", className: "absolute -top-[3px] left-3 right-3 h-[6px] cursor-n-resize z-50" },
{ dir: "s", className: "absolute -bottom-[3px] left-3 right-3 h-[6px] cursor-s-resize z-50" },
{ dir: "w", className: "absolute top-3 -left-[3px] bottom-3 w-[6px] cursor-w-resize z-50" },
{ dir: "e", className: "absolute top-3 -right-[3px] bottom-3 w-[6px] cursor-e-resize z-50" },
{ dir: "nw", className: "absolute -top-[3px] -left-[3px] w-4 h-4 cursor-nw-resize z-50" },
{ dir: "ne", className: "absolute -top-[3px] -right-[3px] w-4 h-4 cursor-ne-resize z-50" },
{ dir: "sw", className: "absolute -bottom-[3px] -left-[3px] w-4 h-4 cursor-sw-resize z-50" },
{ dir: "se", className: "absolute -bottom-[3px] -right-[3px] w-4 h-4 cursor-se-resize z-50" },
];
interface DragState {
dir: Dir;
startX: number;
startY: number;
startPos: { x: number; y: number };
startSize: { width: number; height: number };
}
const MIN_W = 900;
const MIN_H = 600;
export function ResizeEdges() {
const drag = useRef<DragState | null>(null);
const onPointerDown = (dir: Dir) => async (e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const win = getCurrentWindow();
const pos = await win.outerPosition();
const size = await win.outerSize();
drag.current = {
dir,
startX: e.screenX,
startY: e.screenY,
startPos: { x: pos.x, y: pos.y },
startSize: { width: size.width, height: size.height },
};
};
const onPointerMove = async (e: React.PointerEvent) => {
if (!drag.current) return;
const d = drag.current;
const dx = e.screenX - d.startX;
const dy = e.screenY - d.startY;
let x = d.startPos.x;
let y = d.startPos.y;
let w = d.startSize.width;
let h = d.startSize.height;
if (d.dir.includes("e")) w = Math.max(MIN_W, d.startSize.width + dx);
if (d.dir.includes("s")) h = Math.max(MIN_H, d.startSize.height + dy);
if (d.dir.includes("w")) {
const newW = Math.max(MIN_W, d.startSize.width - dx);
x = d.startPos.x + (d.startSize.width - newW);
w = newW;
}
if (d.dir.includes("n")) {
const newH = Math.max(MIN_H, d.startSize.height - dy);
y = d.startPos.y + (d.startSize.height - newH);
h = newH;
}
const win = getCurrentWindow();
await win.setPosition(new PhysicalPosition(x, y));
await win.setSize(new PhysicalSize(w, h));
};
const onPointerUp = () => {
drag.current = null;
};
return (
<>
{edges.map(({ dir, className }) => (
<div
key={dir}
className={className}
onPointerDown={onPointerDown(dir)}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
))}
</>
);
}

View File

@@ -1,59 +1,266 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useFileStore } from "../../stores/fileStore";
import { useFileStore } from "@/stores/fileStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
IconFolder,
IconFolderOpen,
IconChevronRight,
IconArrowUp,
IconFolderPlus,
IconExternalLink,
IconCopy,
IconFolderSymlink,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "framer-motion";
interface SidebarProps {
width: number;
onResize: (width: number) => void;
interface FolderNode {
path: string;
name: string;
children: FolderNode[];
loaded: boolean;
expanded: boolean;
}
export function Sidebar({ width }: SidebarProps) {
export function Sidebar() {
const [pathInput, setPathInput] = useState("");
const [drives, setDrives] = useState<string[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [tree, setTree] = useState<FolderNode[]>([]);
const scanDirectory = useFileStore((s) => s.scanDirectory);
const currentPath = useFileStore((s) => s.currentPath);
const restoredRef = useRef(false);
const treeRef = useRef<FolderNode[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const expandGenRef = useRef(0);
// keep treeRef in sync so async functions see latest tree
treeRef.current = tree;
useEffect(() => {
// detect windows drives
const detected: string[] = [];
for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZ") {
detected.push(`${letter}:\\`);
}
setDrives(detected);
loadDrives().then(async () => {
if (restoredRef.current) return;
restoredRef.current = true;
// check if launched from context menu with paths
try {
const args = await invoke<string[]>("get_launch_args");
if (args.length > 0) {
const result = await invoke<[string, string[]] | null>("resolve_launch_paths", { args });
if (result) {
const [folder, selected] = result;
await scanDirectory(folder);
if (selected.length > 0) {
useFileStore.setState({ selectedFiles: new Set(selected) });
} else {
useFileStore.setState({ selectedFiles: new Set() });
}
expandToPath(folder);
return;
}
}
} catch {
// no launch args support or failed - fall through
}
const { lastFolder, openLastFolder } = useSettingsStore.getState();
if (openLastFolder && lastFolder) {
scanDirectory(lastFolder);
expandToPath(lastFolder);
}
});
}, []);
useEffect(() => {
if (currentPath) {
setPathInput(currentPath);
loadFolders(currentPath);
useSettingsStore.getState().setLastFolder(currentPath);
expandToPath(currentPath);
}
}, [currentPath]);
async function loadFolders(path: string) {
async function loadDrives() {
const drives: FolderNode[] = [];
for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZ") {
const path = `${letter}:\\`;
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,
},
},
);
if (entries.length >= 0) {
drives.push({ path, name: path, children: [], loaded: false, expanded: false });
}
} catch {
// drive doesn't exist
}
}
setTree(drives);
treeRef.current = drives;
}
async function loadChildren(node: FolderNode): Promise<FolderNode[]> {
try {
const entries = await invoke<Array<{ path: string; name: string; is_dir: boolean }>>(
"scan_directory",
{
path,
path: node.path,
filters: {
mask: "*",
regex_filter: null,
min_size: null,
max_size: null,
include_files: false,
include_folders: true,
include_hidden: false,
subfolder_depth: 0,
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));
return entries
.filter((e) => e.is_dir)
.map((e) => ({
path: e.path,
name: e.name,
children: [],
loaded: false,
expanded: false,
}));
} catch {
setFolders([]);
return [];
}
}
// parse a Windows path into ancestor segments: "D:\foo\bar" -> ["D:\", "D:\foo", "D:\foo\bar"]
function pathSegments(p: string): string[] {
const normalized = p.replace(/\//g, "\\").replace(/\\$/, "");
const parts = normalized.split("\\");
const segments: string[] = [];
for (let i = 0; i < parts.length; i++) {
if (i === 0) {
segments.push(parts[0] + "\\");
} else {
segments.push(segments[i - 1] + (segments[i - 1].endsWith("\\") ? "" : "\\") + parts[i]);
}
}
return segments;
}
async function expandToPath(targetPath: string) {
const gen = ++expandGenRef.current;
const segments = pathSegments(targetPath);
if (segments.length === 0) return;
// build set of ancestor paths that should stay expanded
const ancestorSet = new Set(segments.map((s) => s.replace(/\\$/, "").toLowerCase()));
// collapse everything that isn't an ancestor of the target
setTree((prev) => {
function collapse(nodes: FolderNode[]): FolderNode[] {
return nodes.map((n) => {
const nNorm = n.path.replace(/\\$/, "").toLowerCase();
const isAncestor = ancestorSet.has(nNorm);
return {
...n,
expanded: isAncestor,
children: collapse(n.children),
};
});
}
const updated = collapse(prev);
treeRef.current = updated;
return updated;
});
for (const seg of segments) {
if (expandGenRef.current !== gen) return;
const segNorm = seg.replace(/\\$/, "").toLowerCase();
// find the node in the current tree
function findInTree(nodes: FolderNode[]): FolderNode | undefined {
for (const n of nodes) {
if (n.path.replace(/\\$/, "").toLowerCase() === segNorm) return n;
const found = findInTree(n.children);
if (found) return found;
}
return undefined;
}
const node = findInTree(treeRef.current);
if (!node) return;
// load children if needed (async)
let newChildren: FolderNode[] | null = null;
if (!node.loaded) {
newChildren = await loadChildren(node);
if (expandGenRef.current !== gen) return;
}
// update tree immutably, always from latest state
setTree((prev) => {
function update(nodes: FolderNode[]): FolderNode[] {
return nodes.map((n) => {
if (n.path.replace(/\\$/, "").toLowerCase() === segNorm) {
return {
...n,
expanded: true,
loaded: true,
children: newChildren || n.children,
};
}
return { ...n, children: update(n.children) };
});
}
const updated = update(prev);
treeRef.current = updated;
return updated;
});
}
// scroll active node into view after render
requestAnimationFrame(() => {
const el = scrollRef.current?.querySelector("[data-tree-active='true']");
if (el) el.scrollIntoView({ block: "center", behavior: "smooth" });
});
}
async function toggleExpand(path: string) {
async function updateNode(nodes: FolderNode[]): Promise<FolderNode[]> {
const result: FolderNode[] = [];
for (const node of nodes) {
if (node.path === path) {
if (!node.loaded) {
const children = await loadChildren(node);
result.push({ ...node, expanded: true, loaded: true, children });
} else {
result.push({ ...node, expanded: !node.expanded });
}
} else {
const updatedChildren = await updateNode(node.children);
result.push({ ...node, children: updatedChildren });
}
}
return result;
}
const updated = await updateNode(treeRef.current);
setTree(updated);
treeRef.current = updated;
}
function handleFolderClick(path: string) {
scanDirectory(path);
}
function handlePathSubmit(e: React.FormEvent) {
e.preventDefault();
if (pathInput.trim()) {
@@ -61,91 +268,255 @@ export function Sidebar({ width }: SidebarProps) {
}
}
function handleDriveClick(drive: string) {
scanDirectory(drive);
function handleGoUp() {
if (!currentPath) return;
const normalized = currentPath.replace(/\\/g, "/").replace(/\/$/, "");
const parent = normalized.replace(/\/[^/]+$/, "");
if (!parent) return;
let result = parent.replace(/\//g, "\\");
if (/^[A-Za-z]:$/.test(result)) result += "\\";
scanDirectory(result);
}
function handleFolderClick(path: string) {
scanDirectory(path);
}
// flatten visible tree for keyboard navigation
const [focusedPath, setFocusedPath] = useState<string | null>(null);
function folderName(path: string): string {
const parts = path.replace(/\\/g, "/").split("/").filter(Boolean);
return parts[parts.length - 1] || path;
const flattenVisible = useCallback((nodes: FolderNode[]): FolderNode[] => {
const result: FolderNode[] = [];
for (const n of nodes) {
result.push(n);
if (n.expanded) result.push(...flattenVisible(n.children));
}
return result;
}, []);
const findParent = useCallback((nodes: FolderNode[], targetPath: string, parent: FolderNode | null = null): FolderNode | null => {
for (const n of nodes) {
if (n.path === targetPath) return parent;
const found = findParent(n.children, targetPath, n);
if (found) return found;
}
return null;
}, []);
const handleTreeKeyDown = useCallback((e: React.KeyboardEvent) => {
const visible = flattenVisible(tree);
if (visible.length === 0) return;
const idx = focusedPath ? visible.findIndex((n) => n.path === focusedPath) : -1;
const current = idx >= 0 ? visible[idx] : null;
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
const next = Math.min(idx + 1, visible.length - 1);
setFocusedPath(visible[Math.max(0, next)].path);
break;
}
case "ArrowUp": {
e.preventDefault();
const prev = Math.max(idx - 1, 0);
setFocusedPath(visible[prev].path);
break;
}
case "ArrowRight": {
e.preventDefault();
if (current && !current.expanded) {
toggleExpand(current.path);
} else if (current && current.expanded && current.children.length > 0) {
setFocusedPath(current.children[0].path);
}
break;
}
case "ArrowLeft": {
e.preventDefault();
if (current && current.expanded) {
toggleExpand(current.path);
} else if (current) {
const parent = findParent(tree, current.path);
if (parent) setFocusedPath(parent.path);
}
break;
}
case "Enter":
case " ": {
e.preventDefault();
if (current) handleFolderClick(current.path);
break;
}
case "Home": {
e.preventDefault();
if (visible.length > 0) setFocusedPath(visible[0].path);
break;
}
case "End": {
e.preventDefault();
if (visible.length > 0) setFocusedPath(visible[visible.length - 1].path);
break;
}
}
}, [tree, focusedPath, flattenVisible, findParent]);
// scroll focused node into view
useEffect(() => {
if (!focusedPath || !scrollRef.current) return;
const el = scrollRef.current.querySelector(`[data-tree-path="${CSS.escape(focusedPath)}"]`);
if (el) {
(el as HTMLElement).focus();
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [focusedPath]);
function renderNode(node: FolderNode, depth: number) {
const isActive = node.path === currentPath;
const isFocused = node.path === focusedPath;
return (
<div key={node.path} role="treeitem" aria-expanded={node.expanded} aria-label={node.name}>
<ContextMenu>
<ContextMenuTrigger asChild>
<motion.div
data-tree-active={isActive ? "true" : undefined}
data-tree-path={node.path}
tabIndex={isFocused ? 0 : -1}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
whileHover={{ x: 2, transition: { duration: 0.1 } }}
className={cn(
"flex items-center gap-0.5 py-0.5 pr-2 rounded-md cursor-pointer text-[13px] transition-colors group",
isActive
? "bg-primary/10 text-primary"
: "text-foreground/80 hover:bg-muted hover:text-foreground",
isFocused && "ring-2 ring-ring/50 ring-inset",
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
onFocus={() => setFocusedPath(node.path)}
>
<button
onClick={(e) => { e.stopPropagation(); toggleExpand(node.path); }}
aria-label={node.expanded ? `Collapse ${node.name}` : `Expand ${node.name}`}
tabIndex={-1}
className="p-0.5 rounded hover:bg-muted-foreground/10 shrink-0"
>
<motion.div
animate={{ rotate: node.expanded ? 90 : 0 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
<IconChevronRight size={14} stroke={1.5} />
</motion.div>
</button>
<button
onClick={() => handleFolderClick(node.path)}
aria-label={`Open folder ${node.name}`}
aria-current={isActive ? "location" : undefined}
tabIndex={-1}
className="flex items-center gap-1.5 flex-1 min-w-0 truncate"
>
{node.expanded
? <IconFolderOpen size={16} stroke={1.5} className="shrink-0 text-primary" />
: <IconFolder size={16} stroke={1.5} className="shrink-0 text-muted-foreground group-hover:text-foreground" />
}
<span className="truncate">{node.name}</span>
</button>
</motion.div>
</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuItem onClick={() => handleFolderClick(node.path)} className="gap-2 text-[13px]">
<IconFolderSymlink size={15} stroke={1.5} /> Open in Nomina
</ContextMenuItem>
<ContextMenuItem onClick={() => invoke("reveal_in_explorer", { path: node.path }).catch(() => {})} className="gap-2 text-[13px]">
<IconExternalLink size={15} stroke={1.5} /> Open in file explorer
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => navigator.clipboard.writeText(node.path)} className="gap-2 text-[13px]">
<IconCopy size={15} stroke={1.5} /> Copy path
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => toggleExpand(node.path)} className="gap-2 text-[13px]">
<IconChevronRight size={15} stroke={1.5} /> {node.expanded ? "Collapse" : "Expand"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<AnimatePresence>
{node.expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="overflow-hidden"
>
{node.children.map((child) => renderNode(child, depth + 1))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
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)",
}}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
role="navigation"
aria-label="Folder browser"
className="flex flex-col border-r bg-sidebar w-full h-full min-h-0"
>
<form onSubmit={handlePathSubmit} className="p-2 border-b" style={{ borderColor: "var(--border)" }}>
<input
<motion.form
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15, duration: 0.3 }}
onSubmit={handlePathSubmit}
className="px-2 pt-2 pb-2 flex gap-1"
>
<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)",
}}
placeholder="Path..."
aria-label="Folder path"
className="h-7 text-xs font-mono"
/>
</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>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
>
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon-xs" onClick={handleGoUp} aria-label="Go to parent folder">
<IconArrowUp size={14} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Go up</TooltipContent>
</Tooltip>
</motion.div>
)}
</div>
</div>
</motion.form>
<ScrollArea className="flex-1 overflow-hidden">
<div ref={scrollRef} className="px-1.5 pb-2" role="tree" aria-label="Folder tree" onKeyDown={handleTreeKeyDown}>
{tree.length === 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground"
>
<motion.div
animate={{ rotate: [0, 5, -5, 0] }}
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
>
<IconFolderPlus size={24} stroke={1.5} />
</motion.div>
<span className="text-xs">Loading drives...</span>
</motion.div>
)}
{tree.map((node) => renderNode(node, 0))}
</div>
</ScrollArea>
</motion.div>
);
}

View File

@@ -1,35 +1,99 @@
import { useFileStore } from "../../stores/fileStore";
import { useFileStore } from "@/stores/fileStore";
import { motion, AnimatePresence } from "framer-motion";
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 currentPath = useFileStore((s) => s.currentPath);
const conflicts = previewResults.filter((r) => r.has_conflict).length;
const changes = previewResults.filter(
const selectedPreviews = previewResults.filter((r) => selectedFiles.has(r.original_path));
const conflicts = selectedPreviews.filter((r) => r.has_conflict).length;
const changes = selectedPreviews.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)",
}}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
role="status"
aria-live="polite"
aria-label="Status bar"
className="flex items-center gap-4 px-3 py-1 text-[11px] border-t bg-background text-muted-foreground font-mono"
>
<span>{files.length} files</span>
{/* visually hidden summary for screen readers */}
<span className="sr-only">
{currentPath ? `${currentPath}. ` : ""}
{files.length} files, {selectedFiles.size} selected
{changes > 0 ? `, ${changes} to rename` : ""}
{conflicts > 0 ? `, ${conflicts} conflicts` : ""}.
{loading ? " Scanning." : changes > 0 ? " Preview ready." : " Ready."}
</span>
<AnimatePresence mode="wait">
{currentPath && (
<motion.span
key={currentPath}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
className="truncate max-w-[300px]"
title={currentPath}
>
{currentPath}
</motion.span>
)}
</AnimatePresence>
<motion.span
key={`files-${files.length}`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
>
{files.length} files
</motion.span>
<span>{selectedFiles.size} selected</span>
{changes > 0 && <span>{changes} to rename</span>}
{conflicts > 0 && (
<span style={{ color: "var(--error)" }}>{conflicts} conflicts</span>
)}
<AnimatePresence>
{changes > 0 && (
<motion.span
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
className="text-primary"
>
{changes} to rename
</motion.span>
)}
</AnimatePresence>
<AnimatePresence>
{conflicts > 0 && (
<motion.span
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
className="text-destructive"
>
{conflicts} conflicts
</motion.span>
)}
</AnimatePresence>
<div className="flex-1" />
<span>{status}</span>
</div>
<AnimatePresence mode="wait">
<motion.span
key={loading ? "scanning" : changes > 0 ? "preview" : "ready"}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.15 }}
>
{loading ? "Scanning..." : changes > 0 ? "Preview ready" : "Ready"}
</motion.span>
</AnimatePresence>
</motion.div>
);
}

View File

@@ -1,34 +1,107 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useFileStore } from "../../stores/fileStore";
import { useRuleStore } from "../../stores/ruleStore";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useFileStore } from "@/stores/fileStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
IconArrowBackUp,
IconLayoutSidebar,
IconSettings,
IconTemplate,
IconMinus,
IconSquare,
IconX,
} from "@tabler/icons-react";
import { SettingsDialog } from "@/components/settings/SettingsDialog";
import { PresetsDialog } from "@/components/presets/PresetsDialog";
import { motion } from "framer-motion";
export function Toolbar() {
interface ToolbarProps {
sidebarOpen: boolean;
onToggleSidebar: () => void;
}
export function Toolbar({ sidebarOpen, onToggleSidebar }: ToolbarProps) {
const [settingsOpen, setSettingsOpen] = useState(false);
const [presetsOpen, setPresetsOpen] = useState(false);
const previewResults = useFileStore((s) => s.previewResults);
const selectedFiles = useFileStore((s) => s.selectedFiles);
const currentPath = useFileStore((s) => s.currentPath);
const scanDirectory = useFileStore((s) => s.scanDirectory);
const requestPreview = useRuleStore((s) => s.requestPreview);
const resetAllRules = useRuleStore((s) => s.resetAllRules);
const selectedPreviews = previewResults.filter(
(r) => selectedFiles.has(r.original_path) && !r.has_conflict && !r.has_error && r.original_name !== r.new_name,
);
const renameCount = selectedPreviews.length;
const appWindow = getCurrentWindow();
async function handleRename() {
const ops = previewResults.filter(
(r) => !r.has_conflict && !r.has_error && r.original_name !== r.new_name,
);
const ops = selectedPreviews;
if (ops.length === 0) return;
const settings = useSettingsStore.getState();
if (settings.confirmBeforeRename) {
const ok = window.confirm(`Rename ${ops.length} file${ops.length !== 1 ? "s" : ""}?`);
if (!ok) return;
}
try {
const report = await invoke<{ succeeded: number; failed: string[] }>(
"execute_rename",
{ operations: ops },
{
operations: ops,
createBackup: settings.createBackups,
undoLimit: settings.undoHistoryLimit,
skipReadOnly: settings.skipReadOnly,
conflictStrategy: settings.conflictStrategy,
backupPath: settings.backupLocation === "custom" ? settings.customBackupPath : null,
},
);
if (report.failed.length > 0) {
console.error("Some renames failed:", report.failed);
}
// refresh
if (currentPath) {
await scanDirectory(currentPath);
}
// toast notification
if (settings.showToastOnComplete) {
if (report.failed.length > 0) {
toast.warning(`Renamed ${report.succeeded} file${report.succeeded !== 1 ? "s" : ""}`, {
description: `${report.failed.length} failed`,
});
} else {
toast.success(`Renamed ${report.succeeded} file${report.succeeded !== 1 ? "s" : ""}`, {
description: "All operations completed",
});
}
}
// play sound
if (settings.playSoundOnComplete) {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 800;
osc.type = "sine";
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.2);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.2);
} catch {}
}
// flash taskbar
if (settings.flashTaskbarOnComplete && document.hidden) {
getCurrentWindow().requestUserAttention(2).catch(() => {});
}
} catch (e) {
console.error("Rename failed:", e);
toast.error("Rename failed", { description: String(e) });
}
}
@@ -43,56 +116,120 @@ export function Toolbar() {
}
}
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)" }}
<>
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
role="toolbar"
aria-label="Main toolbar"
className="flex items-center gap-1.5 px-2 py-1.5 border-b bg-background rounded-t-[9px]"
data-tauri-drag-region
>
Rename
</button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleSidebar}
aria-label={sidebarOpen ? "Hide sidebar" : "Show sidebar"}
aria-expanded={sidebarOpen}
className={sidebarOpen ? "text-foreground" : "text-muted-foreground"}
>
<motion.div
animate={{ rotateY: sidebarOpen ? 0 : 180 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<IconLayoutSidebar size={18} stroke={1.5} />
</motion.div>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{sidebarOpen ? "Hide sidebar" : "Show sidebar"}</TooltipContent>
</Tooltip>
<button
onClick={() => requestPreview(currentPath)}
className="px-3 py-1.5 rounded text-sm border"
style={{ borderColor: "var(--border)", color: "var(--text-primary)" }}
>
Preview
</button>
<div className="w-px h-4 bg-border mx-1" />
<button
onClick={handleUndo}
className="px-3 py-1.5 rounded text-sm border"
style={{ borderColor: "var(--border)", color: "var(--text-primary)" }}
>
Undo
</button>
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<Button
onClick={handleRename}
disabled={renameCount === 0}
size="sm"
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 font-medium"
>
{renameCount > 0 ? `Rename ${renameCount} file${renameCount !== 1 ? "s" : ""}` : "No changes"}
</Button>
</motion.div>
<div className="flex-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={handleUndo} aria-label="Undo last rename">
<IconArrowBackUp size={18} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Undo last rename</TooltipContent>
</Tooltip>
<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>
<div className="flex-1 flex items-center justify-center" data-tauri-drag-region>
<span className="text-xs font-semibold text-primary tracking-wide uppercase pointer-events-none" data-tauri-drag-region>
Nomina
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={() => setPresetsOpen(true)} aria-label="Presets">
<IconTemplate size={18} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Presets</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={() => setSettingsOpen(true)} aria-label="Settings">
<motion.div
whileHover={{ rotate: 90 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
<IconSettings size={18} stroke={1.5} />
</motion.div>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Settings</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border mx-1" />
{/* window controls */}
<button
onClick={() => appWindow.minimize()}
aria-label="Minimize window"
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<IconMinus size={14} stroke={1.5} />
</button>
<button
onClick={() => appWindow.toggleMaximize()}
aria-label="Maximize window"
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<IconSquare size={13} stroke={1.5} />
</button>
<button
onClick={() => appWindow.close()}
aria-label="Close window"
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<IconX size={14} stroke={1.5} />
</button>
</motion.div>
<PresetsDialog open={presetsOpen} onOpenChange={setPresetsOpen} />
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
</>
);
}

View File

@@ -0,0 +1,694 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
import { useRuleStore, type PipelineRule } from "@/stores/ruleStore";
import { useFileStore } from "@/stores/fileStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { Button } from "@/components/ui/button";
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Switch } from "@/components/ui/switch";
import {
IconPlus,
IconReplace,
IconRegex,
IconEraser,
IconTextPlus,
IconLetterCase,
IconNumbers,
IconCalendar,
IconArrowsExchange,
IconFileTypography,
IconTrash,
IconX,
IconEdit,
IconHash,
IconFolder,
IconLanguage,
IconSortAscendingNumbers,
IconCut,
IconDice,
IconArrowsRightLeft,
IconShieldCheck,
IconCopy,
IconChevronLeft,
IconChevronRight,
IconPlayerPlay,
IconPlayerPause,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { motion, AnimatePresence } from "framer-motion";
import {
DndContext,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ReplaceConfig } from "./configs/ReplaceConfig";
import { RegexConfig } from "./configs/RegexConfig";
import { RemoveConfig } from "./configs/RemoveConfig";
import { AddConfig } from "./configs/AddConfig";
import { CaseConfig } from "./configs/CaseConfig";
import { NumberingConfig } from "./configs/NumberingConfig";
import { ExtensionConfig } from "./configs/ExtensionConfig";
import { DateConfig } from "./configs/DateConfig";
import { MovePartsConfig } from "./configs/MovePartsConfig";
import { TextEditorConfig } from "./configs/TextEditorConfig";
import { HashConfig } from "./configs/HashConfig";
import { FolderNameConfig } from "./configs/FolderNameConfig";
import { TransliterateConfig } from "./configs/TransliterateConfig";
import { PaddingConfig } from "./configs/PaddingConfig";
import { TruncateConfig } from "./configs/TruncateConfig";
import { RandomizeConfig } from "./configs/RandomizeConfig";
import { SwapConfig } from "./configs/SwapConfig";
import { SanitizeConfig } from "./configs/SanitizeConfig";
const ruleTypes = [
{ id: "add", label: "Add", icon: IconTextPlus, desc: "Insert text" },
{ id: "case", label: "Case", icon: IconLetterCase, desc: "Change letter case" },
{ id: "date", label: "Date", icon: IconCalendar, desc: "Insert date/time info" },
{ id: "text_editor", label: "Editor", icon: IconEdit, desc: "Edit names as text" },
{ id: "extension", label: "Extension", icon: IconFileTypography, desc: "Change file extension" },
{ id: "folder_name", label: "Folder", icon: IconFolder, desc: "Insert parent folder name" },
{ id: "hash", label: "Hash", icon: IconHash, desc: "Add file content hash" },
{ id: "move_parts", label: "Move", icon: IconArrowsExchange, desc: "Move or swap parts" },
{ id: "numbering", label: "Number", icon: IconNumbers, desc: "Add sequence numbers" },
{ id: "padding", label: "Padding", icon: IconSortAscendingNumbers, desc: "Pad numbers in names" },
{ id: "randomize", label: "Random", icon: IconDice, desc: "Add random characters" },
{ id: "regex", label: "Regex", icon: IconRegex, desc: "Pattern matching" },
{ id: "remove", label: "Remove", icon: IconEraser, desc: "Strip characters" },
{ id: "replace", label: "Replace", icon: IconReplace, desc: "Find and replace text" },
{ id: "sanitize", label: "Sanitize", icon: IconShieldCheck, desc: "Clean up filenames" },
{ id: "swap", label: "Swap", icon: IconArrowsRightLeft, desc: "Swap parts around delimiter" },
{ id: "transliterate", label: "Translit", icon: IconLanguage, desc: "Non-ASCII to ASCII" },
{ id: "truncate", label: "Truncate", icon: IconCut, desc: "Limit filename length" },
] as const;
const ruleIconMap: Record<string, typeof IconReplace> = {
replace: IconReplace,
regex: IconRegex,
remove: IconEraser,
add: IconTextPlus,
case: IconLetterCase,
numbering: IconNumbers,
date: IconCalendar,
move_parts: IconArrowsExchange,
extension: IconFileTypography,
text_editor: IconEdit,
hash: IconHash,
folder_name: IconFolder,
transliterate: IconLanguage,
padding: IconSortAscendingNumbers,
truncate: IconCut,
randomize: IconDice,
swap: IconArrowsRightLeft,
sanitize: IconShieldCheck,
};
function getRuleSummary(rule: Record<string, unknown>): string {
const type = rule.type as string;
switch (type) {
case "replace": {
const s = rule.search as string;
const r = rule.replace_with as string;
if (!s) return "Not configured";
return `"${s}" -> "${r}"`;
}
case "regex": {
const p = rule.pattern as string;
if (!p) return "Not configured";
return `/${p}/`;
}
case "remove": {
const parts: string[] = [];
if ((rule.first_n as number) > 0) parts.push(`First ${rule.first_n}`);
if ((rule.last_n as number) > 0) parts.push(`Last ${rule.last_n}`);
if (rule.crop_before) parts.push("Crop");
if (rule.remove_pattern) parts.push("Pattern");
const trim = rule.trim as Record<string, boolean> | undefined;
if (trim && Object.values(trim).some(Boolean)) parts.push("Trim");
return parts.length > 0 ? parts.join(", ") : "Not configured";
}
case "add": {
const parts: string[] = [];
if (rule.prefix) parts.push(`"${rule.prefix}"+`);
if (rule.suffix) parts.push(`+"${rule.suffix}"`);
return parts.length > 0 ? parts.join(" ") : "Not configured";
}
case "case":
return (rule.mode as string) === "Same" ? "Not configured" : (rule.mode as string);
case "numbering":
return (rule.mode as string) === "None" ? "Not configured" : `${rule.mode} from ${rule.start}`;
case "extension":
return (rule.mode as string) === "Same" ? "Not configured" : (rule.mode as string);
case "date":
return (rule.mode as string) === "None" ? "Not configured" : `${rule.source} ${rule.mode}`;
case "move_parts": {
const len = rule.source_length as number;
if (!len) return "Not configured";
return `${rule.copy_mode ? "Copy" : "Move"} ${len} chars`;
}
case "text_editor": {
const names = rule.names as string[];
if (!names?.length) return "Not configured";
return `${names.length} name${names.length !== 1 ? "s" : ""}`;
}
case "hash":
return (rule.mode as string) === "None" ? "Not configured" : `${rule.algorithm} ${rule.mode}`;
case "folder_name":
return (rule.mode as string) === "None" ? "Not configured" : `Level ${rule.level} ${rule.mode}`;
case "transliterate":
return "ASCII conversion";
case "padding":
return `Width ${rule.width}, pad '${rule.pad_char}'`;
case "truncate":
return `Max ${rule.max_length} chars`;
case "randomize":
return `${rule.format} ${rule.mode}`;
case "swap":
return (rule.delimiter as string) ? `Split on "${rule.delimiter}"` : "Not configured";
case "sanitize": {
const parts: string[] = [];
if (rule.illegal_chars) parts.push("Illegal");
if ((rule.spaces_to as string) !== "None") parts.push("Spaces");
if (rule.strip_diacritics) parts.push("Accents");
if (rule.normalize_unicode) parts.push("Unicode");
if (rule.strip_zero_width) parts.push("ZW");
if (rule.collapse_whitespace) parts.push("Collapse");
if (rule.trim_dots_spaces) parts.push("Trim");
return parts.length > 0 ? parts.join(", ") : "Not configured";
}
default:
return "...";
}
}
function isRuleActive(rule: Record<string, unknown>): boolean {
const type = rule.type as string;
if (!rule.enabled) return false;
switch (type) {
case "replace": return !!(rule.search as string);
case "regex": return !!(rule.pattern as string);
case "remove": {
const trim = rule.trim as Record<string, boolean> | undefined;
const hasTrim = trim && Object.values(trim).some(Boolean);
return (rule.first_n as number) > 0 || (rule.last_n as number) > 0 || (rule.from as number) !== (rule.to as number) || !!(rule.collapse_chars) || !!(rule.remove_pattern) || !!(rule.crop_before) || !!(rule.crop_after) || !!hasTrim;
}
case "add": return !!(rule.prefix as string) || !!(rule.suffix as string) || !!(rule.insert as string) || !!(rule.inserts as unknown[])?.length;
case "case": return (rule.mode as string) !== "Same";
case "numbering": return (rule.mode as string) !== "None";
case "extension": return (rule.mode as string) !== "Same" || !!(rule.mapping as unknown[])?.length;
case "date": return (rule.mode as string) !== "None";
case "move_parts": return (rule.source_length as number) > 0;
case "text_editor": return !!(rule.names as string[])?.length;
case "hash": return (rule.mode as string) !== "None";
case "folder_name": return (rule.mode as string) !== "None";
case "transliterate": return true;
case "padding": return true;
case "truncate": return true;
case "randomize": return true;
case "swap": return !!(rule.delimiter as string);
case "sanitize": return !!(rule.illegal_chars) || (rule.spaces_to as string) !== "None" || !!(rule.strip_diacritics) || !!(rule.normalize_unicode) || !!(rule.strip_zero_width) || !!(rule.collapse_whitespace) || !!(rule.trim_dots_spaces);
default: return false;
}
}
function ConfigContent({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId));
if (!rule) return null;
switch (rule.config.type) {
case "replace": return <ReplaceConfig ruleId={ruleId} />;
case "regex": return <RegexConfig ruleId={ruleId} />;
case "remove": return <RemoveConfig ruleId={ruleId} />;
case "add": return <AddConfig ruleId={ruleId} />;
case "case": return <CaseConfig ruleId={ruleId} />;
case "numbering": return <NumberingConfig ruleId={ruleId} />;
case "extension": return <ExtensionConfig ruleId={ruleId} />;
case "date": return <DateConfig ruleId={ruleId} />;
case "move_parts": return <MovePartsConfig ruleId={ruleId} />;
case "text_editor": return <TextEditorConfig ruleId={ruleId} />;
case "hash": return <HashConfig ruleId={ruleId} />;
case "folder_name": return <FolderNameConfig ruleId={ruleId} />;
case "transliterate": return <TransliterateConfig ruleId={ruleId} />;
case "padding": return <PaddingConfig ruleId={ruleId} />;
case "truncate": return <TruncateConfig ruleId={ruleId} />;
case "randomize": return <RandomizeConfig ruleId={ruleId} />;
case "swap": return <SwapConfig ruleId={ruleId} />;
case "sanitize": return <SanitizeConfig ruleId={ruleId} />;
default: return null;
}
}
function PopoverArrowWithBorder() {
return (
<div className="absolute left-1/2 -translate-x-1/2" style={{ bottom: -14 }}>
<svg width={32} height={14} viewBox="0 0 32 14">
<polygon points="0,0 16,14 32,0" className="fill-border" />
</svg>
<svg width={30} height={13} viewBox="0 0 30 13" className="absolute left-[1px]" style={{ top: 0 }}>
<polygon points="0,0 15,13 30,0" className="fill-popover" />
</svg>
</div>
);
}
function PipelineCard({ rule, index, onScrollToCenter }: { rule: PipelineRule; index: number; onScrollToCenter: (el: HTMLElement) => void }) {
const [open, setOpen] = useState(false);
const [tooltipsReady, setTooltipsReady] = useState(false);
const updateRule = useRuleStore((s) => s.updateRule);
const removeRule = useRuleStore((s) => s.removeRule);
const resetRule = useRuleStore((s) => s.resetRule);
const duplicateRule = useRuleStore((s) => s.duplicateRule);
const reorderPipeline = useRuleStore((s) => s.reorderPipeline);
const pipeline = useRuleStore((s) => s.pipeline);
const cardRef = useRef<HTMLDivElement>(null);
const {
attributes,
listeners,
setNodeRef: setSortableRef,
transform,
transition,
isDragging,
} = useSortable({ id: rule.id });
const setNodeRef = useCallback((node: HTMLElement | null) => {
setSortableRef(node);
(cardRef as React.MutableRefObject<HTMLDivElement | null>).current = node as HTMLDivElement | null;
}, [setSortableRef]);
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.5 : undefined,
};
useEffect(() => {
if (open) {
setTooltipsReady(false);
const timer = setTimeout(() => setTooltipsReady(true), 700);
if (cardRef.current) onScrollToCenter(cardRef.current);
return () => clearTimeout(timer);
} else {
setTooltipsReady(false);
}
}, [open, onScrollToCenter]);
const Icon = ruleIconMap[rule.config.type] || IconReplace;
const active = isRuleActive(rule.config as unknown as Record<string, unknown>);
const summary = getRuleSummary(rule.config as unknown as Record<string, unknown>);
const typeInfo = ruleTypes.find((r) => r.id === rule.config.type);
const pipelineIndex = pipeline.findIndex((r) => r.id === rule.id);
const canMoveLeft = pipelineIndex > 0;
const canMoveRight = pipelineIndex < pipeline.length - 1;
const stateLabel = !rule.config.enabled ? "disabled" : active ? "active" : "inactive";
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<ContextMenu>
<ContextMenuTrigger asChild>
<div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div>
<PopoverTrigger asChild>
<motion.button
initial={{ opacity: 0, scale: 0.8, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 20 }}
whileHover={{ y: -3, transition: { duration: 0.15 } }}
whileTap={{ scale: 0.97 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
aria-label={`${typeInfo?.label || rule.config.type} rule, step ${index + 1}, ${stateLabel}. Press Space to reorder.`}
aria-roledescription="sortable"
className={cn(
"flex items-start gap-3 px-4 py-4 rounded-xl border text-left shrink-0 transition-colors min-w-[200px] max-w-[260px]",
"cursor-grab group relative overflow-hidden",
isDragging && "cursor-grabbing",
open && "ring-2 ring-primary/40 border-primary/50 shadow-lg shadow-primary/5",
active && rule.config.enabled
? "border-primary/25 bg-primary/[0.04]"
: "border-border bg-card hover:bg-muted/50",
!rule.config.enabled && "opacity-45",
)}
>
{/* step number - bottom right */}
<div className="absolute bottom-2.5 right-3 text-[15px] font-mono text-muted-foreground/40">
{index + 1}
</div>
<div className={cn(
"mt-0.5 p-2.5 rounded-lg transition-colors relative",
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}>
<Icon size={22} stroke={1.5} />
{/* non-color active indicator - small filled dot */}
{active && rule.config.enabled && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-primary border border-card" aria-hidden="true" />
)}
{/* non-color disabled indicator - diagonal line */}
{!rule.config.enabled && (
<span className="absolute inset-0 flex items-center justify-center" aria-hidden="true">
<span className="w-full h-0.5 bg-muted-foreground/60 rotate-[-45deg]" />
</span>
)}
</div>
<div className="flex flex-col gap-1 min-w-0 flex-1">
<span className={cn(
"text-sm font-medium leading-tight",
active ? "text-foreground" : "text-muted-foreground"
)}>
{typeInfo?.label || rule.config.type}
</span>
<span className="text-xs text-muted-foreground leading-tight truncate">
{summary}
</span>
</div>
<Switch
checked={rule.config.enabled}
onCheckedChange={(checked) => updateRule(rule.id, { enabled: checked })}
className="mt-1 scale-75 origin-top-right"
onClick={(e) => e.stopPropagation()}
aria-label={`${rule.config.enabled ? "Disable" : "Enable"} ${typeInfo?.label || rule.config.type} rule`}
/>
</motion.button>
</PopoverTrigger>
</div>
</PopoverAnchor>
<PopoverContent
side="top"
align="center"
sideOffset={24}
onOpenAutoFocus={(e) => {
// focus first interactive element inside the config panel
e.preventDefault();
requestAnimationFrame(() => {
const el = cardRef.current?.closest("[data-slot='popover']")?.querySelector<HTMLElement>(
".p-4 input, .p-4 select, .p-4 button, .p-4 [tabindex='0']"
);
el?.focus();
});
}}
className={cn(
rule.config.type === "text_editor" ? "w-[560px]" : "w-[440px]",
"p-0 shadow-xl shadow-black/10 dark:shadow-black/30 !overflow-visible relative",
)}
>
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-md bg-primary/10">
<Icon size={18} stroke={1.5} className="text-primary" />
</div>
<span className="text-sm font-medium">
{typeInfo?.label || rule.config.type}
</span>
<span className="text-xs text-muted-foreground">
Step {index + 1}
</span>
</div>
<div className="flex items-center gap-1.5">
{tooltipsReady ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => resetRule(rule.id)}
className="text-muted-foreground hover:text-foreground"
aria-label="Reset to defaults"
>
<IconEraser size={20} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Reset to defaults</TooltipContent>
</Tooltip>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => resetRule(rule.id)}
className="text-muted-foreground hover:text-foreground"
aria-label="Reset to defaults"
>
<IconEraser size={20} stroke={1.5} />
</Button>
)}
{tooltipsReady ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => { removeRule(rule.id); setOpen(false); }}
className="text-muted-foreground hover:text-destructive"
aria-label="Remove step"
>
<IconTrash size={20} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Remove step</TooltipContent>
</Tooltip>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => { removeRule(rule.id); setOpen(false); }}
className="text-muted-foreground hover:text-destructive"
aria-label="Remove step"
>
<IconTrash size={20} stroke={1.5} />
</Button>
)}
{tooltipsReady ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setOpen(false)}
className="text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<IconX size={20} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Close</TooltipContent>
</Tooltip>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setOpen(false)}
className="text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<IconX size={20} stroke={1.5} />
</Button>
)}
</div>
</div>
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="p-4"
>
<ConfigContent ruleId={rule.id} />
</motion.div>
<PopoverArrowWithBorder />
</PopoverContent>
</Popover>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem
onClick={() => updateRule(rule.id, { enabled: !rule.config.enabled })}
className="gap-2 text-[13px]"
>
{rule.config.enabled
? <><IconPlayerPause size={15} stroke={1.5} /> Disable</>
: <><IconPlayerPlay size={15} stroke={1.5} /> Enable</>
}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => resetRule(rule.id)} className="gap-2 text-[13px]">
<IconEraser size={15} stroke={1.5} /> Reset to defaults
</ContextMenuItem>
<ContextMenuItem onClick={() => duplicateRule(rule.id)} className="gap-2 text-[13px]">
<IconCopy size={15} stroke={1.5} /> Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => canMoveLeft && reorderPipeline(pipelineIndex, pipelineIndex - 1)}
disabled={!canMoveLeft}
className="gap-2 text-[13px]"
>
<IconChevronLeft size={15} stroke={1.5} /> Move left
</ContextMenuItem>
<ContextMenuItem
onClick={() => canMoveRight && reorderPipeline(pipelineIndex, pipelineIndex + 1)}
disabled={!canMoveRight}
className="gap-2 text-[13px]"
>
<IconChevronRight size={15} stroke={1.5} /> Move right
</ContextMenuItem>
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => removeRule(rule.id)}
className="gap-2 text-[13px] text-destructive focus:text-destructive"
>
<IconTrash size={15} stroke={1.5} /> Remove
</ContextMenuItem>
</>
</ContextMenuContent>
</ContextMenu>
</div>
);
}
export function PipelineStrip() {
const pipeline = useRuleStore((s) => s.pipeline);
const addRule = useRuleStore((s) => s.addRule);
const reorderPipeline = useRuleStore((s) => s.reorderPipeline);
const requestPreview = useRuleStore((s) => s.requestPreview);
const currentPath = useFileStore((s) => s.currentPath);
const sortedFilePaths = useFileStore((s) => s.sortedFilePaths);
const showDisabledRules = useSettingsStore((s) => s.showDisabledRules);
const visiblePipeline = showDisabledRules === "hidden"
? pipeline.filter((r) => r.config.enabled)
: pipeline;
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
const scrollToCenter = useCallback((el: HTMLElement) => {
const instance = osRef.current?.osInstance();
if (!instance) return;
const viewport = instance.elements().viewport;
const viewportRect = viewport.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const elCenter = elRect.left + elRect.width / 2;
const viewportCenter = viewportRect.left + viewportRect.width / 2;
const scrollLeft = viewport.scrollLeft + (elCenter - viewportCenter);
viewport.scrollTo({ left: scrollLeft, behavior: "smooth" });
}, []);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = pipeline.findIndex((r) => r.id === active.id);
const newIndex = pipeline.findIndex((r) => r.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderPipeline(oldIndex, newIndex);
}
}
}
useEffect(() => {
if (currentPath) {
requestPreview(currentPath);
}
}, [pipeline, currentPath, sortedFilePaths, requestPreview]);
const isEmpty = visiblePipeline.length === 0;
return (
<div className="border-t bg-muted/30 h-[104px]" role="region" aria-label="Rename pipeline">
<OverlayScrollbarsComponent
ref={osRef}
options={{ overflow: { x: isEmpty ? "hidden" : "scroll", y: "hidden" }, scrollbars: { autoHide: "move", autoHideDelay: 600 } }}
className="px-4 py-4 h-full"
>
<div className={cn("flex items-center gap-3 h-full", isEmpty ? "justify-center" : "w-max")}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={visiblePipeline.map((r) => r.id)}
strategy={horizontalListSortingStrategy}
>
<AnimatePresence mode="popLayout">
{visiblePipeline.map((rule, index) => (
<PipelineCard key={rule.id} rule={rule} index={index} onScrollToCenter={scrollToCenter} />
))}
</AnimatePresence>
</SortableContext>
</DndContext>
<div className="shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="shrink-0 gap-2 text-sm border-dashed hover:border-primary hover:text-primary transition-colors h-auto py-4 px-5"
>
<IconPlus size={18} stroke={1.5} />
Add Rule
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-[420px] p-2">
<DropdownMenuLabel className="px-2">Add a rule to the pipeline</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="grid grid-cols-2 gap-0.5">
{ruleTypes.map((rt) => (
<DropdownMenuItem
key={rt.id}
className="gap-2 py-2 px-2 cursor-pointer rounded-md"
onClick={() => addRule(rt.id)}
>
<div className="p-1 rounded-md bg-muted shrink-0">
<rt.icon size={14} stroke={1.5} />
</div>
<div className="flex flex-col min-w-0">
<span className="text-[13px] font-medium leading-tight">{rt.label}</span>
<span className="text-[11px] text-muted-foreground leading-tight truncate">{rt.desc}</span>
</div>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</OverlayScrollbarsComponent>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import type { AddConfig as AddConfigType } from "@/types/rules";
export function AddConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as AddConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<AddConfigType>) => updateRule(ruleId, changes);
const addInsert = () => {
update({ inserts: [...rule.inserts, { position: 0, text: "" }] });
};
const removeInsert = (idx: number) => {
update({ inserts: rule.inserts.filter((_, i) => i !== idx) });
};
const updateInsert = (idx: number, changes: Partial<{ position: number; text: string }>) => {
update({
inserts: rule.inserts.map((ins, i) => (i === idx ? { ...ins, ...changes } : ins)),
});
};
return (
<div className="flex flex-col gap-3 text-sm">
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Prefix</span>
<Input
value={rule.prefix}
onChange={(e) => update({ prefix: e.target.value })}
placeholder="Add before..."
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Suffix</span>
<Input
value={rule.suffix}
onChange={(e) => update({ suffix: e.target.value })}
placeholder="Add after..."
className="h-8 text-xs font-mono"
/>
</label>
</div>
<div className="flex gap-2">
<label className="flex flex-col flex-1 gap-1">
<span className="text-xs text-muted-foreground">Insert text</span>
<Input
value={rule.insert}
onChange={(e) => update({ insert: e.target.value })}
placeholder="Text to insert..."
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col w-20 gap-1">
<span className="text-xs text-muted-foreground">At position</span>
<NumberInput value={rule.insert_at} onChange={(v) => update({ insert_at: v })} min={0} />
</label>
</div>
{rule.inserts.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Extra inserts</span>
{rule.inserts.map((ins, idx) => (
<div key={idx} className="flex gap-2 items-center">
<Input
value={ins.text}
onChange={(e) => updateInsert(idx, { text: e.target.value })}
placeholder="Text..."
className="h-7 text-xs font-mono flex-1"
/>
<NumberInput value={ins.position} onChange={(v) => updateInsert(idx, { position: v })} min={0} />
<button onClick={() => removeInsert(idx)} className="text-muted-foreground hover:text-destructive p-0.5">
<IconTrash size={14} />
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.word_space} onCheckedChange={(c) => update({ word_space: !!c })} />
Add spaces between words
</label>
<button onClick={addInsert} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground ml-auto">
<IconPlus size={14} /> Insert
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { CaseConfig as CaseConfigType } from "@/types/rules";
const basicModes = [
{ value: "Same", label: "Same" },
{ value: "Upper", label: "UPPER" },
{ value: "Lower", label: "lower" },
{ value: "Title", label: "Title" },
{ value: "Sentence", label: "Sent." },
{ value: "SmartTitle", label: "Smart" },
{ value: "Invert", label: "iNVERT" },
{ value: "Random", label: "rAnD" },
] as const;
const devModes = [
{ value: "CamelCase", label: "camelCase" },
{ value: "PascalCase", label: "Pascal" },
{ value: "SnakeCase", label: "snake_case" },
{ value: "KebabCase", label: "kebab-case" },
{ value: "DotCase", label: "dot.case" },
] as const;
export function CaseConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as CaseConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<CaseConfigType>) => updateRule(ruleId, changes);
const devValues = ["CamelCase", "PascalCase", "SnakeCase", "KebabCase", "DotCase"];
const isDevMode = devValues.includes(rule.mode);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Text case</span>
<SegmentedControl
value={isDevMode ? ("" as typeof rule.mode) : rule.mode}
onChange={(m) => update({ mode: m })}
options={basicModes}
size="sm"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Developer case</span>
<SegmentedControl
value={isDevMode ? rule.mode : ("" as typeof rule.mode)}
onChange={(m) => update({ mode: m })}
options={devModes}
size="sm"
/>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Exceptions (comma-separated)</span>
<Input
value={rule.exceptions}
onChange={(e) => update({ exceptions: e.target.value })}
placeholder="e.g. USB, HTML, API"
className="h-8 text-xs font-mono"
/>
</label>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { DateConfig as DateConfigType } from "@/types/rules";
const dateModes = [
{ value: "None", label: "None" },
{ value: "Prefix", label: "Prefix" },
{ value: "Suffix", label: "Suffix" },
{ value: "Insert", label: "Insert" },
] as const;
const dateSources = [
{ value: "Created", label: "Created" },
{ value: "Modified", label: "Modified" },
{ value: "Accessed", label: "Accessed" },
{ value: "ExifTaken", label: "EXIF" },
{ value: "Current", label: "Now" },
] as const;
const dateFormats = [
{ value: "YMD", label: "Y-M-D" },
{ value: "DMY", label: "D-M-Y" },
{ value: "MDY", label: "M-D-Y" },
{ value: "YM", label: "Y-M" },
{ value: "MY", label: "M-Y" },
{ value: "Y", label: "Year" },
] as const;
export function DateConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as DateConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<DateConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Position</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={dateModes} />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Source</span>
<SegmentedControl value={rule.source} onChange={(s) => update({ source: s })} options={dateSources} size="sm" />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Format</span>
<SegmentedControl value={rule.format} onChange={(f) => update({ format: f })} options={dateFormats} size="sm" />
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Date separator</span>
<Input
value={rule.separator}
onChange={(e) => update({ separator: e.target.value })}
placeholder="-"
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Name separator</span>
<Input
value={rule.segment_separator}
onChange={(e) => update({ segment_separator: e.target.value })}
placeholder="_"
className="h-8 text-xs font-mono"
/>
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Custom format (overrides preset)</span>
<Input
value={rule.custom_format || ""}
onChange={(e) => update({ custom_format: e.target.value || null })}
placeholder="%Y-%m-%d_%H%M%S"
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.include_time} onCheckedChange={(c) => update({ include_time: !!c })} />
Include time
</label>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { Checkbox } from "@/components/ui/checkbox";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import type { ExtensionConfig as ExtensionConfigType } from "@/types/rules";
const extModes = [
{ value: "Same", label: "Same" },
{ value: "Lower", label: "lower" },
{ value: "Upper", label: "UPPER" },
{ value: "Title", label: "Title" },
{ value: "Extra", label: "Extra" },
{ value: "Remove", label: "Remove" },
{ value: "Fixed", label: "Fixed" },
] as const;
export function ExtensionConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as ExtensionConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<ExtensionConfigType>) => updateRule(ruleId, changes);
const mappings = rule.mapping || [];
const addMapping = () => {
update({ mapping: [...mappings, ["", ""]] });
};
const removeMapping = (idx: number) => {
const next = mappings.filter((_, i) => i !== idx);
update({ mapping: next.length > 0 ? next : null });
};
const updateMapping = (idx: number, pos: 0 | 1, val: string) => {
const next = mappings.map((m, i) => {
if (i !== idx) return m;
const copy: [string, string] = [...m];
copy[pos] = val;
return copy;
});
update({ mapping: next });
};
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Mode</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={extModes} size="sm" />
</div>
{rule.mode === "Fixed" && (
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">New extension</span>
<Input
value={rule.fixed_value}
onChange={(e) => update({ fixed_value: e.target.value })}
placeholder="e.g. txt, jpg, png"
className="h-8 text-xs font-mono"
/>
</label>
)}
{mappings.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Extension mappings</span>
{mappings.map((m, idx) => (
<div key={idx} className="flex gap-2 items-center">
<Input
value={m[0]}
onChange={(e) => updateMapping(idx, 0, e.target.value)}
placeholder="From..."
className="h-7 text-xs font-mono flex-1"
/>
<span className="text-xs text-muted-foreground">{"->"}</span>
<Input
value={m[1]}
onChange={(e) => updateMapping(idx, 1, e.target.value)}
placeholder="To..."
className="h-7 text-xs font-mono flex-1"
/>
<button onClick={() => removeMapping(idx)} className="text-muted-foreground hover:text-destructive p-0.5">
<IconTrash size={14} />
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.multi_extension} onCheckedChange={(c) => update({ multi_extension: !!c })} />
Multi-extension (e.g. .tar.gz)
</label>
<button onClick={addMapping} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground ml-auto">
<IconPlus size={14} /> Mapping
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { FolderNameConfig as FolderNameConfigType } from "@/types/rules";
const folderModes = [
{ value: "None", label: "None" },
{ value: "Prefix", label: "Prefix" },
{ value: "Suffix", label: "Suffix" },
{ value: "Replace", label: "Replace" },
] as const;
export function FolderNameConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as FolderNameConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<FolderNameConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Position</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={folderModes} />
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Folder level (1 = parent)</span>
<NumberInput value={rule.level} onChange={(v) => update({ level: v })} min={1} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Separator</span>
<Input value={rule.separator} onChange={(e) => update({ separator: e.target.value })} className="h-8 text-xs font-mono" />
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { HashConfig as HashConfigType } from "@/types/rules";
const hashModes = [
{ value: "None", label: "None" },
{ value: "Prefix", label: "Prefix" },
{ value: "Suffix", label: "Suffix" },
{ value: "Replace", label: "Replace" },
] as const;
const algorithms = [
{ value: "MD5", label: "MD5" },
{ value: "SHA1", label: "SHA1" },
{ value: "SHA256", label: "SHA256" },
] as const;
export function HashConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as HashConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<HashConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Position</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={hashModes} />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Algorithm</span>
<SegmentedControl value={rule.algorithm} onChange={(a) => update({ algorithm: a })} options={algorithms} />
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Hash length (0 = full)</span>
<NumberInput value={rule.length} onChange={(v) => update({ length: v })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Separator</span>
<Input value={rule.separator} onChange={(e) => update({ separator: e.target.value })} className="h-8 text-xs font-mono" />
</label>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.uppercase} onCheckedChange={(c) => update({ uppercase: !!c })} />
Uppercase hex
</label>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { MovePartsConfig as MovePartsConfigType } from "@/types/rules";
const selectionModes = [
{ value: "Chars", label: "Chars" },
{ value: "Words", label: "Words" },
{ value: "Regex", label: "Regex" },
] as const;
const targetModes = [
{ value: "None", label: "None" },
{ value: "Start", label: "Start" },
{ value: "End", label: "End" },
] as const;
export function MovePartsConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as MovePartsConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<MovePartsConfigType>) => updateRule(ruleId, changes);
const targetValue = typeof rule.target === "string" ? rule.target : "Position";
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Selection mode</span>
<SegmentedControl value={rule.selection_mode} onChange={(m) => update({ selection_mode: m })} options={selectionModes} />
</div>
{rule.selection_mode === "Regex" ? (
<div className="grid grid-cols-[1fr_80px] gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Pattern</span>
<Input
value={rule.regex_pattern || ""}
onChange={(e) => update({ regex_pattern: e.target.value || null })}
placeholder="Regex..."
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Group</span>
<NumberInput value={rule.regex_group} onChange={(v) => update({ regex_group: v })} min={0} />
</label>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">From {rule.selection_mode === "Words" ? "word" : "pos"}</span>
<NumberInput value={rule.source_from} onChange={(v) => update({ source_from: v })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Length</span>
<NumberInput value={rule.source_length} onChange={(v) => update({ source_length: v })} min={0} />
</label>
</div>
)}
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Target</span>
<SegmentedControl value={targetValue} onChange={(t) => update({ target: t as "None" | "Start" | "End" })} options={targetModes} />
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Separator</span>
<Input
value={rule.separator}
onChange={(e) => update({ separator: e.target.value })}
placeholder=""
className="h-8 text-xs font-mono"
/>
</label>
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.copy_mode} onCheckedChange={(c) => update({ copy_mode: !!c })} />
Copy (keep original)
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { Checkbox } from "@/components/ui/checkbox";
import type { NumberingConfig as NumberingConfigType } from "@/types/rules";
const numberModes = [
{ value: "None", label: "None" },
{ value: "Prefix", label: "Prefix" },
{ value: "Suffix", label: "Suffix" },
{ value: "Both", label: "Both" },
{ value: "Insert", label: "Insert" },
] as const;
const bases = [
{ value: "Decimal", label: "Dec" },
{ value: "Hex", label: "Hex" },
{ value: "Octal", label: "Oct" },
{ value: "Binary", label: "Bin" },
{ value: "Alpha", label: "Alpha" },
{ value: "Roman", label: "Roman" },
] as const;
export function NumberingConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as NumberingConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<NumberingConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Position</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={numberModes} />
</div>
<div className="grid grid-cols-4 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Start</span>
<NumberInput value={rule.start} onChange={(v) => update({ start: v })} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Step</span>
<NumberInput value={rule.increment} onChange={(v) => update({ increment: v })} min={1} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Padding</span>
<NumberInput value={rule.padding} onChange={(v) => update({ padding: v })} min={1} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Separator</span>
<Input value={rule.separator} onChange={(e) => update({ separator: e.target.value })} className="h-8 text-xs font-mono" />
</label>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Base</span>
<SegmentedControl value={rule.base} onChange={(b) => update({ base: b })} options={bases} size="sm" />
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Custom format (overrides base)</span>
<Input
value={rule.custom_format || ""}
onChange={(e) => update({ custom_format: e.target.value || null })}
placeholder="e.g. {n:03}"
className="h-8 text-xs font-mono"
/>
</label>
<div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.per_folder} onCheckedChange={(c) => update({ per_folder: !!c })} />
Per folder
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.reverse} onCheckedChange={(c) => update({ reverse: !!c })} />
Reverse order
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import type { PaddingConfig as PaddingConfigType } from "@/types/rules";
export function PaddingConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as PaddingConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<PaddingConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Width</span>
<NumberInput value={rule.width} onChange={(v) => update({ width: v })} min={1} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Pad character</span>
<Input
value={rule.pad_char}
onChange={(e) => update({ pad_char: e.target.value.slice(0, 1) || "0" })}
maxLength={1}
className="h-8 text-xs font-mono text-center"
/>
</label>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.pad_all} onCheckedChange={(c) => update({ pad_all: !!c })} />
Pad all numbers (not just first)
</label>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { RandomizeConfig as RandomizeConfigType } from "@/types/rules";
const randomModes = [
{ value: "Replace", label: "Replace" },
{ value: "Prefix", label: "Prefix" },
{ value: "Suffix", label: "Suffix" },
] as const;
const randomFormats = [
{ value: "Hex", label: "Hex" },
{ value: "Alpha", label: "Alpha" },
{ value: "AlphaNum", label: "AlphaNum" },
{ value: "UUID", label: "UUID" },
] as const;
export function RandomizeConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as RandomizeConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<RandomizeConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Position</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={randomModes} />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Format</span>
<SegmentedControl value={rule.format} onChange={(f) => update({ format: f })} options={randomFormats} />
</div>
<div className="grid grid-cols-2 gap-2">
{rule.format !== "UUID" && (
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Length</span>
<NumberInput value={rule.length} onChange={(v) => update({ length: v })} min={1} />
</label>
)}
{rule.mode !== "Replace" && (
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Separator</span>
<Input value={rule.separator} onChange={(e) => update({ separator: e.target.value })} className="h-8 text-xs font-mono" />
</label>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import type { RegexConfig as RegexConfigType } from "@/types/rules";
export function RegexConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as RegexConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<RegexConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex gap-2">
<label className="flex flex-col flex-1 gap-1">
<span className="text-xs text-muted-foreground">Pattern</span>
<Input
value={rule.pattern}
onChange={(e) => update({ pattern: e.target.value })}
placeholder="Regex pattern..."
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col flex-1 gap-1">
<span className="text-xs text-muted-foreground">Replace with</span>
<Input
value={rule.replace_with}
onChange={(e) => update({ replace_with: e.target.value })}
placeholder="Replacement..."
className="h-8 text-xs font-mono"
/>
</label>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.case_insensitive} onCheckedChange={(c) => update({ case_insensitive: !!c })} />
Case insensitive
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Match limit</span>
<NumberInput value={rule.match_limit ?? 0} onChange={(v) => update({ match_limit: v || null })} min={0} />
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { RemoveConfig as RemoveConfigType } from "@/types/rules";
const removeModes = [
{ value: "Chars", label: "Chars" },
{ value: "Words", label: "Words" },
] as const;
export function RemoveConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as RemoveConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<RemoveConfigType>) => updateRule(ruleId, changes);
const label = rule.mode === "Words" ? "words" : "chars";
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Mode</span>
<SegmentedControl value={rule.mode} onChange={(m) => update({ mode: m })} options={removeModes} />
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">First N {label}</span>
<NumberInput value={rule.first_n} onChange={(v) => update({ first_n: v })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Last N {label}</span>
<NumberInput value={rule.last_n} onChange={(v) => update({ last_n: v })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">From position</span>
<NumberInput value={rule.from} onChange={(v) => update({ from: v })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">To position</span>
<NumberInput value={rule.to} onChange={(v) => update({ to: v })} min={0} />
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Crop before</span>
<Input
value={rule.crop_before || ""}
onChange={(e) => update({ crop_before: e.target.value || null })}
placeholder="Text..."
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Crop after</span>
<Input
value={rule.crop_after || ""}
onChange={(e) => update({ crop_after: e.target.value || null })}
placeholder="Text..."
className="h-8 text-xs font-mono"
/>
</label>
</div>
<div className="flex items-center gap-3 flex-wrap">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.trim.digits} onCheckedChange={(c) => update({ trim: { ...rule.trim, digits: !!c } })} />
Digits
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.trim.spaces} onCheckedChange={(c) => update({ trim: { ...rule.trim, spaces: !!c } })} />
Spaces
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.trim.symbols} onCheckedChange={(c) => update({ trim: { ...rule.trim, symbols: !!c } })} />
Symbols
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.trim.accents} onCheckedChange={(c) => update({ trim: { ...rule.trim, accents: !!c } })} />
Accents
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.trim.lead_dots} onCheckedChange={(c) => update({ trim: { ...rule.trim, lead_dots: !!c } })} />
Leading dots
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Collapse chars</span>
<Input
value={rule.collapse_chars || ""}
onChange={(e) => update({ collapse_chars: e.target.value || null })}
placeholder="e.g. -_"
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Remove pattern</span>
<Input
value={rule.remove_pattern || ""}
onChange={(e) => update({ remove_pattern: e.target.value || null })}
placeholder="Regex..."
className="h-8 text-xs font-mono"
/>
</label>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.allow_empty} onCheckedChange={(c) => update({ allow_empty: !!c })} />
Allow removing everything
</label>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { Checkbox } from "@/components/ui/checkbox";
import type { ReplaceConfig as ReplaceConfigType } from "@/types/rules";
export function ReplaceConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as ReplaceConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<ReplaceConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex gap-2">
<label className="flex flex-col flex-1 gap-1">
<span className="text-xs text-muted-foreground">Find</span>
<Input
value={rule.search}
onChange={(e) => update({ search: e.target.value })}
placeholder="Text to find..."
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col flex-1 gap-1">
<span className="text-xs text-muted-foreground">Replace with</span>
<Input
value={rule.replace_with}
onChange={(e) => update({ replace_with: e.target.value })}
placeholder="Replacement..."
className="h-8 text-xs font-mono"
/>
</label>
</div>
<div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.match_case} onCheckedChange={(c) => update({ match_case: !!c })} />
Match case
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.first_only} onCheckedChange={(c) => update({ first_only: !!c })} />
First only
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.use_regex} onCheckedChange={(c) => update({ use_regex: !!c })} />
Regex
</label>
</div>
<div className="grid grid-cols-3 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Scope start</span>
<NumberInput value={rule.scope_start ?? 0} onChange={(v) => update({ scope_start: v || null })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Scope end</span>
<NumberInput value={rule.scope_end ?? 0} onChange={(v) => update({ scope_end: v || null })} min={0} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Occurrence</span>
<NumberInput value={rule.occurrence ?? 0} onChange={(v) => update({ occurrence: v || null })} min={0} />
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Checkbox } from "@/components/ui/checkbox";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { SanitizeConfig as SanitizeConfigType } from "@/types/rules";
const spaceModes = [
{ value: "None", label: "Keep" },
{ value: "Underscores", label: "_" },
{ value: "Dashes", label: "-" },
{ value: "Dots", label: "." },
] as const;
export function SanitizeConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as SanitizeConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<SanitizeConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Spaces</span>
<SegmentedControl value={rule.spaces_to} onChange={(s) => update({ spaces_to: s })} options={spaceModes} />
</div>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.illegal_chars} onCheckedChange={(c) => update({ illegal_chars: !!c })} />
Remove illegal characters
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.trim_dots_spaces} onCheckedChange={(c) => update({ trim_dots_spaces: !!c })} />
Trim leading/trailing dots and spaces
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.collapse_whitespace} onCheckedChange={(c) => update({ collapse_whitespace: !!c })} />
Collapse repeated separators
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.strip_diacritics} onCheckedChange={(c) => update({ strip_diacritics: !!c })} />
Strip diacritics/accents
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.strip_zero_width} onCheckedChange={(c) => update({ strip_zero_width: !!c })} />
Strip zero-width characters
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
<Checkbox checked={rule.normalize_unicode} onCheckedChange={(c) => update({ normalize_unicode: !!c })} />
Normalize unicode (NFC)
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { SwapConfig as SwapConfigType } from "@/types/rules";
const swapOccurrences = [
{ value: "First", label: "First" },
{ value: "Last", label: "Last" },
] as const;
export function SwapConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as SwapConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<SwapConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Split on</span>
<Input
value={rule.delimiter}
onChange={(e) => update({ delimiter: e.target.value })}
placeholder="e.g. , or -"
className="h-8 text-xs font-mono"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Rejoin with (blank = same)</span>
<Input
value={rule.new_delimiter ?? ""}
onChange={(e) => update({ new_delimiter: e.target.value || null })}
placeholder="Same as split"
className="h-8 text-xs font-mono"
/>
</label>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Split at occurrence</span>
<SegmentedControl value={rule.occurrence} onChange={(o) => update({ occurrence: o })} options={swapOccurrences} />
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { useRuleStore } from "@/stores/ruleStore";
import { useFileStore } from "@/stores/fileStore";
import { Button } from "@/components/ui/button";
import type { TextEditorConfig as TextEditorConfigType } from "@/types/rules";
export function TextEditorConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as TextEditorConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
const sortedFilePaths = useFileStore((s) => s.sortedFilePaths);
const selectedFiles = useFileStore((s) => s.selectedFiles);
const files = useFileStore((s) => s.files);
const [text, setText] = useState("");
const [initialized, setInitialized] = useState(false);
// get selected file stems in display order
const fileMap = new Map(files.map((f) => [f.path, f]));
const selectedStems = sortedFilePaths
.filter((p) => selectedFiles.has(p))
.map((p) => fileMap.get(p)?.stem ?? "");
const lineCount = text ? text.split("\n").length : 0;
const fileCount = selectedStems.length;
const mismatch = lineCount !== fileCount && text.length > 0;
// populate textarea with current stems when first opened or when selection changes and user hasn't edited
useEffect(() => {
if (!rule) return;
if (rule.names.length === 0 && !initialized) {
const joined = selectedStems.join("\n");
setText(joined);
setInitialized(true);
}
}, [rule, selectedStems.length, initialized]);
// sync rule names when first loaded with existing data
useEffect(() => {
if (!rule) return;
if (rule.names.length > 0 && !initialized) {
setText(rule.names.join("\n"));
setInitialized(true);
}
}, [rule, initialized]);
if (!rule) return null;
const handleChange = (val: string) => {
setText(val);
const names = val.split("\n");
updateRule(ruleId, { names });
};
const handleLoad = () => {
const joined = selectedStems.join("\n");
setText(joined);
updateRule(ruleId, { names: selectedStems });
};
return (
<div className="flex flex-col gap-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{lineCount} line{lineCount !== 1 ? "s" : ""} / {fileCount} file{fileCount !== 1 ? "s" : ""}
</span>
<Button variant="ghost" size="sm" onClick={handleLoad} className="h-6 text-xs px-2">
Load current names
</Button>
</div>
{mismatch && (
<div className="text-xs text-amber-500 bg-amber-500/10 rounded px-2 py-1">
Line count ({lineCount}) doesn't match selected files ({fileCount}).
Extra lines will be ignored, missing lines will keep original names.
</div>
)}
<textarea
value={text}
onChange={(e) => handleChange(e.target.value)}
spellCheck={false}
className="w-full min-h-[360px] max-h-[600px] rounded-lg border border-input bg-transparent p-3 text-xs font-mono leading-relaxed resize-y outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 dark:bg-input/30"
placeholder={"One filename per line...\nLine 1 = first selected file\nLine 2 = second selected file\n..."}
/>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import type {} from "@/types/rules";
export function TransliterateConfig({ ruleId: _ }: { ruleId: string }) {
return (
<div className="text-sm text-muted-foreground">
Converts non-ASCII characters to their closest ASCII equivalents.
No configuration needed - just enable the rule.
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useRuleStore } from "@/stores/ruleStore";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/ui/number-input";
import { SegmentedControl } from "@/components/ui/segmented-control";
import type { TruncateConfig as TruncateConfigType } from "@/types/rules";
const truncateFrom = [
{ value: "End", label: "From end" },
{ value: "Start", label: "From start" },
] as const;
export function TruncateConfig({ ruleId }: { ruleId: string }) {
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as TruncateConfigType | undefined;
const updateRule = useRuleStore((s) => s.updateRule);
if (!rule) return null;
const update = (changes: Partial<TruncateConfigType>) => updateRule(ruleId, changes);
return (
<div className="flex flex-col gap-3 text-sm">
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Max length</span>
<NumberInput value={rule.max_length} onChange={(v) => update({ max_length: v })} min={1} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Truncation marker</span>
<Input
value={rule.suffix}
onChange={(e) => update({ suffix: e.target.value })}
placeholder="e.g. ..."
className="h-8 text-xs font-mono"
/>
</label>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Truncate from</span>
<SegmentedControl value={rule.from} onChange={(f) => update({ from: f })} options={truncateFrom} />
</div>
</div>
);
}

View File

@@ -0,0 +1,306 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { save, open as dialogOpen } from "@tauri-apps/plugin-dialog";
import { useRuleStore } from "@/stores/ruleStore";
import { useFileStore } from "@/stores/fileStore";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
IconTrash,
IconPlayerPlay,
IconPlus,
IconFileExport,
IconFileImport,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import type { NominaPreset, PresetInfo } from "@/types/presets";
interface PresetsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function PresetsDialog({ open, onOpenChange }: PresetsDialogProps) {
const [presets, setPresets] = useState<PresetInfo[]>([]);
const [saving, setSaving] = useState(false);
const [name, setName] = useState("");
const [desc, setDesc] = useState("");
const [tooltipsReady, setTooltipsReady] = useState(false);
const pipeline = useRuleStore((s) => s.pipeline);
const loadPipeline = useRuleStore((s) => s.loadPipeline);
const filters = useFileStore((s) => s.filters);
useEffect(() => {
if (open) {
setTooltipsReady(false);
const timer = setTimeout(() => setTooltipsReady(true), 700);
refresh();
return () => clearTimeout(timer);
} else {
setTooltipsReady(false);
}
}, [open]);
async function refresh() {
try {
const list = await invoke<PresetInfo[]>("list_presets");
setPresets(list);
} catch (e) {
console.error("Failed to list presets:", e);
}
}
async function handleSave() {
if (!name.trim()) return;
const preset: NominaPreset = {
version: 1,
name: name.trim(),
description: desc.trim(),
created: new Date().toISOString(),
rules: pipeline.map((r) => r.config),
filters,
};
try {
await invoke("save_preset", { preset });
setName("");
setDesc("");
setSaving(false);
await refresh();
} catch (e) {
console.error("Failed to save preset:", e);
}
}
async function handleLoad(path: string) {
try {
const preset = await invoke<NominaPreset>("load_preset", { path });
loadPipeline(preset.rules);
if (preset.filters) {
useFileStore.getState().setFilters(preset.filters);
}
onOpenChange(false);
} catch (e) {
console.error("Failed to load preset:", e);
}
}
async function handleDelete(path: string) {
try {
await invoke("delete_preset", { path });
await refresh();
} catch (e) {
await refresh();
}
}
async function handleExport(preset: PresetInfo) {
try {
const dest = await save({
title: "Export preset",
defaultPath: `${preset.name}.nomina`,
filters: [{ name: "Nomina Preset", extensions: ["nomina"] }],
});
if (!dest) return;
await invoke("export_preset", { sourcePath: preset.path, destPath: dest });
toast.success(`Exported "${preset.name}"`);
} catch (e) {
toast.error(`Export failed: ${e}`);
}
}
async function handleImport() {
try {
const selected = await dialogOpen({
title: "Import preset",
multiple: false,
filters: [
{ name: "All supported presets", extensions: ["nomina", "bru"] },
{ name: "Nomina Preset", extensions: ["nomina"] },
{ name: "Bulk Rename Utility", extensions: ["bru"] },
],
});
if (!selected) return;
const path = typeof selected === "string" ? selected : selected;
const preset = await invoke<NominaPreset>("import_preset", { path });
loadPipeline(preset.rules);
if (preset.filters) {
useFileStore.getState().setFilters(preset.filters);
}
toast.success(`Imported "${preset.name}" (${preset.rules.length} rules)`);
onOpenChange(false);
} catch (e) {
toast.error(`Import failed: ${e}`);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Presets</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[280px]">
{presets.length === 0 && !saving && (
<p className="text-xs text-muted-foreground py-6 text-center">
No saved presets yet
</p>
)}
<div className="flex flex-col gap-1">
{presets.map((p) => (
<div
key={p.path}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted group"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{p.name}</div>
{p.description && (
<div className="text-xs text-muted-foreground truncate">{p.description}</div>
)}
</div>
{tooltipsReady ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleLoad(p.path)}
className="opacity-0 group-hover:opacity-100"
aria-label={`Apply preset ${p.name}`}
>
<IconPlayerPlay size={14} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Apply</TooltipContent>
</Tooltip>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleLoad(p.path)}
className="opacity-0 group-hover:opacity-100"
aria-label={`Apply preset ${p.name}`}
>
<IconPlayerPlay size={14} stroke={1.5} />
</Button>
)}
{tooltipsReady ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleExport(p)}
className="opacity-0 group-hover:opacity-100"
aria-label={`Export preset ${p.name}`}
>
<IconFileExport size={14} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Export</TooltipContent>
</Tooltip>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleExport(p)}
className="opacity-0 group-hover:opacity-100"
aria-label={`Export preset ${p.name}`}
>
<IconFileExport size={14} stroke={1.5} />
</Button>
)}
{tooltipsReady ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(p.path)}
className="opacity-0 group-hover:opacity-100 text-destructive"
aria-label={`Delete preset ${p.name}`}
>
<IconTrash size={14} stroke={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Delete</TooltipContent>
</Tooltip>
) : (
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDelete(p.path)}
className="opacity-0 group-hover:opacity-100 text-destructive"
aria-label={`Delete preset ${p.name}`}
>
<IconTrash size={14} stroke={1.5} />
</Button>
)}
</div>
))}
</div>
</ScrollArea>
{saving ? (
<div className="flex flex-col gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Preset name"
className="h-8 text-xs"
autoFocus
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
<Input
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="Description (optional)"
className="h-8 text-xs"
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
</div>
) : null}
<DialogFooter className="sm:justify-between">
<Button variant="outline" size="sm" onClick={handleImport}>
<IconFileImport size={14} stroke={1.5} className="mr-1" />
Import
</Button>
<div className="flex gap-2">
{saving ? (
<>
<Button variant="outline" size="sm" onClick={() => setSaving(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={!name.trim()}>
Save
</Button>
</>
) : (
<Button
size="sm"
onClick={() => setSaving(true)}
className={cn(pipeline.length === 0 && "opacity-50")}
disabled={pipeline.length === 0}
>
<IconPlus size={14} stroke={1.5} className="mr-1" />
Save current pipeline
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,102 +0,0 @@
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>
);
}

View File

@@ -1,69 +0,0 @@
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>
);
}

View File

@@ -1,71 +0,0 @@
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>
);
}

View File

@@ -1,136 +0,0 @@
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>
);
}

View File

@@ -1,74 +0,0 @@
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>
);
}

View File

@@ -1,123 +0,0 @@
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>
);
}

View File

@@ -1,82 +0,0 @@
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>
);
}

View File

@@ -1,113 +0,0 @@
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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { IconCheck } from "@tabler/icons-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<IconCheck stroke={1.5} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,266 @@
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { usePortalContainer } from "@/lib/portal"
import { IconChevronRight, IconCheck } from "@tabler/icons-react"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuContent({
className,
collisionPadding = 8,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
const container = usePortalContainer();
return (
<ContextMenuPrimitive.Portal container={container ?? undefined}>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
collisionBoundary={container ?? undefined}
collisionPadding={collisionPadding}
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto overscroll-contain rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<IconCheck
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<IconCheck
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,166 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { usePortalContainer } from "@/lib/portal"
import { Button } from "@/components/ui/button"
import { IconX } from "@tabler/icons-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
const container = usePortalContainer();
return <DialogPrimitive.Portal data-slot="dialog-portal" container={container ?? undefined} {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<IconX stroke={1.5} />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,272 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { usePortalContainer } from "@/lib/portal"
import { IconCheck, IconChevronRight } from "@tabler/icons-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
collisionPadding = 8,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
const container = usePortalContainer();
return (
<DropdownMenuPrimitive.Portal container={container ?? undefined}>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
collisionBoundary={container ?? undefined}
collisionPadding={collisionPadding}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto overscroll-contain rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<IconCheck stroke={1.5} />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<IconCheck stroke={1.5} />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" stroke={1.5} />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,196 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { IconMinus, IconPlus } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settingsStore";
interface NumberInputProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
sensitivity?: number;
className?: string;
"aria-label"?: string;
}
export function NumberInput({
value,
onChange,
min = -Infinity,
max = Infinity,
step = 1,
sensitivity = 0.5,
className,
"aria-label": ariaLabel,
}: NumberInputProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const pressed = useRef(false);
const dragState = useRef({ startX: 0, startVal: 0, moved: false, target: "" });
const repeatTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const repeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const repeatCount = useRef(0);
const valueRef = useRef(value);
valueRef.current = value;
const clamp = useCallback(
(v: number) => Math.max(min, Math.min(max, v)),
[min, max],
);
const stopRepeat = useCallback(() => {
if (repeatTimer.current) { clearTimeout(repeatTimer.current); repeatTimer.current = null; }
if (repeatInterval.current) { clearInterval(repeatInterval.current); repeatInterval.current = null; }
repeatCount.current = 0;
}, []);
useEffect(() => stopRepeat, [stopRepeat]);
const startRepeat = useCallback((direction: number) => {
stopRepeat();
repeatTimer.current = setTimeout(() => {
const tick = () => {
repeatCount.current++;
// accelerate: after 10 ticks use step*2, after 30 use step*5
let mult = 1;
if (repeatCount.current > 30) mult = 5;
else if (repeatCount.current > 10) mult = 2;
onChange(clamp(valueRef.current + direction * step * mult));
};
tick();
repeatInterval.current = setInterval(tick, 60);
}, 400);
}, [stopRepeat, step, clamp, onChange]);
const startEdit = () => {
setEditValue(String(value));
setEditing(true);
requestAnimationFrame(() => inputRef.current?.select());
};
const commitEdit = () => {
setEditing(false);
const parsed = parseInt(editValue);
if (!isNaN(parsed)) onChange(clamp(parsed));
};
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (editing) return;
e.preventDefault();
const actionEl = (e.target as HTMLElement).closest("[data-action]") as HTMLElement | null;
const target = actionEl?.dataset.action || "value";
dragState.current = { startX: e.clientX, startVal: value, moved: false, target };
pressed.current = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
if (target === "dec") startRepeat(-1);
else if (target === "inc") startRepeat(1);
},
[editing, value, startRepeat],
);
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!pressed.current) return;
const zoom = useSettingsStore.getState().zoom;
const dx = (e.clientX - dragState.current.startX) / zoom;
if (Math.abs(dx) > 4) {
dragState.current.moved = true;
stopRepeat();
if (!dragging) setDragging(true);
}
if (dragState.current.moved) {
const delta = Math.round(dx * sensitivity) * step;
onChange(clamp(dragState.current.startVal + delta));
}
},
[dragging, sensitivity, step, clamp, onChange, stopRepeat],
);
const onPointerUp = useCallback(() => {
if (!pressed.current) return;
pressed.current = false;
const wasDragging = dragging;
const wasRepeating = repeatCount.current > 0;
setDragging(false);
stopRepeat();
if (dragState.current.moved) return;
if (wasDragging) return;
if (wasRepeating) return;
const { target } = dragState.current;
if (target === "dec") {
onChange(clamp(dragState.current.startVal - step));
} else if (target === "inc") {
onChange(clamp(dragState.current.startVal + step));
} else {
startEdit();
}
}, [dragging, step, clamp, onChange, stopRepeat]);
return (
<div
className={cn(
"flex items-center h-8 rounded-lg border border-input bg-transparent transition-colors",
"focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
!editing && !dragging && "cursor-ew-resize",
dragging && "cursor-ew-resize border-primary/50",
"dark:bg-input/30",
className,
)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
role="spinbutton"
aria-valuenow={value}
aria-valuemin={min === -Infinity ? undefined : min}
aria-valuemax={max === Infinity ? undefined : max}
aria-label={ariaLabel}
>
<div
data-action="dec"
className={cn(
"shrink-0 flex items-center justify-center w-6 h-full text-muted-foreground rounded-l-lg transition-colors",
dragging ? "cursor-ew-resize" : "cursor-pointer",
!dragging && "hover:text-foreground hover:bg-muted/50",
value <= min && "opacity-30 pointer-events-none",
)}
>
<IconMinus size={12} stroke={2} />
</div>
{editing ? (
<input
ref={inputRef}
type="text"
inputMode="numeric"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditing(false);
}}
className="flex-1 min-w-0 bg-transparent text-center text-xs font-mono outline-none px-0.5"
/>
) : (
<span className="flex-1 min-w-0 text-center text-xs font-mono select-none tabular-nums">
{value}
</span>
)}
<div
data-action="inc"
className={cn(
"shrink-0 flex items-center justify-center w-6 h-full text-muted-foreground rounded-r-lg transition-colors",
dragging ? "cursor-ew-resize" : "cursor-pointer",
!dragging && "hover:text-foreground hover:bg-muted/50",
value >= max && "opacity-30 pointer-events-none",
)}
>
<IconPlus size={12} stroke={2} />
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { usePortalContainer } from "@/lib/portal"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
collisionPadding = 8,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
const container = usePortalContainer();
return (
<PopoverPrimitive.Portal container={container ?? undefined}>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
collisionBoundary={container ?? undefined}
collisionPadding={collisionPadding}
className={cn(
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden overscroll-contain duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
function PopoverArrow({
className,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
return (
<PopoverPrimitive.Arrow
data-slot="popover-arrow"
className={cn("fill-popover", className)}
{...props}
/>
)
}
export {
Popover,
PopoverAnchor,
PopoverArrow,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,71 @@
import { useRef, useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
interface SegmentedControlProps<T extends string> {
value: T;
onChange: (value: T) => void;
options: readonly { value: T; label: string }[];
size?: "sm" | "md";
className?: string;
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
size = "md",
className,
}: SegmentedControlProps<T>) {
const buttonsRef = useRef<Map<string, HTMLButtonElement>>(new Map());
const [highlight, setHighlight] = useState<{ left: number; width: number } | null>(null);
const hasAnimated = useRef(false);
const measure = useCallback(() => {
const btn = buttonsRef.current.get(value);
if (btn) {
setHighlight({ left: btn.offsetLeft, width: btn.offsetWidth });
}
}, [value]);
useEffect(() => {
measure();
}, [measure]);
return (
<div
className={cn(
"relative flex items-center rounded-lg border border-input bg-muted/50 p-0.5 shrink-0",
className,
)}
>
{highlight && (
<motion.div
className="absolute rounded-md bg-background shadow-sm ring-1 ring-primary/20"
initial={{ left: highlight.left, width: highlight.width }}
animate={{ left: highlight.left, width: highlight.width }}
transition={hasAnimated.current ? { type: "tween", duration: 0.3, ease: "easeInOut" } : { duration: 0 }}
onAnimationComplete={() => { hasAnimated.current = true; }}
style={{ top: 2, bottom: 2 }}
/>
)}
{options.map((opt) => (
<button
key={opt.value}
ref={(el) => {
if (el) buttonsRef.current.set(opt.value, el);
else buttonsRef.current.delete(opt.value);
}}
onClick={() => onChange(opt.value)}
className={cn(
"relative z-10 flex-1 rounded-md font-medium whitespace-nowrap",
size === "sm" ? "px-1.5 py-0.5 text-[10px]" : "px-2.5 py-1 text-xs",
value === opt.value ? "text-primary" : "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,20 @@
import { Toaster as Sonner } from "sonner";
export function Toaster() {
return (
<Sonner
position="bottom-right"
toastOptions={{
classNames: {
toast: "bg-card text-card-foreground border-border shadow-lg font-sans text-[13px]",
title: "font-medium",
description: "text-muted-foreground text-[12px]",
success: "border-success/30",
error: "border-destructive/30",
},
}}
offset={8}
gap={6}
/>
);
}

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { usePortalContainer } from "@/lib/portal"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
const container = usePortalContainer();
return (
<TooltipPrimitive.Portal container={container ?? undefined}>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

@@ -0,0 +1,19 @@
import { create } from "zustand";
interface AnnounceStore {
message: string;
announce: (text: string) => void;
}
export const useAnnounceStore = create<AnnounceStore>((set) => ({
message: "",
announce: (text: string) => {
// clear first so repeated identical messages still trigger
set({ message: "" });
requestAnimationFrame(() => set({ message: text }));
},
}));
export function announce(text: string) {
useAnnounceStore.getState().announce(text);
}

View File

@@ -1,12 +0,0 @@
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;
}

View File

@@ -1,16 +1,13 @@
import { useEffect } from "react";
import { useFileStore } from "../stores/fileStore";
import { useRuleStore } from "../stores/ruleStore";
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();
@@ -23,14 +20,9 @@ export function useKeyboardShortcuts() {
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]);
}, [selectAll, deselectAll, resetAllRules]);
}

109
ui/src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,109 @@
import { useEffect } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useSettingsStore } from "@/stores/settingsStore";
export function useTheme() {
const theme = useSettingsStore((s) => s.theme);
const accentHue = useSettingsStore((s) => s.accentHue);
const alwaysOnTop = useSettingsStore((s) => s.alwaysOnTop);
const compactMode = useSettingsStore((s) => s.compactMode);
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const enableDebugLogging = useSettingsStore((s) => s.enableDebugLogging);
// accessibility settings
const highContrast = useSettingsStore((s) => s.highContrast);
const largerTouchTargets = useSettingsStore((s) => s.largerTouchTargets);
const reduceTransparency = useSettingsStore((s) => s.reduceTransparency);
const focusIndicators = useSettingsStore((s) => s.focusIndicators);
const screenReaderOptimized = useSettingsStore((s) => s.screenReaderOptimized);
const motionSensitivity = useSettingsStore((s) => s.motionSensitivity);
const fontScaling = useSettingsStore((s) => s.fontScaling);
const colorBlindMode = useSettingsStore((s) => s.colorBlindMode);
const keyboardNavigationMode = useSettingsStore((s) => s.keyboardNavigationMode);
const minimumContrastRatio = useSettingsStore((s) => s.minimumContrastRatio);
useEffect(() => {
document.documentElement.style.setProperty("--accent-hue", String(accentHue));
}, [accentHue]);
useEffect(() => {
const root = document.documentElement;
function apply(dark: boolean) {
root.classList.toggle("dark", dark);
}
if (theme === "dark") { apply(true); return; }
if (theme === "light") { apply(false); return; }
const mq = window.matchMedia("(prefers-color-scheme: dark)");
apply(mq.matches);
const handler = (e: MediaQueryListEvent) => apply(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [theme]);
useEffect(() => {
getCurrentWindow().setAlwaysOnTop(alwaysOnTop).catch(() => {});
}, [alwaysOnTop]);
useEffect(() => {
document.documentElement.classList.toggle("compact", compactMode);
}, [compactMode]);
useEffect(() => {
document.documentElement.classList.toggle("no-animations", !animationsEnabled);
}, [animationsEnabled]);
useEffect(() => {
(window as any).__NOMINA_DEBUG = enableDebugLogging;
if (enableDebugLogging) console.info("[nomina] debug logging enabled");
}, [enableDebugLogging]);
// accessibility wiring
useEffect(() => {
document.documentElement.classList.toggle("high-contrast", highContrast);
}, [highContrast]);
useEffect(() => {
document.documentElement.classList.toggle("larger-targets", largerTouchTargets);
}, [largerTouchTargets]);
useEffect(() => {
document.documentElement.classList.toggle("reduce-transparency", reduceTransparency);
}, [reduceTransparency]);
useEffect(() => {
document.documentElement.classList.toggle("enhanced-focus", focusIndicators === "enhanced");
}, [focusIndicators]);
useEffect(() => {
document.documentElement.classList.toggle("sr-optimized", screenReaderOptimized);
}, [screenReaderOptimized]);
useEffect(() => {
const root = document.documentElement;
root.classList.toggle("motion-reduced", motionSensitivity === "reduced");
root.classList.toggle("motion-none", motionSensitivity === "none");
if (motionSensitivity === "none") {
root.classList.add("no-animations");
}
}, [motionSensitivity]);
useEffect(() => {
document.documentElement.style.setProperty("--font-scale", String(fontScaling));
}, [fontScaling]);
useEffect(() => {
const root = document.documentElement;
root.removeAttribute("data-color-blind");
if (colorBlindMode !== "none") {
root.setAttribute("data-color-blind", colorBlindMode);
}
}, [colorBlindMode]);
useEffect(() => {
document.documentElement.classList.toggle("keyboard-nav", keyboardNavigationMode);
}, [keyboardNavigationMode]);
useEffect(() => {
document.documentElement.classList.toggle("contrast-aaa", minimumContrastRatio === "aaa");
}, [minimumContrastRatio]);
}

View File

@@ -0,0 +1,123 @@
import { useEffect } from "react";
import { getCurrentWindow, availableMonitors } from "@tauri-apps/api/window";
import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi";
const KEY = "nomina-window-state";
const MIN_WIDTH = 400;
const MIN_HEIGHT = 300;
const MAX_WIDTH = 7680;
const MAX_HEIGHT = 4320;
interface WindowState {
x: number;
y: number;
width: number;
height: number;
maximized: boolean;
}
function applyMaximizedClass(maximized: boolean) {
document.documentElement.classList.toggle("maximized", maximized);
}
async function isPositionVisible(x: number, y: number, w: number, h: number): Promise<boolean> {
try {
const monitors = await availableMonitors();
if (monitors.length === 0) return true; // can't check, assume ok
// at least 100px of the window must be visible on some monitor
for (const m of monitors) {
const mx = m.position.x;
const my = m.position.y;
const mw = m.size.width;
const mh = m.size.height;
const overlapX = Math.max(0, Math.min(x + w, mx + mw) - Math.max(x, mx));
const overlapY = Math.max(0, Math.min(y + h, my + mh) - Math.max(y, my));
if (overlapX >= 100 && overlapY >= 50) return true;
}
return false;
} catch {
return true;
}
}
function isSizeSane(w: number, h: number): boolean {
return w >= MIN_WIDTH && w <= MAX_WIDTH && h >= MIN_HEIGHT && h <= MAX_HEIGHT
&& Number.isFinite(w) && Number.isFinite(h);
}
export function useWindowState() {
useEffect(() => {
const win = getCurrentWindow();
// restore
(async () => {
try {
const raw = localStorage.getItem(KEY);
if (raw) {
const s: WindowState = JSON.parse(raw);
if (s.maximized) {
win.maximize();
applyMaximizedClass(true);
} else {
const sizeOk = isSizeSane(s.width, s.height);
const width = sizeOk ? s.width : 1200;
const height = sizeOk ? s.height : 800;
const posOk = Number.isFinite(s.x) && Number.isFinite(s.y)
&& await isPositionVisible(s.x, s.y, width, height);
await win.setSize(new PhysicalSize(width, height));
if (posOk) {
await win.setPosition(new PhysicalPosition(s.x, s.y));
} else {
await win.center();
}
applyMaximizedClass(false);
}
}
} catch {}
})();
// track maximize/unmaximize to toggle padding/border-radius
const unlistenResize = win.onResized(async () => {
try {
const maximized = await win.isMaximized();
applyMaximizedClass(maximized);
} catch {}
});
// save state periodically - no onCloseRequested needed
async function saveState() {
try {
const maximized = await win.isMaximized();
if (maximized) {
const prev = localStorage.getItem(KEY);
if (prev) {
const s: WindowState = JSON.parse(prev);
s.maximized = true;
localStorage.setItem(KEY, JSON.stringify(s));
} else {
localStorage.setItem(KEY, JSON.stringify({ x: 100, y: 100, width: 1200, height: 800, maximized: true }));
}
return;
}
const pos = await win.outerPosition();
const size = await win.outerSize();
const state: WindowState = {
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
maximized: false,
};
localStorage.setItem(KEY, JSON.stringify(state));
} catch {}
}
const timer = setInterval(saveState, 2000);
return () => {
clearInterval(timer);
unlistenResize.then((fn) => fn());
};
}, []);
}

View File

@@ -1,78 +1,431 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@import "overlayscrollbars/overlayscrollbars.css";
@custom-variant dark (&:is(.dark *));
: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;
--accent-hue: 160;
--background: oklch(0.985 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.546 0.155 var(--accent-hue));
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.965 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.965 0 0);
--muted-foreground: oklch(0.385 0 0);
--accent: oklch(0.965 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--success: oklch(0.648 0.2 145);
--warning: oklch(0.7 0.17 75);
--border: oklch(0.912 0 0);
--input: oklch(0.912 0 0);
--ring: oklch(0.546 0.155 var(--accent-hue));
--radius: 0.5rem;
--sidebar: oklch(0.975 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.546 0.155 var(--accent-hue));
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.965 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.912 0 0);
--sidebar-ring: oklch(0.546 0.155 var(--accent-hue));
--window-border: oklch(0.75 0 0);
}
@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;
.dark {
--background: oklch(0.178 0 0);
--foreground: oklch(0.935 0 0);
--card: oklch(0.215 0 0);
--card-foreground: oklch(0.935 0 0);
--popover: oklch(0.215 0 0);
--popover-foreground: oklch(0.935 0 0);
--primary: oklch(0.696 0.17 var(--accent-hue));
--primary-foreground: oklch(0.145 0.03 var(--accent-hue));
--secondary: oklch(0.255 0 0);
--secondary-foreground: oklch(0.935 0 0);
--muted: oklch(0.255 0 0);
--muted-foreground: oklch(0.72 0 0);
--accent: oklch(0.255 0 0);
--accent-foreground: oklch(0.935 0 0);
--destructive: oklch(0.704 0.191 22.216);
--success: oklch(0.72 0.19 150);
--warning: oklch(0.75 0.17 75);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.696 0.17 var(--accent-hue));
--sidebar: oklch(0.195 0 0);
--sidebar-foreground: oklch(0.935 0 0);
--sidebar-primary: oklch(0.696 0.17 var(--accent-hue));
--sidebar-primary-foreground: oklch(0.145 0.03 var(--accent-hue));
--sidebar-accent: oklch(0.255 0 0);
--sidebar-accent-foreground: oklch(0.935 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.696 0.17 var(--accent-hue));
--window-border: oklch(0.32 0 0);
}
@theme inline {
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'Geist Mono Variable', 'Geist Mono', ui-monospace, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-warning: var(--warning);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@layer base {
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
/* hide native scrollbar on elements managed by OverlayScrollbars */
[data-overlayscrollbars] {
scrollbar-width: none;
}
[data-overlayscrollbars]::-webkit-scrollbar {
display: none;
}
textarea {
resize: vertical;
}
html, body {
overflow: hidden;
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
background: transparent;
}
body {
@apply text-foreground font-sans antialiased;
background: transparent;
}
html {
@apply font-sans;
}
#root {
height: 100vh;
width: 100vw;
padding: 20px;
box-sizing: border-box;
pointer-events: none;
}
html.maximized #root {
padding: 0;
}
html.maximized .window-frame {
border-radius: 0;
box-shadow: none;
border-color: transparent;
}
}
* {
margin: 0;
@layer components {
.window-frame {
position: relative;
border: 1px solid var(--window-border);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--background);
pointer-events: auto;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28), 0 0 2px rgba(0, 0, 0, 0.12);
}
.marquee-selection {
position: fixed;
border: 2.5px dashed var(--primary);
border-radius: 12px;
background: oklch(0.55 0.15 var(--accent-hue) / 12%);
pointer-events: none;
z-index: 50;
}
}
/* disable CSS transitions/animations when toggled off */
html.no-animations *,
html.no-animations *::before,
html.no-animations *::after {
transition-duration: 0s !important;
animation-duration: 0s !important;
}
/* compact mode - tighter spacing throughout */
html.compact {
--radius: 0.3rem;
}
html.compact .window-frame {
font-size: 11px;
}
html.compact [data-slot="switch"] {
scale: 0.8;
}
html.compact [data-slot="dialog-content"] {
gap: 0.25rem;
padding: 0.5rem;
}
html.compact [data-slot="dialog-header"] {
gap: 0.25rem;
}
/* tighter sidebar tree nodes */
html.compact .bg-sidebar {
font-size: 11px;
}
html.compact .bg-sidebar form {
padding: 0.25rem 0.375rem;
gap: 0.125rem;
}
/* tighter buttons and inputs */
html.compact button,
html.compact [data-slot="button"] {
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
/* pipeline strip */
html.compact [data-overlayscrollbars] {
padding: 0.5rem 0.75rem;
}
/* tighter general spacing */
html.compact .gap-3 { gap: 0.375rem; }
html.compact .gap-2 { gap: 0.25rem; }
html.compact .gap-1\.5 { gap: 0.125rem; }
html.compact .py-1\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; }
html.compact .py-2 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
html.compact .px-4 { padding-left: 0.5rem; padding-right: 0.5rem; }
html.compact .py-4 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
html.compact .py-3 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
html.compact .px-2 { padding-left: 0.25rem; padding-right: 0.25rem; }
/* === ACCESSIBILITY === */
/* font scaling */
:root {
--font-scale: 1;
}
html body {
font-size: calc(14px * var(--font-scale));
}
/* high contrast - boost muted colors to meet 7:1 AAA */
html.high-contrast {
--muted-foreground: oklch(0.35 0 0);
--border: oklch(0.78 0 0);
}
html.high-contrast.dark {
--muted-foreground: oklch(0.75 0 0);
--border: oklch(1 0 0 / 25%);
}
/* AAA contrast mode - same adjustments */
html.contrast-aaa {
--muted-foreground: oklch(0.35 0 0);
}
html.contrast-aaa.dark {
--muted-foreground: oklch(0.75 0 0);
}
/* larger touch targets - min 44x44px on all interactive elements */
html.larger-targets button,
html.larger-targets [data-slot="button"],
html.larger-targets [data-slot="switch"],
html.larger-targets [role="checkbox"],
html.larger-targets [role="option"],
html.larger-targets [role="menuitem"],
html.larger-targets [role="treeitem"] {
min-height: 44px;
min-width: 44px;
}
html.larger-targets [data-slot="switch"] {
scale: 1.2;
}
/* reduce transparency - replace all backdrop-filter and opacity overlays */
html.reduce-transparency .window-frame {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
html.reduce-transparency [class*="backdrop-blur"],
html.reduce-transparency [class*="bg-opacity"] {
backdrop-filter: none !important;
}
html.reduce-transparency .bg-primary\/\[0\.03\],
html.reduce-transparency .bg-primary\/\[0\.04\],
html.reduce-transparency .bg-primary\/\[0\.06\] {
background: var(--muted);
}
/* enhanced focus indicators - thicker, high-contrast rings */
html.enhanced-focus *:focus-visible {
outline: 3px solid var(--ring) !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 5px oklch(0.5 0.15 var(--accent-hue) / 25%) !important;
}
/* keyboard navigation mode - show focus only on keyboard, with visible outlines */
html.keyboard-nav *:focus-visible {
outline: 2px solid var(--ring) !important;
outline-offset: 2px !important;
}
html.keyboard-nav *:focus:not(:focus-visible) {
outline: none !important;
}
/* motion reduced - slower, simpler transitions */
html.motion-reduced *,
html.motion-reduced *::before,
html.motion-reduced *::after {
transition-duration: 0.01s !important;
animation-duration: 0.01s !important;
}
/* motion none - no transitions at all */
html.motion-none *,
html.motion-none *::before,
html.motion-none *::after {
transition-duration: 0s !important;
animation-duration: 0s !important;
animation-iteration-count: 1 !important;
}
/* color blind filters */
html[data-color-blind="deuteranopia"] .window-frame {
filter: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><filter id='d'><feColorMatrix type='matrix' values='0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0'/></filter></svg>#d");
}
html[data-color-blind="protanopia"] .window-frame {
filter: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><filter id='p'><feColorMatrix type='matrix' values='0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0'/></filter></svg>#p");
}
html[data-color-blind="tritanopia"] .window-frame {
filter: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><filter id='t'><feColorMatrix type='matrix' values='0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0'/></filter></svg>#t");
}
/* screen reader optimized - visually hidden elements for extra context */
html.sr-optimized .sr-extra {
position: absolute;
width: 1px;
height: 1px;
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);
margin: -1px;
overflow: hidden;
height: 100vh;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
#root {
height: 100vh;
display: flex;
flex-direction: column;
/* skip navigation link */
.skip-nav {
position: absolute;
top: -100px;
left: 0;
z-index: 9999;
padding: 8px 16px;
background: var(--primary);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 600;
text-decoration: none;
border-radius: 0 0 8px 0;
}
.skip-nav:focus {
top: 0;
}
input, select, button {
font-family: inherit;
font-size: inherit;
/* AAA target sizes - ensure 44x44px minimum hit area on all interactive elements */
/* uses ::after pseudo-element to expand clickable area without changing visual size */
button:not([data-no-target-expand]),
[data-slot="switch"],
[role="checkbox"],
[role="option"],
[role="treeitem"] > [role="button"],
[data-action="dec"],
[data-action="inc"] {
position: relative;
}
button:not([data-no-target-expand])::after,
[data-slot="switch"]::after,
[role="checkbox"]::after,
[data-action="dec"]::after,
[data-action="inc"]::after {
content: "";
position: absolute;
min-width: 44px;
min-height: 44px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
/* prevent scroll leaking from overlay menus to the app */
[data-slot="context-menu-content"],
[data-slot="dropdown-menu-content"],
[data-slot="popover-content"] {
overscroll-behavior: contain;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
/* OverlayScrollbars theme */
.os-scrollbar {
--os-size: 8px;
--os-padding-perpendicular: 1px;
--os-padding-axis: 2px;
--os-track-border-radius: 4px;
--os-handle-border-radius: 4px;
--os-handle-bg: var(--border);
--os-handle-bg-hover: var(--muted-foreground);
--os-handle-bg-active: var(--muted-foreground);
--os-track-bg: transparent;
--os-track-bg-hover: transparent;
--os-handle-min-size: 30px;
transition: opacity 0.15s ease;
}

6
ui/src/lib/portal.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createContext, useContext } from "react";
const PortalContainerContext = createContext<HTMLElement | null>(null);
export const PortalContainerProvider = PortalContainerContext.Provider;
export const usePortalContainer = () => useContext(PortalContainerContext);

6
ui/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,12 +1,14 @@
import { create } from "zustand";
import { invoke } from "@tauri-apps/api/core";
import type { FileEntry, FilterConfig, PreviewResult } from "../types/files";
import type { FileEntry, FilterConfig, PreviewResult } from "@/types/files";
import { useSettingsStore } from "./settingsStore";
interface FileState {
currentPath: string;
files: FileEntry[];
previewResults: PreviewResult[];
selectedFiles: Set<string>;
sortedFilePaths: string[];
loading: boolean;
error: string | null;
filters: FilterConfig;
@@ -18,6 +20,7 @@ interface FileState {
selectAll: () => void;
deselectAll: () => void;
setFilters: (filters: Partial<FilterConfig>) => void;
setSortedFilePaths: (paths: string[]) => void;
}
export const useFileStore = create<FileState>((set, get) => ({
@@ -25,6 +28,7 @@ export const useFileStore = create<FileState>((set, get) => ({
files: [],
previewResults: [],
selectedFiles: new Set(),
sortedFilePaths: [],
loading: false,
error: null,
filters: {
@@ -33,7 +37,7 @@ export const useFileStore = create<FileState>((set, get) => ({
min_size: null,
max_size: null,
include_files: true,
include_folders: false,
include_folders: true,
include_hidden: false,
subfolder_depth: 0,
},
@@ -43,17 +47,23 @@ export const useFileStore = create<FileState>((set, get) => ({
scanDirectory: async (path) => {
set({ loading: true, error: null });
try {
const showHidden = useSettingsStore.getState().showHiddenFiles;
const filters = { ...get().filters, include_hidden: showHidden };
const files = await invoke<FileEntry[]>("scan_directory", {
path,
filters: get().filters,
filters,
});
const allPaths = files.map((f) => f.path);
const autoSelect = useSettingsStore.getState().autoSelectAll;
set({
files,
currentPath: path,
loading: false,
selectedFiles: new Set(files.map((f) => f.path)),
selectedFiles: autoSelect ? new Set(allPaths) : new Set(),
sortedFilePaths: allPaths,
previewResults: [],
});
useSettingsStore.getState().setLastFolder(path);
} catch (e) {
set({ error: String(e), loading: false });
}
@@ -82,4 +92,6 @@ export const useFileStore = create<FileState>((set, get) => ({
setFilters: (filters) => {
set({ filters: { ...get().filters, ...filters } });
},
setSortedFilePaths: (paths) => set({ sortedFilePaths: paths }),
}));

View File

@@ -1,8 +1,9 @@
import { create } from "zustand";
import { invoke } from "@tauri-apps/api/core";
import type { PreviewResult } from "../types/files";
import type { RuleConfig } from "../types/rules";
import type { PreviewResult } from "@/types/files";
import type { RuleConfig } from "@/types/rules";
import { useFileStore } from "./fileStore";
import { useSettingsStore } from "./settingsStore";
import {
defaultReplace,
defaultRegex,
@@ -11,77 +12,187 @@ import {
defaultCase,
defaultNumbering,
defaultExtension,
} from "../types/rules";
defaultDate,
defaultMoveParts,
defaultTextEditor,
defaultHash,
defaultFolderName,
defaultTransliterate,
defaultPadding,
defaultTruncate,
defaultRandomize,
defaultSwap,
defaultSanitize,
} from "@/types/rules";
export interface PipelineRule {
id: string;
config: RuleConfig;
}
interface RuleState {
rules: Record<string, RuleConfig>;
activeTab: string;
pipeline: PipelineRule[];
previewDebounceTimer: ReturnType<typeof setTimeout> | null;
setActiveTab: (tab: string) => void;
updateRule: (type: string, updates: Partial<RuleConfig>) => void;
resetRule: (type: string) => void;
addRule: (type: string) => void;
removeRule: (id: string) => void;
updateRule: (id: string, updates: Partial<RuleConfig>) => void;
resetRule: (id: string) => void;
duplicateRule: (id: string) => void;
reorderPipeline: (fromIndex: number, toIndex: number) => void;
resetAllRules: () => void;
loadPipeline: (rules: RuleConfig[]) => 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(),
};
const PIPELINE_KEY = "nomina-last-pipeline";
let ruleCounter = 0;
function nextId(): string {
return `rule-${++ruleCounter}-${Date.now()}`;
}
function savePipeline(pipeline: PipelineRule[]) {
try {
localStorage.setItem(PIPELINE_KEY, JSON.stringify(pipeline.map((r) => r.config)));
} catch {}
}
function loadSavedPipeline(): RuleConfig[] | null {
try {
const raw = localStorage.getItem(PIPELINE_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return null;
}
function getInitialPipeline(): PipelineRule[] {
if (useSettingsStore.getState().restoreLastPipeline) {
const saved = loadSavedPipeline();
if (saved && saved.length > 0) {
return saved.map((config) => ({ id: nextId(), config }));
}
}
return [];
}
function createDefault(type: string): RuleConfig {
const caseSensitive = useSettingsStore.getState().caseSensitiveMatch;
switch (type) {
case "replace": return { ...defaultReplace(), match_case: caseSensitive } as RuleConfig;
case "regex": return { ...defaultRegex(), case_sensitive: caseSensitive } as RuleConfig;
case "remove": return defaultRemove();
case "add": return defaultAdd();
case "case": return defaultCase();
case "numbering": return defaultNumbering();
case "extension": return defaultExtension();
case "date": return defaultDate();
case "move_parts": return defaultMoveParts();
case "text_editor": return defaultTextEditor();
case "hash": return defaultHash();
case "folder_name": return defaultFolderName();
case "transliterate": return defaultTransliterate();
case "padding": return defaultPadding();
case "truncate": return defaultTruncate();
case "randomize": return defaultRandomize();
case "swap": return defaultSwap();
case "sanitize": return defaultSanitize();
default: return defaultReplace();
}
}
export const useRuleStore = create<RuleState>((set, get) => ({
rules: getDefaults(),
activeTab: "replace",
pipeline: getInitialPipeline(),
previewDebounceTimer: null,
setActiveTab: (tab) => set({ activeTab: tab }),
updateRule: (type, updates) => {
const rules = { ...get().rules };
rules[type] = { ...rules[type], ...updates };
set({ rules });
addRule: (type) => {
const rule: PipelineRule = { id: nextId(), config: createDefault(type) };
const pipeline = [...get().pipeline, rule];
set({ pipeline });
savePipeline(pipeline);
},
resetRule: (type) => {
const defaults = getDefaults();
const rules = { ...get().rules };
rules[type] = defaults[type];
set({ rules });
removeRule: (id) => {
const pipeline = get().pipeline.filter((r) => r.id !== id);
set({ pipeline });
savePipeline(pipeline);
},
updateRule: (id, updates) => {
const pipeline = get().pipeline.map((r) =>
r.id === id ? { ...r, config: { ...r.config, ...updates } } : r
);
set({ pipeline });
savePipeline(pipeline);
},
resetRule: (id) => {
const pipeline = get().pipeline.map((r) =>
r.id === id ? { ...r, config: createDefault(r.config.type) } : r
);
set({ pipeline });
savePipeline(pipeline);
},
duplicateRule: (id) => {
const current = get().pipeline;
const idx = current.findIndex((r) => r.id === id);
if (idx === -1) return;
const clone: PipelineRule = { id: nextId(), config: { ...current[idx].config } };
const pipeline = [...current.slice(0, idx + 1), clone, ...current.slice(idx + 1)];
set({ pipeline });
savePipeline(pipeline);
},
reorderPipeline: (fromIndex, toIndex) => {
const pipeline = [...get().pipeline];
const [moved] = pipeline.splice(fromIndex, 1);
pipeline.splice(toIndex, 0, moved);
set({ pipeline });
savePipeline(pipeline);
},
resetAllRules: () => {
set({ rules: getDefaults() });
const pipeline: PipelineRule[] = [];
set({ pipeline });
savePipeline(pipeline);
},
loadPipeline: (rules) => {
const pipeline = rules.map((config) => ({ id: nextId(), config }));
set({ pipeline });
savePipeline(pipeline);
},
requestPreview: (directory) => {
const timer = get().previewDebounceTimer;
if (timer) clearTimeout(timer);
const delay = useSettingsStore.getState().autoPreviewDelay;
const newTimer = setTimeout(async () => {
const { rules } = get();
const activeRules = Object.values(rules).filter((r) => r.enabled);
const { pipeline } = get();
const activeRules = pipeline
.filter((r) => r.config.enabled)
.map((r) => r.config);
if (activeRules.length === 0 || !directory) return;
if (activeRules.length === 0 || !directory) {
useFileStore.getState().setPreviewResults([]);
return;
}
try {
const { filters } = useFileStore.getState();
const results = await invoke<PreviewResult[]>("preview_rename", {
rules: activeRules,
directory,
filters,
selectedPaths: null,
});
useFileStore.getState().setPreviewResults(results);
} catch (e) {
console.error("Preview failed:", e);
}
}, 150);
}, delay);
set({ previewDebounceTimer: newTimer });
},

View File

@@ -1,13 +1,252 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
type Theme = "light" | "dark" | "system";
type SortOrder = "name" | "date" | "size" | "type";
type DoubleClickAction = "open" | "nothing";
type DateFormatMode = "relative" | "absolute";
type ConflictStrategy = "suffix" | "skip";
type BackupLocation = "default" | "custom";
type DisabledRuleDisplay = "dimmed" | "hidden";
type FocusIndicatorMode = "default" | "enhanced";
type MotionSensitivity = "normal" | "reduced" | "none";
type ColorBlindMode = "none" | "deuteranopia" | "protanopia" | "tritanopia";
type ContrastRatio = "aa" | "aaa";
interface SettingsState {
// appearance
theme: Theme;
setTheme: (theme: Theme) => void;
zoom: number;
accentHue: number;
compactMode: boolean;
zebraStriping: boolean;
// file list
doubleClickAction: DoubleClickAction;
showFullPath: boolean;
dateFormat: DateFormatMode;
// behavior
animationsEnabled: boolean;
alwaysOnTop: boolean;
showHiddenFiles: boolean;
autoSelectAll: boolean;
confirmBeforeRename: boolean;
defaultSortOrder: SortOrder;
// rename
skipReadOnly: boolean;
conflictStrategy: ConflictStrategy;
caseSensitiveMatch: boolean;
// pipeline
autoPreviewDelay: number;
showDisabledRules: DisabledRuleDisplay;
// safety
createBackups: boolean;
backupLocation: BackupLocation;
customBackupPath: string;
autoCleanupBackupDays: number;
undoHistoryLimit: number;
// notifications
showToastOnComplete: boolean;
playSoundOnComplete: boolean;
flashTaskbarOnComplete: boolean;
// startup
openLastFolder: boolean;
restoreLastPipeline: boolean;
checkForUpdates: boolean;
lastFolder: string;
// accessibility (store only for now)
highContrast: boolean;
largerTouchTargets: boolean;
reduceTransparency: boolean;
focusIndicators: FocusIndicatorMode;
screenReaderOptimized: boolean;
motionSensitivity: MotionSensitivity;
fontScaling: number;
colorBlindMode: ColorBlindMode;
keyboardNavigationMode: boolean;
minimumContrastRatio: ContrastRatio;
// advanced
locale: string;
enableDebugLogging: boolean;
// setters
setTheme: (v: Theme) => void;
setZoom: (v: number) => void;
setAccentHue: (v: number) => void;
setCompactMode: (v: boolean) => void;
setZebraStriping: (v: boolean) => void;
setDoubleClickAction: (v: DoubleClickAction) => void;
setShowFullPath: (v: boolean) => void;
setDateFormat: (v: DateFormatMode) => void;
setAnimationsEnabled: (v: boolean) => void;
setAlwaysOnTop: (v: boolean) => void;
setShowHiddenFiles: (v: boolean) => void;
setAutoSelectAll: (v: boolean) => void;
setConfirmBeforeRename: (v: boolean) => void;
setDefaultSortOrder: (v: SortOrder) => void;
setSkipReadOnly: (v: boolean) => void;
setConflictStrategy: (v: ConflictStrategy) => void;
setCaseSensitiveMatch: (v: boolean) => void;
setAutoPreviewDelay: (v: number) => void;
setShowDisabledRules: (v: DisabledRuleDisplay) => void;
setCreateBackups: (v: boolean) => void;
setBackupLocation: (v: BackupLocation) => void;
setCustomBackupPath: (v: string) => void;
setAutoCleanupBackupDays: (v: number) => void;
setUndoHistoryLimit: (v: number) => void;
setShowToastOnComplete: (v: boolean) => void;
setPlaySoundOnComplete: (v: boolean) => void;
setFlashTaskbarOnComplete: (v: boolean) => void;
setOpenLastFolder: (v: boolean) => void;
setRestoreLastPipeline: (v: boolean) => void;
setCheckForUpdates: (v: boolean) => void;
setLastFolder: (v: string) => void;
setHighContrast: (v: boolean) => void;
setLargerTouchTargets: (v: boolean) => void;
setReduceTransparency: (v: boolean) => void;
setFocusIndicators: (v: FocusIndicatorMode) => void;
setScreenReaderOptimized: (v: boolean) => void;
setMotionSensitivity: (v: MotionSensitivity) => void;
setFontScaling: (v: number) => void;
setColorBlindMode: (v: ColorBlindMode) => void;
setKeyboardNavigationMode: (v: boolean) => void;
setMinimumContrastRatio: (v: ContrastRatio) => void;
setLocale: (v: string) => void;
setEnableDebugLogging: (v: boolean) => void;
resetAll: () => void;
}
export const useSettingsStore = create<SettingsState>((set) => ({
theme: "system",
setTheme: (theme) => set({ theme }),
}));
function detectOsAccessibilityPrefs() {
if (typeof window === "undefined") return {};
const prefs: Record<string, unknown> = {};
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
prefs.motionSensitivity = "reduced" as MotionSensitivity;
prefs.animationsEnabled = false;
}
if (window.matchMedia("(prefers-contrast: more)").matches) {
prefs.highContrast = true;
prefs.minimumContrastRatio = "aaa" as ContrastRatio;
}
if (window.matchMedia("(prefers-reduced-transparency: reduce)").matches) {
prefs.reduceTransparency = true;
}
if (window.matchMedia("(forced-colors: active)").matches) {
prefs.highContrast = true;
}
return prefs;
}
const osPrefs = detectOsAccessibilityPrefs();
const defaults = {
theme: "system" as Theme,
zoom: 1.0,
accentHue: 160,
compactMode: false,
zebraStriping: false,
doubleClickAction: "nothing" as DoubleClickAction,
showFullPath: false,
dateFormat: "absolute" as DateFormatMode,
animationsEnabled: true,
alwaysOnTop: false,
showHiddenFiles: false,
autoSelectAll: true,
confirmBeforeRename: false,
defaultSortOrder: "name" as SortOrder,
skipReadOnly: true,
conflictStrategy: "suffix" as ConflictStrategy,
caseSensitiveMatch: false,
autoPreviewDelay: 150,
showDisabledRules: "dimmed" as DisabledRuleDisplay,
createBackups: false,
backupLocation: "default" as BackupLocation,
customBackupPath: "",
autoCleanupBackupDays: 0,
undoHistoryLimit: 10,
showToastOnComplete: true,
playSoundOnComplete: false,
flashTaskbarOnComplete: true,
openLastFolder: true,
restoreLastPipeline: false,
checkForUpdates: false,
lastFolder: "",
highContrast: false,
largerTouchTargets: false,
reduceTransparency: false,
focusIndicators: "default" as FocusIndicatorMode,
screenReaderOptimized: false,
motionSensitivity: "normal" as MotionSensitivity,
fontScaling: 1.0,
colorBlindMode: "none" as ColorBlindMode,
keyboardNavigationMode: false,
minimumContrastRatio: "aa" as ContrastRatio,
locale: "system",
enableDebugLogging: false,
};
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
...defaults,
...osPrefs,
setTheme: (v) => set({ theme: v }),
setZoom: (v) => set({ zoom: v }),
setAccentHue: (v) => set({ accentHue: v }),
setCompactMode: (v) => set({ compactMode: v }),
setZebraStriping: (v) => set({ zebraStriping: v }),
setDoubleClickAction: (v) => set({ doubleClickAction: v }),
setShowFullPath: (v) => set({ showFullPath: v }),
setDateFormat: (v) => set({ dateFormat: v }),
setAnimationsEnabled: (v) => set({ animationsEnabled: v }),
setAlwaysOnTop: (v) => set({ alwaysOnTop: v }),
setShowHiddenFiles: (v) => set({ showHiddenFiles: v }),
setAutoSelectAll: (v) => set({ autoSelectAll: v }),
setConfirmBeforeRename: (v) => set({ confirmBeforeRename: v }),
setDefaultSortOrder: (v) => set({ defaultSortOrder: v }),
setSkipReadOnly: (v) => set({ skipReadOnly: v }),
setConflictStrategy: (v) => set({ conflictStrategy: v }),
setCaseSensitiveMatch: (v) => set({ caseSensitiveMatch: v }),
setAutoPreviewDelay: (v) => set({ autoPreviewDelay: v }),
setShowDisabledRules: (v) => set({ showDisabledRules: v }),
setCreateBackups: (v) => set({ createBackups: v }),
setBackupLocation: (v) => set({ backupLocation: v }),
setCustomBackupPath: (v) => set({ customBackupPath: v }),
setAutoCleanupBackupDays: (v) => set({ autoCleanupBackupDays: v }),
setUndoHistoryLimit: (v) => set({ undoHistoryLimit: v }),
setShowToastOnComplete: (v) => set({ showToastOnComplete: v }),
setPlaySoundOnComplete: (v) => set({ playSoundOnComplete: v }),
setFlashTaskbarOnComplete: (v) => set({ flashTaskbarOnComplete: v }),
setOpenLastFolder: (v) => set({ openLastFolder: v }),
setRestoreLastPipeline: (v) => set({ restoreLastPipeline: v }),
setCheckForUpdates: (v) => set({ checkForUpdates: v }),
setLastFolder: (v) => set({ lastFolder: v }),
setHighContrast: (v) => set({ highContrast: v }),
setLargerTouchTargets: (v) => set({ largerTouchTargets: v }),
setReduceTransparency: (v) => set({ reduceTransparency: v }),
setFocusIndicators: (v) => set({ focusIndicators: v }),
setScreenReaderOptimized: (v) => set({ screenReaderOptimized: v }),
setMotionSensitivity: (v) => set({ motionSensitivity: v }),
setFontScaling: (v) => set({ fontScaling: v }),
setColorBlindMode: (v) => set({ colorBlindMode: v }),
setKeyboardNavigationMode: (v) => set({ keyboardNavigationMode: v }),
setMinimumContrastRatio: (v) => set({ minimumContrastRatio: v }),
setLocale: (v) => set({ locale: v }),
setEnableDebugLogging: (v) => set({ enableDebugLogging: v }),
resetAll: () => set({ ...defaults, lastFolder: "" }),
}),
{
name: "nomina-settings",
}
)
);

View File

@@ -13,6 +13,10 @@ export interface ReplaceConfig extends RuleConfig {
replace_with: string;
match_case: boolean;
first_only: boolean;
use_regex: boolean;
scope_start: number | null;
scope_end: number | null;
occurrence: number | null;
}
export interface RegexConfig extends RuleConfig {
@@ -20,16 +24,30 @@ export interface RegexConfig extends RuleConfig {
pattern: string;
replace_with: string;
case_insensitive: boolean;
match_limit: number | null;
}
export interface TrimOptions {
digits: boolean;
spaces: boolean;
symbols: boolean;
accents: boolean;
lead_dots: boolean;
}
export interface RemoveConfig extends RuleConfig {
type: "remove";
mode: "Chars" | "Words";
first_n: number;
last_n: number;
from: number;
to: number;
crop_before: string | null;
crop_after: string | null;
trim: TrimOptions;
collapse_chars: string | null;
remove_pattern: string | null;
allow_empty: boolean;
}
export interface AddConfig extends RuleConfig {
@@ -39,11 +57,13 @@ export interface AddConfig extends RuleConfig {
insert: string;
insert_at: number;
word_space: boolean;
inserts: Array<{ position: number; text: string }>;
}
export interface CaseConfig extends RuleConfig {
type: "case";
mode: "Same" | "Upper" | "Lower" | "Title" | "Sentence" | "Invert" | "Random";
mode: "Same" | "Upper" | "Lower" | "Title" | "Sentence" | "Invert" | "Random"
| "CamelCase" | "PascalCase" | "SnakeCase" | "KebabCase" | "DotCase" | "SmartTitle";
exceptions: string;
}
@@ -55,15 +75,109 @@ export interface NumberingConfig extends RuleConfig {
padding: number;
separator: string;
break_at: number;
base: "Decimal" | "Hex" | "Octal" | "Binary" | "Alpha";
base: "Decimal" | "Hex" | "Octal" | "Binary" | "Alpha" | "Roman";
per_folder: boolean;
insert_at: number;
reverse: boolean;
custom_format: string | null;
}
export interface ExtensionConfig extends RuleConfig {
type: "extension";
mode: "Same" | "Lower" | "Upper" | "Title" | "Extra" | "Remove" | "Fixed";
fixed_value: string;
mapping: Array<[string, string]> | null;
multi_extension: boolean;
}
export interface DateConfig extends RuleConfig {
type: "date";
mode: "None" | "Prefix" | "Suffix" | "Insert";
source: "Created" | "Modified" | "Accessed" | "ExifTaken" | "ExifDigitized" | "Current";
format: string;
separator: string;
segment_separator: string;
include_time: boolean;
custom_format: string | null;
}
export interface MovePartsConfig extends RuleConfig {
type: "move_parts";
source_from: number;
source_length: number;
target: "None" | "Start" | "End" | { Position: number };
separator: string;
copy_mode: boolean;
selection_mode: "Chars" | "Words" | "Regex";
regex_pattern: string | null;
regex_group: number;
swap_with_from: number | null;
swap_with_length: number | null;
}
export interface TextEditorConfig extends RuleConfig {
type: "text_editor";
names: string[];
}
export interface HashConfig extends RuleConfig {
type: "hash";
mode: "None" | "Prefix" | "Suffix" | "Replace";
algorithm: "MD5" | "SHA1" | "SHA256";
length: number;
separator: string;
uppercase: boolean;
}
export interface FolderNameConfig extends RuleConfig {
type: "folder_name";
mode: "None" | "Prefix" | "Suffix" | "Replace";
level: number;
separator: string;
}
export interface TransliterateConfig extends RuleConfig {
type: "transliterate";
}
export interface PaddingConfig extends RuleConfig {
type: "padding";
width: number;
pad_char: string;
pad_all: boolean;
}
export interface TruncateConfig extends RuleConfig {
type: "truncate";
max_length: number;
from: "Start" | "End";
suffix: string;
}
export interface RandomizeConfig extends RuleConfig {
type: "randomize";
mode: "Replace" | "Prefix" | "Suffix";
format: "Hex" | "Alpha" | "AlphaNum" | "UUID";
length: number;
separator: string;
}
export interface SwapConfig extends RuleConfig {
type: "swap";
delimiter: string;
occurrence: "First" | "Last";
new_delimiter: string | null;
}
export interface SanitizeConfig extends RuleConfig {
type: "sanitize";
illegal_chars: boolean;
spaces_to: "None" | "Underscores" | "Dashes" | "Dots";
normalize_unicode: boolean;
strip_zero_width: boolean;
strip_diacritics: boolean;
collapse_whitespace: boolean;
trim_dots_spaces: boolean;
}
export function defaultReplace(): ReplaceConfig {
@@ -75,6 +189,10 @@ export function defaultReplace(): ReplaceConfig {
replace_with: "",
match_case: true,
first_only: false,
use_regex: false,
scope_start: null,
scope_end: null,
occurrence: null,
};
}
@@ -86,6 +204,7 @@ export function defaultRegex(): RegexConfig {
pattern: "",
replace_with: "",
case_insensitive: false,
match_limit: null,
};
}
@@ -94,12 +213,17 @@ export function defaultRemove(): RemoveConfig {
type: "remove",
step_mode: "Simultaneous",
enabled: true,
mode: "Chars",
first_n: 0,
last_n: 0,
from: 0,
to: 0,
crop_before: null,
crop_after: null,
trim: { digits: false, spaces: false, symbols: false, accents: false, lead_dots: false },
collapse_chars: null,
remove_pattern: null,
allow_empty: false,
};
}
@@ -113,6 +237,7 @@ export function defaultAdd(): AddConfig {
insert: "",
insert_at: 0,
word_space: false,
inserts: [],
};
}
@@ -140,6 +265,8 @@ export function defaultNumbering(): NumberingConfig {
base: "Decimal",
per_folder: false,
insert_at: 0,
reverse: false,
custom_format: null,
};
}
@@ -150,5 +277,141 @@ export function defaultExtension(): ExtensionConfig {
enabled: true,
mode: "Same",
fixed_value: "",
mapping: null,
multi_extension: false,
};
}
export function defaultDate(): DateConfig {
return {
type: "date",
step_mode: "Simultaneous",
enabled: true,
mode: "None",
source: "Modified",
format: "YMD",
separator: "-",
segment_separator: "_",
include_time: false,
custom_format: null,
};
}
export function defaultMoveParts(): MovePartsConfig {
return {
type: "move_parts",
step_mode: "Simultaneous",
enabled: true,
source_from: 0,
source_length: 0,
target: "None",
separator: "",
copy_mode: false,
selection_mode: "Chars",
regex_pattern: null,
regex_group: 0,
swap_with_from: null,
swap_with_length: null,
};
}
export function defaultTextEditor(): TextEditorConfig {
return {
type: "text_editor",
step_mode: "Simultaneous",
enabled: true,
names: [],
};
}
export function defaultHash(): HashConfig {
return {
type: "hash",
step_mode: "Simultaneous",
enabled: true,
mode: "None",
algorithm: "SHA256",
length: 8,
separator: "_",
uppercase: false,
};
}
export function defaultFolderName(): FolderNameConfig {
return {
type: "folder_name",
step_mode: "Simultaneous",
enabled: true,
mode: "None",
level: 1,
separator: "_",
};
}
export function defaultTransliterate(): TransliterateConfig {
return {
type: "transliterate",
step_mode: "Simultaneous",
enabled: true,
};
}
export function defaultPadding(): PaddingConfig {
return {
type: "padding",
step_mode: "Simultaneous",
enabled: true,
width: 3,
pad_char: "0",
pad_all: true,
};
}
export function defaultTruncate(): TruncateConfig {
return {
type: "truncate",
step_mode: "Simultaneous",
enabled: true,
max_length: 50,
from: "End",
suffix: "",
};
}
export function defaultRandomize(): RandomizeConfig {
return {
type: "randomize",
step_mode: "Simultaneous",
enabled: true,
mode: "Replace",
format: "Hex",
length: 8,
separator: "_",
};
}
export function defaultSwap(): SwapConfig {
return {
type: "swap",
step_mode: "Simultaneous",
enabled: true,
delimiter: ", ",
occurrence: "First",
new_delimiter: null,
};
}
export function defaultSanitize(): SanitizeConfig {
return {
type: "sanitize",
step_mode: "Simultaneous",
enabled: true,
illegal_chars: true,
spaces_to: "None",
normalize_unicode: false,
strip_zero_width: false,
strip_diacritics: false,
collapse_whitespace: false,
trim_dots_spaces: true,
};
}

View File

@@ -15,7 +15,11 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -1,12 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
clearScreen: false,
server: {
port: 5173,