kilo-cms 0.1.0

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 (179) hide show
  1. package/README.md +186 -0
  2. package/drizzle.kilo.config.ts +17 -0
  3. package/package.json +97 -0
  4. package/src/admin/AiSettingsEditor.tsx +48 -0
  5. package/src/admin/AiWritingAssistant.tsx +159 -0
  6. package/src/admin/ApiTokensEditor.tsx +69 -0
  7. package/src/admin/ArrayField.tsx +118 -0
  8. package/src/admin/BlocksField.tsx +131 -0
  9. package/src/admin/Button.tsx +31 -0
  10. package/src/admin/CloseButton.tsx +18 -0
  11. package/src/admin/CmsLocalesProvider.tsx +19 -0
  12. package/src/admin/CmsSidebar.tsx +147 -0
  13. package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
  14. package/src/admin/CmsThemeProvider.tsx +100 -0
  15. package/src/admin/CollectionListEditor.tsx +278 -0
  16. package/src/admin/CollectionRecordEditor.tsx +420 -0
  17. package/src/admin/ContentEditorForm.tsx +21 -0
  18. package/src/admin/ContentViewSettings.tsx +55 -0
  19. package/src/admin/DashboardBuilder.tsx +284 -0
  20. package/src/admin/DragHandle.tsx +3 -0
  21. package/src/admin/DynamicField.tsx +517 -0
  22. package/src/admin/EditorHeader.tsx +13 -0
  23. package/src/admin/EditorSkeleton.tsx +27 -0
  24. package/src/admin/EmailSettingsEditor.tsx +80 -0
  25. package/src/admin/Field.tsx +215 -0
  26. package/src/admin/FilterBuilder.tsx +62 -0
  27. package/src/admin/GroupField.tsx +61 -0
  28. package/src/admin/ImageUploader.tsx +198 -0
  29. package/src/admin/ListingSettingsModal.tsx +68 -0
  30. package/src/admin/ListingView.tsx +123 -0
  31. package/src/admin/LocalesEditor.tsx +86 -0
  32. package/src/admin/LocalizedField.tsx +46 -0
  33. package/src/admin/MapPicker.tsx +53 -0
  34. package/src/admin/MediaLibrary.tsx +483 -0
  35. package/src/admin/MediaUploader.tsx +55 -0
  36. package/src/admin/Portal.tsx +10 -0
  37. package/src/admin/PublishingPanel.tsx +102 -0
  38. package/src/admin/RecordMeta.tsx +24 -0
  39. package/src/admin/ReviewQueueEditor.tsx +103 -0
  40. package/src/admin/RichTextEditor.tsx +49 -0
  41. package/src/admin/RoleEditor.tsx +121 -0
  42. package/src/admin/RolesEditor.tsx +42 -0
  43. package/src/admin/RowActionsMenu.tsx +64 -0
  44. package/src/admin/ScheduledEditor.tsx +155 -0
  45. package/src/admin/SeoSettingsEditor.tsx +53 -0
  46. package/src/admin/SingleRecordEditor.tsx +215 -0
  47. package/src/admin/SiteSettingsEditor.tsx +96 -0
  48. package/src/admin/Toast.tsx +45 -0
  49. package/src/admin/UserEditor.tsx +64 -0
  50. package/src/admin/UsersEditor.tsx +37 -0
  51. package/src/admin/WebhooksEditor.tsx +115 -0
  52. package/src/admin/admin.css +6637 -0
  53. package/src/admin/globals.css +14 -0
  54. package/src/admin/pages/admin-index.tsx +5 -0
  55. package/src/admin/pages/ai-settings.tsx +9 -0
  56. package/src/admin/pages/api-tokens.tsx +10 -0
  57. package/src/admin/pages/collection-list.tsx +22 -0
  58. package/src/admin/pages/collection-record.tsx +31 -0
  59. package/src/admin/pages/dashboard.tsx +10 -0
  60. package/src/admin/pages/email-settings.tsx +10 -0
  61. package/src/admin/pages/layout.tsx +24 -0
  62. package/src/admin/pages/locales-settings.tsx +10 -0
  63. package/src/admin/pages/login.tsx +25 -0
  64. package/src/admin/pages/media.tsx +9 -0
  65. package/src/admin/pages/review-queue.tsx +20 -0
  66. package/src/admin/pages/role-edit.tsx +11 -0
  67. package/src/admin/pages/roles.tsx +10 -0
  68. package/src/admin/pages/scheduled.tsx +17 -0
  69. package/src/admin/pages/seo-settings.tsx +9 -0
  70. package/src/admin/pages/setup-admin.tsx +76 -0
  71. package/src/admin/pages/single-record.tsx +24 -0
  72. package/src/admin/pages/site-settings.tsx +9 -0
  73. package/src/admin/pages/user-edit.tsx +11 -0
  74. package/src/admin/pages/user-new.tsx +10 -0
  75. package/src/admin/pages/users.tsx +10 -0
  76. package/src/admin/pages/webhooks-settings.tsx +10 -0
  77. package/src/admin/useKeyedRows.ts +35 -0
  78. package/src/ai-config.ts +35 -0
  79. package/src/ai-secrets.ts +17 -0
  80. package/src/ai-types.ts +10 -0
  81. package/src/ai-writer.ts +84 -0
  82. package/src/api-tokens.ts +25 -0
  83. package/src/auth-client.ts +5 -0
  84. package/src/auth.ts +33 -0
  85. package/src/calendar-grid.ts +12 -0
  86. package/src/cli/index.mjs +149 -0
  87. package/src/cms-appearance.ts +55 -0
  88. package/src/collections/content-view.ts +69 -0
  89. package/src/collections/define.ts +17 -0
  90. package/src/collections/filters.ts +92 -0
  91. package/src/collections/index.ts +11 -0
  92. package/src/collections/join.ts +64 -0
  93. package/src/collections/locale.ts +22 -0
  94. package/src/collections/query.ts +160 -0
  95. package/src/collections/registry.ts +88 -0
  96. package/src/collections/schedule.ts +12 -0
  97. package/src/collections/server.ts +12 -0
  98. package/src/collections/single.ts +26 -0
  99. package/src/collections/table.ts +14 -0
  100. package/src/collections/types.ts +336 -0
  101. package/src/collections/validation.ts +217 -0
  102. package/src/collections/values.ts +181 -0
  103. package/src/collections/workflow.ts +90 -0
  104. package/src/config.ts +34 -0
  105. package/src/content-types.ts +218 -0
  106. package/src/dashboard.ts +39 -0
  107. package/src/db.ts +50 -0
  108. package/src/email.ts +39 -0
  109. package/src/format.ts +4 -0
  110. package/src/listQuery.ts +23 -0
  111. package/src/locale-settings.ts +23 -0
  112. package/src/media-storage.ts +19 -0
  113. package/src/permissions.ts +37 -0
  114. package/src/publishing-workflow-db.ts +11 -0
  115. package/src/publishing-workflow.ts +20 -0
  116. package/src/richtext.ts +5 -0
  117. package/src/routes/admin/ai/settings/route.ts +20 -0
  118. package/src/routes/admin/ai/write/route.ts +21 -0
  119. package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
  120. package/src/routes/admin/api-tokens/route.ts +26 -0
  121. package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
  122. package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
  123. package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
  124. package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
  125. package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
  126. package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
  127. package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
  128. package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
  129. package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
  130. package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
  131. package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
  132. package/src/routes/admin/collections/[collection]/route.ts +88 -0
  133. package/src/routes/admin/collections/route.ts +13 -0
  134. package/src/routes/admin/content-views/[type]/route.ts +25 -0
  135. package/src/routes/admin/dashboard/data/route.ts +39 -0
  136. package/src/routes/admin/dashboard/route.ts +30 -0
  137. package/src/routes/admin/email-settings/route.ts +49 -0
  138. package/src/routes/admin/email-settings/test/route.ts +18 -0
  139. package/src/routes/admin/locales/[code]/route.ts +38 -0
  140. package/src/routes/admin/locales/reorder/route.ts +18 -0
  141. package/src/routes/admin/locales/route.ts +46 -0
  142. package/src/routes/admin/media/[id]/restore/route.ts +13 -0
  143. package/src/routes/admin/media/[id]/route.ts +56 -0
  144. package/src/routes/admin/media/bulk/route.ts +46 -0
  145. package/src/routes/admin/media/folders/[id]/route.ts +34 -0
  146. package/src/routes/admin/media/route.ts +41 -0
  147. package/src/routes/admin/media/stats/route.ts +14 -0
  148. package/src/routes/admin/roles/[id]/route.ts +43 -0
  149. package/src/routes/admin/roles/route.ts +34 -0
  150. package/src/routes/admin/settings/route.ts +45 -0
  151. package/src/routes/admin/single/[type]/route.ts +54 -0
  152. package/src/routes/admin/singles/route.ts +15 -0
  153. package/src/routes/admin/upload/route.ts +55 -0
  154. package/src/routes/admin/users/[id]/route.ts +24 -0
  155. package/src/routes/admin/users/route.ts +24 -0
  156. package/src/routes/admin/webhooks/[id]/route.ts +38 -0
  157. package/src/routes/admin/webhooks/route.ts +26 -0
  158. package/src/routes/cron/publish-scheduled.ts +37 -0
  159. package/src/routes/setup/admin.ts +19 -0
  160. package/src/schema/admin-ui.ts +18 -0
  161. package/src/schema/auth.ts +49 -0
  162. package/src/schema/developer-tools.ts +44 -0
  163. package/src/schema/helpers.ts +35 -0
  164. package/src/schema/index.ts +33 -0
  165. package/src/schema/locales.ts +13 -0
  166. package/src/schema/media.ts +22 -0
  167. package/src/schema/rbac.ts +11 -0
  168. package/src/schema/settings.ts +28 -0
  169. package/src/secrets.ts +26 -0
  170. package/src/seo.ts +69 -0
  171. package/src/storage/index.ts +20 -0
  172. package/src/storage/local.ts +27 -0
  173. package/src/storage/s3-compatible.ts +31 -0
  174. package/src/storage/types.ts +8 -0
  175. package/src/storage/vercel-blob.ts +17 -0
  176. package/src/useBodyScrollLock.ts +12 -0
  177. package/src/useLocalStorageState.ts +26 -0
  178. package/src/webhook-events.ts +12 -0
  179. package/src/webhooks.ts +30 -0
