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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
95
ui/src/components/layout/ResizeEdges.tsx
Normal file
95
ui/src/components/layout/ResizeEdges.tsx
Normal 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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
694
ui/src/components/pipeline/PipelineStrip.tsx
Normal file
694
ui/src/components/pipeline/PipelineStrip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
ui/src/components/pipeline/configs/AddConfig.tsx
Normal file
95
ui/src/components/pipeline/configs/AddConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
ui/src/components/pipeline/configs/CaseConfig.tsx
Normal file
65
ui/src/components/pipeline/configs/CaseConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
ui/src/components/pipeline/configs/DateConfig.tsx
Normal file
86
ui/src/components/pipeline/configs/DateConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
ui/src/components/pipeline/configs/ExtensionConfig.tsx
Normal file
98
ui/src/components/pipeline/configs/ExtensionConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
ui/src/components/pipeline/configs/FolderNameConfig.tsx
Normal file
38
ui/src/components/pipeline/configs/FolderNameConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
ui/src/components/pipeline/configs/HashConfig.tsx
Normal file
53
ui/src/components/pipeline/configs/HashConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
ui/src/components/pipeline/configs/MovePartsConfig.tsx
Normal file
83
ui/src/components/pipeline/configs/MovePartsConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
ui/src/components/pipeline/configs/NumberingConfig.tsx
Normal file
80
ui/src/components/pipeline/configs/NumberingConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
ui/src/components/pipeline/configs/PaddingConfig.tsx
Normal file
36
ui/src/components/pipeline/configs/PaddingConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
ui/src/components/pipeline/configs/RandomizeConfig.tsx
Normal file
52
ui/src/components/pipeline/configs/RandomizeConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
ui/src/components/pipeline/configs/RegexConfig.tsx
Normal file
47
ui/src/components/pipeline/configs/RegexConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
ui/src/components/pipeline/configs/RemoveConfig.tsx
Normal file
113
ui/src/components/pipeline/configs/RemoveConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
ui/src/components/pipeline/configs/ReplaceConfig.tsx
Normal file
65
ui/src/components/pipeline/configs/ReplaceConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
ui/src/components/pipeline/configs/SanitizeConfig.tsx
Normal file
53
ui/src/components/pipeline/configs/SanitizeConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
ui/src/components/pipeline/configs/SwapConfig.tsx
Normal file
45
ui/src/components/pipeline/configs/SwapConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
ui/src/components/pipeline/configs/TextEditorConfig.tsx
Normal file
85
ui/src/components/pipeline/configs/TextEditorConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
ui/src/components/pipeline/configs/TransliterateConfig.tsx
Normal file
10
ui/src/components/pipeline/configs/TransliterateConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
ui/src/components/pipeline/configs/TruncateConfig.tsx
Normal file
41
ui/src/components/pipeline/configs/TruncateConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
306
ui/src/components/presets/PresetsDialog.tsx
Normal file
306
ui/src/components/presets/PresetsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
1132
ui/src/components/settings/SettingsDialog.tsx
Normal file
1132
ui/src/components/settings/SettingsDialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
49
ui/src/components/ui/badge.tsx
Normal file
49
ui/src/components/ui/badge.tsx
Normal 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 }
|
||||
67
ui/src/components/ui/button.tsx
Normal file
67
ui/src/components/ui/button.tsx
Normal 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 }
|
||||
30
ui/src/components/ui/checkbox.tsx
Normal file
30
ui/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
266
ui/src/components/ui/context-menu.tsx
Normal file
266
ui/src/components/ui/context-menu.tsx
Normal 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,
|
||||
}
|
||||
166
ui/src/components/ui/dialog.tsx
Normal file
166
ui/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
272
ui/src/components/ui/dropdown-menu.tsx
Normal file
272
ui/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
19
ui/src/components/ui/input.tsx
Normal file
19
ui/src/components/ui/input.tsx
Normal 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 }
|
||||
196
ui/src/components/ui/number-input.tsx
Normal file
196
ui/src/components/ui/number-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
ui/src/components/ui/popover.tsx
Normal file
106
ui/src/components/ui/popover.tsx
Normal 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,
|
||||
}
|
||||
55
ui/src/components/ui/scroll-area.tsx
Normal file
55
ui/src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
71
ui/src/components/ui/segmented-control.tsx
Normal file
71
ui/src/components/ui/segmented-control.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
ui/src/components/ui/separator.tsx
Normal file
28
ui/src/components/ui/separator.tsx
Normal 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 }
|
||||
20
ui/src/components/ui/sonner.tsx
Normal file
20
ui/src/components/ui/sonner.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
31
ui/src/components/ui/switch.tsx
Normal file
31
ui/src/components/ui/switch.tsx
Normal 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 }
|
||||
57
ui/src/components/ui/tooltip.tsx
Normal file
57
ui/src/components/ui/tooltip.tsx
Normal 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 }
|
||||
19
ui/src/hooks/useAnnounce.ts
Normal file
19
ui/src/hooks/useAnnounce.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
109
ui/src/hooks/useTheme.ts
Normal 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]);
|
||||
}
|
||||
123
ui/src/hooks/useWindowState.ts
Normal file
123
ui/src/hooks/useWindowState.ts
Normal 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());
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
471
ui/src/index.css
471
ui/src/index.css
@@ -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
6
ui/src/lib/portal.ts
Normal 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
6
ui/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user