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