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,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>
)
}