@nuasite/cms 0.5.1 → 0.6.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 +11 -11
- package/dist/editor.js +4593 -4449
- package/package.json +1 -1
- package/src/build-processor.ts +38 -1
- package/src/dev-middleware.ts +47 -2
- package/src/editor/components/collections-browser.tsx +10 -6
- package/src/editor/components/fields.tsx +7 -7
- package/src/editor/components/frontmatter-fields.tsx +163 -2
- package/src/editor/components/seo-editor.tsx +2 -1
- package/src/editor/components/toast/toast.tsx +19 -2
- package/src/editor/editor.ts +16 -17
- package/src/editor/index.tsx +4 -2
- package/src/editor/types.ts +2 -0
- package/src/handlers/array-ops.ts +102 -0
- package/src/index.ts +1 -1
- package/src/source-finder/cross-file-tracker.ts +42 -0
- package/src/source-finder/element-finder.ts +18 -4
- package/src/source-finder/types.ts +3 -0
- package/src/source-finder/variable-extraction.ts +2 -2
package/package.json
CHANGED
package/src/build-processor.ts
CHANGED
|
@@ -4,7 +4,8 @@ import fs from 'node:fs/promises'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
6
|
import { getProjectRoot } from './config'
|
|
7
|
-
import {
|
|
7
|
+
import { detectArrayPattern, extractArrayElementProps } from './handlers/array-ops'
|
|
8
|
+
import { extractPropsFromSource, findComponentInvocationLine, findFrontmatterEnd } from './handlers/component-ops'
|
|
8
9
|
import { extractComponentName, processHtml } from './html-processor'
|
|
9
10
|
import type { ManifestWriter } from './manifest-writer'
|
|
10
11
|
import { generateComponentPreviews } from './preview-generator'
|
|
@@ -640,6 +641,42 @@ async function processFile(
|
|
|
640
641
|
comp.props = extractPropsFromSource(pageLines, invLine, comp.componentName)
|
|
641
642
|
}
|
|
642
643
|
}
|
|
644
|
+
|
|
645
|
+
// Resolve spread props for array-rendered components
|
|
646
|
+
const componentGroups = new Map<string, typeof result.components[string][]>()
|
|
647
|
+
for (const comp of Object.values(result.components)) {
|
|
648
|
+
const key = `${comp.componentName}::${comp.invocationSourcePath ?? ''}`
|
|
649
|
+
if (!componentGroups.has(key)) componentGroups.set(key, [])
|
|
650
|
+
componentGroups.get(key)!.push(comp)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
for (const group of componentGroups.values()) {
|
|
654
|
+
if (group.length <= 1) continue
|
|
655
|
+
if (!group.some(c => Object.keys(c.props).length === 0)) continue
|
|
656
|
+
|
|
657
|
+
const firstComp = group[0]!
|
|
658
|
+
const invLine = findComponentInvocationLine(pageLines, firstComp.componentName, 0)
|
|
659
|
+
if (invLine < 0) continue
|
|
660
|
+
|
|
661
|
+
const pattern = detectArrayPattern(pageLines, invLine)
|
|
662
|
+
if (!pattern) continue
|
|
663
|
+
|
|
664
|
+
const fmEnd = findFrontmatterEnd(pageLines)
|
|
665
|
+
if (fmEnd === 0) continue
|
|
666
|
+
|
|
667
|
+
const frontmatterContent = pageLines.slice(1, fmEnd - 1).join('\n')
|
|
668
|
+
|
|
669
|
+
const sorted = [...group].sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
|
|
670
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
671
|
+
const comp = sorted[i]!
|
|
672
|
+
if (Object.keys(comp.props).length > 0) continue
|
|
673
|
+
|
|
674
|
+
const arrayProps = extractArrayElementProps(frontmatterContent, pattern.arrayVarName, i)
|
|
675
|
+
if (arrayProps) {
|
|
676
|
+
comp.props = arrayProps
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
643
680
|
} catch {
|
|
644
681
|
// Could not read page source — leave props empty
|
|
645
682
|
}
|
package/src/dev-middleware.ts
CHANGED
|
@@ -3,10 +3,11 @@ import fs from 'node:fs/promises'
|
|
|
3
3
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { getProjectRoot } from './config'
|
|
6
|
-
import { handleAddArrayItem, handleRemoveArrayItem } from './handlers/array-ops'
|
|
6
|
+
import { detectArrayPattern, extractArrayElementProps, handleAddArrayItem, handleRemoveArrayItem } from './handlers/array-ops'
|
|
7
7
|
import {
|
|
8
8
|
extractPropsFromSource,
|
|
9
9
|
findComponentInvocationLine,
|
|
10
|
+
findFrontmatterEnd,
|
|
10
11
|
getPageFileCandidates,
|
|
11
12
|
handleInsertComponent,
|
|
12
13
|
handleRemoveComponent,
|
|
@@ -429,7 +430,7 @@ async function handleCmsApiRoute(
|
|
|
429
430
|
|
|
430
431
|
// GET /_nua/cms/deployment/status
|
|
431
432
|
if (route === 'deployment/status' && req.method === 'GET') {
|
|
432
|
-
sendJson(res, { currentDeployment: null, pendingCount: 0 })
|
|
433
|
+
sendJson(res, { currentDeployment: null, pendingCount: 0, deploymentEnabled: false })
|
|
433
434
|
return
|
|
434
435
|
}
|
|
435
436
|
|
|
@@ -536,6 +537,50 @@ async function processHtmlForDev(
|
|
|
536
537
|
}
|
|
537
538
|
}
|
|
538
539
|
|
|
540
|
+
// Resolve spread props for array-rendered components.
|
|
541
|
+
// Group components by (name, invocationSourcePath) to detect array patterns.
|
|
542
|
+
const componentGroups = new Map<string, typeof result.components[string][]>()
|
|
543
|
+
for (const comp of Object.values(result.components)) {
|
|
544
|
+
const key = `${comp.componentName}::${comp.invocationSourcePath ?? ''}`
|
|
545
|
+
if (!componentGroups.has(key)) componentGroups.set(key, [])
|
|
546
|
+
componentGroups.get(key)!.push(comp)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
for (const group of componentGroups.values()) {
|
|
550
|
+
if (group.length <= 1) continue
|
|
551
|
+
// Only process groups where at least one component has empty props (spread case)
|
|
552
|
+
if (!group.some(c => Object.keys(c.props).length === 0)) continue
|
|
553
|
+
|
|
554
|
+
const firstComp = group[0]!
|
|
555
|
+
const filePath = normalizeFilePath(firstComp.invocationSourcePath ?? firstComp.sourcePath)
|
|
556
|
+
const lines = await readLines(path.resolve(projectRoot, filePath))
|
|
557
|
+
if (!lines) continue
|
|
558
|
+
|
|
559
|
+
// Find the invocation line (occurrence 0, since .map() has a single <Component> tag)
|
|
560
|
+
const invLine = findComponentInvocationLine(lines, firstComp.componentName, 0)
|
|
561
|
+
if (invLine < 0) continue
|
|
562
|
+
|
|
563
|
+
const pattern = detectArrayPattern(lines, invLine)
|
|
564
|
+
if (!pattern) continue
|
|
565
|
+
|
|
566
|
+
const fmEnd = findFrontmatterEnd(lines)
|
|
567
|
+
if (fmEnd === 0) continue
|
|
568
|
+
|
|
569
|
+
const frontmatterContent = lines.slice(1, fmEnd - 1).join('\n')
|
|
570
|
+
|
|
571
|
+
// Sort group by invocationIndex to match array element order
|
|
572
|
+
const sorted = [...group].sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
|
|
573
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
574
|
+
const comp = sorted[i]!
|
|
575
|
+
if (Object.keys(comp.props).length > 0) continue
|
|
576
|
+
|
|
577
|
+
const arrayProps = extractArrayElementProps(frontmatterContent, pattern.arrayVarName, i)
|
|
578
|
+
if (arrayProps) {
|
|
579
|
+
comp.props = arrayProps
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
539
584
|
// Build collection entry if this is a collection page
|
|
540
585
|
let collectionEntry: CollectionEntry | undefined
|
|
541
586
|
if (collectionInfo && mdContent) {
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
closeCollectionsBrowser,
|
|
4
4
|
isCollectionsBrowserOpen,
|
|
5
5
|
manifest,
|
|
6
|
+
openMarkdownEditorForEntry,
|
|
6
7
|
openMarkdownEditorForNewPage,
|
|
7
8
|
selectBrowserCollection,
|
|
8
9
|
selectedBrowserCollection,
|
|
@@ -16,7 +17,7 @@ export function CollectionsBrowser() {
|
|
|
16
17
|
const collectionDefinitions = manifest.value.collectionDefinitions ?? {}
|
|
17
18
|
|
|
18
19
|
const collections = useMemo(() => {
|
|
19
|
-
return Object.values(collectionDefinitions)
|
|
20
|
+
return Object.values(collectionDefinitions).sort((a, b) => a.label.localeCompare(b.label))
|
|
20
21
|
}, [collectionDefinitions])
|
|
21
22
|
|
|
22
23
|
if (!visible) return null
|
|
@@ -38,11 +39,14 @@ export function CollectionsBrowser() {
|
|
|
38
39
|
|
|
39
40
|
const handleEntryClick = (slug: string, sourcePath: string, pathname?: string) => {
|
|
40
41
|
closeCollectionsBrowser()
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
if (pathname) {
|
|
43
|
+
// Navigate to the collection detail page to edit inline.
|
|
44
|
+
savePendingEntryNavigation({ collectionName: selected, slug, sourcePath, pathname })
|
|
45
|
+
window.location.href = pathname
|
|
46
|
+
} else {
|
|
47
|
+
// No detail page exists for this entry — open the markdown editor inline.
|
|
48
|
+
openMarkdownEditorForEntry(selected, slug, sourcePath, def)
|
|
49
|
+
}
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
const handleAddNew = () => {
|
|
@@ -54,7 +54,7 @@ export function TextField({ label, value, placeholder, onChange, isDirty, onRese
|
|
|
54
54
|
placeholder={placeholder}
|
|
55
55
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
56
56
|
class={cn(
|
|
57
|
-
'w-full px-3 py-2 bg-white/10 border rounded-cms-
|
|
57
|
+
'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
58
58
|
isDirty
|
|
59
59
|
? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
|
|
60
60
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
@@ -90,7 +90,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
|
|
|
90
90
|
placeholder={placeholder}
|
|
91
91
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
92
92
|
class={cn(
|
|
93
|
-
'flex-1 px-3 py-2 bg-white/10 border rounded-cms-
|
|
93
|
+
'flex-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
94
94
|
isDirty
|
|
95
95
|
? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
|
|
96
96
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
@@ -100,7 +100,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
|
|
|
100
100
|
<button
|
|
101
101
|
type="button"
|
|
102
102
|
onClick={onBrowse}
|
|
103
|
-
class="px-3 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-cms-
|
|
103
|
+
class="px-3 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-cms-sm text-sm text-white transition-colors cursor-pointer"
|
|
104
104
|
data-cms-ui
|
|
105
105
|
>
|
|
106
106
|
Browse
|
|
@@ -132,7 +132,7 @@ export function SelectField({ label, value, options, onChange, isDirty, onReset,
|
|
|
132
132
|
value={value ?? ''}
|
|
133
133
|
onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
|
|
134
134
|
class={cn(
|
|
135
|
-
'w-full px-3 py-2 bg-white/10 border rounded-cms-
|
|
135
|
+
'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white focus:outline-none focus:ring-1 transition-colors cursor-pointer',
|
|
136
136
|
isDirty
|
|
137
137
|
? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
|
|
138
138
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
@@ -220,7 +220,7 @@ export function NumberField({ label, value, placeholder, min, max, onChange, isD
|
|
|
220
220
|
onChange(val === '' ? undefined : Number(val))
|
|
221
221
|
}}
|
|
222
222
|
class={cn(
|
|
223
|
-
'w-full px-3 py-2 bg-white/10 border rounded-cms-
|
|
223
|
+
'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
224
224
|
isDirty
|
|
225
225
|
? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
|
|
226
226
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
@@ -341,7 +341,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
341
341
|
onKeyDown={handleKeyDown}
|
|
342
342
|
autocomplete="off"
|
|
343
343
|
class={cn(
|
|
344
|
-
'w-full px-3 py-2 bg-white/10 border rounded-cms-
|
|
344
|
+
'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
|
|
345
345
|
isDirty
|
|
346
346
|
? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
|
|
347
347
|
: 'border-white/20 focus:border-white/40 focus:ring-white/10',
|
|
@@ -351,7 +351,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
351
351
|
{showDropdown && (
|
|
352
352
|
<div
|
|
353
353
|
ref={listRef}
|
|
354
|
-
class="absolute z-50 left-0 right-0 mt-1 max-h-40 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-
|
|
354
|
+
class="absolute z-50 left-0 right-0 mt-1 max-h-40 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-sm shadow-lg"
|
|
355
355
|
data-cms-ui
|
|
356
356
|
>
|
|
357
357
|
{filtered.map((opt, i) => (
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useState } from 'preact/hooks'
|
|
1
2
|
import { markdownEditorState, openMediaLibraryWithCallback, updateMarkdownFrontmatter } from '../signals'
|
|
2
3
|
import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
|
|
3
4
|
import { ComboBoxField, ImageField, NumberField, TextField, ToggleField } from './fields'
|
|
@@ -87,6 +88,17 @@ export function FrontmatterField({
|
|
|
87
88
|
)
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
// Object field - render nested fields with add/remove
|
|
92
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
93
|
+
return (
|
|
94
|
+
<ObjectFields
|
|
95
|
+
label={label}
|
|
96
|
+
value={value as Record<string, unknown>}
|
|
97
|
+
onChange={onChange}
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
90
102
|
// String field (default) - check if it's a long text (excerpt, etc.)
|
|
91
103
|
const isLongText = fieldKey.toLowerCase().includes('excerpt')
|
|
92
104
|
|| fieldKey.toLowerCase().includes('description')
|
|
@@ -158,7 +170,7 @@ export function CreateModeFrontmatter({
|
|
|
158
170
|
}
|
|
159
171
|
}}
|
|
160
172
|
placeholder="url-friendly-slug"
|
|
161
|
-
class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-
|
|
173
|
+
class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
|
|
162
174
|
data-cms-ui
|
|
163
175
|
/>
|
|
164
176
|
<p class="mt-1 text-xs text-white/40">
|
|
@@ -214,7 +226,7 @@ export function EditModeFrontmatter({
|
|
|
214
226
|
<input
|
|
215
227
|
type="text"
|
|
216
228
|
value={page.slug}
|
|
217
|
-
class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-
|
|
229
|
+
class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white/50 focus:outline-none cursor-not-allowed"
|
|
218
230
|
disabled
|
|
219
231
|
data-cms-ui
|
|
220
232
|
/>
|
|
@@ -415,6 +427,32 @@ export function SchemaFrontmatterField({
|
|
|
415
427
|
)
|
|
416
428
|
}
|
|
417
429
|
|
|
430
|
+
case 'object': {
|
|
431
|
+
const obj = (value ?? {}) as Record<string, unknown>
|
|
432
|
+
const nestedFields = field.fields ?? []
|
|
433
|
+
if (nestedFields.length > 0) {
|
|
434
|
+
// Schema-defined nested fields + any extra keys from the actual value
|
|
435
|
+
const schemaNames = new Set(nestedFields.map((f) => f.name))
|
|
436
|
+
const extraKeys = Object.keys(obj).filter((k) => !schemaNames.has(k))
|
|
437
|
+
return (
|
|
438
|
+
<ObjectFields
|
|
439
|
+
label={label}
|
|
440
|
+
value={obj}
|
|
441
|
+
onChange={onChange}
|
|
442
|
+
schemaFields={nestedFields}
|
|
443
|
+
extraKeys={extraKeys}
|
|
444
|
+
/>
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
return (
|
|
448
|
+
<ObjectFields
|
|
449
|
+
label={label}
|
|
450
|
+
value={obj}
|
|
451
|
+
onChange={onChange}
|
|
452
|
+
/>
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
418
456
|
default:
|
|
419
457
|
return (
|
|
420
458
|
<div class="flex flex-col gap-1" data-cms-ui>
|
|
@@ -431,6 +469,129 @@ export function SchemaFrontmatterField({
|
|
|
431
469
|
}
|
|
432
470
|
}
|
|
433
471
|
|
|
472
|
+
// ============================================================================
|
|
473
|
+
// Object Fields — renders nested fields with add/remove key support
|
|
474
|
+
// ============================================================================
|
|
475
|
+
|
|
476
|
+
interface ObjectFieldsProps {
|
|
477
|
+
label: string
|
|
478
|
+
value: Record<string, unknown>
|
|
479
|
+
onChange: (value: unknown) => void
|
|
480
|
+
schemaFields?: FieldDefinition[]
|
|
481
|
+
extraKeys?: string[]
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function ObjectFields({ label, value, onChange, schemaFields, extraKeys }: ObjectFieldsProps) {
|
|
485
|
+
const [newKey, setNewKey] = useState('')
|
|
486
|
+
const obj = value ?? {}
|
|
487
|
+
|
|
488
|
+
const handleRemoveKey = (key: string) => {
|
|
489
|
+
const { [key]: _, ...rest } = obj
|
|
490
|
+
onChange(rest)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const handleAddKey = () => {
|
|
494
|
+
const trimmed = newKey.trim()
|
|
495
|
+
if (!trimmed || trimmed in obj) return
|
|
496
|
+
onChange({ ...obj, [trimmed]: '' })
|
|
497
|
+
setNewKey('')
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return (
|
|
501
|
+
<div class="flex flex-col gap-2 col-span-2" data-cms-ui>
|
|
502
|
+
<label class="text-xs text-white/60 font-medium">{label}</label>
|
|
503
|
+
<div class="space-y-2 pl-3 border-l-2 border-white/10">
|
|
504
|
+
{schemaFields
|
|
505
|
+
? (
|
|
506
|
+
<>
|
|
507
|
+
{schemaFields.map((subField) => (
|
|
508
|
+
<div key={subField.name} class="flex items-end gap-2">
|
|
509
|
+
<div class="flex-1 min-w-0">
|
|
510
|
+
<SchemaFrontmatterField
|
|
511
|
+
field={subField}
|
|
512
|
+
value={obj[subField.name]}
|
|
513
|
+
onChange={(newValue) => onChange({ ...obj, [subField.name]: newValue })}
|
|
514
|
+
/>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
))}
|
|
518
|
+
{(extraKeys ?? []).map((key) => (
|
|
519
|
+
<div key={key} class="flex items-end gap-2">
|
|
520
|
+
<div class="flex-1 min-w-0">
|
|
521
|
+
<FrontmatterField
|
|
522
|
+
fieldKey={key}
|
|
523
|
+
value={obj[key]}
|
|
524
|
+
onChange={(newValue) => onChange({ ...obj, [key]: newValue })}
|
|
525
|
+
/>
|
|
526
|
+
</div>
|
|
527
|
+
<button
|
|
528
|
+
type="button"
|
|
529
|
+
onClick={() => handleRemoveKey(key)}
|
|
530
|
+
class="p-1 mb-1 text-white/30 hover:text-red-400 transition-colors shrink-0"
|
|
531
|
+
title={`Remove ${key}`}
|
|
532
|
+
data-cms-ui
|
|
533
|
+
>
|
|
534
|
+
<RemoveIcon />
|
|
535
|
+
</button>
|
|
536
|
+
</div>
|
|
537
|
+
))}
|
|
538
|
+
</>
|
|
539
|
+
)
|
|
540
|
+
: Object.entries(obj).map(([key, subValue]) => (
|
|
541
|
+
<div key={key} class="flex items-end gap-2">
|
|
542
|
+
<div class="flex-1 min-w-0">
|
|
543
|
+
<FrontmatterField
|
|
544
|
+
fieldKey={key}
|
|
545
|
+
value={subValue}
|
|
546
|
+
onChange={(newValue) => onChange({ ...obj, [key]: newValue })}
|
|
547
|
+
/>
|
|
548
|
+
</div>
|
|
549
|
+
<button
|
|
550
|
+
type="button"
|
|
551
|
+
onClick={() => handleRemoveKey(key)}
|
|
552
|
+
class="p-1 mb-1 text-white/30 hover:text-red-400 transition-colors shrink-0"
|
|
553
|
+
title={`Remove ${key}`}
|
|
554
|
+
data-cms-ui
|
|
555
|
+
>
|
|
556
|
+
<RemoveIcon />
|
|
557
|
+
</button>
|
|
558
|
+
</div>
|
|
559
|
+
))
|
|
560
|
+
}
|
|
561
|
+
{/* Add new key */}
|
|
562
|
+
<div class="flex items-center gap-2 pt-1">
|
|
563
|
+
<input
|
|
564
|
+
type="text"
|
|
565
|
+
value={newKey}
|
|
566
|
+
onInput={(e) => setNewKey((e.target as HTMLInputElement).value)}
|
|
567
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddKey() } }}
|
|
568
|
+
placeholder="New field name..."
|
|
569
|
+
class="flex-1 px-2 py-1 text-xs bg-white/5 border border-white/10 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30"
|
|
570
|
+
data-cms-ui
|
|
571
|
+
/>
|
|
572
|
+
<button
|
|
573
|
+
type="button"
|
|
574
|
+
onClick={handleAddKey}
|
|
575
|
+
disabled={!newKey.trim() || newKey.trim() in obj}
|
|
576
|
+
class="px-2 py-1 text-xs font-medium text-white/60 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-cms-sm transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
577
|
+
data-cms-ui
|
|
578
|
+
>
|
|
579
|
+
+ Add
|
|
580
|
+
</button>
|
|
581
|
+
</div>
|
|
582
|
+
</div>
|
|
583
|
+
</div>
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function RemoveIcon() {
|
|
588
|
+
return (
|
|
589
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
590
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
591
|
+
</svg>
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
|
|
434
595
|
// ============================================================================
|
|
435
596
|
// Helper Functions
|
|
436
597
|
// ============================================================================
|
|
@@ -175,7 +175,8 @@ export function SeoEditor() {
|
|
|
175
175
|
})
|
|
176
176
|
|
|
177
177
|
if (result.errors && result.errors.length > 0) {
|
|
178
|
-
|
|
178
|
+
const details = result.errors.map(e => e.error).join('; ')
|
|
179
|
+
showToast(`SEO save failed: ${details}`, 'error')
|
|
179
180
|
} else {
|
|
180
181
|
showToast(`Saved ${result.updated} SEO change(s) successfully!`, 'success')
|
|
181
182
|
clearPendingSeoChanges()
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from 'preact/hooks'
|
|
1
|
+
import { useCallback, useEffect, useState } from 'preact/hooks'
|
|
2
2
|
import { TIMING } from '../../constants'
|
|
3
3
|
import type { ToastMessage } from './types'
|
|
4
4
|
|
|
@@ -8,8 +8,16 @@ interface ToastProps extends ToastMessage {
|
|
|
8
8
|
|
|
9
9
|
export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
|
|
10
10
|
const [isVisible, setIsVisible] = useState(true)
|
|
11
|
+
const persistent = type === 'error'
|
|
12
|
+
|
|
13
|
+
const dismiss = useCallback(() => {
|
|
14
|
+
setIsVisible(false)
|
|
15
|
+
setTimeout(() => onRemove(id), TIMING.TOAST_FADE_DURATION_MS)
|
|
16
|
+
}, [id, onRemove])
|
|
11
17
|
|
|
12
18
|
useEffect(() => {
|
|
19
|
+
if (persistent) return
|
|
20
|
+
|
|
13
21
|
const hideTimer = setTimeout(() => {
|
|
14
22
|
setIsVisible(false)
|
|
15
23
|
}, TIMING.TOAST_VISIBLE_DURATION_MS)
|
|
@@ -22,7 +30,7 @@ export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
|
|
|
22
30
|
clearTimeout(hideTimer)
|
|
23
31
|
clearTimeout(removeTimer)
|
|
24
32
|
}
|
|
25
|
-
}, [id, onRemove])
|
|
33
|
+
}, [id, onRemove, persistent])
|
|
26
34
|
|
|
27
35
|
const typeClasses = {
|
|
28
36
|
error: 'bg-cms-dark border-l-4 border-l-cms-error text-white',
|
|
@@ -44,6 +52,15 @@ export const Toast = ({ id, message, type, onRemove }: ToastProps) => {
|
|
|
44
52
|
{type === 'error' && <span class="text-cms-error text-lg">✕</span>}
|
|
45
53
|
{type === 'info' && <span class="w-2.5 h-2.5 rounded-full bg-white/50 shrink-0" />}
|
|
46
54
|
{message}
|
|
55
|
+
{persistent && (
|
|
56
|
+
<button
|
|
57
|
+
onClick={dismiss}
|
|
58
|
+
class="ml-1 text-white/60 hover:text-white transition-colors text-lg leading-none cursor-pointer"
|
|
59
|
+
aria-label="Dismiss"
|
|
60
|
+
>
|
|
61
|
+
✕
|
|
62
|
+
</button>
|
|
63
|
+
)}
|
|
47
64
|
</div>
|
|
48
65
|
)
|
|
49
66
|
}
|
package/src/editor/editor.ts
CHANGED
|
@@ -154,27 +154,13 @@ export async function startEditMode(
|
|
|
154
154
|
makeElementEditable(el)
|
|
155
155
|
|
|
156
156
|
// Suppress browser native contentEditable undo/redo (we handle it ourselves)
|
|
157
|
-
// Also
|
|
158
|
-
// default behavior which creates <div> elements with characters
|
|
157
|
+
// Also prevent Enter/Shift+Enter from inserting line breaks
|
|
159
158
|
el.addEventListener('beforeinput', (e) => {
|
|
160
159
|
if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
|
|
161
160
|
e.preventDefault()
|
|
162
161
|
}
|
|
163
|
-
if (e.inputType === 'insertParagraph') {
|
|
162
|
+
if (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak') {
|
|
164
163
|
e.preventDefault()
|
|
165
|
-
const selection = window.getSelection()
|
|
166
|
-
if (selection && selection.rangeCount > 0) {
|
|
167
|
-
const range = selection.getRangeAt(0)
|
|
168
|
-
range.deleteContents()
|
|
169
|
-
const br = document.createElement('br')
|
|
170
|
-
range.insertNode(br)
|
|
171
|
-
range.setStartAfter(br)
|
|
172
|
-
range.collapse(true)
|
|
173
|
-
selection.removeAllRanges()
|
|
174
|
-
selection.addRange(range)
|
|
175
|
-
// Trigger input event for change tracking
|
|
176
|
-
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
177
|
-
}
|
|
178
164
|
}
|
|
179
165
|
})
|
|
180
166
|
|
|
@@ -1140,11 +1126,24 @@ export interface DeploymentPollingOptions {
|
|
|
1140
1126
|
* Start polling for deployment status after a save operation.
|
|
1141
1127
|
* Polls the API every 3 seconds until deployment completes or fails.
|
|
1142
1128
|
* Waits for deployment to appear for up to 30 seconds before giving up.
|
|
1129
|
+
* Skips polling entirely when deployment is not available (e.g. local dev).
|
|
1143
1130
|
*/
|
|
1144
|
-
export function startDeploymentPolling(config: CmsConfig, options?: DeploymentPollingOptions): void {
|
|
1131
|
+
export async function startDeploymentPolling(config: CmsConfig, options?: DeploymentPollingOptions): Promise<void> {
|
|
1145
1132
|
// Clear any existing timers
|
|
1146
1133
|
stopDeploymentPolling()
|
|
1147
1134
|
|
|
1135
|
+
// Do a preflight check to see if deployment is available
|
|
1136
|
+
try {
|
|
1137
|
+
const preflight = await getDeploymentStatus(config.apiBase)
|
|
1138
|
+
if (preflight.deploymentEnabled === false) {
|
|
1139
|
+
// Deployment not available (e.g. local dev) — skip polling entirely
|
|
1140
|
+
return
|
|
1141
|
+
}
|
|
1142
|
+
} catch {
|
|
1143
|
+
// If we can't even reach the endpoint, skip polling
|
|
1144
|
+
return
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1148
1147
|
// Reset wait attempts counter and store the timestamp when we started
|
|
1149
1148
|
deploymentWaitAttempts = 0
|
|
1150
1149
|
deploymentStartTimestamp = new Date().toISOString()
|
package/src/editor/index.tsx
CHANGED
|
@@ -142,10 +142,12 @@ const CmsUI = () => {
|
|
|
142
142
|
if (result.success) {
|
|
143
143
|
signals.showToast(`Saved ${result.updated} change(s) successfully!`, 'success')
|
|
144
144
|
} else if (result.errors) {
|
|
145
|
-
|
|
145
|
+
const details = result.errors.map(e => e.error).join('; ')
|
|
146
|
+
signals.showToast(`Save failed: ${details}`, 'error')
|
|
146
147
|
}
|
|
147
148
|
} catch (err) {
|
|
148
|
-
|
|
149
|
+
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
150
|
+
signals.showToast(`Save failed: ${message}`, 'error')
|
|
149
151
|
}
|
|
150
152
|
}, [config, updateUI])
|
|
151
153
|
|
package/src/editor/types.ts
CHANGED
|
@@ -418,6 +418,8 @@ export interface DeploymentStatusResponse {
|
|
|
418
418
|
publishedUrl: string
|
|
419
419
|
} | null
|
|
420
420
|
pendingCount: number
|
|
421
|
+
/** When false, deployment is not available (e.g. local dev) and polling should be skipped */
|
|
422
|
+
deploymentEnabled?: boolean
|
|
421
423
|
}
|
|
422
424
|
|
|
423
425
|
export interface DeploymentState {
|