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
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # Kilo CMS
2
+
3
+ An installable, config-driven CMS engine for Next.js — an admin panel, RBAC, a generic
4
+ field-type engine, and an API, mounted into your own app. Define your content schema via
5
+ config, not by hand-editing a registry. Modeled on how [Filament](https://filamentphp.com/)
6
+ installs into a Laravel app.
7
+
8
+ > **Status**: pre-1.0, proven end-to-end against one real production app
9
+ > ([`the-usual-dev`](https://github.com/revaldyas/the-usual-dev)'s `apps/site`), not yet used
10
+ > by a second, independent project. API surface may still change.
11
+
12
+ ## What you get
13
+
14
+ - A generic field-type engine (`text`, `richtext`, `image`, `relation`, `array`, `blocks`,
15
+ and more) driving CRUD, validation, list rendering, and filtering from a plain config object
16
+ — no schema-generation step, no code-gen.
17
+ - Role-based access control, per content type and per field.
18
+ - Media library, review/approval workflow, scheduled publishing, localization, webhooks, an
19
+ AI writing assistant, and a dashboard builder — all included, all config-driven.
20
+ - Auth via [better-auth](https://www.better-auth.com/), mounted the same one-line way
21
+ `better-auth` itself mounts into a Next.js route (`export const { GET, POST } =
22
+ toNextJsHandler(auth)`).
23
+ - A CLI (`npx kilo-cms`) for scaffolding new collections without hand-editing a central
24
+ registry.
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ npm install kilo-cms
30
+ ```
31
+
32
+ `next`, `react`, and `react-dom` are peer dependencies — Kilo CMS uses whatever versions your
33
+ own project already has.
34
+
35
+ Add `transpilePackages: ['kilo-cms']` to your `next.config.ts` — Kilo CMS ships raw TypeScript
36
+ source (including its `'use client'` admin components), not a prebuilt dist, so Next's own
37
+ compiler needs to process it directly.
38
+
39
+ ## Quickstart
40
+
41
+ Define a collection — split into a **fields** file (client-safe, no database import) and a
42
+ **table** file (server-only, the actual drizzle table):
43
+
44
+ ```ts
45
+ // src/collections/projects/fields.ts
46
+ import { defineCollectionFields } from 'kilo-cms/collections'
47
+
48
+ export const projectsFields = defineCollectionFields({
49
+ kind: 'collection',
50
+ slug: 'projects',
51
+ label: 'Projects',
52
+ labelSingular: 'Project',
53
+ titleField: 'title',
54
+ defaultSort: { field: 'sortOrder', dir: 'asc' },
55
+ groups: [{ id: 'overview', label: 'Overview' }],
56
+ fields: [
57
+ { key: 'title', type: 'text', label: 'Title', group: 'overview', required: true, listColumn: true, listPrimary: true },
58
+ ],
59
+ })
60
+ ```
61
+
62
+ ```ts
63
+ // src/collections/projects/table.ts
64
+ import 'server-only'
65
+ import { pgTable, text } from 'drizzle-orm/pg-core'
66
+ import { timestamps } from 'kilo-cms/schema'
67
+ import { defineCollectionTable } from 'kilo-cms/collections/table'
68
+
69
+ export const projects = defineCollectionTable('projects', pgTable('projects', {
70
+ id: text('id').primaryKey(),
71
+ title: text('title').notNull(),
72
+ ...timestamps,
73
+ }))
74
+ ```
75
+
76
+ Wire it up — one config file, and a place that creates your database connection (both plain
77
+ data/setup, no magic):
78
+
79
+ ```ts
80
+ // kilo.config.ts — the one file you edit to add/list collections
81
+ import { projectsFields } from './src/collections/projects/fields'
82
+ import './src/collections/projects/table' // side-effecting: registers the table
83
+
84
+ export const collections = [projectsFields]
85
+ export const locales = { codes: ['en'], default: 'en', labels: { en: 'English' } }
86
+ export const currency = { locale: 'en-US', currency: 'USD' }
87
+ ```
88
+
89
+ ```ts
90
+ // src/lib/db.ts — creates your db connection AND registers Kilo CMS with it
91
+ import postgres from 'postgres'
92
+ import { drizzle } from 'drizzle-orm/postgres-js'
93
+ import { defineKiloConfig } from 'kilo-cms/config'
94
+ import { createKiloAuth } from 'kilo-cms/auth'
95
+ import { schema } from './schema' // your own merged schema — see below
96
+ import { collections, currency, locales } from '../../kilo.config'
97
+
98
+ const client = postgres(process.env.DATABASE_URL!)
99
+ export const db = drizzle({ client, schema })
100
+
101
+ defineKiloConfig({ db, collections, locales, currency })
102
+
103
+ export const auth = createKiloAuth({
104
+ db,
105
+ secret: process.env.BETTER_AUTH_SECRET!,
106
+ baseURL: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
107
+ })
108
+ ```
109
+
110
+ ```ts
111
+ // src/lib/schema.ts — merges Kilo CMS's own tables (auth, RBAC, media, settings, ...) with yours
112
+ export * from 'kilo-cms/schema'
113
+ export * from '../collections/projects/table'
114
+
115
+ import { schema as kiloSchema } from 'kilo-cms/schema'
116
+ import { projects } from '../collections/projects/table'
117
+ export const schema = { ...kiloSchema, projects }
118
+ ```
119
+
120
+ Registration is tied to `db.ts`'s own first import (not a `register()`-hook-only approach) —
121
+ Next.js's build-time static generation spawns separate worker processes, and a hook like
122
+ `instrumentation.ts` isn't guaranteed to fire in every one of them before a page module
123
+ evaluates. Every admin page/route stub should start with `import '@/lib/db'` before its
124
+ re-export, for the same reason:
125
+
126
+ ```ts
127
+ // app/(admin)/admin/dashboard/page.tsx
128
+ import '@/lib/db'
129
+ export { default } from 'kilo-cms/admin/pages/dashboard'
130
+ ```
131
+
132
+ ```ts
133
+ // app/api/admin/dashboard/route.ts
134
+ import '@/lib/db'
135
+ export { GET, PUT } from 'kilo-cms/routes/admin/dashboard'
136
+ ```
137
+
138
+ ## CLI
139
+
140
+ Run from your own app's directory:
141
+
142
+ ```sh
143
+ npx kilo-cms add-collection <slug> # scaffold a new collection's fields.ts + table.ts
144
+ npx kilo-cms sync # regenerate .kilo/types.gen.ts from src/collections/*
145
+ npx kilo-cms migrate # apply Kilo CMS's own package-owned migrations
146
+ ```
147
+
148
+ `add-collection` scaffolds the two files and prints the exact lines to paste into
149
+ `kilo.config.ts` — deliberately not an auto-codemod (a script that edits your config file
150
+ wrong is worse than a 10-second copy-paste).
151
+
152
+ `migrate` applies Kilo CMS's own schema (auth, RBAC, media, settings, dev-tools, admin-ui,
153
+ locales — see `src/schema/`) against your `DATABASE_URL`, tracked in its own
154
+ `kilo_cms_migrations` table, independent of your own app's migration history for its content
155
+ tables.
156
+
157
+ ## Package layout
158
+
159
+ | Export | What it is |
160
+ |---|---|
161
+ | `kilo-cms/config` | `defineKiloConfig()` — the one call that wires db + collections + locale/currency together |
162
+ | `kilo-cms/collections` | Client-safe: field types, `defineCollectionFields()`, the registry, value helpers |
163
+ | `kilo-cms/collections/table` | Server-only: `defineCollectionTable()` |
164
+ | `kilo-cms/collections/server` | Server-only: validation, workflow, filters, the query engine |
165
+ | `kilo-cms/schema` | Kilo CMS's own drizzle tables (auth, RBAC, media, settings, dev-tools, admin-ui, locales) |
166
+ | `kilo-cms/auth`, `kilo-cms/auth-client` | `createKiloAuth()` factory + the better-auth React client |
167
+ | `kilo-cms/admin/*` | Admin UI pages and shared components |
168
+ | `kilo-cms/routes/*` | Route Handler logic for the admin API, public API helpers, auth, cron |
169
+
170
+ ## Status / roadmap
171
+
172
+ Built by extracting a working, in-production CMS out of the app it was originally embedded
173
+ in — see [`the-usual-dev`'s `WORKLOG.md`](https://github.com/revaldyas/the-usual-dev) for the
174
+ full history of that extraction. Not yet done:
175
+
176
+ - Centralized auth middleware (every admin page currently does its own session check).
177
+ - A single catch-all admin route instead of one thin file per page.
178
+ - An interactive `kilo-cms init` (migrate + seed settings + create the first admin user in one
179
+ command).
180
+ - A second, independent consumer project to prove the install story beyond the one app this
181
+ was extracted from.
182
+
183
+ ## License
184
+
185
+ Proprietary — all rights reserved. Not yet licensed for redistribution; contact the author
186
+ before using this outside of an explicitly authorized project.
@@ -0,0 +1,17 @@
1
+ import 'dotenv/config'
2
+ import { defineConfig } from 'drizzle-kit'
3
+
4
+ // kilo-cms's OWN migrations — auth, RBAC, media, settings, dev-tools, admin-ui, locales (see
5
+ // ./src/schema/index.ts). Applied via `npx kilo-cms migrate`, tracked in `kilo_cms_migrations`
6
+ // (a separate history from whatever the host tracks its own content-table migrations under) so
7
+ // bumping the kilo-cms package version and running its migrations never collides with the
8
+ // host's own `drizzle-kit generate` for its content tables. Run this BEFORE the host's own
9
+ // `db:generate`/`db:migrate` after bumping the kilo-cms dependency — see apps/site/
10
+ // drizzle.app.config.ts's comment for why order matters.
11
+ export default defineConfig({
12
+ schema: './src/schema/index.ts',
13
+ out: './migrations',
14
+ dialect: 'postgresql',
15
+ migrations: { table: 'kilo_cms_migrations' },
16
+ dbCredentials: { url: process.env.DATABASE_URL || '' },
17
+ })
package/package.json ADDED
@@ -0,0 +1,97 @@
1
+ {
2
+ "name": "kilo-cms",
3
+ "version": "0.1.0",
4
+ "description": "An installable, config-driven CMS engine for Next.js — admin panel, RBAC, a generic field-type engine, and an API, mounted into your own app. Define your content schema via config, not a hand-maintained registry.",
5
+ "keywords": [
6
+ "cms",
7
+ "nextjs",
8
+ "admin-panel",
9
+ "headless-cms",
10
+ "typescript",
11
+ "drizzle",
12
+ "better-auth"
13
+ ],
14
+ "homepage": "https://github.com/revaldyas/KiloCMS#readme",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/revaldyas/KiloCMS.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/revaldyas/KiloCMS/issues"
21
+ },
22
+ "author": "revaldyas",
23
+ "license": "UNLICENSED",
24
+ "type": "module",
25
+ "exports": {
26
+ "./config": "./src/config.ts",
27
+ "./collections": "./src/collections/index.ts",
28
+ "./collections/server": "./src/collections/server.ts",
29
+ "./collections/table": "./src/collections/table.ts",
30
+ "./collections/query": "./src/collections/query.ts",
31
+ "./schema": "./src/schema/index.ts",
32
+ "./auth": "./src/auth.ts",
33
+ "./auth-client": "./src/auth-client.ts",
34
+ "./permissions": "./src/permissions.ts",
35
+ "./content-types": "./src/content-types.ts",
36
+ "./db": "./src/db.ts",
37
+ "./richtext": "./src/richtext.ts",
38
+ "./api-tokens": "./src/api-tokens.ts",
39
+ "./dashboard": "./src/dashboard.ts",
40
+ "./routes/admin/*": "./src/routes/admin/*/route.ts",
41
+ "./routes/*": "./src/routes/*.ts",
42
+ "./admin/*": "./src/admin/*.tsx",
43
+ "./admin.css": "./src/admin/admin.css",
44
+ "./globals.css": "./src/admin/globals.css"
45
+ },
46
+ "bin": {
47
+ "kilo-cms": "./src/cli/index.mjs"
48
+ },
49
+ "files": [
50
+ "src",
51
+ "drizzle.kilo.config.ts",
52
+ "README.md"
53
+ ],
54
+ "scripts": {
55
+ "db:generate": "drizzle-kit generate --config drizzle.kilo.config.ts",
56
+ "db:migrate": "drizzle-kit migrate --config drizzle.kilo.config.ts",
57
+ "typecheck": "tsc --noEmit"
58
+ },
59
+ "dependencies": {
60
+ "@aws-sdk/client-s3": "^3.1135.0",
61
+ "@better-auth/drizzle-adapter": "^1.5.0",
62
+ "@fontsource/dm-sans": "^5.3.0",
63
+ "@tiptap/core": "^3.31.3",
64
+ "@tiptap/extension-image": "^3.31.3",
65
+ "@tiptap/extension-link": "^3.31.3",
66
+ "@tiptap/extension-placeholder": "^3.31.3",
67
+ "@tiptap/extension-table": "^3.31.3",
68
+ "@tiptap/pm": "^3.31.3",
69
+ "@tiptap/react": "^3.31.3",
70
+ "@tiptap/starter-kit": "^3.31.3",
71
+ "@vercel/blob": "^2.8.0",
72
+ "better-auth": "^1.5.0",
73
+ "browser-image-compression": "^2.0.2",
74
+ "drizzle-orm": "^0.45.2",
75
+ "echarts": "^6.1.0",
76
+ "framer-motion": "^13.2.0",
77
+ "leaflet": "^1.9.4",
78
+ "nodemailer": "^10.0.9",
79
+ "react-easy-crop": "^6.2.3",
80
+ "react-leaflet": "^5.0.0",
81
+ "zod": "^4.0.0"
82
+ },
83
+ "peerDependencies": {
84
+ "next": "^16.2.6",
85
+ "react": "^19.2.0",
86
+ "react-dom": "^19.2.0"
87
+ },
88
+ "devDependencies": {
89
+ "@types/leaflet": "^1.9.22",
90
+ "@types/node": "^22.15.0",
91
+ "@types/nodemailer": "^8.0.1",
92
+ "@types/react": "^19.1.0",
93
+ "@types/react-dom": "^19.1.0",
94
+ "drizzle-kit": "^0.31.0",
95
+ "typescript": "^5.8.0"
96
+ }
97
+ }
@@ -0,0 +1,48 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { EditorHeader } from './EditorHeader'
5
+ import { EditorSkeleton } from './EditorSkeleton'
6
+ import { TextField } from './Field'
7
+ import { CmsSidebar } from './CmsSidebar'
8
+ import { aiProviderLabels, aiProviders, type AiProvider } from '../ai-types'
9
+ import { Button } from './Button'
10
+
11
+ type AiAvailability = Record<AiProvider, boolean>
12
+ const emptyKeys = (): Record<AiProvider, string> => ({ manus: '', deepseek: '', openai: '', anthropic: '' })
13
+
14
+ export function AiSettingsEditor({ userName, canManageAi }: { userName: string; canManageAi: boolean }) {
15
+ const [aiAvailability, setAiAvailability] = useState<AiAvailability>({ manus: false, deepseek: false, openai: false, anthropic: false })
16
+ const [aiKeys, setAiKeys] = useState<Record<AiProvider, string>>(emptyKeys)
17
+ const [pendingClear, setPendingClear] = useState<AiProvider[]>([])
18
+ const [aiStatus, setAiStatus] = useState('Loading AI settings…')
19
+ const [loading, setLoading] = useState(true)
20
+
21
+ useEffect(() => { fetch('/api/admin/ai/settings').then(async (response) => { if (!response.ok) throw new Error(); return response.json() }).then((data) => { setAiAvailability(data.providers); setAiStatus('Keys are encrypted at rest.') }).catch(() => setAiStatus('Could not load AI settings')).finally(() => setLoading(false)) }, [])
22
+
23
+ async function save() {
24
+ if (!canManageAi) return
25
+ const keys = Object.fromEntries(aiProviders.map((provider) => [provider, aiKeys[provider].trim()]).filter(([, key]) => key)) as Partial<Record<AiProvider, string>>
26
+ if (!Object.keys(keys).length && !pendingClear.length) return
27
+ setAiStatus('Saving encrypted keys…')
28
+ try {
29
+ const response = await fetch('/api/admin/ai/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys, clear: pendingClear }) })
30
+ const data = await response.json()
31
+ if (!response.ok) throw new Error(data.error)
32
+ setAiAvailability(data.providers)
33
+ setAiKeys(emptyKeys())
34
+ setPendingClear([])
35
+ setAiStatus('Encrypted key settings saved.')
36
+ } catch (error) { setAiStatus(error instanceof Error ? error.message : 'Could not save AI settings') }
37
+ }
38
+
39
+ return <main className="cms-dashboard"><CmsSidebar active="settings" userName={userName} /><section className="cms-content">
40
+ <EditorHeader compact title="AI writing" backHref="/admin/dashboard" backLabel="Dashboard" />
41
+ {loading ? <EditorSkeleton sections={1} /> : <div className="cms-editor-layout"><div className="cms-editor-form">
42
+ <section className="cms-section">
43
+ <div className="cms-section-heading"><p className="section-label">AI writing</p><p>Keys are encrypted before being stored. Keys are never shown again or sent to the browser.</p></div>
44
+ {canManageAi ? <><div className="cms-ai-provider-grid">{aiProviders.map((provider) => { const removing = pendingClear.includes(provider); const configured = aiAvailability[provider] && !removing; return <div className={`cms-ai-provider ${configured ? 'is-configured' : ''}`} key={provider}><div><strong>{aiProviderLabels[provider]}</strong><span className={configured ? 'is-configured' : ''}>{removing ? 'Will be removed on save' : configured ? 'Key configured' : 'Not configured'}</span></div><TextField label={`${aiProviderLabels[provider]} API key`} type="password" autoComplete="new-password" value={aiKeys[provider]} onChange={(event) => { const value = event.target.value; setAiKeys((current) => ({ ...current, [provider]: value })); if (value) setPendingClear((items) => items.filter((item) => item !== provider)) }} placeholder={aiAvailability[provider] ? 'Paste a new key to replace' : 'Paste API key'} /><div className="cms-ai-provider-actions">{aiAvailability[provider] && (removing ? <Button variant="secondary" compact onClick={() => setPendingClear((items) => items.filter((item) => item !== provider))}>Keep key</Button> : <Button variant="negative" compact onClick={() => { setPendingClear((items) => [...items, provider]); setAiStatus('Removal will apply when you save settings.') }}>Remove key</Button>)}</div></div> })}</div><p className="cms-ai-status">{aiStatus}</p></> : <p className="cms-ai-status">Only administrators can manage API keys. Configured providers remain available to editors in the richtext assistant.</p>}
45
+ </section>
46
+ </div>{canManageAi && <aside className="cms-editor-sidebar"><p>{aiStatus}</p><Button variant="primary" type="button" onClick={() => void save()} icon="↗">Save AI settings</Button></aside>}</div>}
47
+ </section></main>
48
+ }
@@ -0,0 +1,159 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import type { Editor } from '@tiptap/core'
5
+ import { aiProviderLabels, aiProviders, type AiAction, type AiProvider } from '../ai-types'
6
+ import { Portal } from './Portal'
7
+
8
+ type Availability = Record<AiProvider, boolean>
9
+ type AiRange = { from: number; to: number }
10
+ type Anchor = { left: number; top: number }
11
+ type Session = { action: AiAction; prompt: string; range: AiRange; selection: string; documentText: string; anchor: Anchor }
12
+ type Props = { editor: Editor | null; open: boolean; onClose: () => void }
13
+
14
+ const initialAvailability: Availability = { manus: false, deepseek: false, openai: false, anthropic: false }
15
+
16
+ function AiIcon() {
17
+ return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.8 13.7 9l6.2 1.7-6.2 1.7-1.7 6.2-1.7-6.2-6.2-1.7L10.3 9 12 2.8Zm6.6 12.3.7 2.5 2.5.7-2.5.7-.7 2.5-.7-2.5-2.5-.7 2.5-.7.7-2.5Z" /></svg>
18
+ }
19
+
20
+ function buttonMouseDown(event: React.MouseEvent<HTMLButtonElement>) { event.preventDefault() }
21
+
22
+ export function AiWritingAssistant({ editor, open, onClose }: Props) {
23
+ const [availability, setAvailability] = useState<Availability>(initialAvailability)
24
+ const [provider, setProvider] = useState<AiProvider>('manus')
25
+ const [selection, setSelection] = useState<Session | null>(null)
26
+ const [session, setSession] = useState<Session | null>(null)
27
+ const [prompt, setPrompt] = useState('')
28
+ const [result, setResult] = useState('')
29
+ const [status, setStatus] = useState('')
30
+ const [loading, setLoading] = useState(false)
31
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
32
+ const wasOpen = useRef(false)
33
+
34
+ const capture = useCallback((): Omit<Session, 'action' | 'prompt'> | null => {
35
+ if (!editor) return null
36
+ const { from, to, empty } = editor.state.selection
37
+ const start = editor.view.coordsAtPos(from)
38
+ const end = editor.view.coordsAtPos(to)
39
+ const selectedText = empty ? '' : editor.state.doc.textBetween(from, to, '\n')
40
+ const isSelection = Boolean(selectedText.trim())
41
+ const top = isSelection && start.top > 64 ? start.top - 48 : end.bottom + 10
42
+ return {
43
+ range: { from, to }, selection: isSelection ? selectedText : '', documentText: editor.getText().slice(0, 16_000),
44
+ anchor: { left: Math.max(12, Math.min(start.left, window.innerWidth - 372)), top: Math.max(12, top) },
45
+ }
46
+ }, [editor])
47
+
48
+ useEffect(() => {
49
+ let cancelled = false
50
+ fetch('/api/admin/ai/settings').then(async (response) => {
51
+ const data = await response.json()
52
+ if (!response.ok) throw new Error(data.error)
53
+ return data
54
+ }).then((data) => {
55
+ if (cancelled) return
56
+ setAvailability(data.providers)
57
+ const first = aiProviders.find((item) => data.providers[item]) || 'manus'
58
+ setProvider((current) => data.providers[current] ? current : first)
59
+ }).catch(() => { if (!cancelled) setStatus('Could not load AI providers.') })
60
+ return () => { cancelled = true }
61
+ }, [])
62
+
63
+ useEffect(() => () => { if (timer.current) clearTimeout(timer.current) }, [])
64
+
65
+ useEffect(() => {
66
+ if (!editor) return
67
+ const updateSelection = () => {
68
+ if (session) return
69
+ const snapshot = capture()
70
+ if (!snapshot?.selection) { setSelection(null); return }
71
+ setSelection({ ...snapshot, action: 'improve', prompt: '' })
72
+ }
73
+ editor.on('selectionUpdate', updateSelection)
74
+ editor.on('focus', updateSelection)
75
+ window.addEventListener('resize', updateSelection)
76
+ window.addEventListener('scroll', updateSelection, true)
77
+ updateSelection()
78
+ return () => {
79
+ editor.off('selectionUpdate', updateSelection)
80
+ editor.off('focus', updateSelection)
81
+ window.removeEventListener('resize', updateSelection)
82
+ window.removeEventListener('scroll', updateSelection, true)
83
+ }
84
+ }, [capture, editor, session])
85
+
86
+ const dismiss = useCallback(() => {
87
+ if (timer.current) clearTimeout(timer.current)
88
+ setSession(null); setSelection(null); setPrompt(''); setResult(''); setStatus(''); setLoading(false)
89
+ onClose()
90
+ }, [onClose])
91
+
92
+ const poll = useCallback(async (taskId: string) => {
93
+ try {
94
+ const response = await fetch(`/api/admin/ai/write?taskId=${encodeURIComponent(taskId)}`)
95
+ const data = await response.json()
96
+ if (!response.ok) throw new Error(data.error)
97
+ if (data.state === 'pending') { setStatus('Manus is writing…'); timer.current = setTimeout(() => void poll(taskId), 2500); return }
98
+ if (data.state === 'error') throw new Error(data.error)
99
+ setResult(data.text); setStatus('Review the draft before applying it.'); setLoading(false)
100
+ } catch (error) { setStatus(error instanceof Error ? error.message : 'Could not retrieve the Manus result.'); setLoading(false) }
101
+ }, [])
102
+
103
+ const run = useCallback(async (next: Session) => {
104
+ if (!availability[provider]) { setStatus(`Add a ${aiProviderLabels[provider]} API key in Settings first.`); return }
105
+ if (next.action === 'prompt' && !next.prompt.trim()) { setStatus('Write an instruction first.'); return }
106
+ setLoading(true); setResult(''); setStatus(provider === 'manus' ? 'Starting Manus task…' : 'AI is writing…')
107
+ try {
108
+ const response = await fetch('/api/admin/ai/write', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, action: next.action, prompt: next.prompt, selection: next.selection, documentText: next.documentText }) })
109
+ const data = await response.json()
110
+ if (!response.ok) throw new Error(data.error)
111
+ if (data.state === 'pending') { await poll(data.taskId); return }
112
+ if (data.state === 'error') throw new Error(data.error)
113
+ setResult(data.text); setStatus('Review the draft before applying it.'); setLoading(false)
114
+ } catch (error) { setStatus(error instanceof Error ? error.message : 'AI writing failed.'); setLoading(false) }
115
+ }, [availability, poll, provider])
116
+
117
+ const begin = useCallback((action: AiAction, preset = '') => {
118
+ const snapshot = capture()
119
+ if (!snapshot) return
120
+ const next: Session = { ...snapshot, action, prompt: preset }
121
+ setSelection(null); setSession(next); setPrompt(preset); setResult(''); setStatus('')
122
+ if (action !== 'prompt' || preset) void run(next)
123
+ }, [capture, run])
124
+
125
+ useEffect(() => {
126
+ if (open && !wasOpen.current) begin('prompt')
127
+ wasOpen.current = open
128
+ }, [begin, open])
129
+
130
+ function apply() {
131
+ if (!editor || !session || !result.trim()) return
132
+ if (session.selection && editor.state.doc.textBetween(session.range.from, session.range.to, '\n') !== session.selection) { setStatus('The selected content changed. Run AI again before applying this draft.'); return }
133
+ const nodes = result.trim().split(/\n{2,}/).filter(Boolean).map((paragraph) => ({ type: 'paragraph', content: [{ type: 'text', text: paragraph.replace(/\n/g, ' ') }] }))
134
+ editor.chain().focus().insertContentAt(session.range, nodes).run()
135
+ dismiss()
136
+ }
137
+
138
+ const configured = aiProviders.some((item) => availability[item])
139
+ const quickActions = selection && !session && !loading && !result
140
+ const panel = session && (open || loading || result || status || session.action === 'prompt')
141
+ if (!quickActions && !panel) return null
142
+
143
+ return <Portal>
144
+ {quickActions && <div className="cms-ai-inline-tools" style={{ left: selection.anchor.left, top: selection.anchor.top }} role="toolbar" aria-label="AI selection actions">
145
+ <button type="button" onMouseDown={buttonMouseDown} onClick={() => begin('prompt')}><AiIcon />AI</button>
146
+ <button type="button" onMouseDown={buttonMouseDown} onClick={() => begin('improve')}>Improve</button>
147
+ <button type="button" onMouseDown={buttonMouseDown} onClick={() => begin('prompt', 'Make this selected text shorter and more concise. Return only the replacement text.')}>Shorten</button>
148
+ <button type="button" onMouseDown={buttonMouseDown} onClick={() => begin('summarize')}>Summarize</button>
149
+ </div>}
150
+ {panel && <section className="cms-ai-inline-panel" style={{ left: session.anchor.left, top: session.anchor.top }} aria-label="AI writing assistant">
151
+ <header><span><AiIcon />AI</span><label>Provider<select value={provider} onChange={(event) => setProvider(event.target.value as AiProvider)}>{aiProviders.map((item) => <option value={item} key={item} disabled={!availability[item]}>{aiProviderLabels[item]}{availability[item] ? '' : ' — not configured'}</option>)}</select></label><button type="button" className="cms-ai-inline-close" onClick={dismiss} aria-label="Close AI">×</button></header>
152
+ {!configured && <p className="cms-ai-inline-status">No AI key is configured yet. Add one in Settings.</p>}
153
+ {!result && !loading && session.action === 'prompt' && <><div className="cms-ai-inline-actions">{session.selection ? <><button type="button" onClick={() => begin('improve')}>Improve</button><button type="button" onClick={() => begin('prompt', 'Make this selected text shorter and more concise. Return only the replacement text.')}>Shorten</button><button type="button" onClick={() => begin('summarize')}>Summarize</button></> : <button type="button" onClick={() => begin('continue')}>Continue writing</button>}</div><textarea autoFocus value={prompt} onChange={(event) => setPrompt(event.target.value)} onKeyDown={(event) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); const next = { ...session, prompt }; setSession(next); void run(next) } }} placeholder={session.selection ? 'Tell AI how to edit the selection…' : 'Tell AI what to write at this cursor…'} maxLength={4000} /></>}
154
+ {result && <textarea className="cms-ai-inline-result" value={result} onChange={(event) => setResult(event.target.value)} aria-label="AI draft" />}
155
+ {status && <p className="cms-ai-inline-status">{status}</p>}
156
+ <footer>{result ? <><button type="button" onClick={dismiss}>Discard</button><button type="button" onClick={() => void run(session)}>Retry</button><button type="button" className="accept" onClick={apply}>Accept <span>↗</span></button></> : <button type="button" className="accept" disabled={!configured || loading} onClick={() => { const next = { ...session, prompt }; setSession(next); void run(next) }}>{loading ? 'Writing…' : session.action === 'prompt' ? 'Generate' : 'Working…'} <span>↗</span></button>}</footer>
157
+ </section>}
158
+ </Portal>
159
+ }
@@ -0,0 +1,69 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { EditorHeader } from './EditorHeader'
5
+ import { TextField } from './Field'
6
+ import { CmsSidebar } from './CmsSidebar'
7
+ import { useToast } from './Toast'
8
+ import { formatDateTime } from '../format'
9
+ import { ListingSkeleton } from './ListingView'
10
+ import { Button } from './Button'
11
+
12
+ type TokenRow = { id: string; name: string; tokenPrefix: string; lastUsedAt: string | null; createdAt: string }
13
+
14
+ export function ApiTokensEditor({ userName }: { userName: string }) {
15
+ const toast = useToast()
16
+ const [tokens, setTokens] = useState<TokenRow[]>([])
17
+ const [loading, setLoading] = useState(true)
18
+ const [name, setName] = useState('')
19
+ const [creating, setCreating] = useState(false)
20
+ const [newToken, setNewToken] = useState<string | null>(null)
21
+
22
+ function load() { setLoading(true); fetch('/api/admin/api-tokens').then((response) => response.ok ? response.json() : []).then(setTokens).finally(() => setLoading(false)) }
23
+ useEffect(() => { load() }, [])
24
+
25
+ async function create() {
26
+ if (!name.trim()) { toast.error('Give the token a name.'); return }
27
+ setCreating(true)
28
+ try {
29
+ const response = await fetch('/api/admin/api-tokens', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) })
30
+ const data = await response.json()
31
+ if (!response.ok) throw new Error(data.error)
32
+ setNewToken(data.token)
33
+ setName('')
34
+ load()
35
+ } catch (error) { toast.error(error instanceof Error ? error.message : 'Could not create token') } finally { setCreating(false) }
36
+ }
37
+
38
+ async function revoke(token: TokenRow) {
39
+ if (!confirm(`Revoke token "${token.name}"? Anything using it will stop working immediately.`)) return
40
+ const response = await fetch(`/api/admin/api-tokens/${token.id}`, { method: 'DELETE' })
41
+ if (response.ok) { toast.success('Token revoked.'); load() } else toast.error('Could not revoke token.')
42
+ }
43
+
44
+ return <main className="cms-dashboard"><CmsSidebar active="settings" userName={userName} /><section className="cms-content">
45
+ <EditorHeader compact title="API tokens" backHref="/admin/dashboard" backLabel="Dashboard" />
46
+ <div className="cms-editor-layout"><div className="cms-editor-form">
47
+ <section className="cms-section">
48
+ <div className="cms-section-heading"><p className="section-label">Public content API</p><p>Read-only access to published Services, Projects, and Blog posts at <code>/api/v1/…</code>, authenticated with <code>Authorization: Bearer &lt;token&gt;</code>.</p></div>
49
+ {newToken && <div className="cms-ai-note" role="status"><strong>{newToken}</strong><p>Copy this now — it won&rsquo;t be shown again.</p><Button variant="ghost" compact onClick={() => setNewToken(null)}>Dismiss</Button></div>}
50
+ <div className="cms-two-columns">
51
+ <TextField label="Token name" value={name} onChange={(event) => setName(event.target.value)} placeholder="e.g. Marketing site" />
52
+ </div>
53
+ <Button variant="positive" type="button" disabled={creating} onClick={() => void create()} icon="↗">{creating ? 'Creating…' : 'Create token'}</Button>
54
+ </section>
55
+ <section className="cms-section">
56
+ <div className="cms-section-heading"><p className="section-label">Existing tokens</p></div>
57
+ {loading ? <ListingSkeleton view="table" columnCount={5} /> : <div className="cms-table-wrap"><table className="cms-table"><thead><tr><th>Name</th><th>Token</th><th>Last used</th><th>Created</th><th /></tr></thead><tbody>
58
+ {tokens.map((token) => <tr key={token.id}>
59
+ <td><strong>{token.name}</strong></td>
60
+ <td><span className="cms-table-id">{token.tokenPrefix}…</span></td>
61
+ <td>{token.lastUsedAt ? formatDateTime(token.lastUsedAt) : 'Never'}</td>
62
+ <td>{formatDateTime(token.createdAt)}</td>
63
+ <td><Button variant="negative" compact type="button" onClick={() => void revoke(token)}>Revoke</Button></td>
64
+ </tr>)}
65
+ </tbody></table>{!tokens.length && <p className="cms-empty-state">No API tokens yet.</p>}</div>}
66
+ </section>
67
+ </div></div>
68
+ </section></main>
69
+ }