Skip to content

Commit a0304e6

Browse files
tindotdevclaude
andcommitted
feat(web): add shadcn/ui components and migrate UI patterns
- Install Badge, Card, Alert, Skeleton, Progress, Dialog, Collapsible, Tooltip, Command, Kbd, Sonner, Breadcrumb, Separator, Table components - Migrate BatchToast to Sonner toasts (delete BatchToast.tsx) - Add Command palette with Cmd+K shortcut for quick navigation - Convert bucket delete confirmation to Dialog overlay - Replace <details>/<summary> with Collapsible in CandidateList - Add Tooltips to BucketForm slug field and BucketList actions - Add TooltipProvider to global providers - Migrate inline badges/buttons to shadcn components across features 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4a58619 commit a0304e6

31 files changed

Lines changed: 1336 additions & 337 deletions

packages/web/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,30 @@
1717
"@dnd-kit/sortable": "^10.0.0",
1818
"@dnd-kit/utilities": "^3.2.2",
1919
"@microsoft/fetch-event-source": "^2.0.1",
20+
"@radix-ui/react-collapsible": "^1.1.12",
21+
"@radix-ui/react-dialog": "^1.1.15",
2022
"@radix-ui/react-dropdown-menu": "^2.1.16",
2123
"@radix-ui/react-label": "^2.1.8",
24+
"@radix-ui/react-progress": "^1.1.8",
2225
"@radix-ui/react-select": "^2.2.6",
26+
"@radix-ui/react-separator": "^1.1.8",
2327
"@radix-ui/react-slot": "^1.2.4",
28+
"@radix-ui/react-tooltip": "^1.2.8",
2429
"@tanstack/react-form": "^1.27.7",
2530
"@tanstack/react-query": "^5.90.12",
2631
"@tanstack/react-router": "^1.132.0",
2732
"better-auth": "^1.4.7",
2833
"class-variance-authority": "^0.7.1",
2934
"clsx": "^2.1.1",
35+
"cmdk": "^1.1.1",
3036
"filepond": "^4.32.10",
3137
"hono": "^4.11.1",
3238
"lucide-react": "^0.562.0",
39+
"next-themes": "^0.4.6",
3340
"react": "^19.2.0",
3441
"react-dom": "^19.2.0",
3542
"react-filepond": "^7.1.3",
43+
"sonner": "^2.0.7",
3644
"tailwind-merge": "^3.4.0",
3745
"valibot": "^1.2.0"
3846
},

packages/web/src/components/layouts/ProtectedLayout.tsx

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
2-
import { ChevronDown, Menu } from 'lucide-react';
3-
import { useEffect, useMemo } from 'react';
2+
import { ChevronDown, FolderOpen, Menu, Plus, Search, Settings } from 'lucide-react';
3+
import { useEffect, useMemo, useState } from 'react';
44
import { signOut, useAuth } from '@/features/auth';
55
import { useUserBuckets } from '@/features/settings';
66
import { CAPTURE_NAV, HEADER_NAV, NAV_LINKS } from '@/lib/navigation';
77
import { NavLink } from '../NavLink';
8+
import { Button } from '../ui/button';
9+
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '../ui/command';
810
import {
911
DropdownMenu,
1012
DropdownMenuContent,
@@ -13,11 +15,13 @@ import {
1315
DropdownMenuSeparator,
1416
DropdownMenuTrigger,
1517
} from '../ui/dropdown-menu';
18+
import { Kbd } from '../ui/kbd';
1619

1720
export function ProtectedLayout() {
1821
// Use AuthProvider (reactive); router context isn't reactive when RouterProvider context changes.
1922
const { data: session, isPending } = useAuth();
2023
const navigate = useNavigate();
24+
const [commandOpen, setCommandOpen] = useState(false);
2125

2226
const isAuthenticated = !!session;
2327
const email = session?.user?.email ?? null;
@@ -40,6 +44,18 @@ export function ProtectedLayout() {
4044
}
4145
}, [isPending, isAuthenticated, navigate]);
4246

