@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
|
@@ -32,7 +32,7 @@ export function ModalHeader({ title, onBack, onClose }: {
|
|
|
32
32
|
onClose: () => void
|
|
33
33
|
}) {
|
|
34
34
|
return (
|
|
35
|
-
<div class="flex items-center gap-3
|
|
35
|
+
<div class="flex items-center gap-3 px-5 py-4 border-b border-white/10">
|
|
36
36
|
{onBack && (
|
|
37
37
|
<button
|
|
38
38
|
type="button"
|
|
@@ -53,7 +53,20 @@ export function ModalHeader({ title, onBack, onClose }: {
|
|
|
53
53
|
|
|
54
54
|
export function ModalFooter({ children }: { children: ComponentChildren }) {
|
|
55
55
|
return (
|
|
56
|
-
<div class="flex items-center justify-end gap-2
|
|
56
|
+
<div class="flex items-center justify-end gap-2 py-3.5 px-4 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
|
|
57
|
+
{children}
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function Section({ title, children, className }: {
|
|
63
|
+
title: string
|
|
64
|
+
children: ComponentChildren
|
|
65
|
+
className?: string
|
|
66
|
+
}) {
|
|
67
|
+
return (
|
|
68
|
+
<div class={cn('space-y-4', className)}>
|
|
69
|
+
<h3 class="text-base font-semibold text-white/90">{title}</h3>
|
|
57
70
|
{children}
|
|
58
71
|
</div>
|
|
59
72
|
)
|
|
@@ -80,7 +93,7 @@ export function CancelButton({ onClick, label = 'Cancel', className }: { onClick
|
|
|
80
93
|
type="button"
|
|
81
94
|
onClick={onClick}
|
|
82
95
|
class={cn(
|
|
83
|
-
'
|
|
96
|
+
'py-2.5 px-3.5 text-sm text-white/80 font-medium rounded-cms-pill hover:bg-white/10 hover:text-white transition-colors cursor-pointer',
|
|
84
97
|
className,
|
|
85
98
|
)}
|
|
86
99
|
data-cms-ui
|
|
@@ -68,7 +68,7 @@ function renderPropInput(prop: ComponentProp, value: string, onChange: (value: s
|
|
|
68
68
|
<select
|
|
69
69
|
value={value}
|
|
70
70
|
onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
|
|
71
|
-
class="w-full px-4 py-2
|
|
71
|
+
class="w-full px-4 py-2 bg-white/10 border border-white/20 text-[13px] text-white outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10 transition-all rounded-cms-sm"
|
|
72
72
|
>
|
|
73
73
|
{!prop.required && <option value="">— None —</option>}
|
|
74
74
|
{unionOptions.map((opt) => <option key={opt} value={opt}>{opt}</option>)}
|
|
@@ -84,7 +84,7 @@ function renderPropInput(prop: ComponentProp, value: string, onChange: (value: s
|
|
|
84
84
|
value={value}
|
|
85
85
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
86
86
|
placeholder={prop.defaultValue || 'Select an image...'}
|
|
87
|
-
class="flex-1 px-4 py-2
|
|
87
|
+
class="flex-1 px-4 py-2 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-sm"
|
|
88
88
|
/>
|
|
89
89
|
<button
|
|
90
90
|
type="button"
|
|
@@ -93,7 +93,7 @@ function renderPropInput(prop: ComponentProp, value: string, onChange: (value: s
|
|
|
93
93
|
onChange(url)
|
|
94
94
|
})
|
|
95
95
|
}}
|
|
96
|
-
class="px-3 py-2.5 bg-white/10 border border-white/20 text-white/70 hover:text-white hover:bg-white/15 rounded-cms-
|
|
96
|
+
class="px-3 py-2.5 bg-white/10 border border-white/20 text-white/70 hover:text-white hover:bg-white/15 rounded-cms-sm transition-colors shrink-0"
|
|
97
97
|
title="Browse media"
|
|
98
98
|
>
|
|
99
99
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
@@ -115,14 +115,14 @@ function renderPropInput(prop: ComponentProp, value: string, onChange: (value: s
|
|
|
115
115
|
type="color"
|
|
116
116
|
value={value || '#000000'}
|
|
117
117
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
118
|
-
class="w-10 h-10 rounded-cms-
|
|
118
|
+
class="w-10 h-10 rounded-cms-sm border border-white/20 bg-transparent cursor-pointer"
|
|
119
119
|
/>
|
|
120
120
|
<input
|
|
121
121
|
type="text"
|
|
122
122
|
value={value}
|
|
123
123
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
124
124
|
placeholder={prop.defaultValue || '#000000'}
|
|
125
|
-
class="flex-1 px-4 py-2
|
|
125
|
+
class="flex-1 px-4 py-2 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-sm font-mono"
|
|
126
126
|
/>
|
|
127
127
|
</div>
|
|
128
128
|
)
|
|
@@ -135,7 +135,7 @@ function renderPropInput(prop: ComponentProp, value: string, onChange: (value: s
|
|
|
135
135
|
onInput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
|
|
136
136
|
placeholder={prop.defaultValue || `Enter ${prop.name}...`}
|
|
137
137
|
rows={3}
|
|
138
|
-
class="w-full px-4 py-2
|
|
138
|
+
class="w-full px-4 py-2 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-sm resize-y"
|
|
139
139
|
/>
|
|
140
140
|
)
|
|
141
141
|
}
|
|
@@ -146,14 +146,14 @@ function renderPropInput(prop: ComponentProp, value: string, onChange: (value: s
|
|
|
146
146
|
value={value}
|
|
147
147
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
148
148
|
placeholder={prop.defaultValue || `Enter ${prop.name}...`}
|
|
149
|
-
class="w-full px-4 py-2
|
|
149
|
+
class="w-full px-4 py-2 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-sm"
|
|
150
150
|
/>
|
|
151
151
|
)
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
export function PropEditor({ prop, value, onChange }: PropEditorProps) {
|
|
155
155
|
return (
|
|
156
|
-
<div class="mb-
|
|
156
|
+
<div class="mb-2">
|
|
157
157
|
<label class="block text-[13px] font-medium text-white mb-1.5">
|
|
158
158
|
{prop.name}
|
|
159
159
|
{prop.required && <span class="text-cms-error ml-1">*</span>}
|
|
@@ -164,9 +164,6 @@ export function PropEditor({ prop, value, onChange }: PropEditorProps) {
|
|
|
164
164
|
</div>
|
|
165
165
|
)}
|
|
166
166
|
{renderPropInput(prop, value, onChange)}
|
|
167
|
-
<div class="text-[10px] text-white/40 mt-1.5 font-mono">
|
|
168
|
-
{prop.type}
|
|
169
|
-
</div>
|
|
170
167
|
</div>
|
|
171
168
|
)
|
|
172
169
|
}
|
|
@@ -232,7 +229,7 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
232
229
|
const slug = slugify(newName.trim())
|
|
233
230
|
return (
|
|
234
231
|
<form
|
|
235
|
-
class="p-3 bg-white/5 border border-white/15 rounded-cms-
|
|
232
|
+
class="p-3 bg-white/5 border border-white/15 rounded-cms-sm space-y-3"
|
|
236
233
|
onSubmit={(e) => {
|
|
237
234
|
e.preventDefault()
|
|
238
235
|
handleCreate()
|
|
@@ -256,7 +253,7 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
256
253
|
onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
|
|
257
254
|
placeholder="Enter name..."
|
|
258
255
|
required
|
|
259
|
-
class="w-full px-4 py-2
|
|
256
|
+
class="w-full px-4 py-2 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-sm"
|
|
260
257
|
autoFocus
|
|
261
258
|
/>
|
|
262
259
|
<div class="text-[11px] text-white/40 font-mono">
|
|
@@ -279,13 +276,13 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
279
276
|
<button
|
|
280
277
|
type="button"
|
|
281
278
|
onClick={resetCreateForm}
|
|
282
|
-
class="px-3 py-1.5 text-[12px] text-white/60 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-cms-
|
|
279
|
+
class="px-3 py-1.5 text-[12px] text-white/60 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-cms-sm transition-colors"
|
|
283
280
|
>
|
|
284
281
|
Cancel
|
|
285
282
|
</button>
|
|
286
283
|
<button
|
|
287
284
|
type="submit"
|
|
288
|
-
class="px-3 py-1.5 text-[12px] bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-
|
|
285
|
+
class="px-3 py-1.5 text-[12px] bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-sm transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
|
289
286
|
>
|
|
290
287
|
Create
|
|
291
288
|
</button>
|
|
@@ -301,7 +298,7 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
301
298
|
value={value}
|
|
302
299
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
303
300
|
placeholder={`Enter ${collection} entry ID...`}
|
|
304
|
-
class="w-full px-4 py-2
|
|
301
|
+
class="w-full px-4 py-2 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-sm"
|
|
305
302
|
/>
|
|
306
303
|
)
|
|
307
304
|
}
|
|
@@ -319,14 +316,14 @@ function ReferenceSelect({ collection, value, required, onChange }: {
|
|
|
319
316
|
onFocus={() => setIsOpen(true)}
|
|
320
317
|
onBlur={() => setTimeout(closeDropdown, 150)}
|
|
321
318
|
placeholder={`Select ${collection} entry...`}
|
|
322
|
-
class="w-full px-4 py-2
|
|
319
|
+
class="w-full px-4 py-2 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-sm"
|
|
323
320
|
/>
|
|
324
321
|
<DropdownPanel
|
|
325
322
|
triggerRef={inputRef}
|
|
326
323
|
isOpen={isOpen}
|
|
327
324
|
onClose={closeDropdown}
|
|
328
325
|
maxHeight={192}
|
|
329
|
-
className="border border-white/20 rounded-cms-
|
|
326
|
+
className="border border-white/20 rounded-cms-sm"
|
|
330
327
|
>
|
|
331
328
|
{!required && (
|
|
332
329
|
<button
|
|
@@ -9,8 +9,9 @@ import {
|
|
|
9
9
|
setRedirectsManagerRules,
|
|
10
10
|
showToast,
|
|
11
11
|
} from '../signals'
|
|
12
|
+
import { STRINGS } from '../strings'
|
|
12
13
|
import type { RedirectRule } from '../types'
|
|
13
|
-
import { ModalBackdrop, ModalHeader } from './modal-shell'
|
|
14
|
+
import { ModalBackdrop, ModalHeader, Section } from './modal-shell'
|
|
14
15
|
|
|
15
16
|
export function RedirectsManager() {
|
|
16
17
|
const visible = isRedirectsManagerOpen.value
|
|
@@ -24,27 +25,33 @@ export function RedirectsManager() {
|
|
|
24
25
|
<ModalBackdrop onClose={() => closeRedirectsManager()} maxWidth="max-w-2xl" extraClass="max-h-[80vh] flex flex-col">
|
|
25
26
|
<ModalHeader title="Redirects" onClose={() => closeRedirectsManager()} />
|
|
26
27
|
|
|
27
|
-
<div class="flex-1 overflow-y-auto p-5
|
|
28
|
-
|
|
28
|
+
<div class="flex-1 overflow-y-auto p-5">
|
|
29
|
+
<Section title="Redirects">
|
|
30
|
+
<div class="space-y-3">
|
|
31
|
+
{state.isLoading && <div class="text-center py-8 text-white/50">Loading redirects...</div>}
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
{!state.isLoading && state.rules.length === 0 && (
|
|
34
|
+
<div class="text-center py-8">
|
|
35
|
+
<p class="text-white/50 mb-2">No redirects configured</p>
|
|
36
|
+
<p class="text-white/30 text-sm">Redirects are stored in src/_redirects</p>
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
{!state.isLoading && state.rules.map((rule) => (
|
|
41
|
+
<RedirectRow
|
|
42
|
+
key={rule.lineIndex}
|
|
43
|
+
rule={rule}
|
|
44
|
+
isEditing={state.editingIndex === rule.lineIndex}
|
|
45
|
+
/>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
</Section>
|
|
44
49
|
</div>
|
|
45
50
|
|
|
46
|
-
<div class="shrink-0 p-5 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
|
|
47
|
-
<
|
|
51
|
+
<div class="shrink-0 p-5 pb-6 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
|
|
52
|
+
<Section title="Add redirect">
|
|
53
|
+
<AddRedirectForm />
|
|
54
|
+
</Section>
|
|
48
55
|
</div>
|
|
49
56
|
</ModalBackdrop>
|
|
50
57
|
)
|
|
@@ -72,9 +79,9 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
|
|
|
72
79
|
if (result.success) {
|
|
73
80
|
setRedirectsManagerEditing(null)
|
|
74
81
|
await refreshRedirects()
|
|
75
|
-
showToast(
|
|
82
|
+
showToast(STRINGS.redirects.updated, 'success')
|
|
76
83
|
} else {
|
|
77
|
-
showToast(result.error ||
|
|
84
|
+
showToast(result.error || STRINGS.redirects.updateFailed, 'error')
|
|
78
85
|
}
|
|
79
86
|
}, [rule.lineIndex, source, destination, statusCode])
|
|
80
87
|
|
|
@@ -85,21 +92,21 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
|
|
|
85
92
|
const result = await deleteRedirect(cfg, { lineIndex: rule.lineIndex })
|
|
86
93
|
if (result.success) {
|
|
87
94
|
await refreshRedirects()
|
|
88
|
-
showToast(
|
|
95
|
+
showToast(STRINGS.redirects.deleted, 'success')
|
|
89
96
|
} else {
|
|
90
|
-
showToast(result.error ||
|
|
97
|
+
showToast(result.error || STRINGS.redirects.deleteFailed, 'error')
|
|
91
98
|
}
|
|
92
99
|
}, [rule.lineIndex])
|
|
93
100
|
|
|
94
101
|
if (isEditing) {
|
|
95
102
|
return (
|
|
96
|
-
<div class="flex flex-col gap-2 p-3 bg-white/5 rounded-cms-
|
|
103
|
+
<div class="flex flex-col gap-2 p-3 bg-white/5 rounded-cms-sm border border-white/10">
|
|
97
104
|
<div class="flex gap-2">
|
|
98
105
|
<input
|
|
99
106
|
type="text"
|
|
100
107
|
value={source}
|
|
101
108
|
onInput={(e) => setSource((e.target as HTMLInputElement).value)}
|
|
102
|
-
class="flex-1 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-
|
|
109
|
+
class="flex-1 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-sm text-white text-sm focus:outline-none focus:border-white/40"
|
|
103
110
|
placeholder="/old-path"
|
|
104
111
|
data-cms-ui
|
|
105
112
|
/>
|
|
@@ -108,7 +115,7 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
|
|
|
108
115
|
type="text"
|
|
109
116
|
value={destination}
|
|
110
117
|
onInput={(e) => setDestination((e.target as HTMLInputElement).value)}
|
|
111
|
-
class="flex-1 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-
|
|
118
|
+
class="flex-1 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-sm text-white text-sm focus:outline-none focus:border-white/40"
|
|
112
119
|
placeholder="/new-path"
|
|
113
120
|
data-cms-ui
|
|
114
121
|
/>
|
|
@@ -116,7 +123,7 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
|
|
|
116
123
|
type="text"
|
|
117
124
|
value={statusCode}
|
|
118
125
|
onInput={(e) => setStatusCode((e.target as HTMLInputElement).value)}
|
|
119
|
-
class="w-16 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-
|
|
126
|
+
class="w-16 px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-sm text-white text-sm text-center focus:outline-none focus:border-white/40"
|
|
120
127
|
placeholder="307"
|
|
121
128
|
data-cms-ui
|
|
122
129
|
/>
|
|
@@ -125,7 +132,7 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
|
|
|
125
132
|
<button
|
|
126
133
|
type="button"
|
|
127
134
|
onClick={() => setRedirectsManagerEditing(null)}
|
|
128
|
-
class="px-3 py-1.5 text-xs text-white/60 hover:text-white hover:bg-white/10 rounded-cms-
|
|
135
|
+
class="px-3 py-1.5 text-xs text-white/60 hover:text-white hover:bg-white/10 rounded-cms-sm transition-colors cursor-pointer"
|
|
129
136
|
data-cms-ui
|
|
130
137
|
>
|
|
131
138
|
Cancel
|
|
@@ -134,7 +141,7 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
|
|
|
134
141
|
type="button"
|
|
135
142
|
onClick={handleSave}
|
|
136
143
|
disabled={isSaving}
|
|
137
|
-
class="px-3 py-1.5 text-xs font-medium bg-cms-primary text-cms-primary-text rounded-cms-
|
|
144
|
+
class="px-3 py-1.5 text-xs font-medium bg-cms-primary text-cms-primary-text rounded-cms-sm hover:bg-cms-primary-hover transition-colors cursor-pointer disabled:opacity-40"
|
|
138
145
|
data-cms-ui
|
|
139
146
|
>
|
|
140
147
|
{isSaving ? 'Saving...' : 'Save'}
|
|
@@ -145,14 +152,14 @@ function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boole
|
|
|
145
152
|
}
|
|
146
153
|
|
|
147
154
|
return (
|
|
148
|
-
<div class="flex items-center gap-3 p-3 bg-white/5 rounded-cms-
|
|
155
|
+
<div class="flex items-center gap-3 p-3 bg-white/5 rounded-cms-sm border border-white/10 group h-[34px]">
|
|
149
156
|
<div class="flex-1 min-w-0 flex items-center gap-2 text-sm">
|
|
150
157
|
<span class="text-white/80 truncate">{rule.source}</span>
|
|
151
158
|
<span class="text-white/30 shrink-0">→</span>
|
|
152
159
|
<span class="text-white/60 truncate">{rule.destination}</span>
|
|
153
160
|
</div>
|
|
154
161
|
<span class="text-xs text-white/30 tabular-nums shrink-0">{rule.statusCode}</span>
|
|
155
|
-
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-
|
|
162
|
+
<div class="flex gap-1 shrink-0 -ml-3 max-w-0 overflow-hidden opacity-0 group-hover:ml-0 group-hover:max-w-[80px] group-hover:opacity-100 transition-all duration-200 ease-out">
|
|
156
163
|
<button
|
|
157
164
|
type="button"
|
|
158
165
|
onClick={() => setRedirectsManagerEditing(rule.lineIndex)}
|
|
@@ -211,9 +218,9 @@ function AddRedirectForm() {
|
|
|
211
218
|
setSource('')
|
|
212
219
|
setDestination('')
|
|
213
220
|
await refreshRedirects()
|
|
214
|
-
showToast(
|
|
221
|
+
showToast(STRINGS.redirects.added, 'success')
|
|
215
222
|
} else {
|
|
216
|
-
showToast(result.error ||
|
|
223
|
+
showToast(result.error || STRINGS.redirects.addFailed, 'error')
|
|
217
224
|
}
|
|
218
225
|
}, [source, destination])
|
|
219
226
|
|
|
@@ -226,7 +233,7 @@ function AddRedirectForm() {
|
|
|
226
233
|
value={source}
|
|
227
234
|
onInput={(e) => setSource((e.target as HTMLInputElement).value)}
|
|
228
235
|
placeholder="/old-path"
|
|
229
|
-
class="w-full px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-
|
|
236
|
+
class="w-full px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-sm text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-white/40"
|
|
230
237
|
data-cms-ui
|
|
231
238
|
/>
|
|
232
239
|
</div>
|
|
@@ -237,7 +244,7 @@ function AddRedirectForm() {
|
|
|
237
244
|
value={destination}
|
|
238
245
|
onInput={(e) => setDestination((e.target as HTMLInputElement).value)}
|
|
239
246
|
placeholder="/new-path"
|
|
240
|
-
class="w-full px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-
|
|
247
|
+
class="w-full px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-sm text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-white/40"
|
|
241
248
|
data-cms-ui
|
|
242
249
|
/>
|
|
243
250
|
</div>
|
|
@@ -245,7 +252,7 @@ function AddRedirectForm() {
|
|
|
245
252
|
type="button"
|
|
246
253
|
onClick={handleAdd}
|
|
247
254
|
disabled={isAdding || !source.trim() || !destination.trim()}
|
|
248
|
-
class="px-4 py-1.5 text-sm font-medium bg-cms-primary text-cms-primary-text rounded-cms-
|
|
255
|
+
class="px-4 py-1.5 text-sm font-medium bg-cms-primary text-cms-primary-text rounded-cms-sm hover:bg-cms-primary-hover transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
|
249
256
|
data-cms-ui
|
|
250
257
|
>
|
|
251
258
|
{isAdding ? 'Adding...' : 'Add'}
|
|
@@ -5,6 +5,7 @@ import { useSearchFilter } from '../hooks/useSearchFilter'
|
|
|
5
5
|
import { getCollectionEntryOptions } from '../manifest'
|
|
6
6
|
import { updateMarkdownPage } from '../markdown-api'
|
|
7
7
|
import { closeReferencePicker, config, manifest, referencePickerState, showToast } from '../signals'
|
|
8
|
+
import { STRINGS } from '../strings'
|
|
8
9
|
import { Spinner } from './spinner'
|
|
9
10
|
|
|
10
11
|
const PANEL_WIDTH = 320
|
|
@@ -46,12 +47,12 @@ export function ReferencePicker() {
|
|
|
46
47
|
frontmatter: { [state.fieldName]: value },
|
|
47
48
|
})
|
|
48
49
|
if (result.success) {
|
|
49
|
-
showToast(
|
|
50
|
+
showToast(STRINGS.reference.updated, 'success')
|
|
50
51
|
} else {
|
|
51
|
-
showToast(result.error ||
|
|
52
|
+
showToast(result.error || STRINGS.reference.updateFailed, 'error')
|
|
52
53
|
}
|
|
53
54
|
} catch {
|
|
54
|
-
showToast(
|
|
55
|
+
showToast(STRINGS.reference.updateFailed, 'error')
|
|
55
56
|
}
|
|
56
57
|
closeReferencePicker()
|
|
57
58
|
}, [state.fieldName, state.ownerPath])
|
|
@@ -153,7 +154,7 @@ export function ReferencePicker() {
|
|
|
153
154
|
}`}
|
|
154
155
|
>
|
|
155
156
|
{isSelected && (
|
|
156
|
-
<svg class="w-3 h-3 text-
|
|
157
|
+
<svg class="w-3 h-3 text-cms-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
157
158
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
|
158
159
|
</svg>
|
|
159
160
|
)}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useState } from 'preact/hooks'
|
|
2
2
|
import { saveBatchChanges } from '../api'
|
|
3
3
|
import { isApplyingUndoRedo, recordChange } from '../history'
|
|
4
|
+
import { cn } from '../lib/cn'
|
|
4
5
|
import {
|
|
5
6
|
clearPendingSeoChanges,
|
|
6
7
|
closeSeoEditor,
|
|
@@ -14,9 +15,10 @@ import {
|
|
|
14
15
|
setPendingSeoChange,
|
|
15
16
|
showToast,
|
|
16
17
|
} from '../signals'
|
|
18
|
+
import { STRINGS } from '../strings'
|
|
17
19
|
import type { ChangePayload, PageSeoData, PendingSeoChange } from '../types'
|
|
18
20
|
import { ColorField, ComboBoxField, ImageField } from './fields'
|
|
19
|
-
import { CancelButton, CloseButton, ModalBackdrop } from './modal-shell'
|
|
21
|
+
import { CancelButton, CloseButton, ModalBackdrop, Section } from './modal-shell'
|
|
20
22
|
import { Spinner } from './spinner'
|
|
21
23
|
|
|
22
24
|
const OG_TYPE_OPTIONS = [
|
|
@@ -55,10 +57,11 @@ interface SeoFieldProps {
|
|
|
55
57
|
value: string | undefined
|
|
56
58
|
placeholder?: string
|
|
57
59
|
multiline?: boolean
|
|
60
|
+
tooltip?: string
|
|
58
61
|
onChange: (id: string, value: string, originalValue: string) => void
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
function SeoField({ label, id, value, placeholder, multiline, onChange }: SeoFieldProps) {
|
|
64
|
+
function SeoField({ label, id, value, placeholder, multiline, tooltip, onChange }: SeoFieldProps) {
|
|
62
65
|
const pendingChange = id ? getPendingSeoChange(id) : undefined
|
|
63
66
|
const currentValue = pendingChange?.newValue ?? value ?? ''
|
|
64
67
|
const isDirty = pendingChange?.isDirty ?? false
|
|
@@ -75,7 +78,19 @@ function SeoField({ label, id, value, placeholder, multiline, onChange }: SeoFie
|
|
|
75
78
|
return (
|
|
76
79
|
<div class="space-y-1.5">
|
|
77
80
|
<div class="flex items-center justify-between">
|
|
78
|
-
<
|
|
81
|
+
<div class="flex items-center gap-1.5">
|
|
82
|
+
<label class="text-sm font-medium text-white/80">{label}</label>
|
|
83
|
+
{tooltip && (
|
|
84
|
+
<span class="relative group/tooltip inline-flex" data-cms-ui>
|
|
85
|
+
<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">
|
|
86
|
+
<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" />
|
|
87
|
+
</svg>
|
|
88
|
+
<span class="absolute left-0 top-full mt-1 w-64 p-2 bg-black/90 text-white text-xs rounded-cms-sm opacity-0 invisible group-hover/tooltip:opacity-100 group-hover/tooltip:visible transition-all z-10 pointer-events-none">
|
|
89
|
+
{tooltip}
|
|
90
|
+
</span>
|
|
91
|
+
</span>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
79
94
|
{isDirty && <span class="text-xs text-cms-primary font-medium">Modified</span>}
|
|
80
95
|
</div>
|
|
81
96
|
<InputComponent
|
|
@@ -84,11 +99,12 @@ function SeoField({ label, id, value, placeholder, multiline, onChange }: SeoFie
|
|
|
84
99
|
placeholder={placeholder ?? `Enter ${label.toLowerCase()}...`}
|
|
85
100
|
onInput={handleChange}
|
|
86
101
|
disabled={!id}
|
|
87
|
-
class={
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
class={cn(
|
|
103
|
+
'w-full p-2.5 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors focus:border-white/40 focus:ring-white/10',
|
|
104
|
+
isDirty ? 'border-white/30' : 'border-white/20',
|
|
105
|
+
!id && 'opacity-50 cursor-not-allowed',
|
|
106
|
+
multiline && 'min-h-20 resize-y',
|
|
107
|
+
)}
|
|
92
108
|
data-cms-ui
|
|
93
109
|
/>
|
|
94
110
|
</div>
|
|
@@ -107,19 +123,13 @@ function useSeoMeta(tag: { id?: string; content: string } | undefined) {
|
|
|
107
123
|
}
|
|
108
124
|
}
|
|
109
125
|
|
|
110
|
-
|
|
111
|
-
title: string
|
|
112
|
-
children: preact.ComponentChildren
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function SeoSection({ title, children }: SeoSectionProps) {
|
|
126
|
+
function SeoSection({ title, children }: { title: string; children: preact.ComponentChildren }) {
|
|
116
127
|
return (
|
|
117
|
-
<
|
|
118
|
-
<h3 class="text-sm font-semibold text-white/60 uppercase tracking-wider">{title}</h3>
|
|
128
|
+
<Section title={title}>
|
|
119
129
|
<div class="space-y-4">
|
|
120
130
|
{children}
|
|
121
131
|
</div>
|
|
122
|
-
</
|
|
132
|
+
</Section>
|
|
123
133
|
)
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -222,14 +232,14 @@ export function SeoEditor() {
|
|
|
222
232
|
|
|
223
233
|
if (result.errors && result.errors.length > 0) {
|
|
224
234
|
const details = result.errors.map(e => e.error).join('; ')
|
|
225
|
-
showToast(
|
|
235
|
+
showToast(STRINGS.seo.saveFailed(details), 'error')
|
|
226
236
|
} else {
|
|
227
|
-
showToast(
|
|
237
|
+
showToast(STRINGS.seo.saveSuccess(result.updated), 'success')
|
|
228
238
|
clearPendingSeoChanges()
|
|
229
239
|
closeSeoEditor()
|
|
230
240
|
}
|
|
231
241
|
} catch (error) {
|
|
232
|
-
showToast(error instanceof Error ? error.message :
|
|
242
|
+
showToast(error instanceof Error ? error.message : STRINGS.seo.saveFailedFallback, 'error')
|
|
233
243
|
} finally {
|
|
234
244
|
setIsSaving(false)
|
|
235
245
|
}
|
|
@@ -260,7 +270,7 @@ export function SeoEditor() {
|
|
|
260
270
|
return (
|
|
261
271
|
<ModalBackdrop onClose={handleClose} maxWidth="max-w-2xl" extraClass="max-h-[85vh] flex flex-col">
|
|
262
272
|
{/* Header */}
|
|
263
|
-
<div class="flex items-center justify-between
|
|
273
|
+
<div class="flex items-center justify-between px-5 py-4 border-b border-white/10">
|
|
264
274
|
<div class="flex items-center gap-3">
|
|
265
275
|
<h2 class="text-lg font-semibold text-white">SEO Settings</h2>
|
|
266
276
|
{dirtyCount > 0 && (
|
|
@@ -327,6 +337,7 @@ export function SeoEditor() {
|
|
|
327
337
|
id={seoData.canonical.id}
|
|
328
338
|
value={seoData.canonical.href}
|
|
329
339
|
placeholder="https://example.com/page"
|
|
340
|
+
tooltip="The preferred URL for this page. Tells search engines which version to index when the same content is reachable from multiple URLs (e.g. with/without query parameters)."
|
|
330
341
|
onChange={handleFieldChange}
|
|
331
342
|
/>
|
|
332
343
|
)}
|
|
@@ -376,7 +387,6 @@ export function SeoEditor() {
|
|
|
376
387
|
<ImageField
|
|
377
388
|
label={label}
|
|
378
389
|
value={currentValue}
|
|
379
|
-
placeholder="/favicon.svg"
|
|
380
390
|
onChange={(v) => {
|
|
381
391
|
if (faviconId) handleFieldChange(faviconId, v, originalValue)
|
|
382
392
|
}}
|
|
@@ -419,7 +429,6 @@ export function SeoEditor() {
|
|
|
419
429
|
<ImageField
|
|
420
430
|
label="OG Image"
|
|
421
431
|
value={ogImage.current}
|
|
422
|
-
placeholder="/images/og-image.jpg"
|
|
423
432
|
onChange={(v) => {
|
|
424
433
|
if (ogImage.id) handleFieldChange(ogImage.id, v, ogImage.original)
|
|
425
434
|
}}
|
|
@@ -502,7 +511,6 @@ export function SeoEditor() {
|
|
|
502
511
|
<ImageField
|
|
503
512
|
label="Twitter Image"
|
|
504
513
|
value={twitterImage.current}
|
|
505
|
-
placeholder="/images/twitter-image.jpg"
|
|
506
514
|
onChange={(v) => {
|
|
507
515
|
if (twitterImage.id) handleFieldChange(twitterImage.id, v, twitterImage.original)
|
|
508
516
|
}}
|
|
@@ -555,11 +563,12 @@ export function SeoEditor() {
|
|
|
555
563
|
type="button"
|
|
556
564
|
onClick={handleSaveAll}
|
|
557
565
|
disabled={dirtyCount === 0 || isSaving}
|
|
558
|
-
class={
|
|
566
|
+
class={cn(
|
|
567
|
+
'px-5 py-2 text-sm font-medium rounded-cms-pill transition-colors flex items-center gap-2',
|
|
559
568
|
dirtyCount > 0 && !isSaving
|
|
560
569
|
? 'bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover'
|
|
561
|
-
: 'bg-white/10 text-white/40 cursor-not-allowed'
|
|
562
|
-
}
|
|
570
|
+
: 'bg-white/10 text-white/40 cursor-not-allowed',
|
|
571
|
+
)}
|
|
563
572
|
data-cms-ui
|
|
564
573
|
>
|
|
565
574
|
{isSaving && <Spinner />}
|