kilo-cms 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +186 -0
  2. package/drizzle.kilo.config.ts +17 -0
  3. package/package.json +97 -0
  4. package/src/admin/AiSettingsEditor.tsx +48 -0
  5. package/src/admin/AiWritingAssistant.tsx +159 -0
  6. package/src/admin/ApiTokensEditor.tsx +69 -0
  7. package/src/admin/ArrayField.tsx +118 -0
  8. package/src/admin/BlocksField.tsx +131 -0
  9. package/src/admin/Button.tsx +31 -0
  10. package/src/admin/CloseButton.tsx +18 -0
  11. package/src/admin/CmsLocalesProvider.tsx +19 -0
  12. package/src/admin/CmsSidebar.tsx +147 -0
  13. package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
  14. package/src/admin/CmsThemeProvider.tsx +100 -0
  15. package/src/admin/CollectionListEditor.tsx +278 -0
  16. package/src/admin/CollectionRecordEditor.tsx +420 -0
  17. package/src/admin/ContentEditorForm.tsx +21 -0
  18. package/src/admin/ContentViewSettings.tsx +55 -0
  19. package/src/admin/DashboardBuilder.tsx +284 -0
  20. package/src/admin/DragHandle.tsx +3 -0
  21. package/src/admin/DynamicField.tsx +517 -0
  22. package/src/admin/EditorHeader.tsx +13 -0
  23. package/src/admin/EditorSkeleton.tsx +27 -0
  24. package/src/admin/EmailSettingsEditor.tsx +80 -0
  25. package/src/admin/Field.tsx +215 -0
  26. package/src/admin/FilterBuilder.tsx +62 -0
  27. package/src/admin/GroupField.tsx +61 -0
  28. package/src/admin/ImageUploader.tsx +198 -0
  29. package/src/admin/ListingSettingsModal.tsx +68 -0
  30. package/src/admin/ListingView.tsx +123 -0
  31. package/src/admin/LocalesEditor.tsx +86 -0
  32. package/src/admin/LocalizedField.tsx +46 -0
  33. package/src/admin/MapPicker.tsx +53 -0
  34. package/src/admin/MediaLibrary.tsx +483 -0
  35. package/src/admin/MediaUploader.tsx +55 -0
  36. package/src/admin/Portal.tsx +10 -0
  37. package/src/admin/PublishingPanel.tsx +102 -0
  38. package/src/admin/RecordMeta.tsx +24 -0
  39. package/src/admin/ReviewQueueEditor.tsx +103 -0
  40. package/src/admin/RichTextEditor.tsx +49 -0
  41. package/src/admin/RoleEditor.tsx +121 -0
  42. package/src/admin/RolesEditor.tsx +42 -0
  43. package/src/admin/RowActionsMenu.tsx +64 -0
  44. package/src/admin/ScheduledEditor.tsx +155 -0
  45. package/src/admin/SeoSettingsEditor.tsx +53 -0
  46. package/src/admin/SingleRecordEditor.tsx +215 -0
  47. package/src/admin/SiteSettingsEditor.tsx +96 -0
  48. package/src/admin/Toast.tsx +45 -0
  49. package/src/admin/UserEditor.tsx +64 -0
  50. package/src/admin/UsersEditor.tsx +37 -0
  51. package/src/admin/WebhooksEditor.tsx +115 -0
  52. package/src/admin/admin.css +6637 -0
  53. package/src/admin/globals.css +14 -0
  54. package/src/admin/pages/admin-index.tsx +5 -0
  55. package/src/admin/pages/ai-settings.tsx +9 -0
  56. package/src/admin/pages/api-tokens.tsx +10 -0
  57. package/src/admin/pages/collection-list.tsx +22 -0
  58. package/src/admin/pages/collection-record.tsx +31 -0
  59. package/src/admin/pages/dashboard.tsx +10 -0
  60. package/src/admin/pages/email-settings.tsx +10 -0
  61. package/src/admin/pages/layout.tsx +24 -0
  62. package/src/admin/pages/locales-settings.tsx +10 -0
  63. package/src/admin/pages/login.tsx +25 -0
  64. package/src/admin/pages/media.tsx +9 -0
  65. package/src/admin/pages/review-queue.tsx +20 -0
  66. package/src/admin/pages/role-edit.tsx +11 -0
  67. package/src/admin/pages/roles.tsx +10 -0
  68. package/src/admin/pages/scheduled.tsx +17 -0
  69. package/src/admin/pages/seo-settings.tsx +9 -0
  70. package/src/admin/pages/setup-admin.tsx +76 -0
  71. package/src/admin/pages/single-record.tsx +24 -0
  72. package/src/admin/pages/site-settings.tsx +9 -0
  73. package/src/admin/pages/user-edit.tsx +11 -0
  74. package/src/admin/pages/user-new.tsx +10 -0
  75. package/src/admin/pages/users.tsx +10 -0
  76. package/src/admin/pages/webhooks-settings.tsx +10 -0
  77. package/src/admin/useKeyedRows.ts +35 -0
  78. package/src/ai-config.ts +35 -0
  79. package/src/ai-secrets.ts +17 -0
  80. package/src/ai-types.ts +10 -0
  81. package/src/ai-writer.ts +84 -0
  82. package/src/api-tokens.ts +25 -0
  83. package/src/auth-client.ts +5 -0
  84. package/src/auth.ts +33 -0
  85. package/src/calendar-grid.ts +12 -0
  86. package/src/cli/index.mjs +149 -0
  87. package/src/cms-appearance.ts +55 -0
  88. package/src/collections/content-view.ts +69 -0
  89. package/src/collections/define.ts +17 -0
  90. package/src/collections/filters.ts +92 -0
  91. package/src/collections/index.ts +11 -0
  92. package/src/collections/join.ts +64 -0
  93. package/src/collections/locale.ts +22 -0
  94. package/src/collections/query.ts +160 -0
  95. package/src/collections/registry.ts +88 -0
  96. package/src/collections/schedule.ts +12 -0
  97. package/src/collections/server.ts +12 -0
  98. package/src/collections/single.ts +26 -0
  99. package/src/collections/table.ts +14 -0
  100. package/src/collections/types.ts +336 -0
  101. package/src/collections/validation.ts +217 -0
  102. package/src/collections/values.ts +181 -0
  103. package/src/collections/workflow.ts +90 -0
  104. package/src/config.ts +34 -0
  105. package/src/content-types.ts +218 -0
  106. package/src/dashboard.ts +39 -0
  107. package/src/db.ts +50 -0
  108. package/src/email.ts +39 -0
  109. package/src/format.ts +4 -0
  110. package/src/listQuery.ts +23 -0
  111. package/src/locale-settings.ts +23 -0
  112. package/src/media-storage.ts +19 -0
  113. package/src/permissions.ts +37 -0
  114. package/src/publishing-workflow-db.ts +11 -0
  115. package/src/publishing-workflow.ts +20 -0
  116. package/src/richtext.ts +5 -0
  117. package/src/routes/admin/ai/settings/route.ts +20 -0
  118. package/src/routes/admin/ai/write/route.ts +21 -0
  119. package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
  120. package/src/routes/admin/api-tokens/route.ts +26 -0
  121. package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
  122. package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
  123. package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
  124. package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
  125. package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
  126. package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
  127. package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
  128. package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
  129. package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
  130. package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
  131. package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
  132. package/src/routes/admin/collections/[collection]/route.ts +88 -0
  133. package/src/routes/admin/collections/route.ts +13 -0
  134. package/src/routes/admin/content-views/[type]/route.ts +25 -0
  135. package/src/routes/admin/dashboard/data/route.ts +39 -0
  136. package/src/routes/admin/dashboard/route.ts +30 -0
  137. package/src/routes/admin/email-settings/route.ts +49 -0
  138. package/src/routes/admin/email-settings/test/route.ts +18 -0
  139. package/src/routes/admin/locales/[code]/route.ts +38 -0
  140. package/src/routes/admin/locales/reorder/route.ts +18 -0
  141. package/src/routes/admin/locales/route.ts +46 -0
  142. package/src/routes/admin/media/[id]/restore/route.ts +13 -0
  143. package/src/routes/admin/media/[id]/route.ts +56 -0
  144. package/src/routes/admin/media/bulk/route.ts +46 -0
  145. package/src/routes/admin/media/folders/[id]/route.ts +34 -0
  146. package/src/routes/admin/media/route.ts +41 -0
  147. package/src/routes/admin/media/stats/route.ts +14 -0
  148. package/src/routes/admin/roles/[id]/route.ts +43 -0
  149. package/src/routes/admin/roles/route.ts +34 -0
  150. package/src/routes/admin/settings/route.ts +45 -0
  151. package/src/routes/admin/single/[type]/route.ts +54 -0
  152. package/src/routes/admin/singles/route.ts +15 -0
  153. package/src/routes/admin/upload/route.ts +55 -0
  154. package/src/routes/admin/users/[id]/route.ts +24 -0
  155. package/src/routes/admin/users/route.ts +24 -0
  156. package/src/routes/admin/webhooks/[id]/route.ts +38 -0
  157. package/src/routes/admin/webhooks/route.ts +26 -0
  158. package/src/routes/cron/publish-scheduled.ts +37 -0
  159. package/src/routes/setup/admin.ts +19 -0
  160. package/src/schema/admin-ui.ts +18 -0
  161. package/src/schema/auth.ts +49 -0
  162. package/src/schema/developer-tools.ts +44 -0
  163. package/src/schema/helpers.ts +35 -0
  164. package/src/schema/index.ts +33 -0
  165. package/src/schema/locales.ts +13 -0
  166. package/src/schema/media.ts +22 -0
  167. package/src/schema/rbac.ts +11 -0
  168. package/src/schema/settings.ts +28 -0
  169. package/src/secrets.ts +26 -0
  170. package/src/seo.ts +69 -0
  171. package/src/storage/index.ts +20 -0
  172. package/src/storage/local.ts +27 -0
  173. package/src/storage/s3-compatible.ts +31 -0
  174. package/src/storage/types.ts +8 -0
  175. package/src/storage/vercel-blob.ts +17 -0
  176. package/src/useBodyScrollLock.ts +12 -0
  177. package/src/useLocalStorageState.ts +26 -0
  178. package/src/webhook-events.ts +12 -0
  179. package/src/webhooks.ts +30 -0
