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}
-
-
+ + {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; }