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,215 @@
1
+ 'use client'
2
+
3
+ import dynamic from 'next/dynamic'
4
+ import { ChangeEvent, ComponentProps, KeyboardEvent, ReactNode, useEffect, useId, useRef, useState } from 'react'
5
+ import { Portal } from './Portal'
6
+ import { Button } from './Button'
7
+ import { monthGrid, parseISODate, toISODate, weekdayLabels } from '../calendar-grid'
8
+
9
+ // Leaflet touches `window` at module load — never render it during SSR.
10
+ const MapPicker = dynamic(() => import('./MapPicker'), { ssr: false })
11
+
12
+ type FieldProps = { label: string; required?: boolean; hint?: string; error?: string; children: ReactNode }
13
+
14
+ export function Field({ label, required, hint, error, children }: FieldProps) {
15
+ return <div className={`cms-field ${error ? 'has-error' : ''}`}>
16
+ <div className="cms-field-label"><span>{label}{required && <b className="required-mark">*</b>}</span>{hint && <small>{hint}</small>}</div>
17
+ {children}
18
+ {error && <small className="field-error">{error}</small>}
19
+ </div>
20
+ }
21
+
22
+ type TextFieldProps = Omit<ComponentProps<'input'>, 'children'> & Omit<FieldProps, 'children'>
23
+ export function TextField({ label, required, hint, error, ...props }: TextFieldProps) { return <Field label={label} required={required} hint={hint} error={error}><span className="cms-input-shell"><input aria-label={label} {...props} /></span></Field> }
24
+
25
+ type PasswordFieldProps = Omit<ComponentProps<'input'>, 'children' | 'type'> & Omit<FieldProps, 'children'>
26
+ export function PasswordField({ label, required, hint, error, ...props }: PasswordFieldProps) {
27
+ const [visible, setVisible] = useState(false)
28
+ return <Field label={label} required={required} hint={hint} error={error}><span className="cms-input-shell cms-password-shell"><input type={visible ? 'text' : 'password'} aria-label={label} autoComplete="off" {...props} /><Button type="button" variant="ghost" iconOnly compact aria-label={visible ? 'Hide value' : 'Show value'} onClick={() => setVisible((current) => !current)} icon={<span aria-hidden="true">{visible ? '🙈' : '👁'}</span>} /></span></Field>
29
+ }
30
+
31
+ type RangeFieldProps = Omit<FieldProps, 'children'> & { value: number; onChange: (value: number) => void; min?: number; max?: number; step?: number; disabled?: boolean }
32
+ export function RangeField({ label, required, hint, error, value, onChange, min = 0, max = 100, step = 1, disabled }: RangeFieldProps) {
33
+ return <Field label={label} required={required} hint={hint} error={error}><div className="cms-range-control"><input type="range" aria-label={label} min={min} max={max} step={step} value={value} disabled={disabled} onChange={(event) => onChange(Number(event.target.value))} /><span className="cms-range-value">{value}</span></div></Field>
34
+ }
35
+
36
+ type RatingFieldProps = Omit<FieldProps, 'children'> & { value: number; onChange: (value: number) => void; max?: number; disabled?: boolean }
37
+ export function RatingField({ label, required, hint, error, value, onChange, max = 5, disabled }: RatingFieldProps) {
38
+ return <Field label={label} required={required} hint={hint} error={error}><div className="cms-rating-control" role="radiogroup" aria-label={label}>{Array.from({ length: max }, (_, index) => index + 1).map((star) => <button key={star} type="button" className={`cms-rating-star ${star <= value ? 'is-filled' : ''}`} aria-label={`${star} star${star === 1 ? '' : 's'}`} aria-pressed={star === value} disabled={disabled} onClick={() => onChange(star === value ? 0 : star)}>★</button>)}</div></Field>
39
+ }
40
+
41
+ type TextAreaFieldProps = ComponentProps<'textarea'> & Omit<FieldProps, 'children'>
42
+ export function TextAreaField({ label, required, hint, error, ...props }: TextAreaFieldProps) { return <Field label={label} required={required} hint={hint} error={error}><span className="cms-input-shell cms-textarea-shell"><textarea aria-label={label} {...props} /></span></Field> }
43
+
44
+ type SelectFieldProps = ComponentProps<'select'> & Omit<FieldProps, 'children'> & { options: Array<{ label: string; value: string }> }
45
+ export function SelectField({ label, required, hint, error, options, ...props }: SelectFieldProps) { return <Field label={label} required={required} hint={hint} error={error}><span className="cms-input-shell cms-select-shell"><select aria-label={label} {...props}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></span></Field> }
46
+
47
+ type RadioFieldProps = Omit<FieldProps, 'children'> & { value: string; onChange: (value: string) => void; options: Array<{ label: string; value: string }>; disabled?: boolean }
48
+ export function RadioField({ label, required, hint, error, value, onChange, options, disabled }: RadioFieldProps) {
49
+ const groupName = useId()
50
+ return <Field label={label} required={required} hint={hint} error={error}><div className="cms-radio-group" role="radiogroup" aria-label={label}>{options.map((option) => <label key={option.value} className={disabled ? 'is-disabled' : ''}><input type="radio" name={groupName} value={option.value} checked={value === option.value} disabled={disabled} onChange={() => onChange(option.value)} />{option.label}</label>)}</div></Field>
51
+ }
52
+
53
+ type Category = { id: string; name: string; slug: string }
54
+ type CategoryFieldProps = Omit<FieldProps, 'children' | 'label'> & { label?: string; value: string; options: Category[]; onChange: (value: string) => void; onCreate: (name: string) => Promise<Category | null> }
55
+ export function CategoryField({ label = 'Category', required, hint, error, value, options, onChange, onCreate }: CategoryFieldProps) {
56
+ const [adding, setAdding] = useState(false); const [name, setName] = useState(''); const [message, setMessage] = useState(''); const [loading, setLoading] = useState(false)
57
+ const hasValue = value && !options.some((category) => category.name === value)
58
+ async function addCategory() { if (!name.trim()) { setMessage('Enter a category name.'); return }; setLoading(true); setMessage(''); const category = await onCreate(name); setLoading(false); if (!category) { setMessage('Could not add category.'); return }; onChange(category.name); setName(''); setAdding(false) }
59
+ return <Field label={label} required={required} hint={hint} error={error}><div className="cms-category-control"><span className="cms-input-shell cms-select-shell"><select aria-label={label} value={value} onChange={(event) => { if (event.target.value === '__new__') setAdding(true); else onChange(event.target.value) }}><option value="">Select a category</option>{hasValue && <option value={value}>{value}</option>}{options.map((category) => <option key={category.id} value={category.name}>{category.name}</option>)}<option value="__new__">+ Create new category</option></select></span>{adding && <div className="cms-inline-create"><input aria-label="New category" value={name} onChange={(event) => setName(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void addCategory() } }} placeholder="e.g. Brand strategy" /><Button variant="positive" compact type="button" onClick={() => void addCategory()} disabled={loading}>{loading ? 'Adding…' : '+ Add'}</Button><Button variant="ghost" compact type="button" className="cms-inline-cancel" onClick={() => { setAdding(false); setMessage('') }}>Cancel</Button></div>}{message && <small className="field-error">{message}</small>}</div></Field>
60
+ }
61
+
62
+ type TagsFieldProps = Omit<FieldProps, 'children' | 'label' | 'hint'> & { label?: string; hint?: string; value: string[]; onChange: (value: string[]) => void }
63
+ export function TagsField({ label = 'Tags', required, hint = 'Press Enter or Add after each tag', error, value, onChange }: TagsFieldProps) {
64
+ const [input, setInput] = useState(''); const [message, setMessage] = useState('')
65
+ function add() { const tag = input.trim().replace(/\s+/g, ' '); if (!tag) return; if (value.some((item) => item.toLowerCase() === tag.toLowerCase())) { setMessage('That tag is already added.'); return }; if (value.length >= 20) { setMessage('Maximum 20 tags.'); return }; onChange([...value, tag]); setInput(''); setMessage('') }
66
+ function keyDown(event: KeyboardEvent<HTMLInputElement>) { if (event.key === 'Enter' || event.key === ',') { event.preventDefault(); add() } }
67
+ return <Field label={label} required={required} hint={hint} error={error}><div className="cms-tags-control"><div className="cms-tags">{value.map((tag) => <span className="cms-tag" key={tag}>{tag}<Button variant="ghost" iconOnly compact type="button" aria-label={`Remove ${tag}`} onClick={() => onChange(value.filter((item) => item !== tag))} icon={<span aria-hidden="true">×</span>} /></span>)}</div><div className="cms-tag-entry"><input aria-label="New tag" value={input} onChange={(event) => setInput(event.target.value)} onKeyDown={keyDown} placeholder="Type a tag" /><Button variant="positive" compact type="button" onClick={add}>+ Add</Button></div></div>{message && <small className="field-error">{message}</small>}</Field>
68
+ }
69
+
70
+ type ToggleFieldProps = Omit<FieldProps, 'children'> & { checked: boolean; onChange: (checked: boolean) => void }
71
+ export function ToggleField({ label, required, hint, error, checked, onChange }: ToggleFieldProps) {
72
+ return <Field label={label} required={required} hint={hint} error={error}><button type="button" role="switch" aria-checked={checked} aria-label={label} className={`cms-toggle ${checked ? 'is-on' : ''}`} onClick={() => onChange(!checked)}><span className="cms-toggle-thumb" /></button></Field>
73
+ }
74
+
75
+ type CheckboxFieldProps = Omit<FieldProps, 'children'> & { checked: boolean; onChange: (checked: boolean) => void; description?: string }
76
+ export function CheckboxField({ label, required, hint, error, checked, onChange, description }: CheckboxFieldProps) {
77
+ return <Field label={label} required={required} hint={hint} error={error}><label className="cms-checkbox-control"><input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} aria-label={label} />{description && <span>{description}</span>}</label></Field>
78
+ }
79
+
80
+ type CheckboxGroupFieldProps = Omit<FieldProps, 'children'> & { value: string[]; onChange: (value: string[]) => void; options: Array<{ value: string; label: string }> }
81
+ export function CheckboxGroupField({ label, required, hint, error, value, onChange, options }: CheckboxGroupFieldProps) {
82
+ function toggle(optionValue: string) { onChange(value.includes(optionValue) ? value.filter((item) => item !== optionValue) : [...value, optionValue]) }
83
+ return <Field label={label} required={required} hint={hint} error={error}><div className="cms-checkbox-group">{options.map((option) => <button type="button" key={option.value} className={`cms-checkbox-pill ${value.includes(option.value) ? 'is-checked' : ''}`} aria-pressed={value.includes(option.value)} onClick={() => toggle(option.value)}>{option.label}</button>)}</div></Field>
84
+ }
85
+
86
+ type TimeFieldProps = Omit<FieldProps, 'children'> & { value: string | null; onChange: (value: string | null) => void }
87
+ export function TimeField({ label, required, hint, error, value, onChange }: TimeFieldProps) {
88
+ return <Field label={label} required={required} hint={hint} error={error}><span className="cms-input-shell"><input type="time" aria-label={label} value={value ?? ''} onChange={(event) => onChange(event.target.value || null)} /></span></Field>
89
+ }
90
+
91
+ /** ISO string -> local 'YYYY-MM-DDTHH:mm' for <input type=datetime-local> (which has no timezone
92
+ * concept of its own — it always shows/edits in the browser's local time). */
93
+ function toDateTimeLocal(iso: string | null): string {
94
+ if (!iso) return ''
95
+ const date = new Date(iso)
96
+ if (isNaN(date.getTime())) return ''
97
+ const pad = (value: number) => String(value).padStart(2, '0')
98
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
99
+ }
100
+
101
+ type DateTimeFieldProps = Omit<FieldProps, 'children'> & { value: string | null; onChange: (value: string | null) => void }
102
+ export function DateTimeField({ label, required, hint, error, value, onChange }: DateTimeFieldProps) {
103
+ return <Field label={label} required={required} hint={hint} error={error}><span className="cms-input-shell"><input type="datetime-local" aria-label={label} value={toDateTimeLocal(value)} onChange={(event) => onChange(event.target.value ? new Date(event.target.value).toISOString() : null)} /></span></Field>
104
+ }
105
+
106
+ const hexColorPattern = /^#[0-9a-fA-F]{6}$/
107
+ type ColorFieldProps = Omit<FieldProps, 'children'> & { value: string; onChange: (value: string) => void; disabled?: boolean }
108
+ export function ColorField({ label, required, hint, error, value, onChange, disabled }: ColorFieldProps) {
109
+ const swatch = hexColorPattern.test(value) ? value : '#000000'
110
+ return <Field label={label} required={required} hint={hint} error={error}><div className="cms-color-control"><input type="color" aria-label={label} value={swatch} disabled={disabled} onChange={(event) => onChange(event.target.value)} /><span className="cms-input-shell cms-color-hex"><input type="text" aria-label={`${label} hex code`} value={value} disabled={disabled} placeholder="#3366ff" onChange={(event) => onChange(event.target.value)} /></span></div></Field>
111
+ }
112
+
113
+ function CalendarDateControl({ label, value, onChange }: { label: string; value: string | null; onChange: (value: string | null) => void }) {
114
+ const [open, setOpen] = useState(false)
115
+ const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
116
+ const selected = parseISODate(value)
117
+ const [viewMonth, setViewMonth] = useState(() => selected ?? new Date())
118
+ const triggerRef = useRef<HTMLButtonElement>(null)
119
+ const popoverRef = useRef<HTMLDivElement>(null)
120
+
121
+ useEffect(() => {
122
+ if (!open) return
123
+ function handleOutside(event: MouseEvent) { const target = event.target as Node; if (triggerRef.current?.contains(target) || popoverRef.current?.contains(target)) return; setOpen(false) }
124
+ function handleKey(event: globalThis.KeyboardEvent) { if (event.key === 'Escape') setOpen(false) }
125
+ document.addEventListener('mousedown', handleOutside)
126
+ document.addEventListener('keydown', handleKey)
127
+ return () => { document.removeEventListener('mousedown', handleOutside); document.removeEventListener('keydown', handleKey) }
128
+ }, [open])
129
+
130
+ function toggleOpen() {
131
+ if (!open && triggerRef.current) {
132
+ const rect = triggerRef.current.getBoundingClientRect()
133
+ setViewMonth(selected ?? new Date())
134
+ setPosition({ top: rect.bottom + 6, left: Math.max(8, Math.min(rect.left, window.innerWidth - 268)) })
135
+ }
136
+ setOpen((current) => !current)
137
+ }
138
+
139
+ const cells = monthGrid(viewMonth)
140
+ const todayIso = toISODate(new Date())
141
+
142
+ return <div className="cms-calendar-control">
143
+ <Button ref={triggerRef} type="button" variant="secondary" className="cms-calendar-trigger" onClick={toggleOpen} aria-label={label}>{selected ? selected.toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' }) : 'Select a date'}</Button>
144
+ {open && position && <Portal><div ref={popoverRef} className="cms-calendar-popover" style={{ position: 'fixed', top: position.top, left: position.left }}>
145
+ <div className="cms-calendar-header"><Button type="button" variant="secondary" iconOnly compact onClick={() => setViewMonth((current) => new Date(current.getFullYear(), current.getMonth() - 1, 1))} aria-label="Previous month" icon="‹" /><strong>{viewMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</strong><Button type="button" variant="secondary" iconOnly compact onClick={() => setViewMonth((current) => new Date(current.getFullYear(), current.getMonth() + 1, 1))} aria-label="Next month" icon="›" /></div>
146
+ <div className="cms-calendar-weekdays">{weekdayLabels.map((day) => <span key={day}>{day}</span>)}</div>
147
+ <div className="cms-calendar-grid">{cells.map((date, index) => { if (!date) return <span key={index} />; const iso = toISODate(date); return <button type="button" key={index} className={`${iso === value ? 'is-selected' : ''} ${iso === todayIso ? 'is-today' : ''}`} onClick={() => { onChange(iso); setOpen(false) }}>{date.getDate()}</button> })}</div>
148
+ {value && <Button type="button" variant="ghost" compact onClick={() => { onChange(null); setOpen(false) }}>Clear date</Button>}
149
+ </div></Portal>}
150
+ </div>
151
+ }
152
+
153
+ type DateFieldProps = Omit<FieldProps, 'children'> & { value: string | null; onChange: (value: string | null) => void; variant?: 'input' | 'calendar' }
154
+ export function DateField({ label, required, hint, error, value, onChange, variant = 'input' }: DateFieldProps) {
155
+ if (variant === 'calendar') return <Field label={label} required={required} hint={hint} error={error}><CalendarDateControl label={label} value={value} onChange={onChange} /></Field>
156
+ return <Field label={label} required={required} hint={hint} error={error}><span className="cms-input-shell"><input type="date" aria-label={label} value={value ?? ''} onChange={(event) => onChange(event.target.value || null)} /></span></Field>
157
+ }
158
+
159
+ type FileFieldProps = Omit<FieldProps, 'children'> & { value: string; onChange: (value: string) => void; disabled?: boolean; accept?: string }
160
+ export function FileField({ label, required, hint, error, value, onChange, disabled, accept }: FileFieldProps) {
161
+ const [status, setStatus] = useState('')
162
+ async function handleFile(event: ChangeEvent<HTMLInputElement>) {
163
+ const file = event.target.files?.[0]
164
+ event.target.value = ''
165
+ if (!file) return
166
+ setStatus('Uploading…')
167
+ const form = new FormData(); form.append('file', file); form.append('folder', 'uploads')
168
+ try {
169
+ const response = await fetch('/api/admin/upload', { method: 'POST', body: form })
170
+ const data = await response.json() as { url?: string; error?: string }
171
+ if (!response.ok || !data.url) throw new Error(data.error || 'Could not upload file.')
172
+ onChange(data.url)
173
+ setStatus('')
174
+ } catch (caught) { setStatus(caught instanceof Error ? caught.message : 'Could not upload file.') }
175
+ }
176
+ return <Field label={label} required={required} hint={hint} error={error}>
177
+ <div className="cms-file-control">
178
+ {value && <a href={value} target="_blank" rel="noreferrer" className="cms-file-current">{value.split('/').pop()}</a>}
179
+ {!disabled && <input type="file" accept={accept} aria-label={label} onChange={(event) => void handleFile(event)} />}
180
+ {value && !disabled && <Button type="button" variant="ghost" compact onClick={() => onChange('')}>Remove file</Button>}
181
+ </div>
182
+ {status && <small className={status.startsWith('Could not') ? 'field-error' : 'cms-file-status'}>{status}</small>}
183
+ </Field>
184
+ }
185
+
186
+ /** Local text state resynced from `value` only when it changes for a reason other than this
187
+ * component's own last edit — same pattern as `useKeyedRows` — so a valid keystroke doesn't get
188
+ * immediately re-formatted by `JSON.stringify` and jump the cursor. */
189
+ type JsonFieldProps = Omit<FieldProps, 'children'> & { value: unknown; onChange: (value: unknown) => void; disabled?: boolean }
190
+ export function JsonField({ label, required, hint, error, value, onChange, disabled }: JsonFieldProps) {
191
+ const [text, setText] = useState(() => JSON.stringify(value, null, 2))
192
+ const [parseError, setParseError] = useState('')
193
+ const lastEmitted = useRef(value)
194
+ useEffect(() => { if (value !== lastEmitted.current) { setText(JSON.stringify(value, null, 2)); lastEmitted.current = value } }, [value])
195
+ function handleChange(next: string) {
196
+ setText(next)
197
+ try { const parsed = JSON.parse(next); setParseError(''); lastEmitted.current = parsed; onChange(parsed) } catch { setParseError('Invalid JSON.') }
198
+ }
199
+ return <Field label={label} required={required} hint={hint} error={error || parseError}>
200
+ <span className="cms-input-shell cms-textarea-shell cms-json-shell"><textarea aria-label={label} value={text} disabled={disabled} spellCheck={false} onChange={(event) => handleChange(event.target.value)} /></span>
201
+ </Field>
202
+ }
203
+
204
+ type PointFieldProps = Omit<FieldProps, 'children'> & { value: { lat: number; lng: number }; onChange: (value: { lat: number; lng: number }) => void; disabled?: boolean }
205
+ export function PointField({ label, required, hint, error, value, onChange, disabled }: PointFieldProps) {
206
+ return <Field label={label} required={required} hint={hint} error={error}>
207
+ <div className="cms-point-field">
208
+ <div className="cms-point-control">
209
+ <span className="cms-input-shell"><input type="number" step="any" aria-label={`${label} latitude`} placeholder="Latitude" value={value.lat} disabled={disabled} onChange={(event) => onChange({ ...value, lat: Number(event.target.value) })} /></span>
210
+ <span className="cms-input-shell"><input type="number" step="any" aria-label={`${label} longitude`} placeholder="Longitude" value={value.lng} disabled={disabled} onChange={(event) => onChange({ ...value, lng: Number(event.target.value) })} /></span>
211
+ </div>
212
+ <MapPicker lat={value.lat} lng={value.lng} disabled={disabled} onChange={(lat, lng) => onChange({ lat, lng })} />
213
+ </div>
214
+ </Field>
215
+ }
@@ -0,0 +1,62 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Button } from './Button'
5
+
6
+ export type FilterOperator = 'is' | 'isNot' | 'isEmpty' | 'isNotEmpty'
7
+ export type FilterFieldDef = {
8
+ key: string
9
+ label: string
10
+ type: 'text' | 'select'
11
+ options?: { value: string; label: string }[]
12
+ supportsEmpty?: boolean
13
+ }
14
+ export type FilterRule = { id: string; field: string; operator: FilterOperator; value: string }
15
+
16
+ const operatorLabel: Record<FilterOperator, string> = { is: 'is', isNot: 'is not', isEmpty: 'is empty', isNotEmpty: 'has a value' }
17
+ const needsValue = (operator: FilterOperator) => operator === 'is' || operator === 'isNot'
18
+
19
+ function valueLabelFor(fieldDef: FilterFieldDef | undefined, value: string) {
20
+ return fieldDef?.options?.find((option) => option.value === value)?.label ?? value
21
+ }
22
+
23
+ export function FilterBuilder({ fields, rules, onAdd, onRemove }: { fields: FilterFieldDef[]; rules: FilterRule[]; onAdd: (rule: FilterRule) => void; onRemove: (id: string) => void }) {
24
+ const [adding, setAdding] = useState(false)
25
+ const [field, setField] = useState(fields[0]?.key ?? '')
26
+ const [operator, setOperator] = useState<FilterOperator>('is')
27
+ const [value, setValue] = useState('')
28
+ const fieldDef = fields.find((item) => item.key === field)
29
+
30
+ function startAdding() { setField(fields[0]?.key ?? ''); setOperator('is'); setValue(''); setAdding(true) }
31
+ function changeField(nextField: string) { setField(nextField); setOperator('is'); setValue('') }
32
+ function submit() {
33
+ onAdd({ id: crypto.randomUUID(), field, operator, value: needsValue(operator) ? value : '' })
34
+ setAdding(false)
35
+ }
36
+
37
+ return <div className="cms-filters">
38
+ {rules.map((rule) => {
39
+ const def = fields.find((item) => item.key === rule.field)
40
+ return <span className="cms-filter-chip" key={rule.id}>
41
+ <b>{def?.label ?? rule.field}</b> {operatorLabel[rule.operator]}{needsValue(rule.operator) ? ` "${valueLabelFor(def, rule.value)}"` : ''}
42
+ <button type="button" onClick={() => onRemove(rule.id)} aria-label="Remove filter">×</button>
43
+ </span>
44
+ })}
45
+ {adding
46
+ ? <span className="cms-filter-builder">
47
+ <select value={field} onChange={(event) => changeField(event.target.value)}>{fields.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}</select>
48
+ <select value={operator} onChange={(event) => setOperator(event.target.value as FilterOperator)}>
49
+ <option value="is">is</option>
50
+ <option value="isNot">is not</option>
51
+ {fieldDef?.supportsEmpty && <option value="isEmpty">is empty</option>}
52
+ {fieldDef?.supportsEmpty && <option value="isNotEmpty">has a value</option>}
53
+ </select>
54
+ {needsValue(operator) && (fieldDef?.type === 'select'
55
+ ? <select value={value} onChange={(event) => setValue(event.target.value)}><option value="">Choose…</option>{fieldDef.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select>
56
+ : <input value={value} onChange={(event) => setValue(event.target.value)} placeholder="Value" autoFocus />)}
57
+ <Button variant="positive" compact type="button" onClick={submit} disabled={needsValue(operator) && !value}>Add</Button>
58
+ <Button variant="ghost" compact type="button" onClick={() => setAdding(false)}>Cancel</Button>
59
+ </span>
60
+ : <Button variant="secondary" compact type="button" onClick={startAdding}>+ Add filter</Button>}
61
+ </div>
62
+ }
@@ -0,0 +1,61 @@
1
+ 'use client'
2
+
3
+ import { DynamicField } from './DynamicField'
4
+ import { Field } from './Field'
5
+ import type { FieldConfig } from '../collections/types'
6
+ import { formatFieldValue, isFieldVisible } from '../collections/values'
7
+
8
+ type GroupFieldConfig = Extract<FieldConfig, { type: 'group' }>
9
+ type Row = Record<string, unknown>
10
+
11
+ /** A nested object WITHOUT repetition — one occurrence, unlike `array`/`blocks`. Visually it's
12
+ * "one ArrayField row with no add/remove/reorder chrome", so it reuses the same
13
+ * `cms-content-field-grid` layout and `cms-array-row`-family CSS. */
14
+ export function GroupField({ field, value, onChange, label, hint, error, disabled }: {
15
+ field: GroupFieldConfig
16
+ value: Row
17
+ onChange: (value: Row) => void
18
+ label: string
19
+ hint?: string
20
+ error?: string
21
+ disabled?: boolean
22
+ }) {
23
+ const visibleFields = field.fields.filter((subfield) => isFieldVisible(subfield, value))
24
+
25
+ if (disabled) {
26
+ return (
27
+ <Field label={label} required={field.required} hint={hint} error={error}>
28
+ <div className="cms-array-field cms-array-readonly">
29
+ <div className="cms-array-row cms-array-row-readonly">
30
+ {visibleFields.map((subfield) => (
31
+ <div className="cms-array-readonly-cell" key={subfield.key}>
32
+ <span className="cms-array-readonly-label">{subfield.label}</span>
33
+ <span>{formatFieldValue(subfield, value[subfield.key])}</span>
34
+ </div>
35
+ ))}
36
+ </div>
37
+ </div>
38
+ </Field>
39
+ )
40
+ }
41
+
42
+ return (
43
+ <Field label={label} required={field.required} hint={hint} error={error}>
44
+ <div className="cms-array-row cms-group-row">
45
+ <div className="cms-content-field-grid">
46
+ {visibleFields.map((subfield) => (
47
+ <div key={subfield.key} className={`cms-content-field cms-content-field-${subfield.width ?? 'full'}`}>
48
+ <DynamicField
49
+ field={subfield}
50
+ value={value[subfield.key]}
51
+ onChange={(next) => onChange({ ...value, [subfield.key]: next })}
52
+ label={subfield.label}
53
+ hint={subfield.hint}
54
+ />
55
+ </div>
56
+ ))}
57
+ </div>
58
+ </div>
59
+ </Field>
60
+ )
61
+ }
@@ -0,0 +1,198 @@
1
+ 'use client'
2
+
3
+ import { ChangeEvent, DragEvent, MouseEvent, useState } from 'react'
4
+ import Cropper, { Area } from 'react-easy-crop'
5
+ import imageCompression from 'browser-image-compression'
6
+ import { useBodyScrollLock } from '../useBodyScrollLock'
7
+ import { Portal } from './Portal'
8
+ import { CloseButton } from './CloseButton'
9
+ import { Button } from './Button'
10
+
11
+ type Folder = { id: string; name: string; slug: string }
12
+ type Media = { id: string; filename: string; url: string; folder: string; mimeType: string }
13
+ type Props = {
14
+ value: string
15
+ onChange: (url: string) => void
16
+ label?: string
17
+ title?: string
18
+ compact?: boolean
19
+ aspect?: number
20
+ allowRemove?: boolean
21
+ }
22
+
23
+ const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
24
+
25
+ async function croppedFile(source: string, area: Area, filename: string) {
26
+ const image = new Image()
27
+ image.src = source
28
+ await new Promise<void>((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error('Could not load image')) })
29
+ const canvas = document.createElement('canvas')
30
+ canvas.width = area.width
31
+ canvas.height = area.height
32
+ const context = canvas.getContext('2d')
33
+ if (!context) throw new Error('Canvas is unavailable')
34
+ context.drawImage(image, area.x, area.y, area.width, area.height, 0, 0, area.width, area.height)
35
+ const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/webp', 0.86))
36
+ if (!blob) throw new Error('Could not crop image')
37
+ return new File([blob], `${filename.replace(/\.[^.]+$/, '')}.webp`, { type: 'image/webp' })
38
+ }
39
+
40
+ function UploadIcon() {
41
+ return <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
42
+ <path d="M12 15V4" /><path d="M7.5 8.5 12 4l4.5 4.5" /><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" />
43
+ </svg>
44
+ }
45
+
46
+ export function ImageUploader({ value, onChange, label = 'Choose media', title, compact = false, aspect = 4 / 3, allowRemove = true }: Props) {
47
+ const [picker, setPicker] = useState(false)
48
+ const [tab, setTab] = useState<'library' | 'upload'>('library')
49
+ const [folders, setFolders] = useState<Folder[]>([])
50
+ const [media, setMedia] = useState<Media[]>([])
51
+ const [folder, setFolder] = useState('uploads')
52
+ const [newFolder, setNewFolder] = useState('')
53
+ const [source, setSource] = useState('')
54
+ const [crop, setCrop] = useState({ x: 0, y: 0 })
55
+ const [zoom, setZoom] = useState(1)
56
+ const [area, setArea] = useState<Area | null>(null)
57
+ const [filename, setFilename] = useState('image')
58
+ const [status, setStatus] = useState('')
59
+ const [dragActive, setDragActive] = useState(false)
60
+ useBodyScrollLock(picker || !!source)
61
+
62
+ async function load(nextFolder = folder) {
63
+ const response = await fetch(`/api/admin/media?folder=${encodeURIComponent(nextFolder)}`)
64
+ const data = await response.json()
65
+ if (!response.ok) { setStatus(data.error || 'Could not load media'); return }
66
+ setFolders(data.folders)
67
+ setMedia((data.media as Media[]).filter((item) => item.mimeType.startsWith('image/')))
68
+ setFolder(nextFolder)
69
+ }
70
+
71
+ async function open() { setStatus(''); await load('uploads'); setPicker(true) }
72
+
73
+ async function createFolder() {
74
+ const response = await fetch('/api/admin/media', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newFolder }) })
75
+ const data = await response.json()
76
+ if (!response.ok) { setStatus(data.error || 'Could not create folder'); return }
77
+ setNewFolder('')
78
+ await load(data.slug)
79
+ }
80
+
81
+ async function handleFile(file: File) {
82
+ if (!ACCEPTED_TYPES.includes(file.type)) { setStatus('Use JPEG, PNG, or WebP.'); return }
83
+ try {
84
+ setStatus('Optimizing image…')
85
+ const optimized = await imageCompression(file, { maxSizeMB: 1, maxWidthOrHeight: 1920, useWebWorker: true })
86
+ setFilename(optimized.name || 'image')
87
+ setSource(URL.createObjectURL(optimized))
88
+ setCrop({ x: 0, y: 0 })
89
+ setZoom(1)
90
+ setPicker(false)
91
+ setStatus('')
92
+ } catch (error) {
93
+ setStatus(error instanceof Error ? error.message : 'Could not optimize image.')
94
+ }
95
+ }
96
+
97
+ async function select(event: ChangeEvent<HTMLInputElement>) {
98
+ const file = event.target.files?.[0]
99
+ event.target.value = ''
100
+ if (!file) return
101
+ await handleFile(file)
102
+ }
103
+
104
+ function onDragOver(event: DragEvent<HTMLDivElement>) { event.preventDefault(); if (!compact) setDragActive(true) }
105
+ function onDragLeave(event: DragEvent<HTMLDivElement>) { event.preventDefault(); setDragActive(false) }
106
+ async function onDrop(event: DragEvent<HTMLDivElement>) {
107
+ event.preventDefault()
108
+ setDragActive(false)
109
+ if (compact) return
110
+ const file = event.dataTransfer.files?.[0]
111
+ if (file) await handleFile(file)
112
+ }
113
+
114
+ function remove(event: MouseEvent) { event.stopPropagation(); onChange('') }
115
+
116
+ async function upload() {
117
+ if (!source || !area) return
118
+ try {
119
+ setStatus('Uploading…')
120
+ const file = await croppedFile(source, area, filename)
121
+ const form = new FormData()
122
+ form.append('file', file)
123
+ form.append('folder', folder)
124
+ const response = await fetch('/api/admin/upload', { method: 'POST', body: form })
125
+ const data = await response.json() as { url?: string; error?: string }
126
+ if (!response.ok || !data.url) throw new Error(data.error)
127
+ onChange(data.url)
128
+ URL.revokeObjectURL(source)
129
+ setSource('')
130
+ setStatus('Uploaded')
131
+ } catch (error) {
132
+ setStatus(error instanceof Error ? error.message : 'Could not upload image.')
133
+ }
134
+ }
135
+
136
+ return <div className={`image-uploader ${compact ? 'image-uploader-compact' : ''}`}>
137
+ {compact
138
+ ? <div className="image-upload-actions">
139
+ <Button type="button" variant="secondary" title={title} aria-label={title || label} className="cms-upload-button cms-richtext-media-trigger" onClick={open}>{label}</Button>
140
+ </div>
141
+ : <div
142
+ className={`cms-media-field ${value ? 'has-value' : ''} ${dragActive ? 'is-drag-active' : ''}`}
143
+ style={{ aspectRatio: value ? aspect : undefined }}
144
+ role="button"
145
+ tabIndex={0}
146
+ title={title}
147
+ aria-label={title || label}
148
+ onClick={open}
149
+ onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); void open() } }}
150
+ onDragOver={onDragOver}
151
+ onDragLeave={onDragLeave}
152
+ onDrop={(event) => void onDrop(event)}
153
+ >
154
+ {value
155
+ ? <>
156
+ <img src={value} alt="Current media" className="cms-media-field-preview" />
157
+ <div className="cms-media-field-overlay"><span>Replace image</span></div>
158
+ {allowRemove && <button type="button" className="cms-media-field-remove" onClick={remove} aria-label="Remove image">×</button>}
159
+ </>
160
+ : <div className="cms-media-field-empty">
161
+ <UploadIcon />
162
+ <strong>{label}</strong>
163
+ <span>Drag &amp; drop an image, or click to browse your library</span>
164
+ </div>}
165
+ </div>}
166
+ {!compact && status && <p className="image-upload-status">{status}</p>}
167
+ {picker && <Portal><div className="media-picker-modal" role="dialog" aria-modal="true" aria-label="Choose media">
168
+ <div className="media-picker">
169
+ <header><strong>Choose media</strong><CloseButton onClick={() => setPicker(false)} /></header>
170
+ <div className="media-picker-tabs">
171
+ <button className={tab === 'library' ? 'active' : ''} onClick={() => setTab('library')}>Media library</button>
172
+ <button className={tab === 'upload' ? 'active' : ''} onClick={() => setTab('upload')}>Upload file</button>
173
+ </div>
174
+ <div className="media-folder-row">
175
+ <select value={folder} onChange={(event) => void load(event.target.value)}>{folders.map((item) => <option key={item.id} value={item.slug}>{item.name}</option>)}</select>
176
+ <input value={newFolder} onChange={(event) => setNewFolder(event.target.value)} placeholder="New folder" />
177
+ <Button variant="positive" compact onClick={createFolder}>Create</Button>
178
+ </div>
179
+ {tab === 'library'
180
+ ? <div className="media-picker-grid">
181
+ {media.map((item) => <button key={item.id} onClick={() => { onChange(item.url); setPicker(false) }}><img src={item.url} alt={item.filename} /><span>{item.filename}</span></button>)}
182
+ {!media.length && <p>No images in this folder.</p>}
183
+ </div>
184
+ : <label className="media-dropzone"><UploadIcon /><strong>Select image from files</strong><span>JPEG, PNG, or WebP</span><input type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => void select(event)} /></label>}
185
+ </div>
186
+ </div></Portal>}
187
+ {source && <Portal><div className="crop-modal" role="dialog" aria-modal="true" aria-label="Crop image">
188
+ <div className="crop-panel">
189
+ <div className="crop-stage"><Cropper image={source} crop={crop} zoom={zoom} aspect={aspect} onCropChange={setCrop} onZoomChange={setZoom} onCropComplete={(_, pixels) => setArea(pixels)} /></div>
190
+ <label>Zoom<input type="range" min={1} max={3} step={0.1} value={zoom} onChange={(event) => setZoom(Number(event.target.value))} /></label>
191
+ <div className="crop-actions">
192
+ <Button type="button" variant="secondary" onClick={() => { URL.revokeObjectURL(source); setSource('') }}>Cancel</Button>
193
+ <Button type="button" variant="primary" onClick={upload} icon="↗">Crop &amp; upload</Button>
194
+ </div>
195
+ </div>
196
+ </div></Portal>}
197
+ </div>
198
+ }
@@ -0,0 +1,68 @@
1
+ 'use client'
2
+
3
+ import { Reorder, useDragControls } from 'framer-motion'
4
+ import { Portal } from './Portal'
5
+ import { CloseButton } from './CloseButton'
6
+ import { useBodyScrollLock } from '../useBodyScrollLock'
7
+ import type { Column } from './ListingView'
8
+ import { Button } from './Button'
9
+
10
+ type Density = 'compact' | 'comfortable' | 'spacious'
11
+ type View = 'table' | 'grid'
12
+ const densityOptions: { value: Density; label: string }[] = [
13
+ { value: 'compact', label: 'Compact' },
14
+ { value: 'comfortable', label: 'Comfortable' },
15
+ { value: 'spacious', label: 'Spacious' },
16
+ ]
17
+ const viewOptions: { value: View; label: string }[] = [
18
+ { value: 'table', label: 'Table' },
19
+ { value: 'grid', label: 'Grid' },
20
+ ]
21
+
22
+ type ListingSettingsModalProps<T> = {
23
+ onClose: () => void
24
+ view: View
25
+ onViewChange: (view: View) => void
26
+ density: Density
27
+ onDensityChange: (density: Density) => void
28
+ columns: Column<T>[]
29
+ hidden: string[]
30
+ labels: Record<string, string>
31
+ onToggleColumn: (key: string) => void
32
+ onReorderColumns: (keys: string[]) => void
33
+ onRenameColumn: (key: string, label: string) => void
34
+ }
35
+
36
+ function FieldRow<T>({ column, label, checked, onToggle, onRename }: { column: Column<T>; label: string; checked: boolean; onToggle: () => void; onRename: (label: string) => void }) {
37
+ const controls = useDragControls()
38
+ return <Reorder.Item value={column.key} dragListener={false} dragControls={controls} className="cms-columns-row" whileDrag={{ scale: 1.02, boxShadow: '0 8px 20px rgba(23,23,23,.18)' }}>
39
+ <button type="button" className="cms-columns-drag-handle" onPointerDown={(event) => controls.start(event)} aria-label={`Drag to reorder ${label}`}>
40
+ <svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor"><circle cx="3" cy="2" r="1.4" /><circle cx="9" cy="2" r="1.4" /><circle cx="3" cy="8" r="1.4" /><circle cx="9" cy="8" r="1.4" /><circle cx="3" cy="14" r="1.4" /><circle cx="9" cy="14" r="1.4" /></svg>
41
+ </button>
42
+ <span className="cms-columns-check"><input type="checkbox" aria-label={`Show ${label} field`} checked={checked} onChange={onToggle} /><input className="cms-columns-rename" value={label} onChange={(event) => onRename(event.target.value)} /></span>
43
+ </Reorder.Item>
44
+ }
45
+
46
+ export function ListingSettingsModal<T>({ onClose, view, onViewChange, density, onDensityChange, columns, hidden, labels, onToggleColumn, onReorderColumns, onRenameColumn }: ListingSettingsModalProps<T>) {
47
+ useBodyScrollLock(true)
48
+ return <Portal><div className="listing-settings-modal" role="dialog" aria-modal="true" aria-label="View settings" onClick={onClose}><div className="listing-settings-panel" onClick={(event) => event.stopPropagation()}>
49
+ <header><strong>View settings</strong><CloseButton onClick={onClose} /></header>
50
+
51
+ <section>
52
+ <p className="listing-settings-label">Layout</p>
53
+ <div className="cms-density-toggle listing-settings-density">{viewOptions.map((option) => <Button key={option.value} variant="secondary" compact type="button" className={view === option.value ? 'active' : ''} onClick={() => onViewChange(option.value)}>{option.label}</Button>)}</div>
54
+ </section>
55
+
56
+ <section>
57
+ <p className="listing-settings-label">Density</p>
58
+ <div className="cms-density-toggle listing-settings-density">{densityOptions.map((option) => <Button key={option.value} variant="secondary" compact type="button" className={density === option.value ? 'active' : ''} onClick={() => onDensityChange(option.value)}>{option.label}</Button>)}</div>
59
+ </section>
60
+
61
+ <section>
62
+ <p className="listing-settings-label">Fields</p>
63
+ <Reorder.Group axis="y" values={columns.map((column) => column.key)} onReorder={onReorderColumns} className="listing-settings-columns">
64
+ {columns.map((column) => <FieldRow key={column.key} column={column} label={labels[column.key] || column.label} checked={!hidden.includes(column.key)} onToggle={() => onToggleColumn(column.key)} onRename={(label) => onRenameColumn(column.key, label)} />)}
65
+ </Reorder.Group>
66
+ </section>
67
+ </div></div></Portal>
68
+ }