@nuasite/collections-admin 0.43.0-beta.1 → 0.43.0-beta.3

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.
@@ -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` — read-only collections SPA over the cms-sidecar
3
- * `/cms/v1` HTTP contract (cms-headless F3.1). Host-agnostic: mount
4
- * `<CollectionsAdminApp apiBase={…} />` and it drives its own internal view-state
5
- * navigation. Self-contained styles ship at `./styles.css` (imported by the app).
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
+ }