feat(confirm-dialog): implement ConfirmDialog component for user confirmations

This commit is contained in:
Jaime Idolpx 2026-06-10 19:45:12 -04:00
parent 07bbf00aa9
commit 1234ba30d9
5 changed files with 165 additions and 41 deletions

View File

@ -33,6 +33,7 @@ import {
DialogDescription, DialogDescription,
} from './ui/dialog'; } from './ui/dialog';
import { MediaEntry } from './MediaEntry'; import { MediaEntry } from './MediaEntry';
import { ConfirmDialog, type ConfirmOptions } from './ui/confirm-dialog';
interface MediaBrowserProps { interface MediaBrowserProps {
currentPath: string; currentPath: string;
@ -49,6 +50,7 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr
const [newFolderName, setNewFolderName] = useState(''); const [newFolderName, setNewFolderName] = useState('');
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null); const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [confirm, setConfirm] = useState<ConfirmOptions | null>(null);
useEffect(() => { useEffect(() => {
const initial = normalizePath(currentPath || '/'); const initial = normalizePath(currentPath || '/');
@ -91,21 +93,38 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr
} catch (e: any) { toast.error(`Failed to create folder: ${e?.message ?? e}`); } } catch (e: any) { toast.error(`Failed to create folder: ${e?.message ?? e}`); }
}; };
const handleDelete = async (entry: EntryInfo) => { const handleDelete = (entry: EntryInfo) => {
if (!window.confirm(entry.type === 'folder' ? `Delete folder "${entry.name}" and all its contents?` : `Delete file "${entry.name}"?`)) return;
setActionEntry(null); setActionEntry(null);
try { await deletePath(entry.path); toast.success(`Deleted ${entry.name}`); if (path) void load(path); } setConfirm({
catch (e: any) { toast.error(`Failed to delete: ${e?.message ?? e}`); } title: entry.type === 'folder' ? `Delete folder "${entry.name}"?` : `Delete "${entry.name}"?`,
description: entry.type === 'folder' ? 'This will delete the folder and all its contents.' : undefined,
confirmLabel: 'Delete',
destructive: true,
onConfirm: async () => {
try { await deletePath(entry.path); toast.success(`Deleted ${entry.name}`); if (path) void load(path); }
catch (e: any) { toast.error(`Failed to delete: ${e?.message ?? e}`); }
},
});
}; };
const handleUpload = async (file: File) => { const handleUpload = async (file: File) => {
if (!path) return; if (!path) return;
const target = joinPath(path, file.name); const target = joinPath(path, file.name);
const doUpload = async () => {
try { await putFileContents(target, await file.arrayBuffer()); toast.success(`Uploaded ${file.name}`); void load(path!); }
catch (e: any) { toast.error(`Failed to upload ${file.name}: ${e?.message ?? e}`); }
};
if (await fileExists(target)) { if (await fileExists(target)) {
if (!window.confirm(`"${file.name}" already exists. Overwrite?`)) return; setConfirm({
title: `"${file.name}" already exists`,
description: 'Replace the existing file?',
confirmLabel: 'Overwrite',
destructive: true,
onConfirm: doUpload,
});
return;
} }
try { await putFileContents(target, await file.arrayBuffer()); toast.success(`Uploaded ${file.name}`); void load(path); } await doUpload();
catch (e: any) { toast.error(`Failed to upload ${file.name}: ${e?.message ?? e}`); }
}; };
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => { const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -200,6 +219,12 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr
</div> </div>
</div> </div>
<ConfirmDialog
open={confirm !== null}
{...(confirm ?? { title: '', onConfirm: () => {} })}
onCancel={() => setConfirm(null)}
/>
<Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}> <Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}>
<DialogContent className="max-w-sm"> <DialogContent className="max-w-sm">
<DialogHeader> <DialogHeader>

View File

