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,278 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { CmsSidebar } from './CmsSidebar'
5
+ import { EditorHeader } from './EditorHeader'
6
+ import { ListingView, type Column, type ListingViewHandle } from './ListingView'
7
+ import { RowActionsMenu } from './RowActionsMenu'
8
+ import { FilterBuilder, type FilterFieldDef, type FilterRule } from './FilterBuilder'
9
+ import { Button } from './Button'
10
+ import { formatDateTime } from '../format'
11
+ import { contentViewField, contentViewLabels, defaultContentViewFor, type ContentViewSettings } from '../collections/content-view'
12
+ import { relationFields } from '../collections/registry'
13
+ import { formatFieldValue, resolvePreviewPath } from '../collections/values'
14
+ import { workflowListColumns, workflowListFilters, type CollectionConfig, type FieldConfig, type SelectOption } from '../collections/types'
15
+
16
+ const reviewStatusLabels: Record<string, string> = { draft: 'Draft', in_review: 'In review', reviewed: 'Reviewed', approved: 'Approved' }
17
+
18
+ type Row = Record<string, unknown>
19
+ type ListResponse = { items: Row[]; total: number; relationLabels: Record<string, Record<string, string>> }
20
+ const PAGE_SIZE_DEFAULT = 25
21
+
22
+ export function CollectionListEditor({ config, userName, canCreate, canPublish, canDelete }: { config: CollectionConfig; userName: string; canCreate: boolean; canPublish: boolean; canDelete: boolean }) {
23
+ // The server page already resolved `config` (registry lookups only work server-side —
24
+ // `defineKiloConfig()` never runs on the client) and passes it down as a prop; `slug` is
25
+ // just a local alias so every existing `slug`-referencing line below keeps working unchanged.
26
+ const slug = config.slug
27
+ const [items, setItems] = useState<Row[]>([])
28
+ const [relationLabels, setRelationLabels] = useState<Record<string, Record<string, string>>>({})
29
+ const [relationFilterOptions, setRelationFilterOptions] = useState<Record<string, SelectOption[]>>({})
30
+ const [users, setUsers] = useState<Array<{ id: string; name: string }>>([])
31
+ const [trashed, setTrashed] = useState(false)
32
+ const [total, setTotal] = useState(0)
33
+ const [loading, setLoading] = useState(true)
34
+ const [error, setError] = useState('')
35
+ const [qInput, setQInput] = useState('')
36
+ const [q, setQ] = useState('')
37
+ const [filters, setFilters] = useState<FilterRule[]>([])
38
+ const [contentView, setContentView] = useState<ContentViewSettings>(() => defaultContentViewFor(config))
39
+ const [sortBy, setSortBy] = useState(config.defaultSort.field)
40
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>(config.defaultSort.dir)
41
+ const [page, setPage] = useState(1)
42
+ const [pageSize, setPageSize] = useState<number | 'all'>(PAGE_SIZE_DEFAULT)
43
+ const searchRef = useRef<HTMLInputElement>(null)
44
+ const listingRef = useRef<ListingViewHandle>(null)
45
+
46
+ useEffect(() => {
47
+ setItems([]); setTotal(0); setPage(1); setSortBy(config.defaultSort.field); setSortDir(config.defaultSort.dir); setFilters([]); setQInput(''); setQ(''); setTrashed(false)
48
+ fetch(`/api/admin/content-views/${slug}`).then((response) => response.ok ? response.json() : null).then((data) => { if (data) setContentView(data) }).catch(() => {})
49
+ if (config.workflow?.audit) fetch('/api/admin/users').then((response) => response.ok ? response.json() : []).then(setUsers).catch(() => {})
50
+ const optionFields = [
51
+ ...relationFields(config).map((field) => [field.key, field.relationTo] as const),
52
+ ...config.fields
53
+ .filter((field): field is Extract<FieldConfig, { type: 'text' }> => field.type === 'text' && Boolean(field.suggestionsFrom) && Boolean(field.filterable))
54
+ .map((field) => [field.key, field.suggestionsFrom!.collection] as const),
55
+ ]
56
+ Promise.all(optionFields.map(([key, target]) =>
57
+ fetch(`/api/admin/collections/${target}/options`).then((response) => response.ok ? response.json() : []).then((options: SelectOption[]) => [key, options] as const),
58
+ )).then((entries) => setRelationFilterOptions(Object.fromEntries(entries))).catch(() => {})
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ }, [slug])
61
+
62
+ useEffect(() => { const timer = setTimeout(() => { setQ(qInput); setPage(1) }, 300); return () => clearTimeout(timer) }, [qInput])
63
+
64
+ const load = useCallback(async () => {
65
+ setLoading(true)
66
+ try {
67
+ const params = new URLSearchParams()
68
+ if (trashed) params.set('trashed', '1')
69
+ if (q) params.set('q', q)
70
+ if (filters.length) params.set('filters', JSON.stringify(filters.map(({ field, operator, value }) => ({ field, operator, value }))))
71
+ params.set('sortBy', sortBy)
72
+ params.set('sortDir', sortDir)
73
+ params.set('page', String(page))
74
+ params.set('pageSize', String(pageSize))
75
+ const response = await fetch(`/api/admin/collections/${slug}?${params}`)
76
+ if (!response.ok) throw new Error()
77
+ const data: ListResponse = await response.json()
78
+ setItems(data.items)
79
+ setTotal(data.total)
80
+ setRelationLabels(data.relationLabels)
81
+ setError('')
82
+ } catch { setError(`Could not load ${config.label.toLowerCase()}`) }
83
+ finally { setLoading(false) }
84
+ }, [slug, trashed, q, filters, sortBy, sortDir, page, pageSize, config.label])
85
+ useEffect(() => { void load() }, [load])
86
+
87
+ useEffect(() => {
88
+ function handler(event: KeyboardEvent) {
89
+ if (event.key !== '/' || event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return
90
+ event.preventDefault()
91
+ searchRef.current?.focus()
92
+ }
93
+ window.addEventListener('keydown', handler)
94
+ return () => window.removeEventListener('keydown', handler)
95
+ }, [])
96
+
97
+ function handleSort(by: string, dir: 'asc' | 'desc') { setSortBy(by); setSortDir(dir); setPage(1) }
98
+ function addFilter(rule: FilterRule) { setFilters((current) => [...current, rule]); setPage(1) }
99
+ function removeFilter(id: string) { setFilters((current) => current.filter((rule) => rule.id !== id)); setPage(1) }
100
+ function switchTab(next: boolean) { setTrashed(next); setPage(1) }
101
+
102
+ async function deleteOne(id: string) {
103
+ if (!confirm(config.workflow?.trash ? `Move this ${config.labelSingular.toLowerCase()} to trash?` : `Delete this ${config.labelSingular.toLowerCase()}? This cannot be undone.`)) return
104
+ await fetch(`/api/admin/collections/${slug}/${id}`, { method: 'DELETE' })
105
+ void load()
106
+ }
107
+ async function restoreOne(id: string) {
108
+ await fetch(`/api/admin/collections/${slug}/${id}/restore`, { method: 'POST' })
109
+ void load()
110
+ }
111
+ async function destroyOne(id: string) {
112
+ if (!confirm(`Permanently delete this ${config.labelSingular.toLowerCase()}? This cannot be undone.`)) return
113
+ await fetch(`/api/admin/collections/${slug}/${id}?permanent=1`, { method: 'DELETE' })
114
+ void load()
115
+ }
116
+ async function bulkRun(selected: Row[], action: 'delete' | 'publish' | 'unpublish' | 'trash' | 'restore') {
117
+ const label = action === 'trash' ? `Move ${selected.length} ${config.label.toLowerCase()} to trash?`
118
+ : action === 'delete' ? `Delete ${selected.length} ${config.label.toLowerCase()}? This cannot be undone.`
119
+ : action === 'restore' ? `Restore ${selected.length} ${config.label.toLowerCase()}?`
120
+ : `${action === 'publish' ? 'Publish' : 'Unpublish'} ${selected.length} ${config.label.toLowerCase()}?`
121
+ if (!confirm(label)) return
122
+ await fetch(`/api/admin/collections/${slug}/bulk`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: selected.map((item) => String(item.id)), action }) })
123
+ void load()
124
+ }
125
+ async function togglePublish(id: string, published: boolean) {
126
+ await fetch(`/api/admin/collections/${slug}/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ published }) })
127
+ void load()
128
+ }
129
+ async function duplicateOne(id: string) {
130
+ await fetch(`/api/admin/collections/${slug}/${id}/duplicate`, { method: 'POST' })
131
+ void load()
132
+ }
133
+ async function move(id: string, direction: 'up' | 'down') {
134
+ const index = items.findIndex((item) => String(item.id) === id)
135
+ const swapWith = direction === 'up' ? index - 1 : index + 1
136
+ if (index < 0 || swapWith < 0 || swapWith >= items.length) return
137
+ const next = [...items]
138
+ ;[next[index], next[swapWith]] = [next[swapWith], next[index]]
139
+ setItems(next)
140
+ await fetch(`/api/admin/collections/${slug}/reorder`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: next.map((item) => String(item.id)) }) })
141
+ }
142
+
143
+ const fieldFilters: FilterFieldDef[] = config.fields.filter((field) => field.filterable).map((field) => {
144
+ const isSuggestion = field.type === 'text' && Boolean(field.suggestionsFrom)
145
+ return {
146
+ key: field.key,
147
+ label: contentViewField(contentView, field.key)?.label ?? field.label,
148
+ type: field.type === 'boolean' || field.type === 'select' || field.type === 'relation' || isSuggestion ? 'select' : 'text',
149
+ supportsEmpty: Boolean(field.nullable) || isSuggestion,
150
+ options: field.type === 'boolean' ? [{ value: 'true', label: 'Yes' }, { value: 'false', label: 'No' }]
151
+ : field.type === 'select' ? field.options
152
+ : (field.type === 'relation' || isSuggestion) ? (relationFilterOptions[field.key] ?? []).map((option) => ({ value: option.label, label: option.label }))
153
+ : undefined,
154
+ }
155
+ })
156
+ const workflowFilters: FilterFieldDef[] = config.workflow ? workflowListFilters
157
+ .filter((field) => Boolean(config.workflow?.[field.requires]))
158
+ .map((field) => ({
159
+ key: field.key,
160
+ label: field.label,
161
+ type: 'select',
162
+ options: field.type === 'boolean'
163
+ ? [
164
+ { value: 'true', label: 'Published' },
165
+ ...(config.workflow?.scheduling ? [{ value: 'scheduled', label: 'Scheduled' }] : []),
166
+ { value: 'false', label: 'Draft' },
167
+ ]
168
+ : field.options.map((option) => ({ ...option })),
169
+ })) : []
170
+ const filterFields = [...fieldFilters, ...workflowFilters]
171
+
172
+ const userName_ = (value: unknown) => users.find((user) => user.id === value)?.name || '—'
173
+ const columns: Column<Row>[] = config.fields
174
+ .filter((field) => field.listColumn)
175
+ .map((field) => ({
176
+ key: field.key,
177
+ label: field.label,
178
+ render: (item: Row) => {
179
+ const display = formatFieldValue(field, item[field.key], relationLabels[field.key])
180
+ if (field.type === 'image') return typeof item[field.key] === 'string' && item[field.key]
181
+ ? <img className="cms-table-thumb" src={String(item[field.key])} alt="" />
182
+ : '—'
183
+ return field.listPrimary
184
+ ? <a className="cms-row-title" href={`/admin/collections/${slug}/${String(item.id)}`}><strong>{display || 'Untitled'}</strong></a>
185
+ : display
186
+ },
187
+ }))
188
+ const workflowColumns: Column<Row>[] = config.workflow ? workflowListColumns
189
+ .filter((column) => Boolean(config.workflow?.[column.requires]))
190
+ .map((column) => ({
191
+ key: column.key,
192
+ label: column.label,
193
+ defaultHidden: true,
194
+ render: (item: Row) =>
195
+ column.kind === 'user' ? userName_(item[column.key])
196
+ : column.kind === 'datetime' ? formatDateTime(item[column.key] as string | null | undefined)
197
+ : reviewStatusLabels[String(item[column.key])] ?? String(item[column.key] ?? '—'),
198
+ })) : []
199
+ const statusColumn: Column<Row>[] = config.workflow?.publish ? [{
200
+ key: 'published',
201
+ label: 'Status',
202
+ render: (item: Row) => trashed
203
+ ? <span className="cms-status">Trashed</span>
204
+ : canPublish
205
+ ? <button type="button" className={`cms-switch ${item.published ? 'is-on' : ''}`} onClick={() => void togglePublish(String(item.id), !item.published)}>{item.published ? 'Published' : 'Draft'}</button>
206
+ : <span className={`cms-status ${item.published ? 'is-published' : ''}`}>{item.published ? 'Published' : 'Draft'}</span>,
207
+ }] : []
208
+ const allColumns: Column<Row>[] = [
209
+ { key: 'id', label: 'ID', defaultHidden: true, render: (item) => <span className="cms-table-id">{String(item.id).slice(0, 8)}</span> },
210
+ ...columns,
211
+ ...workflowColumns,
212
+ ...statusColumn,
213
+ ]
214
+
215
+ const titleField = config.titleField
216
+ const imageField = config.fields.find((field) => field.type === 'image' && field.listColumn)
217
+ const reorderable = Boolean(config.workflow?.sortOrder) && !trashed && sortBy === 'sortOrder' && !q && filters.length === 0 && pageSize === 'all'
218
+ const bulkActions = trashed
219
+ ? [
220
+ { key: 'restore', label: 'Restore', icon: 'restore' as const, onRun: (selected: Row[]) => void bulkRun(selected, 'restore') },
221
+ { key: 'delete', label: 'Delete permanently', icon: 'trash' as const, danger: true, onRun: (selected: Row[]) => void bulkRun(selected, 'delete') },
222
+ ]
223
+ : [
224
+ ...(config.workflow?.publish && canPublish ? [
225
+ { key: 'publish', label: 'Publish', icon: 'publish' as const, onRun: (selected: Row[]) => void bulkRun(selected, 'publish') },
226
+ { key: 'unpublish', label: 'Unpublish', icon: 'unpublish' as const, onRun: (selected: Row[]) => void bulkRun(selected, 'unpublish') },
227
+ ] : []),
228
+ ...(canDelete ? [{ key: config.workflow?.trash ? 'trash' : 'delete', label: config.workflow?.trash ? 'Move to trash' : 'Delete', icon: 'trash' as const, danger: true, onRun: (selected: Row[]) => void bulkRun(selected, config.workflow?.trash ? 'trash' : 'delete') }] : []),
229
+ ]
230
+
231
+ return <main className="cms-dashboard"><CmsSidebar active="collections" userName={userName} /><section className="cms-content">
232
+ <EditorHeader compact title={config.label} meta={error || `${total} ${trashed ? 'in trash' : config.label.toLowerCase()}`} actions={<>
233
+ {config.workflow?.trash && <div className="cms-tabs cms-tabs-inline"><button className={!trashed ? 'active' : ''} onClick={() => switchTab(false)}>Active</button><button className={trashed ? 'active' : ''} onClick={() => switchTab(true)}>Trash</button></div>}
234
+ {canCreate && <Button variant="positive" href={`/admin/collections/${slug}/new`} icon="↗">Add {config.labelSingular.toLowerCase()}</Button>}
235
+ </>} />
236
+ <div className="cms-filter-bar">
237
+ <input ref={searchRef} className="cms-search-input" value={qInput} onChange={(event) => setQInput(event.target.value)} placeholder="Search… (press /)" />
238
+ {!trashed && filterFields.length > 0 && <FilterBuilder fields={filterFields} rules={filters} onAdd={addFilter} onRemove={removeFilter} />}
239
+ {reorderable && <span className="cms-reorder-hint">Drag order active — use ↑↓ to reorder</span>}
240
+ <Button type="button" variant="secondary" onClick={() => listingRef.current?.openSettings()} aria-label="View settings">⚙ View settings</Button>
241
+ </div>
242
+ <ListingView
243
+ ref={listingRef}
244
+ items={items}
245
+ total={total}
246
+ loading={loading}
247
+ getId={(item) => String(item.id)}
248
+ storageKey={`collection-${slug}`}
249
+ columnLabels={contentViewLabels(contentView)}
250
+ resetSelectionKey={trashed ? 'trash' : 'active'}
251
+ emptyLabel={trashed ? 'Trash is empty.' : (q || filters.length) ? `No ${config.label.toLowerCase()} match your search or filters.` : `No ${config.label.toLowerCase()} yet.`}
252
+ sortBy={sortBy}
253
+ sortDir={sortDir}
254
+ onSortChange={handleSort}
255
+ page={page}
256
+ pageSize={pageSize}
257
+ onPageChange={setPage}
258
+ onPageSizeChange={(size) => { setPageSize(size); setPage(1) }}
259
+ reorderable={reorderable}
260
+ onMove={move}
261
+ columns={allColumns}
262
+ card={(item) => ({ image: imageField && typeof item[imageField.key] === 'string' ? String(item[imageField.key]) : null, title: formatFieldValue(config.fields.find((field) => field.key === titleField)!, item[titleField]) || 'Untitled', meta: String(item.id).slice(0, 8), published: config.workflow?.publish ? Boolean(item.published) : true, badge: trashed ? <span className="cms-status">Trashed</span> : undefined })}
263
+ bulkActions={bulkActions.length ? bulkActions : undefined}
264
+ renderActions={trashed
265
+ ? (item) => <RowActionsMenu items={[
266
+ { label: 'Restore', icon: 'restore', onClick: () => void restoreOne(String(item.id)) },
267
+ ...(canDelete ? [{ label: 'Delete permanently', icon: 'trash' as const, danger: true, onClick: () => void destroyOne(String(item.id)) }] : []),
268
+ ]} />
269
+ : (item) => <RowActionsMenu items={[
270
+ { label: 'Edit', icon: 'edit', href: `/admin/collections/${slug}/${String(item.id)}` },
271
+ ...(config.workflow?.duplicate && canCreate ? [{ label: 'Duplicate', icon: 'duplicate' as const, onClick: () => void duplicateOne(String(item.id)) }] : []),
272
+ ...(config.workflow?.publish && canPublish ? [{ label: Boolean(item.published) ? 'Unpublish' : 'Publish', icon: Boolean(item.published) ? 'unpublish' as const : 'publish' as const, onClick: () => void togglePublish(String(item.id), !Boolean(item.published)) }] : []),
273
+ ...(config.previewPath && Boolean(item.published) ? [{ label: 'Preview', icon: 'preview' as const, href: resolvePreviewPath(config.previewPath, item), newTab: true }] : []),
274
+ ...(canDelete ? [{ label: config.workflow?.trash ? 'Move to trash' : 'Delete', icon: 'trash' as const, danger: true, onClick: () => void deleteOne(String(item.id)) }] : []),
275
+ ]} />}
276
+ />
277
+ </section></main>
278
+ }