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,155 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { CmsSidebar } from './CmsSidebar'
5
+ import { EditorHeader } from './EditorHeader'
6
+ import { CloseButton } from './CloseButton'
7
+ import { Portal } from './Portal'
8
+ import { useToast } from './Toast'
9
+ import { useLocalStorageState } from '../useLocalStorageState'
10
+ import { CardListSkeleton } from './EditorSkeleton'
11
+ import { Button } from './Button'
12
+ import { monthGrid, parseISODate, toISODate, weekdayLabels } from '../calendar-grid'
13
+ import type { CollectionSlug } from '../collections/types'
14
+
15
+ type ScheduledPost = { id: string; title: string; slug: string; type: CollectionSlug; scheduledAt: string }
16
+ type ViewPrefs = { view: 'list' | 'calendar' }
17
+ // `labelSingular`/`titleField` per scheduling-enabled collection — resolved server-side (the
18
+ // registry is never populated on the client, `defineKiloConfig()` is server-only) and passed
19
+ // down as a prop, same pattern as `CollectionRecordEditor`'s `config` prop.
20
+ export type ScheduledCollectionMeta = Record<CollectionSlug, { labelSingular: string; titleField: string }>
21
+
22
+ function formatTime(value: string) { return new Date(value).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) }
23
+ function formatDayHeading(iso: string) { const date = parseISODate(iso); return date ? date.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) : iso }
24
+
25
+ function groupByDay(posts: ScheduledPost[]) {
26
+ const map = new Map<string, ScheduledPost[]>()
27
+ for (const post of posts) {
28
+ const key = toISODate(new Date(post.scheduledAt))
29
+ const list = map.get(key)
30
+ if (list) list.push(post)
31
+ else map.set(key, [post])
32
+ }
33
+ return map
34
+ }
35
+
36
+ function ScheduledRow({ post, meta, onPublishNow, onUnschedule }: { post: ScheduledPost; meta: ScheduledCollectionMeta; onPublishNow: (post: ScheduledPost) => void; onUnschedule: (post: ScheduledPost) => void }) {
37
+ const overdue = new Date(post.scheduledAt) < new Date()
38
+ return <div className="cms-schedule-row">
39
+ <span className="cms-schedule-time">{formatTime(post.scheduledAt)}{overdue && <span className="cms-schedule-overdue" title="Past its scheduled time — waiting for the scheduler to run">Overdue</span>}</span>
40
+ <a className="cms-schedule-title" href={`/admin/collections/${post.type}/${post.id}`}>{post.title || 'Untitled'}</a>
41
+ <span className="cms-schedule-category">{meta[post.type]?.labelSingular ?? post.type}</span>
42
+ <div className="cms-schedule-actions">
43
+ <Button variant="positive" compact onClick={() => onPublishNow(post)}>Publish now</Button>
44
+ <Button variant="negative" compact onClick={() => onUnschedule(post)}>Unschedule</Button>
45
+ </div>
46
+ </div>
47
+ }
48
+
49
+ function CalendarMonth({ grouped, meta, onPublishNow, onUnschedule }: { grouped: Map<string, ScheduledPost[]>; meta: ScheduledCollectionMeta; onPublishNow: (post: ScheduledPost) => void; onUnschedule: (post: ScheduledPost) => void }) {
50
+ const [viewMonth, setViewMonth] = useState(() => new Date())
51
+ const [dayPopover, setDayPopover] = useState<{ dateKey: string; top: number; left: number } | null>(null)
52
+ const popoverRef = useRef<HTMLDivElement>(null)
53
+
54
+ useEffect(() => {
55
+ if (!dayPopover) return
56
+ function handleOutside(event: MouseEvent) { if (!popoverRef.current?.contains(event.target as Node)) setDayPopover(null) }
57
+ function handleKey(event: globalThis.KeyboardEvent) { if (event.key === 'Escape') setDayPopover(null) }
58
+ document.addEventListener('mousedown', handleOutside)
59
+ document.addEventListener('keydown', handleKey)
60
+ return () => { document.removeEventListener('mousedown', handleOutside); document.removeEventListener('keydown', handleKey) }
61
+ }, [dayPopover])
62
+
63
+ function openDay(dateKey: string, element: HTMLElement) {
64
+ const rect = element.getBoundingClientRect()
65
+ setDayPopover({ dateKey, top: rect.bottom + 6, left: Math.max(8, Math.min(rect.left, window.innerWidth - 300)) })
66
+ }
67
+
68
+ const cells = monthGrid(viewMonth)
69
+ const todayIso = toISODate(new Date())
70
+
71
+ return <div className="cms-schedule-calendar">
72
+ <div className="cms-schedule-cal-header">
73
+ <Button variant="secondary" iconOnly compact onClick={() => setViewMonth((current) => new Date(current.getFullYear(), current.getMonth() - 1, 1))} aria-label="Previous month" icon="‹" />
74
+ <strong>{viewMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</strong>
75
+ <Button variant="secondary" iconOnly compact onClick={() => setViewMonth((current) => new Date(current.getFullYear(), current.getMonth() + 1, 1))} aria-label="Next month" icon="›" />
76
+ <Button variant="secondary" compact className="cms-schedule-today" onClick={() => setViewMonth(new Date())}>Today</Button>
77
+ </div>
78
+ <div className="cms-schedule-cal-weekdays">{weekdayLabels.map((day) => <span key={day}>{day}</span>)}</div>
79
+ <div className="cms-schedule-cal-grid">{cells.map((date, index) => {
80
+ if (!date) return <div className="cms-schedule-cal-cell is-empty" key={index} />
81
+ const iso = toISODate(date)
82
+ const dayPosts = grouped.get(iso) ?? []
83
+ const visible = dayPosts.slice(0, 3)
84
+ const extra = dayPosts.length - visible.length
85
+ return <div className={`cms-schedule-cal-cell ${iso === todayIso ? 'is-today' : ''}`} key={index}>
86
+ <span className="cms-schedule-cal-date">{date.getDate()}</span>
87
+ {visible.map((post) => <button type="button" key={post.id} className={`cms-schedule-chip ${new Date(post.scheduledAt) < new Date() ? 'is-overdue' : ''}`} onClick={(event) => openDay(iso, event.currentTarget)}>{formatTime(post.scheduledAt)} {post.title || 'Untitled'}</button>)}
88
+ {extra > 0 && <button type="button" className="cms-schedule-more" onClick={(event) => openDay(iso, event.currentTarget)}>+{extra} more</button>}
89
+ </div>
90
+ })}</div>
91
+ {dayPopover && <Portal><div ref={popoverRef} className="cms-schedule-day-popover" style={{ position: 'fixed', top: dayPopover.top, left: dayPopover.left }}>
92
+ <div className="cms-schedule-day-popover-header"><strong>{formatDayHeading(dayPopover.dateKey)}</strong><CloseButton onClick={() => setDayPopover(null)} label="Close" /></div>
93
+ {(grouped.get(dayPopover.dateKey) ?? []).map((post) => <ScheduledRow key={post.id} post={post} meta={meta} onPublishNow={onPublishNow} onUnschedule={onUnschedule} />)}
94
+ </div></Portal>}
95
+ </div>
96
+ }
97
+
98
+ export function ScheduledEditor({ userName, scheduledSlugs, meta }: { userName: string; scheduledSlugs: CollectionSlug[]; meta: ScheduledCollectionMeta }) {
99
+ const toast = useToast()
100
+ const [posts, setPosts] = useState<ScheduledPost[]>([])
101
+ const [loading, setLoading] = useState(true)
102
+ const [error, setError] = useState('')
103
+ const [reloadKey, setReloadKey] = useState(0)
104
+ const [prefs, setPrefs] = useLocalStorageState<ViewPrefs>('cms-scheduled-view', { view: 'list' })
105
+
106
+ useEffect(() => {
107
+ setLoading(true)
108
+ const params = new URLSearchParams({
109
+ filters: JSON.stringify([{ field: 'published', operator: 'is', value: 'scheduled' }]),
110
+ pageSize: 'all', sortBy: 'scheduledAt', sortDir: 'asc',
111
+ })
112
+ Promise.all(scheduledSlugs.map((slug) =>
113
+ fetch(`/api/admin/collections/${slug}?${params}`)
114
+ .then((response) => (response.ok ? response.json() : { items: [] }))
115
+ .then((data: { items: Array<Record<string, unknown>> }) => data.items.map((item) => ({
116
+ id: String(item.id),
117
+ title: String(item[meta[slug]?.titleField ?? 'id'] ?? ''),
118
+ slug: String(item.slug ?? ''),
119
+ type: slug,
120
+ scheduledAt: String(item.scheduledAt),
121
+ }))),
122
+ ))
123
+ .then((groups) => { setPosts(groups.flat().sort((a, b) => a.scheduledAt.localeCompare(b.scheduledAt))); setError('') })
124
+ .catch(() => setError('Could not load scheduled content'))
125
+ .finally(() => setLoading(false))
126
+ // eslint-disable-next-line react-hooks/exhaustive-deps
127
+ }, [reloadKey])
128
+
129
+ async function publishNow(post: ScheduledPost) {
130
+ const response = await fetch(`/api/admin/collections/${post.type}/${post.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ published: true }) })
131
+ if (response.ok) { toast.success(`${meta[post.type]?.labelSingular ?? post.type} published live.`); setReloadKey((key) => key + 1) } else toast.error('Could not publish.')
132
+ }
133
+ async function unschedule(post: ScheduledPost) {
134
+ const response = await fetch(`/api/admin/collections/${post.type}/${post.id}/unschedule`, { method: 'PATCH' })
135
+ if (response.ok) { toast.success('Schedule removed.'); setReloadKey((key) => key + 1) } else toast.error('Could not remove schedule.')
136
+ }
137
+
138
+ const grouped = groupByDay(posts)
139
+ const dayKeys = [...grouped.keys()].sort()
140
+
141
+ return <main className="cms-dashboard"><CmsSidebar active="scheduled" userName={userName} /><section className="cms-content">
142
+ <EditorHeader compact title="Scheduled Publishing" meta={error || `${posts.length} upcoming`} actions={
143
+ <div className="cms-tabs cms-tabs-inline"><button className={prefs.view === 'list' ? 'active' : ''} onClick={() => setPrefs({ view: 'list' })}>List</button><button className={prefs.view === 'calendar' ? 'active' : ''} onClick={() => setPrefs({ view: 'calendar' })}>Calendar</button></div>
144
+ } />
145
+ {loading ? <CardListSkeleton /> : prefs.view === 'list'
146
+ ? <div className="cms-schedule-list">
147
+ {dayKeys.map((dayKey) => <section className="cms-schedule-day" key={dayKey}>
148
+ <h3>{formatDayHeading(dayKey)}</h3>
149
+ {grouped.get(dayKey)!.map((post) => <ScheduledRow key={post.id} post={post} meta={meta} onPublishNow={publishNow} onUnschedule={unschedule} />)}
150
+ </section>)}
151
+ {!dayKeys.length && <p className="cms-empty-state">Nothing scheduled yet.</p>}
152
+ </div>
153
+ : <CalendarMonth grouped={grouped} meta={meta} onPublishNow={publishNow} onUnschedule={unschedule} />}
154
+ </section></main>
155
+ }
@@ -0,0 +1,53 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { EditorHeader } from './EditorHeader'
5
+ import { EditorSkeleton } from './EditorSkeleton'
6
+ import { Field, TextAreaField, TextField } from './Field'
7
+ import { CmsSidebar } from './CmsSidebar'
8
+ import { ImageUploader } from './ImageUploader'
9
+ import { useToast } from './Toast'
10
+ import { useCmsSiteSettings } from './CmsSiteSettingsProvider'
11
+ import { Button } from './Button'
12
+ import { normalizeCmsAppearance, type CmsAppearance } from '../cms-appearance'
13
+ import { defaultPublishingWorkflow, normalizePublishingWorkflow, type PublishingWorkflow } from '../publishing-workflow'
14
+
15
+ type Settings = { siteName: string; logoLight: string | null; logoDark: string | null; metaTitle: string; metaDescription: string; metaImage: string | null; cmsAppearance: CmsAppearance; publishingWorkflow: PublishingWorkflow }
16
+ const empty = (): Settings => ({ siteName: 'Kilo CMS', logoLight: null, logoDark: null, metaTitle: '', metaDescription: '', metaImage: null, cmsAppearance: normalizeCmsAppearance(null), publishingWorkflow: defaultPublishingWorkflow })
17
+
18
+ export function SeoSettingsEditor({ userName }: { userName: string }) {
19
+ const toast = useToast()
20
+ const { setSettings: setCmsSiteSettings } = useCmsSiteSettings()
21
+ const [settings, setSettings] = useState<Settings>(empty)
22
+ const [status, setStatus] = useState('Loading settings…')
23
+ const [loading, setLoading] = useState(true)
24
+
25
+ useEffect(() => { fetch('/api/admin/settings').then(async (response) => { if (!response.ok) throw new Error(); return response.json() }).then((data) => { setSettings({ siteName: data.siteName ?? 'Kilo CMS', logoLight: data.logoLight ?? null, logoDark: data.logoDark ?? null, metaTitle: data.metaTitle ?? '', metaDescription: data.metaDescription ?? '', metaImage: data.metaImage ?? null, cmsAppearance: normalizeCmsAppearance(data.cmsAppearance), publishingWorkflow: normalizePublishingWorkflow(data.publishingWorkflow) }); setStatus('Loaded') }).catch(() => setStatus('Could not load settings')).finally(() => setLoading(false)) }, [])
26
+
27
+ function update<K extends keyof Settings>(key: K, value: Settings[K]) { setSettings((current) => ({ ...current, [key]: value })) }
28
+ async function save() {
29
+ setStatus('Saving…')
30
+ try {
31
+ const response = await fetch('/api/admin/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) })
32
+ const data = await response.json()
33
+ if (!response.ok) throw new Error(data.error)
34
+ setSettings(data)
35
+ setCmsSiteSettings({ siteName: data.siteName, logoLight: data.logoLight ?? null, logoDark: data.logoDark ?? null, publishingWorkflow: normalizePublishingWorkflow(data.publishingWorkflow) })
36
+ setStatus('Saved')
37
+ toast.success('Settings saved.')
38
+ } catch (error) { const message = error instanceof Error ? error.message : 'Could not save settings'; setStatus(message); toast.error(message) }
39
+ }
40
+
41
+ return <main className="cms-dashboard"><CmsSidebar active="settings" userName={userName} /><section className="cms-content">
42
+ <EditorHeader compact title="SEO settings" backHref="/admin/dashboard" backLabel="Dashboard" />
43
+ {loading ? <EditorSkeleton sections={1} /> : <div className="cms-editor-layout"><div className="cms-editor-form">
44
+ <section className="cms-section">
45
+ <div className="cms-section-heading"><p className="section-label">SEO defaults</p><p>Used for any page, service, or project that doesn’t set its own SEO fields.</p></div>
46
+ <TextField label="Default meta title" value={settings.metaTitle} onChange={(event) => update('metaTitle', event.target.value)} hint="Falls back to the site name when empty." placeholder="The Usual — Ideas with a pulse" />
47
+ <TextAreaField label="Default meta description" rows={4} value={settings.metaDescription} onChange={(event) => update('metaDescription', event.target.value)} hint="Shown in search results and social previews when a page has none of its own." />
48
+ <TextField label="Default social image URL" hint="Optional when uploading" type="url" value={settings.metaImage ?? ''} onChange={(event) => update('metaImage', event.target.value || null)} placeholder="https://…" />
49
+ <Field label="Upload default social image" hint="JPEG, PNG, or WebP — used for link previews (Open Graph)"><ImageUploader value={settings.metaImage ?? ''} onChange={(url) => update('metaImage', url)} /></Field>
50
+ </section>
51
+ </div><aside className="cms-editor-sidebar"><p>{status}</p><Button variant="primary" type="button" onClick={save} icon="↗">Save settings</Button></aside></div>}
52
+ </section></main>
53
+ }
@@ -0,0 +1,215 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, type ReactNode } from 'react'
4
+ import { CmsSidebar } from './CmsSidebar'
5
+ import { EditorHeader } from './EditorHeader'
6
+ import { EditorSkeleton } from './EditorSkeleton'
7
+ import { ContentEditorForm } from './ContentEditorForm'
8
+ import { ContentViewSettings as ContentViewSettingsModal } from './ContentViewSettings'
9
+ import { DynamicField } from './DynamicField'
10
+ import { Button } from './Button'
11
+ import { PublishingPanel, type ReviewStatus } from './PublishingPanel'
12
+ import { RecordMeta } from './RecordMeta'
13
+ import { useToast } from './Toast'
14
+ import { useCmsLocales } from './CmsLocalesProvider'
15
+ import { formatDateTime } from '../format'
16
+ import { contentViewField, defaultContentViewFor, type ContentViewSettings as ContentViewConfig } from '../collections/content-view'
17
+ import { relationFields } from '../collections/registry'
18
+ import { emptyRecord, isFieldVisible } from '../collections/values'
19
+ import { workflowColumnKeys, type SelectOption, type SingleConfig } from '../collections/types'
20
+
21
+ type Row = Record<string, unknown>
22
+ type UserOption = { id: string; name: string }
23
+ type SavingState = 'draft' | 'publish' | 'unpublish' | 'workflow' | null
24
+ const recordSignature = (row: Row) => JSON.stringify(Object.fromEntries(Object.entries(row).filter(([key]) => key !== 'id' && key !== 'createdAt' && key !== 'updatedAt' && !(workflowColumnKeys as readonly string[]).includes(key))))
25
+
26
+ export function SingleRecordEditor({ config, userName, currentUserId, canUpdate, canPublish, allowedFields, canConfigureViews }: {
27
+ config: SingleConfig
28
+ userName: string
29
+ currentUserId: string
30
+ canUpdate: boolean
31
+ canPublish: boolean
32
+ allowedFields: string[]
33
+ canConfigureViews: boolean
34
+ }) {
35
+ // The server page already resolved `config` (registry lookups only work server-side —
36
+ // `defineKiloConfig()` never runs on the client) and passes it down as a prop; `type` is
37
+ // just a local alias so every existing `type`-referencing line below keeps working unchanged.
38
+ const type = config.slug
39
+ const toast = useToast()
40
+ const localeConfig = useCmsLocales()
41
+ const [record, setRecord] = useState<Row>(() => emptyRecord(config, localeConfig))
42
+ const [view, setView] = useState<ContentViewConfig>(() => defaultContentViewFor(config))
43
+ const [relationOptions, setRelationOptions] = useState<Record<string, SelectOption[]>>({})
44
+ const [errors, setErrors] = useState<Record<string, string>>({})
45
+ const [saving, setSaving] = useState<SavingState>(null)
46
+ const [loading, setLoading] = useState(true)
47
+ const [status, setStatus] = useState('Loading…')
48
+ const [savedSignature, setSavedSignature] = useState<string | null>(null)
49
+ const [savedPublished, setSavedPublished] = useState(false)
50
+ const [viewSettingsOpen, setViewSettingsOpen] = useState(false)
51
+ const [users, setUsers] = useState<UserOption[]>([])
52
+ const [backTarget, setBackTarget] = useState<{ href: string; label: string } | null>(null)
53
+
54
+ useEffect(() => {
55
+ // Single types have no list page of their own to "go back" to — link back to the first
56
+ // collection the user can see (or another single type) so the back button always lands
57
+ // somewhere with the full Content nav, instead of pointing at this same page.
58
+ Promise.all([
59
+ fetch('/api/admin/collections').then((response) => response.ok ? response.json() : []).catch(() => []),
60
+ fetch('/api/admin/singles').then((response) => response.ok ? response.json() : []).catch(() => []),
61
+ ]).then(([collections, singles]: [Array<{ slug: string; label: string }>, Array<{ slug: string; label: string }>]) => {
62
+ const firstCollection = collections[0]
63
+ const firstOtherSingle = singles.find((single) => single.slug !== type)
64
+ if (firstCollection) setBackTarget({ href: `/admin/collections/${firstCollection.slug}`, label: 'All content' })
65
+ else if (firstOtherSingle) setBackTarget({ href: `/admin/single/${firstOtherSingle.slug}`, label: 'All content' })
66
+ else setBackTarget({ href: '/admin/dashboard', label: 'Dashboard' })
67
+ })
68
+ fetch(`/api/admin/content-views/${type}`).then((response) => response.ok ? response.json() : null).then((data) => { if (data) setView(data) }).catch(() => {})
69
+ if (config.workflow?.audit) fetch('/api/admin/users').then((response) => response.ok ? response.json() : []).then(setUsers).catch(() => {})
70
+ Promise.all(relationFields(config).map((field) =>
71
+ fetch(`/api/admin/collections/${field.relationTo}/options`).then((response) => response.ok ? response.json() : []).then((options: SelectOption[]) => [field.key, options] as const),
72
+ )).then((entries) => setRelationOptions(Object.fromEntries(entries))).catch(() => {})
73
+ setLoading(true)
74
+ fetch(`/api/admin/single/${type}`)
75
+ .then(async (response) => { if (!response.ok) throw new Error(); return response.json() })
76
+ .then((data: Row) => { applyServer(data); setStatus(Boolean(data.published) ? 'Published' : 'Draft') })
77
+ .catch(() => setStatus(`Could not load ${config.label.toLowerCase()}`))
78
+ .finally(() => setLoading(false))
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, [type])
81
+
82
+ function update(key: string, value: unknown) {
83
+ setRecord((current) => ({ ...current, [key]: value }))
84
+ setErrors((current) => {
85
+ if (!(key in current)) return current
86
+ const next = { ...current }
87
+ delete next[key]
88
+ return next
89
+ })
90
+ }
91
+
92
+ function validate(): Record<string, string> {
93
+ const next: Record<string, string> = {}
94
+ for (const field of config.fields) {
95
+ if (field.readOnly || !field.required) continue
96
+ const value = record[field.key]
97
+ if (value === null || value === undefined || value === '') next[field.key] = 'This field is required.'
98
+ }
99
+ setErrors(next)
100
+ return next
101
+ }
102
+
103
+ function applyServer(data: Row) {
104
+ setRecord(data)
105
+ setSavedSignature(recordSignature(data))
106
+ setSavedPublished(Boolean(data.published))
107
+ }
108
+
109
+ async function save(publish = false, kind: SavingState = publish ? 'publish' : 'draft') {
110
+ const validationErrors = !config.workflow?.publish || publish ? validate() : {}
111
+ if (Object.keys(validationErrors).length) {
112
+ const names = Object.keys(validationErrors).map((key) => contentViewField(view, key)?.label || config.fields.find((field) => field.key === key)?.label || key)
113
+ toast.error(`Cannot ${publish ? 'publish' : 'save'}. Check: ${names.join(', ')}.`)
114
+ return
115
+ }
116
+ setSaving(kind)
117
+ setStatus(publish ? 'Publishing…' : kind === 'unpublish' ? 'Unpublishing…' : 'Saving draft…')
118
+ try {
119
+ const response = await fetch(`/api/admin/single/${type}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config.workflow?.publish ? { ...record, published: publish } : record) })
120
+ const body = await response.text()
121
+ const data = body ? JSON.parse(body) : null
122
+ if (!response.ok) throw new Error(data?.error || `Save failed (status ${response.status})`)
123
+ applyServer(data)
124
+ setStatus(publish ? 'Published live' : kind === 'unpublish' ? 'Unpublished — saved as draft' : 'Saved as draft')
125
+ toast.success(publish ? `${config.label} published live.` : kind === 'unpublish' ? `${config.label} unpublished and moved to draft.` : `${config.label} saved.`)
126
+ } catch (error) {
127
+ const message = error instanceof Error ? error.message : `Could not save ${config.label.toLowerCase()}`
128
+ setStatus(message)
129
+ toast.error(message)
130
+ } finally {
131
+ setSaving(null)
132
+ }
133
+ }
134
+
135
+ async function saveView(next: ContentViewConfig) {
136
+ const response = await fetch(`/api/admin/content-views/${type}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(next) })
137
+ const data = await response.json()
138
+ if (!response.ok) throw new Error(data.error || 'Could not save view settings.')
139
+ setView(data)
140
+ toast.success(`${config.label} view settings saved.`)
141
+ }
142
+
143
+ const dirty = savedSignature !== null && savedSignature !== recordSignature(record)
144
+ const createdByName = users.find((item) => item.id === record.createdBy)?.name ?? null
145
+ const updatedByName = users.find((item) => item.id === record.updatedBy)?.name ?? null
146
+
147
+ const fields: Record<string, ReactNode> = Object.fromEntries(
148
+ config.fields
149
+ .filter((field) => !field.hidden && isFieldVisible(field, record) && (field.readOnly || allowedFields.includes(field.key)))
150
+ .map((field) => [field.key, (
151
+ <DynamicField
152
+ key={field.key}
153
+ field={field}
154
+ value={record[field.key]}
155
+ onChange={(value) => update(field.key, value)}
156
+ label={contentViewField(view, field.key)?.label || field.label}
157
+ hint={contentViewField(view, field.key)?.description || field.hint}
158
+ error={errors[field.key]}
159
+ disabled={!canUpdate}
160
+ relationOptions={field.type === 'relation' ? relationOptions[field.key] : undefined}
161
+ />
162
+ )]),
163
+ )
164
+
165
+ const sidePanel = config.workflow?.publish ? (
166
+ <PublishingPanel
167
+ published={savedPublished}
168
+ dirty={dirty}
169
+ saving={saving}
170
+ message={status}
171
+ publishedAt={record.publishedAt as string | null | undefined}
172
+ onSaveDraft={() => void save(false)}
173
+ onPublish={() => void save(true)}
174
+ onUnpublish={canPublish ? () => void save(false, 'unpublish') : undefined}
175
+ reviewStatus="draft"
176
+ requiresApproval={false}
177
+ />
178
+ ) : (
179
+ <div className="cms-editor-sidebar-stack">
180
+ <aside className="cms-editor-sidebar">
181
+ <div className="cms-publishing-heading"><span>{config.label}</span></div>
182
+ <div className="cms-publishing-summary">{dirty && <span className="cms-unsaved-status">Unsaved changes</span>}</div>
183
+ <div className="cms-publishing-actions">
184
+ <Button variant="positive" disabled={saving !== null || !canUpdate} onClick={() => void save(false)}>{saving ? 'Saving…' : 'Save'}</Button>
185
+ </div>
186
+ <p className="cms-ai-status">{status}</p>
187
+ </aside>
188
+ <aside className="cms-content-records">
189
+ <div className="cms-records-heading"><strong>Record</strong></div>
190
+ <section className="cms-record-section">
191
+ <div className="cms-meta-list">
192
+ <div><span>ID</span><strong>{String(record.id ?? '—')}</strong></div>
193
+ <div><span>Created</span><strong>{formatDateTime(record.createdAt as string)}</strong></div>
194
+ {config.touchUpdatedAt && <div><span>Last updated</span><strong>{formatDateTime(record.updatedAt as string)}</strong></div>}
195
+ </div>
196
+ </section>
197
+ </aside>
198
+ </div>
199
+ )
200
+
201
+ return <main className="cms-dashboard">
202
+ <CmsSidebar active="singles" userName={userName} contentContext={{ title: config.label, backHref: backTarget?.href ?? '/admin/dashboard', backLabel: backTarget?.label ?? 'Dashboard', settings: view }} />
203
+ <section className="cms-content">
204
+ <EditorHeader
205
+ compact
206
+ title={config.label}
207
+ backHref={backTarget?.href ?? '/admin/dashboard'}
208
+ backLabel={backTarget?.label ?? 'Dashboard'}
209
+ actions={canConfigureViews ? <Button type="button" variant="secondary" className="cms-view-settings-button" icon="⚙" iconPosition="before" onClick={() => setViewSettingsOpen(true)}>View settings</Button> : undefined}
210
+ />
211
+ {loading ? <EditorSkeleton /> : <div className="cms-editor-layout"><ContentEditorForm settings={view} fields={fields} groupExtras={{ record: <RecordMeta createdByName={createdByName} updatedByName={updatedByName} /> }} />{sidePanel}</div>}
212
+ </section>
213
+ {viewSettingsOpen && <ContentViewSettingsModal title={config.label} value={view} onClose={() => setViewSettingsOpen(false)} onSave={saveView} />}
214
+ </main>
215
+ }
@@ -0,0 +1,96 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { EditorHeader } from './EditorHeader'
5
+ import { EditorSkeleton } from './EditorSkeleton'
6
+ import { CheckboxGroupField, Field, TextField } from './Field'
7
+ import { CmsSidebar } from './CmsSidebar'
8
+ import { ImageUploader } from './ImageUploader'
9
+ import { useToast } from './Toast'
10
+ import { formatDateTime } from '../format'
11
+ import { useCmsSiteSettings } from './CmsSiteSettingsProvider'
12
+ import { useCmsTheme } from './CmsThemeProvider'
13
+ import { Button } from './Button'
14
+ import { cmsAppearancePresets, cmsColorKeys, cmsSizeKeys, normalizeCmsAppearance, resolvedAppearanceTokens, type CmsAppearance, type CmsColorKey, type CmsSizeKey } from '../cms-appearance'
15
+ import { allApprovalContentTypes, defaultPublishingWorkflow, normalizePublishingWorkflow, type ApprovalContentType, type PublishingWorkflow } from '../publishing-workflow'
16
+ import { contentTypeDefs } from '../content-types'
17
+
18
+ type Settings = { siteName: string; logoLight: string | null; logoDark: string | null; metaTitle: string; metaDescription: string; metaImage: string | null; cmsAppearance: CmsAppearance; publishingWorkflow: PublishingWorkflow; createdAt?: string; updatedAt?: string }
19
+ type Errors = Partial<Record<'siteName', string>>
20
+ const empty = (): Settings => ({ siteName: 'Kilo CMS', logoLight: null, logoDark: null, metaTitle: '', metaDescription: '', metaImage: null, cmsAppearance: normalizeCmsAppearance(null), publishingWorkflow: defaultPublishingWorkflow })
21
+
22
+ const colorLabels: Record<CmsColorKey, string> = { ink: 'Foreground', paper: 'Page background', acid: 'Accent', acidHover: 'Accent hover', acidInk: 'Accent text', muted: 'Muted text', line: 'Borders', danger: 'Danger', dangerSoft: 'Danger soft', canvas: 'Canvas', surface: 'Surface', surfaceSoft: 'Soft surface' }
23
+ const sizeLabels: Record<CmsSizeKey, string> = { radius: 'Radius', radiusSm: 'Small radius', space1: 'Space 1', space2: 'Space 2', space3: 'Space 3', space4: 'Space 4', space5: 'Space 5' }
24
+ const approvalOptions = allApprovalContentTypes.map((value) => ({ value, label: contentTypeDefs[value]?.label ?? value }))
25
+
26
+ function CmsAppearanceEditor({ value, onChange }: { value: CmsAppearance; onChange: (value: CmsAppearance) => void }) {
27
+ const [mode, setMode] = useState<'light' | 'dark'>('light')
28
+ const tokens = resolvedAppearanceTokens(value, mode)
29
+ function choosePreset(preset: string) { onChange({ preset, light: {}, dark: {} }) }
30
+ function updateColor(key: CmsColorKey, next: string) { if (!/^#[0-9a-fA-F]{6}$/.test(next)) return; onChange({ ...value, [mode]: { ...value[mode], [key]: next } }) }
31
+ function updateSize(key: CmsSizeKey, next: string) { const amount = Number(next); if (!Number.isFinite(amount)) return; onChange({ ...value, [mode]: { ...value[mode], [key]: Math.max(0, Math.min(64, Math.round(amount))) } }) }
32
+ return <section className="cms-section cms-appearance-section">
33
+ <div className="cms-section-heading"><p className="section-label">CMS theme</p><p>Choose a starting palette, then override every colour, radius, and spacing token. This changes the CMS only.</p></div>
34
+ <div className="cms-theme-presets">{cmsAppearancePresets.map((preset) => <button type="button" key={preset.id} className={`cms-theme-preset ${value.preset === preset.id ? 'is-selected' : ''}`} onClick={() => choosePreset(preset.id)}><span className="cms-theme-swatch" style={{ background: preset.light.acid }} /><strong>{preset.name}</strong><small>{preset.description}</small></button>)}</div>
35
+ <div className="cms-theme-token-head"><div className="cms-tabs cms-tabs-inline"><Button variant="secondary" compact className={mode === 'light' ? 'active' : ''} onClick={() => setMode('light')}>Light tokens</Button><Button variant="secondary" compact className={mode === 'dark' ? 'active' : ''} onClick={() => setMode('dark')}>Dark tokens</Button></div><Button variant="ghost" compact onClick={() => onChange({ ...value, [mode]: {} })}>Reset {mode} overrides</Button></div>
36
+ <div className="cms-theme-token-grid">{cmsColorKeys.map((key) => <label className="cms-theme-token" key={key}><span>{colorLabels[key]}</span><input type="color" aria-label={`${colorLabels[key]} colour`} value={tokens[key]} onChange={(event) => updateColor(key, event.target.value)} /><input value={tokens[key]} onChange={(event) => updateColor(key, event.target.value)} spellCheck={false} /></label>)}</div>
37
+ <div className="cms-theme-size-grid">{cmsSizeKeys.map((key) => <TextField key={key} label={sizeLabels[key]} type="number" min="0" max="64" value={tokens[key]} onChange={(event) => updateSize(key, event.target.value)} hint="px" />)}</div>
38
+ </section>
39
+ }
40
+
41
+ export function SiteSettingsEditor({ userName }: { userName: string }) {
42
+ const toast = useToast()
43
+ const { setSettings: setCmsSiteSettings } = useCmsSiteSettings()
44
+ const { theme, preference, setPreference, setAppearance } = useCmsTheme()
45
+ const [settings, setSettings] = useState<Settings>(empty)
46
+ const [status, setStatus] = useState('Loading settings…')
47
+ const [errors, setErrors] = useState<Errors>({})
48
+ const [loading, setLoading] = useState(true)
49
+
50
+ useEffect(() => { fetch('/api/admin/settings').then(async (response) => { if (!response.ok) throw new Error(); return response.json() }).then((data) => { setSettings({ siteName: data.siteName ?? 'Kilo CMS', logoLight: data.logoLight ?? null, logoDark: data.logoDark ?? null, metaTitle: data.metaTitle ?? '', metaDescription: data.metaDescription ?? '', metaImage: data.metaImage ?? null, cmsAppearance: normalizeCmsAppearance(data.cmsAppearance), publishingWorkflow: normalizePublishingWorkflow(data.publishingWorkflow), createdAt: data.createdAt, updatedAt: data.updatedAt }); setStatus('Loaded') }).catch(() => setStatus('Could not load settings')).finally(() => setLoading(false)) }, [])
51
+
52
+ function update<K extends keyof Settings>(key: K, value: Settings[K]) { setSettings((current) => ({ ...current, [key]: value })); if (key === 'siteName') setErrors((current) => ({ ...current, siteName: undefined })) }
53
+ function validate() { const next: Errors = {}; if (!settings.siteName.trim()) next.siteName = 'Site name is required.'; setErrors(next); return !Object.keys(next).length }
54
+ async function save() {
55
+ if (!validate()) { setStatus('Fix the highlighted fields'); toast.error('Complete the required fields before saving settings.'); return }
56
+ setStatus('Saving…')
57
+ try {
58
+ const response = await fetch('/api/admin/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) })
59
+ const data = await response.json()
60
+ if (!response.ok) throw new Error(data.error)
61
+ setSettings(data)
62
+ setCmsSiteSettings({ siteName: data.siteName, logoLight: data.logoLight ?? null, logoDark: data.logoDark ?? null, publishingWorkflow: normalizePublishingWorkflow(data.publishingWorkflow) })
63
+ setStatus('Saved')
64
+ toast.success('Settings saved.')
65
+ } catch (error) { const message = error instanceof Error ? error.message : 'Could not save settings'; setStatus(message); toast.error(message) }
66
+ }
67
+
68
+ return <main className="cms-dashboard"><CmsSidebar active="settings" userName={userName} /><section className="cms-content">
69
+ <EditorHeader compact title="Site settings" backHref="/admin/dashboard" backLabel="Dashboard" />
70
+ {loading ? <EditorSkeleton sections={1} /> : <div className="cms-editor-layout"><div className="cms-editor-form">
71
+ <section className="cms-section">
72
+ <div className="cms-section-heading"><p className="section-label">Site</p><p>Name and logo used across the CMS and the public site.</p></div>
73
+ <TextField label="Site name" required value={settings.siteName} onChange={(event) => update('siteName', event.target.value)} error={errors.siteName} hint="Shown in the browser tab title, e.g. “Page title — Site name”." />
74
+ <div className="cms-two-columns">
75
+ <Field label="Logo (light mode)" hint="Square image recommended (PNG or WebP)"><ImageUploader value={settings.logoLight ?? ''} onChange={(url) => update('logoLight', url || null)} aspect={1} label="Choose logo" /></Field>
76
+ <Field label="Logo (dark mode)" hint="Optional — falls back to the light mode logo when left blank"><ImageUploader value={settings.logoDark ?? ''} onChange={(url) => update('logoDark', url || null)} aspect={1} label="Choose logo" /></Field>
77
+ </div>
78
+ </section>
79
+ <section className="cms-section">
80
+ <div className="cms-section-heading"><p className="section-label">Appearance</p><p>Personal preference for this CMS browser session. It does not change the public website theme.</p></div>
81
+ <Field label="Theme" hint={preference === 'system' ? `Following your device — currently ${theme} mode` : `Currently using ${theme} mode`}>
82
+ <div className="cms-tabs cms-tabs-inline">
83
+ <Button type="button" variant="secondary" compact className={preference === 'light' ? 'active' : ''} onClick={() => setPreference('light')}>Light</Button>
84
+ <Button type="button" variant="secondary" compact className={preference === 'dark' ? 'active' : ''} onClick={() => setPreference('dark')}>Dark</Button>
85
+ <Button type="button" variant="secondary" compact className={preference === 'system' ? 'active' : ''} onClick={() => setPreference('system')}>System</Button>
86
+ </div>
87
+ </Field>
88
+ </section>
89
+ <section className="cms-section">
90
+ <div className="cms-section-heading"><p className="section-label">Publishing workflow</p><p>Choose which content types must pass review and approval before they can go live. Unticked types can publish directly.</p></div>
91
+ <CheckboxGroupField label="Require approval before publish" hint="This rule is enforced by the CMS API, including direct publish requests." value={settings.publishingWorkflow.approvalRequired} onChange={(approvalRequired) => update('publishingWorkflow', { approvalRequired: approvalRequired.filter((item): item is ApprovalContentType => (allApprovalContentTypes as string[]).includes(item)) })} options={approvalOptions} />
92
+ </section>
93
+ <CmsAppearanceEditor value={settings.cmsAppearance} onChange={(cmsAppearance) => { update('cmsAppearance', cmsAppearance); setAppearance(cmsAppearance) }} />
94
+ </div><aside className="cms-editor-sidebar"><p>{status}</p><Button variant="primary" type="button" onClick={save} icon="↗">Save settings</Button><div className="cms-meta-list"><div><span>Created</span><span>{formatDateTime(settings.createdAt)}</span></div><div><span>Updated</span><span>{formatDateTime(settings.updatedAt)}</span></div></div></aside></div>}
95
+ </section></main>
96
+ }
@@ -0,0 +1,45 @@
1
+ 'use client'
2
+
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
4
+
5
+ export type ToastType = 'success' | 'error' | 'warning' | 'info'
6
+ type ToastInput = { message: string; type?: ToastType; duration?: number }
7
+ type ToastItem = ToastInput & { id: string; type: ToastType }
8
+ type ToastContextValue = { show: (input: ToastInput) => void; success: (message: string) => void; error: (message: string) => void; warning: (message: string) => void; info: (message: string) => void }
9
+
10
+ const ToastContext = createContext<ToastContextValue | null>(null)
11
+
12
+ function ToastIcon({ type }: { type: ToastType }) {
13
+ if (type === 'success') return <span aria-hidden="true">✓</span>
14
+ if (type === 'error') return <span aria-hidden="true">!</span>
15
+ if (type === 'warning') return <span aria-hidden="true">!</span>
16
+ return <span aria-hidden="true">i</span>
17
+ }
18
+
19
+ export function ToastProvider({ children }: { children: ReactNode }) {
20
+ const [toasts, setToasts] = useState<ToastItem[]>([])
21
+ const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>())
22
+ const dismiss = useCallback((id: string) => {
23
+ const timer = timers.current.get(id)
24
+ if (timer) clearTimeout(timer)
25
+ timers.current.delete(id)
26
+ setToasts((items) => items.filter((item) => item.id !== id))
27
+ }, [])
28
+ const show = useCallback((input: ToastInput) => {
29
+ const id = crypto.randomUUID()
30
+ const toast: ToastItem = { ...input, id, type: input.type ?? 'info' }
31
+ setToasts((items) => [...items, toast].slice(-4))
32
+ const duration = input.duration ?? (toast.type === 'error' ? 6500 : 4000)
33
+ timers.current.set(id, setTimeout(() => dismiss(id), duration))
34
+ }, [dismiss])
35
+ useEffect(() => () => { timers.current.forEach((timer) => clearTimeout(timer)) }, [])
36
+ const value = useMemo<ToastContextValue>(() => ({ show, success: (message) => show({ message, type: 'success' }), error: (message) => show({ message, type: 'error' }), warning: (message) => show({ message, type: 'warning' }), info: (message) => show({ message, type: 'info' }) }), [show])
37
+
38
+ return <ToastContext.Provider value={value}>{children}<div className="cms-toast-region" aria-live="polite" aria-atomic="true">{toasts.map((toast) => <div className={`cms-toast is-${toast.type}`} key={toast.id} role={toast.type === 'error' ? 'alert' : 'status'}><span className="cms-toast-icon"><ToastIcon type={toast.type} /></span><p>{toast.message}</p><button type="button" onClick={() => dismiss(toast.id)} aria-label="Dismiss notification">×</button></div>)}</div></ToastContext.Provider>
39
+ }
40
+
41
+ export function useToast() {
42
+ const context = useContext(ToastContext)
43
+ if (!context) throw new Error('useToast must be used inside ToastProvider.')
44
+ return context
45
+ }