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

@@ -1,7 +1,6 @@
import { useState, useEffect, useMemo } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { X, Folder, AlertCircle } from 'lucide-react'
import clsx from 'clsx'
import { Folder, AlertCircle } from 'lucide-react'
import {
heaps as heapsApi,
type Heap,
@@ -10,6 +9,26 @@ import {
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { toast } from '../ToastContainer'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Checkbox } from '@/components/ui/checkbox'
import { Alert, AlertDescription } from '@/components/ui/alert'
interface FlatFolder {
id: string
@@ -37,11 +56,6 @@ interface HeapConvertDialogProps {
onClose: () => void
}
/**
* Modal that converts a heap into a folder. The user picks a target folder
* (any source root, today — sub-folder picking is a follow-up), chooses
* move vs copy semantics, and optionally has the heap deleted on success.
*/
export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
const queryClient = useQueryClient()
const [targetId, setTargetId] = useState('')
@@ -49,19 +63,15 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
const [deleteHeap, setDeleteHeap] = useState(false)
const [subfolderName, setSubfolderName] = useState('')
// Use the recursive folder tree, not the flat source-root list, so the
// user can pick a sub-folder at any depth as the target.
const { data: tree = [] } = useFolderTreeQuery()
const folders = useMemo<FlatFolder[]>(() => flattenTree(tree), [tree])
// Default to the first folder when the dialog opens or folders load.
useEffect(() => {
if (!targetId && folders.length > 0) {
setTargetId(folders[0].id)
}
}, [folders, targetId])
// Reset state on close, prefill subfolder name when opened.
useEffect(() => {
if (heap) {
setSubfolderName(heap.name)
@@ -79,8 +89,6 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
target_id: targetId,
mode,
delete_heap: deleteHeap,
// Empty subfolder = drop directly into the parent. Trim and only
// send if the user kept it populated.
subfolder_name: subfolderName.trim() || null,
}),
onSuccess: (data) => {
@@ -99,149 +107,130 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
toast.error('Convert failed', e?.response?.data?.detail || e.message),
})
if (!heap) return null
const targetFolder = folders.find((f) => f.id === targetId)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<Dialog open={!!heap} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Convert "{heap?.name}" to folder</DialogTitle>
</DialogHeader>
<div className="relative z-10 w-full max-w-md rounded-lg border border-border bg-surface p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-text">
Convert "{heap.name}" to folder
</h2>
<button
onClick={onClose}
disabled={convertMutation.isPending}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4">
{/* Target picker */}
<div className="space-y-1.5">
<Label>Target folder</Label>
{folders.length === 0 ? (
<div className="rounded border border-border bg-bg px-3 py-2 text-xs text-text-muted">
No folders available
</div>
) : (
<Select value={targetId} onValueChange={setTargetId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{folders.map((f) => (
<SelectItem key={f.id} value={f.id}>
{'\u00A0\u00A0'.repeat(f.depth) + f.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{targetFolder && (
<p className="flex items-center gap-1 text-xs text-text-faint">
<Folder className="h-3 w-3" />
{targetFolder.path}
</p>
)}
</div>
{/* Target picker */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">Target folder</label>
{folders.length === 0 ? (
<div className="rounded border border-border bg-bg px-3 py-2 text-xs text-text-muted">
No folders available
</div>
) : (
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text focus:border-primary focus:outline-none"
>
{folders.map((f) => (
<option key={f.id} value={f.id}>
{/* Two non-breaking spaces per depth so nested
* subfolders read as a tree in the native dropdown. */}
{'\u00A0\u00A0'.repeat(f.depth) + f.name}
</option>
))}
</select>
)}
{targetFolder && (
<p className="mt-1 flex items-center gap-1 text-xs text-text-faint">
<Folder className="h-3 w-3" />
{targetFolder.path}
{/* Subfolder name */}
<div className="space-y-1.5">
<Label htmlFor="heap-convert-subfolder">Subfolder name</Label>
<Input
id="heap-convert-subfolder"
type="text"
value={subfolderName}
onChange={(e) => setSubfolderName(e.target.value)}
placeholder="(none — use parent directly)"
/>
<p className="text-xs text-text-faint">
{subfolderName.trim() && targetFolder
? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.`
: 'Photos go directly into the parent folder.'}
</p>
</div>
{/* Mode */}
<div className="space-y-1.5">
<Label>Mode</Label>
<RadioGroup
value={mode}
onValueChange={(v) => setMode(v as 'move' | 'copy')}
className="grid grid-cols-2 gap-2"
>
<Label
htmlFor="heap-convert-move"
className="flex cursor-pointer items-center gap-2 rounded-md border border-border bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary/15"
>
<RadioGroupItem id="heap-convert-move" value="move" />
Move
</Label>
<Label
htmlFor="heap-convert-copy"
className="flex cursor-pointer items-center gap-2 rounded-md border border-border bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary/15"
>
<RadioGroupItem id="heap-convert-copy" value="copy" />
Copy
</Label>
</RadioGroup>
<p className="text-xs text-text-faint">
{mode === 'move'
? 'Files are moved on disk; original photos update their folder.'
: 'Files are copied on disk; new photo records are created.'}
</p>
</div>
{/* Delete heap */}
<div className="flex items-center gap-2">
<Checkbox
id="delete-heap"
checked={deleteHeap}
onCheckedChange={(v) => setDeleteHeap(v === true)}
/>
<Label htmlFor="delete-heap" className="text-sm text-text">
Delete heap after conversion
</Label>
</div>
{convertMutation.isError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{(convertMutation.error as any)?.message || 'Conversion failed'}
</AlertDescription>
</Alert>
)}
</div>
{/* Subfolder name */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">
Subfolder name
</label>
<input
type="text"
value={subfolderName}
onChange={(e) => setSubfolderName(e.target.value)}
placeholder="(none — use parent directly)"
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
<p className="mt-1 text-xs text-text-faint">
{subfolderName.trim() && targetFolder
? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.`
: 'Photos go directly into the parent folder.'}
</p>
</div>
{/* Mode toggle */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">Mode</label>
<div className="flex gap-2">
<button
onClick={() => setMode('move')}
className={clsx(
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
mode === 'move'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Move
</button>
<button
onClick={() => setMode('copy')}
className={clsx(
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
mode === 'copy'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Copy
</button>
</div>
<p className="mt-1 text-xs text-text-faint">
{mode === 'move'
? 'Files are moved on disk; original photos update their folder.'
: 'Files are copied on disk; new photo records are created.'}
</p>
</div>
{/* Delete heap toggle */}
<div className="mb-4 flex items-center gap-2">
<input
id="delete-heap"
type="checkbox"
checked={deleteHeap}
onChange={(e) => setDeleteHeap(e.target.checked)}
className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0"
/>
<label htmlFor="delete-heap" className="text-sm text-text">
Delete heap after conversion
</label>
</div>
{convertMutation.isError && (
<div className="mb-3 flex items-center gap-2 rounded bg-reject/10 p-3 text-sm text-reject">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{(convertMutation.error as any)?.message || 'Conversion failed'}</span>
</div>
)}
<div className="flex justify-end gap-2">
<button
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={convertMutation.isPending}
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
>
Cancel
</button>
<button
</Button>
<Button
onClick={() => convertMutation.mutate()}
disabled={!targetId || convertMutation.isPending}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{convertMutation.isPending ? 'Converting…' : 'Convert'}
</button>
</div>
</div>
</div>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useState } from 'react'
import {
ShoppingBasket,
Plus,
@@ -23,6 +23,15 @@ import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { HeapConvertDialog } from './HeapConvertDialog'
import { ShareDialog } from '../sharing/ShareDialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
/**
* Heaps panel for the left sidebar. Renders the list of heaps with the
@@ -53,28 +62,10 @@ export function HeapsPanel() {
// the draft name. Mirrors the folder rename pattern in LeftSidebar.
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameDraft, setRenameDraft] = useState('')
// Which heap's burger menu is currently open. null when no menu is open.
// The popover closes on outside click and Escape via the effect below.
// Which heap's burger menu is currently open. Drives the trigger's
// hover-visible state via DropdownMenu's open prop — Radix handles
// outside-click + Escape dismissal internally.
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!openMenuId) return
const onDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpenMenuId(null)
}
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenMenuId(null)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [openMenuId])
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
@@ -217,7 +208,7 @@ export function HeapsPanel() {
className="flex items-center gap-1 px-2 py-0.5"
style={{ paddingLeft: '20px' }}
>
<input
<Input
autoFocus
type="text"
value={newName}
@@ -230,15 +221,16 @@ export function HeapsPanel() {
}
}}
placeholder="Heap name"
className="flex-1 rounded border border-border bg-bg px-2 py-0.5 text-[11px] text-text focus:border-primary focus:outline-none"
className="h-6 flex-1 bg-bg px-2 text-[11px]"
/>
<button
<Button
onClick={handleCreate}
disabled={!newName.trim() || createMutation.isPending}
className="rounded bg-primary px-2 py-0.5 text-[11px] text-white hover:bg-primary/80 disabled:opacity-50"
size="sm"
className="h-6 px-2 text-[11px]"
>
Add
</button>
</Button>
</div>
)}
@@ -323,7 +315,7 @@ export function HeapsPanel() {
/>
{isRenaming ? (
<input
<Input
autoFocus
type="text"
value={renameDraft}
@@ -337,7 +329,7 @@ export function HeapsPanel() {
setRenamingId(null)
}
}}
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
className="h-6 flex-1 bg-bg px-1 text-[13px]"
/>
) : (
<>
@@ -389,93 +381,81 @@ export function HeapsPanel() {
{/* Kebab menu — collects rename / duplicate / convert /
* delete so the row stays compact. */}
<div
className={clsx(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
<DropdownMenu
open={isMenuOpen}
onOpenChange={(o) => setOpenMenuId(o ? heap.id : null)}
>
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(isMenuOpen ? null : heap.id)
}}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="More actions"
aria-label="More heap actions"
aria-haspopup="menu"
aria-expanded={isMenuOpen}
<div
className={clsx(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{isMenuOpen && (
<div
ref={menuRef}
role="menu"
className="absolute right-0 top-full z-30 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
onClick={(e) => e.stopPropagation()}
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="More actions"
aria-label="More heap actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="end"
className="min-w-[160px]"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onClick={() => {
setRenamingId(heap.id)
setRenameDraft(heap.name)
}}
>
<MenuItem
icon={<Pencil className="h-3.5 w-3.5" />}
label="Rename"
onClick={() => {
setOpenMenuId(null)
setRenamingId(heap.id)
setRenameDraft(heap.name)
}}
/>
<MenuItem
icon={<Copy className="h-3.5 w-3.5" />}
label="Duplicate"
onClick={() => {
setOpenMenuId(null)
duplicateMutation.mutate(heap.id)
}}
/>
<MenuItem
icon={<FolderOutput className="h-3.5 w-3.5" />}
label="Move to folder…"
onClick={() => {
setOpenMenuId(null)
setConvertingHeap(heap)
}}
/>
<MenuItem
icon={<Users className="h-3.5 w-3.5" />}
label="Share…"
onClick={() => {
setOpenMenuId(null)
setSharingHeap(heap)
}}
/>
<MenuItem
icon={<DownloadIcon className="h-3.5 w-3.5" />}
label="Download as zip"
onClick={() => {
setOpenMenuId(null)
downloads.trigger(downloads.heapUrl(heap.id))
}}
/>
<div className="my-1 h-px bg-border" />
<MenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete"
destructive
onClick={() => {
setOpenMenuId(null)
if (
confirm(
`Delete heap "${heap.name}"? Photos are not affected.`
)
) {
deleteMutation.mutate(heap.id)
}
}}
/>
</div>
)}
</div>
<Pencil className="h-3.5 w-3.5 text-text-muted" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => duplicateMutation.mutate(heap.id)}
>
<Copy className="h-3.5 w-3.5 text-text-muted" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setConvertingHeap(heap)}>
<FolderOutput className="h-3.5 w-3.5 text-text-muted" />
Move to folder
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSharingHeap(heap)}>
<Users className="h-3.5 w-3.5 text-text-muted" />
Share
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
downloads.trigger(downloads.heapUrl(heap.id))
}
>
<DownloadIcon className="h-3.5 w-3.5 text-text-muted" />
Download as zip
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
if (
confirm(
`Delete heap "${heap.name}"? Photos are not affected.`
)
) {
deleteMutation.mutate(heap.id)
}
}}
className="text-reject focus:bg-reject/10 focus:text-reject"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})}
@@ -543,30 +523,3 @@ export function HeapsPanel() {
)
}
function MenuItem({
icon,
label,
onClick,
destructive = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
destructive?: boolean
}) {
return (
<button
role="menuitem"
onClick={onClick}
className={clsx(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
destructive
? 'text-reject hover:bg-reject/10'
: 'text-text hover:bg-surface-2'
)}
>
<span className="text-text-muted">{icon}</span>
{label}
</button>
)
}