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,118 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Reorder, useDragControls } from 'framer-motion'
|
|
4
|
+
import { Button } from './Button'
|
|
5
|
+
import { DynamicField } from './DynamicField'
|
|
6
|
+
import { Field } from './Field'
|
|
7
|
+
import { DragHandle } from './DragHandle'
|
|
8
|
+
import { useKeyedRows, type KeyedRow, type Row } from './useKeyedRows'
|
|
9
|
+
import type { AnyFieldConfig, ArrayItemFieldConfig } from '../collections/types'
|
|
10
|
+
import { blankValueForField, formatFieldValue, isFieldVisible } from '../collections/values'
|
|
11
|
+
|
|
12
|
+
// A top-level `array` field or a block's own nested `array` sub-field (see BlockFieldConfig) —
|
|
13
|
+
// both render through this same component; they differ only in the top-level-only FieldBase
|
|
14
|
+
// props (group/readOnly/…), which ArrayField never reads.
|
|
15
|
+
type ArrayFieldConfig = Extract<AnyFieldConfig, { type: 'array' }>
|
|
16
|
+
|
|
17
|
+
function blankRow(fields: ArrayItemFieldConfig[]): Row {
|
|
18
|
+
return Object.fromEntries(fields.map((subfield) => [subfield.key, blankValueForField(subfield)]))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ArrayRow({ index, fields, item, onChange, onRemove }: {
|
|
22
|
+
index: number
|
|
23
|
+
fields: ArrayItemFieldConfig[]
|
|
24
|
+
item: KeyedRow
|
|
25
|
+
onChange: (row: Row) => void
|
|
26
|
+
onRemove: () => void
|
|
27
|
+
}) {
|
|
28
|
+
const controls = useDragControls()
|
|
29
|
+
const row = item.row
|
|
30
|
+
return (
|
|
31
|
+
// `value` MUST be the exact object reference that appears in the enclosing Reorder.Group's
|
|
32
|
+
// `values` array (here, `items`) — framer-motion tracks drag position by `values.indexOf(value)`.
|
|
33
|
+
// Passing `row` (the inner value) instead of `item` (the KeyedRow wrapper) silently breaks
|
|
34
|
+
// reordering entirely, since it never matches anything in `values`.
|
|
35
|
+
<Reorder.Item value={item} dragListener={false} dragControls={controls} className="cms-array-row">
|
|
36
|
+
<div className="cms-array-row-head">
|
|
37
|
+
<button type="button" className="cms-array-row-grip" onPointerDown={(event) => controls.start(event)} aria-label={`Drag to reorder item ${index + 1}`}><DragHandle /></button>
|
|
38
|
+
<span className="cms-array-row-index">{index + 1}</span>
|
|
39
|
+
<Button type="button" variant="ghost" compact onClick={onRemove} aria-label={`Remove item ${index + 1}`}>Remove</Button>
|
|
40
|
+
</div>
|
|
41
|
+
<div className="cms-content-field-grid">
|
|
42
|
+
{fields.filter((subfield) => isFieldVisible(subfield, row)).map((subfield) => (
|
|
43
|
+
<div key={subfield.key} className={`cms-content-field cms-content-field-${subfield.width ?? 'full'}`}>
|
|
44
|
+
<DynamicField
|
|
45
|
+
field={subfield}
|
|
46
|
+
value={row[subfield.key]}
|
|
47
|
+
onChange={(value) => onChange({ ...row, [subfield.key]: value })}
|
|
48
|
+
label={subfield.label}
|
|
49
|
+
hint={subfield.hint}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
</Reorder.Item>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function ArrayField({ field, value, onChange, label, hint, error, disabled }: {
|
|
59
|
+
field: ArrayFieldConfig
|
|
60
|
+
value: Row[]
|
|
61
|
+
onChange: (rows: Row[]) => void
|
|
62
|
+
label: string
|
|
63
|
+
hint?: string
|
|
64
|
+
error?: string
|
|
65
|
+
disabled?: boolean
|
|
66
|
+
}) {
|
|
67
|
+
const { items, emit } = useKeyedRows(value, onChange)
|
|
68
|
+
const itemNoun = field.itemLabel ?? 'item'
|
|
69
|
+
|
|
70
|
+
if (disabled) {
|
|
71
|
+
return (
|
|
72
|
+
<Field label={label} required={field.required} hint={hint} error={error}>
|
|
73
|
+
<div className="cms-array-field cms-array-readonly">
|
|
74
|
+
{value.length
|
|
75
|
+
? value.map((row, index) => (
|
|
76
|
+
<div className="cms-array-row cms-array-row-readonly" key={index}>
|
|
77
|
+
{field.fields.filter((subfield) => isFieldVisible(subfield, row)).map((subfield) => (
|
|
78
|
+
<div className="cms-array-readonly-cell" key={subfield.key}>
|
|
79
|
+
<span className="cms-array-readonly-label">{subfield.label}</span>
|
|
80
|
+
<span>{formatFieldValue(subfield, row[subfield.key])}</span>
|
|
81
|
+
</div>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
))
|
|
85
|
+
: <span className="cms-muted-value">—</span>}
|
|
86
|
+
</div>
|
|
87
|
+
</Field>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const atMax = typeof field.maxItems === 'number' && items.length >= field.maxItems
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Field label={label} required={field.required} hint={hint} error={error}>
|
|
95
|
+
<div className="cms-array-field">
|
|
96
|
+
{items.length
|
|
97
|
+
? (
|
|
98
|
+
<Reorder.Group axis="y" values={items} onReorder={emit} className="cms-array-rows">
|
|
99
|
+
{items.map((item, index) => (
|
|
100
|
+
<ArrayRow
|
|
101
|
+
key={item.id}
|
|
102
|
+
index={index}
|
|
103
|
+
fields={field.fields}
|
|
104
|
+
item={item}
|
|
105
|
+
onChange={(row) => emit(items.map((entry) => (entry.id === item.id ? { ...entry, row } : entry)))}
|
|
106
|
+
onRemove={() => emit(items.filter((entry) => entry.id !== item.id))}
|
|
107
|
+
/>
|
|
108
|
+
))}
|
|
109
|
+
</Reorder.Group>
|
|
110
|
+
)
|
|
111
|
+
: <p className="cms-array-empty">No {itemNoun}s yet.</p>}
|
|
112
|
+
<Button type="button" variant="secondary" compact className="cms-array-add" onClick={() => emit([...items, { id: crypto.randomUUID(), row: blankRow(field.fields) }])} disabled={atMax}>
|
|
113
|
+
+ Add {itemNoun}
|
|
114
|
+
</Button>
|
|
115
|
+
</div>
|
|
116
|
+
</Field>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Reorder, useDragControls } from 'framer-motion'
|
|
4
|
+
import { Button } from './Button'
|
|
5
|
+
import { DragHandle } from './DragHandle'
|
|
6
|
+
import { DynamicField } from './DynamicField'
|
|
7
|
+
import { Field } from './Field'
|
|
8
|
+
import { useKeyedRows, type KeyedRow, type Row } from './useKeyedRows'
|
|
9
|
+
import type { BlockTypeDef, FieldConfig } from '../collections/types'
|
|
10
|
+
import { blankValueForField, formatFieldValue, isFieldVisible } from '../collections/values'
|
|
11
|
+
|
|
12
|
+
type BlocksFieldConfig = Extract<FieldConfig, { type: 'blocks' }>
|
|
13
|
+
|
|
14
|
+
function blankBlock(blockDef: BlockTypeDef): Row {
|
|
15
|
+
return { blockType: blockDef.type, ...Object.fromEntries(blockDef.fields.map((subfield) => [subfield.key, blankValueForField(subfield)])) }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function BlockRow({ index, blockDef, item, onChange, onRemove }: {
|
|
19
|
+
index: number
|
|
20
|
+
blockDef: BlockTypeDef | undefined
|
|
21
|
+
item: KeyedRow
|
|
22
|
+
onChange: (row: Row) => void
|
|
23
|
+
onRemove: () => void
|
|
24
|
+
}) {
|
|
25
|
+
const controls = useDragControls()
|
|
26
|
+
const row = item.row
|
|
27
|
+
return (
|
|
28
|
+
// `value` MUST be the exact object reference in the enclosing Reorder.Group's `values`
|
|
29
|
+
// array (here, `items`) — framer-motion tracks drag position by `values.indexOf(value)`.
|
|
30
|
+
<Reorder.Item value={item} dragListener={false} dragControls={controls} className="cms-array-row">
|
|
31
|
+
<div className="cms-array-row-head">
|
|
32
|
+
<button type="button" className="cms-array-row-grip" onPointerDown={(event) => controls.start(event)} aria-label={`Drag to reorder block ${index + 1}`}><DragHandle /></button>
|
|
33
|
+
<span className="cms-block-type-badge">{blockDef?.label ?? String(row.blockType ?? '?')}</span>
|
|
34
|
+
<span className="cms-array-row-index">{index + 1}</span>
|
|
35
|
+
<Button type="button" variant="ghost" compact onClick={onRemove} aria-label={`Remove block ${index + 1}`}>Remove</Button>
|
|
36
|
+
</div>
|
|
37
|
+
{blockDef && (
|
|
38
|
+
<div className="cms-content-field-grid">
|
|
39
|
+
{blockDef.fields.filter((subfield) => isFieldVisible(subfield, row)).map((subfield) => (
|
|
40
|
+
<div key={subfield.key} className={`cms-content-field cms-content-field-${subfield.width ?? 'full'}`}>
|
|
41
|
+
<DynamicField
|
|
42
|
+
field={subfield}
|
|
43
|
+
value={row[subfield.key]}
|
|
44
|
+
onChange={(value) => onChange({ ...row, [subfield.key]: value })}
|
|
45
|
+
label={subfield.label}
|
|
46
|
+
hint={subfield.hint}
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
</Reorder.Item>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function BlocksField({ field, value, onChange, label, hint, error, disabled }: {
|
|
57
|
+
field: BlocksFieldConfig
|
|
58
|
+
value: Row[]
|
|
59
|
+
onChange: (rows: Row[]) => void
|
|
60
|
+
label: string
|
|
61
|
+
hint?: string
|
|
62
|
+
error?: string
|
|
63
|
+
disabled?: boolean
|
|
64
|
+
}) {
|
|
65
|
+
const { items, emit } = useKeyedRows(value, onChange)
|
|
66
|
+
const blockDefFor = (blockType: unknown) => field.blockTypes.find((def) => def.type === blockType)
|
|
67
|
+
|
|
68
|
+
if (disabled) {
|
|
69
|
+
return (
|
|
70
|
+
<Field label={label} required={field.required} hint={hint} error={error}>
|
|
71
|
+
<div className="cms-array-field cms-array-readonly">
|
|
72
|
+
{value.length
|
|
73
|
+
? value.map((row, index) => {
|
|
74
|
+
const blockDef = blockDefFor(row.blockType)
|
|
75
|
+
return (
|
|
76
|
+
<div className="cms-array-row cms-array-row-readonly" key={index}>
|
|
77
|
+
<span className="cms-block-type-badge">{blockDef?.label ?? String(row.blockType ?? '?')}</span>
|
|
78
|
+
{blockDef?.fields.filter((subfield) => isFieldVisible(subfield, row)).map((subfield) => (
|
|
79
|
+
<div className="cms-array-readonly-cell" key={subfield.key}>
|
|
80
|
+
<span className="cms-array-readonly-label">{subfield.label}</span>
|
|
81
|
+
<span>{formatFieldValue(subfield, row[subfield.key])}</span>
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
: <span className="cms-muted-value">—</span>}
|
|
88
|
+
</div>
|
|
89
|
+
</Field>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const atMax = typeof field.maxItems === 'number' && items.length >= field.maxItems
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Field label={label} required={field.required} hint={hint} error={error}>
|
|
97
|
+
<div className="cms-array-field">
|
|
98
|
+
{items.length
|
|
99
|
+
? (
|
|
100
|
+
<Reorder.Group axis="y" values={items} onReorder={emit} className="cms-array-rows">
|
|
101
|
+
{items.map((item, index) => (
|
|
102
|
+
<BlockRow
|
|
103
|
+
key={item.id}
|
|
104
|
+
index={index}
|
|
105
|
+
blockDef={blockDefFor(item.row.blockType)}
|
|
106
|
+
item={item}
|
|
107
|
+
onChange={(row) => emit(items.map((entry) => (entry.id === item.id ? { ...entry, row } : entry)))}
|
|
108
|
+
onRemove={() => emit(items.filter((entry) => entry.id !== item.id))}
|
|
109
|
+
/>
|
|
110
|
+
))}
|
|
111
|
+
</Reorder.Group>
|
|
112
|
+
)
|
|
113
|
+
: <p className="cms-array-empty">No blocks yet.</p>}
|
|
114
|
+
<span className="cms-input-shell cms-select-shell cms-blocks-add">
|
|
115
|
+
<select
|
|
116
|
+
aria-label="Add a block"
|
|
117
|
+
value=""
|
|
118
|
+
disabled={atMax}
|
|
119
|
+
onChange={(event) => {
|
|
120
|
+
const blockDef = field.blockTypes.find((def) => def.type === event.target.value)
|
|
121
|
+
if (blockDef) emit([...items, { id: crypto.randomUUID(), row: blankBlock(blockDef) }])
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<option value="" disabled>+ Add a block…</option>
|
|
125
|
+
{field.blockTypes.map((blockDef) => <option key={blockDef.type} value={blockDef.type}>{blockDef.label}</option>)}
|
|
126
|
+
</select>
|
|
127
|
+
</span>
|
|
128
|
+
</div>
|
|
129
|
+
</Field>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { forwardRef, type AnchorHTMLAttributes, type ButtonHTMLAttributes, type ReactNode, type Ref } from 'react'
|
|
2
|
+
import Link from 'next/link'
|
|
3
|
+
|
|
4
|
+
export type ButtonVariant = 'primary' | 'positive' | 'negative' | 'secondary' | 'ghost'
|
|
5
|
+
|
|
6
|
+
type Shared = {
|
|
7
|
+
variant?: ButtonVariant
|
|
8
|
+
icon?: ReactNode
|
|
9
|
+
iconPosition?: 'before' | 'after'
|
|
10
|
+
iconOnly?: boolean
|
|
11
|
+
compact?: boolean
|
|
12
|
+
className?: string
|
|
13
|
+
children?: ReactNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type AsButton = Shared & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'children'> & { href?: undefined }
|
|
17
|
+
type AsAnchor = Shared & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'className' | 'children'> & { href: string }
|
|
18
|
+
|
|
19
|
+
export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, AsButton | AsAnchor>(function Button(props, ref) {
|
|
20
|
+
const { variant = 'secondary', icon, iconPosition = 'after', iconOnly, compact, className, children, href, ...rest } = props
|
|
21
|
+
const classes = ['btn', `btn-${variant}`, iconOnly && 'btn-icon-only', compact && 'btn-compact', className].filter(Boolean).join(' ')
|
|
22
|
+
const content = iconOnly ? icon : iconPosition === 'before' ? <>{icon}{children}</> : <>{children}{icon}</>
|
|
23
|
+
|
|
24
|
+
if (href !== undefined) {
|
|
25
|
+
const anchorRest = rest as Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'className' | 'children'>
|
|
26
|
+
const anchorRef = ref as Ref<HTMLAnchorElement>
|
|
27
|
+
return href.startsWith('/') ? <Link ref={anchorRef} className={classes} href={href} {...anchorRest}>{content}</Link> : <a ref={anchorRef} className={classes} href={href} {...anchorRest}>{content}</a>
|
|
28
|
+
}
|
|
29
|
+
const { type = 'button', ...buttonRest } = rest as Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'children'>
|
|
30
|
+
return <button ref={ref as Ref<HTMLButtonElement>} type={type} className={classes} {...buttonRest}>{content}</button>
|
|
31
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Button } from './Button'
|
|
4
|
+
|
|
5
|
+
function CloseIcon() {
|
|
6
|
+
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M5 5l14 14M19 5L5 19" /></svg>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type CloseButtonProps = {
|
|
10
|
+
onClick: () => void
|
|
11
|
+
variant?: 'icon' | 'text'
|
|
12
|
+
label?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function CloseButton({ onClick, variant = 'icon', label = 'Close' }: CloseButtonProps) {
|
|
16
|
+
if (variant === 'text') return <Button variant="ghost" className="cms-close-button cms-close-button-text" onClick={onClick} icon={<CloseIcon />} iconPosition="before">{label}</Button>
|
|
17
|
+
return <Button variant="secondary" iconOnly className="cms-close-button cms-close-button-icon" onClick={onClick} aria-label={label} title={label} icon={<CloseIcon />} />
|
|
18
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
4
|
+
import type { LocaleConfig } from '../collections/locale'
|
|
5
|
+
|
|
6
|
+
const CmsLocalesContext = createContext<LocaleConfig | null>(null)
|
|
7
|
+
|
|
8
|
+
/** Populated once per admin page load from the server (see `(admin)/layout.tsx`, which reads
|
|
9
|
+
* `getLocaleConfig()`), same mechanism as `CmsSiteSettingsProvider`. `LocalizedField` reads this
|
|
10
|
+
* instead of a static import so admin-added/removed locales show up without a redeploy. */
|
|
11
|
+
export function CmsLocalesProvider({ localeConfig, children }: { localeConfig: LocaleConfig; children: ReactNode }) {
|
|
12
|
+
return <CmsLocalesContext.Provider value={localeConfig}>{children}</CmsLocalesContext.Provider>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useCmsLocales(): LocaleConfig {
|
|
16
|
+
const context = useContext(CmsLocalesContext)
|
|
17
|
+
if (!context) throw new Error('useCmsLocales must be used inside CmsLocalesProvider')
|
|
18
|
+
return context
|
|
19
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
4
|
+
import { useRouter, usePathname } from 'next/navigation'
|
|
5
|
+
import Link from 'next/link'
|
|
6
|
+
import { authClient } from '../auth-client'
|
|
7
|
+
import type { ContentViewSettings } from '../collections/content-view'
|
|
8
|
+
import { useCmsSiteSettings } from './CmsSiteSettingsProvider'
|
|
9
|
+
import { useCmsTheme } from './CmsThemeProvider'
|
|
10
|
+
|
|
11
|
+
type Section = 'dashboard' | 'scheduled' | 'review-queue' | 'media' | 'settings' | 'users' | 'roles' | 'collections' | 'singles'
|
|
12
|
+
type IconName = 'grid' | 'calendar' | 'image' | 'gear' | 'logout' | 'users' | 'shield' | 'inbox' | 'layers' | 'database' | 'star' | 'folder' | 'trash'
|
|
13
|
+
type ContentContext = { title: string; backHref: string; backLabel: string; settings: ContentViewSettings }
|
|
14
|
+
type FeatureContext = { title: string; backHref: string; backLabel: string; groups: Array<{ label: string; links: Array<{ label: string; href: string }> }> }
|
|
15
|
+
type MediaView = 'gallery' | 'recent' | 'starred' | 'trash'
|
|
16
|
+
type MediaContext = {
|
|
17
|
+
view: MediaView
|
|
18
|
+
onSelect: (view: MediaView) => void
|
|
19
|
+
stats: { count: number; bytes: number; trashCount: number } | null
|
|
20
|
+
pinnedFolders: Array<{ slug: string; name: string }>
|
|
21
|
+
activeFolder: string | null
|
|
22
|
+
onOpenFolder: (slug: string) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatBytes(bytes: number) { return bytes >= 1024 * 1024 * 1024 ? `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` : bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB` }
|
|
26
|
+
|
|
27
|
+
const featureContexts: Partial<Record<Section, FeatureContext>> = {
|
|
28
|
+
settings: {
|
|
29
|
+
title: 'Settings', backHref: '/admin/dashboard', backLabel: 'Dashboard',
|
|
30
|
+
groups: [
|
|
31
|
+
{ label: 'General', links: [{ label: 'Site settings', href: '/admin/settings' }, { label: 'SEO', href: '/admin/settings/seo' }, { label: 'Locales', href: '/admin/settings/locales' }] },
|
|
32
|
+
{ label: 'Integrations', links: [{ label: 'AI writing', href: '/admin/settings/ai' }, { label: 'Email', href: '/admin/settings/email' }] },
|
|
33
|
+
{ label: 'Developers', links: [{ label: 'API tokens', href: '/admin/settings/api-tokens' }, { label: 'Webhooks', href: '/admin/settings/webhooks' }] },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Icon({ name }: { name: IconName }) {
|
|
39
|
+
const paths: Record<IconName, ReactNode> = {
|
|
40
|
+
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
|
41
|
+
calendar: <><rect x="3" y="4.5" width="18" height="16" rx="2" /><path d="M3 9.5h18M8 2.5v4M16 2.5v4" /><path d="M8 13.5h2M8 17h2M14 13.5h2M14 17h2" /></>,
|
|
42
|
+
image: <><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="9" r="1.6" /><path d="M21 16.5 16 11l-9 9" /></>,
|
|
43
|
+
gear: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></>,
|
|
44
|
+
logout: <><path d="M9 20H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h4" /><path d="M15 16l4-4-4-4" /><path d="M19 12H9" /></>,
|
|
45
|
+
users: <><circle cx="9" cy="8" r="3.2" /><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6" /><path d="M16 8.5a3 3 0 1 1 3.5 4.4" /><path d="M21 20c0-2.6-1.7-4.8-4-5.6" /></>,
|
|
46
|
+
shield: <><path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6l7-3Z" /><path d="M9 12l2 2 4-4" /></>,
|
|
47
|
+
inbox: <><path d="M3 12h4.5l1.5 3h6l1.5-3H21" /><path d="M5.5 6h13l2.5 6v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-7l2.5-6Z" /></>,
|
|
48
|
+
layers: <><path d="M12 3l9 4.5-9 4.5-9-4.5 9-4.5Z" /><path d="M3 12l9 4.5 9-4.5" /><path d="M3 16.5l9 4.5 9-4.5" /></>,
|
|
49
|
+
database: <><ellipse cx="12" cy="6" rx="8" ry="3" /><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6" /><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" /></>,
|
|
50
|
+
star: <path d="M12 2.5l2.9 6.2 6.6.7-4.9 4.6 1.3 6.6L12 17.9l-5.9 2.7 1.3-6.6-4.9-4.6 6.6-.7L12 2.5Z" />,
|
|
51
|
+
folder: <path d="M3 6a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6Z" />,
|
|
52
|
+
trash: <><path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" /><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" /><path d="M10 11v6M14 11v6" /></>,
|
|
53
|
+
}
|
|
54
|
+
return <svg className="cms-nav-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">{paths[name]}</svg>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function CmsSidebar({ active, userName, contentContext, mediaContext }: { active: Section; userName: string; contentContext?: ContentContext; mediaContext?: MediaContext }) {
|
|
58
|
+
const router = useRouter()
|
|
59
|
+
const pathname = usePathname()
|
|
60
|
+
const session = authClient.useSession()
|
|
61
|
+
const email = session.data?.user?.email
|
|
62
|
+
const { settings } = useCmsSiteSettings()
|
|
63
|
+
const { theme } = useCmsTheme()
|
|
64
|
+
const [profileOpen, setProfileOpen] = useState(false)
|
|
65
|
+
const profileMenu = useRef<HTMLDivElement>(null)
|
|
66
|
+
const [dataLinks, setDataLinks] = useState<Array<{ slug: string; label: string }>>([])
|
|
67
|
+
const [singleLinks, setSingleLinks] = useState<Array<{ slug: string; label: string }>>([])
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
fetch('/api/admin/collections').then((response) => response.ok ? response.json() : []).then(setDataLinks).catch(() => {})
|
|
71
|
+
fetch('/api/admin/singles').then((response) => response.ok ? response.json() : []).then(setSingleLinks).catch(() => {})
|
|
72
|
+
}, [])
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
function closeMenu(event: PointerEvent) { if (!profileMenu.current?.contains(event.target as Node)) setProfileOpen(false) }
|
|
76
|
+
function closeOnEscape(event: KeyboardEvent) { if (event.key === 'Escape') setProfileOpen(false) }
|
|
77
|
+
document.addEventListener('pointerdown', closeMenu)
|
|
78
|
+
document.addEventListener('keydown', closeOnEscape)
|
|
79
|
+
return () => { document.removeEventListener('pointerdown', closeMenu); document.removeEventListener('keydown', closeOnEscape) }
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
async function logout() {
|
|
83
|
+
setProfileOpen(false)
|
|
84
|
+
await authClient.signOut()
|
|
85
|
+
router.push('/admin/login')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const initial = userName.trim().charAt(0).toUpperCase() || '?'
|
|
89
|
+
const logo = theme === 'dark' ? (settings.logoDark ?? settings.logoLight) : (settings.logoLight ?? settings.logoDark)
|
|
90
|
+
const featureContext = !contentContext ? featureContexts[active] : undefined
|
|
91
|
+
const isDataSection = active === 'collections' || active === 'singles'
|
|
92
|
+
const hasSecondaryNav = Boolean(contentContext) || isDataSection || Boolean(mediaContext) || Boolean(featureContext)
|
|
93
|
+
|
|
94
|
+
return <aside className={`cms-sidebar${hasSecondaryNav ? '' : ' is-rail-only'}`}>
|
|
95
|
+
<nav className="cms-feature-rail" aria-label="CMS features">
|
|
96
|
+
<Link className={`cms-feature-link ${active === 'dashboard' ? 'active' : ''}`} href="/admin/dashboard" data-tooltip="Dashboard"><Icon name="grid" /><span className="sr-only">Dashboard</span></Link>
|
|
97
|
+
<Link className={`cms-feature-link ${isDataSection ? 'active' : ''}`} href={dataLinks[0]?.slug ? `/admin/collections/${dataLinks[0].slug}` : singleLinks[0]?.slug ? `/admin/single/${singleLinks[0].slug}` : '/admin/dashboard'} data-tooltip="Content"><Icon name="layers" /><span className="sr-only">Content</span></Link>
|
|
98
|
+
<Link className={`cms-feature-link ${active === 'scheduled' ? 'active' : ''}`} href="/admin/scheduled" data-tooltip="Scheduled"><Icon name="calendar" /><span className="sr-only">Scheduled</span></Link>
|
|
99
|
+
<Link className={`cms-feature-link ${active === 'review-queue' ? 'active' : ''}`} href="/admin/review-queue" data-tooltip="Review & Approval"><Icon name="inbox" /><span className="sr-only">Review & Approval</span></Link>
|
|
100
|
+
<Link className={`cms-feature-link ${active === 'media' ? 'active' : ''}`} href="/admin/media" data-tooltip="Media library"><Icon name="image" /><span className="sr-only">Media library</span></Link>
|
|
101
|
+
<Link className={`cms-feature-link ${active === 'users' ? 'active' : ''}`} href="/admin/users" data-tooltip="Users"><Icon name="users" /><span className="sr-only">Users</span></Link>
|
|
102
|
+
<Link className={`cms-feature-link ${active === 'roles' ? 'active' : ''}`} href="/admin/roles" data-tooltip="Roles"><Icon name="shield" /><span className="sr-only">Roles</span></Link>
|
|
103
|
+
<Link className={`cms-feature-link ${active === 'settings' ? 'active' : ''}`} href="/admin/settings" data-tooltip="Settings"><Icon name="gear" /><span className="sr-only">Settings</span></Link>
|
|
104
|
+
<div className="cms-account-mark" ref={profileMenu}>
|
|
105
|
+
<button type="button" className="cms-profile-trigger" onClick={() => setProfileOpen((open) => !open)} aria-haspopup="menu" aria-expanded={profileOpen} aria-label={`Account: ${userName}`}><span className="cms-avatar">{initial}</span></button>
|
|
106
|
+
{profileOpen && <div className="cms-profile-menu" role="menu">
|
|
107
|
+
<div className="cms-profile-menu-user"><span className="cms-avatar">{initial}</span><span className="cms-profile-menu-text"><strong>{userName}</strong>{email && <small>{email}</small>}</span></div>
|
|
108
|
+
<button type="button" role="menuitem" onClick={() => void logout()}><span>Sign out</span><Icon name="logout" /></button>
|
|
109
|
+
</div>}
|
|
110
|
+
</div>
|
|
111
|
+
</nav>
|
|
112
|
+
<Link className="cms-brand-mark" href="/admin/dashboard" aria-label={`${settings.siteName} — Dashboard`}>{logo ? <img src={logo} alt="" className="cms-brand-logo" /> : <span className="cms-brand-logo-placeholder" aria-hidden="true" />}</Link>
|
|
113
|
+
<Link className="cms-brand-word" href="/admin/dashboard">{settings.siteName}</Link>
|
|
114
|
+
{contentContext ? <nav className="cms-nav cms-context-nav" aria-label={`${contentContext.title} editor navigation`}>
|
|
115
|
+
<div className="cms-context-title"><p className="cms-nav-label">Content editor</p><strong>{contentContext.title}</strong></div>
|
|
116
|
+
<div className="cms-context-groups">{contentContext.settings.groups.map((group) => {
|
|
117
|
+
const fields = contentContext.settings.fields.filter((field) => field.groupId === group.id)
|
|
118
|
+
if (!fields.length) return null
|
|
119
|
+
return <section className="cms-context-group" key={group.id}><a href={`#cms-group-${group.id}`}>{group.label}</a><div>{fields.map((field) => <a href={`#cms-field-${field.key}`} key={field.key}>{field.label}</a>)}</div></section>
|
|
120
|
+
})}</div>
|
|
121
|
+
</nav> : isDataSection ? <nav className="cms-nav" aria-label="Content navigation">
|
|
122
|
+
{singleLinks.length > 0 && <>
|
|
123
|
+
<p className="cms-nav-label">Single types</p>
|
|
124
|
+
{singleLinks.map((link) => <Link key={link.slug} className={`cms-nav-link ${pathname.startsWith(`/admin/single/${link.slug}`) ? 'active' : ''}`} href={`/admin/single/${link.slug}`}><Icon name="layers" />{link.label}</Link>)}
|
|
125
|
+
</>}
|
|
126
|
+
<p className="cms-nav-label">Collections</p>
|
|
127
|
+
{dataLinks.map((link) => <Link key={link.slug} className={`cms-nav-link ${pathname.startsWith(`/admin/collections/${link.slug}`) ? 'active' : ''}`} href={`/admin/collections/${link.slug}`}><Icon name="database" />{link.label}</Link>)}
|
|
128
|
+
</nav> : mediaContext ? <nav className="cms-nav" aria-label="Media library navigation">
|
|
129
|
+
<p className="cms-nav-label">Media library</p>
|
|
130
|
+
<button type="button" className={`cms-nav-link ${mediaContext.view === 'gallery' ? 'active' : ''}`} onClick={() => mediaContext.onSelect('gallery')}><Icon name="image" />Files</button>
|
|
131
|
+
<button type="button" className={`cms-nav-link ${mediaContext.view === 'recent' ? 'active' : ''}`} onClick={() => mediaContext.onSelect('recent')}><Icon name="calendar" />Recent</button>
|
|
132
|
+
<button type="button" className={`cms-nav-link ${mediaContext.view === 'starred' ? 'active' : ''}`} onClick={() => mediaContext.onSelect('starred')}><Icon name="star" />Starred</button>
|
|
133
|
+
<button type="button" className={`cms-nav-link ${mediaContext.view === 'trash' ? 'active' : ''}`} onClick={() => mediaContext.onSelect('trash')}><Icon name="trash" />Trash{!!mediaContext.stats?.trashCount && <span className="cms-nav-badge">{mediaContext.stats.trashCount}</span>}</button>
|
|
134
|
+
{mediaContext.pinnedFolders.length > 0 && <>
|
|
135
|
+
<p className="cms-nav-label">Pinned folders</p>
|
|
136
|
+
{mediaContext.pinnedFolders.map((folder) => <button type="button" key={folder.slug} className={`cms-nav-link ${mediaContext.activeFolder === folder.slug ? 'active' : ''}`} onClick={() => mediaContext.onOpenFolder(folder.slug)}><Icon name="folder" />{folder.name}</button>)}
|
|
137
|
+
</>}
|
|
138
|
+
{mediaContext.stats && <div className="cms-media-stats">
|
|
139
|
+
<div><span>Storage used</span><strong>{formatBytes(mediaContext.stats.bytes)}</strong></div>
|
|
140
|
+
<div><span>Total files</span><strong>{mediaContext.stats.count}</strong></div>
|
|
141
|
+
</div>}
|
|
142
|
+
</nav> : featureContext ? <nav className="cms-nav cms-context-nav" aria-label={`${featureContext.title} navigation`}>
|
|
143
|
+
<div className="cms-context-title"><p className="cms-nav-label">CMS feature</p><strong>{featureContext.title}</strong></div>
|
|
144
|
+
<div className="cms-context-groups">{featureContext.groups.map((group) => <section className="cms-context-group" key={group.label}><Link href={group.links[0]?.href ?? '/admin/settings'}>{group.label}</Link><div>{group.links.map((link) => <Link href={link.href} className={pathname === link.href ? 'active' : ''} key={link.href}>{link.label}</Link>)}</div></section>)}</div>
|
|
145
|
+
</nav> : null}
|
|
146
|
+
</aside>
|
|
147
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
|
|
4
|
+
import type { PublishingWorkflow } from '../publishing-workflow'
|
|
5
|
+
|
|
6
|
+
export type CmsSiteSettings = {
|
|
7
|
+
siteName: string
|
|
8
|
+
logoLight: string | null
|
|
9
|
+
logoDark: string | null
|
|
10
|
+
publishingWorkflow: PublishingWorkflow
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type CmsSiteSettingsContextValue = {
|
|
14
|
+
settings: CmsSiteSettings
|
|
15
|
+
setSettings: (settings: CmsSiteSettings) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CmsSiteSettingsContext = createContext<CmsSiteSettingsContextValue | null>(null)
|
|
19
|
+
|
|
20
|
+
export function CmsSiteSettingsProvider({ initialSettings, children }: { initialSettings: CmsSiteSettings; children: ReactNode }) {
|
|
21
|
+
const [settings, setSettings] = useState(initialSettings)
|
|
22
|
+
const value = useMemo(() => ({ settings, setSettings }), [settings])
|
|
23
|
+
return <CmsSiteSettingsContext.Provider value={value}>{children}</CmsSiteSettingsContext.Provider>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useCmsSiteSettings() {
|
|
27
|
+
const context = useContext(CmsSiteSettingsContext)
|
|
28
|
+
if (!context) throw new Error('useCmsSiteSettings must be used inside CmsSiteSettingsProvider')
|
|
29
|
+
return context
|
|
30
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react'
|
|
4
|
+
import { appearanceCssVariables, defaultCmsAppearance, normalizeCmsAppearance, type CmsAppearance } from '../cms-appearance'
|
|
5
|
+
|
|
6
|
+
export type CmsTheme = 'light' | 'dark'
|
|
7
|
+
export type CmsThemePreference = CmsTheme | 'system'
|
|
8
|
+
const STORAGE_KEY = 'cms-theme'
|
|
9
|
+
const listeners = new Set<() => void>()
|
|
10
|
+
|
|
11
|
+
function subscribe(listener: () => void) {
|
|
12
|
+
listeners.add(listener)
|
|
13
|
+
return () => listeners.delete(listener)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getSnapshot(): CmsTheme {
|
|
17
|
+
return typeof document !== 'undefined' && document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getServerSnapshot(): CmsTheme { return 'light' }
|
|
21
|
+
|
|
22
|
+
function systemPrefersDark() {
|
|
23
|
+
return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// No explicit choice stored yet ⇒ follow the device's own theme, per OS preference.
|
|
27
|
+
function resolveTheme(preference: CmsThemePreference): CmsTheme {
|
|
28
|
+
return preference === 'system' ? (systemPrefersDark() ? 'dark' : 'light') : preference
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readStoredPreference(): CmsThemePreference {
|
|
32
|
+
try {
|
|
33
|
+
const stored = window.localStorage.getItem(STORAGE_KEY)
|
|
34
|
+
return stored === 'dark' || stored === 'light' ? stored : 'system'
|
|
35
|
+
} catch { return 'system' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function applyTheme(theme: CmsTheme) {
|
|
39
|
+
document.documentElement.dataset.theme = theme
|
|
40
|
+
listeners.forEach((listener) => listener())
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function applyAppearance(appearance: CmsAppearance, theme: CmsTheme) {
|
|
44
|
+
if (typeof document === 'undefined') return
|
|
45
|
+
for (const [name, value] of Object.entries(appearanceCssVariables(appearance, theme))) document.documentElement.style.setProperty(name, value)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type ThemeContextValue = {
|
|
49
|
+
theme: CmsTheme
|
|
50
|
+
preference: CmsThemePreference
|
|
51
|
+
setPreference: (preference: CmsThemePreference) => void
|
|
52
|
+
appearance: CmsAppearance
|
|
53
|
+
setAppearance: (appearance: CmsAppearance) => void
|
|
54
|
+
}
|
|
55
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
|
56
|
+
|
|
57
|
+
export function CmsThemeProvider({ initialAppearance = defaultCmsAppearance, children }: { initialAppearance?: CmsAppearance; children: ReactNode }) {
|
|
58
|
+
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
59
|
+
const [appearance, setAppearanceState] = useState(() => normalizeCmsAppearance(initialAppearance))
|
|
60
|
+
const [preference, setPreferenceState] = useState<CmsThemePreference>('system')
|
|
61
|
+
|
|
62
|
+
// Sync the stored preference into state once mounted (the boot script already
|
|
63
|
+
// applied the correct data-theme before paint — this is only for the settings UI).
|
|
64
|
+
useEffect(() => { setPreferenceState(readStoredPreference()) }, [])
|
|
65
|
+
|
|
66
|
+
// Live-follow the OS theme while the preference is 'system'.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (preference !== 'system') return
|
|
69
|
+
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
|
70
|
+
function onChange() {
|
|
71
|
+
const resolved = resolveTheme('system')
|
|
72
|
+
applyTheme(resolved)
|
|
73
|
+
applyAppearance(appearance, resolved)
|
|
74
|
+
}
|
|
75
|
+
media.addEventListener('change', onChange)
|
|
76
|
+
return () => media.removeEventListener('change', onChange)
|
|
77
|
+
}, [preference, appearance])
|
|
78
|
+
|
|
79
|
+
function setPreference(next: CmsThemePreference) {
|
|
80
|
+
setPreferenceState(next)
|
|
81
|
+
try { window.localStorage.setItem(STORAGE_KEY, next) } catch { /* localStorage can be unavailable */ }
|
|
82
|
+
const resolved = resolveTheme(next)
|
|
83
|
+
applyTheme(resolved)
|
|
84
|
+
applyAppearance(appearance, resolved)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function setAppearance(nextAppearance: CmsAppearance) {
|
|
88
|
+
const normalized = normalizeCmsAppearance(nextAppearance)
|
|
89
|
+
setAppearanceState(normalized)
|
|
90
|
+
applyAppearance(normalized, theme)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return <ThemeContext.Provider value={{ theme, preference, setPreference, appearance, setAppearance }}>{children}</ThemeContext.Provider>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function useCmsTheme() {
|
|
97
|
+
const context = useContext(ThemeContext)
|
|
98
|
+
if (!context) throw new Error('useCmsTheme must be used inside CmsThemeProvider')
|
|
99
|
+
return context
|
|
100
|
+
}
|