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,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 &amp; 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
+ }