@betterstart/cli 0.1.69 → 0.1.70

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 (77) hide show
  1. package/dist/chunk-E4HZYXQ2.js +36 -0
  2. package/dist/chunk-E4HZYXQ2.js.map +1 -0
  3. package/dist/cli.js +580 -4444
  4. package/dist/cli.js.map +1 -1
  5. package/dist/reader-2T45D7JZ.js +7 -0
  6. package/package.json +1 -1
  7. package/templates/init/api/auth-route.ts +3 -0
  8. package/templates/init/api/upload-route.ts +74 -0
  9. package/templates/init/cms-globals.css +200 -0
  10. package/templates/init/components/data-table/data-table-pagination.tsx +90 -0
  11. package/templates/init/components/data-table/data-table-toolbar.tsx +93 -0
  12. package/templates/init/components/data-table/data-table.tsx +188 -0
  13. package/templates/init/components/layout/cms-header.tsx +32 -0
  14. package/templates/init/components/layout/cms-nav-link.tsx +25 -0
  15. package/templates/init/components/layout/cms-providers.tsx +33 -0
  16. package/templates/init/components/layout/cms-search.tsx +25 -0
  17. package/templates/init/components/layout/cms-sidebar.tsx +192 -0
  18. package/templates/init/components/layout/cms-sign-out.tsx +30 -0
  19. package/templates/init/components/shared/delete-dialog.tsx +75 -0
  20. package/templates/init/components/shared/page-header.tsx +23 -0
  21. package/templates/init/components/shared/status-badge.tsx +43 -0
  22. package/templates/init/data/navigation.ts +39 -0
  23. package/templates/init/db/client.ts +8 -0
  24. package/templates/init/db/schema.ts +88 -0
  25. package/{dist/chunk-6JCWMKSY.js → templates/init/drizzle.config.ts} +2 -11
  26. package/templates/init/hooks/use-cms-theme.tsx +78 -0
  27. package/templates/init/hooks/use-editor-image-upload.ts +82 -0
  28. package/templates/init/hooks/use-local-storage.ts +46 -0
  29. package/templates/init/hooks/use-mobile.ts +19 -0
  30. package/templates/init/hooks/use-upload.ts +177 -0
  31. package/templates/init/hooks/use-users.ts +13 -0
  32. package/templates/init/lib/actions/form-settings.ts +126 -0
  33. package/templates/init/lib/actions/profile.ts +62 -0
  34. package/templates/init/lib/actions/upload.ts +153 -0
  35. package/templates/init/lib/actions/users.ts +145 -0
  36. package/templates/init/lib/auth/auth-client.ts +12 -0
  37. package/templates/init/lib/auth/auth.ts +43 -0
  38. package/templates/init/lib/auth/middleware.ts +44 -0
  39. package/templates/init/lib/markdown/cached.ts +7 -0
  40. package/templates/init/lib/markdown/format.ts +55 -0
  41. package/templates/init/lib/markdown/render.ts +182 -0
  42. package/templates/init/lib/r2.ts +55 -0
  43. package/templates/init/pages/account-layout.tsx +63 -0
  44. package/templates/init/pages/authenticated-layout.tsx +26 -0
  45. package/templates/init/pages/cms-layout.tsx +16 -0
  46. package/templates/init/pages/dashboard-page.tsx +91 -0
  47. package/templates/init/pages/login-form.tsx +117 -0
  48. package/templates/init/pages/login-page.tsx +17 -0
  49. package/templates/init/pages/profile/profile-form.tsx +361 -0
  50. package/templates/init/pages/profile/profile-page.tsx +34 -0
  51. package/templates/init/pages/users/columns.tsx +241 -0
  52. package/templates/init/pages/users/create-user-dialog.tsx +116 -0
  53. package/templates/init/pages/users/edit-role-dialog.tsx +92 -0
  54. package/templates/init/pages/users/users-page-content.tsx +29 -0
  55. package/templates/init/pages/users/users-page.tsx +19 -0
  56. package/templates/init/pages/users/users-table.tsx +219 -0
  57. package/templates/init/types/auth.ts +78 -0
  58. package/templates/init/types/index.ts +79 -0
  59. package/templates/init/types/table-meta.ts +16 -0
  60. package/templates/init/utils/cn.ts +6 -0
  61. package/templates/init/utils/mailchimp.ts +39 -0
  62. package/templates/init/utils/seo.ts +90 -0
  63. package/templates/init/utils/validation.ts +105 -0
  64. package/templates/init/utils/webhook.ts +28 -0
  65. package/templates/ui/alert-dialog.tsx +46 -28
  66. package/templates/ui/avatar.tsx +37 -20
  67. package/templates/ui/button.tsx +3 -3
  68. package/templates/ui/card.tsx +30 -18
  69. package/templates/ui/dialog.tsx +46 -22
  70. package/templates/ui/dropdown-menu.tsx +1 -1
  71. package/templates/ui/input.tsx +1 -1
  72. package/templates/ui/select.tsx +42 -34
  73. package/templates/ui/sidebar.tsx +13 -13
  74. package/templates/ui/table.tsx +2 -2
  75. package/dist/chunk-6JCWMKSY.js.map +0 -1
  76. package/dist/drizzle-config-EDKOEZ6G.js +0 -7
  77. /package/dist/{drizzle-config-EDKOEZ6G.js.map → reader-2T45D7JZ.js.map} +0 -0