@@ -0,0 +1,14 @@
1
+ @import '@fontsource/dm-sans/400.css';
2
+ @import '@fontsource/dm-sans/500.css';
3
+ @import '@fontsource/dm-sans/600.css';
4
+ @import '@fontsource/dm-sans/700.css';
5
+ @import '@fontsource/dm-sans/400-italic.css';
6
+ @import '@fontsource/dm-sans/500-italic.css';
7
+ @import '@fontsource/dm-sans/600-italic.css';
8
+ @import '@fontsource/dm-sans/700-italic.css';
9
+ :root{--ink:#171717;--paper:#f3f1ed;--acid:#d9ff55;--acid-hover:#ecff9b;--acid-ink:#171717;--muted:#696863;--line:rgba(23,23,23,.18);--danger:#c43b2d;--danger-soft:#ff9c8f;--canvas:#e5e1d9;--surface:#fff;--surface-soft:#f8f7f4;--radius:8px;--radius-sm:6px;--space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px;--space-5:24px}
10
+ [data-theme="dark"]{--ink:#f3f1ed;--paper:#171717;--acid:#d9ff55;--acid-hover:#ecff9b;--acid-ink:#171717;--muted:#94938c;--line:rgba(243,241,237,.18);--danger:#ff6b57;--danger-soft:#7a2318;--canvas:#242424;--surface:#2d2d2d;--surface-soft:#353535}
11
+ html[data-theme="light"]{color-scheme:light}html[data-theme="dark"]{color-scheme:dark}
12
+ *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font-family:'DM Sans',sans-serif}.shell{width:min(1240px,calc(100% - 64px));margin:auto}.nav{height:90px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.logo{font-weight:700;letter-spacing:-.05em;font-size:20px;color:inherit;text-decoration:none}.logo span{font-size:9px;vertical-align:top;margin-left:2px}.nav-links{display:flex;gap:36px;font-family:'DM Mono',monospace;font-size:11px;text-transform:uppercase}.nav a{color:inherit;text-decoration:none}.nav-dot{width:36px;height:36px;border:1px solid var(--ink);border-radius:50%;display:grid;place-items:center;font-size:18px}.hero{min-height:680px;display:grid;grid-template-columns:1.1fr .9fr;gap:40px;align-items:center;padding-top:70px;padding-bottom:90px}.eyebrow,.section-label{font:11px 'DM Mono',monospace;text-transform:uppercase;letter-spacing:.05em}.eyebrow{color:var(--muted);margin:0 0 28px}.hero h1{font-size:clamp(72px,10vw,150px);line-height:.84;letter-spacing:-.09em;margin:0;max-width:700px}.hero-description{font-size:18px;line-height:1.35;max-width:340px;margin:42px 0 30px}.button{display:inline-flex;gap:30px;align-items:center;justify-content:space-between;padding:15px 18px;text-decoration:none;font:11px 'DM Mono',monospace;text-transform:uppercase;min-width:200px}.button-dark{background:var(--ink);color:var(--paper)}.button-light{background:var(--acid);color:var(--ink);min-width:260px}.hero-art{height:440px;background:#d6d1c8;position:relative;overflow:hidden}.art-caption{position:absolute;top:18px;left:20px;right:20px;display:flex;justify-content:space-between;font:10px 'DM Mono',monospace;text-transform:uppercase;z-index:2}.art-shape{position:absolute;border-radius:50%}.art-shape-one{background:var(--acid);width:380px;height:380px;right:-100px;top:55px}.art-shape-two{background:#ef604f;width:260px;height:260px;left:-40px;bottom:-30px}.art-type{font-size:220px;font-weight:700;letter-spacing:-.13em;position:absolute;left:80px;top:100px;z-index:1;color:var(--ink)}.band{background:var(--acid)}.intro{padding:120px 0}.intro-grid{display:grid;grid-template-columns:1fr 2fr 1fr;gap:40px}.intro h2{font-size:clamp(44px,6vw,78px);line-height:.92;letter-spacing:-.07em;margin:0}.intro h2 em,.footer h2 em{font-style:normal;font-family:Georgia,serif;font-weight:400}.intro-text{font-size:16px;line-height:1.45;max-width:260px;margin:0}.services{padding:120px 0}.section-head{display:flex;justify-content:space-between;margin-bottom:70px}.section-note,.text-link{font:11px 'DM Mono',monospace;text-transform:uppercase;color:var(--muted);text-decoration:none;margin:0}.service-row{display:grid;grid-template-columns:70px 1.3fr 1fr 40px;align-items:center;border-top:1px solid var(--line);padding:27px 0}.service-row:last-child{border-bottom:1px solid var(--line)}.service-row span:first-child{font:12px 'DM Mono',monospace;color:var(--muted)}.service-row h3{font-size:34px;letter-spacing:-.06em;margin:0}.service-row p{font-size:14px;line-height:1.4;max-width:260px;margin:0}.row-arrow{font-size:24px;text-align:right}.work{padding:110px 0 130px}.work-grid{display:grid;grid-template-columns:1.4fr 1fr;gap:34px;align-items:end}.work-image{height:450px;position:relative;overflow:hidden;background:#ddd}.work-card-large .work-image{height:580px}.work-image img{object-fit:cover;transition:transform .5s}.work-card:hover img{transform:scale(1.04)}.work-card p{font:10px 'DM Mono',monospace;text-transform:uppercase;color:var(--muted);margin:18px 0 7px}.work-card h3{font-size:28px;letter-spacing:-.05em;margin:0}.footer{background:var(--ink);color:var(--paper);padding:110px 0 30px}.footer .section-label{color:#aaa}.footer h2{font-size:clamp(60px,10vw,140px);line-height:.84;letter-spacing:-.09em;margin:80px 0 55px}.footer-bottom{border-top:1px solid rgba(243,241,237,.3);padding-top:18px;margin-top:140px;display:flex;justify-content:space-between;color:#aaa;font:10px 'DM Mono',monospace;text-transform:uppercase}@media(max-width:760px){.shell{width:calc(100% - 40px)}.nav{height:70px}.nav-links{display:none}.hero{display:block;padding:70px 0 50px;min-height:0}.hero h1{font-size:76px}.hero-art{height:330px;margin-top:70px}.art-type{font-size:160px;left:45px;top:85px}.intro{padding:75px 0}.intro-grid{display:block}.intro h2{margin:45px 0 35px;font-size:56px}.intro-text{max-width:330px}.services{padding:75px 0}.section-head{margin-bottom:45px}.service-row{grid-template-columns:34px 1fr 26px;gap:8px}.service-row h3{font-size:25px}.service-row p{grid-column:2;margin-top:7px}.row-arrow{grid-column:3;grid-row:1}.work{padding:75px 0}.work-grid{display:block}.work-card{margin-bottom:55px}.work-image,.work-card-large .work-image{height:330px}.footer{padding-top:75px}.footer h2{font-size:72px;margin:60px 0 40px}.footer-bottom{gap:12px;flex-wrap:wrap;margin-top:100px}}
13
+ .richtext-content{max-width:760px;margin:44px 0;font-size:18px;line-height:1.7}.richtext-content p{margin:0 0 20px}.richtext-content h2,.richtext-content h3,.richtext-content h4{letter-spacing:-.055em;line-height:1;margin:42px 0 17px}.richtext-content h2{font-size:clamp(32px,4vw,48px)}.richtext-content h3{font-size:30px}.richtext-content h4{font-size:23px}.richtext-content ul,.richtext-content ol{padding-left:24px;margin:0 0 22px}.richtext-content blockquote{margin:28px 0;padding-left:20px;border-left:3px solid var(--acid);font:italic 24px/1.4 Georgia,serif}.richtext-content code{padding:2px 5px;background:#e3e1dc;font:13px 'DM Mono',monospace}.richtext-content a{text-decoration-color:#8eb500;text-decoration-thickness:3px;text-underline-offset:3px;color:inherit}.richtext-content img{display:block;width:100%;height:auto;margin:32px 0}.richtext-table-wrap{margin:32px 0;overflow-x:auto;border:1px solid var(--ink)}.richtext-table-wrap table{width:100%;min-width:520px;border-collapse:collapse}.richtext-table-wrap th,.richtext-table-wrap td{border:1px solid var(--line);padding:12px 14px;text-align:left;vertical-align:top}.richtext-table-wrap th{background:var(--acid);font-size:14px}.richtext-table-wrap td p{margin:0}
14
+ *,*::before,*::after{font-family:'DM Sans',sans-serif!important}
@@ -0,0 +1,5 @@
1
+ import { redirect } from 'next/navigation'
2
+
3
+ export default function AdminPage() {
4
+ redirect('/admin/dashboard')
5
+ }
@@ -0,0 +1,9 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { AiSettingsEditor } from '../AiSettingsEditor'
4
+
5
+ export default async function AiSettingsPage() {
6
+ const result = await getSessionWithPermissions()
7
+ if (!result || !result.can('settings', 'read')) redirect('/admin/login')
8
+ return <AiSettingsEditor userName={result.session.user.name} canManageAi={result.hasSystem('ai.manage')} />
9
+ }
@@ -0,0 +1,10 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { ApiTokensEditor } from '../ApiTokensEditor'
4
+
5
+ export default async function ApiTokensPage() {
6
+ const result = await getSessionWithPermissions()
7
+ if (!result) redirect('/admin/login')
8
+ if (!result.hasSystem('developers.manage')) redirect('/admin/settings')
9
+ return <ApiTokensEditor userName={result.session.user.name} />
10
+ }
@@ -0,0 +1,22 @@
1
+ import { notFound, redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { getCollection } from '../../collections'
4
+ import { CollectionListEditor } from '../CollectionListEditor'
5
+
6
+ export default async function CollectionListPage({ params }: { params: Promise<{ collection: string }> }) {
7
+ const { collection } = await params
8
+ const config = getCollection(collection)
9
+ if (!config) notFound()
10
+ const result = await getSessionWithPermissions()
11
+ if (!result) redirect('/admin/login')
12
+ if (!result.can(config.slug, 'read')) redirect('/admin/dashboard')
13
+ return (
14
+ <CollectionListEditor
15
+ config={config}
16
+ userName={result.session.user.name}
17
+ canCreate={result.can(config.slug, 'create')}
18
+ canPublish={result.can(config.slug, 'publish')}
19
+ canDelete={result.can(config.slug, 'delete')}
20
+ />
21
+ )
22
+ }
@@ -0,0 +1,31 @@
1
+ import { notFound, redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { getCollection } from '../../collections'
4
+ import { CollectionRecordEditor } from '../CollectionRecordEditor'
5
+
6
+ export default async function CollectionRecordPage({ params }: { params: Promise<{ collection: string; id: string }> }) {
7
+ const { collection, id } = await params
8
+ const config = getCollection(collection)
9
+ if (!config) notFound()
10
+ const result = await getSessionWithPermissions()
11
+ if (!result) redirect('/admin/login')
12
+ if (!result.can(config.slug, 'read')) redirect('/admin/dashboard')
13
+ return (
14
+ <CollectionRecordEditor
15
+ config={config}
16
+ id={id}
17
+ userName={result.session.user.name}
18
+ currentUserId={result.session.user.id}
19
+ canCreate={result.can(config.slug, 'create')}
20
+ canUpdate={result.can(config.slug, 'update')}
21
+ canPublish={result.can(config.slug, 'publish')}
22
+ canDelete={result.can(config.slug, 'delete')}
23
+ allowedFields={result.allowedFields(config.slug)}
24
+ canConfigureViews={result.hasSystem('views.manage')}
25
+ canSubmitReview={result.can(config.slug, 'submit_review')}
26
+ canReview={result.can(config.slug, 'review')}
27
+ canApprove={result.can(config.slug, 'approve')}
28
+ isAdmin={result.isSystem}
29
+ />
30
+ )
31
+ }
@@ -0,0 +1,10 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { getDashboardSources } from '../../dashboard'
4
+ import { DashboardBuilder } from '../DashboardBuilder'
5
+
6
+ export default async function DashboardPage() {
7
+ const result = await getSessionWithPermissions()
8
+ if (!result || !result.can('settings', 'read')) redirect('/admin/login')
9
+ return <DashboardBuilder userName={result.session.user.name} canUpdate={result.can('settings', 'update')} dashboardSources={getDashboardSources()} />
10
+ }
@@ -0,0 +1,10 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { EmailSettingsEditor } from '../EmailSettingsEditor'
4
+
5
+ export default async function EmailSettingsPage() {
6
+ const result = await getSessionWithPermissions()
7
+ if (!result) redirect('/admin/login')
8
+ if (!result.hasSystem('developers.manage')) redirect('/admin/settings')
9
+ return <EmailSettingsEditor userName={result.session.user.name} />
10
+ }
@@ -0,0 +1,24 @@
1
+ import type { Metadata } from 'next'
2
+ import { ToastProvider } from '../Toast'
3
+ import { CmsSiteSettingsProvider } from '../CmsSiteSettingsProvider'
4
+ import { CmsLocalesProvider } from '../CmsLocalesProvider'
5
+ import { CmsThemeProvider } from '../CmsThemeProvider'
6
+ import { getSiteSettings } from '../../seo'
7
+ import { getLocaleConfig } from '../../locale-settings'
8
+ import { appearanceCssVariables } from '../../cms-appearance'
9
+ import '../globals.css'
10
+ import '../admin.css'
11
+
12
+ export async function generateMetadata(): Promise<Metadata> {
13
+ const settings = await getSiteSettings()
14
+ const title = /cms/i.test(settings.siteName) ? settings.siteName : `${settings.siteName} CMS`
15
+ return { title }
16
+ }
17
+
18
+ export default async function AdminRootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
19
+ const [settings, localeConfig] = await Promise.all([getSiteSettings(), getLocaleConfig()])
20
+ const initialSettings = { siteName: settings.siteName, logoLight: settings.logoLight, logoDark: settings.logoDark, publishingWorkflow: settings.publishingWorkflow }
21
+ const appearanceByTheme = { light: appearanceCssVariables(settings.cmsAppearance, 'light'), dark: appearanceCssVariables(settings.cmsAppearance, 'dark') }
22
+ const themeBootScript = `try { var pref = window.localStorage.getItem('cms-theme'); var theme = (pref === 'dark' || pref === 'light') ? pref : (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); document.documentElement.dataset.theme = theme; var vars = ${JSON.stringify(appearanceByTheme)}[theme]; Object.keys(vars).forEach(function (key) { document.documentElement.style.setProperty(key, vars[key]) }) } catch (error) { /* localStorage can be unavailable */ }`
23
+ return <html lang="id" suppressHydrationWarning style={appearanceByTheme.light}><head><script dangerouslySetInnerHTML={{ __html: themeBootScript }} /></head><body><CmsThemeProvider initialAppearance={settings.cmsAppearance}><CmsSiteSettingsProvider initialSettings={initialSettings}><CmsLocalesProvider localeConfig={localeConfig}><ToastProvider>{children}</ToastProvider></CmsLocalesProvider></CmsSiteSettingsProvider></CmsThemeProvider></body></html>
24
+ }
@@ -0,0 +1,10 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { LocalesEditor } from '../LocalesEditor'
4
+
5
+ export default async function LocalesPage() {
6
+ const result = await getSessionWithPermissions()
7
+ if (!result) redirect('/admin/login')
8
+ if (!result.hasSystem('locales.manage')) redirect('/admin/settings')
9
+ return <LocalesEditor userName={result.session.user.name} />
10
+ }
@@ -0,0 +1,25 @@
1
+ 'use client'
2
+
3
+ import { FormEvent, useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { authClient } from '../../auth-client'
6
+ import { useCmsSiteSettings } from '../CmsSiteSettingsProvider'
7
+ import { Button } from '../Button'
8
+
9
+ export default function LoginPage() {
10
+ const router = useRouter()
11
+ const [error, setError] = useState('')
12
+ const [loading, setLoading] = useState(false)
13
+ const { settings } = useCmsSiteSettings()
14
+
15
+ async function submit(event: FormEvent<HTMLFormElement>) {
16
+ event.preventDefault(); setError(''); setLoading(true)
17
+ const form = new FormData(event.currentTarget)
18
+ const result = await authClient.signIn.email({ email: String(form.get('email')), password: String(form.get('password')) })
19
+ setLoading(false)
20
+ if (result.error) setError('Email atau password salah.')
21
+ else router.push('/admin/dashboard')
22
+ }
23
+
24
+ return <main className="auth-shell"><div className="auth-panel"><p className="eyebrow">{settings.siteName} / CMS</p><h1>Welcome back.</h1><p className="auth-copy">Masuk untuk mengelola konten website.</p><form onSubmit={submit}><label>Email<input name="email" type="email" required autoComplete="email" /></label><label>Password<input name="password" type="password" required autoComplete="current-password" /></label>{error && <p className="form-error">{error}</p>}<Button variant="primary" type="submit" disabled={loading} icon="↗">{loading ? 'Signing in...' : 'Sign in'}</Button></form></div></main>
25
+ }
@@ -0,0 +1,9 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { MediaLibrary } from '../MediaLibrary'
4
+
5
+ export default async function MediaPage() {
6
+ const result = await getSessionWithPermissions()
7
+ if (!result) redirect('/admin/login')
8
+ return <MediaLibrary userName={result.session.user.name} />
9
+ }
@@ -0,0 +1,20 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { getCollection, isCollectionSlug, reviewableSlugs, type CollectionSlug } from '../../collections'
4
+ import { ReviewQueueEditor, type ReviewCollectionMeta } from '../ReviewQueueEditor'
5
+
6
+ export default async function ReviewQueuePage() {
7
+ const result = await getSessionWithPermissions()
8
+ if (!result) redirect('/admin/login')
9
+ const reviewSlugs = reviewableSlugs.filter((slug): slug is CollectionSlug => isCollectionSlug(slug))
10
+ const canReview = Object.fromEntries(reviewSlugs.map((slug) => [slug, result.can(slug, 'review')])) as Record<CollectionSlug, boolean>
11
+ const canApprove = Object.fromEntries(reviewSlugs.map((slug) => [slug, result.can(slug, 'approve')])) as Record<CollectionSlug, boolean>
12
+ if (!reviewSlugs.some((slug) => canReview[slug] || canApprove[slug])) redirect('/admin')
13
+ const meta: ReviewCollectionMeta = Object.fromEntries(
14
+ reviewSlugs.map((slug) => {
15
+ const config = getCollection(slug)!
16
+ return [slug, { labelSingular: config.labelSingular, titleField: config.titleField }]
17
+ }),
18
+ ) as ReviewCollectionMeta
19
+ return <ReviewQueueEditor userName={result.session.user.name} currentUserId={result.session.user.id} canReview={canReview} canApprove={canApprove} isAdmin={result.isSystem} meta={meta} />
20
+ }
@@ -0,0 +1,11 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { RoleEditor } from '../RoleEditor'
4
+
5
+ export default async function RoleEditPage({ params }: { params: Promise<{ id: string }> }) {
6
+ const result = await getSessionWithPermissions()
7
+ if (!result) redirect('/admin/login')
8
+ if (!result.hasSystem('roles.manage')) redirect('/admin')
9
+ const { id } = await params
10
+ return <RoleEditor id={id} userName={result.session.user.name} />
11
+ }
@@ -0,0 +1,10 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { RolesEditor } from '../RolesEditor'
4
+
5
+ export default async function RolesPage() {
6
+ const result = await getSessionWithPermissions()
7
+ if (!result) redirect('/admin/login')
8
+ if (!result.hasSystem('roles.manage')) redirect('/admin')
9
+ return <RolesEditor userName={result.session.user.name} />
10
+ }
@@ -0,0 +1,17 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { getCollection, schedulingSlugs, type CollectionSlug } from '../../collections'
4
+ import { ScheduledEditor, type ScheduledCollectionMeta } from '../ScheduledEditor'
5
+
6
+ export default async function ScheduledPage() {
7
+ const result = await getSessionWithPermissions()
8
+ const readableSlugs = schedulingSlugs.filter((slug): slug is CollectionSlug => Boolean(result?.can(slug, 'read')))
9
+ if (!result || !readableSlugs.length) redirect('/admin/login')
10
+ const meta: ScheduledCollectionMeta = Object.fromEntries(
11
+ readableSlugs.map((slug) => {
12
+ const config = getCollection(slug)!
13
+ return [slug, { labelSingular: config.labelSingular, titleField: config.titleField }]
14
+ }),
15
+ ) as ScheduledCollectionMeta
16
+ return <ScheduledEditor userName={result.session.user.name} scheduledSlugs={readableSlugs} meta={meta} />
17
+ }
@@ -0,0 +1,9 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { SeoSettingsEditor } from '../SeoSettingsEditor'
4
+
5
+ export default async function SeoSettingsPage() {
6
+ const result = await getSessionWithPermissions()
7
+ if (!result || !result.can('settings', 'read')) redirect('/admin/login')
8
+ return <SeoSettingsEditor userName={result.session.user.name} />
9
+ }
@@ -0,0 +1,76 @@
1
+ 'use client'
2
+
3
+ import { FormEvent, useState } from 'react'
4
+ import { useCmsSiteSettings } from '../CmsSiteSettingsProvider'
5
+ import { Button } from '../Button'
6
+
7
+ type Result = { ok?: boolean; error?: string }
8
+
9
+ export default function AdminSetupPage() {
10
+ const [error, setError] = useState('')
11
+ const [success, setSuccess] = useState(false)
12
+ const [loading, setLoading] = useState(false)
13
+ const { settings } = useCmsSiteSettings()
14
+
15
+ async function submit(event: FormEvent<HTMLFormElement>) {
16
+ event.preventDefault()
17
+ setError('')
18
+ setLoading(true)
19
+
20
+ const form = new FormData(event.currentTarget)
21
+ try {
22
+ const response = await fetch('/api/setup/admin', {
23
+ method: 'POST',
24
+ headers: {
25
+ Authorization: `Bearer ${String(form.get('token'))}`,
26
+ 'Content-Type': 'application/json',
27
+ },
28
+ body: JSON.stringify({
29
+ name: String(form.get('name')),
30
+ email: String(form.get('email')),
31
+ password: String(form.get('password')),
32
+ }),
33
+ })
34
+ const body = await response.text()
35
+ let result: Result = {}
36
+
37
+ if (body) {
38
+ try {
39
+ result = JSON.parse(body) as Result
40
+ } catch {
41
+ setError(`Server mengirim respons tidak valid (status ${response.status}). Cek terminal npm run dev.`)
42
+ return
43
+ }
44
+ }
45
+
46
+ if (!response.ok || !result.ok) {
47
+ setError(result.error ?? `Admin tidak dapat dibuat (status ${response.status}). Cek terminal npm run dev.`)
48
+ return
49
+ }
50
+ setSuccess(true)
51
+ } catch {
52
+ setError('Tidak dapat menghubungi server. Pastikan npm run dev masih berjalan.')
53
+ } finally {
54
+ setLoading(false)
55
+ }
56
+ }
57
+
58
+ return <main className="auth-shell"><div className="auth-panel">
59
+ <p className="eyebrow">{settings.siteName} / CMS</p>
60
+ <h1>Setup admin.</h1>
61
+ {success ? <>
62
+ <p className="auth-copy">Admin berhasil dibuat. Kamu sekarang bisa masuk ke CMS.</p>
63
+ <Button variant="primary" href="/admin/login" icon="↗">Go to login</Button>
64
+ </> : <>
65
+ <p className="auth-copy">Masukkan setup token dari file environment, lalu buat akun admin pertama.</p>
66
+ <form onSubmit={submit}>
67
+ <label>Setup token<input name="token" type="password" required autoComplete="off" /></label>
68
+ <label>Nama<input name="name" defaultValue="Site Admin" required autoComplete="name" /></label>
69
+ <label>Email<input name="email" type="email" required autoComplete="email" /></label>
70
+ <label>Password<input name="password" type="password" required minLength={12} autoComplete="new-password" /></label>
71
+ {error && <p className="form-error">{error}</p>}
72
+ <Button variant="primary" type="submit" disabled={loading} icon="↗">{loading ? 'Creating...' : 'Create admin'}</Button>
73
+ </form>
74
+ </>}
75
+ </div></main>
76
+ }
@@ -0,0 +1,24 @@
1
+ import { notFound, redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { getSingle } from '../../collections'
4
+ import { SingleRecordEditor } from '../SingleRecordEditor'
5
+
6
+ export default async function SingleRecordPage({ params }: { params: Promise<{ type: string }> }) {
7
+ const { type } = await params
8
+ const config = getSingle(type)
9
+ if (!config) notFound()
10
+ const result = await getSessionWithPermissions()
11
+ if (!result) redirect('/admin/login')
12
+ if (!result.can(config.slug, 'read')) redirect('/admin/dashboard')
13
+ return (
14
+ <SingleRecordEditor
15
+ config={config}
16
+ userName={result.session.user.name}
17
+ currentUserId={result.session.user.id}
18
+ canUpdate={result.can(config.slug, 'update')}
19
+ canPublish={result.can(config.slug, 'publish')}
20
+ allowedFields={result.allowedFields(config.slug)}
21
+ canConfigureViews={result.hasSystem('views.manage')}
22
+ />
23
+ )
24
+ }
@@ -0,0 +1,9 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { SiteSettingsEditor } from '../SiteSettingsEditor'
4
+
5
+ export default async function SettingsPage() {
6
+ const result = await getSessionWithPermissions()
7
+ if (!result || !result.can('settings', 'read')) redirect('/admin/login')
8
+ return <SiteSettingsEditor userName={result.session.user.name} />
9
+ }
@@ -0,0 +1,11 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { UserEditor } from '../UserEditor'
4
+
5
+ export default async function UserEditPage({ params }: { params: Promise<{ id: string }> }) {
6
+ const result = await getSessionWithPermissions()
7
+ if (!result) redirect('/admin/login')
8
+ if (!result.hasSystem('users.manage')) redirect('/admin')
9
+ const { id } = await params
10
+ return <UserEditor id={id} userName={result.session.user.name} />
11
+ }
@@ -0,0 +1,10 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { UserEditor } from '../UserEditor'
4
+
5
+ export default async function NewUserPage() {
6
+ const result = await getSessionWithPermissions()
7
+ if (!result) redirect('/admin/login')
8
+ if (!result.hasSystem('users.manage')) redirect('/admin')
9
+ return <UserEditor id="new" userName={result.session.user.name} />
10
+ }
@@ -0,0 +1,10 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { UsersEditor } from '../UsersEditor'
4
+
5
+ export default async function UsersPage() {
6
+ const result = await getSessionWithPermissions()
7
+ if (!result) redirect('/admin/login')
8
+ if (!result.hasSystem('users.manage')) redirect('/admin')
9
+ return <UsersEditor userName={result.session.user.name} />
10
+ }
@@ -0,0 +1,10 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getSessionWithPermissions } from '../../permissions'
3
+ import { WebhooksEditor } from '../WebhooksEditor'
4
+
5
+ export default async function WebhooksPage() {
6
+ const result = await getSessionWithPermissions()
7
+ if (!result) redirect('/admin/login')
8
+ if (!result.hasSystem('developers.manage')) redirect('/admin/settings')
9
+ return <WebhooksEditor userName={result.session.user.name} />
10
+ }
@@ -0,0 +1,35 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+
5
+ export type Row = Record<string, unknown>
6
+ // A per-row id for React/Reorder identity only — never sent to the server. Rows are plain
7
+ // objects with no natural unique key, unlike e.g. content-view groups which already have `id`.
8
+ export type KeyedRow = { id: string; row: Row }
9
+
10
+ function toKeyed(rows: Row[]): KeyedRow[] {
11
+ return rows.map((row) => ({ id: crypto.randomUUID(), row }))
12
+ }
13
+
14
+ /** Shared by `ArrayField`/`BlocksField`: local synthetic-id shadow state over a plain `Row[]`
15
+ * value, so drag-reorder and per-row edits have a stable React/Reorder key even though rows
16
+ * themselves have no natural unique field. Resyncs from `value` only when it changes for a
17
+ * reason other than this hook's own last-emitted update (e.g. the record loading from the
18
+ * server), so typing/reordering never fights the parent's controlled `value`. */
19
+ export function useKeyedRows(value: Row[], onChange: (rows: Row[]) => void) {
20
+ const [items, setItems] = useState<KeyedRow[]>(() => toKeyed(value))
21
+ const lastEmitted = useRef(value)
22
+
23
+ useEffect(() => {
24
+ if (value !== lastEmitted.current) { setItems(toKeyed(value)); lastEmitted.current = value }
25
+ }, [value])
26
+
27
+ function emit(next: KeyedRow[]) {
28
+ setItems(next)
29
+ const rows = next.map((item) => item.row)
30
+ lastEmitted.current = rows
31
+ onChange(rows)
32
+ }
33
+
34
+ return { items, emit }
35
+ }
@@ -0,0 +1,35 @@
1
+ import 'server-only'
2
+
3
+ import { eq } from 'drizzle-orm'
4
+ import { db } from './db'
5
+ import { aiSettings } from './schema'
6
+ import { decryptAiSecret, encryptAiSecret } from './ai-secrets'
7
+ import { aiProviders, type AiProvider } from './ai-types'
8
+
9
+ type AiKeyFields = Pick<typeof aiSettings.$inferSelect, 'manusApiKey' | 'deepseekApiKey' | 'openaiApiKey' | 'anthropicApiKey'>
10
+ const columnFor: Record<AiProvider, keyof AiKeyFields> = { manus: 'manusApiKey', deepseek: 'deepseekApiKey', openai: 'openaiApiKey', anthropic: 'anthropicApiKey' }
11
+
12
+ export type AiProviderAvailability = Record<AiProvider, boolean>
13
+
14
+ export async function getAiProviderAvailability(): Promise<AiProviderAvailability> {
15
+ const [settings] = await db.select().from(aiSettings).where(eq(aiSettings.id, 'default')).limit(1)
16
+ return Object.fromEntries(aiProviders.map((provider) => [provider, Boolean(settings?.[columnFor[provider]])])) as AiProviderAvailability
17
+ }
18
+
19
+ export async function saveAiKeys(keys: Partial<Record<AiProvider, string>>, clear: AiProvider[] = []) {
20
+ const updates: Partial<AiKeyFields> & { updatedAt: Date } = { updatedAt: new Date() }
21
+ for (const provider of aiProviders) {
22
+ const value = keys[provider]?.trim()
23
+ if (value) updates[columnFor[provider]] = encryptAiSecret(value)
24
+ if (clear.includes(provider)) updates[columnFor[provider]] = null
25
+ }
26
+ await db.insert(aiSettings).values({ id: 'default', ...updates }).onConflictDoUpdate({ target: aiSettings.id, set: updates })
27
+ return getAiProviderAvailability()
28
+ }
29
+
30
+ export async function getAiProviderKey(provider: AiProvider) {
31
+ const [settings] = await db.select().from(aiSettings).where(eq(aiSettings.id, 'default')).limit(1)
32
+ const stored = settings?.[columnFor[provider]]
33
+ if (!stored) throw new Error(`${provider} is not configured. Add its API key in Settings.`)
34
+ return decryptAiSecret(stored)
35
+ }
@@ -0,0 +1,17 @@
1
+ import 'server-only'
2
+
3
+ import { decryptSecret, encryptSecret } from './secrets'
4
+
5
+ const DOMAIN = 'cms-ai-keys'
6
+
7
+ export function encryptAiSecret(value: string) {
8
+ return encryptSecret(value, DOMAIN)
9
+ }
10
+
11
+ export function decryptAiSecret(payload: string) {
12
+ try {
13
+ return decryptSecret(payload, DOMAIN)
14
+ } catch {
15
+ throw new Error('Stored AI key is invalid. Save the key again.')
16
+ }
17
+ }
@@ -0,0 +1,10 @@
1
+ export const aiProviders = ['manus', 'deepseek', 'openai', 'anthropic'] as const
2
+ export type AiProvider = typeof aiProviders[number]
3
+ export type AiAction = 'prompt' | 'continue' | 'improve' | 'summarize'
4
+
5
+ export const aiProviderLabels: Record<AiProvider, string> = {
6
+ manus: 'Manus',
7
+ deepseek: 'DeepSeek',
8
+ openai: 'OpenAI',
9
+ anthropic: 'Anthropic',
10
+ }