47+
// Global Cmd+K keyboard shortcut
48+
useEffect(() => {
49+
const handleKeyDown = (e: KeyboardEvent) => {
50+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
51+
e.preventDefault();
52+
setCommandOpen((open) => !open);
53+
}
54+
};
55+
document.addEventListener('keydown', handleKeyDown);
56+
return () => document.removeEventListener('keydown', handleKeyDown);
57+
}, []);
58+
4359
// Show loading while auth is pending or redirecting
4460
if (isPending || !isAuthenticated) {
4561
return (
@@ -132,15 +148,16 @@ export function ProtectedLayout() {
132148
</div>
133149

134150
<div className="flex items-center gap-3">
151+
<Button variant="outline" size="sm" onClick={() => setCommandOpen(true)} className="hidden sm:flex items-center gap-2">
152+
<Search className="size-3.5" />
153+
<span>Search</span>
154+
<Kbd>⌘K</Kbd>
155+
</Button>
135156
<NavLink to={CAPTURE_NAV.to}>{CAPTURE_NAV.label}</NavLink>
136157
{shortEmail && <span className="hidden sm:inline text-xs text-zinc-500">{shortEmail}</span>}
137-
<button
138-
type="button"
139-
onClick={() => signOut()}
140-
className="rounded-md bg-zinc-900 px-3 py-2 text-sm text-zinc-200 hover:bg-zinc-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-200/40"
141-
>
158+
<Button variant="secondary" size="sm" onClick={() => signOut()}>
142159
Sign out
143-
</button>
160+
</Button>
144161
</div>
145162
</div>
146163
</header>
@@ -150,6 +167,62 @@ export function ProtectedLayout() {
150167
<Outlet />
151168
</div>
152169
</main>
170+
171+
{/* Command palette (Cmd+K) */}
172+
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
173+
<CommandInput placeholder="Type to search..." />
174+
<CommandList>
175+
<CommandEmpty>No results found.</CommandEmpty>
176+
<CommandGroup heading="Navigation">
177+
<CommandItem
178+
onSelect={() => {
179+
navigate({ to: '/' });
180+
setCommandOpen(false);
181+
}}
182+
>
183+
<Plus className="mr-2 size-4" />
184+
Capture
185+
</CommandItem>
186+
<CommandItem
187+
onSelect={() => {
188+
navigate({ to: '/search' });
189+
setCommandOpen(false);
190+
}}
191+
>
192+
<Search className="mr-2 size-4" />
193+
Search
194+
</CommandItem>
195+
<CommandItem
196+
onSelect={() => {
197+
navigate({ to: '/settings' });
198+
setCommandOpen(false);
199+
}}
200+
>
201+
<Settings className="mr-2 size-4" />
202+
Settings
203+
</CommandItem>
204+
</CommandGroup>
205+
{buckets.length > 0 && (
206+
<>
207+
<CommandSeparator />
208+
<CommandGroup heading="Buckets">
209+
{buckets.map((bucket) => (
210+
<CommandItem
211+
key={bucket.id}
212+
onSelect={() => {
213+
navigate({ to: '/bucket/$slug', params: { slug: bucket.slug } });
214+
setCommandOpen(false);
215+
}}
216+
>
217+
<FolderOpen className="mr-2 size-4" />
218+
{bucket.name}
219+
</CommandItem>
220+
))}
221+
</CommandGroup>
222+
</>
223+
)}
224+
</CommandList>
225+
</CommandDialog>
153226
</div>
154227
);
155228
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { cva, type VariantProps } from 'class-variance-authority';
2+
import type * as React from 'react';
3+
4+
import { cn } from '@/lib/utils';
5+
6+
const alertVariants = cva(
7+
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
8+
{
9+
variants: {
10+
variant: {
11+
default: 'bg-card text-card-foreground',
12+
destructive: 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
13+
},
14+
},
15+
defaultVariants: {
16+
variant: 'default',
17+
},
18+
}
19+
);
20+
21+
function Alert({ className, variant, ...props }: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
22+
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />;
23+
}
24+
25+
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
26+
return <div data-slot="alert-title" className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)} {...props} />;
27+
}
28+
29+
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
30+
return (
31+
<div
32+
data-slot="alert-description"
33+
className={cn('text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', className)}
34+
{...props}
35+
/>
36+
);
37+
}
38+
39+
export { Alert, AlertTitle, AlertDescription };
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Slot } from '@radix-ui/react-slot';
2+
import { cva, type VariantProps } from 'class-variance-authority';
3+
import type * as React from 'react';
4+
5+
import { cn } from '@/lib/utils';
6+
7+
const badgeVariants = cva(
8+
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
9+
{
10+
variants: {
11+
variant: {
12+
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
13+
secondary: 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
14+
destructive:
15+
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
16+
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
17+
success: 'border-transparent bg-green-900/50 text-green-300 [a&]:hover:bg-green-900/70',
18+
warning: 'border-transparent bg-yellow-900/50 text-yellow-300 [a&]:hover:bg-yellow-900/70',
19+
info: 'border-transparent bg-blue-900/50 text-blue-300 [a&]:hover:bg-blue-900/70',
20+
},
21+
},
22+
defaultVariants: {
23+
variant: 'default',
24+
},
25+
}
26+
);
27+
28+
function Badge({
29+
className,
30+
variant,
31+
asChild = false,
32+
...props
33+
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
34+
const Comp = asChild ? Slot : 'span';
35+
36+
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
37+
}
38+
39+
export { Badge, badgeVariants };
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Slot } from '@radix-ui/react-slot';
2+
import { ChevronRight, MoreHorizontal } from 'lucide-react';
3+
import type * as React from 'react';
4+
5+
import { cn } from '@/lib/utils';
6+
7+
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
8+
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
9+
}
10+
11+
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
12+
return (
13+
<ol
14+
data-slot="breadcrumb-list"
15+
className={cn('text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5', className)}
16+
{...props}
17+
/>
18+
);
19+
}
20+
21+
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
22+
return <li data-slot="breadcrumb-item" className={cn('inline-flex items-center gap-1.5', className)} {...props} />;
23+
}
24+
25+
function BreadcrumbLink({
26+
asChild,
27+
className,
28+
...props
29+
}: React.ComponentProps<'a'> & {
30+
asChild?: boolean;
31+
}) {
32+
const Comp = asChild ? Slot : 'a';
33+
34+
return <Comp data-slot="breadcrumb-link" className={cn('hover:text-foreground transition-colors', className)} {...props} />;
35+
}
36+
37+
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
38+
return <span data-slot="breadcrumb-page" aria-current="page" className={cn('text-foreground font-normal', className)} {...props} />;
39+
}
40+
41+
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<'li'>) {
42+
return (
43+
<li data-slot="breadcrumb-separator" role="presentation" aria-hidden="true" className={cn('[&>svg]:size-3.5', className)} {...props}>
44+
{children ?? <ChevronRight />}
45+
</li>
46+
);
47+
}
48+
49+
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
50+
return (
51+
<span
52+
data-slot="breadcrumb-ellipsis"
53+
role="presentation"
54+
aria-hidden="true"
55+
className={cn('flex size-9 items-center justify-center', className)}
56+
{...props}
57+
>
58+
<MoreHorizontal className="size-4" />
59+
<span className="sr-only">More</span>
60+
</span>
61+
);
62+
}
63+
64+
export { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis };
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type * as React from 'react';
2+
3+
import { cn } from '@/lib/utils';
4+
5+
function Card({ className, ...props }: React.ComponentProps<'div'>) {
6+
return (
7+
<div
8+
data-slot="card"
9+
className={cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', className)}
10+
{...props}
11+
/>
12+
);
13+
}
14+
15+
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
16+
return (
17+
<div
18+
data-slot="card-header"
19+
className={cn(
20+
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
21+
className
22+
)}
23+
{...props}
24+
/>
25+
);
26+
}
27+
28+
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
29+
return <div data-slot="card-title" className={cn('leading-none font-semibold', className)} {...props} />;
30+
}
31+
32+
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
33+
return <div data-slot="card-description" className={cn('text-muted-foreground text-sm', className)} {...props} />;
34+
}
35+
36+
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
37+
return (
38+
<div data-slot="card-action" className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)} {...props} />
39+
);
40+
}
41+
42+
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
43+
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
44+
}
45+
46+
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
47+
return <div data-slot="card-footer" className={cn('flex items-center px-6 [.border-t]:pt-6', className)} {...props} />;
48+
}
49+
50+
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
2+
3+
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
4+
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
5+
}
6+
7+
function CollapsibleTrigger({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
8+
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
9+
}
10+
11+
function CollapsibleContent({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
12+
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
13+
}
14+
15+
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

0 commit comments

Comments
 (0)