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:
@@ -1,5 +1,12 @@
|
||||
import { useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
@@ -14,8 +21,9 @@ interface ConfirmDialogProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny modal-confirmation dialog. Mirrors the AddSourceFolderDialog overlay
|
||||
* pattern (custom fixed inset-0 backdrop, no shadcn Dialog dep). Esc closes.
|
||||
* Modal confirmation dialog. Built on the shadcn Dialog primitive:
|
||||
* Radix handles focus trap, portal, overlay-click dismissal, Esc, and
|
||||
* animations. We only supply title, body, and the two action buttons.
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
@@ -27,49 +35,27 @@ export function ConfirmDialog({
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ConfirmDialogProps) {
|
||||
// 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
|
||||
|
||||
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-96 rounded-lg border border-border bg-surface p-5 shadow-2xl">
|
||||
<h2 className="mb-2 text-base font-semibold text-text">{title}</h2>
|
||||
<div className="mb-4 text-sm text-text-muted">{message}</div>
|
||||
<div className="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"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={clsx(
|
||||
'rounded px-3 py-1.5 text-sm font-medium text-white',
|
||||
destructive
|
||||
? 'bg-reject hover:bg-reject/80'
|
||||
: 'bg-primary hover:bg-primary/80'
|
||||
)}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={isOpen} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div>{message}</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
import { toast } from '../ToastContainer'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { UserManagement } from '../admin/UserManagement'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// React Query keys for the settings panels. Kept here (not in a shared
|
||||
// hook module) since they're internal to this dialog and used by the
|
||||
@@ -212,23 +215,24 @@ export function SettingsPage() {
|
||||
const visibleTabs = TABS.filter((t) => !t.adminOnly || isAdmin)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as SettingsTab)}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center gap-1 border-b border-border bg-surface px-4 py-1.5">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={clsx(
|
||||
'rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||
activeTab === tab.id
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'text-text-muted hover:bg-surface-2 hover:text-text',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center border-b border-border bg-surface px-4 py-1.5">
|
||||
<TabsList className="h-7 bg-transparent">
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="h-6 px-3 text-xs data-[state=active]:bg-primary/20 data-[state=active]:text-primary"
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
@@ -867,7 +871,7 @@ export function SettingsPage() {
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1211,36 +1215,24 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={state.effective}
|
||||
onClick={() => applyFlag(meta.id, !state.effective)}
|
||||
<Switch
|
||||
checked={state.effective}
|
||||
onCheckedChange={(v) => applyFlag(meta.id, v)}
|
||||
disabled={isBusy || (dimmed && !isMaster)}
|
||||
className={clsx(
|
||||
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors',
|
||||
state.effective ? 'bg-primary' : 'bg-surface-2 border border-border',
|
||||
(isBusy || (dimmed && !isMaster)) && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
title={state.effective ? 'Click to disable' : 'Click to enable'}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
'inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform',
|
||||
state.effective ? 'translate-x-[22px]' : 'translate-x-0.5',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
/>
|
||||
{state.overridden && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => applyFlag(meta.id, null)}
|
||||
disabled={isBusy}
|
||||
className="rounded border border-border p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-50"
|
||||
className="h-6 w-6"
|
||||
title="Reset to YAML default"
|
||||
aria-label="Reset override"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user