create-top-secret-starter 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/index.js +117 -0
  2. package/package.json +33 -0
  3. package/templates/template-router/.env.example +1 -0
  4. package/templates/template-router/.oxfmtrc.json +13 -0
  5. package/templates/template-router/.oxlintrc.json +8 -0
  6. package/templates/template-router/_gitignore +39 -0
  7. package/templates/template-router/components.json +25 -0
  8. package/templates/template-router/index.html +24 -0
  9. package/templates/template-router/package.json +58 -0
  10. package/templates/template-router/public/favicon.svg +1 -0
  11. package/templates/template-router/src/api/auth/auth.test.ts +46 -0
  12. package/templates/template-router/src/api/auth/guards.ts +18 -0
  13. package/templates/template-router/src/api/auth/index.ts +46 -0
  14. package/templates/template-router/src/api/auth/refresh.test.ts +89 -0
  15. package/templates/template-router/src/api/auth/router-bridge.test.ts +74 -0
  16. package/templates/template-router/src/api/auth/router-bridge.ts +26 -0
  17. package/templates/template-router/src/api/auth/schema.ts +20 -0
  18. package/templates/template-router/src/api/auth/session-store.test.ts +101 -0
  19. package/templates/template-router/src/api/auth/session-store.ts +56 -0
  20. package/templates/template-router/src/api/index.ts +51 -0
  21. package/templates/template-router/src/components/status-page.tsx +68 -0
  22. package/templates/template-router/src/components/ui/button.tsx +58 -0
  23. package/templates/template-router/src/components/ui/dialog.tsx +136 -0
  24. package/templates/template-router/src/components/ui/empty.tsx +94 -0
  25. package/templates/template-router/src/components/ui/field.tsx +222 -0
  26. package/templates/template-router/src/components/ui/input.tsx +20 -0
  27. package/templates/template-router/src/components/ui/label.tsx +18 -0
  28. package/templates/template-router/src/components/ui/select.tsx +188 -0
  29. package/templates/template-router/src/components/ui/separator.tsx +21 -0
  30. package/templates/template-router/src/components/ui/skeleton.tsx +13 -0
  31. package/templates/template-router/src/components/ui/sonner.tsx +43 -0
  32. package/templates/template-router/src/components/ui/tooltip.tsx +52 -0
  33. package/templates/template-router/src/env.ts +20 -0
  34. package/templates/template-router/src/index.css +134 -0
  35. package/templates/template-router/src/lib/query-client.test.ts +82 -0
  36. package/templates/template-router/src/lib/query-client.ts +30 -0
  37. package/templates/template-router/src/lib/single-flight.test.ts +48 -0
  38. package/templates/template-router/src/lib/single-flight.ts +9 -0
  39. package/templates/template-router/src/lib/utils.ts +1 -0
  40. package/templates/template-router/src/main.tsx +26 -0
  41. package/templates/template-router/src/mocks/mock-server.ts +149 -0
  42. package/templates/template-router/src/providers/index.tsx +27 -0
  43. package/templates/template-router/src/providers/theme.test.tsx +68 -0
  44. package/templates/template-router/src/providers/theme.tsx +76 -0
  45. package/templates/template-router/src/routeTree.gen.ts +102 -0
  46. package/templates/template-router/src/router.tsx +29 -0
  47. package/templates/template-router/src/routes/-error.tsx +34 -0
  48. package/templates/template-router/src/routes/-not-found.tsx +27 -0
  49. package/templates/template-router/src/routes/__root.tsx +15 -0
  50. package/templates/template-router/src/routes/_authenticated/index.tsx +9 -0
  51. package/templates/template-router/src/routes/_authenticated.tsx +38 -0
  52. package/templates/template-router/src/routes/sign-in.tsx +100 -0
  53. package/templates/template-router/src/test/setup.ts +5 -0
  54. package/templates/template-router/tsconfig.app.json +29 -0
  55. package/templates/template-router/tsconfig.json +7 -0
  56. package/templates/template-router/tsconfig.node.json +23 -0
  57. package/templates/template-router/vite.config.ts +33 -0
