@nuasite/cms 0.28.0 → 0.30.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 +12447 -12473
  2. package/package.json +1 -1
  3. package/src/collection-scanner.ts +69 -35
  4. package/src/dev-middleware.ts +86 -45
  5. package/src/editor/components/attribute-editor.tsx +2 -10
  6. package/src/editor/components/bg-image-overlay.tsx +2 -10
  7. package/src/editor/components/collections-browser.tsx +8 -24
  8. package/src/editor/components/color-toolbar.tsx +2 -9
  9. package/src/editor/components/confirm-dialog.tsx +4 -12
  10. package/src/editor/components/create-page-modal.tsx +23 -19
  11. package/src/editor/components/fields.tsx +158 -124
  12. package/src/editor/components/frontmatter-fields.tsx +9 -1
  13. package/src/editor/components/link-edit-popover.tsx +3 -6
  14. package/src/editor/components/markdown-editor-overlay.tsx +44 -46
  15. package/src/editor/components/markdown-inline-editor.tsx +2 -1
  16. package/src/editor/components/mdx-block-view.tsx +1 -0
  17. package/src/editor/components/mdx-component-picker.tsx +3 -6
  18. package/src/editor/components/media-library.tsx +15 -37
  19. package/src/editor/components/modal-shell.tsx +34 -5
  20. package/src/editor/components/prop-editor.tsx +77 -73
  21. package/src/editor/components/reference-picker.tsx +6 -24
  22. package/src/editor/components/seo-editor.tsx +4 -10
  23. package/src/editor/components/spinner.tsx +17 -0
  24. package/src/editor/components/toolbar.tsx +2 -1
  25. package/src/editor/constants.ts +33 -0
  26. package/src/editor/hooks/index.ts +4 -0
  27. package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
  28. package/src/editor/hooks/useSearchFilter.ts +21 -0
  29. package/src/field-types.ts +2 -0
  30. package/src/handlers/api-routes.ts +10 -16
  31. package/src/html-processor.ts +75 -94
  32. package/src/index.ts +5 -0
  33. package/src/manifest-writer.ts +15 -0
  34. package/src/rehype-cms-marker.ts +15 -0
  35. package/src/types.ts +1 -0
  36. package/src/vite-plugin.ts +18 -72
  37. package/src/content-invalidator.ts +0 -134
package/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.28.0",
17
+ "version": "0.30.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -469,7 +469,7 @@ function parseContentConfigReferences(
469
469
  }
470
470
 
471
471
  /** Valid field type names exported by `n` helper from @nuasite/cms */
472
- const FIELD_HELPER_TYPES = new Set(['text', 'number', 'image', 'url', 'email', 'color', 'date', 'datetime', 'time', 'textarea'])
472
+ const FIELD_HELPER_TYPES = new Set(['text', 'number', 'image', 'url', 'email', 'tel', 'color', 'date', 'datetime', 'time', 'textarea'])
473
473
 
474
474
  /**
475
475
  * Parse the content config file to extract explicit field type hints:
@@ -562,17 +562,8 @@ function applyCollectionOrderBy(
562
562
  }
563
563
  }
564
564
 
565
- /**
566
- * Extract all top-level field names from a schema body string.
567
- * Matches `fieldName:` patterns at the start of lines within z.object({...}).
568
- */
569
- function extractSchemaFieldNames(schemaBody: string): Set<string> {
570
- const names = new Set<string>()
571
- for (const m of schemaBody.matchAll(/^\s*(\w+)\s*:/gm)) {
572
- names.add(m[1]!)
573
- }
574
- return names
575
- }
565
+ /** Match `fieldName:` patterns at the start of lines within a schema body. */
566
+ const SCHEMA_FIELD_PATTERN = /^\s*(\w+)\s*:/gm
576
567
 
