@nuasite/collections-admin 0.43.0-beta.1 → 0.43.0-beta.2
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/dist/types/app.d.ts +6 -6
- package/dist/types/app.d.ts.map +1 -1
- package/dist/types/client.d.ts +78 -4
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/entry-create.d.ts +18 -0
- package/dist/types/entry-create.d.ts.map +1 -0
- package/dist/types/entry-editor.d.ts +30 -0
- package/dist/types/entry-editor.d.ts.map +1 -0
- package/dist/types/field-editor.d.ts +31 -0
- package/dist/types/field-editor.d.ts.map +1 -0
- package/dist/types/form-model.d.ts +61 -0
- package/dist/types/form-model.d.ts.map +1 -0
- package/dist/types/index.d.ts +7 -5
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/media-picker.d.ts +24 -0
- package/dist/types/media-picker.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/app.tsx +79 -66
- package/src/client.ts +217 -28
- package/src/entry-create.tsx +116 -0
- package/src/entry-editor.tsx +346 -0
- package/src/field-editor.tsx +341 -0
- package/src/form-model.ts +182 -0
- package/src/index.ts +12 -4
- package/src/media-picker.tsx +130 -0
- package/src/styles.css +381 -0
- package/src/field-view.tsx +0 -88
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure draft model + field coercion for the entry editor (cms-headless F3.2).
|
|
3
|
+
*
|
|
4
|
+
* The sidecar speaks two slightly different frontmatter shapes:
|
|
5
|
+
* - `GET …/entries/:slug` returns `frontmatter: Record<string, { value: string; line: number }>`,
|
|
6
|
+
* where `value` is already stringified (objects/arrays are JSON).
|
|
7
|
+
* - `PATCH …` accepts `frontmatter?: Record<string, unknown>` of *native* values (merged), and a
|
|
8
|
+
* `409` `serverFrontmatter` is likewise native (not stringified).
|
|
9
|
+
*
|
|
10
|
+
* The editor works on a single native draft (`EntryDraft`): `frontmatter` is a
|
|
11
|
+
* `Record<string, unknown>` of native JS values keyed by field name, plus the
|
|
12
|
+
* markdown `body`. This module converts to/from the wire and coerces raw input
|
|
13
|
+
* (form strings) into the native value a `FieldType` expects. Keeping it pure
|
|
14
|
+
* (no React/DOM) makes the mapping unit-testable.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { CollectionEntry, FieldDefinition, FieldType } from '@nuasite/cms-types'
|
|
18
|
+
|
|
19
|
+
/** The editor's in-memory state: native frontmatter values + the markdown body. */
|
|
20
|
+
export interface EntryDraft {
|
|
21
|
+
frontmatter: Record<string, unknown>
|
|
22
|
+
body: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse one stringified frontmatter `value` (from `GET …/entries/:slug`) into the
|
|
31
|
+
* native value a field of `type` expects. Structural types (object/array) and
|
|
32
|
+
* unknowns fall back to a best-effort `JSON.parse`; scalars are coerced per type.
|
|
33
|
+
*/
|
|
34
|
+
export function parseWireValue(type: FieldType, raw: string): unknown {
|
|
35
|
+
switch (type) {
|
|
36
|
+
case 'boolean':
|
|
37
|
+
return raw === 'true' || raw === '1' || raw.toLowerCase() === 'yes'
|
|
38
|
+
case 'number':
|
|
39
|
+
case 'year':
|
|
40
|
+
case 'month': {
|
|
41
|
+
const n = Number(raw)
|
|
42
|
+
return raw.trim() === '' || Number.isNaN(n) ? raw : n
|
|
43
|
+
}
|
|
44
|
+
case 'array':
|
|
45
|
+
case 'object':
|
|
46
|
+
return parseJsonLoose(raw)
|
|
47
|
+
default:
|
|
48
|
+
return raw
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `JSON.parse` for structural values, falling back to the raw string when invalid. */
|
|
53
|
+
function parseJsonLoose(raw: string): unknown {
|
|
54
|
+
const trimmed = raw.trim()
|
|
55
|
+
if (trimmed === '') return undefined
|
|
56
|
+
if (!(trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed.startsWith('"'))) return raw
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(trimmed)
|
|
59
|
+
} catch {
|
|
60
|
+
return raw
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build a native draft from a loaded entry, driven by the collection's fields.
|
|
66
|
+
* Frontmatter keys present on the entry but absent from the inferred schema are
|
|
67
|
+
* preserved verbatim (as raw strings) so a save never silently drops them.
|
|
68
|
+
*/
|
|
69
|
+
export function draftFromEntry(entry: CollectionEntry, fields: FieldDefinition[]): EntryDraft {
|
|
70
|
+
const byName = new Map(fields.map(f => [f.name, f] as const))
|
|
71
|
+
const frontmatter: Record<string, unknown> = {}
|
|
72
|
+
for (const [key, cell] of Object.entries(entry.frontmatter)) {
|
|
73
|
+
const field = byName.get(key)
|
|
74
|
+
frontmatter[key] = field ? parseWireValue(field.type, cell.value) : cell.value
|
|
75
|
+
}
|
|
76
|
+
return { frontmatter, body: entry.body }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build a fresh draft for a create form from the collection's fields, seeding
|
|
81
|
+
* each field with its `defaultValue` (when present) or a type-appropriate blank.
|
|
82
|
+
*/
|
|
83
|
+
export function draftForCreate(fields: FieldDefinition[]): EntryDraft {
|
|
84
|
+
const frontmatter: Record<string, unknown> = {}
|
|
85
|
+
for (const field of fields) {
|
|
86
|
+
if (field.hidden) continue
|
|
87
|
+
if (field.defaultValue !== undefined) {
|
|
88
|
+
frontmatter[field.name] = field.defaultValue
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
frontmatter[field.name] = blankValue(field.type)
|
|
92
|
+
}
|
|
93
|
+
return { frontmatter, body: '' }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A type-appropriate empty value used to seed create forms. */
|
|
97
|
+
export function blankValue(type: FieldType): unknown {
|
|
98
|
+
switch (type) {
|
|
99
|
+
case 'boolean':
|
|
100
|
+
return false
|
|
101
|
+
case 'array':
|
|
102
|
+
return []
|
|
103
|
+
case 'object':
|
|
104
|
+
return {}
|
|
105
|
+
default:
|
|
106
|
+
return ''
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Adopt a server-provided native frontmatter map (from a `409` `serverFrontmatter`)
|
|
112
|
+
* into a draft, re-coercing per field where a definition exists.
|
|
113
|
+
*/
|
|
114
|
+
export function draftFromServerFrontmatter(
|
|
115
|
+
serverFrontmatter: Record<string, unknown>,
|
|
116
|
+
serverBody: string | undefined,
|
|
117
|
+
fields: FieldDefinition[],
|
|
118
|
+
): EntryDraft {
|
|
119
|
+
const byName = new Map(fields.map(f => [f.name, f] as const))
|
|
120
|
+
const frontmatter: Record<string, unknown> = {}
|
|
121
|
+
for (const [key, value] of Object.entries(serverFrontmatter)) {
|
|
122
|
+
const field = byName.get(key)
|
|
123
|
+
// Server values are already native; only re-coerce when the value arrived as
|
|
124
|
+
// a string for a numeric/boolean field (e.g. YAML quirks).
|
|
125
|
+
frontmatter[key] = field && typeof value === 'string' ? parseWireValue(field.type, value) : value
|
|
126
|
+
}
|
|
127
|
+
return { frontmatter, body: serverBody ?? '' }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Coerce a raw form-control string into the native value a field expects. Used by
|
|
132
|
+
* the widgets, whose `<input>` values are always strings.
|
|
133
|
+
*/
|
|
134
|
+
export function coerceInput(type: FieldType, raw: string): unknown {
|
|
135
|
+
switch (type) {
|
|
136
|
+
case 'boolean':
|
|
137
|
+
return raw === 'true'
|
|
138
|
+
case 'number': {
|
|
139
|
+
if (raw.trim() === '') return undefined
|
|
140
|
+
const n = Number(raw)
|
|
141
|
+
return Number.isNaN(n) ? raw : n
|
|
142
|
+
}
|
|
143
|
+
case 'year':
|
|
144
|
+
case 'month': {
|
|
145
|
+
if (raw.trim() === '') return undefined
|
|
146
|
+
const n = Number(raw)
|
|
147
|
+
return Number.isNaN(n) ? raw : n
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
return raw
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Render a native value back to a string for a text/number/date/select control. */
|
|
155
|
+
export function valueToInput(value: unknown): string {
|
|
156
|
+
if (value === undefined || value === null) return ''
|
|
157
|
+
if (typeof value === 'string') return value
|
|
158
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
159
|
+
return JSON.stringify(value)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Read a value as a boolean for toggle widgets, tolerating string encodings. */
|
|
163
|
+
export function valueToBoolean(value: unknown): boolean {
|
|
164
|
+
if (typeof value === 'boolean') return value
|
|
165
|
+
if (typeof value === 'string') return value === 'true' || value === '1' || value.toLowerCase() === 'yes'
|
|
166
|
+
return Boolean(value)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Read a value as an array of items for repeater widgets. */
|
|
170
|
+
export function valueToArray(value: unknown): unknown[] {
|
|
171
|
+
return Array.isArray(value) ? value : []
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Read a value as an object for nested-group widgets. */
|
|
175
|
+
export function valueToObject(value: unknown): Record<string, unknown> {
|
|
176
|
+
return isRecord(value) ? value : {}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Immutably set a top-level frontmatter key in a draft. */
|
|
180
|
+
export function setDraftField(draft: EntryDraft, name: string, value: unknown): EntryDraft {
|
|
181
|
+
return { ...draft, frontmatter: { ...draft.frontmatter, [name]: value } }
|
|
182
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nuasite/collections-admin` —
|
|
3
|
-
*
|
|
4
|
-
* `<CollectionsAdminApp apiBase={…} />` and it drives its own internal
|
|
5
|
-
*
|
|
2
|
+
* `@nuasite/collections-admin` — collections SPA over the cms-sidecar `/cms/v1`
|
|
3
|
+
* HTTP contract (cms-headless F3.1 read-only + F3.2 editing). Host-agnostic:
|
|
4
|
+
* mount `<CollectionsAdminApp apiBase={…} />` and it drives its own internal
|
|
5
|
+
* view-state navigation (list → entries → editor/create), debounced optimistic
|
|
6
|
+
* save and `409` conflict resolution. Self-contained styles ship at `./styles.css`.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
export { CollectionsAdminApp, type CollectionsAdminAppProps } from './app'
|
|
@@ -15,10 +16,17 @@ export {
|
|
|
15
16
|
type CmsCapabilities,
|
|
16
17
|
type CmsClient,
|
|
17
18
|
CmsClientError,
|
|
19
|
+
type CmsConflict,
|
|
18
20
|
type CmsEntriesListResult,
|
|
19
21
|
type CmsErrorCode,
|
|
20
22
|
type CmsPageEntry,
|
|
21
23
|
type CmsProjectModel,
|
|
22
24
|
createClient,
|
|
25
|
+
type CreateEntryInput,
|
|
23
26
|
type GetEntriesOptions,
|
|
27
|
+
isMediaUnavailable,
|
|
28
|
+
type MediaContext,
|
|
29
|
+
type UpdateEntryInput,
|
|
30
|
+
type UpdateEntryResult,
|
|
24
31
|
} from './client'
|
|
32
|
+
export type { EntryDraft } from './form-model'
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media picker widget for `image`/`file`/`astroImage` fields (cms-headless F3.2).
|
|
3
|
+
*
|
|
4
|
+
* Holds a single URL string value. Lets the user paste/clear a URL and (when the
|
|
5
|
+
* sidecar has a media adapter) upload a file via `POST …/media`.
|
|
6
|
+
*
|
|
7
|
+
* Graceful degradation: the deployed sidecar may have NO media adapter wired and
|
|
8
|
+
* answers media routes with `501 unsupported`. The picker probes once via
|
|
9
|
+
* `listMedia`; on `unsupported`/`501` it disables upload and shows a hint while
|
|
10
|
+
* keeping the manual URL field fully usable — the editor is never blocked on media.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useEffect, useRef, useState } from 'react'
|
|
14
|
+
import { type CmsClient, isMediaUnavailable } from './client'
|
|
15
|
+
|
|
16
|
+
interface MediaPickerProps {
|
|
17
|
+
client: CmsClient
|
|
18
|
+
value: string
|
|
19
|
+
collection: string
|
|
20
|
+
entry?: string
|
|
21
|
+
field: string
|
|
22
|
+
accept?: string
|
|
23
|
+
onChange: (url: string) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type MediaState =
|
|
27
|
+
| { kind: 'probing' }
|
|
28
|
+
| { kind: 'ready' }
|
|
29
|
+
| { kind: 'unavailable' }
|
|
30
|
+
| { kind: 'uploading' }
|
|
31
|
+
| { kind: 'error'; message: string }
|
|
32
|
+
|
|
33
|
+
export function MediaPicker({ client, value, collection, entry, field, accept, onChange }: MediaPickerProps) {
|
|
34
|
+
const [state, setState] = useState<MediaState>({ kind: 'probing' })
|
|
35
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
36
|
+
|
|
37
|
+
// Probe media availability once. We don't render the gallery (out of scope for
|
|
38
|
+
// F3.2) — we only need to know whether uploads are supported.
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
let active = true
|
|
41
|
+
client.listMedia({ limit: 1 }).then(
|
|
42
|
+
() => {
|
|
43
|
+
if (active) setState({ kind: 'ready' })
|
|
44
|
+
},
|
|
45
|
+
(err: unknown) => {
|
|
46
|
+
if (!active) return
|
|
47
|
+
setState(isMediaUnavailable(err) ? { kind: 'unavailable' } : { kind: 'ready' })
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
return () => {
|
|
51
|
+
active = false
|
|
52
|
+
}
|
|
53
|
+
}, [client])
|
|
54
|
+
|
|
55
|
+
const onFile = async (file: File) => {
|
|
56
|
+
setState({ kind: 'uploading' })
|
|
57
|
+
try {
|
|
58
|
+
const result = await client.uploadMedia(file, { collection, entry, field })
|
|
59
|
+
if (result.success && result.url) {
|
|
60
|
+
onChange(result.url)
|
|
61
|
+
setState({ kind: 'ready' })
|
|
62
|
+
} else {
|
|
63
|
+
setState({ kind: 'error', message: result.error ?? 'Upload failed' })
|
|
64
|
+
}
|
|
65
|
+
} catch (err: unknown) {
|
|
66
|
+
if (isMediaUnavailable(err)) {
|
|
67
|
+
setState({ kind: 'unavailable' })
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
setState({ kind: 'error', message: err instanceof Error ? err.message : 'Upload failed' })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const looksLikeUrl = value !== '' && /^(https?:\/\/|\/)/.test(value)
|
|
75
|
+
const canUpload = state.kind === 'ready' || state.kind === 'error'
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="nua-cadmin-media">
|
|
79
|
+
{looksLikeUrl ? <img className="nua-cadmin-img" src={value} alt="" /> : null}
|
|
80
|
+
<div className="nua-cadmin-media-row">
|
|
81
|
+
<input
|
|
82
|
+
type="text"
|
|
83
|
+
className="nua-cadmin-input"
|
|
84
|
+
value={value}
|
|
85
|
+
placeholder="Image URL or path"
|
|
86
|
+
onChange={e => onChange(e.target.value)}
|
|
87
|
+
/>
|
|
88
|
+
{value !== ''
|
|
89
|
+
? (
|
|
90
|
+
<button type="button" className="nua-cadmin-icon-btn" aria-label="Clear" onClick={() => onChange('')}>
|
|
91
|
+
×
|
|
92
|
+
</button>
|
|
93
|
+
)
|
|
94
|
+
: null}
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{state.kind === 'uploading' ? <div className="nua-cadmin-field-loading">Uploading…</div> : null}
|
|
98
|
+
{state.kind === 'unavailable' ? <div className="nua-cadmin-media-hint">Media uploads unavailable — paste a URL or path instead.</div> : null}
|
|
99
|
+
{state.kind === 'error' ? <div className="nua-cadmin-media-error">{state.message}</div> : null}
|
|
100
|
+
|
|
101
|
+
{canUpload || state.kind === 'probing'
|
|
102
|
+
? (
|
|
103
|
+
<>
|
|
104
|
+
<input
|
|
105
|
+
ref={fileInputRef}
|
|
106
|
+
type="file"
|
|
107
|
+
accept={accept}
|
|
108
|
+
className="nua-cadmin-file-input"
|
|
109
|
+
disabled={!canUpload}
|
|
110
|
+
onChange={e => {
|
|
111
|
+
const file = e.target.files?.[0]
|
|
112
|
+
if (file) void onFile(file)
|
|
113
|
+
// Reset so re-selecting the same file fires `change` again.
|
|
114
|
+
e.target.value = ''
|
|
115
|
+
}}
|
|
116
|
+
/>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
className="nua-cadmin-add-btn"
|
|
120
|
+
disabled={!canUpload}
|
|
121
|
+
onClick={() => fileInputRef.current?.click()}
|
|
122
|
+
>
|
|
123
|
+
Upload file
|
|
124
|
+
</button>
|
|
125
|
+
</>
|
|
126
|
+
)
|
|
127
|
+
: null}
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|