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,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='© <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
|
+
}
|