@betterstart/cli 0.1.69 → 0.1.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/chunk-E4HZYXQ2.js +36 -0
  2. package/dist/chunk-E4HZYXQ2.js.map +1 -0
  3. package/dist/cli.js +580 -4444
  4. package/dist/cli.js.map +1 -1
  5. package/dist/reader-2T45D7JZ.js +7 -0
  6. package/package.json +1 -1
  7. package/templates/init/api/auth-route.ts +3 -0
  8. package/templates/init/api/upload-route.ts +74 -0
  9. package/templates/init/cms-globals.css +200 -0
  10. package/templates/init/components/data-table/data-table-pagination.tsx +90 -0
  11. package/templates/init/components/data-table/data-table-toolbar.tsx +93 -0
  12. package/templates/init/components/data-table/data-table.tsx +188 -0
  13. package/templates/init/components/layout/cms-header.tsx +32 -0
  14. package/templates/init/components/layout/cms-nav-link.tsx +25 -0
  15. package/templates/init/components/layout/cms-providers.tsx +33 -0
  16. package/templates/init/components/layout/cms-search.tsx +25 -0
  17. package/templates/init/components/layout/cms-sidebar.tsx +192 -0
  18. package/templates/init/components/layout/cms-sign-out.tsx +30 -0
  19. package/templates/init/components/shared/delete-dialog.tsx +75 -0
  20. package/templates/init/components/shared/page-header.tsx +23 -0
  21. package/templates/init/components/shared/status-badge.tsx +43 -0
  22. package/templates/init/data/navigation.ts +39 -0
  23. package/templates/init/db/client.ts +8 -0
  24. package/templates/init/db/schema.ts +88 -0
  25. package/{dist/chunk-6JCWMKSY.js → templates/init/drizzle.config.ts} +2 -11
  26. package/templates/init/hooks/use-cms-theme.tsx +78 -0
  27. package/templates/init/hooks/use-editor-image-upload.ts +82 -0
  28. package/templates/init/hooks/use-local-storage.ts +46 -0
  29. package/templates/init/hooks/use-mobile.ts +19 -0
  30. package/templates/init/hooks/use-upload.ts +177 -0
  31. package/templates/init/hooks/use-users.ts +13 -0
  32. package/templates/init/lib/actions/form-settings.ts +126 -0
  33. package/templates/init/lib/actions/profile.ts +62 -0
  34. package/templates/init/lib/actions/upload.ts +153 -0
  35. package/templates/init/lib/actions/users.ts +145 -0
  36. package/templates/init/lib/auth/auth-client.ts +12 -0
  37. package/templates/init/lib/auth/auth.ts +43 -0
  38. package/templates/init/lib/auth/middleware.ts +44 -0
  39. package/templates/init/lib/markdown/cached.ts +7 -0
  40. package/templates/init/lib/markdown/format.ts +55 -0
  41. package/templates/init/lib/markdown/render.ts +182 -0
  42. package/templates/init/lib/r2.ts +55 -0
  43. package/templates/init/pages/account-layout.tsx +63 -0
  44. package/templates/init/pages/authenticated-layout.tsx +26 -0
  45. package/templates/init/pages/cms-layout.tsx +16 -0
  46. package/templates/init/pages/dashboard-page.tsx +91 -0
  47. package/templates/init/pages/login-form.tsx +117 -0
  48. package/templates/init/pages/login-page.tsx +17 -0
  49. package/templates/init/pages/profile/profile-form.tsx +361 -0
  50. package/templates/init/pages/profile/profile-page.tsx +34 -0
  51. package/templates/init/pages/users/columns.tsx +241 -0
  52. package/templates/init/pages/users/create-user-dialog.tsx +116 -0
  53. package/templates/init/pages/users/edit-role-dialog.tsx +92 -0
  54. package/templates/init/pages/users/users-page-content.tsx +29 -0
  55. package/templates/init/pages/users/users-page.tsx +19 -0
  56. package/templates/init/pages/users/users-table.tsx +219 -0
  57. package/templates/init/types/auth.ts +78 -0
  58. package/templates/init/types/index.ts +79 -0
  59. package/templates/init/types/table-meta.ts +16 -0
  60. package/templates/init/utils/cn.ts +6 -0
  61. package/templates/init/utils/mailchimp.ts +39 -0
  62. package/templates/init/utils/seo.ts +90 -0
  63. package/templates/init/utils/validation.ts +105 -0
  64. package/templates/init/utils/webhook.ts +28 -0
  65. package/templates/ui/alert-dialog.tsx +46 -28
  66. package/templates/ui/avatar.tsx +37 -20
  67. package/templates/ui/button.tsx +3 -3
  68. package/templates/ui/card.tsx +30 -18
  69. package/templates/ui/dialog.tsx +46 -22
  70. package/templates/ui/dropdown-menu.tsx +1 -1
  71. package/templates/ui/input.tsx +1 -1
  72. package/templates/ui/select.tsx +42 -34
  73. package/templates/ui/sidebar.tsx +13 -13
  74. package/templates/ui/table.tsx +2 -2
  75. package/dist/chunk-6JCWMKSY.js.map +0 -1
  76. package/dist/drizzle-config-EDKOEZ6G.js +0 -7
  77. /package/dist/{drizzle-config-EDKOEZ6G.js.map → reader-2T45D7JZ.js.map} +0 -0
