@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.
@@ -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
+ }