@@ -0,0 +1,13 @@
1
+ import { boolean, integer, pgTable, text } from 'drizzle-orm/pg-core'
2
+
3
+ // Admin-editable locale list for `FieldConfig.localized` fields — see
4
+ // src/lib/locale-settings.ts (server read, request-cached) and CmsLocalesProvider (client).
5
+ // An empty table is a valid state (falls back to `fallbackLocaleConfig` in
6
+ // src/lib/collections/locale.ts), so there's no seed migration — the first row is created via
7
+ // the admin UI at /admin/settings/locales.
8
+ export const cmsLocales = pgTable('cms_locales', {
9
+ code: text('code').primaryKey(), // e.g. 'id', 'en' — used as the storage key on localized fields
10
+ label: text('label').notNull(), // e.g. 'Indonesia' — shown on the locale tab
11
+ isDefault: boolean('is_default').notNull().default(false),
12
+ sortOrder: integer('sort_order').notNull().default(0),
13
+ })
@@ -0,0 +1,22 @@
1
+ import { boolean, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
2
+ import { timestamps } from './helpers'
3
+
4
+ export const mediaFolders = pgTable('cms_media_folders', {
5
+ id: text('id').primaryKey(),
6
+ name: text('name').notNull(),
7
+ slug: text('slug').notNull().unique(),
8
+ pinned: boolean('pinned').notNull().default(false),
9
+ ...timestamps,
10
+ })
11
+
12
+ export const media = pgTable('cms_media', {
13
+ id: text('id').primaryKey(),
14
+ filename: text('filename').notNull(),
15
+ url: text('url').notNull().unique(),
16
+ folder: text('folder').notNull().default('uploads'),
17
+ mimeType: text('mime_type').notNull(),
18
+ size: integer('size').notNull(),
19
+ starred: boolean('starred').notNull().default(false),
20
+ deletedAt: timestamp('deleted_at'),
21
+ ...timestamps,
22
+ })
@@ -0,0 +1,11 @@
1
+ import { boolean, jsonb, pgTable, text } from 'drizzle-orm/pg-core'
2
+ import { timestamps } from './helpers'
3
+ import type { StoredRolePermissions } from '../content-types'
4
+
5
+ export const roles = pgTable('cms_roles', {
6
+ id: text('id').primaryKey(),
7
+ name: text('name').notNull(),
8
+ permissions: jsonb('permissions').$type<StoredRolePermissions>().notNull().default({ contentTypes: {}, system: [] }),
9
+ isSystem: boolean('is_system').notNull().default(false),
10
+ ...timestamps,
11
+ })
@@ -0,0 +1,28 @@
1
+ import { jsonb, pgTable, text } from 'drizzle-orm/pg-core'
2
+ import { timestamps } from './helpers'
3
+ import type { CmsAppearance } from '../cms-appearance'
4
+ import type { PublishingWorkflow } from '../publishing-workflow'
5
+
6
+ // `settings` is the one hand-written content type left (see content-types.ts) — this table
7
+ // backs it directly, plus the CMS's own appearance/workflow-policy config.
8
+ export const siteSettings = pgTable('cms_site_settings', {
9
+ id: text('id').primaryKey(),
10
+ siteName: text('site_name').notNull().default('Kilo CMS'),
11
+ logoLight: text('logo_light'),
12
+ logoDark: text('logo_dark'),
13
+ metaTitle: text('meta_title').notNull().default(''),
14
+ metaDescription: text('meta_description').notNull().default(''),
15
+ metaImage: text('meta_image'),
16
+ cmsAppearance: jsonb('cms_appearance').$type<CmsAppearance>().notNull().default({ preset: 'acid', light: {}, dark: {} }),
17
+ publishingWorkflow: jsonb('publishing_workflow').$type<PublishingWorkflow>().notNull().default({ approvalRequired: ['services', 'projects', 'blog'] }),
18
+ ...timestamps,
19
+ })
20
+
21
+ export const aiSettings = pgTable('cms_ai_settings', {
22
+ id: text('id').primaryKey(),
23
+ manusApiKey: text('manus_api_key'),
24
+ deepseekApiKey: text('deepseek_api_key'),
25
+ openaiApiKey: text('openai_api_key'),
26
+ anthropicApiKey: text('anthropic_api_key'),
27
+ ...timestamps,
28
+ })
package/src/secrets.ts ADDED
@@ -0,0 +1,26 @@
1
+ import 'server-only'
2
+
3
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
4
+
5
+ const VERSION = 'v1'
6
+
7
+ function encryptionKey(domain: string) {
8
+ const source = process.env.CMS_ENCRYPTION_KEY || process.env.BETTER_AUTH_SECRET
9
+ if (!source || source === 'replace-with-a-long-random-secret') throw new Error('Set CMS_ENCRYPTION_KEY before saving secrets.')
10
+ return createHash('sha256').update(`the-usual:${domain}:${source}`).digest()
11
+ }
12
+
13
+ export function encryptSecret(value: string, domain: string) {
14
+ const iv = randomBytes(12)
15
+ const cipher = createCipheriv('aes-256-gcm', encryptionKey(domain), iv)
16
+ const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
17
+ return [VERSION, iv.toString('base64url'), cipher.getAuthTag().toString('base64url'), encrypted.toString('base64url')].join(':')
18
+ }
19
+
20
+ export function decryptSecret(payload: string, domain: string) {
21
+ const [version, ivValue, tagValue, encryptedValue] = payload.split(':')
22
+ if (version !== VERSION || !ivValue || !tagValue || !encryptedValue) throw new Error('Stored secret is invalid. Save it again.')
23
+ const decipher = createDecipheriv('aes-256-gcm', encryptionKey(domain), Buffer.from(ivValue, 'base64url'))
24
+ decipher.setAuthTag(Buffer.from(tagValue, 'base64url'))
25
+ return Buffer.concat([decipher.update(Buffer.from(encryptedValue, 'base64url')), decipher.final()]).toString('utf8')
26
+ }
package/src/seo.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { cache } from 'react'
3
+ import type { Metadata } from 'next'
4
+ import { db } from './db'
5
+ import { siteSettings } from './schema'
6
+ import { defaultCmsAppearance, normalizeCmsAppearance } from './cms-appearance'
7
+ import { defaultPublishingWorkflow, normalizePublishingWorkflow } from './publishing-workflow'
8
+
9
+ export const defaultSiteSettings = {
10
+ siteName: 'Kilo CMS',
11
+ logoLight: null as string | null,
12
+ logoDark: null as string | null,
13
+ metaTitle: 'The Usual — Ideas with a pulse',
14
+ metaDescription: 'Independent creative studio for brands with something to say.',
15
+ metaImage: null as string | null,
16
+ cmsAppearance: defaultCmsAppearance,
17
+ publishingWorkflow: defaultPublishingWorkflow,
18
+ }
19
+
20
+ export type SiteSettings = typeof defaultSiteSettings
21
+
22
+ export const getSiteSettings = cache(async function getSiteSettings(): Promise<SiteSettings> {
23
+ try {
24
+ const [settings] = await db.select().from(siteSettings).where(eq(siteSettings.id, 'default')).limit(1)
25
+ if (!settings) return defaultSiteSettings
26
+ return {
27
+ siteName: settings.siteName || defaultSiteSettings.siteName,
28
+ logoLight: settings.logoLight,
29
+ logoDark: settings.logoDark,
30
+ metaTitle: settings.metaTitle || defaultSiteSettings.metaTitle,
31
+ metaDescription: settings.metaDescription || defaultSiteSettings.metaDescription,
32
+ metaImage: settings.metaImage,
33
+ cmsAppearance: normalizeCmsAppearance(settings.cmsAppearance),
34
+ publishingWorkflow: normalizePublishingWorkflow(settings.publishingWorkflow),
35
+ }
36
+ } catch {
37
+ return defaultSiteSettings
38
+ }
39
+ })
40
+
41
+ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
42
+
43
+ export async function siteMetadata(): Promise<Metadata> {
44
+ const settings = await getSiteSettings()
45
+ const icon = settings.logoLight ?? settings.logoDark
46
+ return {
47
+ metadataBase: siteUrl ? new URL(siteUrl) : undefined,
48
+ title: { default: settings.metaTitle, template: `%s — ${settings.siteName}` },
49
+ description: settings.metaDescription,
50
+ icons: icon ? { icon } : undefined,
51
+ openGraph: { siteName: settings.siteName, images: settings.metaImage ? [settings.metaImage] : undefined },
52
+ }
53
+ }
54
+
55
+ type MetaOverride = { title?: string | null; description?: string | null; image?: string | null }
56
+
57
+ export async function pageMetadata(override: MetaOverride): Promise<Metadata> {
58
+ const settings = await getSiteSettings()
59
+ const title = override.title?.trim() || settings.metaTitle
60
+ const description = override.description?.trim() || settings.metaDescription
61
+ const image = override.image || settings.metaImage || undefined
62
+
63
+ return {
64
+ title,
65
+ description,
66
+ openGraph: { title, description, images: image ? [image] : undefined },
67
+ twitter: { card: 'summary_large_image', title, description, images: image ? [image] : undefined },
68
+ }
69
+ }
@@ -0,0 +1,20 @@
1
+ import 'server-only'
2
+ import type { StorageDriver } from './types'
3
+ import { localDriver } from './local'
4
+ import { s3Driver } from './s3-compatible'
5
+ import { vercelBlobDriver } from './vercel-blob'
6
+
7
+ const drivers: Record<string, StorageDriver> = {
8
+ local: localDriver,
9
+ s3: s3Driver,
10
+ 'vercel-blob': vercelBlobDriver,
11
+ }
12
+
13
+ const kind = process.env.STORAGE_DRIVER || 'local'
14
+ const selected = drivers[kind]
15
+ if (!selected && process.env.NODE_ENV !== 'production') {
16
+ console.error(`Unknown STORAGE_DRIVER "${kind}" — expected "local", "s3", or "vercel-blob". Falling back to "local".`)
17
+ }
18
+
19
+ export const storage: StorageDriver = selected ?? localDriver
20
+ export type { StorageDriver } from './types'
@@ -0,0 +1,27 @@
1
+ import 'server-only'
2
+ import { mkdir, writeFile, unlink } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import type { StorageDriver } from './types'
5
+
6
+ // Not viable on serverless hosts (Vercel, etc.) — the filesystem is ephemeral and not shared
7
+ // across invocations/regions. This driver is for local dev and traditional (non-serverless)
8
+ // hosting only; see docs for the s3/vercel-blob drivers when deploying serverless.
9
+ function diskPath(key: string) {
10
+ return path.join(process.cwd(), 'public', 'uploads', key)
11
+ }
12
+
13
+ export const localDriver: StorageDriver = {
14
+ async put(key, data) {
15
+ const directory = path.join(process.cwd(), 'public', 'uploads')
16
+ await mkdir(directory, { recursive: true })
17
+ await writeFile(diskPath(key), data)
18
+ return { url: `/uploads/${key}` }
19
+ },
20
+ async remove(url) {
21
+ const key = url.split('/').pop()
22
+ if (!key) return
23
+ try {
24
+ await unlink(diskPath(key))
25
+ } catch { /* already gone */ }
26
+ },
27
+ }
@@ -0,0 +1,31 @@
1
+ import 'server-only'
2
+ import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'
3
+ import type { StorageDriver } from './types'
4
+
5
+ // One driver for any S3-compatible bucket — covers Cloudflare R2 and Supabase Storage (both
6
+ // expose an S3-compatible API) without pulling in a separate SDK per provider. Only the env
7
+ // vars differ between providers; see .env.example for per-provider setup notes.
8
+ function config() {
9
+ const { S3_ENDPOINT: endpoint, S3_REGION: region = 'auto', S3_BUCKET: bucket, S3_ACCESS_KEY_ID: accessKeyId, S3_SECRET_ACCESS_KEY: secretAccessKey, S3_PUBLIC_URL_BASE: publicUrlBase } = process.env
10
+ if (!endpoint || !bucket || !accessKeyId || !secretAccessKey || !publicUrlBase) {
11
+ throw new Error('STORAGE_DRIVER=s3 requires S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, and S3_PUBLIC_URL_BASE.')
12
+ }
13
+ const client = new S3Client({ endpoint, region, credentials: { accessKeyId, secretAccessKey }, forcePathStyle: true })
14
+ return { client, bucket, publicUrlBase }
15
+ }
16
+
17
+ export const s3Driver: StorageDriver = {
18
+ async put(key, data, contentType) {
19
+ const { client, bucket, publicUrlBase } = config()
20
+ await client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: data, ContentType: contentType }))
21
+ return { url: `${publicUrlBase.replace(/\/$/, '')}/${key}` }
22
+ },
23
+ async remove(url) {
24
+ const key = url.split('/').pop()
25
+ if (!key) return
26
+ try {
27
+ const { client, bucket } = config()
28
+ await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }))
29
+ } catch { /* already gone, or misconfigured — deletion is best-effort */ }
30
+ },
31
+ }
@@ -0,0 +1,8 @@
1
+ import 'server-only'
2
+
3
+ /** A pluggable object-storage backend for uploaded media. `remove` must never throw — a
4
+ * missing/already-deleted object is not an error, since the DB row being gone is what matters. */
5
+ export interface StorageDriver {
6
+ put(key: string, data: Buffer, contentType: string): Promise<{ url: string }>
7
+ remove(url: string): Promise<void>
8
+ }
@@ -0,0 +1,17 @@
1
+ import 'server-only'
2
+ import { put, del } from '@vercel/blob'
3
+ import type { StorageDriver } from './types'
4
+
5
+ // addRandomSuffix: false — our key is already a random UUID, and keeping the key stable is what
6
+ // lets `remove` recover it from the stored URL alone (same contract as the other drivers).
7
+ export const vercelBlobDriver: StorageDriver = {
8
+ async put(key, data, contentType) {
9
+ const blob = await put(key, data, { access: 'public', contentType, addRandomSuffix: false })
10
+ return { url: blob.url }
11
+ },
12
+ async remove(url) {
13
+ try {
14
+ await del(url)
15
+ } catch { /* already gone */ }
16
+ },
17
+ }
@@ -0,0 +1,12 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ export function useBodyScrollLock(locked: boolean) {
6
+ useEffect(() => {
7
+ if (!locked) return
8
+ const previous = document.body.style.overflow
9
+ document.body.style.overflow = 'hidden'
10
+ return () => { document.body.style.overflow = previous }
11
+ }, [locked])
12
+ }
@@ -0,0 +1,26 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ export function useLocalStorageState<T>(key: string, initial: T): [T, (value: T) => void] {
6
+ // The first browser render must use the same value as SSR. Reading storage here
7
+ // can make the client render a saved table layout before React has hydrated the
8
+ // server-rendered default layout.
9
+ const [state, setState] = useState<T>(initial)
10
+ const [ready, setReady] = useState(false)
11
+
12
+ useEffect(() => {
13
+ try {
14
+ const raw = window.localStorage.getItem(key)
15
+ if (raw) setState({ ...initial, ...JSON.parse(raw) })
16
+ } catch { /* storage unavailable or invalid saved value */ }
17
+ setReady(true)
18
+ }, [key])
19
+
20
+ useEffect(() => {
21
+ if (!ready) return
22
+ try { window.localStorage.setItem(key, JSON.stringify(state)) } catch { /* storage unavailable */ }
23
+ }, [key, ready, state])
24
+
25
+ return [state, setState]
26
+ }
@@ -0,0 +1,12 @@
1
+ import { entries, workflowSlugs } from './collections/registry'
2
+
3
+ const verbs = ['created', 'updated', 'published', 'unpublished', 'deleted'] as const
4
+
5
+ export const generatedWebhookSlugs = workflowSlugs.filter((slug) => entries[slug].workflow?.webhooks)
6
+
7
+ export const webhookEventGroups: Array<{ label: string; events: string[] }> = generatedWebhookSlugs.map((slug) => ({
8
+ label: entries[slug].label,
9
+ events: verbs.map((verb) => `${slug}.${verb}`),
10
+ }))
11
+
12
+ export const allWebhookEvents = webhookEventGroups.flatMap((group) => group.events)
@@ -0,0 +1,30 @@
1
+ import 'server-only'
2
+
3
+ import { createHmac } from 'node:crypto'
4
+ import { sql } from 'drizzle-orm'
5
+ import { db } from './db'
6
+ import { webhookDeliveries, webhooks } from './schema'
7
+ import { decryptSecret } from './secrets'
8
+
9
+ export const WEBHOOK_SECRET_DOMAIN = 'cms-webhook-secrets'
10
+ export { allWebhookEvents } from './webhook-events'
11
+
12
+ export async function triggerWebhooks(event: string, payload: unknown) {
13
+ const subscribed = await db.select().from(webhooks).where(sql`${webhooks.enabled} = true and ${webhooks.events} @> ${JSON.stringify([event])}::jsonb`)
14
+ await Promise.all(subscribed.map(async (webhook: any) => {
15
+ const body = JSON.stringify({ event, data: payload, timestamp: new Date().toISOString() })
16
+ let statusCode: number | null = null
17
+ let success = false
18
+ let error: string | null = null
19
+ try {
20
+ const secret = decryptSecret(webhook.encryptedSecret, WEBHOOK_SECRET_DOMAIN)
21
+ const signature = createHmac('sha256', secret).update(body).digest('hex')
22
+ const response = await fetch(webhook.url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webhook-Signature': signature, 'X-Webhook-Event': event }, body, signal: AbortSignal.timeout(5000) })
23
+ statusCode = response.status
24
+ success = response.ok
25
+ } catch (caught) {
26
+ error = caught instanceof Error ? caught.message : 'Delivery failed'
27
+ }
28
+ await db.insert(webhookDeliveries).values({ id: crypto.randomUUID(), webhookId: webhook.id, event, statusCode, success, error })
29
+ }))
30
+ }