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.
- package/index.js +117 -0
- package/package.json +33 -0
- package/templates/template-router/.env.example +1 -0
- package/templates/template-router/.oxfmtrc.json +13 -0
- package/templates/template-router/.oxlintrc.json +8 -0
- package/templates/template-router/_gitignore +39 -0
- package/templates/template-router/components.json +25 -0
- package/templates/template-router/index.html +24 -0
- package/templates/template-router/package.json +58 -0
- package/templates/template-router/public/favicon.svg +1 -0
- package/templates/template-router/src/api/auth/auth.test.ts +46 -0
- package/templates/template-router/src/api/auth/guards.ts +18 -0
- package/templates/template-router/src/api/auth/index.ts +46 -0
- package/templates/template-router/src/api/auth/refresh.test.ts +89 -0
- package/templates/template-router/src/api/auth/router-bridge.test.ts +74 -0
- package/templates/template-router/src/api/auth/router-bridge.ts +26 -0
- package/templates/template-router/src/api/auth/schema.ts +20 -0
- package/templates/template-router/src/api/auth/session-store.test.ts +101 -0
- package/templates/template-router/src/api/auth/session-store.ts +56 -0
- package/templates/template-router/src/api/index.ts +51 -0
- package/templates/template-router/src/components/status-page.tsx +68 -0
- package/templates/template-router/src/components/ui/button.tsx +58 -0
- package/templates/template-router/src/components/ui/dialog.tsx +136 -0
- package/templates/template-router/src/components/ui/empty.tsx +94 -0
- package/templates/template-router/src/components/ui/field.tsx +222 -0
- package/templates/template-router/src/components/ui/input.tsx +20 -0
- package/templates/template-router/src/components/ui/label.tsx +18 -0
- package/templates/template-router/src/components/ui/select.tsx +188 -0
- package/templates/template-router/src/components/ui/separator.tsx +21 -0
- package/templates/template-router/src/components/ui/skeleton.tsx +13 -0
- package/templates/template-router/src/components/ui/sonner.tsx +43 -0
- package/templates/template-router/src/components/ui/tooltip.tsx +52 -0
- package/templates/template-router/src/env.ts +20 -0
- package/templates/template-router/src/index.css +134 -0
- package/templates/template-router/src/lib/query-client.test.ts +82 -0
- package/templates/template-router/src/lib/query-client.ts +30 -0
- package/templates/template-router/src/lib/single-flight.test.ts +48 -0
- package/templates/template-router/src/lib/single-flight.ts +9 -0
- package/templates/template-router/src/lib/utils.ts +1 -0
- package/templates/template-router/src/main.tsx +26 -0
- package/templates/template-router/src/mocks/mock-server.ts +149 -0
- package/templates/template-router/src/providers/index.tsx +27 -0
- package/templates/template-router/src/providers/theme.test.tsx +68 -0
- package/templates/template-router/src/providers/theme.tsx +76 -0
- package/templates/template-router/src/routeTree.gen.ts +102 -0
- package/templates/template-router/src/router.tsx +29 -0
- package/templates/template-router/src/routes/-error.tsx +34 -0
- package/templates/template-router/src/routes/-not-found.tsx +27 -0
- package/templates/template-router/src/routes/__root.tsx +15 -0
- package/templates/template-router/src/routes/_authenticated/index.tsx +9 -0
- package/templates/template-router/src/routes/_authenticated.tsx +38 -0
- package/templates/template-router/src/routes/sign-in.tsx +100 -0
- package/templates/template-router/src/test/setup.ts +5 -0
- package/templates/template-router/tsconfig.app.json +29 -0
- package/templates/template-router/tsconfig.json +7 -0
- package/templates/template-router/tsconfig.node.json +23 -0
- 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 }
|