diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx
index d943348..88cc29a 100644
--- a/src/components/ConfirmDialog.tsx
+++ b/src/components/ConfirmDialog.tsx
@@ -1,4 +1,5 @@
import Button from "./Button";
+import Dialog from "./Dialog";
export type ConfirmDialogTone = "default" | "danger";
@@ -25,20 +26,28 @@ export default function ConfirmDialog({
onConfirm,
onCancel
}: ConfirmDialogProps) {
- if (!open) return null;
-
return (
-
{
- if (event.target === event.currentTarget && !busy) onCancel();
- }}>
-
- {title}
- {message}
-
+
-
-
+ >
+ )}
+ >
+ {message}
+
);
}
diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx
new file mode 100644
index 0000000..cc16866
--- /dev/null
+++ b/src/components/Dialog.tsx
@@ -0,0 +1,96 @@
+import { useEffect, useId, type ReactNode } from "react";
+
+export type DialogProps = {
+ open: boolean;
+ title: ReactNode;
+ children: ReactNode;
+ footer?: ReactNode;
+ role?: "dialog" | "alertdialog";
+ ariaDescribedBy?: string;
+ closeLabel?: string;
+ closeOnBackdrop?: boolean;
+ showCloseButton?: boolean;
+ closeDisabled?: boolean;
+ onClose?: () => void;
+ className?: string;
+ backdropClassName?: string;
+ headerClassName?: string;
+ titleClassName?: string;
+ bodyClassName?: string;
+ footerClassName?: string;
+};
+
+function joinClasses(...classes: Array) {
+ return classes.filter(Boolean).join(" ");
+}
+
+export default function Dialog({
+ open,
+ title,
+ children,
+ footer,
+ role = "dialog",
+ ariaDescribedBy,
+ closeLabel = "Close",
+ closeOnBackdrop = true,
+ showCloseButton = true,
+ closeDisabled = false,
+ onClose,
+ className = "",
+ backdropClassName = "",
+ headerClassName = "",
+ titleClassName = "",
+ bodyClassName = "",
+ footerClassName = ""
+}: DialogProps) {
+ const titleId = useId();
+ const canClose = Boolean(onClose) && !closeDisabled;
+
+ useEffect(() => {
+ if (!open || !canClose) return undefined;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose?.();
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [canClose, onClose, open]);
+
+ if (!open) return null;
+
+ return (
+ {
+ if (closeOnBackdrop && canClose && event.target === event.currentTarget) onClose?.();
+ }}
+ >
+
+
+
{title}
+ {showCloseButton && onClose && (
+
+ )}
+
+ {children}
+ {footer && {footer}
}
+
+
+ );
+}
diff --git a/src/features/files/FilesPage.tsx b/src/features/files/FilesPage.tsx
index 54c494b..85ec822 100644
--- a/src/features/files/FilesPage.tsx
+++ b/src/features/files/FilesPage.tsx
@@ -4,6 +4,8 @@ import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import ConfirmDialog from "../../components/ConfirmDialog";
import DismissibleAlert from "../../components/DismissibleAlert";
+import FormField from "../../components/FormField";
+import FieldLabel from "../../components/help/FieldLabel";
import ToggleSwitch from "../../components/ToggleSwitch";
import LoadingIndicator from "../../components/LoadingIndicator";
import {
@@ -1326,12 +1328,11 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) {
{dialog === "create-folder" && (
-
+
@@ -1343,14 +1344,13 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) {
Choose a destination space and folder. Folders are transferred recursively.
-
+
-
Destination folder
+
Destination folder
-
+
@@ -1385,17 +1383,16 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) {
Bulk rename changes managed display paths only. Immutable blobs stay stable for audit.
-
- {renameMode === "prefix" && }
- {renameMode === "suffix" && }
- {renameMode === "replace" && <>>}
+
+ {renameMode === "prefix" && setRenamePrefix(event.target.value)} />}
+ {renameMode === "suffix" && setRenameSuffix(event.target.value)} />}
+ {renameMode === "replace" && <> setRenameFind(event.target.value)} /> setRenameReplacement(event.target.value)} />>}
{selectedFolderPaths.size > 0 && (
diff --git a/src/features/files/components/FileManagerComponents.tsx b/src/features/files/components/FileManagerComponents.tsx
index 2eee316..17c28ff 100644
--- a/src/features/files/components/FileManagerComponents.tsx
+++ b/src/features/files/components/FileManagerComponents.tsx
@@ -1,6 +1,7 @@
import type { DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { Copy, Download, Folder, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
import Button from "../../../components/Button";
+import Dialog from "../../../components/Dialog";
import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files";
import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types";
import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
@@ -227,15 +228,18 @@ export function RenamePreviewList({
export function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
return (
- { if (event.target === event.currentTarget) onClose(); }}>
-
-
-
{title}
-
-
-
{children}
-
-
+
);
}
diff --git a/src/styles/campaign-workspace.css b/src/styles/campaign-workspace.css
index d6b99ed..c86b276 100644
--- a/src/styles/campaign-workspace.css
+++ b/src/styles/campaign-workspace.css
@@ -1701,7 +1701,8 @@
background: var(--panel-soft);
}
-.file-dialog-header h3 {
+.file-dialog-header h3,
+.file-dialog-header .file-dialog-title {
margin: 0;
color: var(--text-strong);
font-size: 18px;
diff --git a/src/styles/components.css b/src/styles/components.css
index 059b822..bfc1860 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -512,6 +512,80 @@
font-weight: 600;
}
+
+/* Reusable dialog -------------------------------------------------------- */
+.dialog-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 12000;
+ display: grid;
+ place-items: center;
+ padding: 1.5rem;
+ background: rgba(15, 23, 42, 0.36);
+ backdrop-filter: blur(2px);
+}
+
+.dialog-panel {
+ width: min(560px, 100%);
+ max-height: min(760px, calc(100vh - 3rem));
+ overflow: auto;
+ border: 1px solid var(--line, rgba(15, 23, 42, 0.14));
+ border-radius: var(--radius-lg, 18px);
+ background: var(--surface, #fff);
+ color: var(--text, #172033);
+ box-shadow: var(--shadow-strong, 0 24px 64px rgba(15, 23, 42, 0.25));
+}
+
+.dialog-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.dialog-title {
+ margin: 0;
+ color: var(--text-strong, #111827);
+ font-size: 1.05rem;
+}
+
+.dialog-close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: var(--muted, #5f6b7a);
+ cursor: pointer;
+ font-size: 1.5rem;
+ line-height: 1;
+}
+
+.dialog-close:hover,
+.dialog-close:focus-visible {
+ background: rgba(15, 23, 42, 0.07);
+ color: var(--text-strong, #111827);
+ outline: none;
+}
+
+.dialog-close:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.dialog-body {
+ color: var(--text, #172033);
+}
+
+.dialog-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.6rem;
+}
+
/* Reusable confirm dialog ------------------------------------------------ */
.confirm-backdrop {
position: fixed;
@@ -534,8 +608,13 @@
box-shadow: var(--shadow-strong, 0 24px 64px rgba(15, 23, 42, 0.25));
}
+.confirm-dialog-header {
+ margin-bottom: 0.55rem;
+}
+
+.confirm-dialog-header .dialog-title,
.confirm-dialog h2 {
- margin: 0 0 0.55rem;
+ margin: 0;
color: var(--text-strong, #111827);
font-size: 1.05rem;
}