@wikex/admin-kit 0.2.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/CHANGELOG.md +17 -0
- package/README.md +167 -0
- package/RELEASING.md +59 -0
- package/package.json +55 -0
- package/src/TextStyleRuntime.tsx +90 -0
- package/src/admin/BeforeLogin.tsx +19 -0
- package/src/admin/Brand.tsx +74 -0
- package/src/admin/Header.tsx +87 -0
- package/src/admin/LogoutButton.tsx +41 -0
- package/src/admin/Nav.tsx +35 -0
- package/src/admin/NavClient.tsx +288 -0
- package/src/admin/ThemeToggle.tsx +31 -0
- package/src/admin/ViewSite.tsx +11 -0
- package/src/admin/useAdminBrand.ts +81 -0
- package/src/adminExperience.ts +117 -0
- package/src/index.ts +11 -0
- package/src/live-preview/GlobalInlineInspector.tsx +334 -0
- package/src/live-preview/LivePreviewEditor.tsx +2088 -0
- package/src/live-preview/TextStyleInspector.tsx +289 -0
- package/src/live-preview/index.ts +23 -0
- package/src/live-preview/runtimeConfig.ts +52 -0
- package/src/plugin.ts +70 -0
- package/src/project.ts +82 -0
- package/src/rich-text/client.tsx +202 -0
- package/src/rich-text/index.ts +10 -0
- package/src/starter/client.tsx +22 -0
- package/src/starter.ts +95 -0
- package/src/styles/_wikex-fonts.scss +45 -0
- package/src/styles/admin.scss +3032 -0
- package/src/textStyles.ts +161 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
normalizeTextStyleMap,
|
|
7
|
+
normalizeTextStyleValue,
|
|
8
|
+
TEXT_FONT_OPTIONS,
|
|
9
|
+
TEXT_LINE_HEIGHT_OPTIONS,
|
|
10
|
+
type TextFontFamily,
|
|
11
|
+
type TextLineHeight,
|
|
12
|
+
type TextStyleValue,
|
|
13
|
+
} from '../textStyles'
|
|
14
|
+
import type { VisualEditorDocumentTarget } from './runtimeConfig'
|
|
15
|
+
|
|
16
|
+
export type TextInspectorSelection = VisualEditorDocumentTarget & {
|
|
17
|
+
canEditText: boolean
|
|
18
|
+
fieldPath: string
|
|
19
|
+
multiline: boolean
|
|
20
|
+
tagName: string
|
|
21
|
+
text: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type TextStyleFormValues = {
|
|
25
|
+
fontFamily: TextFontFamily
|
|
26
|
+
lineHeight: TextLineHeight
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const getErrorMessage = async (response: Response) => {
|
|
30
|
+
try {
|
|
31
|
+
const body = (await response.json()) as { errors?: { message?: string }[]; message?: string }
|
|
32
|
+
return body.errors?.[0]?.message || body.message
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const getDocumentURL = (selection: TextInspectorSelection) =>
|
|
39
|
+
selection.kind === 'collection'
|
|
40
|
+
? `/api/${selection.collection}/${encodeURIComponent(selection.documentID)}?depth=0&draft=true`
|
|
41
|
+
: `/api/globals/${selection.global}?depth=0&draft=true`
|
|
42
|
+
|
|
43
|
+
export const saveTextStylePatch = async (
|
|
44
|
+
selection: TextInspectorSelection,
|
|
45
|
+
patch: TextStyleValue,
|
|
46
|
+
signal?: AbortSignal,
|
|
47
|
+
) => {
|
|
48
|
+
const currentResponse = await fetch(getDocumentURL(selection), {
|
|
49
|
+
credentials: 'include',
|
|
50
|
+
signal,
|
|
51
|
+
})
|
|
52
|
+
if (!currentResponse.ok) {
|
|
53
|
+
throw new Error((await getErrorMessage(currentResponse)) || 'Không thể đọc dữ liệu hiện tại.')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const currentDocument = (await currentResponse.json()) as { textStyles?: unknown }
|
|
57
|
+
const textStyles = normalizeTextStyleMap(currentDocument.textStyles)
|
|
58
|
+
const nextStyle = normalizeTextStyleValue({
|
|
59
|
+
...textStyles[selection.fieldPath],
|
|
60
|
+
...patch,
|
|
61
|
+
})
|
|
62
|
+
const persistedStyle: TextStyleValue = {}
|
|
63
|
+
|
|
64
|
+
if (nextStyle.fontFamily && nextStyle.fontFamily !== 'default') {
|
|
65
|
+
persistedStyle.fontFamily = nextStyle.fontFamily
|
|
66
|
+
}
|
|
67
|
+
if (nextStyle.lineHeight && nextStyle.lineHeight !== 'default') {
|
|
68
|
+
persistedStyle.lineHeight = nextStyle.lineHeight
|
|
69
|
+
}
|
|
70
|
+
if (nextStyle.width) {
|
|
71
|
+
persistedStyle.width = nextStyle.width
|
|
72
|
+
persistedStyle.widthOffset = nextStyle.widthOffset
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Object.keys(persistedStyle).length) {
|
|
76
|
+
textStyles[selection.fieldPath] = persistedStyle
|
|
77
|
+
} else {
|
|
78
|
+
delete textStyles[selection.fieldPath]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const response = await fetch(`${getDocumentURL(selection)}&autosave=true`, {
|
|
82
|
+
body: JSON.stringify({ _status: 'draft', textStyles }),
|
|
83
|
+
credentials: 'include',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
method: selection.kind === 'collection' ? 'PATCH' : 'POST',
|
|
86
|
+
signal,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
throw new Error((await getErrorMessage(response)) || 'Không thể lưu thuộc tính text.')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return persistedStyle
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const TextStyleInspector = ({
|
|
97
|
+
onApply,
|
|
98
|
+
onTextApply,
|
|
99
|
+
selection,
|
|
100
|
+
}: {
|
|
101
|
+
onApply: (values: TextStyleValue) => void
|
|
102
|
+
onTextApply: (value: string) => void
|
|
103
|
+
selection: TextInspectorSelection
|
|
104
|
+
}) => {
|
|
105
|
+
const initialValues: TextStyleFormValues = {
|
|
106
|
+
fontFamily: 'default',
|
|
107
|
+
lineHeight: 'default',
|
|
108
|
+
}
|
|
109
|
+
const [values, setValues] = useState<TextStyleFormValues>(initialValues)
|
|
110
|
+
const [status, setStatus] = useState<'error' | 'idle' | 'loading' | 'saving' | 'success'>(
|
|
111
|
+
'loading',
|
|
112
|
+
)
|
|
113
|
+
const [message, setMessage] = useState('Đang tải thuộc tính…')
|
|
114
|
+
const [text, setText] = useState(selection.text)
|
|
115
|
+
const saveController = useRef<AbortController | null>(null)
|
|
116
|
+
const saveSequence = useRef(0)
|
|
117
|
+
const valuesRef = useRef<TextStyleFormValues>(initialValues)
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const controller = new AbortController()
|
|
121
|
+
saveController.current?.abort()
|
|
122
|
+
saveSequence.current += 1
|
|
123
|
+
setText(selection.text)
|
|
124
|
+
setStatus('loading')
|
|
125
|
+
setMessage('Đang tải thuộc tính…')
|
|
126
|
+
|
|
127
|
+
void fetch(getDocumentURL(selection), {
|
|
128
|
+
credentials: 'include',
|
|
129
|
+
signal: controller.signal,
|
|
130
|
+
})
|
|
131
|
+
.then(async (response) => {
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
throw new Error((await getErrorMessage(response)) || 'Không thể tải thuộc tính text.')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const document = (await response.json()) as { textStyles?: unknown }
|
|
137
|
+
const savedStyle = normalizeTextStyleValue(
|
|
138
|
+
normalizeTextStyleMap(document.textStyles)[selection.fieldPath],
|
|
139
|
+
)
|
|
140
|
+
const nextValues: TextStyleFormValues = {
|
|
141
|
+
fontFamily: savedStyle.fontFamily || 'default',
|
|
142
|
+
lineHeight: savedStyle.lineHeight || 'default',
|
|
143
|
+
}
|
|
144
|
+
valuesRef.current = nextValues
|
|
145
|
+
setValues(nextValues)
|
|
146
|
+
setStatus('idle')
|
|
147
|
+
setMessage('')
|
|
148
|
+
})
|
|
149
|
+
.catch((error: unknown) => {
|
|
150
|
+
if (controller.signal.aborted) return
|
|
151
|
+
setStatus('error')
|
|
152
|
+
setMessage(error instanceof Error ? error.message : 'Không thể tải thuộc tính text.')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return () => {
|
|
156
|
+
controller.abort()
|
|
157
|
+
saveController.current?.abort()
|
|
158
|
+
}
|
|
159
|
+
}, [selection])
|
|
160
|
+
|
|
161
|
+
const save = async (nextValues: TextStyleFormValues) => {
|
|
162
|
+
saveController.current?.abort()
|
|
163
|
+
const controller = new AbortController()
|
|
164
|
+
const sequence = ++saveSequence.current
|
|
165
|
+
saveController.current = controller
|
|
166
|
+
setStatus('saving')
|
|
167
|
+
setMessage('Đang tự động lưu…')
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await saveTextStylePatch(
|
|
171
|
+
selection,
|
|
172
|
+
{
|
|
173
|
+
fontFamily: nextValues.fontFamily,
|
|
174
|
+
lineHeight: nextValues.lineHeight,
|
|
175
|
+
},
|
|
176
|
+
controller.signal,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if (sequence !== saveSequence.current) return
|
|
180
|
+
setStatus('success')
|
|
181
|
+
setMessage('Đã tự động lưu vào bản nháp. Bấm “Xuất bản thay đổi” để xuất bản.')
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (controller.signal.aborted || sequence !== saveSequence.current) return
|
|
184
|
+
setStatus('error')
|
|
185
|
+
setMessage(error instanceof Error ? error.message : 'Không thể tự động lưu thuộc tính text.')
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const applyAndSave = (nextValues: TextStyleFormValues) => {
|
|
190
|
+
valuesRef.current = nextValues
|
|
191
|
+
setValues(nextValues)
|
|
192
|
+
onApply(nextValues)
|
|
193
|
+
void save(nextValues)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const disabled = status === 'loading'
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<section aria-label="Thuộc tính text" className="wikex-global-inspector wikex-text-inspector">
|
|
200
|
+
<div className="wikex-global-inspector__eyebrow">Thuộc tính văn bản</div>
|
|
201
|
+
<h2>{selection.tagName.toUpperCase()}</h2>
|
|
202
|
+
<p className="wikex-global-inspector__path">{selection.fieldPath}</p>
|
|
203
|
+
|
|
204
|
+
<label className="wikex-global-inspector__field">
|
|
205
|
+
<span>Nội dung</span>
|
|
206
|
+
{selection.multiline ? (
|
|
207
|
+
<textarea
|
|
208
|
+
disabled={!selection.canEditText}
|
|
209
|
+
onChange={(event) => {
|
|
210
|
+
setText(event.target.value)
|
|
211
|
+
onTextApply(event.target.value)
|
|
212
|
+
}}
|
|
213
|
+
rows={3}
|
|
214
|
+
value={text}
|
|
215
|
+
/>
|
|
216
|
+
) : (
|
|
217
|
+
<input
|
|
218
|
+
disabled={!selection.canEditText}
|
|
219
|
+
onChange={(event) => {
|
|
220
|
+
setText(event.target.value)
|
|
221
|
+
onTextApply(event.target.value)
|
|
222
|
+
}}
|
|
223
|
+
type="text"
|
|
224
|
+
value={text}
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
{!selection.canEditText && (
|
|
228
|
+
<small className="wikex-text-inspector__hint">
|
|
229
|
+
Nội dung này nằm trong component tĩnh; có thể chỉnh kiểu chữ nhưng không thể sửa văn bản
|
|
230
|
+
từ CMS.
|
|
231
|
+
</small>
|
|
232
|
+
)}
|
|
233
|
+
</label>
|
|
234
|
+
|
|
235
|
+
<label className="wikex-global-inspector__field">
|
|
236
|
+
<span>Phông chữ</span>
|
|
237
|
+
<select
|
|
238
|
+
disabled={disabled}
|
|
239
|
+
onChange={(event) =>
|
|
240
|
+
applyAndSave({
|
|
241
|
+
...valuesRef.current,
|
|
242
|
+
fontFamily: event.target.value as TextFontFamily,
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
value={values.fontFamily}
|
|
246
|
+
>
|
|
247
|
+
{TEXT_FONT_OPTIONS.map((option) => (
|
|
248
|
+
<option key={option.value} value={option.value}>
|
|
249
|
+
{option.label}
|
|
250
|
+
</option>
|
|
251
|
+
))}
|
|
252
|
+
</select>
|
|
253
|
+
</label>
|
|
254
|
+
|
|
255
|
+
<label className="wikex-global-inspector__field">
|
|
256
|
+
<span>Chiều cao dòng</span>
|
|
257
|
+
<select
|
|
258
|
+
disabled={disabled}
|
|
259
|
+
onChange={(event) =>
|
|
260
|
+
applyAndSave({
|
|
261
|
+
...valuesRef.current,
|
|
262
|
+
lineHeight: event.target.value as TextLineHeight,
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
value={values.lineHeight}
|
|
266
|
+
>
|
|
267
|
+
{TEXT_LINE_HEIGHT_OPTIONS.map((option) => (
|
|
268
|
+
<option key={option.value} value={option.value}>
|
|
269
|
+
{option.label}
|
|
270
|
+
</option>
|
|
271
|
+
))}
|
|
272
|
+
</select>
|
|
273
|
+
</label>
|
|
274
|
+
|
|
275
|
+
<div className="wikex-global-inspector__actions">
|
|
276
|
+
<button
|
|
277
|
+
className="wikex-global-inspector__reset"
|
|
278
|
+
disabled={disabled}
|
|
279
|
+
onClick={() => applyAndSave({ fontFamily: 'default', lineHeight: 'default' })}
|
|
280
|
+
type="button"
|
|
281
|
+
>
|
|
282
|
+
Đặt lại mặc định
|
|
283
|
+
</button>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
{message && <p className={`wikex-global-inspector__status is-${status}`}>{message}</p>}
|
|
287
|
+
</section>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { default } from './LivePreviewEditor'
|
|
2
|
+
export {
|
|
3
|
+
GlobalInlineInspector,
|
|
4
|
+
readGlobal,
|
|
5
|
+
updateGlobalFields,
|
|
6
|
+
type EditableGlobalScope,
|
|
7
|
+
type GlobalInspectorSelection,
|
|
8
|
+
type InspectorValues,
|
|
9
|
+
} from './GlobalInlineInspector'
|
|
10
|
+
export {
|
|
11
|
+
saveTextStylePatch,
|
|
12
|
+
TextStyleInspector,
|
|
13
|
+
type TextInspectorSelection,
|
|
14
|
+
} from './TextStyleInspector'
|
|
15
|
+
export {
|
|
16
|
+
configureLivePreview,
|
|
17
|
+
type BlockPickerOption,
|
|
18
|
+
type BlockPickerScope,
|
|
19
|
+
type LivePreviewRuntimeConfig,
|
|
20
|
+
type VisualEditorDocumentTarget,
|
|
21
|
+
type VisualEditorRoute,
|
|
22
|
+
type VisualEditorScope,
|
|
23
|
+
} from './runtimeConfig'
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type BlockPickerScope = 'page' | 'post'
|
|
2
|
+
|
|
3
|
+
export type BlockPickerOption = {
|
|
4
|
+
description: string
|
|
5
|
+
image?: string
|
|
6
|
+
label: string
|
|
7
|
+
payloadLabel?: string
|
|
8
|
+
slug: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type VisualEditorDocumentTarget =
|
|
12
|
+
| {
|
|
13
|
+
collection: string
|
|
14
|
+
documentID: string
|
|
15
|
+
kind: 'collection'
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
global: string
|
|
19
|
+
kind: 'global'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type VisualEditorScope = 'content' | 'footer' | 'header'
|
|
23
|
+
|
|
24
|
+
export type VisualEditorRoute = {
|
|
25
|
+
documentTarget: VisualEditorDocumentTarget | null
|
|
26
|
+
scope: VisualEditorScope
|
|
27
|
+
type: 'collection' | 'global'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type LivePreviewRuntimeConfig = {
|
|
31
|
+
getVisualEditorRoute: (pathname: string) => VisualEditorRoute | null
|
|
32
|
+
pageBlocks: readonly BlockPickerOption[]
|
|
33
|
+
postBlocks: readonly BlockPickerOption[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let runtimeConfig: LivePreviewRuntimeConfig = {
|
|
37
|
+
getVisualEditorRoute: () => null,
|
|
38
|
+
pageBlocks: [],
|
|
39
|
+
postBlocks: [],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const configureLivePreview = (config: LivePreviewRuntimeConfig) => {
|
|
43
|
+
runtimeConfig = config
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const getVisualEditorRoute = (pathname: string) =>
|
|
47
|
+
runtimeConfig.getVisualEditorRoute(pathname)
|
|
48
|
+
|
|
49
|
+
export const isVisualEditorRoute = (pathname: string) => Boolean(getVisualEditorRoute(pathname))
|
|
50
|
+
|
|
51
|
+
export const getBlockPickerOptions = (scope: BlockPickerScope) =>
|
|
52
|
+
scope === 'post' ? runtimeConfig.postBlocks : runtimeConfig.pageBlocks
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Config, Plugin } from 'payload'
|
|
2
|
+
|
|
3
|
+
export type WikexAdminPluginOptions = {
|
|
4
|
+
components?: {
|
|
5
|
+
beforeDashboard?: false | string
|
|
6
|
+
provider?: false | string
|
|
7
|
+
}
|
|
8
|
+
enabled?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const appendUnique = <T>(current: T[] | undefined, additions: T[]) => [
|
|
12
|
+
...new Set([...(current ?? []), ...additions]),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reusable Payload admin shell. This plugin deliberately owns only generic admin
|
|
17
|
+
* behavior; project collections, globals and blocks are assembled separately by
|
|
18
|
+
* the project module registry.
|
|
19
|
+
*/
|
|
20
|
+
export const wikexAdminPlugin = (options: WikexAdminPluginOptions = {}): Plugin => {
|
|
21
|
+
return (config: Config): Config => {
|
|
22
|
+
if (options.enabled === false) return config
|
|
23
|
+
|
|
24
|
+
const admin = config.admin ?? {}
|
|
25
|
+
const components = admin.components ?? {}
|
|
26
|
+
const beforeDashboard = options.components?.beforeDashboard
|
|
27
|
+
const provider = options.components?.provider
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
...config,
|
|
31
|
+
admin: {
|
|
32
|
+
...admin,
|
|
33
|
+
components: {
|
|
34
|
+
...components,
|
|
35
|
+
Nav: components.Nav ?? '@wikex/admin-kit/admin/nav',
|
|
36
|
+
actions: appendUnique(components.actions, [
|
|
37
|
+
'@wikex/admin-kit/admin/theme-toggle',
|
|
38
|
+
'@wikex/admin-kit/admin/view-site',
|
|
39
|
+
]),
|
|
40
|
+
beforeDashboard:
|
|
41
|
+
typeof beforeDashboard !== 'string'
|
|
42
|
+
? components.beforeDashboard
|
|
43
|
+
: appendUnique(components.beforeDashboard, [beforeDashboard]),
|
|
44
|
+
beforeLogin: appendUnique(components.beforeLogin, [
|
|
45
|
+
'@wikex/admin-kit/admin/before-login',
|
|
46
|
+
]),
|
|
47
|
+
graphics: {
|
|
48
|
+
Icon: components.graphics?.Icon ?? '@wikex/admin-kit/admin/brand#AdminIcon',
|
|
49
|
+
Logo: components.graphics?.Logo ?? '@wikex/admin-kit/admin/brand#AdminLogo',
|
|
50
|
+
},
|
|
51
|
+
header: appendUnique(components.header, ['@wikex/admin-kit/admin/header']),
|
|
52
|
+
logout: {
|
|
53
|
+
Button: components.logout?.Button ?? '@wikex/admin-kit/admin/logout-button',
|
|
54
|
+
},
|
|
55
|
+
providers:
|
|
56
|
+
typeof provider !== 'string'
|
|
57
|
+
? components.providers
|
|
58
|
+
: appendUnique(components.providers, [provider]),
|
|
59
|
+
},
|
|
60
|
+
livePreview: admin.livePreview ?? {
|
|
61
|
+
breakpoints: [
|
|
62
|
+
{ height: 667, label: 'Điện thoại', name: 'mobile', width: 375 },
|
|
63
|
+
{ height: 1024, label: 'Máy tính bảng', name: 'tablet', width: 768 },
|
|
64
|
+
{ height: 900, label: 'Máy tính', name: 'desktop', width: 1440 },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { VisualEditorRoute, VisualEditorScope } from './live-preview/runtimeConfig'
|
|
2
|
+
|
|
3
|
+
export type WikexProjectDefinition<TModuleID extends string = string> = {
|
|
4
|
+
admin?: {
|
|
5
|
+
collectionOrder?: readonly string[]
|
|
6
|
+
globalOrder?: readonly string[]
|
|
7
|
+
}
|
|
8
|
+
brand: {
|
|
9
|
+
description: string
|
|
10
|
+
logo: {
|
|
11
|
+
height: number
|
|
12
|
+
url: string
|
|
13
|
+
width: number
|
|
14
|
+
}
|
|
15
|
+
name: string
|
|
16
|
+
}
|
|
17
|
+
homepage?: {
|
|
18
|
+
defaultMode: 'builder' | 'preset'
|
|
19
|
+
}
|
|
20
|
+
id: string
|
|
21
|
+
modules: readonly TModuleID[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const defineWikexProject = <
|
|
25
|
+
const TModuleID extends string,
|
|
26
|
+
const TProject extends WikexProjectDefinition<TModuleID>,
|
|
27
|
+
>(
|
|
28
|
+
project: TProject,
|
|
29
|
+
) => project
|
|
30
|
+
|
|
31
|
+
export type VisualEditorRouteRegistry = {
|
|
32
|
+
collections?: readonly string[]
|
|
33
|
+
globals?: Readonly<Record<string, VisualEditorScope>> | readonly string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const normalizeGlobalScopes = (globals: VisualEditorRouteRegistry['globals']) => {
|
|
37
|
+
if (!globals) return new Map<string, VisualEditorScope>()
|
|
38
|
+
if (Array.isArray(globals)) {
|
|
39
|
+
return new Map(globals.map((slug) => [slug, 'content' as const]))
|
|
40
|
+
}
|
|
41
|
+
return new Map(Object.entries(globals) as [string, VisualEditorScope][])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates the route adapter consumed by Live Preview without coupling the admin
|
|
46
|
+
* kit to collection names from a specific website.
|
|
47
|
+
*/
|
|
48
|
+
export const createVisualEditorRouteResolver = (registry: VisualEditorRouteRegistry) => {
|
|
49
|
+
const collections = new Set(registry.collections ?? [])
|
|
50
|
+
const globals = normalizeGlobalScopes(registry.globals)
|
|
51
|
+
|
|
52
|
+
return (pathname: string): VisualEditorRoute | null => {
|
|
53
|
+
const collectionMatch = pathname.match(/\/collections\/([^/?#]+)\/([^/?#]+)/)
|
|
54
|
+
|
|
55
|
+
if (collectionMatch && collections.has(collectionMatch[1])) {
|
|
56
|
+
const collection = collectionMatch[1]
|
|
57
|
+
const rawDocumentID = decodeURIComponent(collectionMatch[2])
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
documentTarget:
|
|
61
|
+
rawDocumentID === 'create'
|
|
62
|
+
? null
|
|
63
|
+
: { collection, documentID: rawDocumentID, kind: 'collection' },
|
|
64
|
+
scope: 'content',
|
|
65
|
+
type: 'collection',
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const globalMatch = pathname.match(/\/globals\/([^/?#]+)/)
|
|
70
|
+
const global = globalMatch?.[1]
|
|
71
|
+
|
|
72
|
+
if (global && globals.has(global)) {
|
|
73
|
+
return {
|
|
74
|
+
documentTarget: { global, kind: 'global' },
|
|
75
|
+
scope: globals.get(global) ?? 'content',
|
|
76
|
+
type: 'global',
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|