@nuasite/cms 0.39.2 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/editor.js +15910 -15027
- package/package.json +1 -1
- package/src/collection-scanner.ts +127 -13
- package/src/content-config-ast.ts +91 -24
- package/src/editor/components/attribute-editor.tsx +0 -1
- package/src/editor/components/bg-image-overlay.tsx +7 -8
- package/src/editor/components/block-editor.tsx +12 -12
- package/src/editor/components/collections-browser.tsx +10 -10
- package/src/editor/components/create-page-modal.tsx +18 -18
- package/src/editor/components/delete-page-dialog.tsx +4 -3
- package/src/editor/components/field-utils.ts +54 -0
- package/src/editor/components/fields.tsx +516 -73
- package/src/editor/components/frontmatter-fields.tsx +188 -55
- package/src/editor/components/frontmatter-sidebar.tsx +56 -58
- package/src/editor/components/link-edit-popover.tsx +10 -5
- package/src/editor/components/markdown-editor-overlay.tsx +100 -39
- package/src/editor/components/markdown-inline-editor.tsx +58 -26
- package/src/editor/components/mdx-block-view.tsx +4 -4
- package/src/editor/components/mdx-component-picker.tsx +2 -2
- package/src/editor/components/media-library.tsx +19 -18
- package/src/editor/components/modal-shell.tsx +16 -3
- package/src/editor/components/prop-editor.tsx +15 -18
- package/src/editor/components/redirects-manager.tsx +42 -35
- package/src/editor/components/reference-picker.tsx +5 -4
- package/src/editor/components/seo-editor.tsx +36 -27
- package/src/editor/components/toolbar.tsx +50 -33
- package/src/editor/dom.ts +13 -2
- package/src/editor/editor.ts +7 -6
- package/src/editor/hooks/useBlockEditorHandlers.ts +7 -6
- package/src/editor/index.tsx +7 -6
- package/src/editor/signals.ts +44 -13
- package/src/editor/strings.ts +123 -0
- package/src/editor/styles.css +75 -2
- package/src/editor/types.ts +8 -0
- package/src/field-types.ts +15 -0
- package/src/index.ts +6 -0
- package/src/types.ts +7 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentChildren } from 'preact'
|
|
2
2
|
import { useEffect, useState } from 'preact/hooks'
|
|
3
|
+
import { cn } from '../lib/cn'
|
|
3
4
|
import { buildAstroUploadContext, getCollectionEntryOptions } from '../manifest'
|
|
4
5
|
import { renameMarkdownPage } from '../markdown-api'
|
|
5
6
|
import {
|
|
@@ -11,14 +12,46 @@ import {
|
|
|
11
12
|
updateMarkdownFrontmatter,
|
|
12
13
|
updateMarkdownPageMeta,
|
|
13
14
|
} from '../signals'
|
|
15
|
+
import { STRINGS } from '../strings'
|
|
14
16
|
import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
|
|
15
|
-
import { ColorField, ComboBoxField, ImageField, MultiSelectField, NumberField, TextField, ToggleField } from './fields'
|
|
17
|
+
import { ColorField, ComboBoxField, FileField, ImageField, MultiSelectField, NumberField, TextField, ToggleField } from './fields'
|
|
16
18
|
import { groupFields } from './frontmatter-sidebar'
|
|
17
19
|
|
|
18
20
|
function isArrayOfObjects(value: unknown[]): value is Record<string, unknown>[] {
|
|
19
21
|
return value.length > 0 && typeof value[0] === 'object' && value[0] !== null
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
/** Checkbox below a URL field that toggles opening the link in a new tab. */
|
|
25
|
+
function OpenInNewTabToggle({ field }: { field: FieldDefinition }) {
|
|
26
|
+
const fm = markdownEditorState.value.currentPage?.frontmatter ?? {}
|
|
27
|
+
const targetKey = `${field.name}OpenInNewTab`
|
|
28
|
+
const isChecked = fm[targetKey] === true
|
|
29
|
+
return (
|
|
30
|
+
<label class="flex items-center gap-2 mt-2 text-xs text-white/70 cursor-pointer w-fit" data-cms-ui>
|
|
31
|
+
<input
|
|
32
|
+
type="checkbox"
|
|
33
|
+
checked={isChecked}
|
|
34
|
+
onChange={(e) => updateMarkdownFrontmatter({ [targetKey]: (e.target as HTMLInputElement).checked })}
|
|
35
|
+
class="sr-only peer"
|
|
36
|
+
data-cms-ui
|
|
37
|
+
/>
|
|
38
|
+
<span
|
|
39
|
+
class={cn(
|
|
40
|
+
'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
|
|
41
|
+
isChecked ? 'bg-cms-primary border-cms-primary' : 'border-white/30 bg-white/5',
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
{isChecked && (
|
|
45
|
+
<svg class="w-3 h-3 text-cms-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
46
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
|
47
|
+
</svg>
|
|
48
|
+
)}
|
|
49
|
+
</span>
|
|
50
|
+
Open in new tab
|
|
51
|
+
</label>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
22
55
|
function FieldGroupHeader({ group, children }: { group: string | null; children: ComponentChildren }) {
|
|
23
56
|
return (
|
|
24
57
|
<>
|
|
@@ -66,9 +99,21 @@ export function FrontmatterField({
|
|
|
66
99
|
type="checkbox"
|
|
67
100
|
checked={value}
|
|
68
101
|
onChange={(e) => onChange((e.target as HTMLInputElement).checked)}
|
|
69
|
-
class="
|
|
102
|
+
class="sr-only peer"
|
|
70
103
|
data-cms-ui
|
|
71
104
|
/>
|
|
105
|
+
<span
|
|
106
|
+
class={cn(
|
|
107
|
+
'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
|
|
108
|
+
value ? 'bg-cms-primary border-cms-primary' : 'border-white/30 bg-white/5',
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
{value && (
|
|
112
|
+
<svg class="w-3 h-3 text-cms-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
113
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
|
114
|
+
</svg>
|
|
115
|
+
)}
|
|
116
|
+
</span>
|
|
72
117
|
{label}
|
|
73
118
|
</label>
|
|
74
119
|
)
|
|
@@ -83,7 +128,7 @@ export function FrontmatterField({
|
|
|
83
128
|
type="date"
|
|
84
129
|
value={typeof value === 'string' ? value.split('T')[0] : ''}
|
|
85
130
|
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
86
|
-
class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white focus:outline-none focus:border-
|
|
131
|
+
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"
|
|
87
132
|
data-cms-ui
|
|
88
133
|
/>
|
|
89
134
|
</div>
|
|
@@ -119,7 +164,7 @@ export function FrontmatterField({
|
|
|
119
164
|
onChange(arrayValue)
|
|
120
165
|
}}
|
|
121
166
|
placeholder={`Enter ${label.toLowerCase()} separated by commas`}
|
|
122
|
-
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-
|
|
167
|
+
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"
|
|
123
168
|
data-cms-ui
|
|
124
169
|
/>
|
|
125
170
|
</div>
|
|
@@ -150,7 +195,7 @@ export function FrontmatterField({
|
|
|
150
195
|
value={typeof value === 'string' ? value : ''}
|
|
151
196
|
onChange={(e) => onChange((e.target as HTMLTextAreaElement).value)}
|
|
152
197
|
rows={3}
|
|
153
|
-
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-
|
|
198
|
+
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"
|
|
154
199
|
data-cms-ui
|
|
155
200
|
/>
|
|
156
201
|
</div>
|
|
@@ -165,7 +210,7 @@ export function FrontmatterField({
|
|
|
165
210
|
type="text"
|
|
166
211
|
value={typeof value === 'string' ? value : String(value ?? '')}
|
|
167
212
|
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
168
|
-
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-
|
|
213
|
+
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"
|
|
169
214
|
data-cms-ui
|
|
170
215
|
/>
|
|
171
216
|
</div>
|
|
@@ -190,38 +235,54 @@ export function CreateModeFrontmatter({
|
|
|
190
235
|
onSlugManualEdit,
|
|
191
236
|
}: CreateModeFrontmatterProps) {
|
|
192
237
|
const allFields = fields ?? collectionDefinition.fields
|
|
238
|
+
const allFieldNames = new Set(allFields.map((f) => f.name))
|
|
239
|
+
const urlFieldNames = new Set(allFields.filter((f) => f.type === 'url' || /link|href|url/i.test(f.name)).map((f) => f.name))
|
|
240
|
+
const isOpenInNewTabSibling = (name: string) => {
|
|
241
|
+
if (!name.endsWith('OpenInNewTab')) return false
|
|
242
|
+
const base = name.slice(0, -'OpenInNewTab'.length)
|
|
243
|
+
return urlFieldNames.has(base)
|
|
244
|
+
}
|
|
193
245
|
// In create mode, skip complex fields (arrays, objects) — they can be edited after creation
|
|
194
|
-
|
|
246
|
+
// Draft is always rendered in the sidebar — never inline in the header.
|
|
247
|
+
// `*OpenInNewTab` siblings are handled by the OpenInNewTabToggle next to the URL field.
|
|
248
|
+
const displayFields = allFields.filter(f => f.type !== 'array' && f.type !== 'object' && f.name !== 'draft' && !isOpenInNewTabSibling(f.name))
|
|
195
249
|
const groups = groupFields(displayFields)
|
|
196
250
|
|
|
197
251
|
return (
|
|
198
252
|
<div class="space-y-4">
|
|
199
253
|
{/* Slug field */}
|
|
200
254
|
<div>
|
|
201
|
-
<
|
|
202
|
-
URL Slug
|
|
255
|
+
<div class="flex items-center gap-1.5 mb-1.5">
|
|
256
|
+
<label class="block text-xs font-medium text-white/70">URL Slug</label>
|
|
257
|
+
<span class="relative group/tt inline-flex" data-cms-ui>
|
|
258
|
+
<svg class="w-3.5 h-3.5 text-white/40 hover:text-white/70 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
259
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
260
|
+
</svg>
|
|
261
|
+
<span class="absolute left-0 top-full mt-1 w-72 p-2 bg-black/90 text-white text-xs rounded-cms-sm opacity-0 invisible group-hover/tt:opacity-100 group-hover/tt:visible transition-all z-50 pointer-events-none whitespace-normal break-all">
|
|
262
|
+
Will be saved to: src/content/{collectionDefinition.name}/{page.slug || 'your-slug'}.{collectionDefinition.fileExtension}
|
|
263
|
+
</span>
|
|
264
|
+
</span>
|
|
265
|
+
</div>
|
|
266
|
+
<label class="flex items-center gap-1 px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm focus-within:border-white/40 focus-within:ring-1 focus-within:ring-white/10">
|
|
267
|
+
<span class="text-white/40 text-sm shrink-0">/</span>
|
|
268
|
+
<input
|
|
269
|
+
type="text"
|
|
270
|
+
value={page.slug}
|
|
271
|
+
onInput={(e) => {
|
|
272
|
+
onSlugManualEdit()
|
|
273
|
+
const slug = (e.target as HTMLInputElement).value
|
|
274
|
+
markdownEditorState.value = {
|
|
275
|
+
...markdownEditorState.value,
|
|
276
|
+
currentPage: markdownEditorState.value.currentPage
|
|
277
|
+
? { ...markdownEditorState.value.currentPage, slug }
|
|
278
|
+
: null,
|
|
279
|
+
}
|
|
280
|
+
}}
|
|
281
|
+
placeholder="url-friendly-slug"
|
|
282
|
+
class="flex-1 bg-transparent text-sm text-white placeholder-white/40 focus:outline-none"
|
|
283
|
+
data-cms-ui
|
|
284
|
+
/>
|
|
203
285
|
</label>
|
|
204
|
-
<input
|
|
205
|
-
type="text"
|
|
206
|
-
value={page.slug}
|
|
207
|
-
onInput={(e) => {
|
|
208
|
-
onSlugManualEdit()
|
|
209
|
-
const slug = (e.target as HTMLInputElement).value
|
|
210
|
-
markdownEditorState.value = {
|
|
211
|
-
...markdownEditorState.value,
|
|
212
|
-
currentPage: markdownEditorState.value.currentPage
|
|
213
|
-
? { ...markdownEditorState.value.currentPage, slug }
|
|
214
|
-
: null,
|
|
215
|
-
}
|
|
216
|
-
}}
|
|
217
|
-
placeholder="url-friendly-slug"
|
|
218
|
-
class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
|
|
219
|
-
data-cms-ui
|
|
220
|
-
/>
|
|
221
|
-
<p class="mt-1 text-xs text-white/40">
|
|
222
|
-
Will be saved to: src/content/{collectionDefinition.name}/
|
|
223
|
-
{page.slug || 'your-slug'}.{collectionDefinition.fileExtension}
|
|
224
|
-
</p>
|
|
225
286
|
</div>
|
|
226
287
|
|
|
227
288
|
{/* Schema fields */}
|
|
@@ -236,6 +297,7 @@ export function CreateModeFrontmatter({
|
|
|
236
297
|
onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
|
|
237
298
|
collection={collectionDefinition.name}
|
|
238
299
|
entrySlug={page.slug}
|
|
300
|
+
hasOpenInNewTabSibling={allFieldNames.has(`${field.name}OpenInNewTab`)}
|
|
239
301
|
/>
|
|
240
302
|
))}
|
|
241
303
|
</FieldGroupHeader>
|
|
@@ -271,13 +333,13 @@ function SlugField({ page }: { page: MarkdownPageEntry }) {
|
|
|
271
333
|
if (result.success && result.newSlug && result.newFilePath) {
|
|
272
334
|
updateMarkdownPageMeta({ slug: result.newSlug, filePath: result.newFilePath })
|
|
273
335
|
setLocalSlug(result.newSlug)
|
|
274
|
-
showToast(
|
|
336
|
+
showToast(STRINGS.slug.updated, 'success')
|
|
275
337
|
} else {
|
|
276
|
-
showToast(result.error ||
|
|
338
|
+
showToast(result.error || STRINGS.slug.renameFailed, 'error')
|
|
277
339
|
setLocalSlug(page.slug)
|
|
278
340
|
}
|
|
279
341
|
} catch {
|
|
280
|
-
showToast(
|
|
342
|
+
showToast(STRINGS.slug.renameFailed, 'error')
|
|
281
343
|
setLocalSlug(page.slug)
|
|
282
344
|
} finally {
|
|
283
345
|
setIsRenaming(false)
|
|
@@ -289,7 +351,14 @@ function SlugField({ page }: { page: MarkdownPageEntry }) {
|
|
|
289
351
|
<label class="block text-xs font-medium text-white/70 mb-1.5">
|
|
290
352
|
URL Slug
|
|
291
353
|
</label>
|
|
292
|
-
<
|
|
354
|
+
<label
|
|
355
|
+
class={cn(
|
|
356
|
+
'flex items-center gap-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white focus-within:border-white/40',
|
|
357
|
+
isDirty ? 'border-white/30' : 'border-white/20',
|
|
358
|
+
isRenaming && 'opacity-60',
|
|
359
|
+
)}
|
|
360
|
+
>
|
|
361
|
+
<span class="text-white/40 text-sm shrink-0">/</span>
|
|
293
362
|
<input
|
|
294
363
|
type="text"
|
|
295
364
|
value={localSlug}
|
|
@@ -301,13 +370,11 @@ function SlugField({ page }: { page: MarkdownPageEntry }) {
|
|
|
301
370
|
;(e.target as HTMLInputElement).blur()
|
|
302
371
|
}
|
|
303
372
|
}}
|
|
304
|
-
class=
|
|
305
|
-
isDirty ? 'border-cms-primary' : 'border-white/20'
|
|
306
|
-
}`}
|
|
373
|
+
class="flex-1 bg-transparent focus:outline-none"
|
|
307
374
|
disabled={isRenaming}
|
|
308
375
|
data-cms-ui
|
|
309
376
|
/>
|
|
310
|
-
</
|
|
377
|
+
</label>
|
|
311
378
|
</div>
|
|
312
379
|
)
|
|
313
380
|
}
|
|
@@ -323,14 +390,23 @@ export function EditModeFrontmatter({
|
|
|
323
390
|
collectionDefinition,
|
|
324
391
|
fields,
|
|
325
392
|
}: EditModeFrontmatterProps) {
|
|
326
|
-
const
|
|
393
|
+
const allFields = fields ?? collectionDefinition?.fields ?? []
|
|
394
|
+
const allFieldNames = new Set(allFields.map((f) => f.name))
|
|
395
|
+
const urlFieldNames = new Set(allFields.filter((f) => f.type === 'url' || /link|href|url/i.test(f.name)).map((f) => f.name))
|
|
396
|
+
const isOpenInNewTabSibling = (name: string) => {
|
|
397
|
+
if (!name.endsWith('OpenInNewTab')) return false
|
|
398
|
+
const base = name.slice(0, -'OpenInNewTab'.length)
|
|
399
|
+
return urlFieldNames.has(base)
|
|
400
|
+
}
|
|
401
|
+
const displayFields = allFields.filter((f) => f.name !== 'draft' && !isOpenInNewTabSibling(f.name))
|
|
327
402
|
// Collect schema field names for filtering extra keys
|
|
328
403
|
const schemaFieldNames = new Set(
|
|
329
404
|
collectionDefinition?.fields.map((f) => f.name) ?? [],
|
|
330
405
|
)
|
|
331
|
-
// Frontmatter keys not covered by the schema (user-added fields)
|
|
406
|
+
// Frontmatter keys not covered by the schema (user-added fields). Draft and the
|
|
407
|
+
// `*OpenInNewTab` siblings are rendered separately, so exclude them here.
|
|
332
408
|
const extraKeys = Object.keys(page.frontmatter).filter(
|
|
333
|
-
(key) => !schemaFieldNames.has(key),
|
|
409
|
+
(key) => !schemaFieldNames.has(key) && key !== 'draft' && !isOpenInNewTabSibling(key),
|
|
334
410
|
)
|
|
335
411
|
const groups = groupFields(displayFields)
|
|
336
412
|
|
|
@@ -352,6 +428,7 @@ export function EditModeFrontmatter({
|
|
|
352
428
|
onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
|
|
353
429
|
collection={collectionDefinition.name}
|
|
354
430
|
entrySlug={page.slug}
|
|
431
|
+
hasOpenInNewTabSibling={allFieldNames.has(`${field.name}OpenInNewTab`)}
|
|
355
432
|
/>
|
|
356
433
|
))}
|
|
357
434
|
</FieldGroupHeader>
|
|
@@ -394,6 +471,8 @@ interface SchemaFrontmatterFieldProps {
|
|
|
394
471
|
/** Required when editing an `astroImage` field — routes uploads to the entry's directory. */
|
|
395
472
|
collection?: string
|
|
396
473
|
entrySlug?: string
|
|
474
|
+
/** True when the schema declares a `${field.name}OpenInNewTab` companion boolean — controls toggle visibility next to URL fields. */
|
|
475
|
+
hasOpenInNewTabSibling?: boolean
|
|
397
476
|
}
|
|
398
477
|
|
|
399
478
|
export function SchemaFrontmatterField({
|
|
@@ -402,6 +481,7 @@ export function SchemaFrontmatterField({
|
|
|
402
481
|
onChange,
|
|
403
482
|
collection,
|
|
404
483
|
entrySlug,
|
|
484
|
+
hasOpenInNewTabSibling,
|
|
405
485
|
}: SchemaFrontmatterFieldProps) {
|
|
406
486
|
const label = field.required ? `${formatFieldLabel(field.name)} *` : formatFieldLabel(field.name)
|
|
407
487
|
const hints = field.hints
|
|
@@ -409,19 +489,29 @@ export function SchemaFrontmatterField({
|
|
|
409
489
|
switch (field.type) {
|
|
410
490
|
case 'text':
|
|
411
491
|
case 'url':
|
|
412
|
-
case 'email':
|
|
492
|
+
case 'email': {
|
|
493
|
+
const isLinkLike = field.type === 'url'
|
|
494
|
+
|| /link|href|url/i.test(field.name)
|
|
495
|
+
const linkTooltip = isLinkLike
|
|
496
|
+
? 'Use https://... for external links, or /path for internal pages.'
|
|
497
|
+
: undefined
|
|
413
498
|
return (
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
499
|
+
<>
|
|
500
|
+
<TextField
|
|
501
|
+
label={label}
|
|
502
|
+
value={(value as string) ?? ''}
|
|
503
|
+
placeholder={hints?.placeholder ?? getPlaceholder(field)}
|
|
504
|
+
maxLength={hints?.maxLength as number | undefined}
|
|
505
|
+
minLength={hints?.minLength as number | undefined}
|
|
506
|
+
onChange={(v) => onChange(v)}
|
|
507
|
+
inputType={field.type === 'text' ? undefined : field.type}
|
|
508
|
+
required={field.required}
|
|
509
|
+
tooltip={linkTooltip}
|
|
510
|
+
/>
|
|
511
|
+
{field.type === 'url' && hasOpenInNewTabSibling && <OpenInNewTabToggle field={field} />}
|
|
512
|
+
</>
|
|
424
513
|
)
|
|
514
|
+
}
|
|
425
515
|
|
|
426
516
|
case 'image': {
|
|
427
517
|
const astroContext = buildAstroUploadContext(field, collection, entrySlug)
|
|
@@ -429,14 +519,28 @@ export function SchemaFrontmatterField({
|
|
|
429
519
|
<ImageField
|
|
430
520
|
label={label}
|
|
431
521
|
value={(value as string) ?? ''}
|
|
432
|
-
placeholder={getPlaceholder(field)}
|
|
433
522
|
onChange={(v) => onChange(v)}
|
|
434
523
|
onBrowse={() => {
|
|
435
524
|
openMediaLibraryWithCallback((url: string) => {
|
|
436
525
|
onChange(url)
|
|
437
526
|
}, astroContext)
|
|
438
527
|
}}
|
|
439
|
-
|
|
528
|
+
/>
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
case 'file': {
|
|
533
|
+
return (
|
|
534
|
+
<FileField
|
|
535
|
+
label={label}
|
|
536
|
+
value={(value as string) ?? ''}
|
|
537
|
+
accept={hints?.accept as string | undefined}
|
|
538
|
+
onChange={(v) => onChange(v)}
|
|
539
|
+
onBrowse={() => {
|
|
540
|
+
openMediaLibraryWithCallback((url: string) => {
|
|
541
|
+
onChange(url)
|
|
542
|
+
})
|
|
543
|
+
}}
|
|
440
544
|
/>
|
|
441
545
|
)
|
|
442
546
|
}
|
|
@@ -472,6 +576,7 @@ export function SchemaFrontmatterField({
|
|
|
472
576
|
case 'date':
|
|
473
577
|
case 'datetime':
|
|
474
578
|
case 'time':
|
|
579
|
+
case 'month':
|
|
475
580
|
return (
|
|
476
581
|
<div class="flex flex-col gap-1" data-cms-ui>
|
|
477
582
|
<label class="text-xs text-white/60 font-medium">{label}</label>
|
|
@@ -502,6 +607,28 @@ export function SchemaFrontmatterField({
|
|
|
502
607
|
/>
|
|
503
608
|
)
|
|
504
609
|
|
|
610
|
+
case 'year':
|
|
611
|
+
return (
|
|
612
|
+
<div class="flex flex-col gap-1.5" data-cms-ui>
|
|
613
|
+
<label class="text-xs font-medium text-white/70">{label}</label>
|
|
614
|
+
<input
|
|
615
|
+
type="number"
|
|
616
|
+
value={typeof value === 'number' ? value : ''}
|
|
617
|
+
placeholder={hints?.placeholder ?? String(new Date().getFullYear())}
|
|
618
|
+
min={typeof hints?.min === 'number' ? hints.min : 1900}
|
|
619
|
+
max={typeof hints?.max === 'number' ? hints.max : 2100}
|
|
620
|
+
step={1}
|
|
621
|
+
required={field.required}
|
|
622
|
+
onInput={(e) => {
|
|
623
|
+
const raw = (e.target as HTMLInputElement).value
|
|
624
|
+
onChange(raw === '' ? undefined : Number(raw))
|
|
625
|
+
}}
|
|
626
|
+
class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-colors"
|
|
627
|
+
data-cms-ui
|
|
628
|
+
/>
|
|
629
|
+
</div>
|
|
630
|
+
)
|
|
631
|
+
|
|
505
632
|
case 'boolean':
|
|
506
633
|
return (
|
|
507
634
|
<ToggleField
|
|
@@ -822,7 +949,7 @@ function ObjectFields({ label, value, onChange, schemaFields, extraKeys }: Objec
|
|
|
822
949
|
}
|
|
823
950
|
}}
|
|
824
951
|
placeholder="New field name..."
|
|
825
|
-
class="flex-1 px-2 py-1 text-xs bg-white/5 border border-white/10 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/
|
|
952
|
+
class="flex-1 px-2 py-1 text-xs bg-white/5 border border-white/10 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40"
|
|
826
953
|
data-cms-ui
|
|
827
954
|
/>
|
|
828
955
|
<button
|
|
@@ -871,6 +998,8 @@ export function getPlaceholder(field: FieldDefinition): string {
|
|
|
871
998
|
return 'name@example.com'
|
|
872
999
|
case 'image':
|
|
873
1000
|
return '/images/...'
|
|
1001
|
+
case 'file':
|
|
1002
|
+
return '/files/...'
|
|
874
1003
|
case 'color':
|
|
875
1004
|
return '#000000'
|
|
876
1005
|
case 'date':
|
|
@@ -879,6 +1008,10 @@ export function getPlaceholder(field: FieldDefinition): string {
|
|
|
879
1008
|
return 'YYYY-MM-DDTHH:MM'
|
|
880
1009
|
case 'time':
|
|
881
1010
|
return 'HH:MM'
|
|
1011
|
+
case 'year':
|
|
1012
|
+
return String(new Date().getFullYear())
|
|
1013
|
+
case 'month':
|
|
1014
|
+
return 'YYYY-MM'
|
|
882
1015
|
default:
|
|
883
1016
|
return `Enter ${formatFieldLabel(field.name).toLowerCase()}...`
|
|
884
1017
|
}
|
|
@@ -2,51 +2,10 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
|
|
2
2
|
import { cn } from '../lib/cn'
|
|
3
3
|
import { updateMarkdownFrontmatter } from '../signals'
|
|
4
4
|
import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
|
|
5
|
+
import { groupFields, partitionFields } from './field-utils'
|
|
5
6
|
import { formatFieldLabel, FrontmatterField, SchemaFrontmatterField } from './frontmatter-fields'
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
// Field Utilities
|
|
9
|
-
// ============================================================================
|
|
10
|
-
|
|
11
|
-
export function partitionFields(fields: FieldDefinition[]): { sidebar: FieldDefinition[]; header: FieldDefinition[] } {
|
|
12
|
-
const sidebar: FieldDefinition[] = []
|
|
13
|
-
const header: FieldDefinition[] = []
|
|
14
|
-
for (const field of fields) {
|
|
15
|
-
if (field.hidden) continue
|
|
16
|
-
if (field.position === 'sidebar') {
|
|
17
|
-
sidebar.push(field)
|
|
18
|
-
} else {
|
|
19
|
-
header.push(field)
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return { sidebar, header }
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface FieldGroup {
|
|
26
|
-
group: string | null
|
|
27
|
-
fields: FieldDefinition[]
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function groupFields(fields: FieldDefinition[]): FieldGroup[] {
|
|
31
|
-
const groups: FieldGroup[] = []
|
|
32
|
-
const groupMap = new Map<string | null, FieldDefinition[]>()
|
|
33
|
-
const order: (string | null)[] = []
|
|
34
|
-
|
|
35
|
-
for (const field of fields) {
|
|
36
|
-
const key = field.group ?? null
|
|
37
|
-
if (!groupMap.has(key)) {
|
|
38
|
-
groupMap.set(key, [])
|
|
39
|
-
order.push(key)
|
|
40
|
-
}
|
|
41
|
-
groupMap.get(key)!.push(field)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
for (const key of order) {
|
|
45
|
-
groups.push({ group: key, fields: groupMap.get(key)! })
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return groups
|
|
49
|
-
}
|
|
8
|
+
export { groupFields, partitionFields } from './field-utils'
|
|
50
9
|
|
|
51
10
|
// ============================================================================
|
|
52
11
|
// Group Header
|
|
@@ -94,9 +53,11 @@ interface FrontmatterSidebarProps {
|
|
|
94
53
|
|
|
95
54
|
export function FrontmatterSidebar({ fields, page, collectionDefinition }: FrontmatterSidebarProps) {
|
|
96
55
|
const [state, setState] = useState(loadSidebarState)
|
|
56
|
+
const [isAnimating, setIsAnimating] = useState(false)
|
|
97
57
|
const isResizing = useRef(false)
|
|
98
58
|
const startX = useRef(0)
|
|
99
59
|
const startWidth = useRef(0)
|
|
60
|
+
const animationTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
100
61
|
|
|
101
62
|
const { width, collapsed } = state
|
|
102
63
|
|
|
@@ -108,8 +69,17 @@ export function FrontmatterSidebar({ fields, page, collectionDefinition }: Front
|
|
|
108
69
|
})
|
|
109
70
|
}, [])
|
|
110
71
|
|
|
72
|
+
const toggleCollapsed = useCallback(() => {
|
|
73
|
+
if (animationTimeout.current) clearTimeout(animationTimeout.current)
|
|
74
|
+
setIsAnimating(true)
|
|
75
|
+
updateState({ collapsed: !collapsed })
|
|
76
|
+
animationTimeout.current = setTimeout(() => setIsAnimating(false), 300)
|
|
77
|
+
}, [collapsed, updateState])
|
|
78
|
+
|
|
111
79
|
const handleMouseDown = useCallback((e: MouseEvent) => {
|
|
112
80
|
e.preventDefault()
|
|
81
|
+
if (animationTimeout.current) clearTimeout(animationTimeout.current)
|
|
82
|
+
setIsAnimating(false)
|
|
113
83
|
isResizing.current = true
|
|
114
84
|
startX.current = e.clientX
|
|
115
85
|
startWidth.current = width
|
|
@@ -151,7 +121,11 @@ export function FrontmatterSidebar({ fields, page, collectionDefinition }: Front
|
|
|
151
121
|
|
|
152
122
|
return (
|
|
153
123
|
<div
|
|
154
|
-
class={cn(
|
|
124
|
+
class={cn(
|
|
125
|
+
'relative shrink-0 border-l border-white/10 bg-white/5 flex overflow-hidden',
|
|
126
|
+
isAnimating && 'transition-[width] duration-300 ease-out',
|
|
127
|
+
collapsed && 'w-8',
|
|
128
|
+
)}
|
|
155
129
|
style={collapsed ? undefined : { width: `${width}px` }}
|
|
156
130
|
data-cms-ui
|
|
157
131
|
>
|
|
@@ -166,27 +140,50 @@ export function FrontmatterSidebar({ fields, page, collectionDefinition }: Front
|
|
|
166
140
|
{/* Collapse toggle */}
|
|
167
141
|
<button
|
|
168
142
|
type="button"
|
|
169
|
-
onClick={
|
|
170
|
-
class="absolute top-2 left-
|
|
143
|
+
onClick={toggleCollapsed}
|
|
144
|
+
class="absolute top-2 left-1.5 -translate-x-1/2 z-20 w-5 h-5 rounded-cms-xs bg-white/10 hover:bg-white/20 border border-white/20 hover:border-white/40 flex items-center justify-center text-white/50 hover:text-white transition-colors"
|
|
171
145
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
172
146
|
data-cms-ui
|
|
173
147
|
>
|
|
174
|
-
<
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
148
|
+
<span class="relative w-3 h-3 inline-block">
|
|
149
|
+
<svg
|
|
150
|
+
class={cn(
|
|
151
|
+
'absolute inset-0 w-3 h-3 transition-opacity duration-200 ease-out',
|
|
152
|
+
collapsed ? 'opacity-0' : 'opacity-100',
|
|
153
|
+
)}
|
|
154
|
+
viewBox="0 0 24 24"
|
|
155
|
+
fill="none"
|
|
156
|
+
stroke="currentColor"
|
|
157
|
+
stroke-width="2"
|
|
158
|
+
stroke-linecap="round"
|
|
159
|
+
stroke-linejoin="round"
|
|
160
|
+
>
|
|
161
|
+
<path d="M3 5v14" />
|
|
162
|
+
<path d="M21 12H7" />
|
|
163
|
+
<path d="m15 18 6-6-6-6" />
|
|
164
|
+
</svg>
|
|
165
|
+
<svg
|
|
166
|
+
class={cn(
|
|
167
|
+
'absolute inset-0 w-3 h-3 transition-opacity duration-200 ease-out',
|
|
168
|
+
collapsed ? 'opacity-100' : 'opacity-0',
|
|
169
|
+
)}
|
|
170
|
+
viewBox="0 0 24 24"
|
|
171
|
+
fill="none"
|
|
172
|
+
stroke="currentColor"
|
|
173
|
+
stroke-width="2"
|
|
174
|
+
stroke-linecap="round"
|
|
175
|
+
stroke-linejoin="round"
|
|
176
|
+
>
|
|
177
|
+
<path d="M3 19V5" />
|
|
178
|
+
<path d="m13 6-6 6 6 6" />
|
|
179
|
+
<path d="M7 12h14" />
|
|
180
|
+
</svg>
|
|
181
|
+
</span>
|
|
185
182
|
</button>
|
|
186
183
|
|
|
187
184
|
{/* Sidebar content */}
|
|
188
185
|
{!collapsed && (
|
|
189
|
-
<div class="flex-1 overflow-y-auto p-
|
|
186
|
+
<div class="flex-1 overflow-y-auto p-3 pt-10 space-y-3 min-w-0" style={{ scrollbarGutter: 'stable' }}>
|
|
190
187
|
{groups.map((group, gi) => (
|
|
191
188
|
<div key={gi} data-cms-ui>
|
|
192
189
|
{group.group && <GroupHeader label={group.group} />}
|
|
@@ -203,6 +200,7 @@ export function FrontmatterSidebar({ fields, page, collectionDefinition }: Front
|
|
|
203
200
|
onChange={(newValue) => updateMarkdownFrontmatter({ [field.name]: newValue })}
|
|
204
201
|
collection={collectionDefinition?.name}
|
|
205
202
|
entrySlug={page.slug}
|
|
203
|
+
hasOpenInNewTabSibling={schemaFieldNames.has(`${field.name}OpenInNewTab`)}
|
|
206
204
|
/>
|
|
207
205
|
)
|
|
208
206
|
: (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
|
+
import { cn } from '../lib/cn'
|
|
2
3
|
import { HighlightMatch } from './fields'
|
|
3
4
|
import { PrimaryButton } from './modal-shell'
|
|
4
5
|
|
|
@@ -131,7 +132,10 @@ export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, on
|
|
|
131
132
|
>
|
|
132
133
|
<form
|
|
133
134
|
onSubmit={handleSubmit}
|
|
134
|
-
class={
|
|
135
|
+
class={cn(
|
|
136
|
+
'flex items-center gap-2',
|
|
137
|
+
inline ? 'py-1.5' : 'px-4 py-2 bg-cms-dark border-b border-white/10',
|
|
138
|
+
)}
|
|
135
139
|
>
|
|
136
140
|
<svg class="w-4 h-4 text-white/40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
137
141
|
<path
|
|
@@ -152,7 +156,7 @@ export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, on
|
|
|
152
156
|
onBlur={handleBlur}
|
|
153
157
|
onKeyDown={handleKeyDown}
|
|
154
158
|
autocomplete="off"
|
|
155
|
-
class="w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white placeholder:text-white/30 outline-none focus:border-
|
|
159
|
+
class="w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white placeholder:text-white/30 outline-none focus:border-white/40 transition-colors"
|
|
156
160
|
data-cms-ui
|
|
157
161
|
/>
|
|
158
162
|
{showDropdown && (
|
|
@@ -169,11 +173,12 @@ export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, on
|
|
|
169
173
|
e.preventDefault()
|
|
170
174
|
selectOption(opt.value)
|
|
171
175
|
}}
|
|
172
|
-
class={
|
|
176
|
+
class={cn(
|
|
177
|
+
'w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer',
|
|
173
178
|
i === highlightedIndex
|
|
174
179
|
? 'bg-white/15 text-white'
|
|
175
|
-
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
|
176
|
-
}
|
|
180
|
+
: 'text-white/70 hover:bg-white/10 hover:text-white',
|
|
181
|
+
)}
|
|
177
182
|
data-cms-ui
|
|
178
183
|
>
|
|
179
184
|
<span class="block truncate font-medium">
|