@@ -0,0 +1,7 @@
1
+ import {
2
+ readTemplate
3
+ } from "./chunk-E4HZYXQ2.js";
4
+ export {
5
+ readTemplate
6
+ };
7
+ //# sourceMappingURL=reader-2T45D7JZ.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@betterstart/cli",
3
- "version": "0.1.69",
3
+ "version": "0.1.70",
4
4
  "description": "Scaffold a full-featured CMS into any Next.js 16 application",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,3 @@
1
+ import { auth, toNextJsHandler } from '@cms/auth'
2
+
3
+ export const { GET, POST } = toNextJsHandler(auth)
@@ -0,0 +1,74 @@
1
+ import { PutObjectCommand } from '@aws-sdk/client-s3'
2
+ import { BUCKET_NAME, generateFilePath, getPublicUrl, getR2Client } from '@cms/lib/r2'
3
+ import type { UploadedFile } from '@cms/types'
4
+ import { validateFiles } from '@cms/utils/validation'
5
+ import { type NextRequest, NextResponse } from 'next/server'
6
+
7
+ export async function POST(request: NextRequest) {
8
+ try {
9
+ const formData = await request.formData()
10
+ const prefix = formData.get('prefix')?.toString() || 'uploads'
11
+
12
+ const maxSizeInBytes = Number(formData.get('maxSizeInBytes')) || 10 * 1024 * 1024
13
+ const allowedTypesRaw = formData.get('allowedTypes')?.toString()
14
+ const allowedTypes = allowedTypesRaw ? allowedTypesRaw.split(',').filter(Boolean) : []
15
+
16
+ const files: File[] = []
17
+ for (const [key, value] of formData.entries()) {
18
+ if (value instanceof File && key.startsWith('file')) {
19
+ files.push(value)
20
+ }
21
+ }
22
+
23
+ if (files.length === 0) {
24
+ return NextResponse.json({ success: false, error: 'No files provided' }, { status: 400 })
25
+ }
26
+
27
+ const validation = validateFiles(files, {
28
+ maxSizeInBytes,
29
+ allowedTypes,
30
+ maxFiles: 10,
31
+ })
32
+
33
+ if (!validation.valid) {
34
+ return NextResponse.json(
35
+ {
36
+ success: false,
37
+ error: validation.errors.map((e) => `${e.filename}: ${e.error}`).join('; '),
38
+ },
39
+ { status: 400 },
40
+ )
41
+ }
42
+
43
+ const uploadedFiles: UploadedFile[] = []
44
+
45
+ for (const file of files) {
46
+ const key = generateFilePath(file.name, prefix)
47
+ const arrayBuffer = await file.arrayBuffer()
48
+ const buffer = Buffer.from(arrayBuffer)
49
+
50
+ const command = new PutObjectCommand({
51
+ Bucket: BUCKET_NAME,
52
+ Key: key,
53
+ Body: buffer,
54
+ ContentType: file.type,
55
+ ContentLength: file.size,
56
+ })
57
+
58
+ await getR2Client().send(command)
59
+
60
+ uploadedFiles.push({
61
+ key,
62
+ url: getPublicUrl(key),
63
+ filename: file.name,
64
+ size: file.size,
65
+ contentType: file.type,
66
+ })
67
+ }
68
+
69
+ return NextResponse.json({ success: true, files: uploadedFiles })
70
+ } catch (error) {
71
+ const message = error instanceof Error ? error.message : 'Failed to upload files'
72
+ return NextResponse.json({ success: false, error: message }, { status: 500 })
73
+ }
74
+ }
@@ -0,0 +1,200 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+ @import "shadcn/tailwind.css";
4
+
5
+ @custom-variant dark (&:is(.dark *));
6
+
7
+ @theme inline {
8
+ --color-background: var(--background);
9
+ --color-foreground: var(--foreground);
10
+ --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
11
+ --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
12
+ --color-sidebar-ring: var(--sidebar-ring);
13
+ --color-sidebar-border: var(--sidebar-border);
14
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
15
+ --color-sidebar-accent: var(--sidebar-accent);
16
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
17
+ --color-sidebar-primary: var(--sidebar-primary);
18
+ --color-sidebar-foreground: var(--sidebar-foreground);
19
+ --color-sidebar: var(--sidebar);
20
+ --color-chart-5: var(--chart-5);
21
+ --color-chart-4: var(--chart-4);
22
+ --color-chart-3: var(--chart-3);
23
+ --color-chart-2: var(--chart-2);
24
+ --color-chart-1: var(--chart-1);
25
+ --color-ring: var(--ring);
26
+ --color-input: var(--input);
27
+ --color-border: var(--border);
28
+ --color-destructive: var(--destructive);
29
+ --color-accent-foreground: var(--accent-foreground);
30
+ --color-accent: var(--accent);
31
+ --color-muted-foreground: var(--muted-foreground);
32
+ --color-muted: var(--muted);
33
+ --color-secondary-foreground: var(--secondary-foreground);
34
+ --color-secondary: var(--secondary);
35
+ --color-primary-foreground: var(--primary-foreground);
36
+ --color-primary: var(--primary);
37
+ --color-popover-foreground: var(--popover-foreground);
38
+ --color-popover: var(--popover);
39
+ --color-card-foreground: var(--card-foreground);
40
+ --color-card: var(--card);
41
+ --radius-sm: calc(var(--radius) - 4px);
42
+ --radius-md: calc(var(--radius) - 2px);
43
+ --radius-lg: var(--radius);
44
+ --radius-xl: calc(var(--radius) + 4px);
45
+ --radius-2xl: calc(var(--radius) + 8px);
46
+ --radius-3xl: calc(var(--radius) + 12px);
47
+ --radius-4xl: calc(var(--radius) + 16px);
48
+ --font-heading: var(--font-sans);
49
+ --shadow-sm: var(--shadow-small);
50
+ --shadow-md: var(--shadow-medium);
51
+ --shadow-lg: var(--shadow-large);
52
+ }
53
+
54
+ :root {
55
+ --card: oklch(1 0 0);
56
+ --card-foreground: oklch(0.141 0.005 285.823);
57
+ --popover: oklch(1 0 0);
58
+ --popover-foreground: oklch(0.141 0.005 285.823);
59
+ --primary: oklch(0.21 0.006 285.885);
60
+ --primary-foreground: oklch(0.985 0 0);
61
+ --secondary: oklch(0.967 0.001 286.375);
62
+ --secondary-foreground: oklch(0.21 0.006 285.885);
63
+ --muted: oklch(0.967 0.001 286.375);
64
+ --muted-foreground: oklch(0.4 0.016 285.938);
65
+ --accent: oklch(0.961 0 0);
66
+ --accent-foreground: oklch(0.21 0.006 285.885);
67
+ --destructive: oklch(0.577 0.245 27.325);
68
+ --border: oklch(0.92 0.004 286.32);
69
+ --input: oklch(0.92 0.004 286.32);
70
+ --ring: oklch(0.705 0.015 286.067);
71
+ --chart-1: oklch(0.809 0.105 251.813);
72
+ --chart-2: oklch(0.623 0.214 259.815);
73
+ --chart-3: oklch(0.546 0.245 262.881);
74
+ --chart-4: oklch(0.488 0.243 264.376);
75
+ --chart-5: oklch(0.424 0.199 265.638);
76
+ --radius: 0.45rem;
77
+ --shadow-background-border: 0 0 0 1px var(--background);
78
+ --shadow-border-base: 0 0 0 1px var(--border);
79
+ --shadow-border-inset: inset 0 0 0 1px var(--border);
80
+ --shadow-border: var(--shadow-border-base), var(--shadow-background-border);
81
+ --shadow-small: 0px 2px 2px #0000000a;
82
+ --shadow-border-small: var(--shadow-border-base), var(--shadow-small), var(--shadow-background-border);
83
+ --shadow-medium: 0px 2px 2px #0000000a, 0px 8px 8px -8px #0000000a;
84
+ --shadow-border-medium: var(--shadow-border-base), var(--shadow-medium), var(--shadow-background-border);
85
+ --shadow-large: 0px 2px 2px #0000000a, 0px 8px 16px -4px #0000000a;
86
+ --shadow-border-large: var(--shadow-border-base), var(--shadow-large), var(--shadow-background-border);
87
+ --shadow-tooltip: var(--shadow-border-base), 0px 1px 1px #00000005, 0px 4px 8px #0000000a, var(--shadow-background-border);
88
+ --shadow-menu: var(--shadow-border-base), 0px 1px 1px #00000005, 0px 4px 8px -4px #0000000a, 0px 16px 24px -8px #0000000f, var(--shadow-background-border);
89
+ --shadow-modal: var(--shadow-border-base), 0px 1px 1px #00000005, 0px 8px 16px -4px #0000000a, 0px 24px 32px -8px #0000000f, var(--shadow-background-border);
90
+ --shadow-fullscreen: var(--shadow-border-base), 0px 1px 1px #00000005, 0px 8px 16px -4px #0000000a, 0px 24px 32px -8px #0000000f, var(--shadow-background-border);
91
+ --sidebar: oklch(0.985 0 0);
92
+ --sidebar-foreground: oklch(0.39 0 0);
93
+ --sidebar-primary: oklch(0.21 0.006 285.885);
94
+ --sidebar-primary-foreground: oklch(0.985 0 0);
95
+ --sidebar-accent: oklch(0.94 0 0);
96
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
97
+ --sidebar-border: oklch(0.92 0.004 286.32);
98
+ --sidebar-ring: oklch(0.705 0.015 286.067);
99
+ --background: oklch(0.98 0 0);
100
+ --foreground: oklch(0.141 0.005 285.823);
101
+ }
102
+
103
+ .dark {
104
+ --background: oklch(0.141 0.005 285.823);
105
+ --foreground: oklch(0.985 0 0);
106
+ --card: oklch(0.21 0.006 285.885);
107
+ --card-foreground: oklch(0.985 0 0);
108
+ --popover: oklch(0.21 0.006 285.885);
109
+ --popover-foreground: oklch(0.985 0 0);
110
+ --primary: oklch(0.92 0.004 286.32);
111
+ --primary-foreground: oklch(0.21 0.006 285.885);
112
+ --secondary: oklch(0.274 0.006 286.033);
113
+ --secondary-foreground: oklch(0.985 0 0);
114
+ --muted: oklch(0.274 0.006 286.033);
115
+ --muted-foreground: oklch(0.705 0.015 286.067);
116
+ --accent: oklch(0.274 0.006 286.033);
117
+ --accent-foreground: oklch(0.985 0 0);
118
+ --destructive: oklch(0.704 0.191 22.216);
119
+ --border: oklch(1 0 0 / 10%);
120
+ --input: oklch(1 0 0 / 15%);
121
+ --ring: oklch(0.552 0.016 285.938);
122
+ --chart-1: oklch(0.809 0.105 251.813);
123
+ --chart-2: oklch(0.623 0.214 259.815);
124
+ --chart-3: oklch(0.546 0.245 262.881);
125
+ --chart-4: oklch(0.488 0.243 264.376);
126
+ --chart-5: oklch(0.424 0.199 265.638);
127
+ --sidebar: oklch(0.21 0.006 285.885);
128
+ --sidebar-foreground: oklch(0.985 0 0);
129
+ --sidebar-primary: oklch(0.488 0.243 264.376);
130
+ --sidebar-primary-foreground: oklch(0.985 0 0);
131
+ --sidebar-accent: oklch(0.274 0.006 286.033);
132
+ --sidebar-accent-foreground: oklch(0.985 0 0);
133
+ --sidebar-border: oklch(1 0 0 / 10%);
134
+ --sidebar-ring: oklch(0.552 0.016 285.938);
135
+ --shadow-small: 0px 1px 2px #00000029;
136
+ --shadow-medium: 0px 2px 2px #00000052, 0px 8px 8px -8px #00000029;
137
+ }
138
+
139
+ @layer base {
140
+ * {
141
+ @apply border-border outline-ring/50;
142
+ }
143
+ body {
144
+ @apply bg-background text-foreground;
145
+ }
146
+ .cms-root {
147
+ font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
148
+ }
149
+ .cms-root code, .cms-root pre, .cms-root kbd {
150
+ font-family: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
151
+ }
152
+ }
153
+
154
+ @utility material-base {
155
+ background-color: var(--card);
156
+ box-shadow: var(--shadow-border);
157
+ border-radius: 6px;
158
+ }
159
+
160
+ @utility material-small {
161
+ background-color: var(--card);
162
+ box-shadow: var(--shadow-border-small);
163
+ border-radius: 6px;
164
+ }
165
+
166
+ @utility material-medium {
167
+ background-color: var(--card);
168
+ box-shadow: var(--shadow-border-medium);
169
+ border-radius: 12px;
170
+ }
171
+
172
+ @utility material-large {
173
+ background-color: var(--card);
174
+ box-shadow: var(--shadow-border-large);
175
+ border-radius: 12px;
176
+ }
177
+
178
+ @utility material-tooltip {
179
+ background-color: var(--card);
180
+ box-shadow: var(--shadow-tooltip);
181
+ border-radius: 6px;
182
+ }
183
+
184
+ @utility material-menu {
185
+ background-color: var(--card);
186
+ box-shadow: var(--shadow-menu);
187
+ border-radius: 12px;
188
+ }
189
+
190
+ @utility material-modal {
191
+ background-color: var(--card);
192
+ box-shadow: var(--shadow-modal);
193
+ border-radius: 12px;
194
+ }
195
+
196
+ @utility material-fullscreen {
197
+ background-color: var(--card);
198
+ box-shadow: var(--shadow-fullscreen);
199
+ border-radius: 16px;
200
+ }
@@ -0,0 +1,90 @@
1
+ 'use client'
2
+
3
+ import { Button } from '@cms/components/ui/button'
4
+ import {
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ } from '@cms/components/ui/select'
11
+ import type { Table } from '@tanstack/react-table'
12
+ import * as React from 'react'
13
+
14
+ const PAGE_SIZE_OPTIONS = [
15
+ { value: '10', label: '10' },
16
+ { value: '20', label: '20' },
17
+ { value: '50', label: '50' },
18
+ { value: '100', label: '100' },
19
+ { value: 'all', label: 'All' },
20
+ ]
21
+
22
+ interface DataTablePaginationProps<TData> {
23
+ table: Table<TData>
24
+ pageSize: number
25
+ setPageSize: (value: number | null) => void
26
+ }
27
+
28
+ export function DataTablePagination<TData>({
29
+ table,
30
+ pageSize,
31
+ setPageSize,
32
+ }: DataTablePaginationProps<TData>) {
33
+ const handlePageSizeChange = React.useCallback(
34
+ (value: string) => {
35
+ React.startTransition(() => {
36
+ if (value === 'all') {
37
+ setPageSize(-1)
38
+ } else {
39
+ setPageSize(Number(value))
40
+ }
41
+ table.setPageIndex(0)
42
+ })
43
+ },
44
+ [setPageSize, table],
45
+ )
46
+
47
+ return (
48
+ <div className="flex items-center justify-between">
49
+ <div className="flex items-center gap-2">
50
+ <span className="text-sm text-muted-foreground">Rows per page</span>
51
+ <Select
52
+ value={pageSize === -1 ? 'all' : String(pageSize)}
53
+ onValueChange={handlePageSizeChange}
54
+ >
55
+ <SelectTrigger className="w-[100px] h-8">
56
+ <SelectValue />
57
+ </SelectTrigger>
58
+ <SelectContent>
59
+ {PAGE_SIZE_OPTIONS.map((option) => (
60
+ <SelectItem key={option.value} value={option.value}>
61
+ {option.label}
62
+ </SelectItem>
63
+ ))}
64
+ </SelectContent>
65
+ </Select>
66
+ </div>
67
+ <div className="flex items-center space-x-2">
68
+ <Button
69
+ variant="outline"
70
+ size="sm"
71
+ onClick={() => table.previousPage()}
72
+ disabled={!table.getCanPreviousPage()}
73
+ >
74
+ Previous
75
+ </Button>
76
+ <div className="text-sm text-muted-foreground">
77
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount() || 1}
78
+ </div>
79
+ <Button
80
+ variant="outline"
81
+ size="sm"
82
+ onClick={() => table.nextPage()}
83
+ disabled={!table.getCanNextPage()}
84
+ >
85
+ Next
86
+ </Button>
87
+ </div>
88
+ </div>
89
+ )
90
+ }
@@ -0,0 +1,93 @@
1
+ 'use client'
2
+
3
+ import { Button } from '@cms/components/ui/button'
4
+ import { Input } from '@cms/components/ui/input'
5
+ import { ArrowUpDown, Save, Search } from 'lucide-react'
6
+ import type * as React from 'react'
7
+ import { useFormStatus } from 'react-dom'
8
+
9
+ function SearchButton() {
10
+ const { pending } = useFormStatus()
11
+ return (
12
+ <Button type="submit" variant="outline" size="default" disabled={pending}>
13
+ {pending ? 'Searching...' : 'Search'}
14
+ </Button>
15
+ )
16
+ }
17
+
18
+ interface DataTableToolbarProps {
19
+ search: string
20
+ onSearch: (formData: FormData) => void
21
+ searchPlaceholder?: string
22
+ children?: React.ReactNode
23
+ }
24
+
25
+ export function DataTableToolbar({
26
+ search,
27
+ onSearch,
28
+ searchPlaceholder = 'Search...',
29
+ children,
30
+ }: DataTableToolbarProps) {
31
+ return (
32
+ <div className="flex items-center gap-2">
33
+ <form action={onSearch} className="flex items-center gap-2">
34
+ <div className="relative">
35
+ <Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2" />
36
+ <Input
37
+ key={search}
38
+ name="search"
39
+ placeholder={searchPlaceholder}
40
+ defaultValue={search}
41
+ className="w-64 pl-9"
42
+ />
43
+ </div>
44
+ <SearchButton />
45
+ </form>
46
+ {children}
47
+ </div>
48
+ )
49
+ }
50
+
51
+ interface ReorderControlsProps {
52
+ reorderMode: boolean
53
+ onToggle: () => void
54
+ onSave: () => void
55
+ onCancel: () => void
56
+ hasChanges: boolean
57
+ isSaving: boolean
58
+ }
59
+
60
+ export function ReorderControls({
61
+ reorderMode,
62
+ onToggle,
63
+ onSave,
64
+ onCancel,
65
+ hasChanges,
66
+ isSaving,
67
+ }: ReorderControlsProps) {
68
+ return (
69
+ <div className="flex items-center gap-2">
70
+ <Button
71
+ variant={reorderMode ? 'default' : 'outline'}
72
+ size="sm"
73
+ onClick={onToggle}
74
+ disabled={isSaving}
75
+ >
76
+ <ArrowUpDown className="size-4 mr-1" />
77
+ Sort Order
78
+ </Button>
79
+ {reorderMode && (
80
+ <>
81
+ <Button variant="default" size="sm" onClick={onSave} disabled={!hasChanges || isSaving}>
82
+ <Save className="size-4 mr-1" />
83
+ {isSaving ? 'Saving...' : 'Save'}
84
+ </Button>
85
+ <Button variant="outline" size="sm" onClick={onCancel} disabled={isSaving}>
86
+ Cancel
87
+ </Button>
88
+ {hasChanges && <span className="text-sm text-muted-foreground">Unsaved changes</span>}
89
+ </>
90
+ )}
91
+ </div>
92
+ )
93
+ }
@@ -0,0 +1,188 @@
1
+ 'use client'
2
+
3
+ import {
4
+ Table,
5
+ TableBody,
6
+ TableCell,
7
+ TableHead,
8
+ TableHeader,
9
+ TableRow,
10
+ } from '@cms/components/ui/table'
11
+ import {
12
+ type ColumnDef,
13
+ type ColumnFiltersState,
14
+ flexRender,
15
+ getCoreRowModel,
16
+ getFilteredRowModel,
17
+ getPaginationRowModel,
18
+ getSortedRowModel,
19
+ type SortingState,
20
+ useReactTable,
21
+ type VisibilityState,
22
+ } from '@tanstack/react-table'
23
+ import { parseAsInteger, useQueryState } from 'nuqs'
24
+ import * as React from 'react'
25
+ import { DataTablePagination } from './data-table-pagination'
26
+
27
+ interface DataTableProps<TData, TValue> {
28
+ columns: ColumnDef<TData, TValue>[]
29
+ data: TData[]
30
+ isPending?: boolean
31
+ error?: Error | null
32
+ emptyMessage?: string
33
+ loadingMessage?: string
34
+ selectedIds?: number[]
35
+ onSelectedIdsChange?: (ids: number[]) => void
36
+ meta?: Record<string, unknown>
37
+ getId?: (row: TData) => number
38
+ }
39
+
40
+ export function DataTable<TData, TValue>({
41
+ columns,
42
+ data,
43
+ isPending = false,
44
+ error = null,
45
+ emptyMessage = 'No results found.',
46
+ loadingMessage = 'Loading...',
47
+ selectedIds,
48
+ onSelectedIdsChange,
49
+ meta,
50
+ getId = (row) => (row as { id: number }).id,
51
+ }: DataTableProps<TData, TValue>) {
52
+ const [sorting, setSorting] = React.useState<SortingState>([])
53
+ const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
54
+ const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
55
+ const [pageIndex, setPageIndex] = useQueryState('page', parseAsInteger.withDefault(0))
56
+ const [pageSize, setPageSize] = useQueryState('size', parseAsInteger.withDefault(20))
57
+
58
+ const effectivePageSize = pageSize === -1 ? Number.MAX_SAFE_INTEGER : pageSize
59
+
60
+ // Convert selectedIds to rowSelection object
61
+ const rowSelection = React.useMemo(() => {
62
+ if (!selectedIds) return {}
63
+ const selection: Record<string, boolean> = {}
64
+ data.forEach((row, index) => {
65
+ if (selectedIds.includes(getId(row))) {
66
+ selection[index.toString()] = true
67
+ }
68
+ })
69
+ return selection
70
+ }, [selectedIds, data, getId])
71
+
72
+ const handleRowSelectionChange = React.useCallback(
73
+ (
74
+ updater:
75
+ | Record<string, boolean>
76
+ | ((old: Record<string, boolean>) => Record<string, boolean>),
77
+ ) => {
78
+ if (!onSelectedIdsChange) return
79
+ const newSelection = typeof updater === 'function' ? updater(rowSelection) : updater
80
+ const newIds = Object.keys(newSelection)
81
+ .filter((key) => newSelection[key])
82
+ .map((key) => getId(data[Number.parseInt(key, 10)]))
83
+ .filter(Boolean)
84
+ onSelectedIdsChange(newIds)
85
+ },
86
+ [data, rowSelection, onSelectedIdsChange, getId],
87
+ )
88
+
89
+ const handlePaginationChange = React.useCallback(
90
+ (
91
+ updater:
92
+ | { pageIndex: number; pageSize: number }
93
+ | ((old: { pageIndex: number; pageSize: number }) => {
94
+ pageIndex: number
95
+ pageSize: number
96
+ }),
97
+ ) => {
98
+ const current = { pageIndex, pageSize: effectivePageSize }
99
+ const next = typeof updater === 'function' ? updater(current) : updater
100
+ React.startTransition(() => {
101
+ setPageIndex(next.pageIndex)
102
+ })
103
+ },
104
+ [pageIndex, effectivePageSize, setPageIndex],
105
+ )
106
+
107
+ const table = useReactTable({
108
+ data,
109
+ columns,
110
+ getCoreRowModel: getCoreRowModel(),
111
+ getPaginationRowModel: getPaginationRowModel(),
112
+ onSortingChange: setSorting,
113
+ getSortedRowModel: getSortedRowModel(),
114
+ onColumnFiltersChange: setColumnFilters,
115
+ getFilteredRowModel: getFilteredRowModel(),
116
+ onColumnVisibilityChange: setColumnVisibility,
117
+ onRowSelectionChange: selectedIds ? handleRowSelectionChange : undefined,
118
+ onPaginationChange: handlePaginationChange,
119
+ meta,
120
+ state: {
121
+ sorting,
122
+ columnFilters,
123
+ columnVisibility,
124
+ rowSelection,
125
+ pagination: {
126
+ pageIndex,
127
+ pageSize: effectivePageSize,
128
+ },
129
+ },
130
+ })
131
+
132
+ return (
133
+ <div className="space-y-6">
134
+ <div className="bg-card border overflow-hidden rounded-lg corner-squircle">
135
+ <Table>
136
+ <TableHeader className="bg-secondary">
137
+ {table.getHeaderGroups().map((headerGroup) => (
138
+ <TableRow key={headerGroup.id}>
139
+ {headerGroup.headers.map((header) => (
140
+ <TableHead key={header.id}>
141
+ {header.isPlaceholder
142
+ ? null
143
+ : flexRender(header.column.columnDef.header, header.getContext())}
144
+ </TableHead>
145
+ ))}
146
+ </TableRow>
147
+ ))}
148
+ </TableHeader>
149
+ <TableBody>
150
+ {isPending ? (
151
+ <TableRow>
152
+ <TableCell colSpan={columns.length} className="h-24 text-center">
153
+ <div className="text-muted-foreground">{loadingMessage}</div>
154
+ </TableCell>
155
+ </TableRow>
156
+ ) : error ? (
157
+ <TableRow>
158
+ <TableCell colSpan={columns.length} className="h-24 text-center">
159
+ <div className="text-destructive">Error: {error.message}</div>
160
+ </TableCell>
161
+ </TableRow>
162
+ ) : table.getRowModel().rows?.length ? (
163
+ table.getRowModel().rows.map((row) => (
164
+ <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
165
+ {row.getVisibleCells().map((cell) => (
166
+ <TableCell key={cell.id}>
167
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
168
+ </TableCell>
169
+ ))}
170
+ </TableRow>
171
+ ))
172
+ ) : (
173
+ <TableRow>
174
+ <TableCell colSpan={columns.length} className="h-24 text-center">
175
+ {emptyMessage}
176
+ </TableCell>
177
+ </TableRow>
178
+ )}
179
+ </TableBody>
180
+ </Table>
181
+ </div>
182
+
183
+ <DataTablePagination table={table} pageSize={pageSize} setPageSize={setPageSize} />
184
+ </div>
185
+ )
186
+ }
187
+
188
+ export type { DataTableProps }
@@ -0,0 +1,32 @@
1
+ 'use client'
2
+
3
+ import { Button } from '@cms/components/ui/button'
4
+ import { SidebarTrigger, useSidebar } from '@cms/components/ui/sidebar'
5
+ import { useTheme } from '@cms/hooks/use-cms-theme'
6
+ import { Moon, Sun } from 'lucide-react'
7
+
8
+ export function CmsHeader() {
9
+ const { theme, setTheme } = useTheme()
10
+ const { state } = useSidebar()
11
+
12
+ return (
13
+ <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border w-full sticky top-0 z-50">
14
+ <div className="flex items-center px-5 gap-1 flex-1 w-full justify-between">
15
+ <div className="flex items-center gap-2 w-full">
16
+ {state === 'collapsed' && <SidebarTrigger />}
17
+ </div>
18
+ <div className="flex items-center gap-2 ml-auto">
19
+ <Button
20
+ variant="ghost"
21
+ size="icon"
22
+ onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
23
+ >
24
+ <Sun className="size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
25
+ <Moon className="absolute size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
26
+ <span className="sr-only">Toggle theme</span>
27
+ </Button>
28
+ </div>
29
+ </div>
30
+ </header>
31
+ )
32
+ }