@betterstart/cli 0.1.69 → 0.1.71

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 +799 -4586
  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,34 @@
1
+ import { getSession } from '@cms/auth/middleware'
2
+ import { PageHeader } from '@cms/components/shared/page-header'
3
+ import { UserRole } from '@cms/types/auth'
4
+ import { redirect } from 'next/navigation'
5
+ import { connection } from 'next/server'
6
+ import { ProfileForm } from './profile-form'
7
+
8
+ export default async function ProfilePage() {
9
+ await connection()
10
+ const session = await getSession()
11
+
12
+ if (!session?.user) {
13
+ redirect('/cms/login')
14
+ }
15
+
16
+ const showPageHeader =
17
+ session.user.role === UserRole.ADMIN ||
18
+ session.user.role === UserRole.EDITOR
19
+
20
+ return (
21
+ <>
22
+ {showPageHeader && <PageHeader title="Profile" />}
23
+ <main className="container mx-auto max-w-2xl p-6 pb-20">
24
+ <ProfileForm
25
+ user={{
26
+ name: session.user.name,
27
+ email: session.user.email,
28
+ image: session.user.image ?? null,
29
+ }}
30
+ />
31
+ </main>
32
+ </>
33
+ )
34
+ }
@@ -0,0 +1,241 @@
1
+ 'use client'
2
+
3
+ import { deleteUser } from '@cms/actions/users'
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ AlertDialogDescription,
10
+ AlertDialogFooter,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ AlertDialogTrigger,
14
+ } from '@cms/components/ui/alert-dialog'
15
+ import { Avatar, AvatarFallback } from '@cms/components/ui/avatar'
16
+ import { Badge } from '@cms/components/ui/badge'
17
+ import { Button } from '@cms/components/ui/button'
18
+ import {
19
+ DropdownMenu,
20
+ DropdownMenuContent,
21
+ DropdownMenuItem,
22
+ DropdownMenuLabel,
23
+ DropdownMenuSeparator,
24
+ DropdownMenuTrigger,
25
+ } from '@cms/components/ui/dropdown-menu'
26
+ import type { UserData } from '@cms/types/auth'
27
+ import { useQueryClient } from '@tanstack/react-query'
28
+ import type { ColumnDef } from '@tanstack/react-table'
29
+ import { ArrowUpDown, Edit, MoreHorizontal, Trash } from 'lucide-react'
30
+ import React from 'react'
31
+ import { toast } from 'sonner'
32
+ import { EditRoleDialog } from './edit-role-dialog'
33
+
34
+ function getInitials(nameOrEmail: string): string {
35
+ const parts = nameOrEmail.includes('@')
36
+ ? nameOrEmail.split('@')[0].split(/[._-]/)
37
+ : nameOrEmail.split(' ')
38
+ return parts
39
+ .filter(Boolean)
40
+ .slice(0, 2)
41
+ .map((p) => p[0].toUpperCase())
42
+ .join('')
43
+ }
44
+
45
+ function DeleteUserAction({
46
+ userId,
47
+ userName,
48
+ isCurrentUser,
49
+ }: {
50
+ userId: string
51
+ userName: string
52
+ isCurrentUser: boolean
53
+ }) {
54
+ const [open, setOpen] = React.useState(false)
55
+ const [isPending, startTransition] = React.useTransition()
56
+ const queryClient = useQueryClient()
57
+
58
+ if (isCurrentUser) return null
59
+
60
+ const handleDelete = () => {
61
+ startTransition(async () => {
62
+ try {
63
+ const result = await deleteUser(userId)
64
+
65
+ if (result.success) {
66
+ toast.success('User deleted successfully')
67
+ queryClient.refetchQueries({ queryKey: ['users'] })
68
+ setOpen(false)
69
+ } else {
70
+ toast.error(result.error || 'Failed to delete user')
71
+ }
72
+ } catch (error) {
73
+ toast.error('An error occurred')
74
+ console.error(error)
75
+ }
76
+ })
77
+ }
78
+
79
+ return (
80
+ <AlertDialog open={open} onOpenChange={setOpen}>
81
+ <AlertDialogTrigger asChild>
82
+ <DropdownMenuItem className="text-destructive" onSelect={(e) => e.preventDefault()}>
83
+ <Trash className="size-4 mr-2" />
84
+ Delete user
85
+ </DropdownMenuItem>
86
+ </AlertDialogTrigger>
87
+ <AlertDialogContent>
88
+ <AlertDialogHeader>
89
+ <AlertDialogTitle>Are you sure?</AlertDialogTitle>
90
+ <AlertDialogDescription>
91
+ This action cannot be undone. This will permanently delete <strong>{userName}</strong>{' '}
92
+ and all of their data.
93
+ </AlertDialogDescription>
94
+ </AlertDialogHeader>
95
+ <AlertDialogFooter>
96
+ <AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
97
+ <AlertDialogAction
98
+ onClick={(e) => {
99
+ e.preventDefault()
100
+ handleDelete()
101
+ }}
102
+ disabled={isPending}
103
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
104
+ >
105
+ {isPending ? 'Deleting...' : 'Delete'}
106
+ </AlertDialogAction>
107
+ </AlertDialogFooter>
108
+ </AlertDialogContent>
109
+ </AlertDialog>
110
+ )
111
+ }
112
+
113
+ export const columns: ColumnDef<UserData>[] = [
114
+ {
115
+ accessorKey: 'email',
116
+ header: ({ column }) => (
117
+ <Button
118
+ variant="ghost"
119
+ onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
120
+ className="hover:bg-muted/50 px-0!"
121
+ >
122
+ User
123
+ <ArrowUpDown className="size-4" />
124
+ </Button>
125
+ ),
126
+ cell: ({ row }) => {
127
+ const email = row.getValue('email') as string
128
+ const name = row.original.name
129
+ return (
130
+ <div className="flex items-center gap-3">
131
+ <Avatar className="size-8">
132
+ <AvatarFallback>{getInitials(name || email)}</AvatarFallback>
133
+ </Avatar>
134
+ <div className="flex flex-col">
135
+ <div className="font-medium">{name || 'N/A'}</div>
136
+ <div className="text-muted-foreground text-sm">{email}</div>
137
+ </div>
138
+ </div>
139
+ )
140
+ },
141
+ },
142
+ {
143
+ accessorKey: 'emailVerified',
144
+ header: 'Status',
145
+ cell: ({ row }) => {
146
+ const verified = row.getValue('emailVerified') as boolean
147
+ return (
148
+ <Badge variant={verified ? 'outline' : 'secondary'}>
149
+ {verified ? 'Verified' : 'Unverified'}
150
+ </Badge>
151
+ )
152
+ },
153
+ },
154
+ {
155
+ accessorKey: 'role',
156
+ header: 'Role',
157
+ cell: ({ row, table }) => {
158
+ const role = row.getValue('role') as string
159
+ const currentUser = table.options.meta?.currentUser
160
+ const isCurrentUser = currentUser?.email === row.original.email
161
+
162
+ if (isCurrentUser) {
163
+ return (
164
+ <Badge variant="outline" className="capitalize">
165
+ {role} (you)
166
+ </Badge>
167
+ )
168
+ }
169
+
170
+ return (
171
+ <EditRoleDialog
172
+ userId={row.original.id}
173
+ currentRole={row.original.role}
174
+ userName={row.original.name}
175
+ >
176
+ <Badge variant="outline" className="capitalize cursor-pointer">
177
+ {role}
178
+ <Edit className="size-3 ml-1" strokeWidth={2} />
179
+ </Badge>
180
+ </EditRoleDialog>
181
+ )
182
+ },
183
+ },
184
+ {
185
+ accessorKey: 'createdAt',
186
+ header: ({ column }) => (
187
+ <Button
188
+ variant="ghost"
189
+ onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
190
+ className="hover:bg-muted/50"
191
+ >
192
+ Joined
193
+ <ArrowUpDown className="size-4" />
194
+ </Button>
195
+ ),
196
+ cell: ({ row }) => {
197
+ const date = new Date(row.getValue('createdAt'))
198
+ return (
199
+ <div className="text-sm">
200
+ {date.toLocaleDateString('en-US', {
201
+ month: 'short',
202
+ day: 'numeric',
203
+ year: 'numeric',
204
+ })}
205
+ </div>
206
+ )
207
+ },
208
+ },
209
+ {
210
+ id: 'actions',
211
+ cell: ({ row, table }) => {
212
+ const currentUser = table.options.meta?.currentUser
213
+ const isCurrentUser = currentUser?.email === row.original.email
214
+
215
+ return (
216
+ <div className="flex justify-end">
217
+ <DropdownMenu>
218
+ <DropdownMenuTrigger asChild>
219
+ <Button variant="ghost" className="size-8 p-0">
220
+ <span className="sr-only">Open menu</span>
221
+ <MoreHorizontal className="size-4" />
222
+ </Button>
223
+ </DropdownMenuTrigger>
224
+ <DropdownMenuContent align="end">
225
+ <DropdownMenuLabel>Actions</DropdownMenuLabel>
226
+ <DropdownMenuItem onClick={() => navigator.clipboard.writeText(row.original.id)}>
227
+ Copy user ID
228
+ </DropdownMenuItem>
229
+ <DropdownMenuSeparator />
230
+ <DeleteUserAction
231
+ userId={row.original.id}
232
+ userName={row.original.name}
233
+ isCurrentUser={isCurrentUser}
234
+ />
235
+ </DropdownMenuContent>
236
+ </DropdownMenu>
237
+ </div>
238
+ )
239
+ },
240
+ },
241
+ ]
@@ -0,0 +1,116 @@
1
+ 'use client'
2
+
3
+ import { createUser } from '@cms/actions/users'
4
+ import { Button } from '@cms/components/ui/button'
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ DialogTrigger,
12
+ } from '@cms/components/ui/dialog'
13
+ import { Input } from '@cms/components/ui/input'
14
+ import { Label } from '@cms/components/ui/label'
15
+ import { useQueryClient } from '@tanstack/react-query'
16
+ import { Loader2, UserPlus } from 'lucide-react'
17
+ import * as React from 'react'
18
+ import { toast } from 'sonner'
19
+
20
+ export function CreateUserDialog() {
21
+ const [open, setOpen] = React.useState(false)
22
+ const [isPending, startTransition] = React.useTransition()
23
+ const queryClient = useQueryClient()
24
+
25
+ const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
26
+ e.preventDefault()
27
+ const formData = new FormData(e.currentTarget)
28
+ const email = formData.get('email') as string
29
+ const name = formData.get('name') as string
30
+ const password = formData.get('password') as string
31
+
32
+ startTransition(async () => {
33
+ try {
34
+ const result = await createUser({ email, name, password })
35
+ if (result.success) {
36
+ toast.success('User created successfully')
37
+ queryClient.refetchQueries({ queryKey: ['users'] })
38
+ setOpen(false)
39
+ } else {
40
+ toast.error(result.error || 'Failed to create user')
41
+ }
42
+ } catch {
43
+ toast.error('An unexpected error occurred')
44
+ }
45
+ })
46
+ }
47
+
48
+ return (
49
+ <Dialog open={open} onOpenChange={setOpen}>
50
+ <DialogTrigger asChild>
51
+ <Button>
52
+ <UserPlus className="size-3.5 -ml-0.5" strokeWidth={2} />
53
+ Create User
54
+ </Button>
55
+ </DialogTrigger>
56
+ <DialogContent className="sm:max-w-[425px]">
57
+ <DialogHeader>
58
+ <DialogTitle>Create New User</DialogTitle>
59
+ <DialogDescription>
60
+ Add a new user to the CMS. They can sign in with these credentials.
61
+ </DialogDescription>
62
+ </DialogHeader>
63
+ <form onSubmit={handleSubmit} className="space-y-4">
64
+ <div className="space-y-2">
65
+ <Label htmlFor="create-email">Email</Label>
66
+ <Input
67
+ id="create-email"
68
+ name="email"
69
+ type="email"
70
+ placeholder="user@example.com"
71
+ required
72
+ disabled={isPending}
73
+ />
74
+ </div>
75
+ <div className="space-y-2">
76
+ <Label htmlFor="create-name">Name</Label>
77
+ <Input
78
+ id="create-name"
79
+ name="name"
80
+ type="text"
81
+ placeholder="Full name"
82
+ required
83
+ disabled={isPending}
84
+ />
85
+ </div>
86
+ <div className="space-y-2">
87
+ <Label htmlFor="create-password">Password</Label>
88
+ <Input
89
+ id="create-password"
90
+ name="password"
91
+ type="password"
92
+ placeholder="Min 8 characters"
93
+ minLength={8}
94
+ required
95
+ disabled={isPending}
96
+ />
97
+ </div>
98
+ <div className="flex justify-end gap-2 pt-2">
99
+ <Button
100
+ type="button"
101
+ variant="outline"
102
+ onClick={() => setOpen(false)}
103
+ disabled={isPending}
104
+ >
105
+ Cancel
106
+ </Button>
107
+ <Button type="submit" disabled={isPending}>
108
+ {isPending && <Loader2 className="size-4 mr-1 animate-spin" />}
109
+ Create User
110
+ </Button>
111
+ </div>
112
+ </form>
113
+ </DialogContent>
114
+ </Dialog>
115
+ )
116
+ }
@@ -0,0 +1,92 @@
1
+ 'use client'
2
+
3
+ import { updateUserRole } from '@cms/actions/users'
4
+ import { Button } from '@cms/components/ui/button'
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ DialogTrigger,
13
+ } from '@cms/components/ui/dialog'
14
+ import { Label } from '@cms/components/ui/label'
15
+ import {
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from '@cms/components/ui/select'
22
+ import { UserRole } from '@cms/types/auth'
23
+ import { useQueryClient } from '@tanstack/react-query'
24
+ import { Loader2 } from 'lucide-react'
25
+ import * as React from 'react'
26
+ import { toast } from 'sonner'
27
+
28
+ interface EditRoleDialogProps {
29
+ userId: string
30
+ currentRole: string
31
+ userName: string
32
+ children: React.ReactNode
33
+ }
34
+
35
+ export function EditRoleDialog({ userId, currentRole, userName, children }: EditRoleDialogProps) {
36
+ const [open, setOpen] = React.useState(false)
37
+ const [role, setRole] = React.useState(currentRole)
38
+ const [isPending, startTransition] = React.useTransition()
39
+ const queryClient = useQueryClient()
40
+
41
+ const handleSave = () => {
42
+ startTransition(async () => {
43
+ try {
44
+ const result = await updateUserRole(userId, role as UserRole)
45
+ if (result.success) {
46
+ toast.success(`Role updated for ${userName}`)
47
+ queryClient.refetchQueries({ queryKey: ['users'] })
48
+ setOpen(false)
49
+ } else {
50
+ toast.error(result.error || 'Failed to update role')
51
+ }
52
+ } catch {
53
+ toast.error('An unexpected error occurred')
54
+ }
55
+ })
56
+ }
57
+
58
+ return (
59
+ <Dialog open={open} onOpenChange={setOpen}>
60
+ <DialogTrigger asChild>{children}</DialogTrigger>
61
+ <DialogContent className="sm:max-w-87.5">
62
+ <DialogHeader>
63
+ <DialogTitle>Edit Role</DialogTitle>
64
+ <DialogDescription>Change the role for {userName}</DialogDescription>
65
+ </DialogHeader>
66
+
67
+ <div className="p-2">
68
+ <Select value={role} onValueChange={setRole}>
69
+ <SelectTrigger className="w-full">
70
+ <SelectValue />
71
+ </SelectTrigger>
72
+ <SelectContent>
73
+ <SelectItem value={UserRole.ADMIN}>Admin</SelectItem>
74
+ <SelectItem value={UserRole.EDITOR}>Editor</SelectItem>
75
+ <SelectItem value={UserRole.MEMBER}>Member</SelectItem>
76
+ </SelectContent>
77
+ </Select>
78
+ </div>
79
+
80
+ <DialogFooter>
81
+ <Button variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
82
+ Cancel
83
+ </Button>
84
+ <Button onClick={handleSave} disabled={isPending || role === currentRole}>
85
+ {isPending && <Loader2 className="size-4 animate-spin" />}
86
+ Save Role
87
+ </Button>
88
+ </DialogFooter>
89
+ </DialogContent>
90
+ </Dialog>
91
+ )
92
+ }
@@ -0,0 +1,29 @@
1
+ 'use client'
2
+
3
+ import type { ColumnDef } from '@tanstack/react-table'
4
+ import { PageHeader } from '@cms/components/shared/page-header'
5
+ import type { UserData } from '@cms/types/auth'
6
+ import { CreateUserDialog } from './create-user-dialog'
7
+ import { UsersTable } from './users-table'
8
+
9
+ interface UsersPageContentProps<TValue> {
10
+ columns: ColumnDef<UserData, TValue>[]
11
+ }
12
+
13
+ export function UsersPageContent<TValue>({ columns }: UsersPageContentProps<TValue>) {
14
+ return (
15
+ <>
16
+ <PageHeader
17
+ title="Users"
18
+ actions={
19
+ <div className="flex items-center gap-2">
20
+ <CreateUserDialog />
21
+ </div>
22
+ }
23
+ />
24
+ <main className="space-y-6 p-6">
25
+ <UsersTable columns={columns} />
26
+ </main>
27
+ </>
28
+ )
29
+ }
@@ -0,0 +1,19 @@
1
+ import * as React from 'react'
2
+ import { columns } from './columns'
3
+ import { UsersPageContent } from './users-page-content'
4
+
5
+ export default function UsersPage() {
6
+ return (
7
+ <React.Suspense
8
+ fallback={
9
+ <div className="flex items-center justify-center h-48">
10
+ <div className="text-muted-foreground">Loading Users...</div>
11
+ </div>
12
+ }
13
+ >
14
+ <div className="flex flex-col">
15
+ <UsersPageContent columns={columns} />
16
+ </div>
17
+ </React.Suspense>
18
+ )
19
+ }