@betterstart/cli 0.1.68 → 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 -4443
- package/dist/cli.js.map +1 -1
- package/dist/reader-2T45D7JZ.js +7 -0
- package/package.json +3 -2
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { SidebarMenuButton } from '@cms/components/ui/sidebar'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { usePathname } from 'next/navigation'
|
|
6
|
+
|
|
7
|
+
export function CmsNavLink({
|
|
8
|
+
href,
|
|
9
|
+
children,
|
|
10
|
+
}: {
|
|
11
|
+
href: string
|
|
12
|
+
children: React.ReactNode
|
|
13
|
+
}) {
|
|
14
|
+
const pathname = usePathname()
|
|
15
|
+
const isActive =
|
|
16
|
+
href === '/cms'
|
|
17
|
+
? pathname === '/cms'
|
|
18
|
+
: pathname === href || pathname.startsWith(href + '/')
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<SidebarMenuButton asChild isActive={isActive}>
|
|
22
|
+
<Link href={href}>{children}</Link>
|
|
23
|
+
</SidebarMenuButton>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Toaster } from '@cms/components/ui/sonner'
|
|
4
|
+
import { CmsThemeProvider } from '@cms/hooks/use-cms-theme'
|
|
5
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
6
|
+
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
|
7
|
+
import { useState } from 'react'
|
|
8
|
+
|
|
9
|
+
export function CmsProviders({ children }: { children: React.ReactNode }) {
|
|
10
|
+
const [queryClient] = useState(
|
|
11
|
+
() =>
|
|
12
|
+
new QueryClient({
|
|
13
|
+
defaultOptions: {
|
|
14
|
+
queries: {
|
|
15
|
+
refetchOnWindowFocus: false,
|
|
16
|
+
refetchOnMount: false,
|
|
17
|
+
refetchOnReconnect: false,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<QueryClientProvider client={queryClient}>
|
|
25
|
+
<CmsThemeProvider>
|
|
26
|
+
<NuqsAdapter>
|
|
27
|
+
{children}
|
|
28
|
+
<Toaster position="top-right" richColors />
|
|
29
|
+
</NuqsAdapter>
|
|
30
|
+
</CmsThemeProvider>
|
|
31
|
+
</QueryClientProvider>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Button } from '@cms/components/ui/button'
|
|
2
|
+
import { Command, Search } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
export const CmsSearch = () => {
|
|
5
|
+
return (
|
|
6
|
+
<div className="flex items-center gap-2 relative w-full max-w-[240px]">
|
|
7
|
+
<Button
|
|
8
|
+
variant="outline"
|
|
9
|
+
className="w-full text-left items-center pr-1.5! py-0 rounded-lg bg-white"
|
|
10
|
+
size="lg"
|
|
11
|
+
>
|
|
12
|
+
<Search className="shrink-0 size-3.5 -ml-0.5 text-muted-foreground/70" />
|
|
13
|
+
<span className="w-full font-normal text-sm text-muted-foreground/70 [text-box-trim:trim-both]">
|
|
14
|
+
Quick search...
|
|
15
|
+
</span>
|
|
16
|
+
<div className="flex items-center gap-1 py-0.5 border rounded-full corner-squircle px-2 border-border bg-background">
|
|
17
|
+
<Command className="size-3! text-muted-foreground" />
|
|
18
|
+
<span className="font-mono text-xs font-medium">K</span>
|
|
19
|
+
</div>
|
|
20
|
+
</Button>
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
CmsSearch.displayName = 'CmsSearch'
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { cn } from '@cms/utils/cn'
|
|
2
|
+
import { getSession } from '@cms/auth/middleware'
|
|
3
|
+
import { Avatar, AvatarFallback, AvatarImage, } from '@cms/components/ui/avatar'
|
|
4
|
+
import {
|
|
5
|
+
Sidebar,
|
|
6
|
+
SidebarContent,
|
|
7
|
+
SidebarFooter,
|
|
8
|
+
SidebarGroup,
|
|
9
|
+
SidebarGroupLabel,
|
|
10
|
+
SidebarHeader,
|
|
11
|
+
SidebarMenu,
|
|
12
|
+
SidebarMenuButton,
|
|
13
|
+
SidebarMenuItem,
|
|
14
|
+
} from '@cms/components/ui/sidebar'
|
|
15
|
+
import { Button, buttonVariants } from '@cms/components/ui/button'
|
|
16
|
+
import { cms } from '@cms/data/cms'
|
|
17
|
+
import { type CmsNavigationItem, cmsNavigation } from '@cms/data/navigation'
|
|
18
|
+
import { BookOpen, Ellipsis, Settings, Users } from 'lucide-react'
|
|
19
|
+
import Link from 'next/link'
|
|
20
|
+
import { Fragment } from 'react'
|
|
21
|
+
import { CmsSignOut } from './cms-sign-out'
|
|
22
|
+
import { getSetting } from '@/cms/lib/actions/settings'
|
|
23
|
+
import { CmsNavLink } from './cms-nav-link'
|
|
24
|
+
import { CmsSearch } from './cms-search'
|
|
25
|
+
import { Separator } from '@cms/components/ui/separator'
|
|
26
|
+
import {
|
|
27
|
+
DropdownMenu,
|
|
28
|
+
DropdownMenuContent,
|
|
29
|
+
DropdownMenuGroup,
|
|
30
|
+
DropdownMenuItem,
|
|
31
|
+
DropdownMenuLabel,
|
|
32
|
+
DropdownMenuSeparator,
|
|
33
|
+
DropdownMenuTrigger,
|
|
34
|
+
} from "@cms/components/ui/dropdown-menu"
|
|
35
|
+
|
|
36
|
+
function groupNavItems(items: CmsNavigationItem[]) {
|
|
37
|
+
const groups: { label: string | null; items: CmsNavigationItem[] }[] = []
|
|
38
|
+
const groupMap = new Map<string | null, CmsNavigationItem[]>()
|
|
39
|
+
|
|
40
|
+
for (const item of items) {
|
|
41
|
+
const key = item.group ?? null
|
|
42
|
+
if (!groupMap.has(key)) {
|
|
43
|
+
const arr: CmsNavigationItem[] = []
|
|
44
|
+
groupMap.set(key, arr)
|
|
45
|
+
groups.push({ label: key, items: arr })
|
|
46
|
+
}
|
|
47
|
+
groupMap.get(key)!.push(item)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return groups
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function CmsSidebar(props: React.ComponentProps<typeof Sidebar>) {
|
|
54
|
+
const session = await getSession()
|
|
55
|
+
const settings = await getSetting()
|
|
56
|
+
const user = session?.user ?? null
|
|
57
|
+
const groups = groupNavItems(cmsNavigation)
|
|
58
|
+
|
|
59
|
+
console.log({ settings });
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Sidebar collapsible="icon" {...props}>
|
|
64
|
+
<SidebarHeader className="border-b border-border h-14 items-center flex w-full">
|
|
65
|
+
<div className="flex items-center gap-2 w-full relative h-full">
|
|
66
|
+
<Link href="/cms" className="flex items-center gap-2 w-full">
|
|
67
|
+
<Avatar className="size-8">
|
|
68
|
+
<AvatarImage src={settings?.logo || "https://assets.betterstart.dev/assets/betterstart-logo.svg"} />
|
|
69
|
+
<AvatarFallback className="text-sm font-semibold text-foreground">
|
|
70
|
+
{settings?.siteName?.charAt(0) ?? cms.name?.charAt(0)}
|
|
71
|
+
</AvatarFallback>
|
|
72
|
+
</Avatar>
|
|
73
|
+
<div className="flex text-foreground items-center gap-1 w-full group-data-[collapsible=icon]:hidden">
|
|
74
|
+
<span className="text-sm font-medium line-clamp-1">
|
|
75
|
+
{settings?.siteName ?? cms.name}
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
</Link>
|
|
79
|
+
</div>
|
|
80
|
+
</SidebarHeader>
|
|
81
|
+
<SidebarContent className='pb-2'>
|
|
82
|
+
<SidebarGroup className="pb-1">
|
|
83
|
+
<CmsSearch />
|
|
84
|
+
</SidebarGroup>
|
|
85
|
+
|
|
86
|
+
{groups.map((group) => (
|
|
87
|
+
<Fragment key={group.label ?? '_ungrouped'}>
|
|
88
|
+
<SidebarGroup>
|
|
89
|
+
{group.label && <SidebarGroupLabel>{group.label}</SidebarGroupLabel>}
|
|
90
|
+
<SidebarMenu>
|
|
91
|
+
{group.items.map((item) => (
|
|
92
|
+
<SidebarMenuItem key={item.href}>
|
|
93
|
+
<CmsNavLink href={item.href}>
|
|
94
|
+
{item.icon && (
|
|
95
|
+
<item.icon strokeWidth={2} />
|
|
96
|
+
)}
|
|
97
|
+
<span>{item.label}</span>
|
|
98
|
+
</CmsNavLink>
|
|
99
|
+
</SidebarMenuItem>
|
|
100
|
+
))}
|
|
101
|
+
</SidebarMenu>
|
|
102
|
+
</SidebarGroup>
|
|
103
|
+
</Fragment>
|
|
104
|
+
))}
|
|
105
|
+
|
|
106
|
+
<SidebarGroup className="mt-auto gap-2">
|
|
107
|
+
<Separator />
|
|
108
|
+
<SidebarMenu>
|
|
109
|
+
<SidebarMenuItem>
|
|
110
|
+
<CmsNavLink href="/cms/users">
|
|
111
|
+
<Users strokeWidth={2} />
|
|
112
|
+
<span>Users</span>
|
|
113
|
+
</CmsNavLink>
|
|
114
|
+
</SidebarMenuItem>
|
|
115
|
+
<SidebarMenuItem>
|
|
116
|
+
<CmsNavLink href="/cms/settings">
|
|
117
|
+
<Settings strokeWidth={2} />
|
|
118
|
+
<span>Settings</span>
|
|
119
|
+
</CmsNavLink>
|
|
120
|
+
</SidebarMenuItem>
|
|
121
|
+
</SidebarMenu>
|
|
122
|
+
<Separator />
|
|
123
|
+
</SidebarGroup>
|
|
124
|
+
</SidebarContent>
|
|
125
|
+
|
|
126
|
+
<SidebarFooter>
|
|
127
|
+
{user && (
|
|
128
|
+
<SidebarMenu>
|
|
129
|
+
<SidebarMenuItem className='pb-4'>
|
|
130
|
+
<DropdownMenu>
|
|
131
|
+
<DropdownMenuTrigger asChild>
|
|
132
|
+
<Button variant="ghost" className='bg-transparent! px-0 w-full justify-start text-left ring-0! border-none'>
|
|
133
|
+
<Avatar className="size-8 shrink-0">
|
|
134
|
+
<AvatarImage src={user.image ?? undefined} />
|
|
135
|
+
<AvatarFallback className="text-xs font-semibold group-hover:bg-white">
|
|
136
|
+
{user.name?.charAt(0) ?? user.email?.charAt(0) ?? '?'}
|
|
137
|
+
</AvatarFallback>
|
|
138
|
+
</Avatar>
|
|
139
|
+
<div className="flex-1 truncate group-data-[collapsible=icon]:hidden">
|
|
140
|
+
<p className="font-medium truncate">{user.name}</p>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div className="group-data-[collapsible=icon]:hidden">
|
|
144
|
+
<span className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'rounded-full group-hover:bg-white size-6!')}>
|
|
145
|
+
<Ellipsis strokeWidth={2} />
|
|
146
|
+
</span>
|
|
147
|
+
</div>
|
|
148
|
+
</Button>
|
|
149
|
+
|
|
150
|
+
</DropdownMenuTrigger>
|
|
151
|
+
<DropdownMenuContent align="start" className="w-68 p-0">
|
|
152
|
+
<DropdownMenuGroup className='py-2 border-b border-border px-2'>
|
|
153
|
+
<DropdownMenuItem className="w-full flex justify-between items-center gap-2 py-2 pr-1 pl-3 cursor-pointer" asChild>
|
|
154
|
+
<Link href="/cms/profile" prefetch={true}>
|
|
155
|
+
<div className="flex gap-2 items-center flex-1 min-w-0">
|
|
156
|
+
<div className="flex flex-col min-w-0">
|
|
157
|
+
<p className="font-medium truncate">{user.name}</p>
|
|
158
|
+
<p className="text-sm text-muted-foreground [text-box-trim:trim-start] truncate">{user.email}</p>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
<Button variant="ghost" className="size-8 pointer-events-none shrink-0">
|
|
162
|
+
<Settings strokeWidth={2} className="size-4" />
|
|
163
|
+
</Button>
|
|
164
|
+
</Link>
|
|
165
|
+
</DropdownMenuItem>
|
|
166
|
+
</DropdownMenuGroup>
|
|
167
|
+
<DropdownMenuGroup className='py-2 border-b border-border px-2'>
|
|
168
|
+
<DropdownMenuItem className='p-0'>
|
|
169
|
+
<Button
|
|
170
|
+
variant="ghost"
|
|
171
|
+
className='justify-between w-full px-3'
|
|
172
|
+
asChild
|
|
173
|
+
>
|
|
174
|
+
<Link href="https://betterstart.io/docs" target="_blank">
|
|
175
|
+
<span>Docs</span>
|
|
176
|
+
<BookOpen className="size-4" />
|
|
177
|
+
</Link>
|
|
178
|
+
</Button>
|
|
179
|
+
</DropdownMenuItem>
|
|
180
|
+
<DropdownMenuItem className='p-0'>
|
|
181
|
+
<CmsSignOut className='justify-between w-full px-3' />
|
|
182
|
+
</DropdownMenuItem>
|
|
183
|
+
</DropdownMenuGroup>
|
|
184
|
+
</DropdownMenuContent>
|
|
185
|
+
</DropdownMenu>
|
|
186
|
+
</SidebarMenuItem>
|
|
187
|
+
</SidebarMenu>
|
|
188
|
+
)}
|
|
189
|
+
</SidebarFooter>
|
|
190
|
+
</Sidebar>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { signOut } from '@cms/auth/client'
|
|
4
|
+
import { Button } from '@cms/components/ui/button'
|
|
5
|
+
import { LogOut } from 'lucide-react'
|
|
6
|
+
import { useRouter } from 'next/navigation'
|
|
7
|
+
import { useState } from 'react'
|
|
8
|
+
|
|
9
|
+
export function CmsSignOut({ className }: { className?: string }) {
|
|
10
|
+
const router = useRouter()
|
|
11
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
12
|
+
|
|
13
|
+
async function handleSignOut() {
|
|
14
|
+
setIsLoading(true)
|
|
15
|
+
await signOut()
|
|
16
|
+
router.push('/cms/login')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Button
|
|
21
|
+
variant="ghost"
|
|
22
|
+
onClick={handleSignOut}
|
|
23
|
+
disabled={isLoading}
|
|
24
|
+
className={className}
|
|
25
|
+
>
|
|
26
|
+
<span>Log Out</span>
|
|
27
|
+
<LogOut className="size-4" />
|
|
28
|
+
</Button>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AlertDialog,
|
|
5
|
+
AlertDialogAction,
|
|
6
|
+
AlertDialogCancel,
|
|
7
|
+
AlertDialogContent,
|
|
8
|
+
AlertDialogDescription,
|
|
9
|
+
AlertDialogFooter,
|
|
10
|
+
AlertDialogHeader,
|
|
11
|
+
AlertDialogTitle,
|
|
12
|
+
AlertDialogTrigger,
|
|
13
|
+
} from '@cms/components/ui/alert-dialog'
|
|
14
|
+
import { Button } from '@cms/components/ui/button'
|
|
15
|
+
import { Trash2 } from 'lucide-react'
|
|
16
|
+
|
|
17
|
+
interface DeleteDialogProps {
|
|
18
|
+
open: boolean
|
|
19
|
+
onOpenChange: (open: boolean) => void
|
|
20
|
+
onConfirm: () => void
|
|
21
|
+
isPending?: boolean
|
|
22
|
+
title?: string
|
|
23
|
+
description?: string
|
|
24
|
+
trigger?: React.ReactNode
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function DeleteDialog({
|
|
28
|
+
open,
|
|
29
|
+
onOpenChange,
|
|
30
|
+
onConfirm,
|
|
31
|
+
isPending = false,
|
|
32
|
+
title = 'Are you sure?',
|
|
33
|
+
description = 'This action cannot be undone. This will permanently delete this item.',
|
|
34
|
+
trigger,
|
|
35
|
+
}: DeleteDialogProps) {
|
|
36
|
+
return (
|
|
37
|
+
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
38
|
+
{trigger && <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>}
|
|
39
|
+
<AlertDialogContent>
|
|
40
|
+
<AlertDialogHeader>
|
|
41
|
+
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
42
|
+
<AlertDialogDescription>{description}</AlertDialogDescription>
|
|
43
|
+
</AlertDialogHeader>
|
|
44
|
+
<AlertDialogFooter>
|
|
45
|
+
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
|
|
46
|
+
<AlertDialogAction
|
|
47
|
+
onClick={(e) => {
|
|
48
|
+
e.preventDefault()
|
|
49
|
+
onConfirm()
|
|
50
|
+
}}
|
|
51
|
+
disabled={isPending}
|
|
52
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
53
|
+
>
|
|
54
|
+
{isPending ? 'Deleting...' : 'Delete'}
|
|
55
|
+
</AlertDialogAction>
|
|
56
|
+
</AlertDialogFooter>
|
|
57
|
+
</AlertDialogContent>
|
|
58
|
+
</AlertDialog>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface DeleteButtonProps {
|
|
63
|
+
onClick: () => void
|
|
64
|
+
label?: string
|
|
65
|
+
count?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function DeleteButton({ onClick, label = 'item', count }: DeleteButtonProps) {
|
|
69
|
+
return (
|
|
70
|
+
<Button variant="destructive" size="default" onClick={onClick}>
|
|
71
|
+
<Trash2 className="size-3.5 -ml-0.5" strokeWidth={2} />
|
|
72
|
+
Delete {count !== undefined ? `${count} ${count === 1 ? label : `${label}s`}` : label}
|
|
73
|
+
</Button>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
interface PageHeaderProps {
|
|
2
|
+
title: string
|
|
3
|
+
children?: React.ReactNode
|
|
4
|
+
search?: React.ReactNode
|
|
5
|
+
actions?: React.ReactNode
|
|
6
|
+
back?: React.ReactNode
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function PageHeader({ title, children, search, actions, back }: PageHeaderProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="grid grid-cols-3 items-center justify-between w-full h-14 px-4 border-b border-border sticky top-0 bg-background/60 backdrop-blur-md z-10">
|
|
12
|
+
<div className="flex items-center justify-start gap-2 w-full">{back && back}</div>
|
|
13
|
+
<div className="flex items-center justify-center gap-2">
|
|
14
|
+
<h2 className="text-sm font-medium tracking-tight">{title}</h2>
|
|
15
|
+
</div>
|
|
16
|
+
<div className="flex items-center justify-end gap-2">
|
|
17
|
+
{children && children}
|
|
18
|
+
{search && search}
|
|
19
|
+
{actions && actions}
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Badge, type BadgeProps } from '@cms/components/ui/badge'
|
|
2
|
+
import { cn } from '@cms/utils/cn'
|
|
3
|
+
|
|
4
|
+
type StatusVariant = 'default' | 'success' | 'warning' | 'error' | 'info'
|
|
5
|
+
|
|
6
|
+
const statusStyles: Record<StatusVariant, string> = {
|
|
7
|
+
default: 'bg-secondary text-secondary-foreground',
|
|
8
|
+
success: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300',
|
|
9
|
+
warning: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300',
|
|
10
|
+
error: 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300',
|
|
11
|
+
info: 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface StatusBadgeProps extends Omit<BadgeProps, 'variant'> {
|
|
15
|
+
status: StatusVariant
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function StatusBadge({ status, className, ...props }: StatusBadgeProps) {
|
|
19
|
+
return (
|
|
20
|
+
<Badge
|
|
21
|
+
variant="outline"
|
|
22
|
+
className={cn('border-none font-medium', statusStyles[status], className)}
|
|
23
|
+
{...props}
|
|
24
|
+
/>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Map boolean values to status badges */
|
|
29
|
+
export function BooleanBadge({
|
|
30
|
+
value,
|
|
31
|
+
trueLabel = 'Yes',
|
|
32
|
+
falseLabel = 'No',
|
|
33
|
+
}: {
|
|
34
|
+
value: boolean
|
|
35
|
+
trueLabel?: string
|
|
36
|
+
falseLabel?: string
|
|
37
|
+
}) {
|
|
38
|
+
return (
|
|
39
|
+
<StatusBadge status={value ? 'success' : 'default'}>
|
|
40
|
+
{value ? trueLabel : falseLabel}
|
|
41
|
+
</StatusBadge>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { LucideIcon } from 'lucide-react'
|
|
2
|
+
import { ChartSpline, FileText, House, ImagePlay, Tag } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
export interface CmsNavigationItem {
|
|
5
|
+
label: string
|
|
6
|
+
href: string
|
|
7
|
+
icon?: LucideIcon
|
|
8
|
+
group?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const cmsNavigation: CmsNavigationItem[] = [
|
|
12
|
+
{
|
|
13
|
+
label: 'Overview',
|
|
14
|
+
href: '/cms',
|
|
15
|
+
icon: House,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
label: 'Analytics',
|
|
19
|
+
href: '/cms/analytics',
|
|
20
|
+
icon: ChartSpline,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
label: 'Media',
|
|
24
|
+
href: '/cms/media',
|
|
25
|
+
icon: ImagePlay,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
label: 'Categories',
|
|
29
|
+
href: '/cms/categories',
|
|
30
|
+
icon: Tag,
|
|
31
|
+
group: 'Blog',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
label: 'Posts',
|
|
35
|
+
href: '/cms/posts',
|
|
36
|
+
icon: FileText,
|
|
37
|
+
group: 'Blog',
|
|
38
|
+
},
|
|
39
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
import { boolean, pgTable, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core'
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Better Auth tables
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
export const user = pgTable('user', {
|
|
9
|
+
id: text('id').primaryKey(),
|
|
10
|
+
name: text('name').notNull(),
|
|
11
|
+
email: text('email').notNull().unique(),
|
|
12
|
+
emailVerified: boolean('email_verified').notNull().default(false),
|
|
13
|
+
image: text('image'),
|
|
14
|
+
role: text('role').notNull().default('member'),
|
|
15
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
16
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export const session = pgTable('session', {
|
|
20
|
+
id: text('id').primaryKey(),
|
|
21
|
+
expiresAt: timestamp('expires_at').notNull(),
|
|
22
|
+
token: text('token').notNull().unique(),
|
|
23
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
24
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
25
|
+
ipAddress: text('ip_address'),
|
|
26
|
+
userAgent: text('user_agent'),
|
|
27
|
+
userId: text('user_id')
|
|
28
|
+
.notNull()
|
|
29
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const account = pgTable('account', {
|
|
33
|
+
id: text('id').primaryKey(),
|
|
34
|
+
accountId: text('account_id').notNull(),
|
|
35
|
+
providerId: text('provider_id').notNull(),
|
|
36
|
+
userId: text('user_id')
|
|
37
|
+
.notNull()
|
|
38
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
39
|
+
accessToken: text('access_token'),
|
|
40
|
+
refreshToken: text('refresh_token'),
|
|
41
|
+
idToken: text('id_token'),
|
|
42
|
+
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
|
43
|
+
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
|
44
|
+
scope: text('scope'),
|
|
45
|
+
password: text('password'),
|
|
46
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
47
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export const verification = pgTable('verification', {
|
|
51
|
+
id: text('id').primaryKey(),
|
|
52
|
+
identifier: text('identifier').notNull(),
|
|
53
|
+
value: text('value').notNull(),
|
|
54
|
+
expiresAt: timestamp('expires_at').notNull(),
|
|
55
|
+
createdAt: timestamp('created_at').defaultNow(),
|
|
56
|
+
updatedAt: timestamp('updated_at').defaultNow(),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Form Settings (shared table for all form configurations)
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
export const formSettings = pgTable(
|
|
64
|
+
'FormSettings',
|
|
65
|
+
{
|
|
66
|
+
id: serial().primaryKey().notNull(),
|
|
67
|
+
formName: varchar('formName', { length: 100 }).notNull(),
|
|
68
|
+
webhookUrl: text('webhookUrl'),
|
|
69
|
+
webhookEnabled: boolean('webhookEnabled').default(false).notNull(),
|
|
70
|
+
notificationEmails: text('notificationEmails'),
|
|
71
|
+
createdAt: timestamp('createdAt', { precision: 3, mode: 'string' })
|
|
72
|
+
.default(sql`CURRENT_TIMESTAMP`)
|
|
73
|
+
.notNull(),
|
|
74
|
+
updatedAt: timestamp('updatedAt', { precision: 3, mode: 'string' })
|
|
75
|
+
.default(sql`CURRENT_TIMESTAMP`)
|
|
76
|
+
.notNull(),
|
|
77
|
+
},
|
|
78
|
+
(table) => [
|
|
79
|
+
uniqueIndex('FormSettings_formName_key').using(
|
|
80
|
+
'btree',
|
|
81
|
+
table.formName.asc().nullsLast().op('text_ops')
|
|
82
|
+
),
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// Generated entity tables (appended by `betterstart generate`)
|
|
88
|
+
// ============================================================================
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
function drizzleConfigTemplate() {
|
|
3
|
-
return `import { readFileSync } from 'node:fs'
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
4
2
|
import { resolve } from 'node:path'
|
|
5
3
|
import { defineConfig } from 'drizzle-kit'
|
|
6
4
|
|
|
@@ -8,7 +6,7 @@ import { defineConfig } from 'drizzle-kit'
|
|
|
8
6
|
for (const envFile of ['.env.local', '.env']) {
|
|
9
7
|
try {
|
|
10
8
|
const content = readFileSync(resolve(process.cwd(), envFile), 'utf-8')
|
|
11
|
-
for (const line of content.split('
|
|
9
|
+
for (const line of content.split('\n')) {
|
|
12
10
|
const trimmed = line.trim()
|
|
13
11
|
if (!trimmed || trimmed.startsWith('#')) continue
|
|
14
12
|
const eqIdx = trimmed.indexOf('=')
|
|
@@ -31,10 +29,3 @@ export default defineConfig({
|
|
|
31
29
|
url: process.env.BETTERSTART_DATABASE_URL!
|
|
32
30
|
}
|
|
33
31
|
})
|
|
34
|
-
`;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export {
|
|
38
|
-
drizzleConfigTemplate
|
|
39
|
-
};
|
|
40
|
-
//# sourceMappingURL=chunk-6JCWMKSY.js.map
|