kilo-cms 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -0
- package/drizzle.kilo.config.ts +17 -0
- package/package.json +97 -0
- package/src/admin/AiSettingsEditor.tsx +48 -0
- package/src/admin/AiWritingAssistant.tsx +159 -0
- package/src/admin/ApiTokensEditor.tsx +69 -0
- package/src/admin/ArrayField.tsx +118 -0
- package/src/admin/BlocksField.tsx +131 -0
- package/src/admin/Button.tsx +31 -0
- package/src/admin/CloseButton.tsx +18 -0
- package/src/admin/CmsLocalesProvider.tsx +19 -0
- package/src/admin/CmsSidebar.tsx +147 -0
- package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
- package/src/admin/CmsThemeProvider.tsx +100 -0
- package/src/admin/CollectionListEditor.tsx +278 -0
- package/src/admin/CollectionRecordEditor.tsx +420 -0
- package/src/admin/ContentEditorForm.tsx +21 -0
- package/src/admin/ContentViewSettings.tsx +55 -0
- package/src/admin/DashboardBuilder.tsx +284 -0
- package/src/admin/DragHandle.tsx +3 -0
- package/src/admin/DynamicField.tsx +517 -0
- package/src/admin/EditorHeader.tsx +13 -0
- package/src/admin/EditorSkeleton.tsx +27 -0
- package/src/admin/EmailSettingsEditor.tsx +80 -0
- package/src/admin/Field.tsx +215 -0
- package/src/admin/FilterBuilder.tsx +62 -0
- package/src/admin/GroupField.tsx +61 -0
- package/src/admin/ImageUploader.tsx +198 -0
- package/src/admin/ListingSettingsModal.tsx +68 -0
- package/src/admin/ListingView.tsx +123 -0
- package/src/admin/LocalesEditor.tsx +86 -0
- package/src/admin/LocalizedField.tsx +46 -0
- package/src/admin/MapPicker.tsx +53 -0
- package/src/admin/MediaLibrary.tsx +483 -0
- package/src/admin/MediaUploader.tsx +55 -0
- package/src/admin/Portal.tsx +10 -0
- package/src/admin/PublishingPanel.tsx +102 -0
- package/src/admin/RecordMeta.tsx +24 -0
- package/src/admin/ReviewQueueEditor.tsx +103 -0
- package/src/admin/RichTextEditor.tsx +49 -0
- package/src/admin/RoleEditor.tsx +121 -0
- package/src/admin/RolesEditor.tsx +42 -0
- package/src/admin/RowActionsMenu.tsx +64 -0
- package/src/admin/ScheduledEditor.tsx +155 -0
- package/src/admin/SeoSettingsEditor.tsx +53 -0
- package/src/admin/SingleRecordEditor.tsx +215 -0
- package/src/admin/SiteSettingsEditor.tsx +96 -0
- package/src/admin/Toast.tsx +45 -0
- package/src/admin/UserEditor.tsx +64 -0
- package/src/admin/UsersEditor.tsx +37 -0
- package/src/admin/WebhooksEditor.tsx +115 -0
- package/src/admin/admin.css +6637 -0
- package/src/admin/globals.css +14 -0
- package/src/admin/pages/admin-index.tsx +5 -0
- package/src/admin/pages/ai-settings.tsx +9 -0
- package/src/admin/pages/api-tokens.tsx +10 -0
- package/src/admin/pages/collection-list.tsx +22 -0
- package/src/admin/pages/collection-record.tsx +31 -0
- package/src/admin/pages/dashboard.tsx +10 -0
- package/src/admin/pages/email-settings.tsx +10 -0
- package/src/admin/pages/layout.tsx +24 -0
- package/src/admin/pages/locales-settings.tsx +10 -0
- package/src/admin/pages/login.tsx +25 -0
- package/src/admin/pages/media.tsx +9 -0
- package/src/admin/pages/review-queue.tsx +20 -0
- package/src/admin/pages/role-edit.tsx +11 -0
- package/src/admin/pages/roles.tsx +10 -0
- package/src/admin/pages/scheduled.tsx +17 -0
- package/src/admin/pages/seo-settings.tsx +9 -0
- package/src/admin/pages/setup-admin.tsx +76 -0
- package/src/admin/pages/single-record.tsx +24 -0
- package/src/admin/pages/site-settings.tsx +9 -0
- package/src/admin/pages/user-edit.tsx +11 -0
- package/src/admin/pages/user-new.tsx +10 -0
- package/src/admin/pages/users.tsx +10 -0
- package/src/admin/pages/webhooks-settings.tsx +10 -0
- package/src/admin/useKeyedRows.ts +35 -0
- package/src/ai-config.ts +35 -0
- package/src/ai-secrets.ts +17 -0
- package/src/ai-types.ts +10 -0
- package/src/ai-writer.ts +84 -0
- package/src/api-tokens.ts +25 -0
- package/src/auth-client.ts +5 -0
- package/src/auth.ts +33 -0
- package/src/calendar-grid.ts +12 -0
- package/src/cli/index.mjs +149 -0
- package/src/cms-appearance.ts +55 -0
- package/src/collections/content-view.ts +69 -0
- package/src/collections/define.ts +17 -0
- package/src/collections/filters.ts +92 -0
- package/src/collections/index.ts +11 -0
- package/src/collections/join.ts +64 -0
- package/src/collections/locale.ts +22 -0
- package/src/collections/query.ts +160 -0
- package/src/collections/registry.ts +88 -0
- package/src/collections/schedule.ts +12 -0
- package/src/collections/server.ts +12 -0
- package/src/collections/single.ts +26 -0
- package/src/collections/table.ts +14 -0
- package/src/collections/types.ts +336 -0
- package/src/collections/validation.ts +217 -0
- package/src/collections/values.ts +181 -0
- package/src/collections/workflow.ts +90 -0
- package/src/config.ts +34 -0
- package/src/content-types.ts +218 -0
- package/src/dashboard.ts +39 -0
- package/src/db.ts +50 -0
- package/src/email.ts +39 -0
- package/src/format.ts +4 -0
- package/src/listQuery.ts +23 -0
- package/src/locale-settings.ts +23 -0
- package/src/media-storage.ts +19 -0
- package/src/permissions.ts +37 -0
- package/src/publishing-workflow-db.ts +11 -0
- package/src/publishing-workflow.ts +20 -0
- package/src/richtext.ts +5 -0
- package/src/routes/admin/ai/settings/route.ts +20 -0
- package/src/routes/admin/ai/write/route.ts +21 -0
- package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
- package/src/routes/admin/api-tokens/route.ts +26 -0
- package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
- package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
- package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
- package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
- package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
- package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
- package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
- package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/route.ts +88 -0
- package/src/routes/admin/collections/route.ts +13 -0
- package/src/routes/admin/content-views/[type]/route.ts +25 -0
- package/src/routes/admin/dashboard/data/route.ts +39 -0
- package/src/routes/admin/dashboard/route.ts +30 -0
- package/src/routes/admin/email-settings/route.ts +49 -0
- package/src/routes/admin/email-settings/test/route.ts +18 -0
- package/src/routes/admin/locales/[code]/route.ts +38 -0
- package/src/routes/admin/locales/reorder/route.ts +18 -0
- package/src/routes/admin/locales/route.ts +46 -0
- package/src/routes/admin/media/[id]/restore/route.ts +13 -0
- package/src/routes/admin/media/[id]/route.ts +56 -0
- package/src/routes/admin/media/bulk/route.ts +46 -0
- package/src/routes/admin/media/folders/[id]/route.ts +34 -0
- package/src/routes/admin/media/route.ts +41 -0
- package/src/routes/admin/media/stats/route.ts +14 -0
- package/src/routes/admin/roles/[id]/route.ts +43 -0
- package/src/routes/admin/roles/route.ts +34 -0
- package/src/routes/admin/settings/route.ts +45 -0
- package/src/routes/admin/single/[type]/route.ts +54 -0
- package/src/routes/admin/singles/route.ts +15 -0
- package/src/routes/admin/upload/route.ts +55 -0
- package/src/routes/admin/users/[id]/route.ts +24 -0
- package/src/routes/admin/users/route.ts +24 -0
- package/src/routes/admin/webhooks/[id]/route.ts +38 -0
- package/src/routes/admin/webhooks/route.ts +26 -0
- package/src/routes/cron/publish-scheduled.ts +37 -0
- package/src/routes/setup/admin.ts +19 -0
- package/src/schema/admin-ui.ts +18 -0
- package/src/schema/auth.ts +49 -0
- package/src/schema/developer-tools.ts +44 -0
- package/src/schema/helpers.ts +35 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/locales.ts +13 -0
- package/src/schema/media.ts +22 -0
- package/src/schema/rbac.ts +11 -0
- package/src/schema/settings.ts +28 -0
- package/src/secrets.ts +26 -0
- package/src/seo.ts +69 -0
- package/src/storage/index.ts +20 -0
- package/src/storage/local.ts +27 -0
- package/src/storage/s3-compatible.ts +31 -0
- package/src/storage/types.ts +8 -0
- package/src/storage/vercel-blob.ts +17 -0
- package/src/useBodyScrollLock.ts +12 -0
- package/src/useLocalStorageState.ts +26 -0
- package/src/webhook-events.ts +12 -0
- package/src/webhooks.ts +30 -0
|
@@ -0,0 +1,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
|
+
}
|