@nuasite/cms 0.39.2 → 0.41.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 (37) hide show
  1. package/dist/editor.js +15910 -15027
  2. package/package.json +1 -1
  3. package/src/collection-scanner.ts +127 -13
  4. package/src/content-config-ast.ts +91 -24
  5. package/src/editor/components/attribute-editor.tsx +0 -1
  6. package/src/editor/components/bg-image-overlay.tsx +7 -8
  7. package/src/editor/components/block-editor.tsx +12 -12
  8. package/src/editor/components/collections-browser.tsx +10 -10
  9. package/src/editor/components/create-page-modal.tsx +18 -18
  10. package/src/editor/components/delete-page-dialog.tsx +4 -3
  11. package/src/editor/components/field-utils.ts +54 -0
  12. package/src/editor/components/fields.tsx +516 -73
  13. package/src/editor/components/frontmatter-fields.tsx +188 -55
  14. package/src/editor/components/frontmatter-sidebar.tsx +56 -58
  15. package/src/editor/components/link-edit-popover.tsx +10 -5
  16. package/src/editor/components/markdown-editor-overlay.tsx +100 -39
  17. package/src/editor/components/markdown-inline-editor.tsx +58 -26
  18. package/src/editor/components/mdx-block-view.tsx +4 -4
  19. package/src/editor/components/mdx-component-picker.tsx +2 -2
  20. package/src/editor/components/media-library.tsx +19 -18
  21. package/src/editor/components/modal-shell.tsx +16 -3
  22. package/src/editor/components/prop-editor.tsx +15 -18
  23. package/src/editor/components/redirects-manager.tsx +42 -35
  24. package/src/editor/components/reference-picker.tsx +5 -4
  25. package/src/editor/components/seo-editor.tsx +36 -27
  26. package/src/editor/components/toolbar.tsx +50 -33
  27. package/src/editor/dom.ts +13 -2
  28. package/src/editor/editor.ts +7 -6
  29. package/src/editor/hooks/useBlockEditorHandlers.ts +7 -6
  30. package/src/editor/index.tsx +7 -6
  31. package/src/editor/signals.ts +44 -13
  32. package/src/editor/strings.ts +123 -0
  33. package/src/editor/styles.css +75 -2
  34. package/src/editor/types.ts +8 -0
  35. package/src/field-types.ts +15 -0
  36. package/src/index.ts +6 -0
  37. package/src/types.ts +7 -0
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
3
3
  import { slugify } from '../../shared'
4
4
  import { updateMarkdownPage } from '../api'
5
5
  import { STORAGE_KEYS, Z_INDEX } from '../constants'
6
+ import { cn } from '../lib/cn'
6
7
  import { createMarkdownPage } from '../markdown-api'
7
8
  import { MDX_EXPR_PREFIX } from '../milkdown-mdx-plugin'
