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
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 <token></code>.</p></div>
|
|
49
|
+
{newToken && <div className="cms-ai-note" role="status"><strong>{newToken}</strong><p>Copy this now — it won’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
|
+
}
|