ui: migrate to shadcn/ui primitives across dialogs, filters, and forms

Adopts shadcn/ui components (Dialog, Button, Input, Select, Popover,
Command, Checkbox, Switch, Toggle, Calendar, etc.) across the app,
replacing hand-rolled modals, dropdowns, and form controls. Adds a
reusable cmdk-backed MultiSelect for the Type, Tags, and Flag filters
so all multi-value filter popovers share one component and layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 09:07:20 +02:00
parent 8529771122
commit 7efac4354e
51 changed files with 3032 additions and 1660 deletions

View File

@@ -2,6 +2,18 @@ import { useEffect, useState } from 'react'
import clsx from 'clsx'
import { Trash2, Archive } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
interface DeleteFolderDialogProps {
isOpen: boolean
folderName: string
@@ -13,14 +25,12 @@ interface DeleteFolderDialogProps {
onConfirm: (mode: 'discard' | 'permanent') => void
}
type Mode = 'discard' | 'permanent'
/**
* Two-mode folder delete dialog:
*
* - Move to discard pile (default, soft, recoverable)
* - Permanently delete (destructive, irreversible)
*
* The user picks a mode via the radio cards then clicks Delete. Esc /
* backdrop click cancels.
* Two-mode folder delete dialog built on the shadcn Dialog + RadioGroup
* primitives (instead of the old custom fixed-inset backdrop). Esc
* closes, overlay click closes, focus is trapped by Radix.
*/
export function DeleteFolderDialog({
isOpen,
@@ -29,110 +39,93 @@ export function DeleteFolderDialog({
onClose,
onConfirm,
}: DeleteFolderDialogProps) {
const [mode, setMode] = useState<'discard' | 'permanent'>('discard')
const [mode, setMode] = useState<Mode>('discard')
// Reset mode when re-opening so the safe option is always the default.
useEffect(() => {
if (isOpen) setMode('discard')
}, [isOpen])
// Esc to close.
useEffect(() => {
if (!isOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, onClose])
if (!isOpen) return null
const photoBlurb =
photoCount === undefined
? 'photos in this folder'
: photoCount === 0
? 'this empty folder'
: `${photoCount} photo${photoCount === 1 ? '' : 's'} in this folder`
? 'this empty folder'
: `${photoCount} photo${photoCount === 1 ? '' : 's'} in this folder`
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 w-[420px] rounded-lg border border-border bg-surface p-5 shadow-2xl">
<h2 className="mb-1 text-base font-semibold text-text">
Delete folder "{folderName}"?
</h2>
<p className="mb-4 text-sm text-text-muted">
<Dialog open={isOpen} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[420px]">
<DialogHeader>
<DialogTitle>Delete folder "{folderName}"?</DialogTitle>
<DialogDescription>
What should happen to {photoBlurb}?
</p>
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<ModeCard
icon={<Archive className="h-4 w-4" />}
title="Move photos to discard pile"
description="Photos can be restored later from Discarded. The folder and files stay on disk."
selected={mode === 'discard'}
onClick={() => setMode('discard')}
/>
<ModeCard
icon={<Trash2 className="h-4 w-4" />}
title="Permanently delete folder and photos"
description="Removes the folder, every photo inside it, and the directory from disk. This cannot be undone."
selected={mode === 'permanent'}
destructive
onClick={() => setMode('permanent')}
/>
</div>
<RadioGroup
value={mode}
onValueChange={(v) => setMode(v as Mode)}
className="gap-2"
>
<ModeCard
id="folder-delete-discard"
value="discard"
icon={<Archive className="h-4 w-4" />}
title="Move photos to discard pile"
description="Photos can be restored later from Discarded. The folder and files stay on disk."
selected={mode === 'discard'}
/>
<ModeCard
id="folder-delete-permanent"
value="permanent"
icon={<Trash2 className="h-4 w-4" />}
title="Permanently delete folder and photos"
description="Removes the folder, every photo inside it, and the directory from disk. This cannot be undone."
selected={mode === 'permanent'}
destructive
/>
</RadioGroup>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
>
Cancel
</button>
<button
onClick={() => onConfirm(mode)}
className={clsx(
'rounded px-3 py-1.5 text-sm font-medium text-white',
mode === 'permanent'
? 'bg-reject hover:bg-reject/80'
: 'bg-primary hover:bg-primary/80'
)}
>
{mode === 'permanent' ? 'Delete forever' : 'Move to discard pile'}
</button>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
variant={mode === 'permanent' ? 'destructive' : 'default'}
onClick={() => onConfirm(mode)}
>
{mode === 'permanent' ? 'Delete forever' : 'Move to discard pile'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** Radio-group item rendered as a full-width descriptive card. */
function ModeCard({
id,
value,
icon,
title,
description,
selected,
destructive = false,
onClick,
}: {
id: string
value: string
icon: React.ReactNode
title: string
description: string
selected: boolean
destructive?: boolean
onClick: () => void
}) {
return (
<button
onClick={onClick}
<Label
htmlFor={id}
className={clsx(
'flex w-full gap-3 rounded-lg border p-3 text-left transition-colors',
'flex w-full cursor-pointer gap-3 rounded-lg border p-3 text-left transition-colors',
selected
? destructive
? 'border-reject/60 bg-reject/10'
@@ -140,10 +133,15 @@ function ModeCard({
: 'border-border bg-surface-2 hover:bg-surface-offset'
)}
>
<RadioGroupItem id={id} value={value} className="mt-0.5" />
<div
className={clsx(
'mt-0.5 flex-shrink-0',
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text-muted'
selected
? destructive
? 'text-reject'
: 'text-primary'
: 'text-text-muted'
)}
>
{icon}
@@ -152,13 +150,17 @@ function ModeCard({
<div
className={clsx(
'text-sm font-medium',
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text'
selected
? destructive
? 'text-reject'
: 'text-primary'
: 'text-text'
)}
>
{title}
</div>
<div className="mt-0.5 text-xs text-text-muted">{description}</div>
</div>
</button>
</Label>
)
}