@@ -0,0 +1,219 @@
1
+ 'use client'
2
+
3
+ import { authClient } from '@cms/auth/client'
4
+ import { Button } from '@cms/components/ui/button'
5
+ import {
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from '@cms/components/ui/select'
12
+ import {
13
+ Table,
14
+ TableBody,
15
+ TableCell,
16
+ TableHead,
17
+ TableHeader,
18
+ TableRow,
19
+ } from '@cms/components/ui/table'
20
+ import { useUsers } from '@cms/hooks/use-users'
21
+ import type { UserData } from '@cms/types/auth'
22
+ import {
23
+ type ColumnDef,
24
+ type ColumnFiltersState,
25
+ flexRender,
26
+ getCoreRowModel,
27
+ getFilteredRowModel,
28
+ getPaginationRowModel,
29
+ getSortedRowModel,
30
+ type SortingState,
31
+ useReactTable,
32
+ type VisibilityState,
33
+ } from '@tanstack/react-table'
34
+ import { parseAsInteger, useQueryState } from 'nuqs'
35
+ import * as React from 'react'
36
+
37
+ const PAGE_SIZE_OPTIONS = [
38
+ { value: '10', label: '10' },
39
+ { value: '20', label: '20' },
40
+ { value: '50', label: '50' },
41
+ { value: '100', label: '100' },
42
+ { value: 'all', label: 'All' },
43
+ ]
44
+
45
+ interface UsersTableProps<TValue> {
46
+ columns: ColumnDef<UserData, TValue>[]
47
+ }
48
+
49
+ export function UsersTable<TValue>({ columns }: UsersTableProps<TValue>) {
50
+ const { data: session } = authClient.useSession()
51
+ const { data, error, isPending } = useUsers()
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
+ const handlePageSizeChange = React.useCallback(
61
+ (value: string) => {
62
+ React.startTransition(() => {
63
+ if (value === 'all') {
64
+ setPageSize(-1)
65
+ } else {
66
+ setPageSize(Number(value))
67
+ }
68
+ setPageIndex(0)
69
+ })
70
+ },
71
+ [setPageSize, setPageIndex],
72
+ )
73
+
74
+ const handlePaginationChange = React.useCallback(
75
+ (
76
+ updater:
77
+ | { pageIndex: number; pageSize: number }
78
+ | ((old: { pageIndex: number; pageSize: number }) => {
79
+ pageIndex: number
80
+ pageSize: number
81
+ }),
82
+ ) => {
83
+ const currentPagination = { pageIndex, pageSize: effectivePageSize }
84
+ const newPagination = typeof updater === 'function' ? updater(currentPagination) : updater
85
+ React.startTransition(() => {
86
+ setPageIndex(newPagination.pageIndex)
87
+ })
88
+ },
89
+ [pageIndex, effectivePageSize, setPageIndex],
90
+ )
91
+
92
+ const table = useReactTable({
93
+ data: data?.users ?? [],
94
+ columns,
95
+ getCoreRowModel: getCoreRowModel(),
96
+ getPaginationRowModel: getPaginationRowModel(),
97
+ onSortingChange: setSorting,
98
+ getSortedRowModel: getSortedRowModel(),
99
+ onColumnFiltersChange: setColumnFilters,
100
+ getFilteredRowModel: getFilteredRowModel(),
101
+ onColumnVisibilityChange: setColumnVisibility,
102
+ onPaginationChange: handlePaginationChange,
103
+ meta: {
104
+ currentUser: session?.user
105
+ ? {
106
+ id: session.user.id,
107
+ email: session.user.email,
108
+ name: session.user.name,
109
+ image: session.user.image,
110
+ role: (session.user as { role?: string }).role || 'member',
111
+ }
112
+ : null,
113
+ },
114
+ state: {
115
+ sorting,
116
+ columnFilters,
117
+ columnVisibility,
118
+ pagination: {
119
+ pageIndex,
120
+ pageSize: effectivePageSize,
121
+ },
122
+ },
123
+ })
124
+
125
+ return (
126
+ <div className="space-y-6">
127
+ <div className="bg-card border overflow-hidden rounded-lg">
128
+ <Table>
129
+ <TableHeader className="bg-secondary">
130
+ {table.getHeaderGroups().map((headerGroup) => (
131
+ <TableRow key={headerGroup.id}>
132
+ {headerGroup.headers.map((header) => (
133
+ <TableHead key={header.id}>
134
+ {header.isPlaceholder
135
+ ? null
136
+ : flexRender(header.column.columnDef.header, header.getContext())}
137
+ </TableHead>
138
+ ))}
139
+ </TableRow>
140
+ ))}
141
+ </TableHeader>
142
+ <TableBody>
143
+ {isPending ? (
144
+ <TableRow>
145
+ <TableCell colSpan={columns.length} className="h-24 text-center">
146
+ <div className="text-muted-foreground">Loading users...</div>
147
+ </TableCell>
148
+ </TableRow>
149
+ ) : error ? (
150
+ <TableRow>
151
+ <TableCell colSpan={columns.length} className="h-24 text-center">
152
+ <div className="text-destructive">Error: {error.message}</div>
153
+ </TableCell>
154
+ </TableRow>
155
+ ) : table.getRowModel().rows?.length ? (
156
+ table.getRowModel().rows.map((row) => (
157
+ <TableRow key={row.id}>
158
+ {row.getVisibleCells().map((cell) => (
159
+ <TableCell key={cell.id}>
160
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
161
+ </TableCell>
162
+ ))}
163
+ </TableRow>
164
+ ))
165
+ ) : (
166
+ <TableRow>
167
+ <TableCell colSpan={columns.length} className="h-24 text-center">
168
+ No users found.
169
+ </TableCell>
170
+ </TableRow>
171
+ )}
172
+ </TableBody>
173
+ </Table>
174
+ </div>
175
+
176
+ <div className="flex items-center justify-between">
177
+ <div className="flex items-center gap-2">
178
+ <span className="text-sm text-muted-foreground">Rows per page</span>
179
+ <Select
180
+ value={pageSize === -1 ? 'all' : String(pageSize)}
181
+ onValueChange={handlePageSizeChange}
182
+ >
183
+ <SelectTrigger className="w-[100px] h-8">
184
+ <SelectValue />
185
+ </SelectTrigger>
186
+ <SelectContent>
187
+ {PAGE_SIZE_OPTIONS.map((option) => (
188
+ <SelectItem key={option.value} value={option.value}>
189
+ {option.label}
190
+ </SelectItem>
191
+ ))}
192
+ </SelectContent>
193
+ </Select>
194
+ </div>
195
+ <div className="flex items-center space-x-2">
196
+ <Button
197
+ variant="outline"
198
+ size="sm"
199
+ onClick={() => table.previousPage()}
200
+ disabled={!table.getCanPreviousPage()}
201
+ >
202
+ Previous
203
+ </Button>
204
+ <div className="text-sm text-muted-foreground">
205
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount() || 1}
206
+ </div>
207
+ <Button
208
+ variant="outline"
209
+ size="sm"
210
+ onClick={() => table.nextPage()}
211
+ disabled={!table.getCanNextPage()}
212
+ >
213
+ Next
214
+ </Button>
215
+ </div>
216
+ </div>
217
+ </div>
218
+ )
219
+ }
@@ -0,0 +1,78 @@
1
+ export interface AuthUser {
2
+ id: string
3
+ email: string
4
+ name: string
5
+ emailVerified: boolean
6
+ image: string | null
7
+ role: string
8
+ createdAt: Date
9
+ updatedAt: Date
10
+ }
11
+
12
+ export interface AuthSession {
13
+ user: AuthUser
14
+ isAuthenticated: boolean
15
+ }
16
+
17
+ export enum UserRole {
18
+ ADMIN = 'admin',
19
+ EDITOR = 'editor',
20
+ MEMBER = 'member',
21
+ }
22
+
23
+ export interface UserWithRole extends AuthUser {
24
+ role: UserRole
25
+ }
26
+
27
+ /** Type guard to check if a value is a valid UserRole */
28
+ export function isUserRole(value: unknown): value is UserRole {
29
+ return typeof value === 'string' && Object.values(UserRole).includes(value as UserRole)
30
+ }
31
+
32
+ /** Check if user has one of the allowed roles */
33
+ export function hasRequiredRole(userRole: UserRole, allowedRoles: UserRole[]): boolean {
34
+ return allowedRoles.includes(userRole)
35
+ }
36
+
37
+ export interface AuthState {
38
+ user: AuthUser | null
39
+ loading: boolean
40
+ error: Error | null
41
+ }
42
+
43
+ export interface UserData {
44
+ id: string
45
+ email: string
46
+ name: string
47
+ emailVerified: boolean
48
+ createdAt: string
49
+ updatedAt: string
50
+ role: string
51
+ }
52
+
53
+ export interface UsersResponse {
54
+ users: UserData[]
55
+ total: number
56
+ }
57
+
58
+ export interface CreateUserInput {
59
+ email: string
60
+ name: string
61
+ password: string
62
+ }
63
+
64
+ export interface CreateUserResult {
65
+ success: boolean
66
+ error?: string
67
+ user?: UserData
68
+ }
69
+
70
+ export interface UpdateUserRoleInput {
71
+ userId: string
72
+ role: UserRole
73
+ }
74
+
75
+ export interface UpdateUserRoleResult {
76
+ success: boolean
77
+ error?: string
78
+ }
@@ -0,0 +1,79 @@
1
+ export * from './auth'
2
+
3
+ /** Markdown editor component types */
4
+ export interface MDXComponent {
5
+ name: string
6
+ snippet: string
7
+ category: string
8
+ }
9
+
10
+ export interface MarkdownEditorProps {
11
+ value?: string
12
+ onChange?: (value: string) => void
13
+ placeholder?: string
14
+ disabled?: boolean
15
+ className?: string
16
+ componentSnippets: Record<string, MDXComponent[]>
17
+ }
18
+
19
+ /** Upload types */
20
+ export interface UploadedFile {
21
+ key: string
22
+ url: string
23
+ filename: string
24
+ size: number
25
+ contentType: string
26
+ }
27
+
28
+ export interface UploadFileResult {
29
+ success: boolean
30
+ error?: string
31
+ files?: UploadedFile[]
32
+ }
33
+
34
+ export interface UploadProgress {
35
+ filename: string
36
+ progress: number
37
+ loaded: number
38
+ total: number
39
+ }
40
+
41
+ /** API response types */
42
+ export interface ApiResponse<T = unknown> {
43
+ success: boolean
44
+ data?: T
45
+ error?: string
46
+ message?: string
47
+ }
48
+
49
+ /** Database response types */
50
+ export interface PaginationParams {
51
+ page: number
52
+ limit: number
53
+ sortBy?: string
54
+ sortOrder?: 'asc' | 'desc'
55
+ }
56
+
57
+ export interface PaginatedResponse<T> {
58
+ data: T[]
59
+ total: number
60
+ page: number
61
+ limit: number
62
+ totalPages: number
63
+ }
64
+
65
+ /** TanStack Table meta augmentation */
66
+ declare module '@tanstack/react-table' {
67
+ // biome-ignore lint/correctness/noUnusedVariables: augmenting module
68
+ interface TableMeta<TData extends import('@tanstack/react-table').RowData> {
69
+ reorderMode?: boolean
70
+ onMoveRow?: (id: number, direction: 'up' | 'down') => void
71
+ currentUser?: {
72
+ id: string
73
+ email: string
74
+ name: string
75
+ image?: string | null
76
+ role: string
77
+ } | null
78
+ }
79
+ }
@@ -0,0 +1,16 @@
1
+ import type { RowData } from '@tanstack/react-table'
2
+
3
+ declare module '@tanstack/react-table' {
4
+ // biome-ignore lint/correctness/noUnusedVariables: augmenting module
5
+ interface TableMeta<TData extends RowData> {
6
+ reorderMode?: boolean
7
+ onMoveRow?: (id: number, direction: 'up' | 'down') => void
8
+ currentUser?: {
9
+ id: string
10
+ email: string
11
+ name: string
12
+ image?: string | null
13
+ role: string
14
+ } | null
15
+ }
16
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,39 @@
1
+ import mailchimp from '@mailchimp/mailchimp_marketing'
2
+
3
+ if (process.env.BETTERSTART_MAILCHIMP_API_KEY && process.env.BETTERSTART_MAILCHIMP_SERVER_PREFIX) {
4
+ mailchimp.setConfig({
5
+ apiKey: process.env.BETTERSTART_MAILCHIMP_API_KEY,
6
+ server: process.env.BETTERSTART_MAILCHIMP_SERVER_PREFIX
7
+ })
8
+ }
9
+
10
+ /**
11
+ * Add an email address to the Mailchimp audience (fire-and-forget, non-blocking)
12
+ * Follows the same pattern as sendWebhook() in utils/webhook.ts
13
+ */
14
+ export function addToMailchimpAudience(email: string): void {
15
+ if (
16
+ !process.env.BETTERSTART_MAILCHIMP_API_KEY ||
17
+ !process.env.BETTERSTART_MAILCHIMP_SERVER_PREFIX ||
18
+ !process.env.BETTERSTART_MAILCHIMP_AUDIENCE_ID
19
+ )
20
+ return
21
+
22
+ ;(async () => {
23
+ try {
24
+ await mailchimp.lists.addListMember(process.env.BETTERSTART_MAILCHIMP_AUDIENCE_ID!, {
25
+ email_address: email,
26
+ status: 'subscribed' as const,
27
+ merge_fields: {}
28
+ })
29
+ } catch (error: unknown) {
30
+ // Silently ignore "Member Exists" (user re-submitting form)
31
+ if (
32
+ (error as { response?: { body?: { title?: string } } })?.response?.body?.title ===
33
+ 'Member Exists'
34
+ )
35
+ return
36
+ console.error('Failed to add to Mailchimp audience:', error)
37
+ }
38
+ })()
39
+ }
@@ -0,0 +1,90 @@
1
+ import type { Metadata } from 'next'
2
+
3
+ interface CreateMetadataOptions {
4
+ title: string
5
+ description: string
6
+ path?: string
7
+ ogImage?: string
8
+ noIndex?: boolean
9
+ }
10
+
11
+ /**
12
+ * Create consistent Next.js metadata for CMS-generated pages.
13
+ * Use this in page.tsx files for entities that have public-facing pages.
14
+ */
15
+ export function createMetadata({
16
+ title,
17
+ description,
18
+ path,
19
+ ogImage,
20
+ noIndex = false,
21
+ }: CreateMetadataOptions): Metadata {
22
+ const metadata: Metadata = {
23
+ title,
24
+ description,
25
+ openGraph: {
26
+ title,
27
+ description,
28
+ type: 'website',
29
+ ...(ogImage && { images: [{ url: ogImage }] }),
30
+ },
31
+ twitter: {
32
+ card: ogImage ? 'summary_large_image' : 'summary',
33
+ title,
34
+ description,
35
+ ...(ogImage && { images: [ogImage] }),
36
+ },
37
+ }
38
+
39
+ if (path) {
40
+ metadata.alternates = { canonical: path }
41
+ }
42
+
43
+ if (noIndex) {
44
+ metadata.robots = { index: false, follow: false }
45
+ }
46
+
47
+ return metadata
48
+ }
49
+
50
+ /**
51
+ * Generate JSON-LD structured data for a blog post / article.
52
+ */
53
+ export function generateArticleSchema({
54
+ title,
55
+ description,
56
+ url,
57
+ imageUrl,
58
+ datePublished,
59
+ dateModified,
60
+ authorName,
61
+ }: {
62
+ title: string
63
+ description: string
64
+ url: string
65
+ imageUrl?: string
66
+ datePublished: string
67
+ dateModified?: string
68
+ authorName?: string
69
+ }) {
70
+ return {
71
+ '@context': 'https://schema.org',
72
+ '@type': 'BlogPosting',
73
+ headline: title,
74
+ description,
75
+ url,
76
+ ...(imageUrl && { image: imageUrl }),
77
+ datePublished,
78
+ ...(dateModified && { dateModified }),
79
+ ...(authorName && {
80
+ author: { '@type': 'Person', name: authorName },
81
+ }),
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Serialize a JSON-LD schema object to a string for embedding in a script tag.
87
+ */
88
+ export function schemaToJson(schema: Record<string, unknown>): string {
89
+ return JSON.stringify(schema)
90
+ }
@@ -0,0 +1,105 @@
1
+ export interface FileValidationConfig {
2
+ maxSizeInBytes?: number
3
+ allowedTypes?: string[]
4
+ maxFiles?: number
5
+ }
6
+
7
+ export interface FileValidationError {
8
+ filename: string
9
+ error: string
10
+ }
11
+
12
+ export interface FileValidationResult {
13
+ valid: boolean
14
+ errors: FileValidationError[]
15
+ }
16
+
17
+ const DEFAULT_MAX_SIZE = 10 * 1024 * 1024 // 10MB
18
+ const DEFAULT_MAX_FILES = 10
19
+
20
+ function isFileTypeAllowed(file: File, allowedTypes: string[]): boolean {
21
+ for (const type of allowedTypes) {
22
+ if (type.endsWith('/*')) {
23
+ const prefix = type.slice(0, -1)
24
+ if (file.type.startsWith(prefix)) return true
25
+ } else if (type.startsWith('.')) {
26
+ if (file.name.toLowerCase().endsWith(type.toLowerCase())) return true
27
+ } else {
28
+ if (file.type === type) return true
29
+ }
30
+ }
31
+ return false
32
+ }
33
+
34
+ /**
35
+ * Validate an array of files against size, type, and count constraints.
36
+ */
37
+ export function validateFiles(
38
+ files: File[],
39
+ config: FileValidationConfig = {},
40
+ ): FileValidationResult {
41
+ const { maxSizeInBytes = DEFAULT_MAX_SIZE, allowedTypes, maxFiles = DEFAULT_MAX_FILES } = config
42
+
43
+ const errors: FileValidationError[] = []
44
+
45
+ if (files.length > maxFiles) {
46
+ errors.push({
47
+ filename: '',
48
+ error: `Too many files. Maximum is ${maxFiles}.`,
49
+ })
50
+ }
51
+
52
+ for (const file of files) {
53
+ if (file.size > maxSizeInBytes) {
54
+ const maxMB = Math.round(maxSizeInBytes / (1024 * 1024))
55
+ errors.push({
56
+ filename: file.name,
57
+ error: `File exceeds maximum size of ${maxMB}MB.`,
58
+ })
59
+ }
60
+
61
+ if (allowedTypes && allowedTypes.length > 0 && !isFileTypeAllowed(file, allowedTypes)) {
62
+ errors.push({
63
+ filename: file.name,
64
+ error: `File type "${file.type || 'unknown'}" is not allowed.`,
65
+ })
66
+ }
67
+ }
68
+
69
+ return { valid: errors.length === 0, errors }
70
+ }
71
+
72
+ /**
73
+ * Format bytes to a human-readable string.
74
+ */
75
+ export function formatFileSize(bytes: number): string {
76
+ if (bytes === 0) return '0 B'
77
+ const units = ['B', 'KB', 'MB', 'GB']
78
+ const i = Math.floor(Math.log(bytes) / Math.log(1024))
79
+ return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`
80
+ }
81
+
82
+ /**
83
+ * Check if a string is a valid URL.
84
+ */
85
+ export function isValidUrl(str: string): boolean {
86
+ try {
87
+ new URL(str)
88
+ return true
89
+ } catch {
90
+ return false
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Slugify a string for use in URLs.
96
+ */
97
+ export function slugify(text: string): string {
98
+ return text
99
+ .toLowerCase()
100
+ .trim()
101
+ .replace(/[^\w\s-]/g, '')
102
+ .replace(/[\s_]+/g, '-')
103
+ .replace(/-+/g, '-')
104
+ .replace(/^-|-$/g, '')
105
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Send payload to webhook URL (fire-and-forget, non-blocking)
3
+ * @param webhookUrl - The webhook URL to send data to
4
+ * @param payload - The form data to send as URL-encoded
5
+ */
6
+ export function sendWebhook(
7
+ webhookUrl: string | null | undefined,
8
+ payload: Record<string, unknown>,
9
+ ): void {
10
+ if (!webhookUrl) return // Fire-and-forget: runs in background, doesn't block
11
+ ;(async () => {
12
+ try {
13
+ const formData = new URLSearchParams()
14
+ for (const [key, value] of Object.entries(payload)) {
15
+ if (value === null || value === undefined) continue
16
+ const stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value)
17
+ formData.append(key, stringValue)
18
+ }
19
+ await fetch(webhookUrl, {
20
+ method: 'POST',
21
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
22
+ body: formData.toString(),
23
+ })
24
+ } catch (error) {
25
+ console.error('Webhook failed:', error)
26
+ }
27
+ })()
28
+ }