@nuasite/cms 0.18.1 → 0.19.1
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 +52746 -36711
- package/package.json +16 -14
- package/src/build-processor.ts +4 -1
- package/src/collection-scanner.ts +425 -48
- package/src/dev-middleware.ts +26 -203
- package/src/editor/api.ts +1 -22
- package/src/editor/components/ai-chat.tsx +3 -3
- package/src/editor/components/ai-tooltip.tsx +2 -1
- package/src/editor/components/block-editor.tsx +13 -108
- package/src/editor/components/collections-browser.tsx +168 -205
- package/src/editor/components/component-card.tsx +49 -0
- package/src/editor/components/confirm-dialog.tsx +34 -47
- package/src/editor/components/create-page-modal.tsx +529 -101
- package/src/editor/components/delete-page-dialog.tsx +100 -0
- package/src/editor/components/fields.tsx +175 -0
- package/src/editor/components/frontmatter-fields.tsx +281 -70
- package/src/editor/components/frontmatter-sidebar.tsx +223 -0
- package/src/editor/components/highlight-overlay.ts +3 -2
- package/src/editor/components/markdown-editor-overlay.tsx +131 -85
- package/src/editor/components/markdown-inline-editor.tsx +74 -5
- package/src/editor/components/mdx-block-view.tsx +102 -0
- package/src/editor/components/mdx-component-picker.tsx +123 -0
- package/src/editor/components/mdx-props-editor.tsx +94 -0
- package/src/editor/components/media-library.tsx +373 -100
- package/src/editor/components/modal-shell.tsx +87 -0
- package/src/editor/components/prop-editor.tsx +52 -0
- package/src/editor/components/redirect-countdown.tsx +3 -1
- package/src/editor/components/redirects-manager.tsx +269 -0
- package/src/editor/components/reference-picker.tsx +203 -0
- package/src/editor/components/seo-editor.tsx +285 -303
- package/src/editor/components/toast/toast-container.tsx +2 -1
- package/src/editor/components/toolbar.tsx +177 -46
- package/src/editor/constants.ts +26 -0
- package/src/editor/editor.ts +112 -0
- package/src/editor/fetch.ts +62 -0
- package/src/editor/index.tsx +19 -1
- package/src/editor/markdown-api.ts +105 -156
- package/src/editor/milkdown-mdx-plugin.tsx +269 -0
- package/src/editor/signals.ts +206 -13
- package/src/editor/types.ts +52 -1
- package/src/handlers/api-routes.ts +251 -0
- package/src/handlers/component-ops.ts +2 -18
- package/src/handlers/markdown-ops.ts +202 -47
- package/src/handlers/page-ops.ts +229 -0
- package/src/handlers/redirect-ops.ts +163 -0
- package/src/handlers/source-writer.ts +157 -1
- package/src/html-processor.ts +14 -2
- package/src/index.ts +78 -14
- package/src/manifest-writer.ts +19 -1
- package/src/media/contember.ts +2 -1
- package/src/media/local.ts +66 -28
- package/src/media/project-images.ts +81 -0
- package/src/media/s3.ts +32 -11
- package/src/media/types.ts +24 -2
- package/src/shared.ts +27 -0
- package/src/source-finder/collection-finder.ts +219 -41
- package/src/source-finder/index.ts +7 -1
- package/src/source-finder/search-index.ts +178 -36
- package/src/source-finder/snippet-utils.ts +423 -3
- package/src/types.ts +111 -2
- package/src/utils.ts +40 -4
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ComponentChildren } from 'preact'
|
|
2
|
+
import { Z_INDEX } from '../constants'
|
|
3
|
+
|
|
4
|
+
export function ModalBackdrop({ onClose, maxWidth = 'max-w-lg', extraClass, children }: {
|
|
5
|
+
onClose: () => void
|
|
6
|
+
maxWidth?: string
|
|
7
|
+
extraClass?: string
|
|
8
|
+
children: ComponentChildren
|
|
9
|
+
}) {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
style={{ zIndex: Z_INDEX.MODAL }}
|
|
13
|
+
class="fixed inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
|
14
|
+
onClick={onClose}
|
|
15
|
+
data-cms-ui
|
|
16
|
+
>
|
|
17
|
+
<div
|
|
18
|
+
class={`bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] ${maxWidth} w-full border border-white/10 ${extraClass ?? ''}`}
|
|
19
|
+
onClick={(e) => e.stopPropagation()}
|
|
20
|
+
data-cms-ui
|
|
21
|
+
>
|
|
22
|
+
{children}
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ModalHeader({ title, onBack, onClose }: {
|
|
29
|
+
title: string
|
|
30
|
+
onBack?: () => void
|
|
31
|
+
onClose: () => void
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<div class="flex items-center gap-3 p-5 border-b border-white/10">
|
|
35
|
+
{onBack && (
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={onBack}
|
|
39
|
+
class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors cursor-pointer"
|
|
40
|
+
data-cms-ui
|
|
41
|
+
>
|
|
42
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
43
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
44
|
+
</svg>
|
|
45
|
+
</button>
|
|
46
|
+
)}
|
|
47
|
+
<h2 class="text-lg font-semibold text-white flex-1">{title}</h2>
|
|
48
|
+
<CloseButton onClick={onClose} />
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ModalFooter({ children }: { children: ComponentChildren }) {
|
|
54
|
+
return (
|
|
55
|
+
<div class="flex items-center justify-end gap-2 p-5 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
|
|
56
|
+
{children}
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function CloseButton({ onClick }: { onClick: () => void }) {
|
|
62
|
+
return (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={onClick}
|
|
66
|
+
class="text-white/50 hover:text-white p-1.5 hover:bg-white/10 rounded-full transition-colors cursor-pointer"
|
|
67
|
+
data-cms-ui
|
|
68
|
+
>
|
|
69
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
70
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
71
|
+
</svg>
|
|
72
|
+
</button>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function CancelButton({ onClick, label = 'Cancel' }: { onClick: () => void; label?: string }) {
|
|
77
|
+
return (
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
onClick={onClick}
|
|
81
|
+
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"
|
|
82
|
+
data-cms-ui
|
|
83
|
+
>
|
|
84
|
+
{label}
|
|
85
|
+
</button>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ComponentProp } from '../types'
|
|
2
|
+
|
|
3
|
+
export interface PropEditorProps {
|
|
4
|
+
prop: ComponentProp
|
|
5
|
+
value: string
|
|
6
|
+
onChange: (value: string) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function PropEditor({ prop, value, onChange }: PropEditorProps) {
|
|
10
|
+
const isBoolean = prop.type === 'boolean'
|
|
11
|
+
const isNumber = prop.type === 'number'
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div class="mb-4">
|
|
15
|
+
<label class="block text-[13px] font-medium text-white mb-1.5">
|
|
16
|
+
{prop.name}
|
|
17
|
+
{prop.required && <span class="text-cms-error ml-1">*</span>}
|
|
18
|
+
</label>
|
|
19
|
+
{prop.description && (
|
|
20
|
+
<div class="text-[11px] text-white/50 mb-1.5">
|
|
21
|
+
{prop.description}
|
|
22
|
+
</div>
|
|
23
|
+
)}
|
|
24
|
+
{isBoolean
|
|
25
|
+
? (
|
|
26
|
+
<label class="flex items-center gap-2 cursor-pointer">
|
|
27
|
+
<input
|
|
28
|
+
type="checkbox"
|
|
29
|
+
checked={value === 'true'}
|
|
30
|
+
onChange={(e) => onChange((e.target as HTMLInputElement).checked ? 'true' : 'false')}
|
|
31
|
+
class="accent-cms-primary w-5 h-5 rounded"
|
|
32
|
+
/>
|
|
33
|
+
<span class="text-[13px] text-white">
|
|
34
|
+
{value === 'true' ? 'Enabled' : 'Disabled'}
|
|
35
|
+
</span>
|
|
36
|
+
</label>
|
|
37
|
+
)
|
|
38
|
+
: (
|
|
39
|
+
<input
|
|
40
|
+
type={isNumber ? 'number' : 'text'}
|
|
41
|
+
value={value}
|
|
42
|
+
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
43
|
+
placeholder={prop.defaultValue || `Enter ${prop.name}...`}
|
|
44
|
+
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"
|
|
45
|
+
/>
|
|
46
|
+
)}
|
|
47
|
+
<div class="text-[10px] text-white/40 mt-1.5 font-mono">
|
|
48
|
+
{prop.type}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Z_INDEX } from '../constants'
|
|
1
2
|
import { redirectCountdown, stopRedirectCountdown } from '../signals'
|
|
2
3
|
|
|
3
4
|
export function RedirectCountdown() {
|
|
@@ -8,7 +9,8 @@ export function RedirectCountdown() {
|
|
|
8
9
|
|
|
9
10
|
return (
|
|
10
11
|
<div
|
|
11
|
-
|
|
12
|
+
style={{ zIndex: Z_INDEX.MODAL }}
|
|
13
|
+
class="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-3 px-5 py-3 bg-cms-dark/95 border border-white/15 rounded-cms-pill shadow-[0_8px_32px_rgba(0,0,0,0.4)] backdrop-blur-md"
|
|
12
14
|
data-cms-ui
|
|
13
15
|
onMouseDown={stopPropagation}
|
|
14
16
|
onClick={stopPropagation}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'preact/hooks'
|
|
2
|
+
import { addRedirect, deleteRedirect, getRedirects, updateRedirect } from '../markdown-api'
|
|
3
|
+
import {
|
|
4
|
+
closeRedirectsManager,
|
|
5
|
+
config,
|
|
6
|
+
isRedirectsManagerOpen,
|
|
7
|
+
redirectsManagerState,
|
|
8
|
+
setRedirectsManagerEditing,
|
|
9
|
+
setRedirectsManagerRules,
|
|
10
|
+
showToast,
|
|
11
|
+
} from '../signals'
|
|
12
|
+
import type { RedirectRule } from '../types'
|
|
13
|
+
import { ModalBackdrop, ModalHeader } from './modal-shell'
|
|
14
|
+
|
|
15
|
+
export function RedirectsManager() {
|
|
16
|
+
const visible = isRedirectsManagerOpen.value
|
|
17
|
+
const state = redirectsManagerState.value
|
|
18
|
+
|
|
19
|
+
useLoadRedirects()
|
|
20
|
+
|
|
21
|
+
if (!visible) return null
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<ModalBackdrop onClose={() => closeRedirectsManager()} maxWidth="max-w-2xl" extraClass="max-h-[80vh] flex flex-col">
|
|
25
|
+
<ModalHeader title="Redirects" onClose={() => closeRedirectsManager()} />
|
|
26
|
+
|
|
27
|
+
<div class="flex-1 overflow-y-auto p-5 space-y-3">
|
|
28
|
+
{state.isLoading && <div class="text-center py-8 text-white/50">Loading redirects...</div>}
|
|
29
|
+
|
|
30
|
+
{!state.isLoading && state.rules.length === 0 && (
|
|
31
|
+
<div class="text-center py-8">
|
|
32
|
+
<p class="text-white/50 mb-2">No redirects configured</p>
|
|
33
|
+
<p class="text-white/30 text-sm">Redirects are stored in src/_redirects</p>
|
|
34
|
+
</div>
|
|
35
|
+
)}
|
|
36
|
+
|
|
37
|
+
{!state.isLoading && state.rules.map((rule) => (
|
|
38
|
+
<RedirectRow
|
|
39
|
+
key={rule.lineIndex}
|
|
40
|
+
rule={rule}
|
|
41
|
+
isEditing={state.editingIndex === rule.lineIndex}
|
|
42
|
+
/>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div class="shrink-0 p-5 border-t border-white/10 bg-white/5 rounded-b-cms-xl">
|
|
47
|
+
<AddRedirectForm />
|
|
48
|
+
</div>
|
|
49
|
+
</ModalBackdrop>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function RedirectRow({ rule, isEditing }: { rule: RedirectRule; isEditing: boolean }) {
|
|
54
|
+
const [source, setSource] = useState(rule.source)
|
|
55
|
+
const [destination, setDestination] = useState(rule.destination)
|
|
56
|
+
const [statusCode, setStatusCode] = useState(String(rule.statusCode))
|
|
57
|
+
const [isSaving, setIsSaving] = useState(false)
|
|
58
|
+
|
|
59
|
+
const handleSave = useCallback(async () => {
|
|
60
|
+
const cfg = config.value
|
|
61
|
+
if (!cfg) return
|
|
62
|
+
|
|
63
|
+
setIsSaving(true)
|
|
64
|
+
const result = await updateRedirect(cfg, {
|
|
65
|
+
lineIndex: rule.lineIndex,
|
|
66
|
+
source,
|
|
67
|
+
destination,
|
|
68
|
+
statusCode: parseInt(statusCode, 10) || 307,
|
|
69
|
+
})
|
|
70
|
+
setIsSaving(false)
|
|
71
|
+
|
|
72
|
+
if (result.success) {
|
|
73
|
+
setRedirectsManagerEditing(null)
|
|
74
|
+
await refreshRedirects()
|
|
75
|
+
showToast('Redirect updated', 'success')
|
|
76
|
+
} else {
|
|
77
|
+
showToast(result.error || 'Failed to update', 'error')
|
|
78
|
+
}
|
|
79
|
+
}, [rule.lineIndex, source, destination, statusCode])
|
|
80
|
+
|
|
81
|
+
const handleDelete = useCallback(async () => {
|
|
82
|
+
const cfg = config.value
|
|
83
|
+
if (!cfg) return
|
|
84
|
+
|
|
85
|
+
const result = await deleteRedirect(cfg, { lineIndex: rule.lineIndex })
|
|
86
|
+
if (result.success) {
|
|
87
|
+
await refreshRedirects()
|
|
88
|
+
showToast('Redirect deleted', 'success')
|
|
89
|
+
} else {
|
|
90
|
+
showToast(result.error || 'Failed to delete', 'error')
|
|
91
|
+
}
|
|
92
|
+
}, [rule.lineIndex])
|
|
93
|
+
|
|
94
|
+
if (isEditing) {
|
|
95
|
+
return (
|
|
96
|
+
<div class="flex flex-col gap-2 p-3 bg-white/5 rounded-cms-lg border border-white/10">
|
|
97
|
+
<div class="flex gap-2">
|
|
98
|
+
<input
|
|
99
|
+
type="text"
|
|
100
|
+
value={source}
|
|
101
|
+
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-md text-white text-sm focus:outline-none focus:border-cms-primary/50"
|
|
103
|
+
placeholder="/old-path"
|
|
104
|
+
data-cms-ui
|
|
105
|
+
/>
|
|
106
|
+
<span class="text-white/30 self-center">→</span>
|
|
107
|
+
<input
|
|
108
|
+
type="text"
|
|
109
|
+
value={destination}
|
|
110
|
+
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-md text-white text-sm focus:outline-none focus:border-cms-primary/50"
|
|
112
|
+
placeholder="/new-path"
|
|
113
|
+
data-cms-ui
|
|
114
|
+
/>
|
|
115
|
+
<input
|
|
116
|
+
type="text"
|
|
117
|
+
value={statusCode}
|
|
118
|
+
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-md text-white text-sm text-center focus:outline-none focus:border-cms-primary/50"
|
|
120
|
+
placeholder="307"
|
|
121
|
+
data-cms-ui
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="flex justify-end gap-2">
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
onClick={() => setRedirectsManagerEditing(null)}
|
|
128
|
+
class="px-3 py-1.5 text-xs text-white/60 hover:text-white hover:bg-white/10 rounded-cms-md transition-colors cursor-pointer"
|
|
129
|
+
data-cms-ui
|
|
130
|
+
>
|
|
131
|
+
Cancel
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={handleSave}
|
|
136
|
+
disabled={isSaving}
|
|
137
|
+
class="px-3 py-1.5 text-xs font-medium bg-cms-primary text-cms-primary-text rounded-cms-md hover:bg-cms-primary-hover transition-colors cursor-pointer disabled:opacity-40"
|
|
138
|
+
data-cms-ui
|
|
139
|
+
>
|
|
140
|
+
{isSaving ? 'Saving...' : 'Save'}
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div class="flex items-center gap-3 p-3 bg-white/5 rounded-cms-lg border border-white/10 group">
|
|
149
|
+
<div class="flex-1 min-w-0 flex items-center gap-2 text-sm">
|
|
150
|
+
<span class="text-white/80 truncate">{rule.source}</span>
|
|
151
|
+
<span class="text-white/30 shrink-0">→</span>
|
|
152
|
+
<span class="text-white/60 truncate">{rule.destination}</span>
|
|
153
|
+
</div>
|
|
154
|
+
<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-opacity shrink-0">
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
onClick={() => setRedirectsManagerEditing(rule.lineIndex)}
|
|
159
|
+
class="p-1.5 text-white/40 hover:text-white hover:bg-white/10 rounded transition-colors cursor-pointer"
|
|
160
|
+
title="Edit"
|
|
161
|
+
data-cms-ui
|
|
162
|
+
>
|
|
163
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
164
|
+
<path
|
|
165
|
+
stroke-linecap="round"
|
|
166
|
+
stroke-linejoin="round"
|
|
167
|
+
stroke-width="2"
|
|
168
|
+
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
|
169
|
+
/>
|
|
170
|
+
</svg>
|
|
171
|
+
</button>
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
onClick={handleDelete}
|
|
175
|
+
class="p-1.5 text-white/40 hover:text-red-400 hover:bg-white/10 rounded transition-colors cursor-pointer"
|
|
176
|
+
title="Delete"
|
|
177
|
+
data-cms-ui
|
|
178
|
+
>
|
|
179
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
180
|
+
<path
|
|
181
|
+
stroke-linecap="round"
|
|
182
|
+
stroke-linejoin="round"
|
|
183
|
+
stroke-width="2"
|
|
184
|
+
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
185
|
+
/>
|
|
186
|
+
</svg>
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function AddRedirectForm() {
|
|
194
|
+
const [source, setSource] = useState('')
|
|
195
|
+
const [destination, setDestination] = useState('')
|
|
196
|
+
const [isAdding, setIsAdding] = useState(false)
|
|
197
|
+
|
|
198
|
+
const handleAdd = useCallback(async () => {
|
|
199
|
+
const cfg = config.value
|
|
200
|
+
if (!cfg || !source.trim() || !destination.trim()) return
|
|
201
|
+
|
|
202
|
+
setIsAdding(true)
|
|
203
|
+
const result = await addRedirect(cfg, {
|
|
204
|
+
source: source.trim(),
|
|
205
|
+
destination: destination.trim(),
|
|
206
|
+
statusCode: 307,
|
|
207
|
+
})
|
|
208
|
+
setIsAdding(false)
|
|
209
|
+
|
|
210
|
+
if (result.success) {
|
|
211
|
+
setSource('')
|
|
212
|
+
setDestination('')
|
|
213
|
+
await refreshRedirects()
|
|
214
|
+
showToast('Redirect added', 'success')
|
|
215
|
+
} else {
|
|
216
|
+
showToast(result.error || 'Failed to add redirect', 'error')
|
|
217
|
+
}
|
|
218
|
+
}, [source, destination])
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div class="flex gap-2 items-end">
|
|
222
|
+
<div class="flex-1 space-y-1">
|
|
223
|
+
<label class="text-xs text-white/40" data-cms-ui>From</label>
|
|
224
|
+
<input
|
|
225
|
+
type="text"
|
|
226
|
+
value={source}
|
|
227
|
+
onInput={(e) => setSource((e.target as HTMLInputElement).value)}
|
|
228
|
+
placeholder="/old-path"
|
|
229
|
+
class="w-full px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-md text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
|
|
230
|
+
data-cms-ui
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
<div class="flex-1 space-y-1">
|
|
234
|
+
<label class="text-xs text-white/40" data-cms-ui>To</label>
|
|
235
|
+
<input
|
|
236
|
+
type="text"
|
|
237
|
+
value={destination}
|
|
238
|
+
onInput={(e) => setDestination((e.target as HTMLInputElement).value)}
|
|
239
|
+
placeholder="/new-path"
|
|
240
|
+
class="w-full px-2.5 py-1.5 bg-white/5 border border-white/10 rounded-cms-md text-white text-sm placeholder:text-white/30 focus:outline-none focus:border-cms-primary/50"
|
|
241
|
+
data-cms-ui
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={handleAdd}
|
|
247
|
+
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-md hover:bg-cms-primary-hover transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
|
249
|
+
data-cms-ui
|
|
250
|
+
>
|
|
251
|
+
{isAdding ? 'Adding...' : 'Add'}
|
|
252
|
+
</button>
|
|
253
|
+
</div>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function refreshRedirects(): Promise<void> {
|
|
258
|
+
const cfg = config.value
|
|
259
|
+
if (!cfg) return
|
|
260
|
+
const result = await getRedirects(cfg)
|
|
261
|
+
setRedirectsManagerRules(result.rules)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function useLoadRedirects() {
|
|
265
|
+
const isOpen = isRedirectsManagerOpen.value
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
if (isOpen) refreshRedirects()
|
|
268
|
+
}, [isOpen])
|
|
269
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
|
+
import { clampPanelPosition, Z_INDEX } from '../constants'
|
|
3
|
+
import { updateMarkdownPage } from '../markdown-api'
|
|
4
|
+
import { closeReferencePicker, config, manifest, referencePickerState, showToast } from '../signals'
|
|
5
|
+
|
|
6
|
+
const PANEL_WIDTH = 320
|
|
7
|
+
|
|
8
|
+
function getCollectionEntryOptions(collectionName?: string): Array<{ value: string; label: string }> {
|
|
9
|
+
if (!collectionName) return []
|
|
10
|
+
const def = manifest.value.collectionDefinitions?.[collectionName]
|
|
11
|
+
if (!def?.entries) return []
|
|
12
|
+
return def.entries.map(e => ({
|
|
13
|
+
value: e.slug,
|
|
14
|
+
label: e.title ?? e.slug,
|
|
15
|
+
}))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ReferencePicker() {
|
|
19
|
+
const state = referencePickerState.value
|
|
20
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
21
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
22
|
+
const [query, setQuery] = useState('')
|
|
23
|
+
const [saving, setSaving] = useState(false)
|
|
24
|
+
|
|
25
|
+
const options = useMemo(
|
|
26
|
+
() => getCollectionEntryOptions(state.collection ?? undefined),
|
|
27
|
+
[state.collection],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// Reset search when picker opens
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (state.isOpen) {
|
|
33
|
+
setQuery('')
|
|
34
|
+
setSaving(false)
|
|
35
|
+
setTimeout(() => inputRef.current?.focus(), 50)
|
|
36
|
+
}
|
|
37
|
+
}, [state.isOpen])
|
|
38
|
+
|
|
39
|
+
const filtered = useMemo(() => {
|
|
40
|
+
if (!query) return options
|
|
41
|
+
const q = query.toLowerCase()
|
|
42
|
+
return options.filter(o => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
|
|
43
|
+
}, [query, options])
|
|
44
|
+
|
|
45
|
+
const currentLabel = useMemo(() => {
|
|
46
|
+
if (state.isArray) return null
|
|
47
|
+
return options.find(o => o.value === state.currentValue)?.label ?? state.currentValue
|
|
48
|
+
}, [options, state.currentValue, state.isArray])
|
|
49
|
+
|
|
50
|
+
const updateReference = useCallback(async (value: string | string[]) => {
|
|
51
|
+
if (!state.fieldName || !state.ownerPath) return
|
|
52
|
+
setSaving(true)
|
|
53
|
+
try {
|
|
54
|
+
const result = await updateMarkdownPage(config.value, {
|
|
55
|
+
filePath: state.ownerPath,
|
|
56
|
+
frontmatter: { [state.fieldName]: value },
|
|
57
|
+
})
|
|
58
|
+
if (result.success) {
|
|
59
|
+
showToast('Reference updated', 'success')
|
|
60
|
+
} else {
|
|
61
|
+
showToast(result.error || 'Failed to update reference', 'error')
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
showToast('Failed to update reference', 'error')
|
|
65
|
+
}
|
|
66
|
+
closeReferencePicker()
|
|
67
|
+
}, [state.fieldName, state.ownerPath])
|
|
68
|
+
|
|
69
|
+
const handleSelect = useCallback((newValue: string) => updateReference(newValue), [updateReference])
|
|
70
|
+
|
|
71
|
+
const handleArrayToggle = useCallback((toggledValue: string) => {
|
|
72
|
+
const current = new Set(state.currentValues)
|
|
73
|
+
if (current.has(toggledValue)) {
|
|
74
|
+
current.delete(toggledValue)
|
|
75
|
+
} else {
|
|
76
|
+
current.add(toggledValue)
|
|
77
|
+
}
|
|
78
|
+
updateReference([...current])
|
|
79
|
+
}, [state.currentValues, updateReference])
|
|
80
|
+
|
|
81
|
+
// Close on outside click or Escape
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!state.isOpen) return
|
|
84
|
+
const onMouseDown = (e: MouseEvent) => {
|
|
85
|
+
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
|
86
|
+
closeReferencePicker()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
90
|
+
if (e.key === 'Escape') closeReferencePicker()
|
|
91
|
+
}
|
|
92
|
+
document.addEventListener('mousedown', onMouseDown)
|
|
93
|
+
document.addEventListener('keydown', onKeyDown)
|
|
94
|
+
return () => {
|
|
95
|
+
document.removeEventListener('mousedown', onMouseDown)
|
|
96
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
97
|
+
}
|
|
98
|
+
}, [state.isOpen])
|
|
99
|
+
|
|
100
|
+
if (!state.isOpen || !state.cursorPos) return null
|
|
101
|
+
|
|
102
|
+
const position = clampPanelPosition(state.cursorPos, PANEL_WIDTH)
|
|
103
|
+
const fieldLabel = (state.fieldName ?? 'reference')
|
|
104
|
+
.replace(/([A-Z])/g, ' $1')
|
|
105
|
+
.replace(/^./, s => s.toUpperCase())
|
|
106
|
+
.trim()
|
|
107
|
+
|
|
108
|
+
const selectedSet = useMemo(
|
|
109
|
+
() => new Set(state.isArray ? state.currentValues : (state.currentValue ? [state.currentValue] : [])),
|
|
110
|
+
[state.isArray, state.currentValues, state.currentValue],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
ref={panelRef}
|
|
116
|
+
data-cms-ui
|
|
117
|
+
style={{ zIndex: Z_INDEX.MODAL, top: position.top, left: position.left, maxHeight: position.maxHeight }}
|
|
118
|
+
class="fixed w-80 bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 font-sans overflow-hidden flex flex-col"
|
|
119
|
+
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
|
120
|
+
onClick={(e: MouseEvent) => e.stopPropagation()}
|
|
121
|
+
>
|
|
122
|
+
{saving
|
|
123
|
+
? (
|
|
124
|
+
<div class="flex items-center justify-center gap-2 px-4 py-6">
|
|
125
|
+
<span class="inline-block w-4 h-4 border-2 border-white/80 border-t-transparent rounded-full animate-spin" />
|
|
126
|
+
<span class="text-sm text-white/80">Updating...</span>
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
: (
|
|
130
|
+
<>
|
|
131
|
+
{/* Header */}
|
|
132
|
+
<div class="px-4 pt-3 pb-2">
|
|
133
|
+
<div class="text-xs text-white/50 font-medium mb-1">{fieldLabel}</div>
|
|
134
|
+
{currentLabel && !state.isArray && (
|
|
135
|
+
<div class="text-sm text-white/70 mb-2">
|
|
136
|
+
Current: <span class="text-white">{currentLabel}</span>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
<input
|
|
140
|
+
ref={inputRef}
|
|
141
|
+
type="text"
|
|
142
|
+
value={query}
|
|
143
|
+
placeholder="Search..."
|
|
144
|
+
onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
|
|
145
|
+
autocomplete="off"
|
|
146
|
+
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"
|
|
147
|
+
data-cms-ui
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* Options list */}
|
|
152
|
+
<div class="overflow-y-auto max-h-64 px-2 pb-2">
|
|
153
|
+
{filtered.length === 0
|
|
154
|
+
? <div class="px-2 py-3 text-xs text-white/40 text-center">No options found</div>
|
|
155
|
+
: filtered.map(opt => {
|
|
156
|
+
const isSelected = selectedSet.has(opt.value)
|
|
157
|
+
return (
|
|
158
|
+
<button
|
|
159
|
+
key={opt.value}
|
|
160
|
+
type="button"
|
|
161
|
+
onMouseDown={(e) => {
|
|
162
|
+
e.preventDefault()
|
|
163
|
+
if (state.isArray) {
|
|
164
|
+
handleArrayToggle(opt.value)
|
|
165
|
+
} else {
|
|
166
|
+
handleSelect(opt.value)
|
|
167
|
+
}
|
|
168
|
+
}}
|
|
169
|
+
class={`w-full text-left px-3 py-2 text-sm rounded-cms-sm transition-colors cursor-pointer flex items-center gap-2 ${
|
|
170
|
+
isSelected
|
|
171
|
+
? 'bg-cms-primary/15 text-white'
|
|
172
|
+
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
|
173
|
+
}`}
|
|
174
|
+
data-cms-ui
|
|
175
|
+
>
|
|
176
|
+
{state.isArray && (
|
|
177
|
+
<span
|
|
178
|
+
class={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
|
179
|
+
isSelected ? 'bg-cms-primary border-cms-primary' : 'border-white/30 bg-white/5'
|
|
180
|
+
}`}
|
|
181
|
+
>
|
|
182
|
+
{isSelected && (
|
|
183
|
+
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
184
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
|
185
|
+
</svg>
|
|
186
|
+
)}
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
189
|
+
<span class="truncate">{opt.label}</span>
|
|
190
|
+
{isSelected && !state.isArray && (
|
|
191
|
+
<svg class="w-4 h-4 ml-auto text-cms-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
192
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
193
|
+
</svg>
|
|
194
|
+
)}
|
|
195
|
+
</button>
|
|
196
|
+
)
|
|
197
|
+
})}
|
|
198
|
+
</div>
|
|
199
|
+
</>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
}
|