@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.
- package/dist/chunk-E4HZYXQ2.js +36 -0
- package/dist/chunk-E4HZYXQ2.js.map +1 -0
- package/dist/cli.js +580 -4444
- package/dist/cli.js.map +1 -1
- package/dist/reader-2T45D7JZ.js +7 -0
- package/package.json +1 -1
- package/templates/init/api/auth-route.ts +3 -0
- package/templates/init/api/upload-route.ts +74 -0
- package/templates/init/cms-globals.css +200 -0
- package/templates/init/components/data-table/data-table-pagination.tsx +90 -0
- package/templates/init/components/data-table/data-table-toolbar.tsx +93 -0
- package/templates/init/components/data-table/data-table.tsx +188 -0
- package/templates/init/components/layout/cms-header.tsx +32 -0
- package/templates/init/components/layout/cms-nav-link.tsx +25 -0
- package/templates/init/components/layout/cms-providers.tsx +33 -0
- package/templates/init/components/layout/cms-search.tsx +25 -0
- package/templates/init/components/layout/cms-sidebar.tsx +192 -0
- package/templates/init/components/layout/cms-sign-out.tsx +30 -0
- package/templates/init/components/shared/delete-dialog.tsx +75 -0
- package/templates/init/components/shared/page-header.tsx +23 -0
- package/templates/init/components/shared/status-badge.tsx +43 -0
- package/templates/init/data/navigation.ts +39 -0
- package/templates/init/db/client.ts +8 -0
- package/templates/init/db/schema.ts +88 -0
- package/{dist/chunk-6JCWMKSY.js → templates/init/drizzle.config.ts} +2 -11
- package/templates/init/hooks/use-cms-theme.tsx +78 -0
- package/templates/init/hooks/use-editor-image-upload.ts +82 -0
- package/templates/init/hooks/use-local-storage.ts +46 -0
- package/templates/init/hooks/use-mobile.ts +19 -0
- package/templates/init/hooks/use-upload.ts +177 -0
- package/templates/init/hooks/use-users.ts +13 -0
- package/templates/init/lib/actions/form-settings.ts +126 -0
- package/templates/init/lib/actions/profile.ts +62 -0
- package/templates/init/lib/actions/upload.ts +153 -0
- package/templates/init/lib/actions/users.ts +145 -0
- package/templates/init/lib/auth/auth-client.ts +12 -0
- package/templates/init/lib/auth/auth.ts +43 -0
- package/templates/init/lib/auth/middleware.ts +44 -0
- package/templates/init/lib/markdown/cached.ts +7 -0
- package/templates/init/lib/markdown/format.ts +55 -0
- package/templates/init/lib/markdown/render.ts +182 -0
- package/templates/init/lib/r2.ts +55 -0
- package/templates/init/pages/account-layout.tsx +63 -0
- package/templates/init/pages/authenticated-layout.tsx +26 -0
- package/templates/init/pages/cms-layout.tsx +16 -0
- package/templates/init/pages/dashboard-page.tsx +91 -0
- package/templates/init/pages/login-form.tsx +117 -0
- package/templates/init/pages/login-page.tsx +17 -0
- package/templates/init/pages/profile/profile-form.tsx +361 -0
- package/templates/init/pages/profile/profile-page.tsx +34 -0
- package/templates/init/pages/users/columns.tsx +241 -0
- package/templates/init/pages/users/create-user-dialog.tsx +116 -0
- package/templates/init/pages/users/edit-role-dialog.tsx +92 -0
- package/templates/init/pages/users/users-page-content.tsx +29 -0
- package/templates/init/pages/users/users-page.tsx +19 -0
- package/templates/init/pages/users/users-table.tsx +219 -0
- package/templates/init/types/auth.ts +78 -0
- package/templates/init/types/index.ts +79 -0
- package/templates/init/types/table-meta.ts +16 -0
- package/templates/init/utils/cn.ts +6 -0
- package/templates/init/utils/mailchimp.ts +39 -0
- package/templates/init/utils/seo.ts +90 -0
- package/templates/init/utils/validation.ts +105 -0
- package/templates/init/utils/webhook.ts +28 -0
- package/templates/ui/alert-dialog.tsx +46 -28
- package/templates/ui/avatar.tsx +37 -20
- package/templates/ui/button.tsx +3 -3
- package/templates/ui/card.tsx +30 -18
- package/templates/ui/dialog.tsx +46 -22
- package/templates/ui/dropdown-menu.tsx +1 -1
- package/templates/ui/input.tsx +1 -1
- package/templates/ui/select.tsx +42 -34
- package/templates/ui/sidebar.tsx +13 -13
- package/templates/ui/table.tsx +2 -2
- package/dist/chunk-6JCWMKSY.js.map +0 -1
- package/dist/drizzle-config-EDKOEZ6G.js +0 -7
- /package/dist/{drizzle-config-EDKOEZ6G.js.map → reader-2T45D7JZ.js.map} +0 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|