8
9
  import {
@@ -13,11 +14,14 @@ import {
13
14
  markdownEditorState,
14
15
  pendingCollectionEntries,
15
16
  resetMarkdownEditorState,
17
+ showConfirmDialog,
16
18
  showToast,
17
19
  startRedirectCountdown,
18
20
  updateMarkdownFrontmatter,
19
21
  } from '../signals'
20
22
  import { clearMarkdownDraft } from '../storage'
23
+ import { STRINGS } from '../strings'
24
+ import type { FieldDefinition } from '../types'
21
25
  import { CreateModeFrontmatter, EditModeFrontmatter } from './frontmatter-fields'
22
26
  import { FrontmatterSidebar, partitionFields } from './frontmatter-sidebar'
23
27
  import { MarkdownInlineEditor } from './markdown-inline-editor'
@@ -36,9 +40,16 @@ export function MarkdownEditorOverlay() {
36
40
  const collectionDef = editorState.collectionDefinition
37
41
 
38
42
  const activeCollectionDef = isCreateMode ? createOptions?.collectionDefinition : collectionDef
39
- const { sidebar: sidebarFields, header: headerFields } = activeCollectionDef
40
- ? partitionFields(activeCollectionDef.fields)
41
- : { sidebar: [], header: [] }
43
+ // Always surface a Draft toggle in the sidebar even when the schema scanner
44
+ // didn't pick it up (e.g. inferred schemas where draft only appears on some entries).
45
+ // Push a proper FieldDefinition into the input list so partitionFields() handles
46
+ // placement (above the Date field) via its existing logic.
47
+ const fields: FieldDefinition[] = activeCollectionDef ? [...activeCollectionDef.fields] : []
48
+ const draftInPage = page && Object.hasOwn(page.frontmatter, 'draft')
49
+ if (draftInPage && !fields.some((f) => f.name === 'draft')) {
50
+ fields.push({ name: 'draft', type: 'boolean', required: false, position: 'sidebar', role: 'publish-toggle' })
51
+ }
52
+ const { sidebar: sidebarFields, header: headerFields } = partitionFields(fields)
42
53
  const hasSidebar = sidebarFields.length > 0
43
54
  const isDataCollection = activeCollectionDef?.type === 'data'
44
55
  // Derive MDX mode from the actual file extension when available
@@ -58,14 +69,19 @@ export function MarkdownEditorOverlay() {
58
69
  const previewTargetRef = useRef<HTMLElement | null>(null)
59
70
  const editorInstanceRef = useRef<Editor | null>(null)
60
71
 
61
- // Lock page scroll while the modal overlay is visible (not during preview)
72
+ // Lock page scroll while the modal overlay is visible (not during preview).
73
+ // scrollbar-gutter: stable reserves space for the scrollbar so the page
74
+ // doesn't shift horizontally when overflow flips to hidden — no manual padding math.
62
75
  useEffect(() => {
63
76
  if (!page || isPreview) return
64
77
  const html = document.documentElement
65
78
  const prevOverflow = html.style.overflow
79
+ const prevGutter = html.style.scrollbarGutter
66
80
  html.style.overflow = 'hidden'
81
+ html.style.scrollbarGutter = 'stable'
67
82
  return () => {
68
83
  html.style.overflow = prevOverflow
84
+ html.style.scrollbarGutter = prevGutter
69
85
  }
70
86
  }, [!!page, isPreview])
71
87
 
@@ -165,17 +181,17 @@ export function MarkdownEditorOverlay() {
165
181
  setIsSaving(false)
166
182
 
167
183
  clearMarkdownDraft(currentPage.filePath)
168
- showToast('Content saved', 'success')
184
+ showToast(STRINGS.markdown.saveSuccess, 'success')
169
185
  // Clear pending entry navigation so editor doesn't auto-open after save
170
186
  sessionStorage.removeItem(STORAGE_KEYS.PENDING_ENTRY_NAVIGATION)
171
187
  resetMarkdownEditorState()
172
188
  } else {
173
- showToast(result.error || 'Failed to save markdown', 'error')
189
+ showToast(result.error || STRINGS.markdown.saveFailed, 'error')
174
190
  setIsSaving(false)
175
191
  }
176
192
  } catch (error) {
177
193
  const message = error instanceof Error ? error.message : 'Unknown error'
178
- showToast(`Save failed: ${message}`, 'error')
194
+ showToast(STRINGS.markdown.saveFailedDetails(message), 'error')
179
195
  setIsSaving(false)
180
196
  }
181
197
  },
@@ -190,13 +206,13 @@ export function MarkdownEditorOverlay() {
190
206
 
191
207
  const title = (currentPage.frontmatter.title as string) || ''
192
208
  if (!title.trim()) {
193
- showToast('Please enter a title', 'error')
209
+ showToast(STRINGS.markdown.titleRequired, 'error')
194
210
  return
195
211
  }
196
212
 
197
213
  const slug = currentPage.slug || slugify(title)
198
214
  if (!slug) {
199
- showToast('Please enter a slug', 'error')
215
+ showToast(STRINGS.markdown.slugRequired, 'error')
200
216
  return
201
217
  }
202
218
 
@@ -236,17 +252,17 @@ export function MarkdownEditorOverlay() {
236
252
  }
237
253
  }
238
254
 
239
- showToast('Page created', 'success')
255
+ showToast(STRINGS.markdown.pageCreated, 'success')
240
256
  resetMarkdownEditorState()
241
257
  if (redirectUrl) {
242
258
  startRedirectCountdown(redirectUrl, title.trim())
243
259
  }
244
260
  } else {
245
- showToast(result.error || 'Failed to create page', 'error')
261
+ showToast(result.error || STRINGS.markdown.createFailed, 'error')
246
262
  }
247
263
  } catch (error) {
248
264
  const message = error instanceof Error ? error.message : 'Unknown error'
249
- showToast(`Create failed: ${message}`, 'error')
265
+ showToast(STRINGS.markdown.createFailedDetails(message), 'error')
250
266
  } finally {
251
267
  setIsSaving(false)
252
268
  }
