@nuasite/cms 0.39.1 → 0.40.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.
Files changed (40) hide show
  1. package/dist/editor.js +14575 -13938
  2. package/package.json +1 -1
  3. package/src/build-processor.ts +1 -1
  4. package/src/collection-scanner.ts +49 -2
  5. package/src/dev-middleware.ts +1 -1
  6. package/src/editor/components/attribute-editor.tsx +0 -1
  7. package/src/editor/components/bg-image-overlay.tsx +7 -8
  8. package/src/editor/components/block-editor.tsx +12 -12
  9. package/src/editor/components/collections-browser.tsx +10 -10
  10. package/src/editor/components/create-page-modal.tsx +18 -18
  11. package/src/editor/components/delete-page-dialog.tsx +4 -3
  12. package/src/editor/components/field-utils.ts +54 -0
  13. package/src/editor/components/fields.tsx +254 -72
  14. package/src/editor/components/frontmatter-fields.tsx +135 -54
  15. package/src/editor/components/frontmatter-sidebar.tsx +55 -58
  16. package/src/editor/components/link-edit-popover.tsx +10 -5
  17. package/src/editor/components/markdown-editor-overlay.tsx +100 -39
  18. package/src/editor/components/markdown-inline-editor.tsx +58 -26
  19. package/src/editor/components/mdx-block-view.tsx +4 -4
  20. package/src/editor/components/mdx-component-picker.tsx +2 -2
  21. package/src/editor/components/media-library.tsx +19 -18
  22. package/src/editor/components/modal-shell.tsx +16 -3
  23. package/src/editor/components/prop-editor.tsx +15 -18
  24. package/src/editor/components/redirects-manager.tsx +42 -35
  25. package/src/editor/components/reference-picker.tsx +5 -4
  26. package/src/editor/components/seo-editor.tsx +36 -27
  27. package/src/editor/components/toolbar.tsx +50 -33
  28. package/src/editor/dom.ts +13 -2
  29. package/src/editor/editor.ts +7 -6
  30. package/src/editor/hooks/useBlockEditorHandlers.ts +7 -6
  31. package/src/editor/index.tsx +7 -6
  32. package/src/editor/signals.ts +44 -13
  33. package/src/editor/strings.ts +123 -0
  34. package/src/editor/styles.css +75 -2
  35. package/src/editor/types.ts +8 -0
  36. package/src/index.ts +6 -0
  37. package/src/source-finder/image-finder.ts +1 -1
  38. package/src/source-finder/search-index.ts +12 -4
  39. package/src/source-finder/snippet-utils.ts +4 -1
  40. package/src/types.ts +4 -0
@@ -4,6 +4,7 @@ import { CMS_VERSION, Z_INDEX } from '../constants'
4
4
  import { cn } from '../lib/cn'
5
5
  import * as signals from '../signals'
6
6
  import { showConfirmDialog } from '../signals'
7
+ import { STRINGS } from '../strings'
7
8
  import type { CollectionDefinition } from '../types'
8
9
  import { Spinner } from './spinner'
9
10
 
@@ -46,6 +47,9 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
46
47
  const isSaving = signals.isSaving.value
47
48
  const showEditableHighlights = signals.showEditableHighlights.value
48
49
  const isPreviewingMarkdown = signals.isMarkdownPreview.value
50
+ const isSideModalOpen = signals.isColorEditorOpen.value
51
+ || signals.isAttributeEditorOpen.value
52
+ || signals.isBgImageOverlayOpen.value
49
53
  const currentPageCollection = signals.currentPageCollection.value
50
54
  const [isMenuOpen, setIsMenuOpen] = useState(false)
51
55
  const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
@@ -53,15 +57,18 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
53
57
  const versionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
54
58
 
55
59
  if (isPreviewingMarkdown) return null
60
+ // Right-anchored side panels (color editor, attribute editor, background-image overlay)
61
+ // would visually overlap the right-anchored toolbar pill — hide while any of them is open.
62
+ if (isSideModalOpen) return null
56
63
 
