@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.
- package/dist/chunk-E4HZYXQ2.js +36 -0
- package/dist/chunk-E4HZYXQ2.js.map +1 -0
- package/dist/cli.js +799 -4586
- 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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
type Theme = 'light' | 'dark' | 'system'
|
|
6
|
+
|
|
7
|
+
interface ThemeContext {
|
|
8
|
+
theme: Theme
|
|
9
|
+
setTheme: (theme: Theme) => void
|
|
10
|
+
resolvedTheme: 'light' | 'dark'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const CmsThemeContext = React.createContext<ThemeContext | undefined>(undefined)
|
|
14
|
+
|
|
15
|
+
const STORAGE_KEY = 'cms-theme'
|
|
16
|
+
|
|
17
|
+
function getSystemTheme(): 'light' | 'dark' {
|
|
18
|
+
if (typeof window === 'undefined') return 'light'
|
|
19
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function CmsThemeProvider({ children }: { children: React.ReactNode }) {
|
|
23
|
+
const [theme, setThemeState] = React.useState<Theme>('system')
|
|
24
|
+
const [resolved, setResolved] = React.useState<'light' | 'dark'>('light')
|
|
25
|
+
|
|
26
|
+
// Read from localStorage on mount
|
|
27
|
+
React.useEffect(() => {
|
|
28
|
+
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
|
|
29
|
+
if (stored && ['light', 'dark', 'system'].includes(stored)) {
|
|
30
|
+
setThemeState(stored)
|
|
31
|
+
}
|
|
32
|
+
}, [])
|
|
33
|
+
|
|
34
|
+
// Resolve theme and apply .dark class to .cms-root
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
const actual = theme === 'system' ? getSystemTheme() : theme
|
|
37
|
+
setResolved(actual)
|
|
38
|
+
|
|
39
|
+
const root = document.querySelector('.cms-root')
|
|
40
|
+
if (root) {
|
|
41
|
+
root.classList.toggle('dark', actual === 'dark')
|
|
42
|
+
}
|
|
43
|
+
}, [theme])
|
|
44
|
+
|
|
45
|
+
// Listen for system theme changes
|
|
46
|
+
React.useEffect(() => {
|
|
47
|
+
if (theme !== 'system') return
|
|
48
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
|
49
|
+
const handler = () => {
|
|
50
|
+
const actual = getSystemTheme()
|
|
51
|
+
setResolved(actual)
|
|
52
|
+
const root = document.querySelector('.cms-root')
|
|
53
|
+
if (root) {
|
|
54
|
+
root.classList.toggle('dark', actual === 'dark')
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
mq.addEventListener('change', handler)
|
|
58
|
+
return () => mq.removeEventListener('change', handler)
|
|
59
|
+
}, [theme])
|
|
60
|
+
|
|
61
|
+
const setTheme = React.useCallback((t: Theme) => {
|
|
62
|
+
setThemeState(t)
|
|
63
|
+
localStorage.setItem(STORAGE_KEY, t)
|
|
64
|
+
}, [])
|
|
65
|
+
|
|
66
|
+
const value = React.useMemo(
|
|
67
|
+
() => ({ theme, setTheme, resolvedTheme: resolved }),
|
|
68
|
+
[theme, setTheme, resolved],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return <CmsThemeContext.Provider value={value}>{children}</CmsThemeContext.Provider>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function useTheme(): ThemeContext {
|
|
75
|
+
const ctx = React.useContext(CmsThemeContext)
|
|
76
|
+
if (!ctx) throw new Error('useTheme must be used within CmsThemeProvider')
|
|
77
|
+
return ctx
|
|
78
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { useUpload } from './use-upload'
|
|
5
|
+
|
|
6
|
+
export interface EditorImageUploadResult {
|
|
7
|
+
url: string
|
|
8
|
+
filename: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UseEditorImageUploadOptions {
|
|
12
|
+
onImagesUploaded: (images: EditorImageUploadResult[]) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useEditorImageUpload({ onImagesUploaded }: UseEditorImageUploadOptions) {
|
|
16
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
|
17
|
+
const onImagesUploadedRef = React.useRef(onImagesUploaded)
|
|
18
|
+
onImagesUploadedRef.current = onImagesUploaded
|
|
19
|
+
|
|
20
|
+
const { upload, progress, mutation } = useUpload({
|
|
21
|
+
accept: 'image/*',
|
|
22
|
+
maxSizeInMB: 10,
|
|
23
|
+
prefix: 'images',
|
|
24
|
+
onSuccess: (result) => {
|
|
25
|
+
if (result.success && result.files) {
|
|
26
|
+
const images: EditorImageUploadResult[] = result.files.map((f) => ({
|
|
27
|
+
url: f.url,
|
|
28
|
+
filename: f.filename,
|
|
29
|
+
}))
|
|
30
|
+
onImagesUploadedRef.current(images)
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const isUploading = mutation.isPending
|
|
36
|
+
|
|
37
|
+
const uploadImages = React.useCallback(
|
|
38
|
+
(files: File[]) => {
|
|
39
|
+
const imageFiles = files.filter((f) => f.type.startsWith('image/'))
|
|
40
|
+
if (imageFiles.length === 0) return
|
|
41
|
+
upload(imageFiles, 'images')
|
|
42
|
+
},
|
|
43
|
+
[upload],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const handleDrop = React.useCallback(
|
|
47
|
+
(e: React.DragEvent) => {
|
|
48
|
+
e.preventDefault()
|
|
49
|
+
e.stopPropagation()
|
|
50
|
+
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'))
|
|
51
|
+
if (files.length > 0) {
|
|
52
|
+
uploadImages(files)
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
[uploadImages],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const openFilePicker = React.useCallback(() => {
|
|
59
|
+
fileInputRef.current?.click()
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
const handleFileInputChange = React.useCallback(
|
|
63
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
64
|
+
const files = Array.from(e.target.files || [])
|
|
65
|
+
if (files.length > 0) {
|
|
66
|
+
uploadImages(files)
|
|
67
|
+
}
|
|
68
|
+
e.target.value = ''
|
|
69
|
+
},
|
|
70
|
+
[uploadImages],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
uploadImages,
|
|
75
|
+
isUploading,
|
|
76
|
+
progress,
|
|
77
|
+
handleDrop,
|
|
78
|
+
openFilePicker,
|
|
79
|
+
fileInputRef,
|
|
80
|
+
handleFileInputChange,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
const CMS_STORAGE_PREFIX = 'cms-'
|
|
4
|
+
|
|
5
|
+
/** Type-safe wrapper for localStorage operations with cms- prefix. */
|
|
6
|
+
export function useLocalStorage<T>(key: string) {
|
|
7
|
+
const prefixedKey = CMS_STORAGE_PREFIX + key
|
|
8
|
+
|
|
9
|
+
const setItem = React.useCallback(
|
|
10
|
+
(value: T) => {
|
|
11
|
+
try {
|
|
12
|
+
localStorage.setItem(prefixedKey, JSON.stringify(value))
|
|
13
|
+
} catch {
|
|
14
|
+
// Silent failure for localStorage access errors
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
[prefixedKey],
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const getItem = React.useCallback((): T | null => {
|
|
21
|
+
try {
|
|
22
|
+
const stored = localStorage.getItem(prefixedKey)
|
|
23
|
+
return stored ? JSON.parse(stored) : null
|
|
24
|
+
} catch {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
}, [prefixedKey])
|
|
28
|
+
|
|
29
|
+
const removeItem = React.useCallback(() => {
|
|
30
|
+
try {
|
|
31
|
+
localStorage.removeItem(prefixedKey)
|
|
32
|
+
} catch {
|
|
33
|
+
// Silent failure for localStorage access errors
|
|
34
|
+
}
|
|
35
|
+
}, [prefixedKey])
|
|
36
|
+
|
|
37
|
+
const hasItem = React.useCallback((): boolean => {
|
|
38
|
+
try {
|
|
39
|
+
return localStorage.getItem(prefixedKey) !== null
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}, [prefixedKey])
|
|
44
|
+
|
|
45
|
+
return { setItem, getItem, removeItem, hasItem }
|
|
46
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
const MOBILE_BREAKPOINT = 768
|
|
4
|
+
|
|
5
|
+
export function useIsMobile() {
|
|
6
|
+
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
|
7
|
+
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
10
|
+
const onChange = () => {
|
|
11
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
12
|
+
}
|
|
13
|
+
mql.addEventListener('change', onChange)
|
|
14
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
15
|
+
return () => mql.removeEventListener('change', onChange)
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
return !!isMobile
|
|
19
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { UploadFileResult, UploadProgress } from '@cms/types'
|
|
4
|
+
import { type FileValidationConfig, validateFiles } from '@cms/utils/validation'
|
|
5
|
+
import { type UseMutationResult, useMutation } from '@tanstack/react-query'
|
|
6
|
+
import * as React from 'react'
|
|
7
|
+
|
|
8
|
+
export interface UseUploadOptions {
|
|
9
|
+
/** File validation configuration */
|
|
10
|
+
validationConfig?: FileValidationConfig
|
|
11
|
+
/** Accept string (e.g., "image/*") */
|
|
12
|
+
accept?: string
|
|
13
|
+
/** Maximum file size in MB */
|
|
14
|
+
maxSizeInMB?: number
|
|
15
|
+
/** Callback fired when upload progress changes */
|
|
16
|
+
onProgress?: (progress: UploadProgress[]) => void
|
|
17
|
+
/** Callback fired when upload succeeds */
|
|
18
|
+
onSuccess?: (result: UploadFileResult) => void
|
|
19
|
+
/** Callback fired when upload fails */
|
|
20
|
+
onError?: (error: Error) => void
|
|
21
|
+
/** Optional prefix for file paths in R2 */
|
|
22
|
+
prefix?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UploadMutationVariables {
|
|
26
|
+
files: File[]
|
|
27
|
+
prefix?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UseUploadReturn {
|
|
31
|
+
mutation: UseMutationResult<UploadFileResult, Error, UploadMutationVariables, unknown>
|
|
32
|
+
progress: UploadProgress[]
|
|
33
|
+
upload: (files: File[], prefix?: string) => void
|
|
34
|
+
validate: (files: File[]) => { valid: boolean; errors: string[] }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseAcceptTypes(accept: string): string[] {
|
|
38
|
+
return accept
|
|
39
|
+
.split(',')
|
|
40
|
+
.map((t) => t.trim())
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useUpload(options: UseUploadOptions = {}): UseUploadReturn {
|
|
45
|
+
const {
|
|
46
|
+
validationConfig: providedConfig,
|
|
47
|
+
accept,
|
|
48
|
+
maxSizeInMB,
|
|
49
|
+
onProgress,
|
|
50
|
+
onSuccess,
|
|
51
|
+
onError,
|
|
52
|
+
prefix: defaultPrefix,
|
|
53
|
+
} = options
|
|
54
|
+
|
|
55
|
+
const validationConfig = React.useMemo<FileValidationConfig>(() => {
|
|
56
|
+
const config: FileValidationConfig = { ...providedConfig }
|
|
57
|
+
|
|
58
|
+
if (accept) {
|
|
59
|
+
const parsedTypes = parseAcceptTypes(accept)
|
|
60
|
+
if (config.allowedTypes && config.allowedTypes.length > 0) {
|
|
61
|
+
config.allowedTypes = [...config.allowedTypes, ...parsedTypes].filter(
|
|
62
|
+
(v, i, a) => a.indexOf(v) === i,
|
|
63
|
+
)
|
|
64
|
+
} else {
|
|
65
|
+
config.allowedTypes = parsedTypes
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (maxSizeInMB !== undefined) {
|
|
70
|
+
config.maxSizeInBytes = maxSizeInMB * 1024 * 1024
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return config
|
|
74
|
+
}, [providedConfig, accept, maxSizeInMB])
|
|
75
|
+
|
|
76
|
+
const [progress, setProgress] = React.useState<UploadProgress[]>([])
|
|
77
|
+
const [_isPending, startTransition] = React.useTransition()
|
|
78
|
+
|
|
79
|
+
const mutation = useMutation<UploadFileResult, Error, UploadMutationVariables>({
|
|
80
|
+
mutationFn: async ({ files, prefix = defaultPrefix }) => {
|
|
81
|
+
const initialProgress: UploadProgress[] = files.map((file) => ({
|
|
82
|
+
filename: file.name,
|
|
83
|
+
progress: 0,
|
|
84
|
+
loaded: 0,
|
|
85
|
+
total: file.size,
|
|
86
|
+
}))
|
|
87
|
+
setProgress(initialProgress)
|
|
88
|
+
onProgress?.(initialProgress)
|
|
89
|
+
|
|
90
|
+
const formData = new FormData()
|
|
91
|
+
if (prefix) {
|
|
92
|
+
formData.append('prefix', prefix)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
files.forEach((file, index) => {
|
|
96
|
+
formData.append(`file${index}`, file)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// Simulate progress since server doesn't support streaming
|
|
100
|
+
const progressInterval = setInterval(() => {
|
|
101
|
+
setProgress((prev) =>
|
|
102
|
+
prev.map((p) => {
|
|
103
|
+
const newProgress = Math.min(p.progress + 10, 90)
|
|
104
|
+
return {
|
|
105
|
+
...p,
|
|
106
|
+
progress: newProgress,
|
|
107
|
+
loaded: Math.floor((p.total * newProgress) / 100),
|
|
108
|
+
}
|
|
109
|
+
}),
|
|
110
|
+
)
|
|
111
|
+
}, 200)
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
if (validationConfig?.maxSizeInBytes) {
|
|
115
|
+
formData.append('maxSizeInBytes', validationConfig.maxSizeInBytes.toString())
|
|
116
|
+
}
|
|
117
|
+
if (validationConfig?.allowedTypes?.length) {
|
|
118
|
+
formData.append('allowedTypes', validationConfig.allowedTypes.join(','))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const response = await fetch('/api/cms/upload', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
body: formData,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const result = (await response.json()) as UploadFileResult
|
|
127
|
+
|
|
128
|
+
if (result.success) {
|
|
129
|
+
const completeProgress: UploadProgress[] = files.map((file) => ({
|
|
130
|
+
filename: file.name,
|
|
131
|
+
progress: 100,
|
|
132
|
+
loaded: file.size,
|
|
133
|
+
total: file.size,
|
|
134
|
+
}))
|
|
135
|
+
setProgress(completeProgress)
|
|
136
|
+
onProgress?.(completeProgress)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return result
|
|
140
|
+
} finally {
|
|
141
|
+
clearInterval(progressInterval)
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
onSuccess: (result) => {
|
|
145
|
+
onSuccess?.(result)
|
|
146
|
+
startTransition(() => {
|
|
147
|
+
setTimeout(() => {
|
|
148
|
+
setProgress([])
|
|
149
|
+
}, 2000)
|
|
150
|
+
})
|
|
151
|
+
},
|
|
152
|
+
onError: (error) => {
|
|
153
|
+
onError?.(error)
|
|
154
|
+
setProgress([])
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const upload = React.useCallback(
|
|
159
|
+
(files: File[], prefix?: string) => {
|
|
160
|
+
mutation.mutate({ files, prefix })
|
|
161
|
+
},
|
|
162
|
+
[mutation],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
const validate = React.useCallback(
|
|
166
|
+
(files: File[]) => {
|
|
167
|
+
const result = validateFiles(files, validationConfig)
|
|
168
|
+
return {
|
|
169
|
+
valid: result.valid,
|
|
170
|
+
errors: result.errors.map((e) => `${e.filename}: ${e.error}`),
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
[validationConfig],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return { mutation, progress, upload, validate }
|
|
177
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { getUsers } from '@cms/actions/users'
|
|
4
|
+
import type { UsersResponse } from '@cms/types/auth'
|
|
5
|
+
import { useQuery } from '@tanstack/react-query'
|
|
6
|
+
|
|
7
|
+
export function useUsers() {
|
|
8
|
+
return useQuery<UsersResponse>({
|
|
9
|
+
queryKey: ['users'],
|
|
10
|
+
queryFn: () => getUsers(),
|
|
11
|
+
staleTime: 0,
|
|
12
|
+
})
|
|
13
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import db from '@cms/db'
|
|
4
|
+
import { formSettings } from '@cms/db/schema'
|
|
5
|
+
import { eq } from 'drizzle-orm'
|
|
6
|
+
|
|
7
|
+
export interface FormSettingsData {
|
|
8
|
+
id: number
|
|
9
|
+
formName: string
|
|
10
|
+
webhookUrl: string | null
|
|
11
|
+
webhookEnabled: boolean
|
|
12
|
+
notificationEmails: string | null
|
|
13
|
+
createdAt: string
|
|
14
|
+
updatedAt: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UpsertFormSettingsInput {
|
|
18
|
+
webhookUrl?: string | null
|
|
19
|
+
webhookEnabled?: boolean
|
|
20
|
+
notificationEmails?: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FormSettingsResult {
|
|
24
|
+
success: boolean
|
|
25
|
+
error?: string
|
|
26
|
+
settings?: FormSettingsData
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function getFormSettings(formName: string): Promise<FormSettingsData | null> {
|
|
30
|
+
try {
|
|
31
|
+
const [settings] = await db
|
|
32
|
+
.select()
|
|
33
|
+
.from(formSettings)
|
|
34
|
+
.where(eq(formSettings.formName, formName))
|
|
35
|
+
.limit(1)
|
|
36
|
+
return (settings as FormSettingsData) ?? null
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error(`Error fetching form settings for ${formName}:`, error)
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function upsertFormSettings(
|
|
44
|
+
formName: string,
|
|
45
|
+
data: UpsertFormSettingsInput,
|
|
46
|
+
): Promise<FormSettingsResult> {
|
|
47
|
+
try {
|
|
48
|
+
const existing = await getFormSettings(formName)
|
|
49
|
+
|
|
50
|
+
if (existing) {
|
|
51
|
+
const [updated] = await db
|
|
52
|
+
.update(formSettings)
|
|
53
|
+
.set({
|
|
54
|
+
...data,
|
|
55
|
+
updatedAt: new Date().toISOString(),
|
|
56
|
+
})
|
|
57
|
+
.where(eq(formSettings.formName, formName))
|
|
58
|
+
.returning()
|
|
59
|
+
return { success: true, settings: updated as FormSettingsData }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const [created] = await db
|
|
63
|
+
.insert(formSettings)
|
|
64
|
+
.values({
|
|
65
|
+
formName,
|
|
66
|
+
webhookUrl: data.webhookUrl ?? null,
|
|
67
|
+
webhookEnabled: data.webhookEnabled ?? false,
|
|
68
|
+
notificationEmails: data.notificationEmails ?? null,
|
|
69
|
+
})
|
|
70
|
+
.returning()
|
|
71
|
+
return { success: true, settings: created as FormSettingsData }
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error(`Error upserting form settings for ${formName}:`, error)
|
|
74
|
+
return {
|
|
75
|
+
success: false,
|
|
76
|
+
error: error instanceof Error ? error.message : 'Failed to save form settings',
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function getAllFormSettings(): Promise<FormSettingsData[]> {
|
|
82
|
+
try {
|
|
83
|
+
const settings = await db.select().from(formSettings)
|
|
84
|
+
return settings as FormSettingsData[]
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('Error fetching all form settings:', error)
|
|
87
|
+
return []
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function testFormWebhook(
|
|
92
|
+
formName: string,
|
|
93
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
94
|
+
try {
|
|
95
|
+
const settings = await getFormSettings(formName)
|
|
96
|
+
if (!settings?.webhookUrl) {
|
|
97
|
+
return { success: false, error: 'No webhook URL configured' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const formData = new URLSearchParams()
|
|
101
|
+
formData.append('form_name', formName)
|
|
102
|
+
formData.append('test', 'true')
|
|
103
|
+
formData.append('message', 'This is a test webhook from BetterStart')
|
|
104
|
+
formData.append('timestamp', new Date().toISOString())
|
|
105
|
+
|
|
106
|
+
const response = await fetch(settings.webhookUrl, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
109
|
+
body: formData.toString(),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
return {
|
|
114
|
+
success: false,
|
|
115
|
+
error: `Webhook returned status ${response.status}`,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { success: true }
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return {
|
|
122
|
+
success: false,
|
|
123
|
+
error: error instanceof Error ? error.message : 'Failed to send test webhook',
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { auth } from '@cms/auth'
|
|
4
|
+
import { getSession } from '@cms/auth/middleware'
|
|
5
|
+
import db from '@cms/db'
|
|
6
|
+
import { user } from '@cms/db/schema'
|
|
7
|
+
import { eq } from 'drizzle-orm'
|
|
8
|
+
|
|
9
|
+
export interface UpdateEmailResult {
|
|
10
|
+
success: boolean
|
|
11
|
+
error?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Update the current user's email address.
|
|
16
|
+
* Requires the current password for verification.
|
|
17
|
+
*/
|
|
18
|
+
export async function updateEmail(
|
|
19
|
+
newEmail: string,
|
|
20
|
+
currentPassword: string
|
|
21
|
+
): Promise<UpdateEmailResult> {
|
|
22
|
+
try {
|
|
23
|
+
const session = await getSession()
|
|
24
|
+
if (!session?.user) {
|
|
25
|
+
return { success: false, error: 'Not authenticated' }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Verify current password
|
|
29
|
+
const verification = await auth.api.signInEmail({
|
|
30
|
+
body: { email: session.user.email, password: currentPassword },
|
|
31
|
+
asResponse: false,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
if (!verification?.user) {
|
|
35
|
+
return { success: false, error: 'Incorrect password' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check if new email is already taken
|
|
39
|
+
const existing = await db
|
|
40
|
+
.select({ id: user.id })
|
|
41
|
+
.from(user)
|
|
42
|
+
.where(eq(user.email, newEmail))
|
|
43
|
+
.limit(1)
|
|
44
|
+
|
|
45
|
+
if (existing.length > 0) {
|
|
46
|
+
return { success: false, error: 'Email already in use' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Update the email
|
|
50
|
+
await db
|
|
51
|
+
.update(user)
|
|
52
|
+
.set({ email: newEmail, updatedAt: new Date() })
|
|
53
|
+
.where(eq(user.id, session.user.id))
|
|
54
|
+
|
|
55
|
+
return { success: true }
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return {
|
|
58
|
+
success: false,
|
|
59
|
+
error: error instanceof Error ? error.message : 'Failed to update email',
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|