@@ -0,0 +1,20 @@
1
+ import * as z from 'zod/mini'
2
+
3
+ export const TokensSchema = z.object({
4
+ accessToken: z.string(),
5
+ refreshToken: z.string()
6
+ })
7
+ export type Tokens = z.infer<typeof TokensSchema>
8
+
9
+ // TODO: match your backend's user shape
10
+ export const UserSchema = z.object({
11
+ id: z.string(),
12
+ email: z.email()
13
+ })
14
+ export type User = z.infer<typeof UserSchema>
15
+
16
+ export const SessionSchema = z.object({
17
+ ...TokensSchema.shape,
18
+ user: UserSchema
19
+ })
20
+ export type Session = z.infer<typeof SessionSchema>
@@ -0,0 +1,101 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import type { Session } from '@/api/auth/schema'
4
+
5
+ // session-store reads localStorage once at module load, so every case needs a
6
+ // fresh module instance seeded before the import.
7
+ const load = async () => {
8
+ vi.resetModules()
9
+ return (await import('@/api/auth/session-store')).sessionStore
10
+ }
11
+
12
+ const session: Session = {
13
+ user: { id: 'u_1', email: 'a@b.com' },
14
+ accessToken: 'access',
15
+ refreshToken: 'refresh'
16
+ }
17
+
18
+ beforeEach(() => localStorage.clear())
19
+
20
+ describe('reading persisted state', () => {
21
+ it('returns null for unparsable JSON instead of throwing at boot', async () => {
22
+ localStorage.setItem('session:v1', 'not json at all')
23
+ const store = await load()
24
+ expect(store.get()).toBeNull()
25
+ })
26
+
27
+ it('returns null when the shape no longer matches the schema', async () => {
28
+ // the shape a previous release wrote, or a renamed field
29
+ localStorage.setItem('session:v1', JSON.stringify({ token: 'access', user: { id: 'u_1' } }))
30
+ const store = await load()
31
+ expect(store.get()).toBeNull()
32
+ })
33
+ })
34
+
35
+ describe('cross-tab sync', () => {
36
+ it('adopts a session written by another tab', async () => {
37
+ const store = await load()
38
+ const listener = vi.fn()
39
+ store.subscribe(listener)
40
+
41
+ window.dispatchEvent(
42
+ new StorageEvent('storage', { key: 'session:v1', newValue: JSON.stringify(session) })
43
+ )
44
+
45
+ expect(store.get()?.user.id).toBe('u_1')
46
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'access' }))
47
+ })
48
+
49
+ it('drops the session when another tab signs out', async () => {
50
+ localStorage.setItem('session:v1', JSON.stringify(session))
51
+ const store = await load()
52
+
53
+ window.dispatchEvent(new StorageEvent('storage', { key: 'session:v1', newValue: null }))
54
+
55
+ expect(store.get()).toBeNull()
56
+ })
57
+
58
+ it('treats a corrupt cross-tab write as a sign-out, not a crash', async () => {
59
+ localStorage.setItem('session:v1', JSON.stringify(session))
60
+ const store = await load()
61
+
62
+ window.dispatchEvent(new StorageEvent('storage', { key: 'session:v1', newValue: '{{{' }))
63
+
64
+ expect(store.get()).toBeNull()
65
+ })
66
+
67
+ it('ignores writes to unrelated keys', async () => {
68
+ localStorage.setItem('session:v1', JSON.stringify(session))
69
+ const store = await load()
70
+
71
+ window.dispatchEvent(new StorageEvent('storage', { key: 'theme', newValue: 'dark' }))
72
+
73
+ expect(store.get()?.user.id).toBe('u_1')
74
+ })
75
+ })
76
+
77
+ describe('writing', () => {
78
+ it('refuses to update tokens with no active session', async () => {
79
+ const store = await load()
80
+ expect(() => store.updateTokens({ accessToken: 'a', refreshToken: 'r' })).toThrow(
81
+ /no active session/i
82
+ )
83
+ })
84
+
85
+ it('keeps the user when rotating tokens', async () => {
86
+ const store = await load()
87
+ store.set(session)
88
+ store.updateTokens({ accessToken: 'a2', refreshToken: 'r2' })
89
+
90
+ expect(store.get()).toEqual({ ...session, accessToken: 'a2', refreshToken: 'r2' })
91
+ expect(JSON.parse(localStorage.getItem('session:v1')!).accessToken).toBe('a2')
92
+ })
93
+
94
+ it('removes the key on clear so a reload starts signed out', async () => {
95
+ const store = await load()
96
+ store.set(session)
97
+ store.clear()
98
+
99
+ expect(localStorage.getItem('session:v1')).toBeNull()
100
+ })
101
+ })
@@ -0,0 +1,56 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ import { SessionSchema, type Session, type Tokens } from '@/api/auth/schema'
4
+
5
+ // Versioned: bumping the suffix discards every persisted session at once, which is
6
+ // what you want after changing the shape or revoking tokens. Reading is already
7
+ // shape-safe via zod, so this is about deliberate invalidation, not crash safety.
8
+ const KEY = 'session:v1'
9
+
10
+ type Listener = (session: Session | null) => void
11
+
12
+ const parseRaw = (raw: string | null): Session | null => {
13
+ try {
14
+ return raw ? SessionSchema.parse(JSON.parse(raw)) : null
15
+ } catch {
16
+ return null
17
+ }
18
+ }
19
+
20
+ let current = parseRaw(localStorage.getItem(KEY))
21
+ const listeners = new Set<Listener>()
22
+
23
+ const notify = () => listeners.forEach(l => l(current))
24
+
25
+ const commit = (next: Session | null) => {
26
+ current = next
27
+ if (next) localStorage.setItem(KEY, JSON.stringify(next))
28
+ else localStorage.removeItem(KEY)
29
+ notify()
30
+ }
31
+
32
+ window.addEventListener('storage', e => {
33
+ if (e.key !== KEY) return
34
+ current = parseRaw(e.newValue)
35
+ notify()
36
+ })
37
+
38
+ export const sessionStore = {
39
+ get: () => current,
40
+ set: (next: Session) => commit(next),
41
+ clear: () => commit(null),
42
+ updateTokens: (tokens: Tokens) => {
43
+ if (!current) throw new Error('Cannot update tokens: no active session')
44
+ commit({ ...current, ...tokens })
45
+ },
46
+ subscribe: (listener: Listener) => {
47
+ listeners.add(listener)
48
+ return () => {
49
+ listeners.delete(listener)
50
+ }
51
+ }
52
+ }
53
+
54
+ // The React binding of the store's subscribe contract lives with the store: one
55
+ // concept, one file. Components use this; route guards use @/api/auth/guards.
56
+ export const useSession = () => useSyncExternalStore(sessionStore.subscribe, sessionStore.get)
@@ -0,0 +1,51 @@
1
+ import ky from 'ky'
2
+
3
+ import { TokensSchema } from '@/api/auth/schema'
4
+ import { sessionStore } from '@/api/auth/session-store'
5
+ import { env } from '@/env'
6
+ import { singleFlight } from '@/lib/single-flight'
7
+
8
+ const baseUrl = env.VITE_API_URL
9
+
10
+ const authClient = ky.create({ baseUrl })
11
+
12
+ const refreshTokens = singleFlight(async () => {
13
+ const session = sessionStore.get()
14
+ if (!session) throw new Error('No session to refresh')
15
+
16
+ try {
17
+ const tokens = await authClient
18
+ .post('auth/refresh', { json: { refreshToken: session.refreshToken } })
19
+ .json()
20
+ .then(data => TokensSchema.parse(data))
21
+ sessionStore.updateTokens(tokens)
22
+ return tokens
23
+ } catch (error) {
24
+ sessionStore.clear()
25
+ throw error
26
+ }
27
+ })
28
+
29
+ export const api = ky.create({
30
+ baseUrl,
31
+ hooks: {
32
+ beforeRequest: [
33
+ ({ request }) => {
34
+ const token = sessionStore.get()?.accessToken
35
+ if (token) request.headers.set('Authorization', `Bearer ${token}`)
36
+ }
37
+ ],
38
+ afterResponse: [
39
+ async ({ request, response, retryCount }) => {
40
+ if (response.status !== 401 || retryCount > 0) return
41
+
42
+ try {
43
+ const tokens = await refreshTokens()
44
+ const headers = new Headers(request.headers)
45
+ headers.set('Authorization', `Bearer ${tokens.accessToken}`)
46
+ return ky.retry({ request: new Request(request, { headers }), code: 'TOKEN_REFRESHED' })
47
+ } catch {}
48
+ }
49
+ ]
50
+ }
51
+ })
@@ -0,0 +1,68 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { VariantProps } from 'class-variance-authority'
3
+ import { Link } from '@tanstack/react-router'
4
+ import { House } from 'lucide-react'
5
+
6
+ import { buttonVariants } from '@/components/ui/button'
7
+ import {
8
+ Empty,
9
+ EmptyContent,
10
+ EmptyDescription,
11
+ EmptyHeader,
12
+ EmptyMedia,
13
+ EmptyTitle
14
+ } from '@/components/ui/empty'
15
+
16
+ type StatusPageProps = {
17
+ code: string
18
+ icon: ReactNode
19
+ mediaClassName?: string
20
+ title: string
21
+ description: string
22
+ actions: ReactNode
23
+ children?: ReactNode
24
+ }
25
+
26
+ export const StatusPage = ({
27
+ code,
28
+ icon,
29
+ mediaClassName,
30
+ title,
31
+ description,
32
+ actions,
33
+ children
34
+ }: StatusPageProps) => (
35
+ <main className='flex min-h-svh items-center justify-center p-6'>
36
+ <Empty className='max-w-md border-none'>
37
+ <EmptyHeader>
38
+ <EmptyMedia variant='icon' className={mediaClassName}>
39
+ {icon}
40
+ </EmptyMedia>
41
+ <span className='text-muted-foreground font-mono text-xs tracking-widest tabular-nums'>
42
+ {code}
43
+ </span>
44
+ <EmptyTitle>{title}</EmptyTitle>
45
+ <EmptyDescription>{description}</EmptyDescription>
46
+ </EmptyHeader>
47
+ <EmptyContent>
48
+ <div className='grid grid-cols-2 gap-2'>{actions}</div>
49
+ {children}
50
+ </EmptyContent>
51
+ </Empty>
52
+ </main>
53
+ )
54
+
55
+ // A styled Link, not a Button rendering a Link: Base UI's Button applies
56
+ // role="button", which overrides the anchor's link semantics. Assistive tech then
57
+ // announces an action where a navigation happens, and link affordances (open in a
58
+ // new tab, copy address, the screen reader's list of links) disappear.
59
+ export const BackHomeButton = ({
60
+ variant = 'default'
61
+ }: {
62
+ variant?: VariantProps<typeof buttonVariants>['variant']
63
+ }) => (
64
+ <Link to='/' className={buttonVariants({ variant, size: 'default' })}>
65
+ <House data-icon='inline-start' />
66
+ Back home
67
+ </Link>
68
+ )
@@ -0,0 +1,58 @@
1
+ import { Button as ButtonPrimitive } from '@base-ui/react/button'
2
+ import { cva, type VariantProps } from 'class-variance-authority'
3
+
4
+ import { cn } from '@/lib/utils'
5
+
6
+ const buttonVariants = cva(
7
+ "group/button focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: 'bg-primary text-primary-foreground hover:bg-primary/80',
12
+ outline:
13
+ 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
14
+ secondary:
15
+ 'bg-secondary text-secondary-foreground aria-expanded:bg-secondary aria-expanded:text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
16
+ ghost:
17
+ 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
18
+ destructive:
19
+ 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
20
+ link: 'text-primary underline-offset-4 hover:underline'
21
+ },
22
+ size: {
23
+ default:
24
+ 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
25
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
26
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
27
+ lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
28
+ icon: 'size-8',
29
+ 'icon-xs':
30
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
31
+ 'icon-sm':
32
+ 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
33
+ 'icon-lg': 'size-9'
34
+ }
35
+ },
36
+ defaultVariants: {
37
+ variant: 'default',
38
+ size: 'default'
39
+ }
40
+ }
41
+ )
42
+
43
+ const Button = ({
44
+ className,
45
+ variant = 'default',
46
+ size = 'default',
47
+ ...props
48
+ }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) => {
49
+ return (
50
+ <ButtonPrimitive
51
+ data-slot='button'
52
+ className={cn(buttonVariants({ variant, size, className }))}
53
+ {...props}
54
+ />
55
+ )
56
+ }
57
+
58
+ export { Button, buttonVariants }
@@ -0,0 +1,136 @@
1
+ import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
2
+ import * as React from 'react'
3
+
4
+ import { Button } from '@/components/ui/button'
5
+ import { cn } from '@/lib/utils'
6
+ import { XIcon } from 'lucide-react'
7
+
8
+ const Dialog = ({ ...props }: DialogPrimitive.Root.Props) => {
9
+ return <DialogPrimitive.Root data-slot='dialog' {...props} />
10
+ }
11
+
12
+ const DialogTrigger = ({ ...props }: DialogPrimitive.Trigger.Props) => {
13
+ return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />
14
+ }
15
+
16
+ const DialogPortal = ({ ...props }: DialogPrimitive.Portal.Props) => {
17
+ return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />
18
+ }
19
+
20
+ const DialogClose = ({ ...props }: DialogPrimitive.Close.Props) => {
21
+ return <DialogPrimitive.Close data-slot='dialog-close' {...props} />
22
+ }
23
+
24
+ const DialogOverlay = ({ className, ...props }: DialogPrimitive.Backdrop.Props) => {
25
+ return (
26
+ <DialogPrimitive.Backdrop
27
+ data-slot='dialog-overlay'
28
+ className={cn(
29
+ 'data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs',
30
+ className
31
+ )}
32
+ {...props}
33
+ />
34
+ )
35
+ }
36
+
37
+ const DialogContent = ({
38
+ className,
39
+ children,
40
+ showCloseButton = true,
41
+ ...props
42
+ }: DialogPrimitive.Popup.Props & {
43
+ showCloseButton?: boolean
44
+ }) => {
45
+ return (
46
+ <DialogPortal>
47
+ <DialogOverlay />
48
+ <DialogPrimitive.Popup
49
+ data-slot='dialog-content'
50
+ className={cn(
51
+ 'bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 text-sm ring-1 duration-100 outline-none sm:max-w-sm',
52
+ className
53
+ )}
54
+ {...props}
55
+ >
56
+ {children}
57
+ {showCloseButton && (
58
+ <DialogPrimitive.Close
59
+ data-slot='dialog-close'
60
+ render={<Button variant='ghost' className='absolute top-2 right-2' size='icon-sm' />}
61
+ >
62
+ <XIcon />
63
+ <span className='sr-only'>Close</span>
64
+ </DialogPrimitive.Close>
65
+ )}
66
+ </DialogPrimitive.Popup>
67
+ </DialogPortal>
68
+ )
69
+ }
70
+
71
+ const DialogHeader = ({ className, ...props }: React.ComponentProps<'div'>) => {
72
+ return (
73
+ <div data-slot='dialog-header' className={cn('flex flex-col gap-2', className)} {...props} />
74
+ )
75
+ }
76
+
77
+ const DialogFooter = ({
78
+ className,
79
+ showCloseButton = false,
80
+ children,
81
+ ...props
82
+ }: React.ComponentProps<'div'> & {
83
+ showCloseButton?: boolean
84
+ }) => {
85
+ return (
86
+ <div
87
+ data-slot='dialog-footer'
88
+ className={cn(
89
+ 'bg-muted/50 -mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t p-4 sm:flex-row sm:justify-end',
90
+ className
91
+ )}
92
+ {...props}
93
+ >
94
+ {children}
95
+ {showCloseButton && (
96
+ <DialogPrimitive.Close render={<Button variant='outline' />}>Close</DialogPrimitive.Close>
97
+ )}
98
+ </div>
99
+ )
100
+ }
101
+
102
+ const DialogTitle = ({ className, ...props }: DialogPrimitive.Title.Props) => {
103
+ return (
104
+ <DialogPrimitive.Title
105
+ data-slot='dialog-title'
106
+ className={cn('font-heading text-base leading-none font-medium', className)}
107
+ {...props}
108
+ />
109
+ )
110
+ }
111
+
112
+ const DialogDescription = ({ className, ...props }: DialogPrimitive.Description.Props) => {
113
+ return (
114
+ <DialogPrimitive.Description
115
+ data-slot='dialog-description'
116
+ className={cn(
117
+ 'text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3',
118
+ className
119
+ )}
120
+ {...props}
121
+ />
122
+ )
123
+ }
124
+
125
+ export {
126
+ Dialog,
127
+ DialogClose,
128
+ DialogContent,
129
+ DialogDescription,
130
+ DialogFooter,
131
+ DialogHeader,
132
+ DialogOverlay,
133
+ DialogPortal,
134
+ DialogTitle,
135
+ DialogTrigger
136
+ }
@@ -0,0 +1,94 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority'
2
+
3
+ import { cn } from '@/lib/utils'
4
+
5
+ function Empty({ className, ...props }: React.ComponentProps<'div'>) {
6
+ return (
7
+ <div
8
+ data-slot='empty'
9
+ className={cn(
10
+ 'flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance',
11
+ className
12
+ )}
13
+ {...props}
14
+ />
15
+ )
16
+ }
17
+
18
+ function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
19
+ return (
20
+ <div
21
+ data-slot='empty-header'
22
+ className={cn('flex max-w-sm flex-col items-center gap-2', className)}
23
+ {...props}
24
+ />
25
+ )
26
+ }
27
+
28
+ const emptyMediaVariants = cva(
29
+ 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
30
+ {
31
+ variants: {
32
+ variant: {
33
+ default: 'bg-transparent',
34
+ icon: "bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-4"
35
+ }
36
+ },
37
+ defaultVariants: {
38
+ variant: 'default'
39
+ }
40
+ }
41
+ )
42
+
43
+ function EmptyMedia({
44
+ className,
45
+ variant = 'default',
46
+ ...props
47
+ }: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
48
+ return (
49
+ <div
50
+ data-slot='empty-icon'
51
+ data-variant={variant}
52
+ className={cn(emptyMediaVariants({ variant, className }))}
53
+ {...props}
54
+ />
55
+ )
56
+ }
57
+
58
+ function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
59
+ return (
60
+ <div
61
+ data-slot='empty-title'
62
+ className={cn('font-heading text-sm font-medium tracking-tight', className)}
63
+ {...props}
64
+ />
65
+ )
66
+ }
67
+
68
+ function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
69
+ return (
70
+ <div
71
+ data-slot='empty-description'
72
+ className={cn(
73
+ 'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
74
+ className
75
+ )}
76
+ {...props}
77
+ />
78
+ )
79
+ }
80
+
81
+ function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
82
+ return (
83
+ <div
84
+ data-slot='empty-content'
85
+ className={cn(
86
+ 'flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance',
87
+ className
88
+ )}
89
+ {...props}
90
+ />
91
+ )
92
+ }
93
+
94
+ export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia }