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,517 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+ import { ImageUploader } from './ImageUploader'
5
+ import { ArrayField } from './ArrayField'
6
+ import { BlocksField } from './BlocksField'
7
+ import { CategoryField, CheckboxField, CheckboxGroupField, ColorField, DateField, DateTimeField, Field, FileField, JsonField, PasswordField, PointField, RadioField, RangeField, RatingField, SelectField, TagsField, TextAreaField, TextField, TimeField, ToggleField } from './Field'
8
+ import { GroupField } from './GroupField'
9
+ import { LocalizedField } from './LocalizedField'
10
+ import { RichTextEditor } from './RichTextEditor'
11
+ import type { AnyFieldConfig, SelectOption } from '../collections/types'
12
+ import { richTextToText } from '../collections/values'
13
+ import { emptyRichText, type RichTextDocument } from '../richtext'
14
+
15
+ export type DynamicFieldProps = {
16
+ field: AnyFieldConfig
17
+ value: unknown
18
+ onChange: (value: unknown) => void
19
+ label: string
20
+ hint?: string
21
+ error?: string
22
+ disabled?: boolean
23
+ relationOptions?: SelectOption[]
24
+ }
25
+
26
+ export function DynamicField({ field, value, onChange, label, hint, error, disabled, relationOptions = [] }: DynamicFieldProps) {
27
+ // Array items never declare `readOnly` (meaningless nested) — see ArrayItemFieldConfig.
28
+ const readOnly = 'readOnly' in field && Boolean(field.readOnly)
29
+ const shared = { label, hint, error, required: field.required, disabled: disabled || readOnly }
30
+
31
+ // 'localized' only exists on top-level FieldConfig (see ArrayItemFieldConfig/BlockFieldConfig,
32
+ // which omit it) — intercepts before the type switch since the stored value is a per-locale
33
+ // map, not the field's normal shape.
34
+ if ('localized' in field && field.localized)
35
+ return (
36
+ <LocalizedField
37
+ field={field}
38
+ value={typeof value === 'object' && value ? (value as Record<string, unknown>) : {}}
39
+ onChange={(next) => onChange(next)}
40
+ label={label}
41
+ hint={hint}
42
+ error={error}
43
+ disabled={shared.disabled}
44
+ />
45
+ )
46
+
47
+ switch (field.type) {
48
+ case 'text':
49
+ if (field.format === 'password') {
50
+ if (shared.disabled)
51
+ return (
52
+ <Field label={label} required={field.required} hint={hint} error={error}>
53
+ <span className="cms-input-shell cms-password-shell">
54
+ <input aria-label={label} type="password" value={value ? '********' : ''} readOnly disabled />
55
+ </span>
56
+ </Field>
57
+ )
58
+ return (
59
+ <PasswordField
60
+ label={label}
61
+ hint={hint}
62
+ error={error}
63
+ required={field.required}
64
+ placeholder={field.placeholder}
65
+ value={typeof value === 'string' ? value : ''}
66
+ onChange={(event) => onChange(event.target.value)}
67
+ />
68
+ )
69
+ }
70
+ if (field.suggestionsFrom && !shared.disabled)
71
+ return (
72
+ <SuggestionTextField
73
+ label={label}
74
+ hint={hint}
75
+ error={error}
76
+ required={field.required}
77
+ value={typeof value === 'string' ? value : ''}
78
+ onChange={(next) => onChange(next)}
79
+ source={field.suggestionsFrom}
80
+ />
81
+ )
82
+ return (
83
+ <TextField
84
+ {...shared}
85
+ type={field.format === 'email' ? 'email' : field.format === 'url' ? 'url' : field.format === 'tel' ? 'tel' : 'text'}
86
+ placeholder={field.placeholder}
87
+ value={typeof value === 'string' ? value : ''}
88
+ onChange={(event) => onChange(event.target.value)}
89
+ />
90
+ )
91
+
92
+ case 'textarea':
93
+ return (
94
+ <TextAreaField
95
+ {...shared}
96
+ rows={field.rows ?? 4}
97
+ placeholder={field.placeholder}
98
+ value={typeof value === 'string' ? value : ''}
99
+ onChange={(event) => onChange(event.target.value)}
100
+ />
101
+ )
102
+
103
+ case 'number':
104
+ if (field.display === 'range')
105
+ return (
106
+ <RangeField
107
+ label={label}
108
+ hint={hint}
109
+ error={error}
110
+ required={field.required}
111
+ disabled={shared.disabled}
112
+ min={field.min}
113
+ max={field.max}
114
+ step={field.integer ? 1 : undefined}
115
+ value={typeof value === 'number' ? value : (field.min ?? 0)}
116
+ onChange={(next) => onChange(next)}
117
+ />
118
+ )
119
+ return (
120
+ <TextField
121
+ {...shared}
122
+ type="number"
123
+ inputMode="numeric"
124
+ min={field.min}
125
+ max={field.max}
126
+ step={field.integer ? 1 : 'any'}
127
+ placeholder={field.placeholder}
128
+ value={value === null || value === undefined ? '' : String(value)}
129
+ onChange={(event) => onChange(event.target.value === '' ? (field.nullable ? null : 0) : Number(event.target.value))}
130
+ />
131
+ )
132
+
133
+ case 'rating':
134
+ return (
135
+ <RatingField
136
+ label={label}
137
+ hint={hint}
138
+ error={error}
139
+ required={field.required}
140
+ disabled={shared.disabled}
141
+ max={field.max}
142
+ value={typeof value === 'number' ? value : 0}
143
+ onChange={(next) => onChange(next)}
144
+ />
145
+ )
146
+
147
+ case 'boolean':
148
+ return field.display === 'checkbox'
149
+ ? <CheckboxField label={label} hint={hint} error={error} checked={Boolean(value)} onChange={onChange} description={field.checkboxLabel} />
150
+ : <ToggleField label={label} hint={hint} error={error} checked={Boolean(value)} onChange={onChange} />
151
+
152
+ case 'select':
153
+ if (field.display === 'radio')
154
+ return (
155
+ <RadioField
156
+ label={label}
157
+ hint={hint}
158
+ error={error}
159
+ required={field.required}
160
+ disabled={shared.disabled}
161
+ value={typeof value === 'string' ? value : ''}
162
+ onChange={(next) => onChange(next)}
163
+ options={withCurrentValue(field.options, value)}
164
+ />
165
+ )
166
+ return (
167
+ <SelectField
168
+ {...shared}
169
+ value={typeof value === 'string' ? value : ''}
170
+ options={withCurrentValue(field.options, value)}
171
+ onChange={(event) => onChange(event.target.value)}
172
+ />
173
+ )
174
+
175
+ case 'color':
176
+ return (
177
+ <ColorField
178
+ label={label}
179
+ hint={hint}
180
+ error={error}
181
+ required={field.required}
182
+ disabled={shared.disabled}
183
+ value={typeof value === 'string' ? value : '#000000'}
184
+ onChange={(next) => onChange(next)}
185
+ />
186
+ )
187
+
188
+ case 'date':
189
+ return (
190
+ <DateField
191
+ label={label}
192
+ hint={hint}
193
+ error={error}
194
+ required={field.required}
195
+ variant="calendar"
196
+ value={typeof value === 'string' ? value : null}
197
+ onChange={(next) => onChange(next)}
198
+ />
199
+ )
200
+
201
+ case 'time':
202
+ return (
203
+ <TimeField
204
+ label={label}
205
+ hint={hint}
206
+ error={error}
207
+ required={field.required}
208
+ value={typeof value === 'string' ? value : null}
209
+ onChange={(next) => onChange(next)}
210
+ />
211
+ )
212
+
213
+ case 'datetime':
214
+ // Most datetime fields are DB-managed (createdAt/updatedAt/scheduledAt) and readOnly, which
215
+ // renders this read-only branch. A declared, non-readOnly datetime field (e.g.
216
+ // payments.paidAt) gets a real editable control instead.
217
+ if (shared.disabled)
218
+ return (
219
+ <Field label={label} hint={hint} error={error}>
220
+ <span className="cms-input-shell">
221
+ <input
222
+ readOnly
223
+ aria-label={label}
224
+ value={value ? new Date(String(value)).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
225
+ />
226
+ </span>
227
+ </Field>
228
+ )
229
+ return (
230
+ <DateTimeField
231
+ label={label}
232
+ hint={hint}
233
+ error={error}
234
+ required={field.required}
235
+ value={typeof value === 'string' ? value : null}
236
+ onChange={(next) => onChange(next)}
237
+ />
238
+ )
239
+
240
+ case 'relation':
241
+ if (field.hasMany) {
242
+ const picked = Array.isArray(value) ? (value as string[]) : []
243
+ if (shared.disabled) {
244
+ return (
245
+ <Field label={label} required={field.required} hint={hint} error={error}>
246
+ <div className="cms-tags cms-tags-readonly">
247
+ {picked.length
248
+ ? picked.map((id) => <span className="cms-tag" key={id}>{relationOptions.find((option) => option.value === id)?.label ?? id}</span>)
249
+ : <span className="cms-muted-value">—</span>}
250
+ </div>
251
+ </Field>
252
+ )
253
+ }
254
+ return (
255
+ <CheckboxGroupField
256
+ label={label}
257
+ required={field.required}
258
+ hint={hint}
259
+ error={error}
260
+ value={picked}
261
+ onChange={(next) => onChange(next)}
262
+ options={relationOptions}
263
+ />
264
+ )
265
+ }
266
+ return (
267
+ <SelectField
268
+ {...shared}
269
+ value={typeof value === 'string' ? value : ''}
270
+ options={[{ value: '', label: field.required ? 'Select…' : 'None' }, ...withCurrentValue(relationOptions, value)]}
271
+ onChange={(event) => onChange(event.target.value)}
272
+ />
273
+ )
274
+
275
+ case 'join': {
276
+ const rows = Array.isArray(value) ? (value as Array<{ id: string; label: unknown }>) : []
277
+ return (
278
+ <Field label={label} hint={hint} error={error}>
279
+ <div className="cms-array-field cms-array-readonly">
280
+ {rows.length
281
+ ? rows.map((row) => (
282
+ <div className="cms-array-row cms-array-row-readonly" key={row.id}>
283
+ <a href={`/admin/collections/${field.collection}/${row.id}`}>{String(row.label ?? row.id)}</a>
284
+ </div>
285
+ ))
286
+ : <span className="cms-muted-value">—</span>}
287
+ </div>
288
+ </Field>
289
+ )
290
+ }
291
+
292
+ case 'group':
293
+ return (
294
+ <GroupField
295
+ field={field}
296
+ label={label}
297
+ hint={hint}
298
+ error={error}
299
+ disabled={shared.disabled}
300
+ value={typeof value === 'object' && value ? (value as Record<string, unknown>) : {}}
301
+ onChange={(next) => onChange(next)}
302
+ />
303
+ )
304
+
305
+ case 'json':
306
+ return (
307
+ <JsonField
308
+ label={label}
309
+ hint={hint}
310
+ error={error}
311
+ required={field.required}
312
+ disabled={shared.disabled}
313
+ value={value ?? null}
314
+ onChange={(next) => onChange(next)}
315
+ />
316
+ )
317
+
318
+ case 'point':
319
+ return (
320
+ <PointField
321
+ label={label}
322
+ hint={hint}
323
+ error={error}
324
+ required={field.required}
325
+ disabled={shared.disabled}
326
+ value={value && typeof value === 'object' ? (value as { lat: number; lng: number }) : { lat: 0, lng: 0 }}
327
+ onChange={(next) => onChange(next)}
328
+ />
329
+ )
330
+
331
+ case 'file':
332
+ return (
333
+ <FileField
334
+ label={label}
335
+ hint={hint}
336
+ error={error}
337
+ required={field.required}
338
+ disabled={shared.disabled}
339
+ accept={field.accept}
340
+ value={typeof value === 'string' ? value : ''}
341
+ onChange={(next) => onChange(next)}
342
+ />
343
+ )
344
+
345
+ case 'richtext': {
346
+ const doc = isRichTextDocument(value) ? value : emptyRichText
347
+ if (shared.disabled) {
348
+ return (
349
+ <Field label={label} required={field.required} hint={hint} error={error}>
350
+ <div className="cms-richtext-readonly">{richTextToText(doc, 400) || '—'}</div>
351
+ </Field>
352
+ )
353
+ }
354
+ return (
355
+ <RichTextEditor
356
+ label={label}
357
+ hint={hint}
358
+ required={field.required}
359
+ error={error}
360
+ value={doc}
361
+ onChange={(next) => onChange(next)}
362
+ />
363
+ )
364
+ }
365
+
366
+ case 'image': {
367
+ const url = typeof value === 'string' ? value : ''
368
+ return (
369
+ <Field label={label} required={field.required} hint={hint} error={error}>
370
+ <div className="cms-dynamic-image">
371
+ <ImageUploader
372
+ value={url}
373
+ onChange={(next) => onChange(next || (field.nullable ? null : ''))}
374
+ aspect={field.aspect}
375
+ allowRemove={!field.required && !shared.disabled}
376
+ />
377
+ {field.allowUrlInput && (
378
+ <span className="cms-input-shell">
379
+ <input
380
+ aria-label={`${label} URL`}
381
+ disabled={shared.disabled}
382
+ placeholder="https://..."
383
+ value={url}
384
+ onChange={(event) => onChange(event.target.value || (field.nullable ? null : ''))}
385
+ />
386
+ </span>
387
+ )}
388
+ </div>
389
+ </Field>
390
+ )
391
+ }
392
+
393
+ case 'tags':
394
+ if (shared.disabled) {
395
+ return (
396
+ <Field label={label} required={field.required} hint={hint} error={error}>
397
+ <div className="cms-tags cms-tags-readonly">
398
+ {Array.isArray(value) && value.length ? value.map((tag) => <span className="cms-tag" key={String(tag)}>{String(tag)}</span>) : <span className="cms-muted-value">—</span>}
399
+ </div>
400
+ </Field>
401
+ )
402
+ }
403
+ return (
404
+ <TagsField
405
+ label={label}
406
+ required={field.required}
407
+ hint={hint}
408
+ error={error}
409
+ value={Array.isArray(value) ? (value as string[]) : []}
410
+ onChange={(next) => onChange(next)}
411
+ />
412
+ )
413
+
414
+ case 'multiselect': {
415
+ const picked = Array.isArray(value) ? (value as string[]) : []
416
+ if (shared.disabled) {
417
+ return (
418
+ <Field label={label} required={field.required} hint={hint} error={error}>
419
+ <div className="cms-tags cms-tags-readonly">
420
+ {picked.length
421
+ ? picked.map((item) => <span className="cms-tag" key={item}>{field.options.find((option) => option.value === item)?.label ?? item}</span>)
422
+ : <span className="cms-muted-value">—</span>}
423
+ </div>
424
+ </Field>
425
+ )
426
+ }
427
+ return (
428
+ <CheckboxGroupField
429
+ label={label}
430
+ required={field.required}
431
+ hint={hint}
432
+ error={error}
433
+ value={picked}
434
+ onChange={(next) => onChange(next)}
435
+ options={field.options}
436
+ />
437
+ )
438
+ }
439
+
440
+ case 'array':
441
+ return (
442
+ <ArrayField
443
+ field={field}
444
+ label={label}
445
+ hint={hint}
446
+ error={error}
447
+ disabled={shared.disabled}
448
+ value={Array.isArray(value) ? (value as Record<string, unknown>[]) : []}
449
+ onChange={(next) => onChange(next)}
450
+ />
451
+ )
452
+
453
+ case 'blocks':
454
+ return (
455
+ <BlocksField
456
+ field={field}
457
+ label={label}
458
+ hint={hint}
459
+ error={error}
460
+ disabled={shared.disabled}
461
+ value={Array.isArray(value) ? (value as Record<string, unknown>[]) : []}
462
+ onChange={(next) => onChange(next)}
463
+ />
464
+ )
465
+ }
466
+ }
467
+
468
+ /** Keeps an unknown stored value visible instead of silently blanking the select. */
469
+ function withCurrentValue(options: SelectOption[], value: unknown): SelectOption[] {
470
+ return typeof value === 'string' && value && !options.some((option) => option.value === value)
471
+ ? [...options, { value, label: value }]
472
+ : options
473
+ }
474
+
475
+ function isRichTextDocument(value: unknown): value is RichTextDocument {
476
+ return typeof value === 'object' && value !== null && (value as { type?: unknown }).type === 'doc'
477
+ }
478
+
479
+ /** Autocomplete + inline create over another collection's titleField, storing the plain label.
480
+ * Reuses CategoryField verbatim; only the data plumbing is generic. */
481
+ function SuggestionTextField({ label, hint, error, required, value, onChange, source }: {
482
+ label: string
483
+ hint?: string
484
+ error?: string
485
+ required?: boolean
486
+ value: string
487
+ onChange: (value: string) => void
488
+ source: { collection: string; createLabel?: string }
489
+ }) {
490
+ const [options, setOptions] = useState<Array<{ id: string; name: string; slug: string }>>([])
491
+
492
+ useEffect(() => {
493
+ let cancelled = false
494
+ fetch(`/api/admin/collections/${source.collection}/options`)
495
+ .then((response) => (response.ok ? response.json() : []))
496
+ .then((data: SelectOption[]) => { if (!cancelled) setOptions(data.map((option) => ({ id: option.value, name: option.label, slug: option.value }))) })
497
+ .catch(() => {})
498
+ return () => { cancelled = true }
499
+ }, [source.collection])
500
+
501
+ const create = useCallback(async (name: string) => {
502
+ try {
503
+ const response = await fetch(`/api/admin/collections/${source.collection}`, {
504
+ method: 'POST',
505
+ headers: { 'Content-Type': 'application/json' },
506
+ body: JSON.stringify({ name }),
507
+ })
508
+ const data = await response.json()
509
+ if (!response.ok) return null
510
+ const created = { id: String(data.id), name: String(data.name ?? name), slug: String(data.slug ?? '') }
511
+ setOptions((items) => [...items, created].sort((a, b) => a.name.localeCompare(b.name)))
512
+ return created
513
+ } catch { return null }
514
+ }, [source.collection])
515
+
516
+ return <CategoryField label={label} hint={hint} error={error} required={required} value={value} options={options} onChange={onChange} onCreate={create} />
517
+ }
@@ -0,0 +1,13 @@
1
+ import { ReactNode } from 'react'
2
+ import Link from 'next/link'
3
+
4
+ export function EditorHeader({ title, backHref, backLabel, actions, meta, compact }: { title: string; backHref?: string; backLabel?: string; actions?: ReactNode; meta?: ReactNode; compact?: boolean }) {
5
+ return <header className={`cms-header cms-editor-header ${compact ? 'cms-header-compact' : ''}`}>
6
+ <div>
7
+ {backHref && <Link className="cms-back-link" href={backHref}>← {backLabel ?? 'Back'}</Link>}
8
+ <h1>{title}</h1>
9
+ {meta && <p className="cms-header-count">{meta}</p>}
10
+ </div>
11
+ {actions && <div className="cms-header-actions">{actions}</div>}
12
+ </header>
13
+ }
@@ -0,0 +1,27 @@
1
+ const fieldWidths = ['85%', '60%', '95%', '40%']
2
+
3
+ export function EditorSkeleton({ sections = 2 }: { sections?: number }) {
4
+ return <div className="cms-editor-layout" aria-busy="true" aria-label="Loading editor">
5
+ <div className="cms-editor-form">{Array.from({ length: sections }).map((_, section) => <section className="cms-section" key={section}>
6
+ <div className="cms-skeleton-line" style={{ width: '30%', height: 11 }} />
7
+ {fieldWidths.map((width, field) => <div className="cms-skeleton-line" key={field} style={{ width }} />)}
8
+ </section>)}</div>
9
+ <aside className="cms-editor-sidebar">
10
+ <div className="cms-skeleton-line" style={{ width: '35%', height: 11 }} />
11
+ <div className="cms-skeleton-line" style={{ width: '100%', height: 38 }} />
12
+ <div className="cms-skeleton-line" style={{ width: '100%', height: 38 }} />
13
+ <div className="cms-skeleton-line" style={{ width: '70%', height: 11 }} />
14
+ </aside>
15
+ </div>
16
+ }
17
+
18
+ export function CardListSkeleton({ rows = 5 }: { rows?: number }) {
19
+ return <div className="cms-skeleton-list" aria-busy="true" aria-label="Loading list">
20
+ {Array.from({ length: rows }).map((_, index) => <div className="cms-skeleton-list-row" key={index}>
21
+ <div className="cms-skeleton-line" style={{ width: '18%', height: 11 }} />
22
+ <div className="cms-skeleton-line" style={{ width: '34%', height: 16 }} />
23
+ <div className="cms-skeleton-line" style={{ width: '22%', height: 11 }} />
24
+ <div className="cms-skeleton-line" style={{ width: '15%', height: 11 }} />
25
+ </div>)}
26
+ </div>
27
+ }
@@ -0,0 +1,80 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { EditorHeader } from './EditorHeader'
5
+ import { EditorSkeleton } from './EditorSkeleton'
6
+ import { TextField, ToggleField } from './Field'
7
+ import { CmsSidebar } from './CmsSidebar'
8
+ import { useToast } from './Toast'
9
+ import { Button } from './Button'
10
+
11
+ type EmailSettings = { host: string; port: number; secure: boolean; username: string; fromAddress: string; fromName: string; configured: boolean; hasPassword: boolean }
12
+ const empty = (): EmailSettings => ({ host: '', port: 587, secure: true, username: '', fromAddress: '', fromName: '', configured: false, hasPassword: false })
13
+
14
+ export function EmailSettingsEditor({ userName }: { userName: string }) {
15
+ const toast = useToast()
16
+ const [settings, setSettings] = useState<EmailSettings>(empty)
17
+ const [password, setPassword] = useState('')
18
+ const [loading, setLoading] = useState(true)
19
+ const [saving, setSaving] = useState(false)
20
+ const [testTo, setTestTo] = useState('')
21
+ const [testStatus, setTestStatus] = useState('')
22
+ const [testing, setTesting] = useState(false)
23
+
24
+ useEffect(() => { fetch('/api/admin/email-settings').then((response) => response.ok ? response.json() : empty()).then(setSettings).finally(() => setLoading(false)) }, [])
25
+
26
+ function update<K extends keyof EmailSettings>(key: K, value: EmailSettings[K]) { setSettings((current) => ({ ...current, [key]: value })) }
27
+
28
+ async function save() {
29
+ setSaving(true)
30
+ try {
31
+ const response = await fetch('/api/admin/email-settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ host: settings.host, port: settings.port, secure: settings.secure, username: settings.username, password: password || undefined, fromAddress: settings.fromAddress, fromName: settings.fromName }) })
32
+ const data = await response.json()
33
+ if (!response.ok) throw new Error(data.error)
34
+ setSettings(data)
35
+ setPassword('')
36
+ toast.success('Email settings saved.')
37
+ } catch (error) { toast.error(error instanceof Error ? error.message : 'Could not save email settings') } finally { setSaving(false) }
38
+ }
39
+
40
+ async function sendTest() {
41
+ if (!testTo.trim()) { setTestStatus('Enter an address to send the test to.'); return }
42
+ setTesting(true)
43
+ setTestStatus('Sending…')
44
+ try {
45
+ const response = await fetch('/api/admin/email-settings/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to: testTo }) })
46
+ const data = await response.json()
47
+ if (!response.ok) throw new Error(data.error)
48
+ setTestStatus('Test email sent — check the inbox.')
49
+ } catch (error) { setTestStatus(error instanceof Error ? error.message : 'Could not send test email') } finally { setTesting(false) }
50
+ }
51
+
52
+ return <main className="cms-dashboard"><CmsSidebar active="settings" userName={userName} /><section className="cms-content">
53
+ <EditorHeader compact title="Email" backHref="/admin/dashboard" backLabel="Dashboard" />
54
+ {loading ? <EditorSkeleton sections={2} /> : <div className="cms-editor-layout"><div className="cms-editor-form">
55
+ <section className="cms-section">
56
+ <div className="cms-section-heading"><p className="section-label">SMTP</p><p>Used to send transactional email from the CMS. The password is encrypted at rest and never shown again.</p></div>
57
+ <TextField label="SMTP host" value={settings.host} onChange={(event) => update('host', event.target.value)} placeholder="smtp.example.com" />
58
+ <div className="cms-two-columns">
59
+ <TextField label="Port" type="number" value={settings.port} onChange={(event) => update('port', Number(event.target.value) || 587)} />
60
+ <ToggleField label="Use TLS" checked={settings.secure} onChange={(checked) => update('secure', checked)} />
61
+ </div>
62
+ <TextField label="Username" value={settings.username} onChange={(event) => update('username', event.target.value)} />
63
+ <TextField label="Password" type="password" autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder={settings.hasPassword ? 'Paste a new password to replace' : 'Paste SMTP password'} />
64
+ <div className="cms-two-columns">
65
+ <TextField label="From address" type="email" value={settings.fromAddress} onChange={(event) => update('fromAddress', event.target.value)} placeholder="noreply@example.com" />
66
+ <TextField label="From name" value={settings.fromName} onChange={(event) => update('fromName', event.target.value)} placeholder="Kilo CMS" />
67
+ </div>
68
+ <p className="cms-ai-status">{settings.configured ? 'Email is configured.' : 'Not configured yet — fill in host, port, and from address, then save.'}</p>
69
+ </section>
70
+ {settings.configured && <section className="cms-section">
71
+ <div className="cms-section-heading"><p className="section-label">Test</p><p>Send a test email to confirm the configuration works.</p></div>
72
+ <div className="cms-two-columns">
73
+ <TextField label="Send test to" type="email" value={testTo} onChange={(event) => setTestTo(event.target.value)} placeholder="you@example.com" />
74
+ </div>
75
+ <Button variant="secondary" type="button" disabled={testing} onClick={() => void sendTest()}>{testing ? 'Sending…' : 'Send test email'}</Button>
76
+ {testStatus && <p className="cms-ai-status">{testStatus}</p>}
77
+ </section>}
78
+ </div><aside className="cms-editor-sidebar"><Button variant="primary" type="button" disabled={saving} onClick={() => void save()} icon="↗">{saving ? 'Saving…' : 'Save email settings'}</Button></aside></div>}
79
+ </section></main>
80
+ }