@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.
- package/dist/editor.js +12447 -12473
- package/package.json +1 -1
- package/src/collection-scanner.ts +69 -35
- package/src/dev-middleware.ts +86 -45
- package/src/editor/components/attribute-editor.tsx +2 -10
- package/src/editor/components/bg-image-overlay.tsx +2 -10
- package/src/editor/components/collections-browser.tsx +8 -24
- package/src/editor/components/color-toolbar.tsx +2 -9
- package/src/editor/components/confirm-dialog.tsx +4 -12
- package/src/editor/components/create-page-modal.tsx +23 -19
- package/src/editor/components/fields.tsx +158 -124
- package/src/editor/components/frontmatter-fields.tsx +9 -1
- package/src/editor/components/link-edit-popover.tsx +3 -6
- package/src/editor/components/markdown-editor-overlay.tsx +44 -46
- package/src/editor/components/markdown-inline-editor.tsx +2 -1
- package/src/editor/components/mdx-block-view.tsx +1 -0
- package/src/editor/components/mdx-component-picker.tsx +3 -6
- package/src/editor/components/media-library.tsx +15 -37
- package/src/editor/components/modal-shell.tsx +34 -5
- package/src/editor/components/prop-editor.tsx +77 -73
- package/src/editor/components/reference-picker.tsx +6 -24
- package/src/editor/components/seo-editor.tsx +4 -10
- package/src/editor/components/spinner.tsx +17 -0
- package/src/editor/components/toolbar.tsx +2 -1
- package/src/editor/constants.ts +33 -0
- package/src/editor/hooks/index.ts +4 -0
- package/src/editor/hooks/useClickOutsideEscape.ts +43 -0
- package/src/editor/hooks/useSearchFilter.ts +21 -0
- package/src/field-types.ts +2 -0
- package/src/handlers/api-routes.ts +10 -16
- package/src/html-processor.ts +75 -94
- package/src/index.ts +5 -0
- package/src/manifest-writer.ts +15 -0
- package/src/rehype-cms-marker.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',
|
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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 =
|
|
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
|
|
50
|
+
const handleEntryClick = (slug: string, sourcePath: string) => {
|
|
55
51
|
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
|
-
}
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
</
|
|
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="
|
|
257
|
-
|
|
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="
|
|
367
|
-
|
|
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.
|