577
568
  /**
578
569
  * When a content config schema exists, filter scanned fields to only include
@@ -586,34 +577,45 @@ function filterFieldsBySchema(
586
577
  for (const { collectionName, schemaBody } of schemaBlocks) {
587
578
  const def = collections[collectionName]
588
579
  if (!def) continue
589
- const schemaNames = extractSchemaFieldNames(schemaBody)
580
+ const schemaNames = new Set<string>()
581
+ for (const m of schemaBody.matchAll(SCHEMA_FIELD_PATTERN)) {
582
+ schemaNames.add(m[1]!)
583
+ }
590
584
  if (schemaNames.size === 0) continue
591
585
  def.fields = def.fields.filter(f => schemaNames.has(f.name))
592
586
  }
593
587
  }
594
588
 
595
589
  /**
596
- * Apply field type overrides from config parsing to scanned collections.
590
+ * Apply a parsed per-field config map to scanned collection definitions.
597
591
  */
598
- function applyConfigFieldTypes(
592
+ function applyPerFieldConfig<T>(
599
593
  collections: Record<string, CollectionDefinition>,
600
- schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
594
+ configMap: Map<string, Map<string, T>>,
595
+ apply: (field: FieldDefinition, value: T) => void,
601
596
  ): void {
602
- const configTypes = parseContentConfigFieldTypes(schemaBlocks)
603
- for (const [collectionName, fieldTypes] of configTypes) {
597
+ for (const [collectionName, fieldMap] of configMap) {
604
598
  const def = collections[collectionName]
605
599
  if (!def) continue
606
- for (const [fieldName, override] of fieldTypes) {
600
+ for (const [fieldName, value] of fieldMap) {
607
601
  const field = def.fields.find(f => f.name === fieldName)
608
602
  if (!field) continue
609
- field.type = override.type
610
- if (override.options) {
611
- field.options = override.options
612
- }
603
+ apply(field, value)
613
604
  }
614
605
  }
615
606
  }
616
607
 
608
+ /** Apply field type overrides from config parsing to scanned collections. */
609
+ function applyConfigFieldTypes(
610
+ collections: Record<string, CollectionDefinition>,
611
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
612
+ ): void {
613
+ applyPerFieldConfig(collections, parseContentConfigFieldTypes(schemaBlocks), (field, override) => {
614
+ field.type = override.type
615
+ if (override.options) field.options = override.options
616
+ })
617
+ }
618
+
617
619
  /** All recognized hint keys */
618
620
  const VALID_HINT_KEYS = new Set(['min', 'max', 'step', 'placeholder', 'maxLength', 'minLength', 'rows', 'accept'])
619
621
  /** Subset of hint keys that take numeric values */
@@ -672,22 +674,53 @@ function parseContentConfigFieldHints(
672
674
  }
673
675
 
674
676
  /**
675
- * Apply field hints from content config parsing to scanned collections.
677
+ * Parse required/optional status from schema blocks.
678
+ * In Zod, fields are required by default. `.optional()`, `.nullable()`, and `.default(...)` make them not required.
676
679
  */
680
+ function parseContentConfigRequiredFields(
681
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
682
+ ): Map<string, Map<string, boolean>> {
683
+ const result = new Map<string, Map<string, boolean>>()
684
+
685
+ for (const { collectionName, schemaBody } of schemaBlocks) {
686
+ const fields = new Map<string, boolean>()
687
+
688
+ const fieldMatches = [...schemaBody.matchAll(SCHEMA_FIELD_PATTERN)]
689
+ for (let i = 0; i < fieldMatches.length; i++) {
690
+ const fieldName = fieldMatches[i]![1]!
691
+ const start = fieldMatches[i]!.index!
692
+ const end = i + 1 < fieldMatches.length ? fieldMatches[i + 1]!.index! : schemaBody.length
693
+ const fieldSource = schemaBody.slice(start, end)
694
+
695
+ const isOptional = /\.(optional|nullable|default)\s*\(/.test(fieldSource)
696
+ fields.set(fieldName, !isOptional)
697
+ }
698
+
699
+ if (fields.size > 0) {
700
+ result.set(collectionName, fields)
701
+ }
702
+ }
703
+ return result
704
+ }
705
+
706
+ /** Apply field hints from content config parsing to scanned collections. */
677
707
  function applyConfigFieldHints(
678
708
  collections: Record<string, CollectionDefinition>,
679
709
  schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
680
710
  ): void {
681
- const configHints = parseContentConfigFieldHints(schemaBlocks)
682
- for (const [collectionName, fieldHints] of configHints) {
683
- const def = collections[collectionName]
684
- if (!def) continue
685
- for (const [fieldName, hints] of fieldHints) {
686
- const field = def.fields.find(f => f.name === fieldName)
687
- if (!field) continue
688
- field.hints = hints
689
- }
690
- }
711
+ applyPerFieldConfig(collections, parseContentConfigFieldHints(schemaBlocks), (field, hints) => {
712
+ field.hints = hints
713
+ })
714
+ }
715
+
716
+ /** Apply required/optional status from content config to scanned collections. */
717
+ function applyConfigRequiredFields(
718
+ collections: Record<string, CollectionDefinition>,
719
+ schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
720
+ ): void {
721
+ applyPerFieldConfig(collections, parseContentConfigRequiredFields(schemaBlocks), (field, required) => {
722
+ field.required = required
723
+ })
691
724
  }
692
725
 
693
726
  /**
@@ -925,11 +958,12 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
925
958
  // Content directory doesn't exist or isn't readable
926
959
  }
927
960
 
928
- // Post-scan: apply explicit type hints, field hints, detect references, derived fields, and ordering
961
+ // Post-scan: apply explicit type hints, field hints, required status, detect references, derived fields, and ordering
929
962
  const schemaBlocks = await parseContentConfigSchemaBlocks()
930
963
  filterFieldsBySchema(collections, schemaBlocks)
931
964
  applyConfigFieldTypes(collections, schemaBlocks)
932
965
  applyConfigFieldHints(collections, schemaBlocks)
966
+ applyConfigRequiredFields(collections, schemaBlocks)
933
967
  await detectReferenceFields(collections, schemaBlocks)
934
968
  detectDerivedHrefFields(collections)
935
969
  applyCollectionOrderBy(collections, schemaBlocks)
@@ -2,7 +2,6 @@ import fs from 'node:fs/promises'
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http'
3
3
  import path from 'node:path'
4
4
  import { getProjectRoot } from './config'
5
- import { awaitNextContentStoreUpdate } from './content-invalidator'
6
5
  import { handleCmsApiRoute } from './handlers/api-routes'
7
6
  import { buildMapPattern, detectArrayPattern, extractArrayElementProps, parseInlineArrayName } from './handlers/array-ops'
8
7
  import {
@@ -104,43 +103,6 @@ export function createDevMiddleware(
104
103
  if (options.enableCmsApi) {
105
104
  const projectRoot = getProjectRoot()
106
105
 
107
- /**
108
- * Hold the HTTP response for a `markdown/update` (or equivalent) call
109
- * until Astro's content layer has actually re-synced the edited file.
110
- *
111
- * The race we're fixing: handleUpdateMarkdown writes the file and
112
- * returns immediately, the editor then triggers a full-reload, and
113
- * the next page render reads a still-cached `astro:data-layer-content`
114
- * virtual module — so the user sees their edit disappear until Astro's
115
- * async chain (glob loader → syncData → 500 ms save debounce → atomic
116
- * write → fs.watch → invalidateModule) finally catches up.
117
- *
118
- * The fix, end to end:
119
- *
120
- * 1. `server.watcher.emit('change', fullPath)` kicks Astro's glob
121
- * loader directly. It is registered on this exact watcher (see
122
- * astro/dist/core/dev/dev.js — `viteServer.watcher` is handed to
123
- * `globalContentLayer.init`), so synthetic change events fire its
124
- * `onChange` handler and trigger `syncData`. This also works
125
- * around Vite's bundled chokidar missing some edits.
126
- * 2. `awaitNextContentStoreUpdate` parks until the shared data-store
127
- * watcher (in `vite-plugin.ts`) observes the resulting atomic
128
- * write and finishes invalidating the SSR module graph.
129
- * 3. Only then do we return — so the subsequent full-reload lands
130
- * on a page that will re-execute with fresh content.
131
- *
132
- * The timeout fallback covers edits that legitimately do not rewrite
133
- * the data store (Astro's MutableDataStore skips identical writes).
134
- * In that case no fs.watch event will ever fire, and 3 s is plenty of
135
- * budget before we give up and let the response through anyway.
136
- */
137
- const notifyContentChanged = async (filePath: string): Promise<void> => {
138
- const fullPath = path.resolve(projectRoot, filePath)
139
- const waiter = awaitNextContentStoreUpdate(3000)
140
- server.watcher?.emit('change', fullPath)
141
- await waiter
142
- }
143
-
144
106
  server.middlewares.use((req, res, next) => {
145
107
  const url = req.url || ''
146
108
  if (!url.startsWith('/_nua/cms/')) {
@@ -152,7 +114,7 @@ export function createDevMiddleware(
152
114
 
153
115
  const route = url.replace('/_nua/cms/', '').split('?')[0]!
154
116
 
155
- handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter, notifyContentChanged)
117
+ handleCmsApiRoute(route, req, res, manifestWriter, config.contentDir, options.mediaAdapter)
156
118
  .catch((error) => {
157
119
  console.error('[astro-cms] API error:', error)
158
120
  sendError(res, 'Internal server error', 500)
@@ -177,11 +139,31 @@ export function createDevMiddleware(
177
139
  pageMap.set(pagePath, { pathname: pagePath })
178
140
  }
179
141
 
180
- // 2. Add collection entry pages from collection definitions
142
+ // 2. Add collection entry pages from collection definitions,
143
+ // pre-populating pathnames from filesystem routes so the collections
144
+ // browser can redirect to detail pages without visiting them first.
145
+ // We build patched copies rather than mutating the originals so that
146
+ // heuristic pathnames don't persist if the route file is later removed.
181
147
  const collectionDefs = manifestWriter.getCollectionDefinitions()
182
- for (const def of Object.values(collectionDefs)) {
183
- if (def.entries) {
184
- for (const entry of def.entries) {
148
+ const collectionRoutes = await discoverCollectionRoutes()
149
+ const responseCollectionDefs: Record<string, CollectionDefinition> = {}
150
+
151
+ for (const [name, def] of Object.entries(collectionDefs)) {
152
+ const routePrefix = collectionRoutes.get(def.name)
153
+ const needsPatching = routePrefix && def.entries?.some(e => !e.pathname)
154
+
155
+ if (!needsPatching) {
156
+ responseCollectionDefs[name] = def
157
+ } else {
158
+ responseCollectionDefs[name] = {
159
+ ...def,
160
+ entries: def.entries!.map(e => e.pathname ? e : { ...e, pathname: `${routePrefix}${e.slug}` }),
161
+ }
162
+ }
163
+
164
+ const entries = responseCollectionDefs[name].entries
165
+ if (entries) {
166
+ for (const entry of entries) {
185
167
  if (entry.pathname) {
186
168
  pageMap.set(entry.pathname, { pathname: entry.pathname, title: entry.title })
187
169
  }
@@ -205,8 +187,8 @@ export function createDevMiddleware(
205
187
  availableTextStyles: manifestWriter.getAvailableTextStyles(),
206
188
  pages,
207
189
  }
208
- if (Object.keys(collectionDefs).length > 0) {
209
- manifest.collectionDefinitions = collectionDefs
190
+ if (Object.keys(responseCollectionDefs).length > 0) {
191
+ manifest.collectionDefinitions = responseCollectionDefs
210
192
  }
211
193
  const mdxComponents = manifestWriter.getMdxComponents()
212
194
  if (mdxComponents) {
@@ -626,6 +608,65 @@ async function discoverPagesFromFilesystem(): Promise<string[]> {
626
608
  return pages
627
609
  }
628
610
 
611
+ /** Cached result of collection route discovery; invalidated by file watcher */
612
+ let collectionRoutesCache: Map<string, string> | null = null
613
+
614
+ /** Invalidate the cached collection routes (called from vite-plugin when route files change) */
615
+ export function invalidateCollectionRoutesCache() {
616
+ collectionRoutesCache = null
617
+ }
618
+
619
+ /**
620
+ * Discover collection route patterns by scanning src/pages for dynamic route files
621
+ * (e.g. [slug].astro) that call getCollection(). Returns a map from collection name
622
+ * to the URL prefix (e.g. 'blog' → '/blog/'). Result is cached after first call.
623
+ */
624
+ async function discoverCollectionRoutes(): Promise<Map<string, string>> {
625
+ if (collectionRoutesCache) return collectionRoutesCache
626
+
627
+ const projectRoot = getProjectRoot()
628
+ const pagesDir = path.join(projectRoot, 'src', 'pages')
629
+ const routes = new Map<string, string>()
630
+
631
+ try {
632
+ await fs.access(pagesDir)
633
+ } catch {
634
+ collectionRoutesCache = routes
635
+ return routes
636
+ }
637
+
638
+ async function walk(dir: string, urlPrefix: string) {
639
+ const entries = await fs.readdir(dir, { withFileTypes: true })
640
+ for (const entry of entries) {
641
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
642
+
643
+ const fullPath = path.join(dir, entry.name)
644
+ if (entry.isDirectory()) {
645
+ // Skip directories with dynamic segments
646
+ if (entry.name.includes('[')) continue
647
+ await walk(fullPath, `${urlPrefix}${entry.name}/`)
648
+ } else {
649
+ const ext = path.extname(entry.name)
650
+ if (!PAGE_EXTENSIONS.has(ext)) continue
651
+ // Only interested in dynamic route files
652
+ if (!entry.name.includes('[')) continue
653
+
654
+ try {
655
+ const content = await fs.readFile(fullPath, 'utf-8')
656
+ const match = content.match(/getCollection\(\s*['"](\w+)['"]\s*\)/)
657
+ if (match?.[1]) {
658
+ routes.set(match[1], urlPrefix)
659
+ }
660
+ } catch { /* skip unreadable files */ }
661
+ }
662
+ }
663
+ }
664
+
665
+ await walk(pagesDir, '/')
666
+ collectionRoutesCache = routes
667
+ return routes
668
+ }
669
+
629
670
  function mediaMimeFromExt(ext: string): string {
630
671
  const map: Record<string, string> = {
631
672
  '.jpg': 'image/jpeg',
@@ -4,6 +4,7 @@ import * as signals from '../signals'
4
4
  import { saveAttributeEditsToStorage } from '../storage'
5
5
  import type { Attribute } from '../types'
6
6
  import { ComboBoxField, FieldLabel, ImageField, NumberField, SelectField, TextField, ToggleField } from './fields'
7
+ import { CloseButton } from './modal-shell'
7
8
 
8
9
  // ============================================================================
9
10
  // Attribute Field Configuration
@@ -542,16 +543,7 @@ export function AttributeEditor({ onClose }: AttributeEditorProps) {
542
543
  </span>
543
544
  )}
544
545
  </div>
545
- <button
546
- type="button"
547
- onClick={handleClose}
548
- class="text-white/50 hover:text-white cursor-pointer p-1.5 hover:bg-white/10 rounded-full transition-colors"
549
- data-cms-ui
550
- >
551
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
552
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
553
- </svg>
554
- </button>
546
+ <CloseButton onClick={handleClose} size="sm" />
555
547
  </div>
556
548
 
557
549
  {/* Content */}
@@ -5,6 +5,7 @@ import { cn } from '../lib/cn'
5
5
  import * as signals from '../signals'
6
6
  import { saveBgImageEditsToStorage } from '../storage'
7
7
  import { FieldLabel, ImageField, SelectField } from './fields'
8
+ import { CloseButton } from './modal-shell'
8
9
 
9
10
  export interface BgImageOverlayProps {
10
11
  visible: boolean
@@ -232,16 +233,7 @@ export function BgImageOverlay({ visible, rect, element, cmsId }: BgImageOverlay
232
233
  </span>
233
234
  )}
234
235
  </div>
235
- <button
236
- type="button"
237
- onClick={handleClose}
238
- class="text-white/50 hover:text-white cursor-pointer p-1.5 hover:bg-white/10 rounded-full transition-colors"
239
- data-cms-ui
240
- >
241
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
242
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
243
- </svg>
244
- </button>
236
+ <CloseButton onClick={handleClose} size="sm" />
245
237
  </div>
246
238
 
247
239
  {/* Content */}
@@ -1,5 +1,6 @@
1
1
  import { signal } from '@preact/signals'
2
2
  import { useMemo, useState } from 'preact/hooks'
3
+ import { useSearchFilter } from '../hooks/useSearchFilter'
3
4
  import { deleteMarkdownPage } from '../markdown-api'
4
5
  import {
5
6
  closeCollectionsBrowser,
@@ -11,9 +12,8 @@ import {
11
12
  selectBrowserCollection,
12
13
  selectedBrowserCollection,
13
14
  } from '../signals'
14
- import { savePendingEntryNavigation } from '../storage'
15
15
  import { ChevronRightIcon, CollectionIcon } from './create-page-modal'
16
- import { CloseButton, ModalBackdrop, ModalHeader } from './modal-shell'
16
+ import { CloseButton, ModalBackdrop, ModalHeader, PrimaryButton } from './modal-shell'
17
17
 
18
18
  const deletingEntry = signal<string | null>(null)
19
19
  const confirmDeleteSlug = signal<string | null>(null)
@@ -34,11 +34,7 @@ export function CollectionsBrowser() {
34
34
  const selectedDef = selected ? collectionDefinitions[selected] : undefined
35
35
  const entries = selectedDef?.entries ?? EMPTY_ENTRIES
36
36
 
37
- const filteredEntries = useMemo(() => {
38
- if (!search) return entries
39
- const q = search.toLowerCase()
40
- return entries.filter(e => (e.title || '').toLowerCase().includes(q) || e.slug.toLowerCase().includes(q))
41
- }, [entries, search])
37
+ const filteredEntries = useSearchFilter(entries, search, e => `${e.title ?? ''} ${e.slug}`)
42
38
 
43
39
  if (!visible) return null
44
40
 
@@ -51,16 +47,9 @@ export function CollectionsBrowser() {
51
47
  const def = selectedDef
52
48
  if (!def) return null
53
49
 
54
- const handleEntryClick = (slug: string, sourcePath: string, pathname?: string) => {
50
+ const handleEntryClick = (slug: string, sourcePath: string) => {
55
51
  closeCollectionsBrowser()
56
- if (pathname) {
57
- // Navigate to the collection detail page to edit inline.
58
- savePendingEntryNavigation({ collectionName: selected, slug, sourcePath, pathname })
59
- window.location.href = pathname
60
- } else {
61
- // No detail page exists for this entry — open the markdown editor inline.
62
- openMarkdownEditorForEntry(selected, slug, sourcePath, def)
63
- }
52
+ openMarkdownEditorForEntry(selected, slug, sourcePath, def)
64
53
  }
65
54
 
66
55
  const handleAddNew = () => {
@@ -124,14 +113,9 @@ export function CollectionsBrowser() {
124
113
  <h2 class="text-lg font-semibold text-white">{def.label}</h2>
125
114
  </div>
126
115
  <div class="flex items-center gap-2">
127
- <button
128
- type="button"
129
- onClick={handleAddNew}
130
- class="px-3 py-1.5 text-sm font-medium text-black bg-cms-primary hover:bg-cms-primary/80 rounded-cms-pill transition-colors"
131
- data-cms-ui
132
- >
116
+ <PrimaryButton onClick={handleAddNew} className="px-3 py-1.5">
133
117
  + Add New
134
- </button>
118
+ </PrimaryButton>
135
119
  <CloseButton onClick={handleClose} />
136
120
  </div>
137
121
  </div>
@@ -201,7 +185,7 @@ export function CollectionsBrowser() {
201
185
  : (
202
186
  <button
203
187
  type="button"
204
- onClick={() => handleEntryClick(entry.slug, entry.sourcePath, entry.pathname)}
188
+ onClick={() => handleEntryClick(entry.slug, entry.sourcePath)}
205
189
  class="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/10 rounded-cms-lg transition-colors text-left group"
206
190
  data-cms-ui
207
191
  >
@@ -12,6 +12,7 @@ import { CSS, Z_INDEX } from '../constants'
12
12
  import { cn } from '../lib/cn'
13
13
  import * as signals from '../signals'
14
14
  import type { Attribute, AvailableColors } from '../types'
15
+ import { CloseButton } from './modal-shell'
15
16
 
16
17
  export interface ColorToolbarProps {
17
18
  visible: boolean
@@ -247,15 +248,7 @@ export function ColorToolbar({
247
248
  {/* Header */}
248
249
  <div class="flex items-center justify-between">
249
250
  <span class="font-medium text-white">Element Colors</span>
250
- <button
251
- type="button"
252
- onClick={onClose}
253
- class="text-white/50 hover:text-white cursor-pointer p-1.5 hover:bg-white/10 rounded-full transition-colors"
254
- >
255
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
256
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
257
- </svg>
258
- </button>
251
+ {onClose && <CloseButton onClick={onClose} size="sm" />}
259
252
  </div>
260
253
 
261
254
  {/* Background color section */}
@@ -1,6 +1,6 @@
1
1
  import { cn } from '../lib/cn'
2
2
  import { confirmDialogState } from '../signals'
3
- import { ModalBackdrop } from './modal-shell'
3
+ import { CancelButton, ModalBackdrop, ModalFooter } from './modal-shell'
4
4
 
5
5
  export function ConfirmDialog() {
6
6
  const state = confirmDialogState.value
@@ -27,16 +27,8 @@ export function ConfirmDialog() {
27
27
  <p class="text-sm text-white/70 leading-relaxed">{state.message}</p>
28
28
  </div>
29
29
 
30
- {/* Footer */}
31
- <div class="flex items-center justify-end gap-3 p-5 pt-4 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
32
- <button
33
- type="button"
34
- onClick={handleCancel}
35
- class="px-4 py-2.5 text-sm text-white/80 font-medium rounded-cms-pill hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
36
- data-cms-ui
37
- >
38
- {state.cancelLabel}
39
- </button>
30
+ <ModalFooter>
31
+ <CancelButton onClick={handleCancel} label={state.cancelLabel} />
40
32
  <button
41
33
  type="button"
42
34
  onClick={handleConfirm}
@@ -50,7 +42,7 @@ export function ConfirmDialog() {
50
42
  >
51
43
  {state.confirmLabel}
52
44
  </button>
53
- </div>
45
+ </ModalFooter>
54
46
  </ModalBackdrop>
55
47
  )
56
48
  }
@@ -14,6 +14,7 @@ import {
14
14
  } from '../signals'
15
15
  import type { LayoutInfo } from '../types'
16
16
  import { CancelButton, ModalBackdrop, ModalFooter, ModalHeader } from './modal-shell'
17
+ import { Spinner } from './spinner'
17
18
 
18
19
  export function CreatePageModal() {
19
20
  const visible = isCreatePageOpen.value
@@ -206,7 +207,12 @@ function NewPageForm() {
206
207
  }
207
208
 
208
209
  return (
209
- <>
210
+ <form
211
+ onSubmit={(e) => {
212
+ e.preventDefault()
213
+ handleSubmit()
214
+ }}
215
+ >
210
216
  <ModalHeader title="New Blank Page" onBack={() => setCreatePageMode('pick')} onClose={() => resetCreatePageState()} />
211
217
  <div class="p-5 space-y-4">
212
218
  <Field label="Title">
@@ -215,6 +221,7 @@ function NewPageForm() {
215
221
  value={form.title}
216
222
  onInput={(e) => form.handleTitleChange((e.target as HTMLInputElement).value)}
217
223
  placeholder="My New Page"
224
+ required
218
225
  class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
219
226
  autoFocus
220
227
  data-cms-ui
@@ -229,6 +236,7 @@ function NewPageForm() {
229
236
  value={form.slug}
230
237
  onInput={(e) => form.handleSlugChange((e.target as HTMLInputElement).value)}
231
238
  placeholder="my-new-page"
239
+ required
232
240
  class="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
233
241
  data-cms-ui
234
242
  />
@@ -253,16 +261,15 @@ function NewPageForm() {
253
261
  <ModalFooter>
254
262
  <CancelButton onClick={() => resetCreatePageState()} />
255
263
  <button
256
- type="button"
257
- onClick={handleSubmit}
258
- disabled={!canSubmit}
264
+ type="submit"
265
+ disabled={!!form.slugError || form.slugChecking || form.isSubmitting}
259
266
  class="px-5 py-2.5 text-sm font-medium rounded-cms-pill transition-colors cursor-pointer bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover disabled:opacity-40 disabled:cursor-not-allowed"
260
267
  data-cms-ui
261
268
  >
262
269
  Create Page
263
270
  </button>
264
271
  </ModalFooter>
265
- </>
272
+ </form>
266
273
  )
267
274
  }
268
275
 
@@ -302,7 +309,12 @@ function DuplicatePageForm() {
302
309
  }
303
310
 
304
311
  return (
305
- <>
312
+ <form
313
+ onSubmit={(e) => {
314
+ e.preventDefault()
315
+ handleSubmit()
316
+ }}
317
+ >
306
318
  <ModalHeader title="Duplicate Page" onBack={() => setCreatePageMode('pick')} onClose={() => resetCreatePageState()} />
307
319
  <div class="p-5 space-y-4">
308
320
  <Field label="Source Page">
@@ -312,6 +324,7 @@ function DuplicatePageForm() {
312
324
  setSourcePath((e.target as HTMLSelectElement).value)
313
325
  form.resetSlugManual()
314
326
  }}
327
+ required
315
328
  class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white focus:outline-none focus:border-cms-primary/50"
316
329
  data-cms-ui
317
330
  >
@@ -342,6 +355,7 @@ function DuplicatePageForm() {
342
355
  value={form.slug}
343
356
  onInput={(e) => form.handleSlugChange((e.target as HTMLInputElement).value)}
344
357
  placeholder="new-page-slug"
358
+ required
345
359
  class="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-cms-md text-white placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
346
360
  data-cms-ui
347
361
  />
@@ -363,16 +377,15 @@ function DuplicatePageForm() {
363
377
  <ModalFooter>
364
378
  <CancelButton onClick={() => resetCreatePageState()} />
365
379
  <button
366
- type="button"
367
- onClick={handleSubmit}
368
- disabled={!canSubmit}
380
+ type="submit"
381
+ disabled={!!form.slugError || form.slugChecking || form.isSubmitting}
369
382
  class="px-5 py-2.5 text-sm font-medium rounded-cms-pill transition-colors cursor-pointer bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover disabled:opacity-40 disabled:cursor-not-allowed"
370
383
  data-cms-ui
371
384
  >
372
385
  Duplicate Page
373
386
  </button>
374
387
  </ModalFooter>
375
- </>
388
+ </form>
376
389
  )
377
390
  }
378
391
 
@@ -482,15 +495,6 @@ function PageCreatingOverlay({ phase, slug }: { phase: 'creating' | 'preparing';
482
495
  )
483
496
  }
484
497
 
485
- function Spinner() {
486
- return (
487
- <svg class="w-8 h-8 animate-spin text-cms-primary" viewBox="0 0 24 24" fill="none" data-cms-ui>
488
- <circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
489
- <path class="opacity-80" d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
490
- </svg>
491
- )
492
- }
493
-
494
498
  /**
495
499
  * Poll a URL until the dev server returns a non-404 response,
496
500
  * so navigation doesn't land on a 404 while Astro processes the new file.