@nuasite/cms 0.28.0 → 0.29.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/dist/editor.js +5600 -5560
- package/package.json +1 -1
- package/src/collection-scanner.ts +69 -35
- package/src/dev-middleware.ts +86 -45
- package/src/editor/components/collections-browser.tsx +3 -11
- package/src/editor/components/create-page-modal.tsx +22 -10
- package/src/editor/components/fields.tsx +24 -8
- package/src/editor/components/frontmatter-fields.tsx +9 -1
- package/src/editor/components/markdown-editor-overlay.tsx +16 -12
- package/src/editor/components/mdx-block-view.tsx +1 -0
- package/src/editor/components/prop-editor.tsx +10 -5
- package/src/field-types.ts +2 -0
- package/src/handlers/api-routes.ts +10 -16
- package/src/manifest-writer.ts +15 -0
- package/src/types.ts +1 -0
- package/src/vite-plugin.ts +18 -72
- package/src/content-invalidator.ts +0 -134
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
590
|
+
* Apply a parsed per-field config map to scanned collection definitions.
|
|
597
591
|
*/
|
|
598
|
-
function
|
|
592
|
+
function applyPerFieldConfig<T>(
|
|
599
593
|
collections: Record<string, CollectionDefinition>,
|
|
600
|
-
|
|
594
|
+
configMap: Map<string, Map<string, T>>,
|
|
595
|
+
apply: (field: FieldDefinition, value: T) => void,
|
|
601
596
|
): void {
|
|
602
|
-
const
|
|
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,
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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)
|
package/src/dev-middleware.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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(
|
|
209
|
-
manifest.collectionDefinitions =
|
|
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',
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
selectBrowserCollection,
|
|
12
12
|
selectedBrowserCollection,
|
|
13
13
|
} from '../signals'
|
|
14
|
-
import { savePendingEntryNavigation } from '../storage'
|
|
15
14
|
import { ChevronRightIcon, CollectionIcon } from './create-page-modal'
|
|
16
15
|
import { CloseButton, ModalBackdrop, ModalHeader } from './modal-shell'
|
|
17
16
|
|
|
@@ -51,16 +50,9 @@ export function CollectionsBrowser() {
|
|
|
51
50
|
const def = selectedDef
|
|
52
51
|
if (!def) return null
|
|
53
52
|
|
|
54
|
-
const handleEntryClick = (slug: string, sourcePath: string
|
|
53
|
+
const handleEntryClick = (slug: string, sourcePath: string) => {
|
|
55
54
|
closeCollectionsBrowser()
|
|
56
|
-
|
|
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
|
-
}
|
|
55
|
+
openMarkdownEditorForEntry(selected, slug, sourcePath, def)
|
|
64
56
|
}
|
|
65
57
|
|
|
66
58
|
const handleAddNew = () => {
|
|
@@ -201,7 +193,7 @@ export function CollectionsBrowser() {
|
|
|
201
193
|
: (
|
|
202
194
|
<button
|
|
203
195
|
type="button"
|
|
204
|
-
onClick={() => handleEntryClick(entry.slug, entry.sourcePath
|
|
196
|
+
onClick={() => handleEntryClick(entry.slug, entry.sourcePath)}
|
|
205
197
|
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
198
|
data-cms-ui
|
|
207
199
|
>
|
|
@@ -206,7 +206,12 @@ function NewPageForm() {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
return (
|
|
209
|
-
|
|
209
|
+
<form
|
|
210
|
+
onSubmit={(e) => {
|
|
211
|
+
e.preventDefault()
|
|
212
|
+
handleSubmit()
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
210
215
|
<ModalHeader title="New Blank Page" onBack={() => setCreatePageMode('pick')} onClose={() => resetCreatePageState()} />
|
|
211
216
|
<div class="p-5 space-y-4">
|
|
212
217
|
<Field label="Title">
|
|
@@ -215,6 +220,7 @@ function NewPageForm() {
|
|
|
215
220
|
value={form.title}
|
|
216
221
|
onInput={(e) => form.handleTitleChange((e.target as HTMLInputElement).value)}
|
|
217
222
|
placeholder="My New Page"
|
|
223
|
+
required
|
|
218
224
|
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
225
|
autoFocus
|
|
220
226
|
data-cms-ui
|
|
@@ -229,6 +235,7 @@ function NewPageForm() {
|
|
|
229
235
|
value={form.slug}
|
|
230
236
|
onInput={(e) => form.handleSlugChange((e.target as HTMLInputElement).value)}
|
|
231
237
|
placeholder="my-new-page"
|
|
238
|
+
required
|
|
232
239
|
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
240
|
data-cms-ui
|
|
234
241
|
/>
|
|
@@ -253,16 +260,15 @@ function NewPageForm() {
|
|
|
253
260
|
<ModalFooter>
|
|
254
261
|
<CancelButton onClick={() => resetCreatePageState()} />
|
|
255
262
|
<button
|
|
256
|
-
type="
|
|
257
|
-
|
|
258
|
-
disabled={!canSubmit}
|
|
263
|
+
type="submit"
|
|
264
|
+
disabled={!!form.slugError || form.slugChecking || form.isSubmitting}
|
|
259
265
|
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
266
|
data-cms-ui
|
|
261
267
|
>
|
|
262
268
|
Create Page
|
|
263
269
|
</button>
|
|
264
270
|
</ModalFooter>
|
|
265
|
-
|
|
271
|
+
</form>
|
|
266
272
|
)
|
|
267
273
|
}
|
|
268
274
|
|
|
@@ -302,7 +308,12 @@ function DuplicatePageForm() {
|
|
|
302
308
|
}
|
|
303
309
|
|
|
304
310
|
return (
|
|
305
|
-
|
|
311
|
+
<form
|
|
312
|
+
onSubmit={(e) => {
|
|
313
|
+
e.preventDefault()
|
|
314
|
+
handleSubmit()
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
306
317
|
<ModalHeader title="Duplicate Page" onBack={() => setCreatePageMode('pick')} onClose={() => resetCreatePageState()} />
|
|
307
318
|
<div class="p-5 space-y-4">
|
|
308
319
|
<Field label="Source Page">
|
|
@@ -312,6 +323,7 @@ function DuplicatePageForm() {
|
|
|
312
323
|
setSourcePath((e.target as HTMLSelectElement).value)
|
|
313
324
|
form.resetSlugManual()
|
|
314
325
|
}}
|
|
326
|
+
required
|
|
315
327
|
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
328
|
data-cms-ui
|
|
317
329
|
>
|
|
@@ -342,6 +354,7 @@ function DuplicatePageForm() {
|
|
|
342
354
|
value={form.slug}
|
|
343
355
|
onInput={(e) => form.handleSlugChange((e.target as HTMLInputElement).value)}
|
|
344
356
|
placeholder="new-page-slug"
|
|
357
|
+
required
|
|
345
358
|
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
359
|
data-cms-ui
|
|
347
360
|
/>
|
|
@@ -363,16 +376,15 @@ function DuplicatePageForm() {
|
|
|
363
376
|
<ModalFooter>
|
|
364
377
|
<CancelButton onClick={() => resetCreatePageState()} />
|
|
365
378
|
<button
|
|
366
|
-
type="
|
|
367
|
-
|
|
368
|
-
disabled={!canSubmit}
|
|
379
|
+
type="submit"
|
|
380
|
+
disabled={!!form.slugError || form.slugChecking || form.isSubmitting}
|
|
369
381
|
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
382
|
data-cms-ui
|
|
371
383
|
>
|
|
372
384
|
Duplicate Page
|
|
373
385
|
</button>
|
|
374
386
|
</ModalFooter>
|
|
375
|
-
|
|
387
|
+
</form>
|
|
376
388
|
)
|
|
377
389
|
}
|
|
378
390
|
|
|
@@ -45,9 +45,12 @@ export interface TextFieldProps {
|
|
|
45
45
|
isDirty?: boolean
|
|
46
46
|
onReset?: () => void
|
|
47
47
|
inputType?: string
|
|
48
|
+
required?: boolean
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
export function TextField(
|
|
51
|
+
export function TextField(
|
|
52
|
+
{ label, value, placeholder, maxLength, minLength, onChange, isDirty, onReset, inputType = 'text', required }: TextFieldProps,
|
|
53
|
+
) {
|
|
51
54
|
return (
|
|
52
55
|
<div class="space-y-1.5">
|
|
53
56
|
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
@@ -57,6 +60,7 @@ export function TextField({ label, value, placeholder, maxLength, minLength, onC
|
|
|
57
60
|
placeholder={placeholder}
|
|
58
61
|
maxLength={maxLength}
|
|
59
62
|
minLength={minLength}
|
|
63
|
+
required={required}
|
|
60
64
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
61
65
|
class={cn(
|
|
62
66
|
'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',
|
|
@@ -82,9 +86,10 @@ export interface ImageFieldProps {
|
|
|
82
86
|
onBrowse: () => void
|
|
83
87
|
isDirty?: boolean
|
|
84
88
|
onReset?: () => void
|
|
89
|
+
required?: boolean
|
|
85
90
|
}
|
|
86
91
|
|
|
87
|
-
export function ImageField({ label, value, placeholder, onChange, onBrowse, isDirty, onReset }: ImageFieldProps) {
|
|
92
|
+
export function ImageField({ label, value, placeholder, onChange, onBrowse, isDirty, onReset, required }: ImageFieldProps) {
|
|
88
93
|
const hasImage = !!value && value.length > 0
|
|
89
94
|
|
|
90
95
|
return (
|
|
@@ -114,6 +119,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
|
|
|
114
119
|
type="text"
|
|
115
120
|
value={value ?? ''}
|
|
116
121
|
placeholder={placeholder}
|
|
122
|
+
required={required}
|
|
117
123
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
118
124
|
class={cn(
|
|
119
125
|
'flex-1 min-w-0 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',
|
|
@@ -147,9 +153,10 @@ export interface ColorFieldProps {
|
|
|
147
153
|
onChange: (value: string) => void
|
|
148
154
|
isDirty?: boolean
|
|
149
155
|
onReset?: () => void
|
|
156
|
+
required?: boolean
|
|
150
157
|
}
|
|
151
158
|
|
|
152
|
-
export function ColorField({ label, value, placeholder, onChange, isDirty, onReset }: ColorFieldProps) {
|
|
159
|
+
export function ColorField({ label, value, placeholder, onChange, isDirty, onReset, required }: ColorFieldProps) {
|
|
153
160
|
const colorValue = value || '#000000'
|
|
154
161
|
// Validate hex for the native picker (must be #rrggbb)
|
|
155
162
|
const isValidHex = /^#[0-9a-fA-F]{6}$/.test(colorValue)
|
|
@@ -170,6 +177,7 @@ export function ColorField({ label, value, placeholder, onChange, isDirty, onRes
|
|
|
170
177
|
type="text"
|
|
171
178
|
value={value ?? ''}
|
|
172
179
|
placeholder={placeholder ?? '#000000'}
|
|
180
|
+
required={required}
|
|
173
181
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
174
182
|
class={cn(
|
|
175
183
|
'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',
|
|
@@ -278,9 +286,10 @@ export interface NumberFieldProps {
|
|
|
278
286
|
onChange: (value: number | undefined) => void
|
|
279
287
|
isDirty?: boolean
|
|
280
288
|
onReset?: () => void
|
|
289
|
+
required?: boolean
|
|
281
290
|
}
|
|
282
291
|
|
|
283
|
-
export function NumberField({ label, value, placeholder, min, max, step, onChange, isDirty, onReset }: NumberFieldProps) {
|
|
292
|
+
export function NumberField({ label, value, placeholder, min, max, step, onChange, isDirty, onReset, required }: NumberFieldProps) {
|
|
284
293
|
return (
|
|
285
294
|
<div class="space-y-1.5">
|
|
286
295
|
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
@@ -291,6 +300,7 @@ export function NumberField({ label, value, placeholder, min, max, step, onChang
|
|
|
291
300
|
min={min}
|
|
292
301
|
max={max}
|
|
293
302
|
step={step}
|
|
303
|
+
required={required}
|
|
294
304
|
onInput={(e) => {
|
|
295
305
|
const val = (e.target as HTMLInputElement).value
|
|
296
306
|
onChange(val === '' ? undefined : Number(val))
|
|
@@ -336,9 +346,10 @@ export interface ComboBoxFieldProps {
|
|
|
336
346
|
onChange: (value: string) => void
|
|
337
347
|
isDirty?: boolean
|
|
338
348
|
onReset?: () => void
|
|
349
|
+
required?: boolean
|
|
339
350
|
}
|
|
340
351
|
|
|
341
|
-
export function ComboBoxField({ label, value, placeholder, options, onChange, isDirty, onReset }: ComboBoxFieldProps) {
|
|
352
|
+
export function ComboBoxField({ label, value, placeholder, options, onChange, isDirty, onReset, required }: ComboBoxFieldProps) {
|
|
342
353
|
const [query, setQuery] = useState('')
|
|
343
354
|
const [isOpen, setIsOpen] = useState(false)
|
|
344
355
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
@@ -378,6 +389,13 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
378
389
|
}, [onChange])
|
|
379
390
|
|
|
380
391
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
392
|
+
if (e.key === 'Enter') {
|
|
393
|
+
e.preventDefault()
|
|
394
|
+
if (isOpen && highlightedIndex >= 0 && filtered[highlightedIndex]) {
|
|
395
|
+
selectOption(filtered[highlightedIndex]!.value)
|
|
396
|
+
}
|
|
397
|
+
return
|
|
398
|
+
}
|
|
381
399
|
if (!isOpen || filtered.length === 0) return
|
|
382
400
|
if (e.key === 'ArrowDown') {
|
|
383
401
|
e.preventDefault()
|
|
@@ -385,9 +403,6 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
385
403
|
} else if (e.key === 'ArrowUp') {
|
|
386
404
|
e.preventDefault()
|
|
387
405
|
setHighlightedIndex(i => Math.max(i - 1, 0))
|
|
388
|
-
} else if (e.key === 'Enter' && highlightedIndex >= 0) {
|
|
389
|
-
e.preventDefault()
|
|
390
|
-
selectOption(filtered[highlightedIndex]!.value)
|
|
391
406
|
} else if (e.key === 'Escape') {
|
|
392
407
|
setIsOpen(false)
|
|
393
408
|
}
|
|
@@ -411,6 +426,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
|
|
|
411
426
|
type="text"
|
|
412
427
|
value={value ?? ''}
|
|
413
428
|
placeholder={placeholder}
|
|
429
|
+
required={required}
|
|
414
430
|
onInput={handleInput}
|
|
415
431
|
onFocus={handleFocus}
|
|
416
432
|
onBlur={handleBlur}
|
|
@@ -394,7 +394,7 @@ export function SchemaFrontmatterField({
|
|
|
394
394
|
value,
|
|
395
395
|
onChange,
|
|
396
396
|
}: SchemaFrontmatterFieldProps) {
|
|
397
|
-
const label = formatFieldLabel(field.name)
|
|
397
|
+
const label = field.required ? `${formatFieldLabel(field.name)} *` : formatFieldLabel(field.name)
|
|
398
398
|
const hints = field.hints
|
|
399
399
|
|
|
400
400
|
switch (field.type) {
|
|
@@ -410,6 +410,7 @@ export function SchemaFrontmatterField({
|
|
|
410
410
|
minLength={hints?.minLength as number | undefined}
|
|
411
411
|
onChange={(v) => onChange(v)}
|
|
412
412
|
inputType={field.type === 'text' ? undefined : field.type}
|
|
413
|
+
required={field.required}
|
|
413
414
|
/>
|
|
414
415
|
)
|
|
415
416
|
|
|
@@ -425,6 +426,7 @@ export function SchemaFrontmatterField({
|
|
|
425
426
|
onChange(url)
|
|
426
427
|
})
|
|
427
428
|
}}
|
|
429
|
+
required={field.required}
|
|
428
430
|
/>
|
|
429
431
|
)
|
|
430
432
|
|
|
@@ -435,6 +437,7 @@ export function SchemaFrontmatterField({
|
|
|
435
437
|
value={(value as string) ?? ''}
|
|
436
438
|
placeholder={getPlaceholder(field)}
|
|
437
439
|
onChange={(v) => onChange(v)}
|
|
440
|
+
required={field.required}
|
|
438
441
|
/>
|
|
439
442
|
)
|
|
440
443
|
|
|
@@ -448,6 +451,7 @@ export function SchemaFrontmatterField({
|
|
|
448
451
|
placeholder={hints?.placeholder ?? getPlaceholder(field)}
|
|
449
452
|
rows={hints?.rows ?? 3}
|
|
450
453
|
maxLength={hints?.maxLength as number | undefined}
|
|
454
|
+
required={field.required}
|
|
451
455
|
class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40 resize-none"
|
|
452
456
|
data-cms-ui
|
|
453
457
|
/>
|
|
@@ -465,6 +469,7 @@ export function SchemaFrontmatterField({
|
|
|
465
469
|
value={(value as string) ?? ''}
|
|
466
470
|
min={hints?.min != null ? String(hints.min) : undefined}
|
|
467
471
|
max={hints?.max != null ? String(hints.max) : undefined}
|
|
472
|
+
required={field.required}
|
|
468
473
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
469
474
|
class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white focus:outline-none focus:border-white/40"
|
|
470
475
|
data-cms-ui
|
|
@@ -482,6 +487,7 @@ export function SchemaFrontmatterField({
|
|
|
482
487
|
max={typeof hints?.max === 'number' ? hints.max : undefined}
|
|
483
488
|
step={hints?.step}
|
|
484
489
|
onChange={(v) => onChange(v ?? 0)}
|
|
490
|
+
required={field.required}
|
|
485
491
|
/>
|
|
486
492
|
)
|
|
487
493
|
|
|
@@ -505,6 +511,7 @@ export function SchemaFrontmatterField({
|
|
|
505
511
|
label: opt,
|
|
506
512
|
}))}
|
|
507
513
|
onChange={(v) => onChange(v)}
|
|
514
|
+
required={field.required}
|
|
508
515
|
/>
|
|
509
516
|
)
|
|
510
517
|
|
|
@@ -517,6 +524,7 @@ export function SchemaFrontmatterField({
|
|
|
517
524
|
placeholder={`Select ${label.toLowerCase()}...`}
|
|
518
525
|
options={refOptions}
|
|
519
526
|
onChange={(v) => onChange(v)}
|
|
527
|
+
required={field.required}
|
|
520
528
|
/>
|
|
521
529
|
)
|
|
522
530
|
}
|