@ -61,6 +61,7 @@ import {
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} from './ui/dialog'; } from './ui/dialog';
import { ConfirmDialog, type ConfirmOptions } from './ui/confirm-dialog';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@ -372,6 +373,7 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null); const renameInputRef = useRef<HTMLInputElement>(null);
const dragCounter = useRef(0); const dragCounter = useRef(0);
const [confirm, setConfirm] = useState<ConfirmOptions | null>(null);
// ── Directory loading ──────────────────────────────────────────────────── // ── Directory loading ────────────────────────────────────────────────────
@ -564,23 +566,34 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
// ── Delete ─────────────────────────────────────────────────────────────── // ── Delete ───────────────────────────────────────────────────────────────
const deleteEntry = async (entry: EntryInfo) => { const deleteEntry = (entry: EntryInfo) => {
if (!window.confirm(entry.type === 'folder' setConfirm({
? `Delete folder "${entry.name}" and all its contents?` title: entry.type === 'folder' ? `Delete folder "${entry.name}"?` : `Delete "${entry.name}"?`,
: `Delete "${entry.name}"?`)) return; description: entry.type === 'folder' ? 'This will delete the folder and all its contents.' : undefined,
try { await deletePath(entry.path); toast.success(`Deleted ${entry.name}`); void load(path); } confirmLabel: 'Delete',
catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); } destructive: true,
onConfirm: async () => {
try { await deletePath(entry.path); toast.success(`Deleted ${entry.name}`); void load(path); }
catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); }
},
});
}; };
const deleteSelected = async () => { const deleteSelected = () => {
const targets = entries.filter(e => selected.has(e.path)); const targets = entries.filter(e => selected.has(e.path));
if (!window.confirm(`Delete ${targets.length} item${targets.length !== 1 ? 's' : ''}?`)) return; setConfirm({
let failed = 0; title: `Delete ${targets.length} item${targets.length !== 1 ? 's' : ''}?`,
for (const e of targets) { try { await deletePath(e.path); } catch { failed++; } } confirmLabel: 'Delete',
if (failed) toast.error(`${failed} item${failed !== 1 ? 's' : ''} failed to delete`); destructive: true,
else toast.success(`Deleted ${targets.length} item${targets.length !== 1 ? 's' : ''}`); onConfirm: async () => {
void load(path); let failed = 0;
setSelected(new Set()); for (const e of targets) { try { await deletePath(e.path); } catch { failed++; } }
if (failed) toast.error(`${failed} item${failed !== 1 ? 's' : ''} failed to delete`);
else toast.success(`Deleted ${targets.length} item${targets.length !== 1 ? 's' : ''}`);
void load(path);
setSelected(new Set());
},
});
}; };
// ── Rename ─────────────────────────────────────────────────────────────── // ── Rename ───────────────────────────────────────────────────────────────
@ -752,14 +765,24 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
const handleUpload = async (file: File) => { const handleUpload = async (file: File) => {
const target = joinPath(path, file.name); const target = joinPath(path, file.name);
const doUpload = async () => {
try {
await putFileContents(target, await file.arrayBuffer());
toast.success(`Uploaded ${file.name}`);
void load(path);
} catch (e: any) { toast.error(`Upload failed for ${file.name}: ${e?.message ?? e}`); }
};
if (await fileExists(target)) { if (await fileExists(target)) {
if (!window.confirm(`"${file.name}" already exists. Overwrite?`)) return; setConfirm({
title: `"${file.name}" already exists`,
description: 'Replace the existing file?',
confirmLabel: 'Overwrite',
destructive: true,
onConfirm: doUpload,
});
return;
} }
try { await doUpload();
await putFileContents(target, await file.arrayBuffer());
toast.success(`Uploaded ${file.name}`);
void load(path);
} catch (e: any) { toast.error(`Upload failed for ${file.name}: ${e?.message ?? e}`); }
}; };
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => { const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -1128,6 +1151,12 @@ export default function MediaManager({ initialPath = '/', rootPath, title, confi
</Suspense> </Suspense>
)} )}
<ConfirmDialog
open={confirm !== null}
{...(confirm ?? { title: '', onConfirm: () => {} })}
onCancel={() => setConfirm(null)}
/>
{/* ── Mount dialog ── */} {/* ── Mount dialog ── */}
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}> <Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
<DialogContent className="max-w-sm"> <DialogContent className="max-w-sm">

View File

