kilo-cms 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -0
- package/drizzle.kilo.config.ts +17 -0
- package/package.json +97 -0
- package/src/admin/AiSettingsEditor.tsx +48 -0
- package/src/admin/AiWritingAssistant.tsx +159 -0
- package/src/admin/ApiTokensEditor.tsx +69 -0
- package/src/admin/ArrayField.tsx +118 -0
- package/src/admin/BlocksField.tsx +131 -0
- package/src/admin/Button.tsx +31 -0
- package/src/admin/CloseButton.tsx +18 -0
- package/src/admin/CmsLocalesProvider.tsx +19 -0
- package/src/admin/CmsSidebar.tsx +147 -0
- package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
- package/src/admin/CmsThemeProvider.tsx +100 -0
- package/src/admin/CollectionListEditor.tsx +278 -0
- package/src/admin/CollectionRecordEditor.tsx +420 -0
- package/src/admin/ContentEditorForm.tsx +21 -0
- package/src/admin/ContentViewSettings.tsx +55 -0
- package/src/admin/DashboardBuilder.tsx +284 -0
- package/src/admin/DragHandle.tsx +3 -0
- package/src/admin/DynamicField.tsx +517 -0
- package/src/admin/EditorHeader.tsx +13 -0
- package/src/admin/EditorSkeleton.tsx +27 -0
- package/src/admin/EmailSettingsEditor.tsx +80 -0
- package/src/admin/Field.tsx +215 -0
- package/src/admin/FilterBuilder.tsx +62 -0
- package/src/admin/GroupField.tsx +61 -0
- package/src/admin/ImageUploader.tsx +198 -0
- package/src/admin/ListingSettingsModal.tsx +68 -0
- package/src/admin/ListingView.tsx +123 -0
- package/src/admin/LocalesEditor.tsx +86 -0
- package/src/admin/LocalizedField.tsx +46 -0
- package/src/admin/MapPicker.tsx +53 -0
- package/src/admin/MediaLibrary.tsx +483 -0
- package/src/admin/MediaUploader.tsx +55 -0
- package/src/admin/Portal.tsx +10 -0
- package/src/admin/PublishingPanel.tsx +102 -0
- package/src/admin/RecordMeta.tsx +24 -0
- package/src/admin/ReviewQueueEditor.tsx +103 -0
- package/src/admin/RichTextEditor.tsx +49 -0
- package/src/admin/RoleEditor.tsx +121 -0
- package/src/admin/RolesEditor.tsx +42 -0
- package/src/admin/RowActionsMenu.tsx +64 -0
- package/src/admin/ScheduledEditor.tsx +155 -0
- package/src/admin/SeoSettingsEditor.tsx +53 -0
- package/src/admin/SingleRecordEditor.tsx +215 -0
- package/src/admin/SiteSettingsEditor.tsx +96 -0
- package/src/admin/Toast.tsx +45 -0
- package/src/admin/UserEditor.tsx +64 -0
- package/src/admin/UsersEditor.tsx +37 -0
- package/src/admin/WebhooksEditor.tsx +115 -0
- package/src/admin/admin.css +6637 -0
- package/src/admin/globals.css +14 -0
- package/src/admin/pages/admin-index.tsx +5 -0
- package/src/admin/pages/ai-settings.tsx +9 -0
- package/src/admin/pages/api-tokens.tsx +10 -0
- package/src/admin/pages/collection-list.tsx +22 -0
- package/src/admin/pages/collection-record.tsx +31 -0
- package/src/admin/pages/dashboard.tsx +10 -0
- package/src/admin/pages/email-settings.tsx +10 -0
- package/src/admin/pages/layout.tsx +24 -0
- package/src/admin/pages/locales-settings.tsx +10 -0
- package/src/admin/pages/login.tsx +25 -0
- package/src/admin/pages/media.tsx +9 -0
- package/src/admin/pages/review-queue.tsx +20 -0
- package/src/admin/pages/role-edit.tsx +11 -0
- package/src/admin/pages/roles.tsx +10 -0
- package/src/admin/pages/scheduled.tsx +17 -0
- package/src/admin/pages/seo-settings.tsx +9 -0
- package/src/admin/pages/setup-admin.tsx +76 -0
- package/src/admin/pages/single-record.tsx +24 -0
- package/src/admin/pages/site-settings.tsx +9 -0
- package/src/admin/pages/user-edit.tsx +11 -0
- package/src/admin/pages/user-new.tsx +10 -0
- package/src/admin/pages/users.tsx +10 -0
- package/src/admin/pages/webhooks-settings.tsx +10 -0
- package/src/admin/useKeyedRows.ts +35 -0
- package/src/ai-config.ts +35 -0
- package/src/ai-secrets.ts +17 -0
- package/src/ai-types.ts +10 -0
- package/src/ai-writer.ts +84 -0
- package/src/api-tokens.ts +25 -0
- package/src/auth-client.ts +5 -0
- package/src/auth.ts +33 -0
- package/src/calendar-grid.ts +12 -0
- package/src/cli/index.mjs +149 -0
- package/src/cms-appearance.ts +55 -0
- package/src/collections/content-view.ts +69 -0
- package/src/collections/define.ts +17 -0
- package/src/collections/filters.ts +92 -0
- package/src/collections/index.ts +11 -0
- package/src/collections/join.ts +64 -0
- package/src/collections/locale.ts +22 -0
- package/src/collections/query.ts +160 -0
- package/src/collections/registry.ts +88 -0
- package/src/collections/schedule.ts +12 -0
- package/src/collections/server.ts +12 -0
- package/src/collections/single.ts +26 -0
- package/src/collections/table.ts +14 -0
- package/src/collections/types.ts +336 -0
- package/src/collections/validation.ts +217 -0
- package/src/collections/values.ts +181 -0
- package/src/collections/workflow.ts +90 -0
- package/src/config.ts +34 -0
- package/src/content-types.ts +218 -0
- package/src/dashboard.ts +39 -0
- package/src/db.ts +50 -0
- package/src/email.ts +39 -0
- package/src/format.ts +4 -0
- package/src/listQuery.ts +23 -0
- package/src/locale-settings.ts +23 -0
- package/src/media-storage.ts +19 -0
- package/src/permissions.ts +37 -0
- package/src/publishing-workflow-db.ts +11 -0
- package/src/publishing-workflow.ts +20 -0
- package/src/richtext.ts +5 -0
- package/src/routes/admin/ai/settings/route.ts +20 -0
- package/src/routes/admin/ai/write/route.ts +21 -0
- package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
- package/src/routes/admin/api-tokens/route.ts +26 -0
- package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
- package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
- package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
- package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
- package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
- package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
- package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
- package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/route.ts +88 -0
- package/src/routes/admin/collections/route.ts +13 -0
- package/src/routes/admin/content-views/[type]/route.ts +25 -0
- package/src/routes/admin/dashboard/data/route.ts +39 -0
- package/src/routes/admin/dashboard/route.ts +30 -0
- package/src/routes/admin/email-settings/route.ts +49 -0
- package/src/routes/admin/email-settings/test/route.ts +18 -0
- package/src/routes/admin/locales/[code]/route.ts +38 -0
- package/src/routes/admin/locales/reorder/route.ts +18 -0
- package/src/routes/admin/locales/route.ts +46 -0
- package/src/routes/admin/media/[id]/restore/route.ts +13 -0
- package/src/routes/admin/media/[id]/route.ts +56 -0
- package/src/routes/admin/media/bulk/route.ts +46 -0
- package/src/routes/admin/media/folders/[id]/route.ts +34 -0
- package/src/routes/admin/media/route.ts +41 -0
- package/src/routes/admin/media/stats/route.ts +14 -0
- package/src/routes/admin/roles/[id]/route.ts +43 -0
- package/src/routes/admin/roles/route.ts +34 -0
- package/src/routes/admin/settings/route.ts +45 -0
- package/src/routes/admin/single/[type]/route.ts +54 -0
- package/src/routes/admin/singles/route.ts +15 -0
- package/src/routes/admin/upload/route.ts +55 -0
- package/src/routes/admin/users/[id]/route.ts +24 -0
- package/src/routes/admin/users/route.ts +24 -0
- package/src/routes/admin/webhooks/[id]/route.ts +38 -0
- package/src/routes/admin/webhooks/route.ts +26 -0
- package/src/routes/cron/publish-scheduled.ts +37 -0
- package/src/routes/setup/admin.ts +19 -0
- package/src/schema/admin-ui.ts +18 -0
- package/src/schema/auth.ts +49 -0
- package/src/schema/developer-tools.ts +44 -0
- package/src/schema/helpers.ts +35 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/locales.ts +13 -0
- package/src/schema/media.ts +22 -0
- package/src/schema/rbac.ts +11 -0
- package/src/schema/settings.ts +28 -0
- package/src/secrets.ts +26 -0
- package/src/seo.ts +69 -0
- package/src/storage/index.ts +20 -0
- package/src/storage/local.ts +27 -0
- package/src/storage/s3-compatible.ts +31 -0
- package/src/storage/types.ts +8 -0
- package/src/storage/vercel-blob.ts +17 -0
- package/src/useBodyScrollLock.ts +12 -0
- package/src/useLocalStorageState.ts +26 -0
- package/src/webhook-events.ts +12 -0
- package/src/webhooks.ts +30 -0
|
@@ -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
|
+
}
|