diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 95cd90cb..7fd92407 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -73,6 +73,7 @@ import { RequireFeature } from './components/admin/RequireFeature'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { GlobalThemeProvider } from './components/GlobalThemeProvider'; +import { ConfirmDialogProvider } from './components/common'; import { usePublicSettings } from './hooks/usePublicSettings'; // Create a client @@ -149,6 +150,7 @@ function App() { + @@ -406,6 +408,7 @@ function App() { pauseOnHover theme={toastTheme} /> + diff --git a/frontend/src/components/common/ConfirmDialog.tsx b/frontend/src/components/common/ConfirmDialog.tsx new file mode 100644 index 00000000..4530d8bd --- /dev/null +++ b/frontend/src/components/common/ConfirmDialog.tsx @@ -0,0 +1,182 @@ +import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertCircle, AlertTriangle, X } from 'lucide-react'; +import { Button } from './Button'; +import { Card } from './Card'; + +/** + * Promise-based confirm dialog (#640 part C, ported from 8digit/picpeak@88bfde1). + * + * Replaces `window.confirm()` with a styled, themed, accessible in-app modal. + * Usage: + * + * const confirm = useConfirm(); + * const ok = await confirm({ + * title: 'Delete event?', + * message: 'This will permanently remove the gallery and all photos.', + * variant: 'danger', + * confirmLabel: 'Delete', + * }); + * if (ok) doDelete(); + * + * Wraps once at the App level via ; every component + * below it gets `useConfirm()` for free. Variants: + * - 'primary' (default) — plain confirm, no icon + * - 'danger' — red AlertCircle, red confirm button + * - 'warning' — amber AlertTriangle + * + * Keyboard: Escape cancels, Enter confirms, backdrop click cancels. The cancel + * button is focused by default so a stray Enter doesn't accidentally confirm a + * destructive action. + * + * This is the generic primitive. Existing inline-modal flows (PublishGalleryDialog, + * DuplicateEventDialog, PasswordResetModal, etc.) stay as-is — they collect + * structured input, not a simple yes/no. Call-site sweeps of `window.confirm()` + * follow in later PRs. + */ + +export type ConfirmVariant = 'primary' | 'danger' | 'warning'; + +export interface ConfirmOptions { + title?: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: ConfirmVariant; +} + +type Resolver = (value: boolean) => void; + +interface ConfirmContextValue { + confirm: (options: ConfirmOptions) => Promise; +} + +const ConfirmContext = createContext(null); + +export const useConfirm = (): ((options: ConfirmOptions) => Promise) => { + const ctx = useContext(ConfirmContext); + if (!ctx) { + throw new Error('useConfirm must be used within a ConfirmDialogProvider'); + } + return ctx.confirm; +}; + +export const ConfirmDialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { t } = useTranslation(); + const [options, setOptions] = useState(null); + const resolverRef = useRef(null); + const cancelButtonRef = useRef(null); + + const confirm = useCallback((opts: ConfirmOptions): Promise => { + return new Promise((resolve) => { + // If a prior confirm is still open (shouldn't happen in practice but + // guard anyway), resolve it as cancelled before opening the new one. + if (resolverRef.current) { + resolverRef.current(false); + } + resolverRef.current = resolve; + setOptions(opts); + }); + }, []); + + const settle = useCallback((value: boolean) => { + if (resolverRef.current) { + resolverRef.current(value); + resolverRef.current = null; + } + setOptions(null); + }, []); + + useEffect(() => { + if (!options) return; + cancelButtonRef.current?.focus(); + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + settle(false); + } else if (e.key === 'Enter') { + // Don't hijack Enter when the focus is in an editable element — covers + // the (unusual) case where a confirm is open over an open input. + const tag = (document.activeElement as HTMLElement | null)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + e.preventDefault(); + settle(true); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [options, settle]); + + const variant = options?.variant ?? 'primary'; + const Icon = variant === 'danger' ? AlertCircle : variant === 'warning' ? AlertTriangle : null; + const iconClass = + variant === 'danger' + ? 'text-red-600 dark:text-red-400' + : variant === 'warning' + ? 'text-amber-600 dark:text-amber-400' + : ''; + + // Danger uses the outline button + an inline red override so the visual + // weight matches the action without redefining a Button variant for one case. + const confirmButtonVariant: 'primary' | 'outline' = variant === 'danger' ? 'outline' : 'primary'; + const confirmButtonClass = variant === 'danger' + ? 'bg-red-600 hover:bg-red-700 text-white border-red-600' + : ''; + + return ( + + {children} + {options && ( + settle(false)} + role="dialog" + aria-modal="true" + > + e.stopPropagation()} + > + + {Icon && } + + {options.title && ( + + {options.title} + + )} + + {options.message} + + + settle(false)} + className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300" + aria-label={t('common.close', 'Close')} + > + + + + + + settle(false)} + > + {options.cancelLabel ?? t('common.cancel', 'Cancel')} + + settle(true)} + className={confirmButtonClass} + > + {options.confirmLabel ?? t('common.confirm', 'Confirm')} + + + + + )} + + ); +}; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 9692c906..f68369aa 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -29,3 +29,4 @@ export { ProtectionWarning } from './ProtectionWarning'; export { ReCaptcha } from './ReCaptcha'; export { PasswordGenerator } from './PasswordGenerator'; export { MarkdownContent } from './MarkdownContent'; +export { ConfirmDialogProvider, useConfirm, type ConfirmOptions, type ConfirmVariant } from './ConfirmDialog';
+ {options.message} +