create-top-secret-starter 0.14.0 → 0.15.0
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/features.json +0 -1
- package/index.js +1 -2
- package/package.json +1 -1
- package/templates/template-router/.oxlintrc.json +8 -18
- package/templates/template-router/e2e/keyboard.spec.ts +3 -3
- package/templates/template-router/src/components/app-shell.plain.tsx +5 -1
- package/templates/template-router/src/components/app-shell.tsx +5 -1
- package/templates/template-router/src/components/orders/orders-table.tsx +18 -16
- package/templates/template-router/src/components/skip-link.tsx +11 -0
- package/templates/template-router/src/components/theme-toggle.tsx +1 -1
- package/templates/template-router/src/components/ui/table.tsx +17 -2
- package/templates/template-router/src/components/ui/toast.tsx +24 -2
- package/templates/template-router/src/index.css +27 -3
- package/templates/template-router/src/routes/_authenticated/index.tsx +1 -1
- package/templates/template-router/src/routes/_authenticated/notes/$noteId.tsx +2 -1
- package/templates/template-router/src/routes/_authenticated/notes/index.tsx +41 -3
- package/templates/template-router/src/routes/_authenticated/orders/index.tsx +34 -1
- package/templates/template-start/.oxlintrc.json +8 -18
- package/templates/template-start/e2e/keyboard.spec.ts +3 -3
- package/templates/template-start/src/components/app-shell.plain.tsx +5 -1
- package/templates/template-start/src/components/app-shell.tsx +5 -1
- package/templates/template-start/src/components/orders/orders-table.tsx +18 -16
- package/templates/template-start/src/components/skip-link.tsx +11 -0
- package/templates/template-start/src/components/theme-toggle.tsx +1 -1
- package/templates/template-start/src/components/ui/table.tsx +17 -2
- package/templates/template-start/src/components/ui/toast.tsx +24 -2
- package/templates/template-start/src/index.css +27 -3
- package/templates/template-start/src/routes/_authenticated/index.tsx +1 -1
- package/templates/template-start/src/routes/_authenticated/notes/$noteId.tsx +2 -1
- package/templates/template-start/src/routes/_authenticated/notes/index.tsx +41 -3
- package/templates/template-start/src/routes/_authenticated/orders/index.tsx +34 -1
package/features.json
CHANGED
package/index.js
CHANGED
|
@@ -248,11 +248,10 @@ const substitute = (file, pairs) =>
|
|
|
248
248
|
return text.replaceAll(from, to)
|
|
249
249
|
}, source)
|
|
250
250
|
)
|
|
251
|
-
// the project name lands in the document title/description and the
|
|
251
|
+
// the project name lands in the document title/description and the sidebar brand
|
|
252
252
|
const nameTargets = base => [
|
|
253
253
|
path.join(base, 'index.html'),
|
|
254
254
|
path.join(base, 'src', 'site.ts'),
|
|
255
|
-
path.join(base, 'src', 'routes', '_authenticated', 'index.tsx'),
|
|
256
255
|
path.join(base, 'src', 'components', 'app-sidebar.tsx')
|
|
257
256
|
]
|
|
258
257
|
const applyName = files => {
|
package/package.json
CHANGED
|
@@ -60,11 +60,11 @@
|
|
|
60
60
|
"error",
|
|
61
61
|
{
|
|
62
62
|
"name": "fetch",
|
|
63
|
-
"message": "Use the ky
|
|
63
|
+
"message": "Use the `api` ky instance from src/api: it owns the bearer header and the single-flight token refresh."
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"name": "localStorage",
|
|
67
|
-
"message": "
|
|
67
|
+
"message": "Persisted state goes through createStorageStore (src/lib/storage-store.ts): it owns decoding and cross-tab sync."
|
|
68
68
|
}
|
|
69
69
|
],
|
|
70
70
|
"no-restricted-imports": [
|
|
@@ -73,12 +73,12 @@
|
|
|
73
73
|
"paths": [
|
|
74
74
|
{
|
|
75
75
|
"name": "zod",
|
|
76
|
-
"message": "Import from zod/mini. For forms use zodResolver
|
|
76
|
+
"message": "Import from 'zod/mini'. Mixing the two ships both builds (+15.8 kB gzip). For forms use zodResolver (typed against zod/v4/core, works with mini)."
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
"name": "react",
|
|
80
80
|
"importNames": ["useMemo", "useCallback"],
|
|
81
|
-
"message": "React Compiler is
|
|
81
|
+
"message": "React Compiler is on: manual memoization is noise."
|
|
82
82
|
}
|
|
83
83
|
]
|
|
84
84
|
}
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"error",
|
|
93
93
|
{
|
|
94
94
|
"name": "localStorage",
|
|
95
|
-
"message": "
|
|
95
|
+
"message": "Persisted state goes through createStorageStore (src/lib/storage-store.ts): it owns decoding and cross-tab sync."
|
|
96
96
|
}
|
|
97
97
|
]
|
|
98
98
|
}
|
|
@@ -104,13 +104,13 @@
|
|
|
104
104
|
"error",
|
|
105
105
|
{
|
|
106
106
|
"name": "fetch",
|
|
107
|
-
"message": "Use the ky
|
|
107
|
+
"message": "Use the `api` ky instance from src/api: it owns the bearer header and the single-flight token refresh."
|
|
108
108
|
}
|
|
109
109
|
]
|
|
110
110
|
}
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
|
-
"files": ["src/api
|
|
113
|
+
"files": ["src/api/**/*.test.ts"],
|
|
114
114
|
"rules": {
|
|
115
115
|
"no-restricted-globals": "off"
|
|
116
116
|
}
|
|
@@ -118,17 +118,7 @@
|
|
|
118
118
|
{
|
|
119
119
|
"files": ["src/components/ui/**"],
|
|
120
120
|
"rules": {
|
|
121
|
-
"no-restricted-imports":
|
|
122
|
-
"error",
|
|
123
|
-
{
|
|
124
|
-
"paths": [
|
|
125
|
-
{
|
|
126
|
-
"name": "zod",
|
|
127
|
-
"message": "Import from zod/mini. For forms use zodResolver; it works with mini."
|
|
128
|
-
}
|
|
129
|
-
]
|
|
130
|
-
}
|
|
131
|
-
]
|
|
121
|
+
"no-restricted-imports": "off"
|
|
132
122
|
}
|
|
133
123
|
}
|
|
134
124
|
]
|
|
@@ -55,7 +55,7 @@ test('signing out is reachable by keyboard from a signed-in shell', async ({ pag
|
|
|
55
55
|
)
|
|
56
56
|
.map(el => el.textContent.trim() || (el.getAttribute('aria-label') ?? ''))
|
|
57
57
|
)
|
|
58
|
-
const order = ['Home', 'Notes', '
|
|
58
|
+
const order = ['Home', 'Notes', 'Switch to', 'Sign out'].map(name =>
|
|
59
59
|
tabbables.findIndex(text => text.startsWith(name))
|
|
60
60
|
)
|
|
61
61
|
expect(order.every(index => index >= 0)).toBe(true)
|
|
@@ -73,12 +73,12 @@ test('signing out is reachable by keyboard from a signed-in shell', async ({ pag
|
|
|
73
73
|
|
|
74
74
|
test('a button activates with Space', async ({ page }) => {
|
|
75
75
|
await signIn(page)
|
|
76
|
-
const toggle = page.getByRole('button', { name: '
|
|
76
|
+
const toggle = page.getByRole('button', { name: 'Switch to light theme' })
|
|
77
77
|
|
|
78
78
|
await toggle.focus()
|
|
79
79
|
await page.keyboard.press('Space')
|
|
80
80
|
|
|
81
|
-
await expect(page.getByRole('button', { name: '
|
|
81
|
+
await expect(page.getByRole('button', { name: 'Switch to dark theme' })).toBeFocused()
|
|
82
82
|
})
|
|
83
83
|
|
|
84
84
|
test('the back-home link activates with Enter', async ({ page }) => {
|
|
@@ -3,11 +3,13 @@ import type { PropsWithChildren } from 'react'
|
|
|
3
3
|
|
|
4
4
|
import { HeaderActions } from '@/components/header-actions'
|
|
5
5
|
import { NAV } from '@/components/nav'
|
|
6
|
+
import { MAIN_CONTENT_ID, SkipLink } from '@/components/skip-link'
|
|
6
7
|
|
|
7
8
|
// header-only shell: `create-cli` renames this over `app-shell.tsx` when the sidebar is off
|
|
8
9
|
export const AppShell = ({ children }: PropsWithChildren) => {
|
|
9
10
|
return (
|
|
10
11
|
<>
|
|
12
|
+
<SkipLink />
|
|
11
13
|
<header className='flex h-14 items-center gap-4 border-b px-6'>
|
|
12
14
|
<nav aria-label='Main' className='flex items-center gap-4 text-sm'>
|
|
13
15
|
{NAV.map(item => (
|
|
@@ -23,7 +25,9 @@ export const AppShell = ({ children }: PropsWithChildren) => {
|
|
|
23
25
|
</nav>
|
|
24
26
|
<HeaderActions />
|
|
25
27
|
</header>
|
|
26
|
-
<div className='flex flex-1 flex-col p-4 sm:p-6'>
|
|
28
|
+
<div id={MAIN_CONTENT_ID} tabIndex={-1} className='flex flex-1 flex-col p-4 sm:p-6'>
|
|
29
|
+
{children}
|
|
30
|
+
</div>
|
|
27
31
|
</>
|
|
28
32
|
)
|
|
29
33
|
}
|
|
@@ -2,6 +2,7 @@ import type { PropsWithChildren } from 'react'
|
|
|
2
2
|
|
|
3
3
|
import { AppSidebar } from '@/components/app-sidebar'
|
|
4
4
|
import { HeaderActions } from '@/components/header-actions'
|
|
5
|
+
import { MAIN_CONTENT_ID, SkipLink } from '@/components/skip-link'
|
|
5
6
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
|
6
7
|
import { sidebarStore, useSidebarOpen } from '@/lib/sidebar-store'
|
|
7
8
|
|
|
@@ -11,13 +12,16 @@ export const AppShell = ({ children }: PropsWithChildren) => {
|
|
|
11
12
|
|
|
12
13
|
return (
|
|
13
14
|
<SidebarProvider open={open} onOpenChange={sidebarStore.set}>
|
|
15
|
+
<SkipLink />
|
|
14
16
|
<AppSidebar />
|
|
15
17
|
<SidebarInset className='min-w-0'>
|
|
16
18
|
<header className='flex h-14 items-center gap-2 border-b px-4'>
|
|
17
19
|
<SidebarTrigger />
|
|
18
20
|
<HeaderActions />
|
|
19
21
|
</header>
|
|
20
|
-
<div className='flex flex-1 flex-col p-4 sm:p-6'>
|
|
22
|
+
<div id={MAIN_CONTENT_ID} tabIndex={-1} className='flex flex-1 flex-col p-4 sm:p-6'>
|
|
23
|
+
{children}
|
|
24
|
+
</div>
|
|
21
25
|
</SidebarInset>
|
|
22
26
|
</SidebarProvider>
|
|
23
27
|
)
|
|
@@ -53,7 +53,6 @@ import {
|
|
|
53
53
|
DropdownMenuSeparator,
|
|
54
54
|
DropdownMenuTrigger
|
|
55
55
|
} from '@/components/ui/dropdown-menu'
|
|
56
|
-
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'
|
|
57
56
|
import {
|
|
58
57
|
Table,
|
|
59
58
|
TableBody,
|
|
@@ -111,7 +110,11 @@ const columns = helper.columns([
|
|
|
111
110
|
header: 'Amount',
|
|
112
111
|
size: 130,
|
|
113
112
|
meta: { align: 'end' },
|
|
114
|
-
cell: ({ row }) =>
|
|
113
|
+
cell: ({ row }) => (
|
|
114
|
+
<span className='tabular-nums'>
|
|
115
|
+
{formatMoney(row.original.amountCents, row.original.currency)}
|
|
116
|
+
</span>
|
|
117
|
+
)
|
|
115
118
|
}),
|
|
116
119
|
helper.accessor('createdAt', {
|
|
117
120
|
header: 'Date',
|
|
@@ -181,10 +184,12 @@ type Props = {
|
|
|
181
184
|
sorting: OrdersSorting
|
|
182
185
|
onSortingChange: (next: OrdersSorting) => void
|
|
183
186
|
toolbar?: React.ReactNode
|
|
187
|
+
/** shown in place of the rows; the caller knows which filters produced the emptiness */
|
|
188
|
+
empty: React.ReactNode
|
|
184
189
|
busy?: boolean
|
|
185
190
|
}
|
|
186
191
|
|
|
187
|
-
export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }: Props) => {
|
|
192
|
+
export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, empty, busy }: Props) => {
|
|
188
193
|
const layout = useOrdersTableLayout()
|
|
189
194
|
const [dragging, setDragging] = useState<OrderColumn | null>(null)
|
|
190
195
|
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
|
@@ -280,11 +285,13 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
|
|
|
280
285
|
</DropdownMenu>
|
|
281
286
|
</div>
|
|
282
287
|
|
|
283
|
-
<div
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
+
<div className='transition-opacity aria-busy:opacity-60' aria-busy={busy ? true : undefined}>
|
|
289
|
+
<Table
|
|
290
|
+
label='Orders'
|
|
291
|
+
containerClassName='rounded-lg border'
|
|
292
|
+
className='table-fixed'
|
|
293
|
+
style={{ minWidth: table.getTotalSize() }}
|
|
294
|
+
>
|
|
288
295
|
<TableHeader>
|
|
289
296
|
{table.getHeaderGroups().map(group => (
|
|
290
297
|
<TableRow key={group.id}>
|
|
@@ -332,7 +339,7 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
|
|
|
332
339
|
<span
|
|
333
340
|
draggable
|
|
334
341
|
aria-hidden='true'
|
|
335
|
-
className='cursor-grab rounded-sm p-1 text-muted-foreground opacity-0 transition-opacity group-hover/head:opacity-100 active:cursor-grabbing'
|
|
342
|
+
className='cursor-grab rounded-sm p-1 text-muted-foreground opacity-0 transition-opacity group-hover/head:opacity-100 active:cursor-grabbing pointer-coarse:hidden'
|
|
336
343
|
onDragStart={event => {
|
|
337
344
|
event.dataTransfer.setData(DRAG_TYPE, column.id)
|
|
338
345
|
event.dataTransfer.effectAllowed = 'move'
|
|
@@ -367,7 +374,7 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
|
|
|
367
374
|
variant='ghost'
|
|
368
375
|
size='icon-xs'
|
|
369
376
|
className={cn(
|
|
370
|
-
'opacity-0 transition-opacity group-hover/head:opacity-100 focus-visible:opacity-100 data-open:opacity-100',
|
|
377
|
+
'opacity-0 transition-opacity group-hover/head:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 data-open:opacity-100',
|
|
371
378
|
align === 'end' ? 'mr-auto' : 'ml-auto'
|
|
372
379
|
)}
|
|
373
380
|
aria-label={`${labelOf(column)} column options`}
|
|
@@ -445,12 +452,7 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
|
|
|
445
452
|
{table.getRowModel().rows.length === 0 ? (
|
|
446
453
|
<TableRow>
|
|
447
454
|
<TableCell colSpan={table.getVisibleLeafColumns().length} className='p-0'>
|
|
448
|
-
|
|
449
|
-
<EmptyHeader>
|
|
450
|
-
<EmptyTitle>No orders match</EmptyTitle>
|
|
451
|
-
<EmptyDescription>Clear the search or pick another status.</EmptyDescription>
|
|
452
|
-
</EmptyHeader>
|
|
453
|
-
</Empty>
|
|
455
|
+
{empty}
|
|
454
456
|
</TableCell>
|
|
455
457
|
</TableRow>
|
|
456
458
|
) : (
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const MAIN_CONTENT_ID = 'main-content'
|
|
2
|
+
|
|
3
|
+
// the first focusable element on the page: the shell's nav sits between the top and the content
|
|
4
|
+
export const SkipLink = () => (
|
|
5
|
+
<a
|
|
6
|
+
href={`#${MAIN_CONTENT_ID}`}
|
|
7
|
+
className='sr-only z-50 rounded-lg bg-background px-3 py-2 text-sm font-medium ring-1 ring-border focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:ring-3 focus:ring-ring/50'
|
|
8
|
+
>
|
|
9
|
+
Skip to content
|
|
10
|
+
</a>
|
|
11
|
+
)
|
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import { cn } from 'cn'
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
type TableProps = React.ComponentProps<'table'> & {
|
|
5
|
+
/** names the scroll region; a keyboard user lands on it before the columns it hides */
|
|
6
|
+
label: string
|
|
7
|
+
containerClassName?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function Table({ className, label, containerClassName, ...props }: TableProps) {
|
|
5
11
|
return (
|
|
6
|
-
<div
|
|
12
|
+
<div
|
|
13
|
+
data-slot='table-container'
|
|
14
|
+
role='region'
|
|
15
|
+
aria-label={label}
|
|
16
|
+
tabIndex={0}
|
|
17
|
+
className={cn(
|
|
18
|
+
'relative w-full overflow-x-auto scroll-x-hint focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
|
19
|
+
containerClassName
|
|
20
|
+
)}
|
|
21
|
+
>
|
|
7
22
|
<table
|
|
8
23
|
data-slot='table'
|
|
9
24
|
className={cn('w-full caption-bottom text-sm', className)}
|
|
@@ -12,7 +12,29 @@ import {
|
|
|
12
12
|
Loader2Icon
|
|
13
13
|
} from 'lucide-react'
|
|
14
14
|
|
|
15
|
-
const
|
|
15
|
+
const manager = ToastPrimitive.createToastManager()
|
|
16
|
+
|
|
17
|
+
// an error, or an action like Undo, is the way back: it must not expire before it is read
|
|
18
|
+
const persist = <T,>(value: T) =>
|
|
19
|
+
typeof value === 'string' ? { title: value, timeout: 0 } : { ...value, timeout: 0 }
|
|
20
|
+
|
|
21
|
+
const toast: typeof manager = {
|
|
22
|
+
...manager,
|
|
23
|
+
add: options =>
|
|
24
|
+
manager.add({
|
|
25
|
+
timeout: options.type === 'error' || options.actionProps ? 0 : undefined,
|
|
26
|
+
...options
|
|
27
|
+
}),
|
|
28
|
+
// `promise` builds its own toasts, so its error branch never reaches `add`
|
|
29
|
+
promise: (value, options) => {
|
|
30
|
+
const { error } = options
|
|
31
|
+
return manager.promise(value, {
|
|
32
|
+
...options,
|
|
33
|
+
error:
|
|
34
|
+
typeof error === 'function' ? (reason: unknown) => persist(error(reason)) : persist(error)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
16
38
|
|
|
17
39
|
function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
|
|
18
40
|
return <ToastPrimitive.Provider {...props} />
|
|
@@ -152,7 +174,7 @@ function ToastIcon({ type }: { type: string | undefined }) {
|
|
|
152
174
|
}
|
|
153
175
|
|
|
154
176
|
if (type === 'loading') {
|
|
155
|
-
icon = <Loader2Icon className='animate-spin' aria-hidden='true' />
|
|
177
|
+
icon = <Loader2Icon className='motion-safe:animate-spin' aria-hidden='true' />
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
if (!icon) {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
--destructive: oklch(0.577 0.245 27.325);
|
|
67
67
|
--border: oklch(0.922 0 0);
|
|
68
68
|
--input: oklch(0.922 0 0);
|
|
69
|
-
--ring: oklch(0.
|
|
69
|
+
--ring: oklch(0.62 0 0);
|
|
70
70
|
--chart-1: oklch(0.87 0 0);
|
|
71
71
|
--chart-2: oklch(0.556 0 0);
|
|
72
72
|
--chart-3: oklch(0.439 0 0);
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
--sidebar-accent: oklch(0.97 0 0);
|
|
81
81
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
82
82
|
--sidebar-border: oklch(0.922 0 0);
|
|
83
|
-
--sidebar-ring: oklch(0.
|
|
83
|
+
--sidebar-ring: oklch(0.62 0 0);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
.dark {
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
color-scheme: dark;
|
|
132
132
|
}
|
|
133
133
|
body {
|
|
134
|
-
@apply bg-background text-foreground
|
|
134
|
+
@apply bg-background text-foreground antialiased;
|
|
135
135
|
}
|
|
136
136
|
h1,
|
|
137
137
|
h2,
|
|
@@ -151,4 +151,28 @@
|
|
|
151
151
|
appearance: none;
|
|
152
152
|
margin: 0;
|
|
153
153
|
}
|
|
154
|
+
/* covers the component library's enter/exit keyframes too, which no `motion-safe:` reaches */
|
|
155
|
+
@media (prefers-reduced-motion: reduce) {
|
|
156
|
+
*,
|
|
157
|
+
*::before,
|
|
158
|
+
*::after {
|
|
159
|
+
animation-duration: 0.01ms !important;
|
|
160
|
+
animation-iteration-count: 1 !important;
|
|
161
|
+
transition-duration: 0.01ms !important;
|
|
162
|
+
scroll-behavior: auto !important;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* edge shadows that appear only on the side with more to scroll: `local` gradients cover them at the ends */
|
|
168
|
+
@utility scroll-x-hint {
|
|
169
|
+
--shadow-edge: color-mix(in oklch, var(--foreground) 18%, transparent);
|
|
170
|
+
background:
|
|
171
|
+
linear-gradient(to right, var(--background), transparent) 0 0 / 24px 100% no-repeat local,
|
|
172
|
+
linear-gradient(to left, var(--background), transparent) 100% 0 / 24px 100% no-repeat local,
|
|
173
|
+
radial-gradient(farthest-side at 0 50%, var(--shadow-edge), transparent) 0 0 / 12px 100%
|
|
174
|
+
no-repeat scroll,
|
|
175
|
+
radial-gradient(farthest-side at 100% 50%, var(--shadow-edge), transparent) 100% 0 / 12px 100%
|
|
176
|
+
no-repeat scroll;
|
|
177
|
+
background-color: var(--background);
|
|
154
178
|
}
|
|
@@ -26,7 +26,7 @@ function HomePage() {
|
|
|
26
26
|
return (
|
|
27
27
|
<main className='flex flex-1 flex-col gap-6'>
|
|
28
28
|
<div className='flex flex-col gap-1'>
|
|
29
|
-
<h1 className='text-2xl font-semibold'>
|
|
29
|
+
<h1 className='text-2xl font-semibold'>Home</h1>
|
|
30
30
|
<p className='text-sm text-muted-foreground'>Signed in as {user.email}</p>
|
|
31
31
|
</div>
|
|
32
32
|
{/* realtime:start */}
|
|
@@ -4,6 +4,7 @@ import { ArrowLeft } from 'lucide-react'
|
|
|
4
4
|
import { isNotFound } from '@/api'
|
|
5
5
|
import { noteQuery, useNote } from '@/api/notes'
|
|
6
6
|
import { buttonVariants } from '@/components/ui/button'
|
|
7
|
+
import { formatDate } from '@/lib/format'
|
|
7
8
|
|
|
8
9
|
export const Route = createFileRoute('/_authenticated/notes/$noteId')({
|
|
9
10
|
loader: async ({ context, params }) => {
|
|
@@ -31,7 +32,7 @@ function NotePage() {
|
|
|
31
32
|
<header className='space-y-1'>
|
|
32
33
|
<h1 className='text-2xl font-semibold'>{note.title}</h1>
|
|
33
34
|
<p className='text-xs text-muted-foreground'>
|
|
34
|
-
<time dateTime={note.createdAt}>{
|
|
35
|
+
<time dateTime={note.createdAt}>{formatDate(note.createdAt)}</time>
|
|
35
36
|
</p>
|
|
36
37
|
</header>
|
|
37
38
|
{note.body ? (
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
2
|
import { Link, createFileRoute } from '@tanstack/react-router'
|
|
3
3
|
import { Trash2 } from 'lucide-react'
|
|
4
|
+
import { useState } from 'react'
|
|
4
5
|
import { useForm } from 'react-hook-form'
|
|
5
6
|
|
|
6
7
|
import { notesQuery, useCreateNote, useDeleteNote, useDeletingNoteIds, useNotes } from '@/api/notes'
|
|
7
|
-
import { CreateNoteSchema } from '@/api/notes/schema'
|
|
8
|
+
import { CreateNoteSchema, type Note } from '@/api/notes/schema'
|
|
8
9
|
import { Button } from '@/components/ui/button'
|
|
10
|
+
import {
|
|
11
|
+
Dialog,
|
|
12
|
+
DialogContent,
|
|
13
|
+
DialogDescription,
|
|
14
|
+
DialogFooter,
|
|
15
|
+
DialogHeader,
|
|
16
|
+
DialogTitle
|
|
17
|
+
} from '@/components/ui/dialog'
|
|
9
18
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'
|
|
10
19
|
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
|
11
20
|
import { Input } from '@/components/ui/input'
|
|
@@ -22,6 +31,9 @@ function NotesPage() {
|
|
|
22
31
|
const createNote = useCreateNote()
|
|
23
32
|
const deleteNote = useDeleteNote()
|
|
24
33
|
const deleting = new Set(useDeletingNoteIds())
|
|
34
|
+
// deleting a note is final, so it is confirmed rather than undone.
|
|
35
|
+
// the note outlives `open`: the dialog is still on screen while it animates out
|
|
36
|
+
const [confirm, setConfirm] = useState<{ note: Note; open: boolean } | null>(null)
|
|
25
37
|
const {
|
|
26
38
|
register,
|
|
27
39
|
handleSubmit,
|
|
@@ -33,6 +45,7 @@ function NotesPage() {
|
|
|
33
45
|
})
|
|
34
46
|
|
|
35
47
|
const onSubmit = handleSubmit(values => createNote.mutate(values, { onSuccess: () => reset() }))
|
|
48
|
+
const close = () => setConfirm(current => current && { ...current, open: false })
|
|
36
49
|
|
|
37
50
|
return (
|
|
38
51
|
<main className='mx-auto w-full max-w-md space-y-6'>
|
|
@@ -90,11 +103,11 @@ function NotesPage() {
|
|
|
90
103
|
{note.title}
|
|
91
104
|
</Link>
|
|
92
105
|
<Button
|
|
93
|
-
variant='
|
|
106
|
+
variant='destructive'
|
|
94
107
|
size='icon-sm'
|
|
95
108
|
aria-label={`Delete ${note.title}`}
|
|
96
109
|
disabled={deleting.has(note.id)}
|
|
97
|
-
onClick={() =>
|
|
110
|
+
onClick={() => setConfirm({ note, open: true })}
|
|
98
111
|
>
|
|
99
112
|
<Trash2 />
|
|
100
113
|
</Button>
|
|
@@ -102,6 +115,31 @@ function NotesPage() {
|
|
|
102
115
|
))}
|
|
103
116
|
</ul>
|
|
104
117
|
)}
|
|
118
|
+
|
|
119
|
+
<Dialog open={confirm?.open === true} onOpenChange={next => !next && close()}>
|
|
120
|
+
<DialogContent>
|
|
121
|
+
<DialogHeader>
|
|
122
|
+
<DialogTitle>Delete this note?</DialogTitle>
|
|
123
|
+
<DialogDescription>
|
|
124
|
+
“{confirm?.note.title}” will be removed for good. This cannot be undone.
|
|
125
|
+
</DialogDescription>
|
|
126
|
+
</DialogHeader>
|
|
127
|
+
<DialogFooter>
|
|
128
|
+
<Button variant='outline' onClick={close}>
|
|
129
|
+
Cancel
|
|
130
|
+
</Button>
|
|
131
|
+
<Button
|
|
132
|
+
variant='destructive'
|
|
133
|
+
onClick={() => {
|
|
134
|
+
if (confirm) deleteNote.mutate(confirm.note.id)
|
|
135
|
+
close()
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
Delete note
|
|
139
|
+
</Button>
|
|
140
|
+
</DialogFooter>
|
|
141
|
+
</DialogContent>
|
|
142
|
+
</Dialog>
|
|
105
143
|
</main>
|
|
106
144
|
)
|
|
107
145
|
}
|
|
@@ -18,7 +18,14 @@ import {
|
|
|
18
18
|
import { CreateOrderDialog } from '@/components/orders/create-order-dialog'
|
|
19
19
|
import { statusLabel } from '@/components/orders/order-status'
|
|
20
20
|
import { OrdersTable, type OrdersSorting } from '@/components/orders/orders-table'
|
|
21
|
-
import { buttonVariants } from '@/components/ui/button'
|
|
21
|
+
import { Button, buttonVariants } from '@/components/ui/button'
|
|
22
|
+
import {
|
|
23
|
+
Empty,
|
|
24
|
+
EmptyContent,
|
|
25
|
+
EmptyDescription,
|
|
26
|
+
EmptyHeader,
|
|
27
|
+
EmptyTitle
|
|
28
|
+
} from '@/components/ui/empty'
|
|
22
29
|
import { Input } from '@/components/ui/input'
|
|
23
30
|
import {
|
|
24
31
|
Select,
|
|
@@ -84,11 +91,36 @@ function OrdersPage() {
|
|
|
84
91
|
})
|
|
85
92
|
const onSorting = ({ sort, order }: OrdersSorting) =>
|
|
86
93
|
void navigate({ search: prev => ({ ...prev, sort, order, page: 1 }) })
|
|
94
|
+
const onClearFilters = () =>
|
|
95
|
+
void navigate({ search: prev => ({ ...prev, q: undefined, status: undefined, page: 1 }) })
|
|
87
96
|
const onPageSize = (value: string | null) =>
|
|
88
97
|
void navigate({
|
|
89
98
|
search: prev => ({ ...prev, pageSize: Number(value) as (typeof PAGE_SIZES)[number], page: 1 })
|
|
90
99
|
})
|
|
91
100
|
|
|
101
|
+
const filtered = Boolean(search.q || search.status)
|
|
102
|
+
const empty = (
|
|
103
|
+
<Empty className='border-none'>
|
|
104
|
+
<EmptyHeader>
|
|
105
|
+
<EmptyTitle>
|
|
106
|
+
{search.q ? `No orders match \u201C${search.q}\u201D` : 'No orders to show'}
|
|
107
|
+
</EmptyTitle>
|
|
108
|
+
<EmptyDescription>
|
|
109
|
+
{filtered ? 'Try another search or status.' : 'Create the first one to see it here.'}
|
|
110
|
+
</EmptyDescription>
|
|
111
|
+
</EmptyHeader>
|
|
112
|
+
<EmptyContent>
|
|
113
|
+
{filtered ? (
|
|
114
|
+
<Button variant='outline' onClick={onClearFilters}>
|
|
115
|
+
Clear filters
|
|
116
|
+
</Button>
|
|
117
|
+
) : (
|
|
118
|
+
<CreateOrderDialog />
|
|
119
|
+
)}
|
|
120
|
+
</EmptyContent>
|
|
121
|
+
</Empty>
|
|
122
|
+
)
|
|
123
|
+
|
|
92
124
|
return (
|
|
93
125
|
<main className='flex flex-1 flex-col gap-4'>
|
|
94
126
|
<div className='flex flex-wrap items-center justify-between gap-2'>
|
|
@@ -100,6 +132,7 @@ function OrdersPage() {
|
|
|
100
132
|
orders={data.items}
|
|
101
133
|
sorting={search}
|
|
102
134
|
onSortingChange={onSorting}
|
|
135
|
+
empty={empty}
|
|
103
136
|
busy={busy}
|
|
104
137
|
toolbar={
|
|
105
138
|
<>
|
|
@@ -60,11 +60,11 @@
|
|
|
60
60
|
"error",
|
|
61
61
|
{
|
|
62
62
|
"name": "fetch",
|
|
63
|
-
"message": "Use the ky
|
|
63
|
+
"message": "Use the `api` ky instance from src/api: it owns the bearer header and the single-flight token refresh."
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"name": "localStorage",
|
|
67
|
-
"message": "
|
|
67
|
+
"message": "Persisted state goes through createStorageStore (src/lib/storage-store.ts): it owns decoding and cross-tab sync."
|
|
68
68
|
}
|
|
69
69
|
],
|
|
70
70
|
"no-restricted-imports": [
|
|
@@ -73,12 +73,12 @@
|
|
|
73
73
|
"paths": [
|
|
74
74
|
{
|
|
75
75
|
"name": "zod",
|
|
76
|
-
"message": "Import from zod/mini. For forms use zodResolver
|
|
76
|
+
"message": "Import from 'zod/mini'. Mixing the two ships both builds (+15.8 kB gzip). For forms use zodResolver (typed against zod/v4/core, works with mini)."
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
"name": "react",
|
|
80
80
|
"importNames": ["useMemo", "useCallback"],
|
|
81
|
-
"message": "React Compiler is
|
|
81
|
+
"message": "React Compiler is on: manual memoization is noise."
|
|
82
82
|
}
|
|
83
83
|
]
|
|
84
84
|
}
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"error",
|
|
93
93
|
{
|
|
94
94
|
"name": "localStorage",
|
|
95
|
-
"message": "
|
|
95
|
+
"message": "Persisted state goes through createStorageStore (src/lib/storage-store.ts): it owns decoding and cross-tab sync."
|
|
96
96
|
}
|
|
97
97
|
]
|
|
98
98
|
}
|
|
@@ -104,13 +104,13 @@
|
|
|
104
104
|
"error",
|
|
105
105
|
{
|
|
106
106
|
"name": "fetch",
|
|
107
|
-
"message": "Use the ky
|
|
107
|
+
"message": "Use the `api` ky instance from src/api: it owns the bearer header and the single-flight token refresh."
|
|
108
108
|
}
|
|
109
109
|
]
|
|
110
110
|
}
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
|
-
"files": ["src/api
|
|
113
|
+
"files": ["src/api/**/*.test.ts"],
|
|
114
114
|
"rules": {
|
|
115
115
|
"no-restricted-globals": "off"
|
|
116
116
|
}
|
|
@@ -118,17 +118,7 @@
|
|
|
118
118
|
{
|
|
119
119
|
"files": ["src/components/ui/**"],
|
|
120
120
|
"rules": {
|
|
121
|
-
"no-restricted-imports":
|
|
122
|
-
"error",
|
|
123
|
-
{
|
|
124
|
-
"paths": [
|
|
125
|
-
{
|
|
126
|
-
"name": "zod",
|
|
127
|
-
"message": "Import from zod/mini. For forms use zodResolver; it works with mini."
|
|
128
|
-
}
|
|
129
|
-
]
|
|
130
|
-
}
|
|
131
|
-
]
|
|
121
|
+
"no-restricted-imports": "off"
|
|
132
122
|
}
|
|
133
123
|
}
|
|
134
124
|
]
|
|
@@ -55,7 +55,7 @@ test('signing out is reachable by keyboard from a signed-in shell', async ({ pag
|
|
|
55
55
|
)
|
|
56
56
|
.map(el => el.textContent.trim() || (el.getAttribute('aria-label') ?? ''))
|
|
57
57
|
)
|
|
58
|
-
const order = ['Home', 'Notes', '
|
|
58
|
+
const order = ['Home', 'Notes', 'Switch to', 'Sign out'].map(name =>
|
|
59
59
|
tabbables.findIndex(text => text.startsWith(name))
|
|
60
60
|
)
|
|
61
61
|
expect(order.every(index => index >= 0)).toBe(true)
|
|
@@ -73,12 +73,12 @@ test('signing out is reachable by keyboard from a signed-in shell', async ({ pag
|
|
|
73
73
|
|
|
74
74
|
test('a button activates with Space', async ({ page }) => {
|
|
75
75
|
await signIn(page)
|
|
76
|
-
const toggle = page.getByRole('button', { name: '
|
|
76
|
+
const toggle = page.getByRole('button', { name: 'Switch to light theme' })
|
|
77
77
|
|
|
78
78
|
await toggle.focus()
|
|
79
79
|
await page.keyboard.press('Space')
|
|
80
80
|
|
|
81
|
-
await expect(page.getByRole('button', { name: '
|
|
81
|
+
await expect(page.getByRole('button', { name: 'Switch to dark theme' })).toBeFocused()
|
|
82
82
|
})
|
|
83
83
|
|
|
84
84
|
test('the back-home link activates with Enter', async ({ page }) => {
|
|
@@ -3,11 +3,13 @@ import type { PropsWithChildren } from 'react'
|
|
|
3
3
|
|
|
4
4
|
import { HeaderActions } from '@/components/header-actions'
|
|
5
5
|
import { NAV } from '@/components/nav'
|
|
6
|
+
import { MAIN_CONTENT_ID, SkipLink } from '@/components/skip-link'
|
|
6
7
|
|
|
7
8
|
// header-only shell: `create-cli` renames this over `app-shell.tsx` when the sidebar is off
|
|
8
9
|
export const AppShell = ({ children }: PropsWithChildren) => {
|
|
9
10
|
return (
|
|
10
11
|
<>
|
|
12
|
+
<SkipLink />
|
|
11
13
|
<header className='flex h-14 items-center gap-4 border-b px-6'>
|
|
12
14
|
<nav aria-label='Main' className='flex items-center gap-4 text-sm'>
|
|
13
15
|
{NAV.map(item => (
|
|
@@ -23,7 +25,9 @@ export const AppShell = ({ children }: PropsWithChildren) => {
|
|
|
23
25
|
</nav>
|
|
24
26
|
<HeaderActions />
|
|
25
27
|
</header>
|
|
26
|
-
<div className='flex flex-1 flex-col p-4 sm:p-6'>
|
|
28
|
+
<div id={MAIN_CONTENT_ID} tabIndex={-1} className='flex flex-1 flex-col p-4 sm:p-6'>
|
|
29
|
+
{children}
|
|
30
|
+
</div>
|
|
27
31
|
</>
|
|
28
32
|
)
|
|
29
33
|
}
|
|
@@ -2,6 +2,7 @@ import type { PropsWithChildren } from 'react'
|
|
|
2
2
|
|
|
3
3
|
import { AppSidebar } from '@/components/app-sidebar'
|
|
4
4
|
import { HeaderActions } from '@/components/header-actions'
|
|
5
|
+
import { MAIN_CONTENT_ID, SkipLink } from '@/components/skip-link'
|
|
5
6
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
|
6
7
|
import { sidebarStore, useSidebarOpen } from '@/lib/sidebar-store'
|
|
7
8
|
|
|
@@ -11,13 +12,16 @@ export const AppShell = ({ children }: PropsWithChildren) => {
|
|
|
11
12
|
|
|
12
13
|
return (
|
|
13
14
|
<SidebarProvider open={open} onOpenChange={sidebarStore.set}>
|
|
15
|
+
<SkipLink />
|
|
14
16
|
<AppSidebar />
|
|
15
17
|
<SidebarInset className='min-w-0'>
|
|
16
18
|
<header className='flex h-14 items-center gap-2 border-b px-4'>
|
|
17
19
|
<SidebarTrigger />
|
|
18
20
|
<HeaderActions />
|
|
19
21
|
</header>
|
|
20
|
-
<div className='flex flex-1 flex-col p-4 sm:p-6'>
|
|
22
|
+
<div id={MAIN_CONTENT_ID} tabIndex={-1} className='flex flex-1 flex-col p-4 sm:p-6'>
|
|
23
|
+
{children}
|
|
24
|
+
</div>
|
|
21
25
|
</SidebarInset>
|
|
22
26
|
</SidebarProvider>
|
|
23
27
|
)
|
|
@@ -53,7 +53,6 @@ import {
|
|
|
53
53
|
DropdownMenuSeparator,
|
|
54
54
|
DropdownMenuTrigger
|
|
55
55
|
} from '@/components/ui/dropdown-menu'
|
|
56
|
-
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'
|
|
57
56
|
import {
|
|
58
57
|
Table,
|
|
59
58
|
TableBody,
|
|
@@ -111,7 +110,11 @@ const columns = helper.columns([
|
|
|
111
110
|
header: 'Amount',
|
|
112
111
|
size: 130,
|
|
113
112
|
meta: { align: 'end' },
|
|
114
|
-
cell: ({ row }) =>
|
|
113
|
+
cell: ({ row }) => (
|
|
114
|
+
<span className='tabular-nums'>
|
|
115
|
+
{formatMoney(row.original.amountCents, row.original.currency)}
|
|
116
|
+
</span>
|
|
117
|
+
)
|
|
115
118
|
}),
|
|
116
119
|
helper.accessor('createdAt', {
|
|
117
120
|
header: 'Date',
|
|
@@ -181,10 +184,12 @@ type Props = {
|
|
|
181
184
|
sorting: OrdersSorting
|
|
182
185
|
onSortingChange: (next: OrdersSorting) => void
|
|
183
186
|
toolbar?: React.ReactNode
|
|
187
|
+
/** shown in place of the rows; the caller knows which filters produced the emptiness */
|
|
188
|
+
empty: React.ReactNode
|
|
184
189
|
busy?: boolean
|
|
185
190
|
}
|
|
186
191
|
|
|
187
|
-
export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }: Props) => {
|
|
192
|
+
export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, empty, busy }: Props) => {
|
|
188
193
|
const layout = useOrdersTableLayout()
|
|
189
194
|
const [dragging, setDragging] = useState<OrderColumn | null>(null)
|
|
190
195
|
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
|
@@ -280,11 +285,13 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
|
|
|
280
285
|
</DropdownMenu>
|
|
281
286
|
</div>
|
|
282
287
|
|
|
283
|
-
<div
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
+
<div className='transition-opacity aria-busy:opacity-60' aria-busy={busy ? true : undefined}>
|
|
289
|
+
<Table
|
|
290
|
+
label='Orders'
|
|
291
|
+
containerClassName='rounded-lg border'
|
|
292
|
+
className='table-fixed'
|
|
293
|
+
style={{ minWidth: table.getTotalSize() }}
|
|
294
|
+
>
|
|
288
295
|
<TableHeader>
|
|
289
296
|
{table.getHeaderGroups().map(group => (
|
|
290
297
|
<TableRow key={group.id}>
|
|
@@ -332,7 +339,7 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
|
|
|
332
339
|
<span
|
|
333
340
|
draggable
|
|
334
341
|
aria-hidden='true'
|
|
335
|
-
className='cursor-grab rounded-sm p-1 text-muted-foreground opacity-0 transition-opacity group-hover/head:opacity-100 active:cursor-grabbing'
|
|
342
|
+
className='cursor-grab rounded-sm p-1 text-muted-foreground opacity-0 transition-opacity group-hover/head:opacity-100 active:cursor-grabbing pointer-coarse:hidden'
|
|
336
343
|
onDragStart={event => {
|
|
337
344
|
event.dataTransfer.setData(DRAG_TYPE, column.id)
|
|
338
345
|
event.dataTransfer.effectAllowed = 'move'
|
|
@@ -367,7 +374,7 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
|
|
|
367
374
|
variant='ghost'
|
|
368
375
|
size='icon-xs'
|
|
369
376
|
className={cn(
|
|
370
|
-
'opacity-0 transition-opacity group-hover/head:opacity-100 focus-visible:opacity-100 data-open:opacity-100',
|
|
377
|
+
'opacity-0 transition-opacity group-hover/head:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 data-open:opacity-100',
|
|
371
378
|
align === 'end' ? 'mr-auto' : 'ml-auto'
|
|
372
379
|
)}
|
|
373
380
|
aria-label={`${labelOf(column)} column options`}
|
|
@@ -445,12 +452,7 @@ export const OrdersTable = ({ orders, sorting, onSortingChange, toolbar, busy }:
|
|
|
445
452
|
{table.getRowModel().rows.length === 0 ? (
|
|
446
453
|
<TableRow>
|
|
447
454
|
<TableCell colSpan={table.getVisibleLeafColumns().length} className='p-0'>
|
|
448
|
-
|
|
449
|
-
<EmptyHeader>
|
|
450
|
-
<EmptyTitle>No orders match</EmptyTitle>
|
|
451
|
-
<EmptyDescription>Clear the search or pick another status.</EmptyDescription>
|
|
452
|
-
</EmptyHeader>
|
|
453
|
-
</Empty>
|
|
455
|
+
{empty}
|
|
454
456
|
</TableCell>
|
|
455
457
|
</TableRow>
|
|
456
458
|
) : (
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const MAIN_CONTENT_ID = 'main-content'
|
|
2
|
+
|
|
3
|
+
// the first focusable element on the page: the shell's nav sits between the top and the content
|
|
4
|
+
export const SkipLink = () => (
|
|
5
|
+
<a
|
|
6
|
+
href={`#${MAIN_CONTENT_ID}`}
|
|
7
|
+
className='sr-only z-50 rounded-lg bg-background px-3 py-2 text-sm font-medium ring-1 ring-border focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:ring-3 focus:ring-ring/50'
|
|
8
|
+
>
|
|
9
|
+
Skip to content
|
|
10
|
+
</a>
|
|
11
|
+
)
|
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import { cn } from 'cn'
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
type TableProps = React.ComponentProps<'table'> & {
|
|
5
|
+
/** names the scroll region; a keyboard user lands on it before the columns it hides */
|
|
6
|
+
label: string
|
|
7
|
+
containerClassName?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function Table({ className, label, containerClassName, ...props }: TableProps) {
|
|
5
11
|
return (
|
|
6
|
-
<div
|
|
12
|
+
<div
|
|
13
|
+
data-slot='table-container'
|
|
14
|
+
role='region'
|
|
15
|
+
aria-label={label}
|
|
16
|
+
tabIndex={0}
|
|
17
|
+
className={cn(
|
|
18
|
+
'relative w-full overflow-x-auto scroll-x-hint focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
|
19
|
+
containerClassName
|
|
20
|
+
)}
|
|
21
|
+
>
|
|
7
22
|
<table
|
|
8
23
|
data-slot='table'
|
|
9
24
|
className={cn('w-full caption-bottom text-sm', className)}
|
|
@@ -12,7 +12,29 @@ import {
|
|
|
12
12
|
Loader2Icon
|
|
13
13
|
} from 'lucide-react'
|
|
14
14
|
|
|
15
|
-
const
|
|
15
|
+
const manager = ToastPrimitive.createToastManager()
|
|
16
|
+
|
|
17
|
+
// an error, or an action like Undo, is the way back: it must not expire before it is read
|
|
18
|
+
const persist = <T,>(value: T) =>
|
|
19
|
+
typeof value === 'string' ? { title: value, timeout: 0 } : { ...value, timeout: 0 }
|
|
20
|
+
|
|
21
|
+
const toast: typeof manager = {
|
|
22
|
+
...manager,
|
|
23
|
+
add: options =>
|
|
24
|
+
manager.add({
|
|
25
|
+
timeout: options.type === 'error' || options.actionProps ? 0 : undefined,
|
|
26
|
+
...options
|
|
27
|
+
}),
|
|
28
|
+
// `promise` builds its own toasts, so its error branch never reaches `add`
|
|
29
|
+
promise: (value, options) => {
|
|
30
|
+
const { error } = options
|
|
31
|
+
return manager.promise(value, {
|
|
32
|
+
...options,
|
|
33
|
+
error:
|
|
34
|
+
typeof error === 'function' ? (reason: unknown) => persist(error(reason)) : persist(error)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
16
38
|
|
|
17
39
|
function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
|
|
18
40
|
return <ToastPrimitive.Provider {...props} />
|
|
@@ -152,7 +174,7 @@ function ToastIcon({ type }: { type: string | undefined }) {
|
|
|
152
174
|
}
|
|
153
175
|
|
|
154
176
|
if (type === 'loading') {
|
|
155
|
-
icon = <Loader2Icon className='animate-spin' aria-hidden='true' />
|
|
177
|
+
icon = <Loader2Icon className='motion-safe:animate-spin' aria-hidden='true' />
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
if (!icon) {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
--destructive: oklch(0.577 0.245 27.325);
|
|
67
67
|
--border: oklch(0.922 0 0);
|
|
68
68
|
--input: oklch(0.922 0 0);
|
|
69
|
-
--ring: oklch(0.
|
|
69
|
+
--ring: oklch(0.62 0 0);
|
|
70
70
|
--chart-1: oklch(0.87 0 0);
|
|
71
71
|
--chart-2: oklch(0.556 0 0);
|
|
72
72
|
--chart-3: oklch(0.439 0 0);
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
--sidebar-accent: oklch(0.97 0 0);
|
|
81
81
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
82
82
|
--sidebar-border: oklch(0.922 0 0);
|
|
83
|
-
--sidebar-ring: oklch(0.
|
|
83
|
+
--sidebar-ring: oklch(0.62 0 0);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
.dark {
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
color-scheme: dark;
|
|
132
132
|
}
|
|
133
133
|
body {
|
|
134
|
-
@apply bg-background text-foreground
|
|
134
|
+
@apply bg-background text-foreground antialiased;
|
|
135
135
|
}
|
|
136
136
|
h1,
|
|
137
137
|
h2,
|
|
@@ -151,4 +151,28 @@
|
|
|
151
151
|
appearance: none;
|
|
152
152
|
margin: 0;
|
|
153
153
|
}
|
|
154
|
+
/* covers the component library's enter/exit keyframes too, which no `motion-safe:` reaches */
|
|
155
|
+
@media (prefers-reduced-motion: reduce) {
|
|
156
|
+
*,
|
|
157
|
+
*::before,
|
|
158
|
+
*::after {
|
|
159
|
+
animation-duration: 0.01ms !important;
|
|
160
|
+
animation-iteration-count: 1 !important;
|
|
161
|
+
transition-duration: 0.01ms !important;
|
|
162
|
+
scroll-behavior: auto !important;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* edge shadows that appear only on the side with more to scroll: `local` gradients cover them at the ends */
|
|
168
|
+
@utility scroll-x-hint {
|
|
169
|
+
--shadow-edge: color-mix(in oklch, var(--foreground) 18%, transparent);
|
|
170
|
+
background:
|
|
171
|
+
linear-gradient(to right, var(--background), transparent) 0 0 / 24px 100% no-repeat local,
|
|
172
|
+
linear-gradient(to left, var(--background), transparent) 100% 0 / 24px 100% no-repeat local,
|
|
173
|
+
radial-gradient(farthest-side at 0 50%, var(--shadow-edge), transparent) 0 0 / 12px 100%
|
|
174
|
+
no-repeat scroll,
|
|
175
|
+
radial-gradient(farthest-side at 100% 50%, var(--shadow-edge), transparent) 100% 0 / 12px 100%
|
|
176
|
+
no-repeat scroll;
|
|
177
|
+
background-color: var(--background);
|
|
154
178
|
}
|
|
@@ -26,7 +26,7 @@ function HomePage() {
|
|
|
26
26
|
return (
|
|
27
27
|
<main className='flex flex-1 flex-col gap-6'>
|
|
28
28
|
<div className='flex flex-col gap-1'>
|
|
29
|
-
<h1 className='text-2xl font-semibold'>
|
|
29
|
+
<h1 className='text-2xl font-semibold'>Home</h1>
|
|
30
30
|
<p className='text-sm text-muted-foreground'>Signed in as {user.email}</p>
|
|
31
31
|
</div>
|
|
32
32
|
{/* realtime:start */}
|
|
@@ -4,6 +4,7 @@ import { ArrowLeft } from 'lucide-react'
|
|
|
4
4
|
import { isNotFound } from '@/api'
|
|
5
5
|
import { noteQuery, useNote } from '@/api/notes'
|
|
6
6
|
import { buttonVariants } from '@/components/ui/button'
|
|
7
|
+
import { formatDate } from '@/lib/format'
|
|
7
8
|
|
|
8
9
|
export const Route = createFileRoute('/_authenticated/notes/$noteId')({
|
|
9
10
|
loader: async ({ context, params }) => {
|
|
@@ -31,7 +32,7 @@ function NotePage() {
|
|
|
31
32
|
<header className='space-y-1'>
|
|
32
33
|
<h1 className='text-2xl font-semibold'>{note.title}</h1>
|
|
33
34
|
<p className='text-xs text-muted-foreground'>
|
|
34
|
-
<time dateTime={note.createdAt}>{
|
|
35
|
+
<time dateTime={note.createdAt}>{formatDate(note.createdAt)}</time>
|
|
35
36
|
</p>
|
|
36
37
|
</header>
|
|
37
38
|
{note.body ? (
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
2
|
import { Link, createFileRoute } from '@tanstack/react-router'
|
|
3
3
|
import { Trash2 } from 'lucide-react'
|
|
4
|
+
import { useState } from 'react'
|
|
4
5
|
import { useForm } from 'react-hook-form'
|
|
5
6
|
|
|
6
7
|
import { notesQuery, useCreateNote, useDeleteNote, useDeletingNoteIds, useNotes } from '@/api/notes'
|
|
7
|
-
import { CreateNoteSchema } from '@/api/notes/schema'
|
|
8
|
+
import { CreateNoteSchema, type Note } from '@/api/notes/schema'
|
|
8
9
|
import { Button } from '@/components/ui/button'
|
|
10
|
+
import {
|
|
11
|
+
Dialog,
|
|
12
|
+
DialogContent,
|
|
13
|
+
DialogDescription,
|
|
14
|
+
DialogFooter,
|
|
15
|
+
DialogHeader,
|
|
16
|
+
DialogTitle
|
|
17
|
+
} from '@/components/ui/dialog'
|
|
9
18
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'
|
|
10
19
|
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
|
11
20
|
import { Input } from '@/components/ui/input'
|
|
@@ -22,6 +31,9 @@ function NotesPage() {
|
|
|
22
31
|
const createNote = useCreateNote()
|
|
23
32
|
const deleteNote = useDeleteNote()
|
|
24
33
|
const deleting = new Set(useDeletingNoteIds())
|
|
34
|
+
// deleting a note is final, so it is confirmed rather than undone.
|
|
35
|
+
// the note outlives `open`: the dialog is still on screen while it animates out
|
|
36
|
+
const [confirm, setConfirm] = useState<{ note: Note; open: boolean } | null>(null)
|
|
25
37
|
const {
|
|
26
38
|
register,
|
|
27
39
|
handleSubmit,
|
|
@@ -33,6 +45,7 @@ function NotesPage() {
|
|
|
33
45
|
})
|
|
34
46
|
|
|
35
47
|
const onSubmit = handleSubmit(values => createNote.mutate(values, { onSuccess: () => reset() }))
|
|
48
|
+
const close = () => setConfirm(current => current && { ...current, open: false })
|
|
36
49
|
|
|
37
50
|
return (
|
|
38
51
|
<main className='mx-auto w-full max-w-md space-y-6'>
|
|
@@ -90,11 +103,11 @@ function NotesPage() {
|
|
|
90
103
|
{note.title}
|
|
91
104
|
</Link>
|
|
92
105
|
<Button
|
|
93
|
-
variant='
|
|
106
|
+
variant='destructive'
|
|
94
107
|
size='icon-sm'
|
|
95
108
|
aria-label={`Delete ${note.title}`}
|
|
96
109
|
disabled={deleting.has(note.id)}
|
|
97
|
-
onClick={() =>
|
|
110
|
+
onClick={() => setConfirm({ note, open: true })}
|
|
98
111
|
>
|
|
99
112
|
<Trash2 />
|
|
100
113
|
</Button>
|
|
@@ -102,6 +115,31 @@ function NotesPage() {
|
|
|
102
115
|
))}
|
|
103
116
|
</ul>
|
|
104
117
|
)}
|
|
118
|
+
|
|
119
|
+
<Dialog open={confirm?.open === true} onOpenChange={next => !next && close()}>
|
|
120
|
+
<DialogContent>
|
|
121
|
+
<DialogHeader>
|
|
122
|
+
<DialogTitle>Delete this note?</DialogTitle>
|
|
123
|
+
<DialogDescription>
|
|
124
|
+
“{confirm?.note.title}” will be removed for good. This cannot be undone.
|
|
125
|
+
</DialogDescription>
|
|
126
|
+
</DialogHeader>
|
|
127
|
+
<DialogFooter>
|
|
128
|
+
<Button variant='outline' onClick={close}>
|
|
129
|
+
Cancel
|
|
130
|
+
</Button>
|
|
131
|
+
<Button
|
|
132
|
+
variant='destructive'
|
|
133
|
+
onClick={() => {
|
|
134
|
+
if (confirm) deleteNote.mutate(confirm.note.id)
|
|
135
|
+
close()
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
Delete note
|
|
139
|
+
</Button>
|
|
140
|
+
</DialogFooter>
|
|
141
|
+
</DialogContent>
|
|
142
|
+
</Dialog>
|
|
105
143
|
</main>
|
|
106
144
|
)
|
|
107
145
|
}
|
|
@@ -18,7 +18,14 @@ import {
|
|
|
18
18
|
import { CreateOrderDialog } from '@/components/orders/create-order-dialog'
|
|
19
19
|
import { statusLabel } from '@/components/orders/order-status'
|
|
20
20
|
import { OrdersTable, type OrdersSorting } from '@/components/orders/orders-table'
|
|
21
|
-
import { buttonVariants } from '@/components/ui/button'
|
|
21
|
+
import { Button, buttonVariants } from '@/components/ui/button'
|
|
22
|
+
import {
|
|
23
|
+
Empty,
|
|
24
|
+
EmptyContent,
|
|
25
|
+
EmptyDescription,
|
|
26
|
+
EmptyHeader,
|
|
27
|
+
EmptyTitle
|
|
28
|
+
} from '@/components/ui/empty'
|
|
22
29
|
import { Input } from '@/components/ui/input'
|
|
23
30
|
import {
|
|
24
31
|
Select,
|
|
@@ -84,11 +91,36 @@ function OrdersPage() {
|
|
|
84
91
|
})
|
|
85
92
|
const onSorting = ({ sort, order }: OrdersSorting) =>
|
|
86
93
|
void navigate({ search: prev => ({ ...prev, sort, order, page: 1 }) })
|
|
94
|
+
const onClearFilters = () =>
|
|
95
|
+
void navigate({ search: prev => ({ ...prev, q: undefined, status: undefined, page: 1 }) })
|
|
87
96
|
const onPageSize = (value: string | null) =>
|
|
88
97
|
void navigate({
|
|
89
98
|
search: prev => ({ ...prev, pageSize: Number(value) as (typeof PAGE_SIZES)[number], page: 1 })
|
|
90
99
|
})
|
|
91
100
|
|
|
101
|
+
const filtered = Boolean(search.q || search.status)
|
|
102
|
+
const empty = (
|
|
103
|
+
<Empty className='border-none'>
|
|
104
|
+
<EmptyHeader>
|
|
105
|
+
<EmptyTitle>
|
|
106
|
+
{search.q ? `No orders match \u201C${search.q}\u201D` : 'No orders to show'}
|
|
107
|
+
</EmptyTitle>
|
|
108
|
+
<EmptyDescription>
|
|
109
|
+
{filtered ? 'Try another search or status.' : 'Create the first one to see it here.'}
|
|
110
|
+
</EmptyDescription>
|
|
111
|
+
</EmptyHeader>
|
|
112
|
+
<EmptyContent>
|
|
113
|
+
{filtered ? (
|
|
114
|
+
<Button variant='outline' onClick={onClearFilters}>
|
|
115
|
+
Clear filters
|
|
116
|
+
</Button>
|
|
117
|
+
) : (
|
|
118
|
+
<CreateOrderDialog />
|
|
119
|
+
)}
|
|
120
|
+
</EmptyContent>
|
|
121
|
+
</Empty>
|
|
122
|
+
)
|
|
123
|
+
|
|
92
124
|
return (
|
|
93
125
|
<main className='flex flex-1 flex-col gap-4'>
|
|
94
126
|
<div className='flex flex-wrap items-center justify-between gap-2'>
|
|
@@ -100,6 +132,7 @@ function OrdersPage() {
|
|
|
100
132
|
orders={data.items}
|
|
101
133
|
sorting={search}
|
|
102
134
|
onSortingChange={onSorting}
|
|
135
|
+
empty={empty}
|
|
103
136
|
busy={busy}
|
|
104
137
|
toolbar={
|
|
105
138
|
<>
|