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,123 @@
1
+ 'use client'
2
+
3
+ import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, type ReactNode, type Ref } from 'react'
4
+ import { useLocalStorageState } from '../useLocalStorageState'
5
+ import { ListingSettingsModal } from './ListingSettingsModal'
6
+ import { ActionIcon, type ActionIconName } from './RowActionsMenu'
7
+ import { CloseButton } from './CloseButton'
8
+ import { Button } from './Button'
9
+
10
+ export type ListingViewHandle = { openSettings: () => void }
11
+
12
+ export type Column<T> = { key: string; label: string; render: (item: T) => ReactNode; defaultHidden?: boolean }
13
+ export type Card = { image?: string | null; title: ReactNode; meta: ReactNode; published: boolean; badge?: ReactNode }
14
+ export type BulkAction<T> = { key: string; label: string; icon?: ActionIconName; danger?: boolean; onRun: (items: T[]) => void }
15
+
16
+ type ViewPrefs = { view: 'table' | 'grid'; density: 'compact' | 'comfortable' | 'spacious'; hidden: string[]; order: string[]; labels: Record<string, string> }
17
+ const defaultPrefs: ViewPrefs = { view: 'table', density: 'comfortable', hidden: [], order: [], labels: {} }
18
+ const pageSizes = [10, 25, 50]
19
+
20
+ type ListingViewProps<T> = {
21
+ items: T[]
22
+ total: number
23
+ loading?: boolean
24
+ getId: (item: T) => string
25
+ columns: Column<T>[]
26
+ card: (item: T) => Card
27
+ renderActions?: (item: T) => ReactNode
28
+ storageKey: string
29
+ columnLabels?: Record<string, string | undefined>
30
+ emptyLabel?: string
31
+ sortBy?: string
32
+ sortDir?: 'asc' | 'desc'
33
+ onSortChange?: (by: string, dir: 'asc' | 'desc') => void
34
+ page: number
35
+ pageSize: number | 'all'
36
+ onPageChange: (page: number) => void
37
+ onPageSizeChange: (size: number | 'all') => void
38
+ bulkActions?: BulkAction<T>[]
39
+ reorderable?: boolean
40
+ onMove?: (id: string, direction: 'up' | 'down') => void
41
+ resetSelectionKey?: string | number
42
+ }
43
+
44
+ function ListingViewInner<T>({ items, total, loading, getId, columns, card, renderActions, storageKey, columnLabels, emptyLabel = 'Nothing here yet.', sortBy, sortDir = 'asc', onSortChange, page, pageSize, onPageChange, onPageSizeChange, bulkActions, reorderable, onMove, resetSelectionKey }: ListingViewProps<T>, ref: Ref<ListingViewHandle>) {
45
+ const [prefs, setPrefs] = useLocalStorageState<ViewPrefs>(`cms-listing:${storageKey}`, { ...defaultPrefs, hidden: columns.filter((column) => column.defaultHidden).map((column) => column.key) })
46
+ const [selected, setSelected] = useState<Set<string>>(new Set())
47
+ const [settingsOpen, setSettingsOpen] = useState(false)
48
+ useEffect(() => { setSelected(new Set()) }, [resetSelectionKey])
49
+ useImperativeHandle(ref, () => ({ openSettings: () => setSettingsOpen(true) }), [])
50
+
51
+ const allOrdered = useMemo(() => {
52
+ const byKey = new Map(columns.map((column) => [column.key, column]))
53
+ const orderKeys = prefs.order.length ? prefs.order.filter((key) => byKey.has(key)) : columns.map((column) => column.key)
54
+ const missing = columns.map((column) => column.key).filter((key) => !orderKeys.includes(key))
55
+ return [...orderKeys, ...missing].map((key) => byKey.get(key)!)
56
+ }, [columns, prefs.order])
57
+ const orderedColumns = useMemo(() => allOrdered.filter((column) => !prefs.hidden.includes(column.key)), [allOrdered, prefs.hidden])
58
+
59
+ function labelFor(column: Column<T>) { return columnLabels?.[column.key] || prefs.labels[column.key] || column.label }
60
+ function toggleColumn(key: string) { setPrefs({ ...prefs, hidden: prefs.hidden.includes(key) ? prefs.hidden.filter((item) => item !== key) : [...prefs.hidden, key] }) }
61
+ function reorderColumns(keys: string[]) { setPrefs({ ...prefs, order: keys }) }
62
+ function renameColumn(key: string, label: string) { setPrefs({ ...prefs, labels: { ...prefs.labels, [key]: label } }) }
63
+
64
+ function toggleSelect(id: string) { setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next }) }
65
+ function toggleSelectAll() { setSelected((current) => current.size === items.length ? new Set() : new Set(items.map(getId))) }
66
+ function runBulk(action: BulkAction<T>) { action.onRun(items.filter((item) => selected.has(getId(item)))); setSelected(new Set()) }
67
+
68
+ const pageCount = pageSize === 'all' ? 1 : Math.max(1, Math.ceil(total / pageSize))
69
+
70
+ return <>
71
+ {settingsOpen && <ListingSettingsModal
72
+ onClose={() => setSettingsOpen(false)}
73
+ view={prefs.view}
74
+ onViewChange={(view) => setPrefs({ ...prefs, view })}
75
+ density={prefs.density}
76
+ onDensityChange={(density) => setPrefs({ ...prefs, density })}
77
+ columns={allOrdered}
78
+ hidden={prefs.hidden}
79
+ labels={prefs.labels}
80
+ onToggleColumn={toggleColumn}
81
+ onReorderColumns={reorderColumns}
82
+ onRenameColumn={renameColumn}
83
+ />}
84
+
85
+ {selected.size > 0 && bulkActions && <div className="cms-bulk-bar">
86
+ <span className="cms-bulk-count"><b>{selected.size}</b> selected</span>
87
+ <div className="cms-bulk-actions">{bulkActions.map((action) => <Button key={action.key} variant={action.danger ? 'negative' : 'secondary'} compact type="button" onClick={() => runBulk(action)} icon={action.icon ? <ActionIcon name={action.icon} /> : undefined} iconPosition="before">{action.label}</Button>)}</div>
88
+ <CloseButton onClick={() => setSelected(new Set())} label="Clear selection" />
89
+ </div>}
90
+
91
+ {loading
92
+ ? <ListingSkeleton view={prefs.view} columnCount={orderedColumns.length + (bulkActions ? 1 : 0) + (reorderable ? 1 : 0) + 1} />
93
+ : prefs.view === 'table'
94
+ ? <div className={`cms-table-wrap density-${prefs.density}`}><table className="cms-table"><thead><tr>
95
+ {bulkActions && <th><input type="checkbox" checked={items.length > 0 && selected.size === items.length} onChange={toggleSelectAll} /></th>}
96
+ {reorderable && <th />}
97
+ {orderedColumns.map((column) => <th key={column.key}>{onSortChange ? <Button variant="ghost" compact type="button" className={`cms-sort-button ${sortBy === column.key ? 'active' : ''}`} onClick={() => onSortChange(column.key, sortBy === column.key && sortDir === 'asc' ? 'desc' : 'asc')}>{labelFor(column)}{sortBy === column.key ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ''}</Button> : labelFor(column)}</th>)}
98
+ <th />
99
+ </tr></thead><tbody>{items.map((item, index) => { const id = getId(item); return <tr key={id}>
100
+ {bulkActions && <td><input type="checkbox" checked={selected.has(id)} onChange={() => toggleSelect(id)} /></td>}
101
+ {reorderable && <td className="cms-reorder-cell"><Button variant="secondary" compact type="button" disabled={index === 0} onClick={() => onMove?.(id, 'up')} aria-label="Move up">↑</Button><Button variant="secondary" compact type="button" disabled={index === items.length - 1} onClick={() => onMove?.(id, 'down')} aria-label="Move down">↓</Button></td>}
102
+ {orderedColumns.map((column) => <td key={column.key}>{column.render(item)}</td>)}
103
+ <td>{renderActions?.(item)}</td>
104
+ </tr> })}</tbody></table>{!items.length && <p className="cms-empty-state">{emptyLabel}</p>}</div>
105
+ : <div className={`cms-item-grid density-${prefs.density}`}>{items.map((item) => { const id = getId(item); const { image, title, meta, published, badge } = card(item); return <article className={`cms-list-card ${selected.has(id) ? 'is-selected' : ''}`} key={id}>
106
+ {bulkActions && <label className="cms-card-select"><input type="checkbox" checked={selected.has(id)} onChange={() => toggleSelect(id)} /></label>}
107
+ {image && <img src={image} alt="" />}
108
+ <div>{badge ?? <span className={`cms-status ${published ? 'is-published' : ''}`}>{published ? 'Published' : 'Draft'}</span>}<h2>{title}</h2><p>{meta}</p>{renderActions?.(item)}</div>
109
+ </article> })}{!items.length && <p className="cms-empty-state">{emptyLabel}</p>}</div>}
110
+
111
+ <div className="cms-pagination">
112
+ <select value={pageSize} onChange={(event) => onPageSizeChange(event.target.value === 'all' ? 'all' : Number(event.target.value))}>{pageSizes.map((size) => <option key={size} value={size}>{size} / page</option>)}<option value="all">All</option></select>
113
+ {pageSize !== 'all' && <div className="cms-pagination-nav"><Button variant="secondary" compact type="button" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>← Prev</Button><span>Page {page} of {pageCount}</span><Button variant="secondary" compact type="button" disabled={page >= pageCount} onClick={() => onPageChange(page + 1)}>Next →</Button></div>}
114
+ </div>
115
+ </>
116
+ }
117
+
118
+ export const ListingView = forwardRef(ListingViewInner) as <T>(props: ListingViewProps<T> & { ref?: Ref<ListingViewHandle> }) => ReturnType<typeof ListingViewInner>
119
+
120
+ export function ListingSkeleton({ view, columnCount }: { view: 'table' | 'grid'; columnCount: number }) {
121
+ if (view === 'grid') return <div className="cms-item-grid">{Array.from({ length: 6 }).map((_, index) => <div className="cms-skeleton-card" key={index} />)}</div>
122
+ return <div className="cms-table-wrap"><table className="cms-table"><tbody>{Array.from({ length: 6 }).map((_, row) => <tr key={row}>{Array.from({ length: columnCount }).map((_, cell) => <td key={cell}><div className="cms-skeleton-line" /></td>)}</tr>)}</tbody></table></div>
123
+ }
@@ -0,0 +1,86 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { EditorHeader } from './EditorHeader'
5
+ import { TextField } from './Field'
6
+ import { CmsSidebar } from './CmsSidebar'
7
+ import { useToast } from './Toast'
8
+ import { ListingSkeleton } from './ListingView'
9
+ import { Button } from './Button'
10
+
11
+ type LocaleRow = { code: string; label: string; isDefault: boolean; sortOrder: number }
12
+
13
+ export function LocalesEditor({ userName }: { userName: string }) {
14
+ const toast = useToast()
15
+ const [rows, setRows] = useState<LocaleRow[]>([])
16
+ const [loading, setLoading] = useState(true)
17
+ const [code, setCode] = useState('')
18
+ const [label, setLabel] = useState('')
19
+ const [creating, setCreating] = useState(false)
20
+
21
+ function load() { setLoading(true); fetch('/api/admin/locales').then((response) => response.ok ? response.json() : []).then(setRows).finally(() => setLoading(false)) }
22
+ useEffect(() => { load() }, [])
23
+
24
+ async function create() {
25
+ if (!code.trim() || !label.trim()) { toast.error('Give the locale both a code and a label.'); return }
26
+ setCreating(true)
27
+ try {
28
+ const response = await fetch('/api/admin/locales', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: code.trim(), label: label.trim(), isDefault: rows.length === 0 }) })
29
+ const data = await response.json()
30
+ if (!response.ok) throw new Error(data.error)
31
+ setCode(''); setLabel('')
32
+ toast.success(`Locale "${data.label}" added.`)
33
+ load()
34
+ } catch (error) { toast.error(error instanceof Error ? error.message : 'Could not add locale') } finally { setCreating(false) }
35
+ }
36
+
37
+ async function setDefault(row: LocaleRow) {
38
+ const response = await fetch(`/api/admin/locales/${row.code}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label: row.label, isDefault: true }) })
39
+ if (response.ok) { toast.success(`"${row.label}" is now the default locale.`); load() } else toast.error('Could not set default locale.')
40
+ }
41
+
42
+ async function remove(row: LocaleRow) {
43
+ if (rows.length <= 1) { toast.error('At least one locale is required — add another before removing this one.'); return }
44
+ if (!confirm(`Remove locale "${row.label}"? Any values already stored under "${row.code}" on localized fields stay in the database but won’t be editable anymore.`)) return
45
+ const response = await fetch(`/api/admin/locales/${row.code}`, { method: 'DELETE' })
46
+ if (response.ok) { toast.success('Locale removed.'); load() } else toast.error('Could not remove locale.')
47
+ }
48
+
49
+ async function move(index: number, direction: 'up' | 'down') {
50
+ const swapWith = direction === 'up' ? index - 1 : index + 1
51
+ if (swapWith < 0 || swapWith >= rows.length) return
52
+ const next = [...rows]
53
+ ;[next[index], next[swapWith]] = [next[swapWith], next[index]]
54
+ setRows(next)
55
+ await fetch('/api/admin/locales/reorder', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ codes: next.map((row) => row.code) }) })
56
+ }
57
+
58
+ return <main className="cms-dashboard"><CmsSidebar active="settings" userName={userName} /><section className="cms-content">
59
+ <EditorHeader compact title="Locales" backHref="/admin/dashboard" backLabel="Dashboard" />
60
+ <div className="cms-editor-layout"><div className="cms-editor-form">
61
+ <section className="cms-section">
62
+ <div className="cms-section-heading"><p className="section-label">What this controls</p><p>The languages available on every <code>localized</code> field (a tab per locale). The DEFAULT locale is the one <code>required</code> is enforced against — other locales may stay untranslated.</p></div>
63
+ <div className="cms-two-columns">
64
+ <TextField label="Code" value={code} onChange={(event) => setCode(event.target.value)} placeholder="e.g. en, id, en-US" hint="Short code — this is the storage key, don't change it later without a data migration" />
65
+ <TextField label="Label" value={label} onChange={(event) => setLabel(event.target.value)} placeholder="e.g. English" />
66
+ </div>
67
+ <Button variant="positive" type="button" disabled={creating} onClick={() => void create()} icon="↗">{creating ? 'Adding…' : '+ Add locale'}</Button>
68
+ </section>
69
+ <section className="cms-section">
70
+ <div className="cms-section-heading"><p className="section-label">Locales</p></div>
71
+ {loading ? <ListingSkeleton view="table" columnCount={4} /> : <div className="cms-table-wrap"><table className="cms-table"><thead><tr><th>Code</th><th>Label</th><th>Default</th><th /></tr></thead><tbody>
72
+ {rows.map((row, index) => <tr key={row.code}>
73
+ <td><span className="cms-table-id">{row.code}</span></td>
74
+ <td><strong>{row.label}</strong></td>
75
+ <td>{row.isDefault ? <span className="cms-status is-published">Default</span> : <Button variant="ghost" compact type="button" onClick={() => void setDefault(row)}>Make default</Button>}</td>
76
+ <td className="cms-row-actions">
77
+ <Button variant="ghost" compact iconOnly type="button" disabled={index === 0} onClick={() => void move(index, 'up')} aria-label={`Move ${row.label} up`} icon="↑" />
78
+ <Button variant="ghost" compact iconOnly type="button" disabled={index === rows.length - 1} onClick={() => void move(index, 'down')} aria-label={`Move ${row.label} down`} icon="↓" />
79
+ <Button variant="negative" compact type="button" onClick={() => void remove(row)}>Remove</Button>
80
+ </td>
81
+ </tr>)}
82
+ </tbody></table>{!rows.length && <p className="cms-empty-state">No locales configured yet — add one above (falls back to Indonesian/English until then).</p>}</div>}
83
+ </section>
84
+ </div></div>
85
+ </section></main>
86
+ }
@@ -0,0 +1,46 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { DynamicField } from './DynamicField'
5
+ import { useCmsLocales } from './CmsLocalesProvider'
6
+ import type { FieldConfig } from '../collections/types'
7
+
8
+ /** Wraps any `localized: true` field — one tab per locale (sourced live from
9
+ * `useCmsLocales()`, not a static list — see `CmsLocalesProvider`), delegating to
10
+ * `DynamicField` for the ACTIVE locale's value only (not all locales stacked, to avoid
11
+ * clutter). `field.localized` is forced false on the delegated call so `DynamicField` renders
12
+ * the field's normal (non-map) control against that one locale's plain value. */
13
+ export function LocalizedField({ field, value, onChange, label, hint, error, disabled }: {
14
+ field: FieldConfig
15
+ value: Record<string, unknown>
16
+ onChange: (value: Record<string, unknown>) => void
17
+ label: string
18
+ hint?: string
19
+ error?: string
20
+ disabled?: boolean
21
+ }) {
22
+ const { codes, default: defaultLocale, labels } = useCmsLocales()
23
+ const [active, setActive] = useState(defaultLocale)
24
+ const innerField = { ...field, localized: false } as FieldConfig
25
+ const activeLocale = codes.includes(active) ? active : defaultLocale
26
+ return (
27
+ <div className="cms-localized-field">
28
+ <div className="cms-locale-tabs" role="tablist" aria-label={`${label} — language`}>
29
+ {codes.map((locale) => (
30
+ <button key={locale} type="button" role="tab" aria-selected={activeLocale === locale} className={activeLocale === locale ? 'active' : ''} onClick={() => setActive(locale)}>
31
+ {labels[locale] ?? locale}{locale === defaultLocale && <span className="cms-locale-default-mark" title="Default locale">*</span>}
32
+ </button>
33
+ ))}
34
+ </div>
35
+ <DynamicField
36
+ field={innerField}
37
+ value={value?.[activeLocale]}
38
+ onChange={(next) => onChange({ ...value, [activeLocale]: next })}
39
+ label={label}
40
+ hint={hint}
41
+ error={error}
42
+ disabled={disabled}
43
+ />
44
+ </div>
45
+ )
46
+ }
@@ -0,0 +1,53 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+ import { MapContainer, Marker, TileLayer, useMapEvents } from 'react-leaflet'
5
+ import L from 'leaflet'
6
+ import 'leaflet/dist/leaflet.css'
7
+
8
+ // Leaflet's default marker icon references image paths that don't survive Next.js's bundler —
9
+ // a divIcon (inline SVG, no external asset) sidesteps that entirely rather than fighting webpack
10
+ // asset resolution for a handful of PNGs.
11
+ const markerIcon = L.divIcon({
12
+ className: 'cms-map-marker',
13
+ html: '<svg viewBox="0 0 24 24" width="28" height="28"><path fill="#c43b2d" stroke="#fff" stroke-width="1.5" d="M12 0C7 0 3 4 3 9c0 6.5 9 15 9 15s9-8.5 9-15c0-5-4-9-9-9z"/><circle cx="12" cy="9" r="3.2" fill="#fff"/></svg>',
14
+ iconSize: [28, 28],
15
+ iconAnchor: [14, 28],
16
+ })
17
+
18
+ function ClickToPlace({ onChange }: { onChange: (lat: number, lng: number) => void }) {
19
+ useMapEvents({ click(event) { onChange(event.latlng.lat, event.latlng.lng) } })
20
+ return null
21
+ }
22
+
23
+ /** Recenters the map when `lat`/`lng` change from OUTSIDE a click on the map itself (e.g. typing
24
+ * into the number inputs next to it) — react-leaflet doesn't do this automatically since
25
+ * MapContainer's `center` prop is only the INITIAL center, not a controlled prop. */
26
+ function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) {
27
+ const map = useMapEvents({})
28
+ useEffect(() => { map.setView([lat, lng], map.getZoom(), { animate: true }) }, [lat, lng, map])
29
+ return null
30
+ }
31
+
32
+ export default function MapPicker({ lat, lng, onChange, disabled }: {
33
+ lat: number
34
+ lng: number
35
+ onChange: (lat: number, lng: number) => void
36
+ disabled?: boolean
37
+ }) {
38
+ const center: [number, number] = [
39
+ Number.isFinite(lat) && lat !== 0 ? lat : -6.2,
40
+ Number.isFinite(lng) && lng !== 0 ? lng : 106.8, // Jakarta — a reasonable default center, not a claim about the actual point
41
+ ]
42
+ return (
43
+ <div className="cms-map-picker">
44
+ <MapContainer center={center} zoom={lat || lng ? 12 : 4} scrollWheelZoom={false} style={{ height: 220, width: '100%', borderRadius: 8 }}>
45
+ <TileLayer attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
46
+ {!disabled && <ClickToPlace onChange={onChange} />}
47
+ <RecenterOnChange lat={center[0]} lng={center[1]} />
48
+ <Marker position={[lat, lng]} icon={markerIcon} />
49
+ </MapContainer>
50
+ {!disabled && <p className="cms-map-hint">Click the map to set the point, or type exact coordinates above.</p>}
51
+ </div>
52
+ )
53
+ }