@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,334 @@
1
+ 'use client'
2
+
3
+ import React, { useEffect, useMemo, useState } from 'react'
4
+
5
+ export type EditableGlobalScope = 'footer' | 'header' | 'settings'
6
+
7
+ export type GlobalInspectorSelection = {
8
+ fieldPath: string
9
+ href?: string
10
+ scope: EditableGlobalScope
11
+ supportsLink: boolean
12
+ text: string
13
+ }
14
+
15
+ type GlobalFieldUpdate = {
16
+ path: string
17
+ value: unknown
18
+ }
19
+
20
+ export type InspectorValues = {
21
+ backgroundColor: string
22
+ text: string
23
+ textColor: string
24
+ url: string
25
+ }
26
+
27
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
28
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value)
29
+
30
+ const readValueAtPath = (source: unknown, fieldPath: string) => {
31
+ return fieldPath.split('.').reduce<unknown>((current, segment) => {
32
+ if (Array.isArray(current)) return current[Number(segment)]
33
+ if (isRecord(current)) return current[segment]
34
+ return undefined
35
+ }, source)
36
+ }
37
+
38
+ const setValueAtPath = (source: unknown, segments: string[], value: unknown): unknown => {
39
+ if (!segments.length) return value
40
+
41
+ const [segment, ...remainingSegments] = segments
42
+ const arrayIndex = Number(segment)
43
+
44
+ if (Number.isInteger(arrayIndex) && String(arrayIndex) === segment) {
45
+ const nextArray = Array.isArray(source) ? [...source] : []
46
+ nextArray[arrayIndex] = setValueAtPath(nextArray[arrayIndex], remainingSegments, value)
47
+ return nextArray
48
+ }
49
+
50
+ const nextRecord = isRecord(source) ? { ...source } : {}
51
+ nextRecord[segment] = setValueAtPath(nextRecord[segment], remainingSegments, value)
52
+ return nextRecord
53
+ }
54
+
55
+ const getErrorMessage = async (response: Response) => {
56
+ try {
57
+ const body = (await response.json()) as { errors?: { message?: string }[]; message?: string }
58
+ return body.errors?.[0]?.message || body.message
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+
64
+ export const readGlobal = async (scope: EditableGlobalScope, signal?: AbortSignal) => {
65
+ const response = await fetch(`/api/globals/${scope}?depth=0&draft=true`, {
66
+ credentials: 'include',
67
+ signal,
68
+ })
69
+
70
+ if (!response.ok) {
71
+ throw new Error((await getErrorMessage(response)) || `Không thể đọc ${scope}.`)
72
+ }
73
+
74
+ return (await response.json()) as Record<string, unknown>
75
+ }
76
+
77
+ export const updateGlobalFields = async (
78
+ scope: EditableGlobalScope,
79
+ updates: GlobalFieldUpdate[],
80
+ ) => {
81
+ const currentGlobal = await readGlobal(scope)
82
+ const patch: Record<string, unknown> = { _status: 'draft' }
83
+
84
+ for (const { path, value } of updates) {
85
+ const [rootField, ...remainingSegments] = path.split('.')
86
+ const currentRoot = Object.hasOwn(patch, rootField)
87
+ ? patch[rootField]
88
+ : currentGlobal[rootField]
89
+ patch[rootField] = setValueAtPath(currentRoot, remainingSegments, value)
90
+ }
91
+
92
+ const response = await fetch(`/api/globals/${scope}?depth=0&draft=true&autosave=true`, {
93
+ body: JSON.stringify(patch),
94
+ credentials: 'include',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ method: 'POST',
97
+ })
98
+
99
+ if (!response.ok) {
100
+ throw new Error((await getErrorMessage(response)) || `Không thể cập nhật ${scope}.`)
101
+ }
102
+ }
103
+
104
+ const getLinkBasePath = (fieldPath: string) => {
105
+ if (fieldPath.endsWith('.label')) return fieldPath.slice(0, -'.label'.length)
106
+ if (fieldPath === 'announcement.text') return 'announcement'
107
+ return undefined
108
+ }
109
+
110
+ const normalizeInspectorColor = (value: unknown) =>
111
+ typeof value === 'string' && /^#[0-9A-F]{6}$/i.test(value) ? value.toUpperCase() : ''
112
+
113
+ export const GlobalInlineInspector = ({
114
+ onApply,
115
+ selection,
116
+ }: {
117
+ onApply: (values: InspectorValues) => void
118
+ selection: GlobalInspectorSelection
119
+ }) => {
120
+ const linkBasePath = useMemo(() => getLinkBasePath(selection.fieldPath), [selection.fieldPath])
121
+ const [values, setValues] = useState<InspectorValues>({
122
+ backgroundColor: '',
123
+ text: selection.text,
124
+ textColor: '',
125
+ url: selection.href || '',
126
+ })
127
+ const [status, setStatus] = useState<'error' | 'idle' | 'loading' | 'saving' | 'success'>(
128
+ 'loading',
129
+ )
130
+ const [linkContext, setLinkContext] = useState({ initialURL: selection.href || '', type: '' })
131
+ const [message, setMessage] = useState('Đang tải thuộc tính…')
132
+
133
+ useEffect(() => {
134
+ const controller = new AbortController()
135
+ setStatus('loading')
136
+ setMessage('Đang tải thuộc tính…')
137
+
138
+ void readGlobal(selection.scope, controller.signal)
139
+ .then((global) => {
140
+ const storedURL = linkBasePath
141
+ ? String(readValueAtPath(global, `${linkBasePath}.url`) ?? selection.href ?? '')
142
+ : ''
143
+
144
+ setValues({
145
+ backgroundColor: linkBasePath
146
+ ? normalizeInspectorColor(readValueAtPath(global, `${linkBasePath}.backgroundColor`))
147
+ : '',
148
+ text:
149
+ String(readValueAtPath(global, selection.fieldPath) ?? selection.text) ||
150
+ selection.text,
151
+ textColor: linkBasePath
152
+ ? normalizeInspectorColor(readValueAtPath(global, `${linkBasePath}.textColor`))
153
+ : '',
154
+ url: storedURL,
155
+ })
156
+ setLinkContext({
157
+ initialURL: storedURL,
158
+ type: linkBasePath ? String(readValueAtPath(global, `${linkBasePath}.type`) ?? '') : '',
159
+ })
160
+ setStatus('idle')
161
+ setMessage('')
162
+ })
163
+ .catch((error: unknown) => {
164
+ if (controller.signal.aborted) return
165
+ setStatus('error')
166
+ setMessage(error instanceof Error ? error.message : 'Không thể tải thuộc tính.')
167
+ })
168
+
169
+ return () => controller.abort()
170
+ }, [linkBasePath, selection])
171
+
172
+ const save = async () => {
173
+ const textColor = normalizeInspectorColor(values.textColor)
174
+ const backgroundColor = normalizeInspectorColor(values.backgroundColor)
175
+
176
+ if ((values.textColor && !textColor) || (values.backgroundColor && !backgroundColor)) {
177
+ setStatus('error')
178
+ setMessage('Màu tùy chỉnh cần dùng mã HEX đầy đủ, ví dụ #172019.')
179
+ return
180
+ }
181
+
182
+ setStatus('saving')
183
+ setMessage('Đang lưu…')
184
+
185
+ const savedValues = { ...values, backgroundColor, textColor }
186
+
187
+ const updates: GlobalFieldUpdate[] = [{ path: selection.fieldPath, value: values.text }]
188
+
189
+ if (selection.supportsLink && linkBasePath) {
190
+ updates.push(
191
+ { path: `${linkBasePath}.textColor`, value: textColor || null },
192
+ { path: `${linkBasePath}.backgroundColor`, value: backgroundColor || null },
193
+ )
194
+
195
+ if (!linkBasePath.includes('.link') || linkContext.type === 'custom') {
196
+ updates.push({ path: `${linkBasePath}.url`, value: values.url })
197
+ } else if (values.url !== linkContext.initialURL) {
198
+ updates.push(
199
+ { path: `${linkBasePath}.type`, value: 'custom' },
200
+ { path: `${linkBasePath}.url`, value: values.url },
201
+ )
202
+ }
203
+ }
204
+
205
+ try {
206
+ await updateGlobalFields(selection.scope, updates)
207
+ setValues(savedValues)
208
+ onApply(savedValues)
209
+ setStatus('success')
210
+ setMessage('Đã lưu vào nội dung dùng chung.')
211
+ } catch (error) {
212
+ setStatus('error')
213
+ setMessage(error instanceof Error ? error.message : 'Không thể lưu thay đổi.')
214
+ }
215
+ }
216
+
217
+ const disabled = status === 'loading' || status === 'saving'
218
+ const scopeLabel =
219
+ selection.scope === 'header'
220
+ ? 'Đầu trang'
221
+ : selection.scope === 'footer'
222
+ ? 'Chân trang'
223
+ : 'Thông tin website'
224
+
225
+ return (
226
+ <section aria-label={`Thuộc tính ${scopeLabel}`} className="wikex-global-inspector">
227
+ <div className="wikex-global-inspector__eyebrow">Nội dung dùng chung</div>
228
+ <h2>{scopeLabel}</h2>
229
+ <p className="wikex-global-inspector__path">{selection.fieldPath}</p>
230
+
231
+ <label className="wikex-global-inspector__field">
232
+ <span>Nội dung</span>
233
+ <textarea
234
+ disabled={disabled}
235
+ onChange={(event) => setValues((current) => ({ ...current, text: event.target.value }))}
236
+ rows={3}
237
+ value={values.text}
238
+ />
239
+ </label>
240
+
241
+ {selection.supportsLink && linkBasePath && (
242
+ <>
243
+ <label className="wikex-global-inspector__field">
244
+ <span>Đường dẫn</span>
245
+ <input
246
+ disabled={disabled}
247
+ onChange={(event) =>
248
+ setValues((current) => ({ ...current, url: event.target.value }))
249
+ }
250
+ placeholder="/contact hoặc https://…"
251
+ type="text"
252
+ value={values.url}
253
+ />
254
+ </label>
255
+
256
+ <div className="wikex-global-inspector__colors">
257
+ <label className="wikex-global-inspector__field">
258
+ <span>Màu chữ</span>
259
+ <div className="wikex-global-inspector__color-control">
260
+ <input
261
+ aria-label="Chọn màu chữ"
262
+ disabled={disabled}
263
+ onChange={(event) =>
264
+ setValues((current) => ({ ...current, textColor: event.target.value }))
265
+ }
266
+ type="color"
267
+ value={normalizeInspectorColor(values.textColor) || '#FFFFFF'}
268
+ />
269
+ <input
270
+ disabled={disabled}
271
+ maxLength={7}
272
+ onChange={(event) =>
273
+ setValues((current) => ({ ...current, textColor: event.target.value }))
274
+ }
275
+ placeholder="Mặc định"
276
+ type="text"
277
+ value={values.textColor}
278
+ />
279
+ </div>
280
+ </label>
281
+ <label className="wikex-global-inspector__field">
282
+ <span>Màu nền</span>
283
+ <div className="wikex-global-inspector__color-control">
284
+ <input
285
+ aria-label="Chọn màu nền"
286
+ disabled={disabled}
287
+ onChange={(event) =>
288
+ setValues((current) => ({ ...current, backgroundColor: event.target.value }))
289
+ }
290
+ type="color"
291
+ value={normalizeInspectorColor(values.backgroundColor) || '#172019'}
292
+ />
293
+ <input
294
+ disabled={disabled}
295
+ maxLength={7}
296
+ onChange={(event) =>
297
+ setValues((current) => ({ ...current, backgroundColor: event.target.value }))
298
+ }
299
+ placeholder="Mặc định"
300
+ type="text"
301
+ value={values.backgroundColor}
302
+ />
303
+ </div>
304
+ </label>
305
+ </div>
306
+ </>
307
+ )}
308
+
309
+ <div className="wikex-global-inspector__actions">
310
+ <button
311
+ disabled={disabled || !values.text.trim()}
312
+ onClick={() => void save()}
313
+ type="button"
314
+ >
315
+ {status === 'saving' ? 'Đang lưu…' : 'Lưu thay đổi'}
316
+ </button>
317
+ {selection.supportsLink && linkBasePath && (
318
+ <button
319
+ className="wikex-global-inspector__reset"
320
+ disabled={disabled}
321
+ onClick={() =>
322
+ setValues((current) => ({ ...current, backgroundColor: '', textColor: '' }))
323
+ }
324
+ type="button"
325
+ >
326
+ Dùng màu mặc định
327
+ </button>
328
+ )}
329
+ </div>
330
+
331
+ {message && <p className={`wikex-global-inspector__status is-${status}`}>{message}</p>}
332
+ </section>
333
+ )
334
+ }