57
64
  const stopPropagation = (e: Event) => e.stopPropagation()
58
65
 
59
66
  const handleDiscard = async () => {
60
67
  const confirmed = await showConfirmDialog({
61
- title: 'Discard Changes',
62
- message: 'Discard all changes? This cannot be undone.',
63
- confirmLabel: 'Discard',
64
- cancelLabel: 'Cancel',
68
+ title: STRINGS.dialog.discardAll.title,
69
+ message: STRINGS.dialog.discardAll.message,
70
+ confirmLabel: STRINGS.dialog.discardAll.confirm,
71
+ cancelLabel: STRINGS.dialog.discardAll.cancel,
65
72
  variant: 'danger',
66
73
  })
67
74
  if (confirmed) {
@@ -76,7 +83,7 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
76
83
  const topLevelItems: MenuItem[] = []
77
84
  if (callbacks.onSelectElement && signals.config.value.features?.selectElement) {
78
85
  topLevelItems.push({
79
- label: 'Select Element',
86
+ label: STRINGS.toolbar.selectElement,
80
87
  icon: (
81
88
  <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
82
89
  <path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z" />
@@ -201,7 +208,7 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
201
208
  class={cn(
202
209
  'fixed bottom-4 sm:bottom-8 font-sans transition-all duration-300',
203
210
  isToolbarOpen
204
- ? 'left-4 right-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2'
211
+ ? 'left-4 right-4 sm:right-8 sm:left-auto sm:-translate-x-1/2'
205
212
  : 'right-4 sm:right-8',
206
213
  )}
207
214
  data-cms-ui
@@ -213,9 +220,9 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
213
220
  {isToolbarOpen && !showingOriginal && callbacks.onToggleHighlights && (
214
221
  <ToolbarButton
215
222
  onClick={() => callbacks.onToggleHighlights?.()}
216
- class={'flex gap-2.5 bg-white/10 text-white/80 hover:bg-white/20 hover:text-white py-2! pr-1.5!'}
223
+ class={'flex min-w-[157px] gap-2.5 bg-white/10 text-white/80 hover:bg-white/20 hover:text-white py-2! pr-1.5!'}
217
224
  >
218
- Outlines
225
+ {showEditableHighlights ? 'Hide Outlines' : 'Show Outlines'}
219
226
  <span
220
227
  class={cn(
221
228
  'inline-block w-6 h-6 rounded-full shrink-0 transition-colors',
@@ -331,7 +338,7 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
331
338
  }}
332
339
  />
333
340
  {/* Menu popover */}
334
- <div class="absolute bottom-full right-0 mb-4 min-w-[200px] bg-cms-dark rounded-cms-lg shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 overflow-hidden py-1">
341
+ <div class="absolute bottom-full right-0 mb-4 min-w-[200px] bg-cms-dark rounded-cms-lg shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 overflow-hidden">
335
342
  {topLevelItems.map((item, index) => (
336
343
  <button
337
344
  key={`top-${index}`}
@@ -341,7 +348,8 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
341
348
  setIsMenuOpen(false)
342
349
  }}
343
350
  class={cn(
344
- 'w-full px-4 py-2.5 text-sm font-medium text-left transition-colors cursor-pointer flex items-center gap-3',
351
+ 'w-full p-2.5 text-sm font-medium text-left transition-colors cursor-pointer flex items-center gap-3',
352
+ index === 0 && 'pt-3.5',
345
353
  item.isActive
346
354
  ? 'bg-white/20 text-white'
347
355
  : 'text-white/80 hover:bg-white/10 hover:text-white',
@@ -351,7 +359,7 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
351
359
  {item.label}
352
360
  </button>
353
361
  ))}
354
- {topLevelItems.length > 0 && menuSections.length > 0 && <div class="border-t border-white/10 my-1" />}
362
+ {topLevelItems.length > 0 && menuSections.length > 0 && <div class="border-t border-white/10" />}
355
363
  {menuSections.map((section) => {
356
364
  const isExpanded = expandedSections.has(section.label)
357
365
  return (
@@ -369,7 +377,7 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
369
377
  return next
370
378
  })
371
379
  }}
372
- class="w-full px-4 py-2.5 text-sm font-medium text-left transition-colors cursor-pointer flex items-center gap-3 text-white/80 hover:bg-white/10 hover:text-white"
380
+ class="w-full p-2.5 text-sm font-medium text-left transition-colors cursor-pointer flex items-center gap-3 text-white/80 hover:bg-white/10 hover:text-white"
373
381
  >
374
382
  <span class="shrink-0 opacity-70">{section.icon}</span>
375
383
  {section.label}
@@ -385,29 +393,38 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
385
393
  <path d="m6 9 6 6 6-6" />
386
394
  </svg>
387
395
  </button>
388
- {isExpanded && section.items.map((item, index) => (
389
- <button
390
- key={index}
391
- onClick={(e) => {
392
- e.stopPropagation()
393
- item.onClick()
394
- setIsMenuOpen(false)
395
- }}
396
- class={cn(
397
- 'w-full pl-11 pr-4 py-2 text-sm text-left transition-colors cursor-pointer flex items-center gap-3',
398
- item.isActive
399
- ? 'bg-white/20 text-white'
400
- : 'text-white/60 hover:bg-white/10 hover:text-white',
401
- )}
402
- >
403
- <span class="shrink-0 opacity-70">{item.icon}</span>
404
- {item.label}
405
- </button>
406
- ))}
396
+ <div
397
+ class={cn(
398
+ 'grid transition-[grid-template-rows] duration-200 ease-out',
399
+ isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
400
+ )}
401
+ >
402
+ <div class="overflow-hidden">
403
+ {section.items.map((item, index) => (
404
+ <button
405
+ key={index}
406
+ onClick={(e) => {
407
+ e.stopPropagation()
408
+ item.onClick()
409
+ setIsMenuOpen(false)
410
+ }}
411
+ class={cn(
412
+ 'w-full pl-9 pr-4 py-2 text-sm text-left transition-colors cursor-pointer flex items-center gap-3',
413
+ item.isActive
414
+ ? 'bg-white/20 text-white'
415
+ : 'text-white/60 hover:bg-white/10 hover:text-white',
416
+ )}
417
+ >
418
+ <span class="shrink-0 opacity-70">{item.icon}</span>
419
+ {item.label}
420
+ </button>
421
+ ))}
422
+ </div>
423
+ </div>
407
424
  </div>
408
425
  )
409
426
  })}
410
- {destructiveItems.length > 0 && <div class="border-t border-white/10 my-1" />}
427
+ {destructiveItems.length > 0 && <div class="border-t border-white/10" />}
411
428
  {destructiveItems.map((item, index) => (
412
429
  <button
413
430
  key={`destructive-${index}`}
@@ -416,7 +433,7 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
416
433
  item.onClick()
417
434
  setIsMenuOpen(false)
418
435
  }}
419
- class="w-full px-4 py-2.5 text-sm font-medium text-left transition-colors cursor-pointer flex items-center gap-3 text-red-400/80 hover:bg-red-500/10 hover:text-red-400"
436
+ class="w-full p-2.5 pb-3.5 text-sm font-medium text-left transition-colors cursor-pointer flex items-center gap-3 text-red-400/80 hover:bg-red-500/10 hover:text-red-400"
420
437
  >
421
438
  <span class="shrink-0 opacity-70">{item.icon}</span>
422
439
  {item.label}
package/src/editor/dom.ts CHANGED
@@ -45,10 +45,21 @@ export function isPageDark(): boolean {
45
45
  }
46
46
 
47
47
  /**
48
- * Get an outline color that contrasts with the page background.
48
+ * Get an outline color that contrasts with the user's `siteTheme` config:
49
+ * - 'light' — host is light → dark outline
50
+ * - 'dark' — host is dark → light outline
51
+ * - 'auto' (default) — detect via `prefers-color-scheme` then fall back to the page background
49
52
  */
50
53
  export function getOutlineColor(): string {
51
- return isPageDark() ? '#FFFFFF' : '#1A1A1A'
54
+ const cfg = typeof window !== 'undefined' ? window.NuaCmsConfig : undefined
55
+ const theme = cfg?.siteTheme ?? 'auto'
56
+ if (theme === 'light') return 'rgba(26, 26, 26, 0.7)'
57
+ if (theme === 'dark') return 'rgba(255, 255, 255, 0.85)'
58
+ // auto: trust the OS hint when present, otherwise sample the page
59
+ const prefersDark = typeof window !== 'undefined'
60
+ && window.matchMedia?.('(prefers-color-scheme: dark)').matches
61
+ const hostIsDark = prefersDark || isPageDark()
62
+ return hostIsDark ? '#FFFFFF' : '#1A1A1A'
52
63
  }
53
64
 
54
65
  /**
@@ -33,6 +33,7 @@ import {
33
33
  saveEditsToStorage,
34
34
  saveImageEditsToStorage,
35
35
  } from './storage'
36
+ import { STRINGS } from './strings'
36
37
  import type { Attribute } from './types'
37
38
  import type { AttributeChangePayload, ChangePayload, CmsConfig, ManifestEntry, SavedAttributeEdit } from './types'
38
39
 
@@ -88,7 +89,7 @@ function notifyFormattingBlocked(): void {
88
89
  return
89
90
  }
90
91
  lastFormattingBlockedToastAt = now
91
- signals.showToast("Formatting isn't available — this text is used as a plain value", 'info')
92
+ signals.showToast(STRINGS.editor.formattingBlocked, 'info')
92
93
  }
93
94
 
94
95
  export function notifyLockedElement(): void {
@@ -97,7 +98,7 @@ export function notifyLockedElement(): void {
97
98
  return
98
99
  }
99
100
  lastLockedToastAt = now
100
- signals.showToast("This text can't be edited here — no source file is linked to it", 'info')
101
+ signals.showToast(STRINGS.editor.lockedElement, 'info')
101
102
  }
102
103
 
103
104
  /**
@@ -1197,14 +1198,14 @@ function setupMarkdownClickHandler(
1197
1198
  const entry = manifest.entries[cmsId] as ManifestEntry | undefined
1198
1199
 
1199
1200
  if (!entry) {
1200
- signals.showToast('Markdown element not found in manifest', 'error')
1201
+ signals.showToast(STRINGS.editor.markdownNotInManifest, 'error')
1201
1202
  return
1202
1203
  }
1203
1204
 
1204
1205
  // Check if it has a content path for markdown
1205
1206
  const contentPath = entry.contentPath
1206
1207
  if (!contentPath) {
1207
- signals.showToast('No markdown file path configured for this element', 'error')
1208
+ signals.showToast(STRINGS.editor.noMarkdownPath, 'error')
1208
1209
  return
1209
1210
  }
1210
1211
 
@@ -1213,7 +1214,7 @@ function setupMarkdownClickHandler(
1213
1214
  const result = await getMarkdownContent(config.apiBase, contentPath)
1214
1215
 
1215
1216
  if (!result) {
1216
- signals.showToast('Markdown content not found', 'error')
1217
+ signals.showToast(STRINGS.editor.markdownContentMissing, 'error')
1217
1218
  return
1218
1219
  }
1219
1220
 
@@ -1231,7 +1232,7 @@ function setupMarkdownClickHandler(
1231
1232
  signals.setMarkdownEditorOpen(true)
1232
1233
  } catch (error) {
1233
1234
  const message = error instanceof Error ? error.message : 'Unknown error'
1234
- signals.showToast(`Failed to load markdown: ${message}`, 'error')
1235
+ signals.showToast(STRINGS.editor.markdownLoadFailed(message), 'error')
1235
1236
  logDebug(config.debug, 'Failed to fetch markdown content:', error)
1236
1237
  }
1237
1238
  })
@@ -2,6 +2,7 @@ import { useCallback, useState } from 'preact/hooks'
2
2
  import { logDebug } from '../dom'
3
3
  import { getComponentInstances } from '../manifest'
4
4
  import * as signals from '../signals'
5
+ import { STRINGS } from '../strings'
5
6
  import type { CmsConfig, CmsManifest, ComponentInstance, InsertPosition } from '../types'
6
7
 
7
8
  /**
@@ -86,7 +87,7 @@ export function useBlockEditorHandlers({
86
87
  (componentId: string, props: Record<string, any>) => {
87
88
  logDebug(config.debug, 'Update props for component:', componentId, props)
88
89
  // TODO: Implement prop update logic - this will require server-side file modification
89
- showToast('Props updated (preview only)', 'info')
90
+ showToast(STRINGS.block.propsPreviewOnly, 'info')
90
91
  },
91
92
  [config.debug, showToast],
92
93
  )
@@ -151,7 +152,7 @@ export function useBlockEditorHandlers({
151
152
  throw new Error(error || 'Failed to add array item')
152
153
  }
153
154
 
154
- showToast(`Item added ${position} current item`, 'success')
155
+ showToast(STRINGS.block.insertItem(position), 'success')
155
156
  } else {
156
157
  // Standard component insertion
157
158
  const response = await fetch(`${config.apiBase}/insert-component`, {
@@ -175,7 +176,7 @@ export function useBlockEditorHandlers({
175
176
  throw new Error(error || 'Failed to insert component')
176
177
  }
177
178
 
178
- showToast(`${componentName} inserted ${position} component`, 'success')
179
+ showToast(STRINGS.block.insertComponent(componentName, position), 'success')
179
180
  }
180
181
  } catch (error) {
181
182
  console.error('[CMS] Failed to insert component:', error)
@@ -183,7 +184,7 @@ export function useBlockEditorHandlers({
183
184
  // Remove the preview on failure
184
185
  previewEl?.remove()
185
186
 
186
- showToast(arrayMode ? 'Failed to add array item' : 'Failed to insert component', 'error')
187
+ showToast(arrayMode ? STRINGS.block.insertItemFailed : STRINGS.block.insertComponentFailed, 'error')
187
188
  }
188
189
  },
189
190
  [config.apiBase, config.debug, config, showToast],
@@ -232,7 +233,7 @@ export function useBlockEditorHandlers({
232
233
  throw new Error(error || `Failed to ${arrayMode ? 'remove item' : 'remove component'}`)
233
234
  }
234
235
 
235
- showToast(arrayMode ? 'Item removed' : 'Component removed', 'success')
236
+ showToast(arrayMode ? STRINGS.block.removeItem : STRINGS.block.removeComponent, 'success')
236
237
 
237
238
  // Visually collapse and hide the component until HMR refreshes the page
238
239
  if (componentEl) {
@@ -240,7 +241,7 @@ export function useBlockEditorHandlers({
240
241
  }
241
242
  } catch (error) {
242
243
  console.error('[CMS] Failed to remove component:', error)
243
- showToast(arrayMode ? 'Failed to remove item' : 'Failed to remove component', 'error')
244
+ showToast(arrayMode ? STRINGS.block.removeItemFailed : STRINGS.block.removeComponentFailed, 'error')
244
245
 
245
246
  // Restore the component's appearance on failure
246
247
  if (componentEl) {
@@ -58,6 +58,7 @@ import {
58
58
  } from './signals'
59
59
  import * as signals from './signals'
60
60
  import { hasAnyMarkdownDraft, hasPendingEntryNavigation, loadEditingState, loadSettingsFromStorage, saveSettingsToStorage } from './storage'
61
+ import { STRINGS } from './strings'
61
62
  import CMS_STYLES from './styles.css?inline'
62
63
  import { generateCSSVariables, resolveTheme } from './themes'
63
64
 
@@ -307,20 +308,20 @@ const CmsUI = () => {
307
308
  try {
308
309
  const result = await saveAllChanges(config, updateUI)
309
310
  if (result.success) {
310
- signals.showToast(`Saved ${result.updated} change(s) successfully!`, 'success')
311
+ signals.showToast(STRINGS.save.success(result.updated), 'success')
311
312
  } else if (result.errors) {
312
313
  const details = result.errors.map(e => e.error).join('; ')
313
- signals.showToast(`Save failed: ${details}`, 'error')
314
+ signals.showToast(STRINGS.save.failed(details), 'error')
314
315
  }
315
316
  } catch (err) {
316
317
  const message = err instanceof Error ? err.message : 'Unknown error'
317
- signals.showToast(`Save failed: ${message}`, 'error')
318
+ signals.showToast(STRINGS.save.failed(message), 'error')
318
319
  }
319
320
  }, [config, updateUI])
320
321
 
321
322
  const handleDiscard = useCallback(() => {
322
323
  discardAllChanges(updateUI)
323
- signals.showToast('All changes discarded', 'info')
324
+ signals.showToast(STRINGS.save.discarded, 'info')
324
325
  }, [updateUI])
325
326
 
326
327
  const handleOpenCollection = useCallback((name: string) => {
@@ -334,7 +335,7 @@ const CmsUI = () => {
334
335
 
335
336
  const handleEditContent = useCallback(async () => {
336
337
  if (!await openMarkdownEditorForCurrentPage()) {
337
- signals.showToast('No collection content found on this page', 'error')
338
+ signals.showToast(STRINGS.save.noCollectionContent, 'error')
338
339
  }
339
340
  }, [])
340
341
 
@@ -738,7 +739,7 @@ class CmsEditor {
738
739
  this.appRoot.style.display = isHidden ? 'block' : 'none'
739
740
 
740
741
  // Use signals.showToast for consistency
741
- signals.showToast(isHidden ? 'CMS editing enabled' : 'CMS editing disabled', 'info')
742
+ signals.showToast(isHidden ? STRINGS.editingMode.enabled : STRINGS.editingMode.disabled, 'info')
742
743
  }
743
744
  }
744
745
 
@@ -4,6 +4,7 @@ import { fetchManifest, getMarkdownContent } from './api'
4
4
  import type { ToastMessage, ToastType } from './components/toast/types'
5
5
  import { getConfig } from './config'
6
6
  import { clearMarkdownDraft, loadMarkdownDraft, saveMarkdownDraft } from './storage'
7
+ import { STRINGS } from './strings'
7
8
  import type {
8
9
  AttributeEditorState,
9
10
  BlockEditorState,
@@ -416,6 +417,10 @@ export const mediaLibraryState = signal<MediaLibraryState>(
416
417
  // Convenience computed signals for media library
417
418
  export const isMediaLibraryOpen = computed(() => mediaLibraryState.value.isOpen)
418
419
 
420
+ // Background-image overlay panel open state (lifted from local useState so the
421
+ // toolbar can hide itself when this right-side panel is showing).
422
+ export const isBgImageOverlayOpen = signal(false)
423
+
419
424
  // ============================================================================
420
425
  // Create Page State Signals
421
426
  // ============================================================================
@@ -1205,33 +1210,59 @@ export async function openMarkdownEditorForEntry(
1205
1210
  sourcePath: string,
1206
1211
  collectionDefinition: CollectionDefinition,
1207
1212
  ): Promise<void> {
1208
- let frontmatter: Record<string, unknown> = {}
1209
- let content = ''
1213
+ // Open the editor immediately with placeholder content so the modal
1214
+ // transition feels seamless — content streams in once fetched.
1215
+ markdownEditorState.value = {
1216
+ isOpen: true,
1217
+ currentPage: {
1218
+ filePath: sourcePath,
1219
+ slug,
1220
+ frontmatter: {},
1221
+ content: '',
1222
+ isDirty: false,
1223
+ },
1224
+ activeElementId: null,
1225
+ mode: 'edit',
1226
+ collectionDefinition,
1227
+ }
1228
+
1229
+ // Guard against late responses: if the user closed the modal or navigated
1230
+ // to a different entry while the fetch was in-flight, drop the result.
1231
+ const isStillTargeted = () => markdownEditorState.value.currentPage?.filePath === sourcePath
1232
+
1233
+ let frontmatter: Record<string, unknown>
1234
+ let content: string
1210
1235
 
1211
1236
  try {
1212
1237
  const result = await getMarkdownContent(config.value.apiBase, sourcePath)
1213
- if (result) {
1214
- frontmatter = result.frontmatter as Record<string, unknown>
1215
- content = result.content
1238
+ if (!isStillTargeted()) return
1239
+ if (!result) {
1240
+ resetMarkdownEditorState()
1241
+ showToast(`Could not load entry: ${slug}`, 'error')
1242
+ return
1216
1243
  }
1244
+ frontmatter = result.frontmatter
1245
+ content = result.content
1217
1246
  } catch (err) {
1218
1247
  console.error('[CMS] Failed to fetch markdown content for entry:', err)
1248
+ if (!isStillTargeted()) return
1249
+ resetMarkdownEditorState()
1250
+ showToast(`Could not load entry: ${slug}`, 'error')
1251
+ return
1219
1252
  }
1220
1253
 
1221
1254
  const recovered = await maybeRecoverDraft(sourcePath, frontmatter, content)
1255
+ if (!isStillTargeted()) return
1222
1256
 
1223
1257
  markdownEditorState.value = {
1224
- isOpen: true,
1258
+ ...markdownEditorState.value,
1225
1259
  currentPage: {
1226
1260
  filePath: sourcePath,
1227
1261
  slug,
1228
- frontmatter: recovered.frontmatter as import('./types').BlogFrontmatter,
1262
+ frontmatter: recovered.frontmatter,
1229
1263
  content: recovered.content,
1230
1264
  isDirty: recovered.isDirty,
1231
1265
  },
1232
- activeElementId: null,
1233
- mode: 'edit',
1234
- collectionDefinition,
1235
1266
  }
1236
1267
  }
1237
1268
 
@@ -1288,10 +1319,10 @@ export function showConfirmDialog(
1288
1319
  return new Promise((resolve) => {
1289
1320
  confirmDialogState.value = {
1290
1321
  isOpen: true,
1291
- title: options.title ?? 'Confirm',
1322
+ title: options.title ?? STRINGS.dialog.defaults.title,
1292
1323
  message: options.message,
1293
- confirmLabel: options.confirmLabel ?? 'Confirm',
1294
- cancelLabel: options.cancelLabel ?? 'Cancel',
1324
+ confirmLabel: options.confirmLabel ?? STRINGS.dialog.defaults.confirm,
1325
+ cancelLabel: options.cancelLabel ?? STRINGS.dialog.defaults.cancel,
1295
1326
  variant: options.variant ?? 'info',
1296
1327
  onConfirm: () => {
1297
1328
  closeConfirmDialog()
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Central catalog of editor UI strings. Step 1 of i18n groundwork — no locale
3
+ * switching yet. Parametrized messages are functions so a future `t(key, vars)`
4
+ * helper can swap them in without touching call sites.
5
+ */
6
+ import type { InsertPosition } from './types'
7
+
8
+ export const STRINGS = {
9
+ dialog: {
10
+ defaults: {
11
+ title: 'Confirm',
12
+ confirm: 'Confirm',
13
+ cancel: 'Cancel',
14
+ },
15
+ discardAll: {
16
+ title: 'Discard Changes',
17
+ message: 'Discard all changes? This cannot be undone.',
18
+ confirm: 'Discard',
19
+ cancel: 'Cancel',
20
+ },
21
+ discardMarkdown: {
22
+ title: 'Discard changes?',
23
+ message: 'You have unsaved changes. Discard them and close?',
24
+ confirm: 'Discard',
25
+ cancel: 'Keep editing',
26
+ },
27
+ },
28
+
29
+ toolbar: {
30
+ selectElement: 'Select Element',
31
+ },
32
+
33
+ editingMode: {
34
+ enabled: 'CMS editing enabled',
35
+ disabled: 'CMS editing disabled',
36
+ },
37
+
38
+ editor: {
39
+ formattingBlocked: "Formatting isn't available — this text is used as a plain value",
40
+ lockedElement: "This text can't be edited here — no source file is linked to it",
41
+ markdownNotInManifest: 'Markdown element not found in manifest',
42
+ noMarkdownPath: 'No markdown file path configured for this element',
43
+ markdownContentMissing: 'Markdown content not found',
44
+ markdownLoadFailed: (message: string) => `Failed to load markdown: ${message}`,
45
+ },
46
+
47
+ save: {
48
+ success: (updated: number) => `Saved ${updated} change(s) successfully!`,
49
+ failed: (details: string) => `Save failed: ${details}`,
50
+ discarded: 'All changes discarded',
51
+ noCollectionContent: 'No collection content found on this page',
52
+ },
53
+
54
+ block: {
55
+ propsPreviewOnly: 'Props updated (preview only)',
56
+ insertItem: (position: InsertPosition) => `Item added ${position} current item`,
57
+ insertComponent: (componentName: string, position: InsertPosition) => `${componentName} inserted ${position} component`,
58
+ insertItemFailed: 'Failed to add array item',
59
+ insertComponentFailed: 'Failed to insert component',
60
+ removeItem: 'Item removed',
61
+ removeComponent: 'Component removed',
62
+ removeItemFailed: 'Failed to remove item',
63
+ removeComponentFailed: 'Failed to remove component',
64
+ },
65
+
66
+ reference: {
67
+ updated: 'Reference updated',
68
+ updateFailed: 'Failed to update reference',
69
+ },
70
+
71
+ markdown: {
72
+ saveSuccess: 'Content saved',
73
+ saveFailed: 'Failed to save markdown',
74
+ saveFailedDetails: (message: string) => `Save failed: ${message}`,
75
+ initFailed: 'Failed to initialize markdown editor',
76
+ titleRequired: 'Please enter a title',
77
+ slugRequired: 'Please enter a slug',
78
+ pageCreated: 'Page created',
79
+ createFailed: 'Failed to create page',
80
+ createFailedDetails: (message: string) => `Create failed: ${message}`,
81
+ previewElementMissing: 'Could not find page element to preview',
82
+ previewGenerationFailed: 'Failed to generate preview',
83
+ },
84
+
85
+ media: {
86
+ notConfigured: 'CMS not configured',
87
+ loadFailed: 'Failed to load media library',
88
+ imageRequired: 'Please drop an image file',
89
+ uploadSucceeded: 'File uploaded successfully',
90
+ uploadedNextToEntry: 'Uploaded next to entry',
91
+ fileUploaded: 'File uploaded',
92
+ imageInserted: 'Image uploaded and inserted',
93
+ uploadFailed: 'Upload failed',
94
+ invalidFolderName: 'Invalid folder name',
95
+ folderCreated: 'Folder created',
96
+ folderCreateFailed: 'Failed to create folder',
97
+ },
98
+
99
+ redirects: {
100
+ updated: 'Redirect updated',
101
+ updateFailed: 'Failed to update',
102
+ deleted: 'Redirect deleted',
103
+ deleteFailed: 'Failed to delete',
104
+ added: 'Redirect added',
105
+ addFailed: 'Failed to add redirect',
106
+ },
107
+
108
+ slug: {
109
+ updated: 'Slug updated',
110
+ renameFailed: 'Failed to rename',
111
+ },
112
+
113
+ page: {
114
+ deleted: 'Page deleted',
115
+ deleteFailed: 'Failed to delete page',
116
+ },
117
+
118
+ seo: {
119
+ saveSuccess: (updated: number) => `Saved ${updated} SEO change(s) successfully!`,
120
+ saveFailed: (details: string) => `SEO save failed: ${details}`,
121
+ saveFailedFallback: 'Failed to save SEO changes',
122
+ },
123
+ } as const