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,9 +1,27 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Users, Trash2, X } from 'lucide-react'
import clsx from 'clsx'
import { Users, Trash2 } from 'lucide-react'
import { sharing, type ShareInfo } from '../../services/api'
import { SHARED_HEAPS_KEY, SHARED_FOLDERS_KEY } from '../../hooks/useSharingQueries'
import {
SHARED_HEAPS_KEY,
SHARED_FOLDERS_KEY,
} from '../../hooks/useSharingQueries'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
interface ShareDialogProps {
isOpen: boolean
@@ -13,7 +31,13 @@ interface ShareDialogProps {
onClose: () => void
}
export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: ShareDialogProps) {
export function ShareDialog({
isOpen,
type,
targetId,
targetName,
onClose,
}: ShareDialogProps) {
const [username, setUsername] = useState('')
const [permission, setPermission] = useState<'read' | 'write'>('read')
const [error, setError] = useState<string | null>(null)
@@ -40,7 +64,9 @@ export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: Sha
setPermission('read')
setError(null)
queryClient.invalidateQueries({ queryKey: sharesQueryKey })
queryClient.invalidateQueries({ queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY })
queryClient.invalidateQueries({
queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY,
})
},
onError: (err: any) => {
setError(err?.response?.data?.detail || 'Failed to share')
@@ -54,19 +80,12 @@ export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: Sha
: sharing.revokeFolderShare(targetId, shareId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: sharesQueryKey })
queryClient.invalidateQueries({ queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY })
queryClient.invalidateQueries({
queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY,
})
},
})
useEffect(() => {
if (!isOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, onClose])
useEffect(() => {
if (isOpen) {
setUsername('')
@@ -75,103 +94,102 @@ export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: Sha
}
}, [isOpen])
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-[420px] rounded-lg border border-border bg-surface p-5 shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-text-muted" />
<h2 className="text-base font-semibold text-text">
Share {type === 'heap' ? 'heap' : 'folder'}
</h2>
</div>
<button onClick={onClose} className="text-text-muted hover:text-text">
<X className="h-4 w-4" />
</button>
</div>
<Dialog open={isOpen} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[420px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-text-muted" />
Share {type === 'heap' ? 'heap' : 'folder'}
</DialogTitle>
</DialogHeader>
<div className="mb-3 text-sm text-text-muted">
Sharing <span className="font-medium text-text">{targetName}</span>
</div>
{/* Existing shares */}
{shares.length > 0 && (
<div className="mb-4 space-y-1.5">
{shares.map((share) => (
<div
key={share.id}
className="flex items-center justify-between rounded border border-border bg-surface-2 px-3 py-1.5 text-sm"
>
<div className="flex items-center gap-2">
<span className="font-medium text-text">{share.shared_with_username}</span>
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium uppercase text-text-muted">
{share.permission}
</span>
</div>
<button
onClick={() => revokeMutation.mutate(share.id)}
className="text-text-muted hover:text-reject"
title="Revoke access"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
{isLoading && <div className="mb-4 text-xs text-text-muted">Loading shares...</div>}
{/* Add new share */}
<form
onSubmit={(e) => {
e.preventDefault()
if (username.trim()) addMutation.mutate()
}}
className="space-y-3"
>
<div className="flex gap-2">
<input
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value)
setError(null)
}}
placeholder="Username"
className="flex-1 rounded border border-border bg-surface-2 px-3 py-1.5 text-sm text-text placeholder:text-text-muted focus:border-primary focus:outline-none"
autoFocus
/>
<select
value={permission}
onChange={(e) => setPermission(e.target.value as 'read' | 'write')}
className="rounded border border-border bg-surface-2 px-2 py-1.5 text-sm text-text"
>
<option value="read">Read</option>
<option value="write">Read + Write</option>
</select>
</div>
{error && <div className="text-xs text-reject">{error}</div>}
<div className="flex justify-end">
<button
type="submit"
disabled={!username.trim() || addMutation.isPending}
className={clsx(
'rounded px-3 py-1.5 text-sm font-medium text-white',
'bg-primary hover:bg-primary/80 disabled:opacity-50'
)}
>
{addMutation.isPending ? 'Sharing...' : 'Share'}
</button>
</div>
</form>
<div className="text-sm text-text-muted">
Sharing <span className="font-medium text-text">{targetName}</span>
</div>
</div>
</div>
{/* Existing shares */}
{shares.length > 0 && (
<div className="space-y-1.5">
{shares.map((share) => (
<div
key={share.id}
className="flex items-center justify-between rounded border border-border bg-surface-2 px-3 py-1.5 text-sm"
>
<div className="flex items-center gap-2">
<span className="font-medium text-text">
{share.shared_with_username}
</span>
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium uppercase text-text-muted">
{share.permission}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => revokeMutation.mutate(share.id)}
className="h-6 w-6 text-text-muted hover:text-reject"
title="Revoke access"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
{isLoading && (
<div className="text-xs text-text-muted">Loading shares...</div>
)}
<form
onSubmit={(e) => {
e.preventDefault()
if (username.trim()) addMutation.mutate()
}}
className="space-y-3"
>
<div className="flex gap-2">
<Input
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value)
setError(null)
}}
placeholder="Username"
className="flex-1"
autoFocus
/>
<Select
value={permission}
onValueChange={(v) => setPermission(v as 'read' | 'write')}
>
<SelectTrigger className="w-auto">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="read">Read</SelectItem>
<SelectItem value="write">Read + Write</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
disabled={!username.trim() || addMutation.isPending}
>
{addMutation.isPending ? 'Sharing...' : 'Share'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}