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,
} from './ui/dialog';
import { MediaEntry } from './MediaEntry';
import { ConfirmDialog, type ConfirmOptions } from './ui/confirm-dialog';
interface MediaBrowserProps {
currentPath: string;
@ -49,6 +50,7 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr
const [newFolderName, setNewFolderName] = useState('');
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [confirm, setConfirm] = useState<ConfirmOptions | null>(null);
useEffect(() => {
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}`); }
};
const handleDelete = async (entry: EntryInfo) => {
if (!window.confirm(entry.type === 'folder' ? `Delete folder "${entry.name}" and all its contents?` : `Delete file "${entry.name}"?`)) return;
const handleDelete = (entry: EntryInfo) => {
setActionEntry(null);
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}`); }
setConfirm({
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) => {
if (!path) return;
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 (!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); }
catch (e: any) { toast.error(`Failed to upload ${file.name}: ${e?.message ?? e}`); }
await doUpload();
};
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -200,6 +219,12 @@ export default function MediaBrowser({ currentPath, onSelect, onClose }: MediaBr
</div>
</div>
<ConfirmDialog
open={confirm !== null}
{...(confirm ?? { title: '', onConfirm: () => {} })}
onCancel={() => setConfirm(null)}
/>
<Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>

View File

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

View File

@ -8,6 +8,7 @@ import DeviceDetailOverlay from './DeviceDetailOverlay';
import MediaSet from './MediaSet';
import { ImageWithFallback } from './figma/ImageWithFallback';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { ConfirmDialog, type ConfirmOptions } from './ui/confirm-dialog';
interface StatusPageProps {
config: any;
@ -88,6 +89,7 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
const [printFiles, setPrintFiles] = useState<EntryInfo[]>([]);
const [printLoading, setPrintLoading] = useState(true);
const [printActionEntry, setPrintActionEntry] = useState<EntryInfo | null>(null);
const [confirm, setConfirm] = useState<ConfirmOptions | null>(null);
const loadPrintFiles = () => {
setPrintLoading(true);
@ -111,13 +113,19 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
} catch (e: any) { toast.error(`Download failed: ${e?.message ?? e}`); }
};
const deletePrintFile = async (entry: EntryInfo) => {
if (!window.confirm(`Delete "${entry.name}"?`)) return;
try {
await deletePath(entry.path);
toast.success(`Deleted ${entry.name}`);
loadPrintFiles();
} catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); }
const deletePrintFile = (entry: EntryInfo) => {
setConfirm({
title: `Delete "${entry.name}"?`,
confirmLabel: 'Delete',
destructive: true,
onConfirm: async () => {
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 */}
<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="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>
<div className="flex flex-row justify-between gap-4 w-full">
@ -459,6 +467,11 @@ export default function StatusPage({ config, setConfig }: StatusPageProps) {
</div>
</div>
<ConfirmDialog
open={confirm !== null}
{...(confirm ?? { title: '', onConfirm: () => {} })}
onCancel={() => setConfirm(null)}
/>
</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 { toast } from 'sonner';
import { getFileContents, putFileContents, humanFileSize } from '../webdav';
import { ConfirmDialog, type ConfirmOptions } from './ui/confirm-dialog';
interface ToolsPageProps {
config: any;
@ -139,6 +140,7 @@ function FirmwareOverlay({ onClose }: { onClose: () => void }) {
export default function ToolsPage({ config }: ToolsPageProps) {
const [showFirmware, setShowFirmware] = useState(false);
const [confirm, setConfirm] = useState<ConfirmOptions | null>(null);
const handleBackup = () => {
const dataStr = JSON.stringify(config, null, 2);
@ -175,15 +177,23 @@ export default function ToolsPage({ config }: ToolsPageProps) {
};
const handleFactoryReset = () => {
if (confirm('Are you sure you want to reset to factory defaults? This cannot be undone.')) {
toast.success('Configuration reset to factory defaults');
}
setConfirm({
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 = () => {
if (confirm('Are you sure you want to format the SD card? All data will be lost.')) {
toast.success('SD card formatting started');
}
setConfirm({
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 = () => {
@ -341,6 +351,12 @@ export default function ToolsPage({ config }: ToolsPageProps) {
{showFirmware && <FirmwareOverlay onClose={() => setShowFirmware(false)} />}
<ConfirmDialog
open={confirm !== null}
{...(confirm ?? { title: '', onConfirm: () => {} })}
onCancel={() => setConfirm(null)}
/>
</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>
);
}