@@ -260,7 +276,7 @@ export function MarkdownEditorOverlay() {
260
276
  // Enter preview — inject editor HTML into the markdown wrapper element.
261
277
  const el = findMarkdownWrapper()
262
278
  if (!el) {
263
- showToast('Could not find page element to preview', 'error')
279
+ showToast(STRINGS.markdown.previewElementMissing, 'error')
264
280
  return
265
281
  }
266
282
  originalHTMLRef.current = el.innerHTML
@@ -307,7 +323,7 @@ export function MarkdownEditorOverlay() {
307
323
  console.error('Failed to get editor HTML for preview:', error)
308
324
  originalHTMLRef.current = null
309
325
  previewTargetRef.current = null
310
- showToast('Failed to generate preview', 'error')
326
+ showToast(STRINGS.markdown.previewGenerationFailed, 'error')
311
327
  return
312
328
  }
313
329
  setIsPreview(true)
@@ -320,7 +336,18 @@ export function MarkdownEditorOverlay() {
320
336
  }
321
337
  }, [isPreview, restoreOriginalHTML, findMarkdownWrapper])
322
338
 
323
- const handleCancel = useCallback(() => {
339
+ const handleCancel = useCallback(async () => {
340
+ const hasUnsavedChanges = currentMarkdownPage.value?.isDirty === true
341
+ if (hasUnsavedChanges) {
342
+ const confirmed = await showConfirmDialog({
343
+ title: STRINGS.dialog.discardMarkdown.title,
344
+ message: STRINGS.dialog.discardMarkdown.message,
345
+ confirmLabel: STRINGS.dialog.discardMarkdown.confirm,
346
+ cancelLabel: STRINGS.dialog.discardMarkdown.cancel,
347
+ variant: 'danger',
348
+ })
349
+ if (!confirmed) return
350
+ }
324
351
  restoreOriginalHTML()
325
352
  isMarkdownPreview.value = false
326
353
  resetMarkdownEditorState()
@@ -390,15 +417,16 @@ export function MarkdownEditorOverlay() {
390
417
  return (
391
418
  <div
392
419
  style={{ zIndex: Z_INDEX.MODAL }}
393
- class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 backdrop-blur-md"
420
+ class="fixed inset-0 bg-black/40 flex items-start justify-center p-4 pt-[5vh] backdrop-blur-md"
394
421
  data-cms-ui
395
422
  onMouseDown={stopPropagation}
396
423
  onClick={stopPropagation}
397
424
  >
398
425
  <form
399
- class={`bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 w-full max-h-[90vh] flex flex-col ${
400
- hasSidebar ? 'max-w-6xl' : 'max-w-4xl'
401
- }`}
426
+ class={cn(
427
+ 'bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 w-full max-h-[90vh] flex flex-col overflow-hidden',
428
+ hasSidebar ? 'max-w-6xl' : 'max-w-4xl',
429
+ )}
402
430
  data-cms-ui
403
431
  onSubmit={(e) => {
404
432
  e.preventDefault()
@@ -436,6 +464,11 @@ export function MarkdownEditorOverlay() {
436
464
  <span class="text-base font-semibold text-white truncate">
437
465
  {(page.frontmatter.title as string) || (page.frontmatter.name as string) || (isDataCollection ? 'Entry name' : 'Page title')}
438
466
  </span>
467
+ {activeCollectionDef && (
468
+ <span class="shrink-0 px-2 py-0.5 text-xs font-medium text-white/70 bg-white/10 rounded-full border border-white/15">
469
+ {activeCollectionDef.label}
470
+ </span>
471
+ )}
439
472
  </div>
440
473
  <div class="flex items-center gap-2 shrink-0">
441
474
  {!isDataCollection && (
@@ -467,7 +500,7 @@ export function MarkdownEditorOverlay() {
467
500
  d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
468
501
  />
469
502
  </svg>
470
- Metadata
503
+ {showFrontmatter ? 'Hide' : 'Show'} Metadata
471
504
  <svg
472
505
  class={`w-3.5 h-3.5 transition-transform ${showFrontmatter ? 'rotate-180' : ''}`}
473
506
  fill="none"
@@ -530,26 +563,54 @@ export function MarkdownEditorOverlay() {
530
563
  {/* Main: frontmatter header + editor */}
531
564
  <div class="flex-1 min-w-0 flex flex-col">
532
565
  {/* Frontmatter Editor (header-positioned fields only) */}
533
- {(showFrontmatter || isDataCollection) && (
534
- <div class={`px-5 py-4 border-b border-white/10 bg-white/5 overflow-y-auto ${isDataCollection ? 'flex-1' : 'max-h-[40vh]'}`}>
535
- {isCreateMode && createOptions
536
- ? (
537
- <CreateModeFrontmatter
538
- page={page}
539
- collectionDefinition={createOptions.collectionDefinition}
540
- fields={headerFields}
541
- onSlugManualEdit={() => setSlugManuallyEdited(true)}
542
- />
543
- )
544
- : (
545
- <EditModeFrontmatter
546
- page={page}
547
- collectionDefinition={collectionDef}
548
- fields={headerFields}
549
- />
566
+ {isDataCollection
567
+ ? (
568
+ <div class="px-5 py-4 border-b border-white/10 bg-white/5 overflow-y-auto flex-1">
569
+ {isCreateMode && createOptions
570
+ ? (
571
+ <CreateModeFrontmatter
572
+ page={page}
573
+ collectionDefinition={createOptions.collectionDefinition}
574
+ fields={headerFields}
575
+ onSlugManualEdit={() => setSlugManuallyEdited(true)}
576
+ />
577
+ )
578
+ : (
579
+ <EditModeFrontmatter
580
+ page={page}
581
+ collectionDefinition={collectionDef}
582
+ fields={headerFields}
583
+ />
584
+ )}
585
+ </div>
586
+ )
587
+ : (
588
+ <div
589
+ class={cn(
590
+ 'overflow-hidden transition-[max-height,opacity,border-bottom-width] duration-300 ease-out border-white/10',
591
+ showFrontmatter ? 'max-h-[40vh] opacity-100 border-b' : 'max-h-0 opacity-0 border-b-0',
550
592
  )}
551
- </div>
552
- )}
593
+ >
594
+ <div class="px-5 py-4 bg-white/5 overflow-y-auto max-h-[40vh]" style={{ scrollbarGutter: 'stable' }}>
595
+ {isCreateMode && createOptions
596
+ ? (
597
+ <CreateModeFrontmatter
598
+ page={page}
599
+ collectionDefinition={createOptions.collectionDefinition}
600
+ fields={headerFields}
601
+ onSlugManualEdit={() => setSlugManuallyEdited(true)}
602
+ />
603
+ )
604
+ : (
605
+ <EditModeFrontmatter
606
+ page={page}
607
+ collectionDefinition={collectionDef}
608
+ fields={headerFields}
609
+ />
610
+ )}
611
+ </div>
612
+ </div>
613
+ )}
553
614
 
554
615
  {/* Editor — hidden for data collections (JSON/YAML have no body) */}
555
616
  {!isDataCollection && (
@@ -12,11 +12,14 @@ import {
12
12
  import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm'
13
13
  import { callCommand, insert, replaceAll } from '@milkdown/utils'
14
14
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
15
+ import { lift } from 'prosemirror-commands'
15
16
  import { useLinkPopover } from '../hooks/useLinkPopover'
17
+ import { cn } from '../lib/cn'
16
18
  import { uploadMedia } from '../markdown-api'
17
19
  import { insertMdxComponentCommand, mdxComponentPlugin } from '../milkdown-mdx-plugin'
18
20
  import { type ActiveFormats, defaultActiveFormats, isInListType, setupFormatTracking, toggleHeading } from '../milkdown-utils'
19
21
  import { config, mdxComponentPickerOpen, openMediaLibraryWithCallback, resetMarkdownEditorState, showToast, updateMarkdownContent } from '../signals'
22
+ import { STRINGS } from '../strings'
20
23
  import { LinkEditPopover } from './link-edit-popover'
21
24
  import { MdxComponentIcon } from './mdx-block-view'
22
25
  import { MdxComponentPicker } from './mdx-component-picker'
@@ -108,7 +111,7 @@ export function MarkdownInlineEditor({
108
111
  cleanupTracking = setupFormatTracking(editor, setActiveFormats)
109
112
  } catch (error) {
110
113
  console.error('Milkdown editor initialization failed:', error)
111
- showToast('Failed to initialize markdown editor', 'error')
114
+ showToast(STRINGS.markdown.initFailed, 'error')
112
115
  }
113
116
  }
114
117
 
@@ -176,10 +179,29 @@ export function MarkdownInlineEditor({
176
179
  () => runCommand(toggleStrikethroughCommand.key),
177
180
  [runCommand],
178
181
  )
179
- const handleQuote = useCallback(
180
- () => runCommand(wrapInBlockquoteCommand.key),
181
- [runCommand],
182
- )
182
+ const handleQuote = useCallback(() => {
183
+ if (!editorInstanceRef.current) return
184
+ try {
185
+ const view = editorInstanceRef.current.ctx.get(editorViewCtx)
186
+ const { $from } = view.state.selection
187
+ let inBlockquote = false
188
+ for (let depth = $from.depth; depth > 0; depth--) {
189
+ if ($from.node(depth).type.name === 'blockquote') {
190
+ inBlockquote = true
191
+ break
192
+ }
193
+ }
194
+ if (inBlockquote) {
195
+ // Lift selection out of the blockquote (may need multiple lifts for nested wrappers)
196
+ lift(view.state, view.dispatch)
197
+ view.focus()
198
+ } else {
199
+ runCommand(wrapInBlockquoteCommand.key)
200
+ }
201
+ } catch {
202
+ runCommand(wrapInBlockquoteCommand.key)
203
+ }
204
+ }, [runCommand])
183
205
 
184
206
  // Check if selection is inside a list of given type
185
207
  const checkInList = useCallback(
@@ -269,7 +291,7 @@ export function MarkdownInlineEditor({
269
291
 
270
292
  const file = files[0]
271
293
  if (!file || !file.type.startsWith('image/')) {
272
- showToast('Please drop an image file', 'error')
294
+ showToast(STRINGS.media.imageRequired, 'error')
273
295
  return
274
296
  }
275
297
 
@@ -288,16 +310,16 @@ export function MarkdownInlineEditor({
288
310
  if (editorInstanceRef.current) {
289
311
  try {
290
312
  editorInstanceRef.current.action(insert(imageMarkdown))
291
- showToast('Image uploaded and inserted', 'success')
313
+ showToast(STRINGS.media.imageInserted, 'success')
292
314
  } catch (error) {
293
315
  console.error('Failed to insert image:', error)
294
316
  }
295
317
  }
296
318
  } else {
297
- showToast(result.error || 'Upload failed', 'error')
319
+ showToast(result.error || STRINGS.media.uploadFailed, 'error')
298
320
  }
299
321
  } catch (error) {
300
- showToast('Upload failed', 'error')
322
+ showToast(STRINGS.media.uploadFailed, 'error')
301
323
  } finally {
302
324
  setUploadProgress(null)
303
325
  }
@@ -326,13 +348,13 @@ export function MarkdownInlineEditor({
326
348
 
327
349
  if (editorInstanceRef.current) {
328
350
  editorInstanceRef.current.action(insert(imageMarkdown))
329
- showToast('Image uploaded and inserted', 'success')
351
+ showToast(STRINGS.media.imageInserted, 'success')
330
352
  }
331
353
  } else {
332
- showToast(result.error || 'Upload failed', 'error')
354
+ showToast(result.error || STRINGS.media.uploadFailed, 'error')
333
355
  }
334
356
  } catch (error) {
335
- showToast('Upload failed', 'error')
357
+ showToast(STRINGS.media.uploadFailed, 'error')
336
358
  } finally {
337
359
  setUploadProgress(null)
338
360
  }
@@ -422,6 +444,7 @@ export function MarkdownInlineEditor({
422
444
  onClick={() => handleInsertHeading(1)}
423
445
  title="Heading 1"
424
446
  active={activeFormats.heading === 1}
447
+ compact
425
448
  >
426
449
  <span class="text-xs font-bold">H1</span>
427
450
  </ToolbarButton>
@@ -429,6 +452,7 @@ export function MarkdownInlineEditor({
429
452
  onClick={() => handleInsertHeading(2)}
430
453
  title="Heading 2"
431
454
  active={activeFormats.heading === 2}
455
+ compact
432
456
  >
433
457
  <span class="text-xs font-bold">H2</span>
434
458
  </ToolbarButton>
@@ -436,6 +460,7 @@ export function MarkdownInlineEditor({
436
460
  onClick={() => handleInsertHeading(3)}
437
461
  title="Heading 3"
438
462
  active={activeFormats.heading === 3}
463
+ compact
439
464
  >
440
465
  <span class="text-xs font-bold">H3</span>
441
466
  </ToolbarButton>
@@ -443,6 +468,7 @@ export function MarkdownInlineEditor({
443
468
  onClick={() => handleInsertHeading(4)}
444
469
  title="Heading 4"
445
470
  active={activeFormats.heading === 4}
471
+ compact
446
472
  >
447
473
  <span class="text-xs font-bold">H4</span>
448
474
  </ToolbarButton>
@@ -583,17 +609,6 @@ export function MarkdownInlineEditor({
583
609
  </div>
584
610
  </div>
585
611
 
586
- {/* Link edit popover — rendered outside the toolbar stacking context so it layers above the sidebar */}
587
- {linkPopoverState && (
588
- <LinkEditPopover
589
- initialUrl={linkPopoverState.href}
590
- suggestions={pageSuggestions}
591
- onApply={applyLink}
592
- onRemove={linkPopoverState.isEdit ? removeLink : undefined}
593
- onClose={closeLinkPopover}
594
- />
595
- )}
596
-
597
612
  {/* Editor */}
598
613
  <div
599
614
  class={`flex-1 min-h-0 overflow-auto relative transition-colors ${isDragging ? 'bg-cms-primary/10' : ''}`}
@@ -602,6 +617,18 @@ export function MarkdownInlineEditor({
602
617
  onDrop={handleDrop}
603
618
  onPaste={handlePaste}
604
619
  >
620
+ {/* Link edit popover — overlays the top of the editor so it doesn't push content */}
621
+ {linkPopoverState && (
622
+ <div class="absolute top-0 left-0 right-0 z-40">
623
+ <LinkEditPopover
624
+ initialUrl={linkPopoverState.href}
625
+ suggestions={pageSuggestions}
626
+ onApply={applyLink}
627
+ onRemove={linkPopoverState.isEdit ? removeLink : undefined}
628
+ onClose={closeLinkPopover}
629
+ />
630
+ </div>
631
+ )}
605
632
  <div
606
633
  ref={editorRef}
607
634
  class="milkdown-editor milkdown-dark prose prose-invert prose-sm max-w-none p-6 min-h-75 focus:outline-none"
@@ -666,6 +693,8 @@ interface ToolbarButtonProps {
666
693
  title: string
667
694
  children: preact.ComponentChildren
668
695
  active?: boolean
696
+ /** Use tighter padding suitable for text-only labels like H1/H2 */
697
+ compact?: boolean
669
698
  }
670
699
 
671
700
  function ToolbarButton({
@@ -673,16 +702,19 @@ function ToolbarButton({
673
702
  title,
674
703
  children,
675
704
  active,
705
+ compact,
676
706
  }: ToolbarButtonProps) {
677
707
  return (
678
708
  <button
679
709
  type="button"
680
710
  onClick={onClick}
681
- class={`p-2 rounded-cms-sm transition-colors ${
711
+ class={cn(
712
+ 'rounded-cms-sm transition-colors',
713
+ compact ? 'px-2 pt-0.5 pb-1' : 'p-2',
682
714
  active
683
715
  ? 'bg-cms-primary text-cms-primary-text'
684
- : 'hover:bg-white/10 text-white/70 hover:text-white'
685
- }`}
716
+ : 'hover:bg-white/10 text-white/70 hover:text-white',
717
+ )}
686
718
  title={title}
687
719
  data-cms-ui
688
720
  >
@@ -302,7 +302,7 @@ function InlineInput({ value, onChange, placeholder }: { value: string; onChange
302
302
  if (el.value !== value) onChange(el.value)
303
303
  }}
304
304
  placeholder={placeholder}
305
- class="w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white/80 placeholder:text-white/30 outline-none focus:border-white/25 transition-colors"
305
+ class="w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white/80 placeholder:text-white/30 outline-none focus:border-white/40 transition-colors"
306
306
  />
307
307
  )
308
308
  }
@@ -317,7 +317,7 @@ const INLINE_INPUT_TYPES: Record<string, string> = {
317
317
  tel: 'tel',
318
318
  }
319
319
  const inputClass =
320
- 'w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white/80 placeholder:text-white/30 outline-none focus:border-white/25 transition-colors'
320
+ 'w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white/80 placeholder:text-white/30 outline-none focus:border-white/40 transition-colors'
321
321
 
322
322
  function InlinePropField(
323
323
  { name, value, propDef, onChange }: { name: string; value: string; propDef?: ComponentProp; onChange: (v: string) => void },
@@ -434,7 +434,7 @@ export function MdxBlockCard({ componentName, props, hasExpressions, slotContent
434
434
  data-cms-ui
435
435
  >
436
436
  {/* Header */}
437
- <div class="flex items-center justify-between px-4 py-2.5 bg-white/5 border-b border-white/10 rounded-t-cms-md">
437
+ <div class="flex items-center justify-between px-4 py-2 bg-white/5 border-b border-white/10 rounded-t-cms-md">
438
438
  <div class="flex items-center gap-2">
439
439
  <MdxComponentIcon />
440
440
  <span class="text-[13px] font-semibold text-white">{componentName}</span>
@@ -461,7 +461,7 @@ export function MdxBlockCard({ componentName, props, hasExpressions, slotContent
461
461
 
462
462
  {/* Slot content editor */}
463
463
  {hasSlotContent && (
464
- <div class="px-4 py-2.5 border-b border-white/10" data-mdx-action="children">
464
+ <div class="px-4 py-2 border-b border-white/10" data-mdx-action="children">
465
465
  <MiniMilkdownEditor
466
466
  value={slotContent || ''}
467
467
  onChange={onSlotContentChange}
@@ -88,7 +88,7 @@ export function MdxComponentPicker({ onInsert }: MdxComponentPickerProps) {
88
88
  onInput={(e) => setChildrenValue((e.target as HTMLTextAreaElement).value)}
89
89
  placeholder="Enter content..."
90
90
  rows={3}
91
- class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md resize-y"
91
+ class="w-full px-4 py-2 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md resize-y"
92
92
  />
93
93
  </div>
94
94
  )}
@@ -121,7 +121,7 @@ export function MdxComponentPicker({ onInsert }: MdxComponentPickerProps) {
121
121
  value={searchQuery}
122
122
  onInput={(e) => setSearchQuery((e.target as HTMLInputElement).value)}
123
123
  placeholder="Search components..."
124
- class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
124
+ class="w-full px-4 py-2 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
125
125
  />
126
126
  </div>
127
127
  <div class="p-5 overflow-y-auto flex-1">
@@ -3,6 +3,7 @@ import { Z_INDEX } from '../constants'
3
3
  import { useSearchFilter } from '../hooks/useSearchFilter'
4
4
  import { createMediaFolder, fetchMediaLibrary, fetchProjectImages, uploadMedia } from '../markdown-api'
5
5
  import { config, isMediaLibraryOpen, mediaLibraryState, resetMediaLibraryState, showToast } from '../signals'
6
+ import { STRINGS } from '../strings'
6
7
  import type { MediaFolderItem, MediaItem, MediaTypeFilter } from '../types'
7
8
  import { CloseButton, PrimaryButton } from './modal-shell'
8
9
  import { Spinner } from './spinner'
@@ -77,7 +78,7 @@ export function MediaLibrary() {
77
78
  }
78
79
  setAllItems(combined)
79
80
  } catch (error) {
80
- showToast('Failed to load media library', 'error')
81
+ showToast(STRINGS.media.loadFailed, 'error')
81
82
  } finally {
82
83
  setIsLoading(false)
83
84
  }
@@ -129,7 +130,7 @@ export function MediaLibrary() {
129
130
  if (uploadContext && result.url.startsWith('./')) {
130
131
  insertCallback?.(result.url, '')
131
132
  handleClose()
132
- showToast('Uploaded next to entry', 'success')
133
+ showToast(STRINGS.media.uploadedNextToEntry, 'success')
133
134
  return
134
135
  }
135
136
 
@@ -142,12 +143,12 @@ export function MediaLibrary() {
142
143
  folder: currentFolder || undefined,
143
144
  }
144
145
  setAllItems((prev) => [newItem, ...prev])
145
- showToast('File uploaded successfully', 'success')
146
+ showToast(STRINGS.media.uploadSucceeded, 'success')
146
147
  } else {
147
- showToast(result.error || 'Upload failed', 'error')
148
+ showToast(result.error || STRINGS.media.uploadFailed, 'error')
148
149
  }
149
150
  } catch (error) {
150
- showToast('Upload failed', 'error')
151
+ showToast(STRINGS.media.uploadFailed, 'error')
151
152
  } finally {
152
153
  setUploadProgress(null)
153
154
  }
@@ -167,7 +168,7 @@ export function MediaLibrary() {
167
168
 
168
169
  const file = e.dataTransfer?.files[0]
169
170
  if (!file || !file.type.startsWith('image/')) {
170
- showToast('Please drop an image file', 'error')
171
+ showToast(STRINGS.media.imageRequired, 'error')
171
172
  return
172
173
  }
173
174
  await handleUploadFile(file)
@@ -182,7 +183,7 @@ export function MediaLibrary() {
182
183
  const name = newFolderName.trim()
183
184
  if (!name) return
184
185
  if (/[/\\:*?"<>|]/.test(name)) {
185
- showToast('Invalid folder name', 'error')
186
+ showToast(STRINGS.media.invalidFolderName, 'error')
186
187
  return
187
188
  }
188
189
  const folderPath = currentFolder ? `${currentFolder}/${name}` : name
@@ -190,12 +191,12 @@ export function MediaLibrary() {
190
191
  const result = await createMediaFolder(config.value, folderPath)
191
192
  if (result.success) {
192
193
  setFolders((prev) => [...prev, { name, path: folderPath }].sort((a, b) => a.name.localeCompare(b.name)))
193
- showToast('Folder created', 'success')
194
+ showToast(STRINGS.media.folderCreated, 'success')
194
195
  } else {
195
- showToast(result.error || 'Failed to create folder', 'error')
196
+ showToast(result.error || STRINGS.media.folderCreateFailed, 'error')
196
197
  }
197
198
  } catch {
198
- showToast('Failed to create folder', 'error')
199
+ showToast(STRINGS.media.folderCreateFailed, 'error')
199
200
  }
200
201
  setNewFolderName('')
201
202
  setShowNewFolderInput(false)
@@ -236,7 +237,7 @@ export function MediaLibrary() {
236
237
  data-cms-ui
237
238
  >
238
239
  {/* Header */}
239
- <div class="flex items-center justify-between p-5 border-b border-white/10">
240
+ <div class="flex items-center justify-between px-5 py-4 border-b border-white/10">
240
241
  <h2 class="text-lg font-semibold text-white">Media Library</h2>
241
242
  <CloseButton onClick={handleClose} />
242
243
  </div>
@@ -287,17 +288,17 @@ export function MediaLibrary() {
287
288
  placeholder="Search files..."
288
289
  value={searchQuery}
289
290
  onInput={(e) => setSearchQuery((e.target as HTMLInputElement).value)}
290
- class="flex-1 px-4 py-2.5 bg-white/10 border border-white/20 rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
291
+ class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
291
292
  data-cms-ui
292
293
  />
293
294
  <button
294
295
  type="button"
295
296
  onClick={() => setShowNewFolderInput((v) => !v)}
296
- class="px-3 py-2.5 bg-white/10 text-white/70 rounded-cms-md text-sm hover:bg-white/15 hover:text-white transition-colors border border-white/20"
297
+ class="px-3 py-2.5 bg-white/10 text-white/70 rounded-cms-sm text-sm hover:bg-white/15 hover:text-white transition-colors border border-white/20"
297
298
  title="New folder"
298
299
  data-cms-ui
299
300
  >
300
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
301
+ <svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
301
302
  <path
302
303
  stroke-linecap="round"
303
304
  stroke-linejoin="round"
@@ -363,10 +364,10 @@ export function MediaLibrary() {
363
364
  setNewFolderName('')
364
365
  }
365
366
  }}
366
- class="flex-1 px-3 py-1.5 bg-white/10 border border-white/20 rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40"
367
+ class="flex-1 px-3 py-1.5 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40"
367
368
  data-cms-ui
368
369
  />
369
- <PrimaryButton onClick={handleCreateFolder} className="px-3 py-1.5 rounded-cms-md text-xs">
370
+ <PrimaryButton onClick={handleCreateFolder} className="px-3 py-1.5 rounded-cms-sm text-xs">
370
371
  Create
371
372
  </PrimaryButton>
372
373
  <button
@@ -430,7 +431,7 @@ export function MediaLibrary() {
430
431
  <button
431
432
  type="button"
432
433
  onClick={() => navigateToFolder(folder.path)}
433
- class="w-full h-full rounded-cms-md overflow-hidden border-2 border-white/10 hover:border-white/30 focus:outline-none focus:border-white/30 transition-all bg-white/5 hover:bg-white/10 flex flex-col items-center justify-center gap-2"
434
+ class="w-full h-full rounded-cms-sm overflow-hidden border-2 border-white/10 hover:border-white/30 focus:outline-none focus:border-white/40 transition-all bg-white/5 hover:bg-white/10 flex flex-col items-center justify-center gap-2"
434
435
  data-cms-ui
435
436
  >
436
437
  <svg
@@ -459,7 +460,7 @@ export function MediaLibrary() {
459
460
  <button
460
461
  type="button"
461
462
  onClick={() => handleSelectImage(item)}
462
- class="w-full h-full rounded-cms-md overflow-hidden border-2 border-white/10 hover:border-cms-primary focus:outline-none focus:border-cms-primary transition-all"
463
+ class="w-full h-full rounded-cms-sm overflow-hidden border-2 border-white/10 hover:border-cms-primary focus:outline-none focus:border-white/40 transition-all"
463
464
  data-cms-ui
464
465
  >
465
466
  {item.contentType.startsWith('image/')