@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
|
@@ -20,6 +20,7 @@ import { config, mdxComponentPickerOpen, openMediaLibraryWithCallback, resetMark
|
|
|
20
20
|
import { LinkEditPopover } from './link-edit-popover'
|
|
21
21
|
import { MdxComponentIcon } from './mdx-block-view'
|
|
22
22
|
import { MdxComponentPicker } from './mdx-component-picker'
|
|
23
|
+
import { Spinner } from './spinner'
|
|
23
24
|
|
|
24
25
|
export interface MarkdownInlineEditorProps {
|
|
25
26
|
elementId: string
|
|
@@ -649,7 +650,7 @@ export function MarkdownInlineEditor({
|
|
|
649
650
|
{/* Loading state */}
|
|
650
651
|
{!isReady && (
|
|
651
652
|
<div class="absolute inset-0 flex items-center justify-center bg-cms-dark/80">
|
|
652
|
-
<
|
|
653
|
+
<Spinner size="lg" className="text-cms-primary" />
|
|
653
654
|
</div>
|
|
654
655
|
)}
|
|
655
656
|
</div>
|
|
@@ -314,6 +314,7 @@ const INLINE_INPUT_TYPES: Record<string, string> = {
|
|
|
314
314
|
datetime: 'datetime-local',
|
|
315
315
|
time: 'time',
|
|
316
316
|
email: 'email',
|
|
317
|
+
tel: 'tel',
|
|
317
318
|
}
|
|
318
319
|
const inputClass =
|
|
319
320
|
'w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white/80 placeholder:text-white/30 outline-none focus:border-white/25 transition-colors'
|
|
@@ -2,7 +2,7 @@ import { useState } from 'preact/hooks'
|
|
|
2
2
|
import { getComponentDefinitions } from '../manifest'
|
|
3
3
|
import { manifest, mdxComponentPickerOpen } from '../signals'
|
|
4
4
|
import { ComponentCard, getDefaultProps } from './component-card'
|
|
5
|
-
import { CancelButton, ModalBackdrop, ModalHeader } from './modal-shell'
|
|
5
|
+
import { CancelButton, ModalBackdrop, ModalHeader, PrimaryButton } from './modal-shell'
|
|
6
6
|
import { PropEditor } from './prop-editor'
|
|
7
7
|
|
|
8
8
|
export interface MdxComponentPickerProps {
|
|
@@ -106,12 +106,9 @@ export function MdxComponentPicker({ onInsert }: MdxComponentPickerProps) {
|
|
|
106
106
|
</div>
|
|
107
107
|
<div class="px-5 py-4 border-t border-white/10 flex gap-2 justify-end">
|
|
108
108
|
<CancelButton onClick={resetSelection} label="Back" />
|
|
109
|
-
<
|
|
110
|
-
onClick={handleConfirmInsert}
|
|
111
|
-
class="px-4 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill cursor-pointer hover:bg-cms-primary-hover transition-all font-medium"
|
|
112
|
-
>
|
|
109
|
+
<PrimaryButton onClick={handleConfirmInsert} className="px-4">
|
|
113
110
|
Insert
|
|
114
|
-
</
|
|
111
|
+
</PrimaryButton>
|
|
115
112
|
</div>
|
|
116
113
|
</>
|
|
117
114
|
)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
2
|
import { Z_INDEX } from '../constants'
|
|
3
|
+
import { useSearchFilter } from '../hooks/useSearchFilter'
|
|
3
4
|
import { createMediaFolder, fetchMediaLibrary, fetchProjectImages, uploadMedia } from '../markdown-api'
|
|
4
5
|
import { config, isMediaLibraryOpen, mediaLibraryState, resetMediaLibraryState, showToast } from '../signals'
|
|
5
6
|
import type { MediaFolderItem, MediaItem, MediaTypeFilter } from '../types'
|
|
7
|
+
import { CloseButton, PrimaryButton } from './modal-shell'
|
|
8
|
+
import { Spinner } from './spinner'
|
|
6
9
|
|
|
7
10
|
const VECTOR_TYPES = new Set(['image/svg+xml', 'image/x-icon'])
|
|
8
11
|
|
|
@@ -187,18 +190,12 @@ export function MediaLibrary() {
|
|
|
187
190
|
setShowNewFolderInput(false)
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
// Client-side filtering:
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (searchQuery) {
|
|
197
|
-
const q = searchQuery.toLowerCase()
|
|
198
|
-
items = items.filter((item) => item.filename.toLowerCase().includes(q))
|
|
199
|
-
}
|
|
200
|
-
return items
|
|
201
|
-
}, [searchQuery, typeFilter, allItems])
|
|
193
|
+
// Client-side filtering: type filter, then search query
|
|
194
|
+
const typeFiltered = useMemo(
|
|
195
|
+
() => typeFilter === 'all' ? allItems : allItems.filter(item => matchesTypeFilter(item.contentType, typeFilter)),
|
|
196
|
+
[typeFilter, allItems],
|
|
197
|
+
)
|
|
198
|
+
const filteredItems = useSearchFilter(typeFiltered, searchQuery, item => item.filename)
|
|
202
199
|
|
|
203
200
|
// Build breadcrumb segments
|
|
204
201
|
const breadcrumbs = useMemo(() => {
|
|
@@ -230,16 +227,7 @@ export function MediaLibrary() {
|
|
|
230
227
|
{/* Header */}
|
|
231
228
|
<div class="flex items-center justify-between p-5 border-b border-white/10">
|
|
232
229
|
<h2 class="text-lg font-semibold text-white">Media Library</h2>
|
|
233
|
-
<
|
|
234
|
-
type="button"
|
|
235
|
-
onClick={handleClose}
|
|
236
|
-
class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors"
|
|
237
|
-
data-cms-ui
|
|
238
|
-
>
|
|
239
|
-
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
240
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
241
|
-
</svg>
|
|
242
|
-
</button>
|
|
230
|
+
<CloseButton onClick={handleClose} />
|
|
243
231
|
</div>
|
|
244
232
|
|
|
245
233
|
{/* Breadcrumbs */}
|
|
@@ -307,14 +295,9 @@ export function MediaLibrary() {
|
|
|
307
295
|
/>
|
|
308
296
|
</svg>
|
|
309
297
|
</button>
|
|
310
|
-
<
|
|
311
|
-
type="button"
|
|
312
|
-
onClick={handleUploadClick}
|
|
313
|
-
class="px-5 py-2.5 bg-cms-primary text-cms-primary-text rounded-cms-pill text-sm font-medium hover:bg-cms-primary-hover transition-colors"
|
|
314
|
-
data-cms-ui
|
|
315
|
-
>
|
|
298
|
+
<PrimaryButton onClick={handleUploadClick}>
|
|
316
299
|
Upload
|
|
317
|
-
</
|
|
300
|
+
</PrimaryButton>
|
|
318
301
|
<input
|
|
319
302
|
ref={fileInputRef}
|
|
320
303
|
type="file"
|
|
@@ -372,14 +355,9 @@ export function MediaLibrary() {
|
|
|
372
355
|
class="flex-1 px-3 py-1.5 bg-white/10 border border-white/20 rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40"
|
|
373
356
|
data-cms-ui
|
|
374
357
|
/>
|
|
375
|
-
<
|
|
376
|
-
type="button"
|
|
377
|
-
onClick={handleCreateFolder}
|
|
378
|
-
class="px-3 py-1.5 bg-cms-primary text-cms-primary-text rounded-cms-md text-xs font-medium hover:bg-cms-primary-hover transition-colors"
|
|
379
|
-
data-cms-ui
|
|
380
|
-
>
|
|
358
|
+
<PrimaryButton onClick={handleCreateFolder} className="px-3 py-1.5 rounded-cms-md text-xs">
|
|
381
359
|
Create
|
|
382
|
-
</
|
|
360
|
+
</PrimaryButton>
|
|
383
361
|
<button
|
|
384
362
|
type="button"
|
|
385
363
|
onClick={() => {
|
|
@@ -414,7 +392,7 @@ export function MediaLibrary() {
|
|
|
414
392
|
{isLoading
|
|
415
393
|
? (
|
|
416
394
|
<div class="flex items-center justify-center h-48">
|
|
417
|
-
<
|
|
395
|
+
<Spinner size="xl" className="text-cms-primary" />
|
|
418
396
|
</div>
|
|
419
397
|
)
|
|
420
398
|
: folders.length === 0 && filteredItems.length === 0
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentChildren } from 'preact'
|
|
2
2
|
import { Z_INDEX } from '../constants'
|
|
3
|
+
import { cn } from '../lib/cn'
|
|
3
4
|
|
|
4
5
|
export function ModalBackdrop({ onClose, maxWidth = 'max-w-lg', extraClass, children }: {
|
|
5
6
|
onClose: () => void
|
|
@@ -15,7 +16,7 @@ export function ModalBackdrop({ onClose, maxWidth = 'max-w-lg', extraClass, chil
|
|
|
15
16
|
data-cms-ui
|
|
16
17
|
>
|
|
17
18
|
<div
|
|
18
|
-
class={
|
|
19
|
+
class={cn('bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] w-full border border-white/10', maxWidth, extraClass)}
|
|
19
20
|
onClick={(e) => e.stopPropagation()}
|
|
20
21
|
data-cms-ui
|
|
21
22
|
>
|
|
@@ -58,7 +59,7 @@ export function ModalFooter({ children }: { children: ComponentChildren }) {
|
|
|
58
59
|
)
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
export function CloseButton({ onClick }: { onClick: () => void }) {
|
|
62
|
+
export function CloseButton({ onClick, size = 'md' }: { onClick: () => void; size?: 'sm' | 'md' }) {
|
|
62
63
|
return (
|
|
63
64
|
<button
|
|
64
65
|
type="button"
|
|
@@ -66,22 +67,50 @@ export function CloseButton({ onClick }: { onClick: () => void }) {
|
|
|
66
67
|
class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors cursor-pointer"
|
|
67
68
|
data-cms-ui
|
|
68
69
|
>
|
|
69
|
-
<svg class=
|
|
70
|
+
<svg class={size === 'sm' ? 'w-4 h-4' : 'w-5 h-5'} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
70
71
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
71
72
|
</svg>
|
|
72
73
|
</button>
|
|
73
74
|
)
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
export function CancelButton({ onClick, label = 'Cancel' }: { onClick: () => void; label?: string }) {
|
|
77
|
+
export function CancelButton({ onClick, label = 'Cancel', className }: { onClick: () => void; label?: string; className?: string }) {
|
|
77
78
|
return (
|
|
78
79
|
<button
|
|
79
80
|
type="button"
|
|
80
81
|
onClick={onClick}
|
|
81
|
-
class=
|
|
82
|
+
class={cn(
|
|
83
|
+
'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',
|
|
84
|
+
className,
|
|
85
|
+
)}
|
|
82
86
|
data-cms-ui
|
|
83
87
|
>
|
|
84
88
|
{label}
|
|
85
89
|
</button>
|
|
86
90
|
)
|
|
87
91
|
}
|
|
92
|
+
|
|
93
|
+
export function PrimaryButton({ onClick, children, disabled, type = 'button', className }: {
|
|
94
|
+
onClick?: () => void
|
|
95
|
+
children: ComponentChildren
|
|
96
|
+
disabled?: boolean
|
|
97
|
+
type?: 'button' | 'submit'
|
|
98
|
+
className?: string
|
|
99
|
+
}) {
|
|
100
|
+
return (
|
|
101
|
+
<button
|
|
102
|
+
type={type}
|
|
103
|
+
onClick={onClick}
|
|
104
|
+
disabled={disabled}
|
|
105
|
+
class={cn(
|
|
106
|
+
'px-5 py-2.5 text-sm font-medium rounded-cms-pill transition-colors cursor-pointer',
|
|
107
|
+
'bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover',
|
|
108
|
+
'disabled:opacity-40 disabled:cursor-not-allowed',
|
|
109
|
+
className,
|
|
110
|
+
)}
|
|
111
|
+
data-cms-ui
|
|
112
|
+
>
|
|
113
|
+
{children}
|
|
114
|
+
</button>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { useMemo, useRef, useState } from 'preact/hooks'
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
2
|
import { slugify } from '../../shared'
|
|
3
|
+
import { useSearchFilter } from '../hooks/useSearchFilter'
|
|
3
4
|
import { getCollectionEntryOptions } from '../manifest'
|
|
4
5
|
import { manifest, openMediaLibraryWithCallback, pendingCollectionEntries } from '../signals'
|
|
5
6
|
import type { ComponentProp } from '../types'
|
|
7
|
+
import { DropdownPanel } from './fields'
|
|
6
8
|
import { SchemaFrontmatterField } from './frontmatter-fields'
|
|
7
9
|
|
|
8
10
|
export interface PropEditorProps {
|
|
@@ -181,20 +183,14 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
181
183
|
[collection, currentManifest],
|
|
182
184
|
)
|
|
183
185
|
const collectionDef = currentManifest?.collectionDefinitions?.[collection]
|
|
184
|
-
const
|
|
186
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
185
187
|
const [search, setSearch] = useState('')
|
|
186
188
|
const [isOpen, setIsOpen] = useState(false)
|
|
187
189
|
const [isCreating, setIsCreating] = useState(false)
|
|
188
190
|
const [newName, setNewName] = useState('')
|
|
189
191
|
const [formData, setFormData] = useState<Record<string, unknown>>({})
|
|
190
192
|
|
|
191
|
-
const filtered =
|
|
192
|
-
() =>
|
|
193
|
-
search
|
|
194
|
-
? options.filter(o => o.label.toLowerCase().includes(search.toLowerCase()) || o.value.toLowerCase().includes(search.toLowerCase()))
|
|
195
|
-
: options,
|
|
196
|
-
[options, search],
|
|
197
|
-
)
|
|
193
|
+
const filtered = useSearchFilter(options, search, o => `${o.label} ${o.value}`)
|
|
198
194
|
|
|
199
195
|
const selectedLabel = useMemo(
|
|
200
196
|
() => value ? (options.find(o => o.value === value)?.label ?? value) : '',
|
|
@@ -206,6 +202,8 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
206
202
|
[collectionDef],
|
|
207
203
|
)
|
|
208
204
|
|
|
205
|
+
const closeDropdown = useCallback(() => setIsOpen(false), [])
|
|
206
|
+
|
|
209
207
|
const resetCreateForm = () => {
|
|
210
208
|
setIsCreating(false)
|
|
211
209
|
setNewName('')
|
|
@@ -233,7 +231,13 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
233
231
|
if (isCreating) {
|
|
234
232
|
const slug = slugify(newName.trim())
|
|
235
233
|
return (
|
|
236
|
-
<
|
|
234
|
+
<form
|
|
235
|
+
class="p-3 bg-white/5 border border-white/15 rounded-cms-md space-y-3"
|
|
236
|
+
onSubmit={(e) => {
|
|
237
|
+
e.preventDefault()
|
|
238
|
+
handleCreate()
|
|
239
|
+
}}
|
|
240
|
+
>
|
|
237
241
|
<div class="flex items-center justify-between">
|
|
238
242
|
<span class="text-[12px] font-medium text-white/70">Create new entry</span>
|
|
239
243
|
{options.length > 0 && (
|
|
@@ -251,6 +255,7 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
251
255
|
value={newName}
|
|
252
256
|
onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
|
|
253
257
|
placeholder="Enter name..."
|
|
258
|
+
required
|
|
254
259
|
class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
|
|
255
260
|
autoFocus
|
|
256
261
|
/>
|
|
@@ -279,15 +284,13 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
279
284
|
Cancel
|
|
280
285
|
</button>
|
|
281
286
|
<button
|
|
282
|
-
type="
|
|
283
|
-
onClick={handleCreate}
|
|
284
|
-
disabled={!newName.trim()}
|
|
287
|
+
type="submit"
|
|
285
288
|
class="px-3 py-1.5 text-[12px] bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-md transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
|
286
289
|
>
|
|
287
290
|
Create
|
|
288
291
|
</button>
|
|
289
292
|
</div>
|
|
290
|
-
</
|
|
293
|
+
</form>
|
|
291
294
|
)
|
|
292
295
|
}
|
|
293
296
|
|
|
@@ -304,8 +307,9 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
304
307
|
}
|
|
305
308
|
|
|
306
309
|
return (
|
|
307
|
-
<div
|
|
310
|
+
<div>
|
|
308
311
|
<input
|
|
312
|
+
ref={inputRef}
|
|
309
313
|
type="text"
|
|
310
314
|
value={isOpen ? search : selectedLabel}
|
|
311
315
|
onInput={(e) => {
|
|
@@ -313,67 +317,67 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
313
317
|
setIsOpen(true)
|
|
314
318
|
}}
|
|
315
319
|
onFocus={() => setIsOpen(true)}
|
|
316
|
-
onBlur={(
|
|
317
|
-
const related = (e as FocusEvent).relatedTarget as Node | null
|
|
318
|
-
if (containerRef.current && related && containerRef.current.contains(related)) return
|
|
319
|
-
setIsOpen(false)
|
|
320
|
-
}}
|
|
320
|
+
onBlur={() => setTimeout(closeDropdown, 150)}
|
|
321
321
|
placeholder={`Select ${collection} entry...`}
|
|
322
322
|
class="w-full px-4 py-2.5 bg-white/10 border border-white/20 text-[13px] text-white placeholder:text-white/40 outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-md"
|
|
323
323
|
/>
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
<
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
324
|
+
<DropdownPanel
|
|
325
|
+
triggerRef={inputRef}
|
|
326
|
+
isOpen={isOpen}
|
|
327
|
+
onClose={closeDropdown}
|
|
328
|
+
maxHeight={192}
|
|
329
|
+
className="border border-white/20 rounded-cms-md"
|
|
330
|
+
>
|
|
331
|
+
{!required && (
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
335
|
+
onClick={() => {
|
|
336
|
+
onChange('')
|
|
337
|
+
setSearch('')
|
|
338
|
+
setIsOpen(false)
|
|
339
|
+
}}
|
|
340
|
+
class="w-full px-4 py-2 text-left text-[13px] text-white/50 hover:bg-white/10 transition-colors"
|
|
341
|
+
>
|
|
342
|
+
— None —
|
|
343
|
+
</button>
|
|
344
|
+
)}
|
|
345
|
+
{filtered.map((opt) => (
|
|
346
|
+
<button
|
|
347
|
+
key={opt.value}
|
|
348
|
+
type="button"
|
|
349
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
350
|
+
onClick={() => {
|
|
351
|
+
onChange(opt.value)
|
|
352
|
+
setSearch('')
|
|
353
|
+
setIsOpen(false)
|
|
354
|
+
}}
|
|
355
|
+
class={`w-full px-4 py-2 text-left text-[13px] transition-colors ${
|
|
356
|
+
opt.value === value ? 'bg-cms-primary/20 text-white' : 'text-white/80 hover:bg-white/10'
|
|
357
|
+
}`}
|
|
358
|
+
>
|
|
359
|
+
<div>{opt.label}</div>
|
|
360
|
+
{opt.label !== opt.value && <div class="text-[11px] text-white/40 font-mono">{opt.value}</div>}
|
|
361
|
+
</button>
|
|
362
|
+
))}
|
|
363
|
+
{filtered.length === 0 && <div class="px-4 py-2 text-[13px] text-white/40">No entries found</div>}
|
|
364
|
+
{collectionDef && (
|
|
365
|
+
<button
|
|
366
|
+
type="button"
|
|
367
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
368
|
+
onClick={() => {
|
|
369
|
+
setIsCreating(true)
|
|
370
|
+
setIsOpen(false)
|
|
371
|
+
}}
|
|
372
|
+
class="w-full px-4 py-2 text-left text-[13px] text-cms-primary hover:bg-cms-primary/10 transition-colors border-t border-white/10 flex items-center gap-2"
|
|
373
|
+
>
|
|
374
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
|
375
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
|
376
|
+
</svg>
|
|
377
|
+
Create new {collectionDef.label?.toLowerCase() ?? collection}
|
|
378
|
+
</button>
|
|
379
|
+
)}
|
|
380
|
+
</DropdownPanel>
|
|
377
381
|
</div>
|
|
378
382
|
)
|
|
379
383
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
2
|
import { clampPanelPosition, Z_INDEX } from '../constants'
|
|
3
|
+
import { useClickOutsideEscape } from '../hooks/useClickOutsideEscape'
|
|
4
|
+
import { useSearchFilter } from '../hooks/useSearchFilter'
|
|
3
5
|
import { getCollectionEntryOptions } from '../manifest'
|
|
4
6
|
import { updateMarkdownPage } from '../markdown-api'
|
|
5
7
|
import { closeReferencePicker, config, manifest, referencePickerState, showToast } from '../signals'
|
|
8
|
+
import { Spinner } from './spinner'
|
|
6
9
|
|
|
7
10
|
const PANEL_WIDTH = 320
|
|
8
11
|
|
|
@@ -27,11 +30,7 @@ export function ReferencePicker() {
|
|
|
27
30
|
}
|
|
28
31
|
}, [state.isOpen])
|
|
29
32
|
|
|
30
|
-
const filtered =
|
|
31
|
-
if (!query) return options
|
|
32
|
-
const q = query.toLowerCase()
|
|
33
|
-
return options.filter(o => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
|
|
34
|
-
}, [query, options])
|
|
33
|
+
const filtered = useSearchFilter(options, query, o => `${o.label} ${o.value}`)
|
|
35
34
|
|
|
36
35
|
const currentLabel = useMemo(() => {
|
|
37
36
|
if (state.isArray) return null
|
|
@@ -69,24 +68,7 @@ export function ReferencePicker() {
|
|
|
69
68
|
updateReference([...current])
|
|
70
69
|
}, [state.currentValues, updateReference])
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
if (!state.isOpen) return
|
|
75
|
-
const onMouseDown = (e: MouseEvent) => {
|
|
76
|
-
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
|
77
|
-
closeReferencePicker()
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
const onKeyDown = (e: KeyboardEvent) => {
|
|
81
|
-
if (e.key === 'Escape') closeReferencePicker()
|
|
82
|
-
}
|
|
83
|
-
document.addEventListener('mousedown', onMouseDown)
|
|
84
|
-
document.addEventListener('keydown', onKeyDown)
|
|
85
|
-
return () => {
|
|
86
|
-
document.removeEventListener('mousedown', onMouseDown)
|
|
87
|
-
document.removeEventListener('keydown', onKeyDown)
|
|
88
|
-
}
|
|
89
|
-
}, [state.isOpen])
|
|
71
|
+
useClickOutsideEscape([panelRef], state.isOpen, closeReferencePicker)
|
|
90
72
|
|
|
91
73
|
if (!state.isOpen || !state.cursorPos) return null
|
|
92
74
|
|
|
@@ -113,7 +95,7 @@ export function ReferencePicker() {
|
|
|
113
95
|
{saving
|
|
114
96
|
? (
|
|
115
97
|
<div class="flex items-center justify-center gap-2 px-4 py-6">
|
|
116
|
-
<
|
|
98
|
+
<Spinner className="text-white/80" />
|
|
117
99
|
<span class="text-sm text-white/80">Updating...</span>
|
|
118
100
|
</div>
|
|
119
101
|
)
|
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
} from '../signals'
|
|
17
17
|
import type { ChangePayload, PageSeoData, PendingSeoChange } from '../types'
|
|
18
18
|
import { ColorField, ComboBoxField, ImageField } from './fields'
|
|
19
|
-
import { CloseButton, ModalBackdrop } from './modal-shell'
|
|
19
|
+
import { CancelButton, CloseButton, ModalBackdrop } from './modal-shell'
|
|
20
|
+
import { Spinner } from './spinner'
|
|
20
21
|
|
|
21
22
|
const OG_TYPE_OPTIONS = [
|
|
22
23
|
{ value: 'website', label: 'Website', description: 'Default type for most pages' },
|
|
@@ -549,14 +550,7 @@ export function SeoEditor() {
|
|
|
549
550
|
{/* Footer */}
|
|
550
551
|
{hasSeoData && (
|
|
551
552
|
<div class="flex items-center justify-end gap-3 px-5 py-4 border-t border-white/10">
|
|
552
|
-
<
|
|
553
|
-
type="button"
|
|
554
|
-
onClick={handleClose}
|
|
555
|
-
class="px-4 py-2 text-sm font-medium text-white/70 hover:text-white hover:bg-white/10 rounded-cms-pill transition-colors"
|
|
556
|
-
data-cms-ui
|
|
557
|
-
>
|
|
558
|
-
Cancel
|
|
559
|
-
</button>
|
|
553
|
+
<CancelButton onClick={handleClose} />
|
|
560
554
|
<button
|
|
561
555
|
type="button"
|
|
562
556
|
onClick={handleSaveAll}
|
|
@@ -568,7 +562,7 @@ export function SeoEditor() {
|
|
|
568
562
|
}`}
|
|
569
563
|
data-cms-ui
|
|
570
564
|
>
|
|
571
|
-
{isSaving && <
|
|
565
|
+
{isSaving && <Spinner />}
|
|
572
566
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
|
573
567
|
</button>
|
|
574
568
|
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { cn } from '../lib/cn'
|
|
2
|
+
|
|
3
|
+
const sizes = {
|
|
4
|
+
xs: 'h-3 w-3',
|
|
5
|
+
sm: 'h-3.5 w-3.5',
|
|
6
|
+
md: 'h-4 w-4',
|
|
7
|
+
lg: 'h-6 w-6',
|
|
8
|
+
xl: 'h-8 w-8',
|
|
9
|
+
} as const
|
|
10
|
+
|
|
11
|
+
export function Spinner({ size = 'md', className }: { size?: keyof typeof sizes; className?: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<span
|
|
14
|
+
class={cn('inline-block animate-spin rounded-full border-2 border-current/30 border-t-current', sizes[size], className)}
|
|
15
|
+
/>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -5,6 +5,7 @@ import { cn } from '../lib/cn'
|
|
|
5
5
|
import * as signals from '../signals'
|
|
6
6
|
import { showConfirmDialog } from '../signals'
|
|
7
7
|
import type { CollectionDefinition } from '../types'
|
|
8
|
+
import { Spinner } from './spinner'
|
|
8
9
|
|
|
9
10
|
export interface ToolbarCallbacks {
|
|
10
11
|
onEdit: () => void
|
|
@@ -229,7 +230,7 @@ export const Toolbar = ({ callbacks, collectionDefinitions }: ToolbarProps) => {
|
|
|
229
230
|
{/* Saving indicator */}
|
|
230
231
|
{isSaving && !showingOriginal && (
|
|
231
232
|
<div class="flex items-center gap-1.5 px-3 py-2 sm:px-5 sm:py-2.5 text-sm font-medium text-white/80">
|
|
232
|
-
<
|
|
233
|
+
<Spinner size="sm" className="text-white/80" />
|
|
233
234
|
<span>Saving</span>
|
|
234
235
|
</div>
|
|
235
236
|
)}
|
package/src/editor/constants.ts
CHANGED
|
@@ -136,3 +136,36 @@ export function clampPanelPosition(
|
|
|
136
136
|
maxHeight: `${maxHeight}px`,
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Calculate fixed-position style for a dropdown that needs to escape parent overflow clipping.
|
|
142
|
+
* Positions below the trigger element by default, flipping above when space is insufficient.
|
|
143
|
+
*/
|
|
144
|
+
export function getDropdownPosition(
|
|
145
|
+
trigger: HTMLElement | null,
|
|
146
|
+
maxHeight: number,
|
|
147
|
+
padding = LAYOUT.VIEWPORT_PADDING,
|
|
148
|
+
): Record<string, string> | undefined {
|
|
149
|
+
if (!trigger) return undefined
|
|
150
|
+
const rect = trigger.getBoundingClientRect()
|
|
151
|
+
const spaceBelow = window.innerHeight - rect.bottom - padding
|
|
152
|
+
const spaceAbove = rect.top - padding
|
|
153
|
+
const showAbove = spaceBelow < 80 && spaceAbove > spaceBelow
|
|
154
|
+
|
|
155
|
+
const style: Record<string, string> = {
|
|
156
|
+
position: 'fixed',
|
|
157
|
+
left: `${rect.left}px`,
|
|
158
|
+
width: `${rect.width}px`,
|
|
159
|
+
zIndex: String(Z_INDEX.MODAL),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (showAbove) {
|
|
163
|
+
style.bottom = `${window.innerHeight - rect.top + 4}px`
|
|
164
|
+
style.maxHeight = `${Math.max(Math.min(maxHeight, spaceAbove), 80)}px`
|
|
165
|
+
} else {
|
|
166
|
+
style.top = `${rect.bottom + 4}px`
|
|
167
|
+
style.maxHeight = `${Math.max(Math.min(maxHeight, spaceBelow), 80)}px`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return style
|
|
171
|
+
}
|
|
@@ -17,3 +17,7 @@ export type { ImageHoverState } from './useImageHoverDetection'
|
|
|
17
17
|
|
|
18
18
|
export { useBgImageHoverDetection } from './useBgImageHoverDetection'
|
|
19
19
|
export type { BgImageHoverState } from './useBgImageHoverDetection'
|
|
20
|
+
|
|
21
|
+
export { useClickOutsideEscape } from './useClickOutsideEscape'
|
|
22
|
+
|
|
23
|
+
export { useSearchFilter } from './useSearchFilter'
|