@ -8,6 +8,7 @@ import DeviceDetailOverlay from './DeviceDetailOverlay';
import MediaSet from './MediaSet'; import MediaSet from './MediaSet';
import { ImageWithFallback } from './figma/ImageWithFallback'; import { ImageWithFallback } from './figma/ImageWithFallback';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { ConfirmDialog, type ConfirmOptions } from './ui/confirm-dialog';
interface StatusPageProps { interface StatusPageProps {
config: any; config: any;
@ -88,6 +89,7 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
const [printFiles, setPrintFiles] = useState<EntryInfo[]>([]); const [printFiles, setPrintFiles] = useState<EntryInfo[]>([]);
const [printLoading, setPrintLoading] = useState(true); const [printLoading, setPrintLoading] = useState(true);
const [printActionEntry, setPrintActionEntry] = useState<EntryInfo | null>(null); const [printActionEntry, setPrintActionEntry] = useState<EntryInfo | null>(null);
const [confirm, setConfirm] = useState<ConfirmOptions | null>(null);
const loadPrintFiles = () => { const loadPrintFiles = () => {
setPrintLoading(true); setPrintLoading(true);
@ -111,13 +113,19 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
} catch (e: any) { toast.error(`Download failed: ${e?.message ?? e}`); } } catch (e: any) { toast.error(`Download failed: ${e?.message ?? e}`); }
}; };
const deletePrintFile = async (entry: EntryInfo) => { const deletePrintFile = (entry: EntryInfo) => {
if (!window.confirm(`Delete "${entry.name}"?`)) return; setConfirm({
try { title: `Delete "${entry.name}"?`,
await deletePath(entry.path); confirmLabel: 'Delete',
toast.success(`Deleted ${entry.name}`); destructive: true,
loadPrintFiles(); onConfirm: async () => {
} catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); } try {
await deletePath(entry.path);
toast.success(`Deleted ${entry.name}`);
loadPrintFiles();
} catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); }
},
});
}; };
@ -163,7 +171,7 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
{/* New device info cards */} {/* New device info cards */}
<div className="mb-4"> <div className="mb-4">
<div className="bg-neutral-50 rounded-lg p-3 flex flex-col items-start justify-center w-full mb-2"> <div className="bg-neutral-50 rounded-lg p-3 flex flex-col items-start justify-center w-full mb-2">
<div className="text-xs text-neutral-500 mb-1">Last File</div> <div className="text-xs text-neutral-500 mb-1">Last File Access</div>
<div className="text-sm font-medium break-all w-full text-left">{lastFile}</div> <div className="text-sm font-medium break-all w-full text-left">{lastFile}</div>
</div> </div>
<div className="flex flex-row justify-between gap-4 w-full"> <div className="flex flex-row justify-between gap-4 w-full">
@ -459,6 +467,11 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
</div> </div>
</div> </div>
<ConfirmDialog
open={confirm !== null}
{...(confirm ?? { title: '', onConfirm: () => {} })}
onCancel={() => setConfirm(null)}
/>
</div> </div>
); );
} }

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Wrench, Download, Upload, RotateCcw, Database, FileText, HardDrive, X, Loader2, Cpu, ChevronRight, AlertCircle } from 'lucide-react'; import { Wrench, Download, Upload, RotateCcw, Database, FileText, HardDrive, X, Loader2, Cpu, ChevronRight, AlertCircle } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getFileContents, putFileContents, humanFileSize } from '../webdav'; import { getFileContents, putFileContents, humanFileSize } from '../webdav';
import { ConfirmDialog, type ConfirmOptions } from './ui/confirm-dialog';
interface ToolsPageProps { interface ToolsPageProps {
config: any; config: any;
@ -139,6 +140,7 @@ function FirmwareOverlay({ onClose }: { onClose: () => void }) {
export default function ToolsPage({ config }: ToolsPageProps) { export default function ToolsPage({ config }: ToolsPageProps) {
const [showFirmware, setShowFirmware] = useState(false); const [showFirmware, setShowFirmware] = useState(false);
const [confirm, setConfirm] = useState<ConfirmOptions | null>(null);
const handleBackup = () => { const handleBackup = () => {
const dataStr = JSON.stringify(config, null, 2); const dataStr = JSON.stringify(config, null, 2);
@ -175,15 +177,23 @@ export default function ToolsPage({ config }: ToolsPageProps) {
}; };
const handleFactoryReset = () => { const handleFactoryReset = () => {
if (confirm('Are you sure you want to reset to factory defaults? This cannot be undone.')) { setConfirm({
toast.success('Configuration reset to factory defaults'); title: 'Factory Reset',
} description: 'Reset to default settings? This cannot be undone.',
confirmLabel: 'Reset',
destructive: true,
onConfirm: () => toast.success('Configuration reset to factory defaults'),
});
}; };
const handleFormatSD = () => { const handleFormatSD = () => {
if (confirm('Are you sure you want to format the SD card? All data will be lost.')) { setConfirm({
toast.success('SD card formatting started'); title: 'Format SD Card',
} description: 'All data on the SD card will be lost.',
confirmLabel: 'Format',
destructive: true,
onConfirm: () => toast.success('SD card formatting started'),
});
}; };
const handleSystemUpdate = () => { const handleSystemUpdate = () => {
@ -341,6 +351,12 @@ export default function ToolsPage({ config }: ToolsPageProps) {
{showFirmware && <FirmwareOverlay onClose={() => setShowFirmware(false)} />} {showFirmware && <FirmwareOverlay onClose={() => setShowFirmware(false)} />}
<ConfirmDialog
open={confirm !== null}
{...(confirm ?? { title: '', onConfirm: () => {} })}
onCancel={() => setConfirm(null)}
/>
</div> </div>
); );
} }

View File

@ -0,0 +1,41 @@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './dialog';
export interface ConfirmOptions {
title: string;
description?: string;
confirmLabel?: string;
destructive?: boolean;
onConfirm: () => void;
}
interface Props extends ConfirmOptions {
open: boolean;
onCancel: () => void;
}
export function ConfirmDialog({ open, title, description, confirmLabel = 'Confirm', destructive = false, onConfirm, onCancel }: Props) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel(); }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<DialogFooter>
<button
onClick={onCancel}
className="px-4 py-2 text-sm border border-neutral-200 rounded-lg hover:bg-neutral-50"
>
Cancel
</button>
<button
onClick={() => { onConfirm(); onCancel(); }}
className={`px-4 py-2 text-sm rounded-lg text-white ${destructive ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'}`}
>
{confirmLabel}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}