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.
- package/README.md +186 -0
- package/drizzle.kilo.config.ts +17 -0
- package/package.json +97 -0
- package/src/admin/AiSettingsEditor.tsx +48 -0
- package/src/admin/AiWritingAssistant.tsx +159 -0
- package/src/admin/ApiTokensEditor.tsx +69 -0
- package/src/admin/ArrayField.tsx +118 -0
- package/src/admin/BlocksField.tsx +131 -0
- package/src/admin/Button.tsx +31 -0
- package/src/admin/CloseButton.tsx +18 -0
- package/src/admin/CmsLocalesProvider.tsx +19 -0
- package/src/admin/CmsSidebar.tsx +147 -0
- package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
- package/src/admin/CmsThemeProvider.tsx +100 -0
- package/src/admin/CollectionListEditor.tsx +278 -0
- package/src/admin/CollectionRecordEditor.tsx +420 -0
- package/src/admin/ContentEditorForm.tsx +21 -0
- package/src/admin/ContentViewSettings.tsx +55 -0
- package/src/admin/DashboardBuilder.tsx +284 -0
- package/src/admin/DragHandle.tsx +3 -0
- package/src/admin/DynamicField.tsx +517 -0
- package/src/admin/EditorHeader.tsx +13 -0
- package/src/admin/EditorSkeleton.tsx +27 -0
- package/src/admin/EmailSettingsEditor.tsx +80 -0
- package/src/admin/Field.tsx +215 -0
- package/src/admin/FilterBuilder.tsx +62 -0
- package/src/admin/GroupField.tsx +61 -0
- package/src/admin/ImageUploader.tsx +198 -0
- package/src/admin/ListingSettingsModal.tsx +68 -0
- package/src/admin/ListingView.tsx +123 -0
- package/src/admin/LocalesEditor.tsx +86 -0
- package/src/admin/LocalizedField.tsx +46 -0
- package/src/admin/MapPicker.tsx +53 -0
- package/src/admin/MediaLibrary.tsx +483 -0
- package/src/admin/MediaUploader.tsx +55 -0
- package/src/admin/Portal.tsx +10 -0
- package/src/admin/PublishingPanel.tsx +102 -0
- package/src/admin/RecordMeta.tsx +24 -0
- package/src/admin/ReviewQueueEditor.tsx +103 -0
- package/src/admin/RichTextEditor.tsx +49 -0
- package/src/admin/RoleEditor.tsx +121 -0
- package/src/admin/RolesEditor.tsx +42 -0
- package/src/admin/RowActionsMenu.tsx +64 -0
- package/src/admin/ScheduledEditor.tsx +155 -0
- package/src/admin/SeoSettingsEditor.tsx +53 -0
- package/src/admin/SingleRecordEditor.tsx +215 -0
- package/src/admin/SiteSettingsEditor.tsx +96 -0
- package/src/admin/Toast.tsx +45 -0
- package/src/admin/UserEditor.tsx +64 -0
- package/src/admin/UsersEditor.tsx +37 -0
- package/src/admin/WebhooksEditor.tsx +115 -0
- package/src/admin/admin.css +6637 -0
- package/src/admin/globals.css +14 -0
- package/src/admin/pages/admin-index.tsx +5 -0
- package/src/admin/pages/ai-settings.tsx +9 -0
- package/src/admin/pages/api-tokens.tsx +10 -0
- package/src/admin/pages/collection-list.tsx +22 -0
- package/src/admin/pages/collection-record.tsx +31 -0
- package/src/admin/pages/dashboard.tsx +10 -0
- package/src/admin/pages/email-settings.tsx +10 -0
- package/src/admin/pages/layout.tsx +24 -0
- package/src/admin/pages/locales-settings.tsx +10 -0
- package/src/admin/pages/login.tsx +25 -0
- package/src/admin/pages/media.tsx +9 -0
- package/src/admin/pages/review-queue.tsx +20 -0
- package/src/admin/pages/role-edit.tsx +11 -0
- package/src/admin/pages/roles.tsx +10 -0
- package/src/admin/pages/scheduled.tsx +17 -0
- package/src/admin/pages/seo-settings.tsx +9 -0
- package/src/admin/pages/setup-admin.tsx +76 -0
- package/src/admin/pages/single-record.tsx +24 -0
- package/src/admin/pages/site-settings.tsx +9 -0
- package/src/admin/pages/user-edit.tsx +11 -0
- package/src/admin/pages/user-new.tsx +10 -0
- package/src/admin/pages/users.tsx +10 -0
- package/src/admin/pages/webhooks-settings.tsx +10 -0
- package/src/admin/useKeyedRows.ts +35 -0
- package/src/ai-config.ts +35 -0
- package/src/ai-secrets.ts +17 -0
- package/src/ai-types.ts +10 -0
- package/src/ai-writer.ts +84 -0
- package/src/api-tokens.ts +25 -0
- package/src/auth-client.ts +5 -0
- package/src/auth.ts +33 -0
- package/src/calendar-grid.ts +12 -0
- package/src/cli/index.mjs +149 -0
- package/src/cms-appearance.ts +55 -0
- package/src/collections/content-view.ts +69 -0
- package/src/collections/define.ts +17 -0
- package/src/collections/filters.ts +92 -0
- package/src/collections/index.ts +11 -0
- package/src/collections/join.ts +64 -0
- package/src/collections/locale.ts +22 -0
- package/src/collections/query.ts +160 -0
- package/src/collections/registry.ts +88 -0
- package/src/collections/schedule.ts +12 -0
- package/src/collections/server.ts +12 -0
- package/src/collections/single.ts +26 -0
- package/src/collections/table.ts +14 -0
- package/src/collections/types.ts +336 -0
- package/src/collections/validation.ts +217 -0
- package/src/collections/values.ts +181 -0
- package/src/collections/workflow.ts +90 -0
- package/src/config.ts +34 -0
- package/src/content-types.ts +218 -0
- package/src/dashboard.ts +39 -0
- package/src/db.ts +50 -0
- package/src/email.ts +39 -0
- package/src/format.ts +4 -0
- package/src/listQuery.ts +23 -0
- package/src/locale-settings.ts +23 -0
- package/src/media-storage.ts +19 -0
- package/src/permissions.ts +37 -0
- package/src/publishing-workflow-db.ts +11 -0
- package/src/publishing-workflow.ts +20 -0
- package/src/richtext.ts +5 -0
- package/src/routes/admin/ai/settings/route.ts +20 -0
- package/src/routes/admin/ai/write/route.ts +21 -0
- package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
- package/src/routes/admin/api-tokens/route.ts +26 -0
- package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
- package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
- package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
- package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
- package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
- package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
- package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
- package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/route.ts +88 -0
- package/src/routes/admin/collections/route.ts +13 -0
- package/src/routes/admin/content-views/[type]/route.ts +25 -0
- package/src/routes/admin/dashboard/data/route.ts +39 -0
- package/src/routes/admin/dashboard/route.ts +30 -0
- package/src/routes/admin/email-settings/route.ts +49 -0
- package/src/routes/admin/email-settings/test/route.ts +18 -0
- package/src/routes/admin/locales/[code]/route.ts +38 -0
- package/src/routes/admin/locales/reorder/route.ts +18 -0
- package/src/routes/admin/locales/route.ts +46 -0
- package/src/routes/admin/media/[id]/restore/route.ts +13 -0
- package/src/routes/admin/media/[id]/route.ts +56 -0
- package/src/routes/admin/media/bulk/route.ts +46 -0
- package/src/routes/admin/media/folders/[id]/route.ts +34 -0
- package/src/routes/admin/media/route.ts +41 -0
- package/src/routes/admin/media/stats/route.ts +14 -0
- package/src/routes/admin/roles/[id]/route.ts +43 -0
- package/src/routes/admin/roles/route.ts +34 -0
- package/src/routes/admin/settings/route.ts +45 -0
- package/src/routes/admin/single/[type]/route.ts +54 -0
- package/src/routes/admin/singles/route.ts +15 -0
- package/src/routes/admin/upload/route.ts +55 -0
- package/src/routes/admin/users/[id]/route.ts +24 -0
- package/src/routes/admin/users/route.ts +24 -0
- package/src/routes/admin/webhooks/[id]/route.ts +38 -0
- package/src/routes/admin/webhooks/route.ts +26 -0
- package/src/routes/cron/publish-scheduled.ts +37 -0
- package/src/routes/setup/admin.ts +19 -0
- package/src/schema/admin-ui.ts +18 -0
- package/src/schema/auth.ts +49 -0
- package/src/schema/developer-tools.ts +44 -0
- package/src/schema/helpers.ts +35 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/locales.ts +13 -0
- package/src/schema/media.ts +22 -0
- package/src/schema/rbac.ts +11 -0
- package/src/schema/settings.ts +28 -0
- package/src/secrets.ts +26 -0
- package/src/seo.ts +69 -0
- package/src/storage/index.ts +20 -0
- package/src/storage/local.ts +27 -0
- package/src/storage/s3-compatible.ts +31 -0
- package/src/storage/types.ts +8 -0
- package/src/storage/vercel-blob.ts +17 -0
- package/src/useBodyScrollLock.ts +12 -0
- package/src/useLocalStorageState.ts +26 -0
- package/src/webhook-events.ts +12 -0
- 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)
|
package/src/webhooks.ts
ADDED
|
@@ -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
|
+
}
|