@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Các thay đổi đáng chú ý của `@wikex/admin-kit` được ghi tại đây theo Semantic Versioning.
|
|
4
|
+
|
|
5
|
+
## 0.2.0 - 2026-09-12
|
|
6
|
+
|
|
7
|
+
- Thêm `createWikexStarter`: một factory cấu hình content schema, SEO, redirect, admin UI,
|
|
8
|
+
branding, route Live Preview và module registry từ một cấu hình dự án.
|
|
9
|
+
- Chuyển cấu hình phát hành sang npm public; dự án consumer không cần `.npmrc` hay token.
|
|
10
|
+
|
|
11
|
+
## 0.1.0 - 2026-09-12
|
|
12
|
+
|
|
13
|
+
- Tách Payload admin shell dùng chung khỏi dự án Estate.
|
|
14
|
+
- Thêm Live Preview editor, drawer thuộc tính và modal chọn block dùng chung.
|
|
15
|
+
- Thêm typography editor với font family, line-height và resize trực tiếp.
|
|
16
|
+
- Thêm Minimal UI skin, chuẩn hóa tiếng Việt và hướng dẫn kích thước ảnh.
|
|
17
|
+
- Thêm adapter branding, block catalog và route registry theo từng dự án.
|
package/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# @wikex/admin-kit
|
|
2
|
+
|
|
3
|
+
Admin shell dùng chung cho các website Payload CMS của Wikex.
|
|
4
|
+
|
|
5
|
+
Package sở hữu:
|
|
6
|
+
|
|
7
|
+
- navigation, header, đăng nhập, đổi theme và branding động;
|
|
8
|
+
- Minimal UI skin và các bản vá layout Payload;
|
|
9
|
+
- Live Preview editor, drawer thuộc tính, block picker và resize text;
|
|
10
|
+
- font family, line-height và runtime áp dụng typography;
|
|
11
|
+
- chuẩn hóa tiếng Việt và hướng dẫn kích thước upload ảnh.
|
|
12
|
+
|
|
13
|
+
Schema, block catalog, route registry, dashboard và seed data vẫn thuộc ứng dụng sử dụng package vì chúng thay đổi theo từng dự án.
|
|
14
|
+
|
|
15
|
+
`@wikex/admin-kit` phụ thuộc `@wikex/content-kit`, nên chỉ cần cài Admin Kit là có luôn
|
|
16
|
+
schema Page/Post/Media, SEO, redirects, robots và sitemap chuẩn. Schema, block catalog và
|
|
17
|
+
frontend renderer cụ thể vẫn thuộc dự án consumer.
|
|
18
|
+
|
|
19
|
+
## Dùng trong workspace
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@wikex/admin-kit": "workspace:*"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// next.config.ts
|
|
31
|
+
const nextConfig = {
|
|
32
|
+
transpilePackages: ['@wikex/admin-kit'],
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```scss
|
|
37
|
+
// Payload custom.scss
|
|
38
|
+
@use '@wikex/admin-kit/styles';
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// Payload plugins
|
|
43
|
+
import { adminExperiencePlugin, wikexAdminPlugin } from '@wikex/admin-kit'
|
|
44
|
+
|
|
45
|
+
export const plugins = [
|
|
46
|
+
adminExperiencePlugin,
|
|
47
|
+
wikexAdminPlugin({
|
|
48
|
+
components: {
|
|
49
|
+
beforeDashboard: '@/components/BeforeDashboard',
|
|
50
|
+
provider: '@/components/LivePreviewResizer',
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Live Preview provider của dự án là adapter khai báo route và component khả dụng:
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
'use client'
|
|
60
|
+
|
|
61
|
+
import { configureAdminBrand } from '@wikex/admin-kit/admin/brand'
|
|
62
|
+
import LivePreviewEditor, { configureLivePreview } from '@wikex/admin-kit/live-preview'
|
|
63
|
+
|
|
64
|
+
configureLivePreview({
|
|
65
|
+
getVisualEditorRoute,
|
|
66
|
+
pageBlocks,
|
|
67
|
+
postBlocks,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
configureAdminBrand({
|
|
71
|
+
logo: { alt: projectName, height: 64, url: '/logo.svg', width: 64 },
|
|
72
|
+
siteName: projectName,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
export default LivePreviewEditor
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Project config và route resolver cũng dùng API chung, nhưng tên module/collection vẫn do
|
|
79
|
+
từng website quyết định:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { createVisualEditorRouteResolver, defineWikexProject } from '@wikex/admin-kit/project'
|
|
83
|
+
|
|
84
|
+
export const project = defineWikexProject({
|
|
85
|
+
brand: {
|
|
86
|
+
description: 'Website doanh nghiệp',
|
|
87
|
+
logo: { height: 64, url: '/logo.svg', width: 64 },
|
|
88
|
+
name: 'Wikex Corporate',
|
|
89
|
+
},
|
|
90
|
+
id: 'wikex-corporate',
|
|
91
|
+
modules: ['website', 'services'],
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
export const getVisualEditorRoute = createVisualEditorRouteResolver({
|
|
95
|
+
collections: ['pages'],
|
|
96
|
+
globals: { footer: 'footer', header: 'header', homepage: 'content' },
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Xem consumer không thuộc lĩnh vực bất động sản tại `examples/corporate-demo`.
|
|
101
|
+
|
|
102
|
+
Ứng dụng cần cung cấp các file font tại `public/fonts/google`, hoặc ghi đè các biến `--font-*` nếu dùng font riêng.
|
|
103
|
+
|
|
104
|
+
## Cài vào dự án khác — một package, một cấu hình
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pnpm add @wikex/admin-kit@0.2.0
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Không cần `.npmrc`, GitLab token hay npm token ở dự án consumer. Các peer dependency
|
|
111
|
+
Payload/Next/React phải đã có trong dự án.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// src/wikex.ts — server-only config, dùng một lần trong Payload config
|
|
115
|
+
import { createWikexStarter } from '@wikex/admin-kit/starter'
|
|
116
|
+
import { pageBlocks, postBlocks } from './blocks'
|
|
117
|
+
|
|
118
|
+
export const wikex = createWikexStarter({
|
|
119
|
+
brand: {
|
|
120
|
+
description: 'Website công ty Acme',
|
|
121
|
+
logo: { height: 64, url: '/logo.svg', width: 64 },
|
|
122
|
+
name: 'Acme',
|
|
123
|
+
},
|
|
124
|
+
content: {
|
|
125
|
+
page: { blocks: pageBlocks, previewURL: '/' },
|
|
126
|
+
post: { blocks: postBlocks, previewURL: '/news' },
|
|
127
|
+
seo: {
|
|
128
|
+
siteName: 'Acme',
|
|
129
|
+
pathFor: ({ collection, slug }) =>
|
|
130
|
+
collection === 'pages' ? (slug === 'home' ? '/' : `/${slug}`) : `/news/${slug}`,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
globals: { footer: 'footer', header: 'header' },
|
|
134
|
+
id: 'acme',
|
|
135
|
+
provider: '@/LivePreviewAdapter',
|
|
136
|
+
})
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
// payload.config.ts
|
|
141
|
+
import { wikex } from './src/wikex'
|
|
142
|
+
|
|
143
|
+
export default buildConfig({
|
|
144
|
+
collections: [Users, ...wikex.collections, ...projectCollections],
|
|
145
|
+
globals: projectGlobals,
|
|
146
|
+
plugins: wikex.plugins,
|
|
147
|
+
})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Giữ `LivePreviewAdapter.tsx` nhỏ ở client vì block renderer của mỗi website là riêng.
|
|
151
|
+
Adapter gọi `createWikexLivePreviewAdapter` từ `@wikex/admin-kit/starter/client` với
|
|
152
|
+
branding, block picker và route resolver tương ứng; xem implementation hoàn chỉnh tại
|
|
153
|
+
`examples/corporate-demo`.
|
|
154
|
+
|
|
155
|
+
## Phát hành npm public
|
|
156
|
+
|
|
157
|
+
Khi phát hành:
|
|
158
|
+
|
|
159
|
+
1. tăng version theo SemVer và cập nhật `CHANGELOG.md`;
|
|
160
|
+
2. chạy test/typecheck/build trong starter;
|
|
161
|
+
3. tạo tag `admin-kit-v<version>` để GitLab CI publish npm;
|
|
162
|
+
4. ở từng dự án consumer đổi `workspace:*` thành version cố định, ví dụ `0.2.0`;
|
|
163
|
+
5. nâng version có chủ đích, không dùng `latest` trong production.
|
|
164
|
+
|
|
165
|
+
GitLab CI cần một CI/CD variable bảo mật `NPM_TOKEN` có quyền publish scope `@wikex`.
|
|
166
|
+
Đây là credential duy nhất của phía phát hành; người dùng package công khai không cần
|
|
167
|
+
credential nào.
|
package/RELEASING.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Phát hành Admin Kit
|
|
2
|
+
|
|
3
|
+
Package không tự publish khi merge. Chỉ người có quyền registry mới thực hiện phát hành.
|
|
4
|
+
|
|
5
|
+
## Kiểm tra trước khi phát hành
|
|
6
|
+
|
|
7
|
+
Từ thư mục gốc workspace:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm install --frozen-lockfile
|
|
11
|
+
pnpm generate:types
|
|
12
|
+
pnpm exec tsc --noEmit
|
|
13
|
+
pnpm exec tsc --noEmit -p examples/corporate-demo/tsconfig.json
|
|
14
|
+
pnpm lint
|
|
15
|
+
pnpm exec vitest run --config ./vitest.config.mts --exclude tests/int/api.int.spec.ts
|
|
16
|
+
pnpm build
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Kiểm tra nội dung package:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cd packages/admin-kit
|
|
23
|
+
npm pack --dry-run
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Version
|
|
27
|
+
|
|
28
|
+
- `patch`: sửa lỗi, không đổi API.
|
|
29
|
+
- `minor`: thêm tính năng tương thích ngược.
|
|
30
|
+
- `major`: thay đổi adapter/API bắt buộc dự án consumer phải sửa.
|
|
31
|
+
|
|
32
|
+
Cập nhật `version` trong `package.json` và ghi thay đổi vào `CHANGELOG.md` trước khi publish.
|
|
33
|
+
|
|
34
|
+
## npm public
|
|
35
|
+
|
|
36
|
+
Pipeline `.gitlab-ci.yml` publish vào `https://registry.npmjs.org/`. Thêm một GitLab
|
|
37
|
+
CI/CD Variable tên `NPM_TOKEN`, masked và protected, chứa npm granular access token có
|
|
38
|
+
quyền publish scope `@wikex` (và bypass 2FA nếu npm account bật 2FA cho publish).
|
|
39
|
+
|
|
40
|
+
Token chỉ tồn tại ở CI; consumer cài package công khai không cần `.npmrc` hay token.
|
|
41
|
+
|
|
42
|
+
Lần đầu kết nối source repository, chạy từ thư mục gốc:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bash scripts/connect-gitlab.sh
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Script không ghi đè một remote `origin` khác nếu đã tồn tại.
|
|
49
|
+
|
|
50
|
+
Tạo tag trùng với version package để phát hành:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git tag admin-kit-v0.2.0
|
|
54
|
+
git push origin admin-kit-v0.2.0
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Job phát hành kiểm tra tag phải đúng mẫu `admin-kit-v<version>` trước khi upload package.
|
|
58
|
+
Vì Admin Kit phụ thuộc Content Kit, release `content-kit-v<version>` trước, rồi mới tạo
|
|
59
|
+
tag Admin Kit dùng version Content Kit đó.
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wikex/admin-kit",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Reusable Payload CMS admin shell, visual editor and typography tools for Wikex projects",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": [
|
|
9
|
+
"**/*.scss"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"README.md",
|
|
15
|
+
"RELEASING.md"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public",
|
|
19
|
+
"registry": "https://registry.npmjs.org/"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@wikex/content-kit": "^0.1.0"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./src/index.ts",
|
|
26
|
+
"./admin/before-login": "./src/admin/BeforeLogin.tsx",
|
|
27
|
+
"./admin/brand": "./src/admin/Brand.tsx",
|
|
28
|
+
"./admin/header": "./src/admin/Header.tsx",
|
|
29
|
+
"./admin/logout-button": "./src/admin/LogoutButton.tsx",
|
|
30
|
+
"./admin/nav": "./src/admin/Nav.tsx",
|
|
31
|
+
"./admin/theme-toggle": "./src/admin/ThemeToggle.tsx",
|
|
32
|
+
"./admin/view-site": "./src/admin/ViewSite.tsx",
|
|
33
|
+
"./live-preview": "./src/live-preview/index.ts",
|
|
34
|
+
"./project": "./src/project.ts",
|
|
35
|
+
"./rich-text": "./src/rich-text/index.ts",
|
|
36
|
+
"./rich-text/client": "./src/rich-text/client.tsx",
|
|
37
|
+
"./starter": "./src/starter.ts",
|
|
38
|
+
"./starter/client": "./src/starter/client.tsx",
|
|
39
|
+
"./styles": "./src/styles/admin.scss",
|
|
40
|
+
"./text-style-runtime": "./src/TextStyleRuntime.tsx",
|
|
41
|
+
"./text-styles": "./src/textStyles.ts"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@lexical/react": ">=0.41.0",
|
|
45
|
+
"@lexical/selection": ">=0.41.0",
|
|
46
|
+
"@payloadcms/richtext-lexical": ">=3.88.0",
|
|
47
|
+
"@payloadcms/ui": ">=3.88.0",
|
|
48
|
+
"lexical": ">=0.41.0",
|
|
49
|
+
"lucide-react": ">=0.563.0",
|
|
50
|
+
"next": ">=16.3.3",
|
|
51
|
+
"payload": ">=3.88.0",
|
|
52
|
+
"react": ">=19.0.0",
|
|
53
|
+
"react-dom": ">=19.0.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
EDITABLE_PREVIEW_SELECTOR,
|
|
7
|
+
getEditableElementStyleKey,
|
|
8
|
+
getTextStyleCSS,
|
|
9
|
+
normalizeTextStyleMap,
|
|
10
|
+
} from './textStyles'
|
|
11
|
+
|
|
12
|
+
type OriginalStyle = {
|
|
13
|
+
boxSizing: string
|
|
14
|
+
fontFamily: string
|
|
15
|
+
lineHeight: string
|
|
16
|
+
marginLeft: string
|
|
17
|
+
marginRight: string
|
|
18
|
+
maxWidth: string
|
|
19
|
+
width: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const TextStyleRuntime = ({ styles }: { styles?: unknown }) => {
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const normalizedStyles = normalizeTextStyleMap(styles)
|
|
25
|
+
const originals = new Map<HTMLElement, OriginalStyle>()
|
|
26
|
+
let applyFrame = 0
|
|
27
|
+
|
|
28
|
+
const applyStyles = () => {
|
|
29
|
+
const fields = new Set(
|
|
30
|
+
document.querySelectorAll<HTMLElement>(`[data-wikex-field],${EDITABLE_PREVIEW_SELECTOR}`),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
for (const element of fields) {
|
|
34
|
+
const fieldPath = element.dataset.wikexField || getEditableElementStyleKey(element)
|
|
35
|
+
if (!fieldPath || !normalizedStyles[fieldPath]) continue
|
|
36
|
+
|
|
37
|
+
if (!originals.has(element)) {
|
|
38
|
+
originals.set(element, {
|
|
39
|
+
boxSizing: element.style.boxSizing,
|
|
40
|
+
fontFamily: element.style.fontFamily,
|
|
41
|
+
lineHeight: element.style.lineHeight,
|
|
42
|
+
marginLeft: element.style.marginLeft,
|
|
43
|
+
marginRight: element.style.marginRight,
|
|
44
|
+
maxWidth: element.style.maxWidth,
|
|
45
|
+
width: element.style.width,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const style = getTextStyleCSS(normalizedStyles[fieldPath])
|
|
50
|
+
element.style.boxSizing = style.boxSizing || ''
|
|
51
|
+
element.style.fontFamily = style.fontFamily || ''
|
|
52
|
+
element.style.lineHeight = style.lineHeight || ''
|
|
53
|
+
element.style.marginLeft = style.marginLeft || ''
|
|
54
|
+
element.style.marginRight = style.marginRight || ''
|
|
55
|
+
element.style.maxWidth = style.maxWidth || ''
|
|
56
|
+
element.style.width = style.width || ''
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const scheduleApplyStyles = () => {
|
|
61
|
+
if (applyFrame) return
|
|
62
|
+
|
|
63
|
+
applyFrame = window.requestAnimationFrame(() => {
|
|
64
|
+
applyFrame = 0
|
|
65
|
+
applyStyles()
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
applyStyles()
|
|
70
|
+
const observer = new MutationObserver(scheduleApplyStyles)
|
|
71
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
72
|
+
|
|
73
|
+
return () => {
|
|
74
|
+
observer.disconnect()
|
|
75
|
+
window.cancelAnimationFrame(applyFrame)
|
|
76
|
+
for (const [element, original] of originals) {
|
|
77
|
+
if (!element.isConnected) continue
|
|
78
|
+
element.style.boxSizing = original.boxSizing
|
|
79
|
+
element.style.fontFamily = original.fontFamily
|
|
80
|
+
element.style.lineHeight = original.lineHeight
|
|
81
|
+
element.style.marginLeft = original.marginLeft
|
|
82
|
+
element.style.marginRight = original.marginRight
|
|
83
|
+
element.style.maxWidth = original.maxWidth
|
|
84
|
+
element.style.width = original.width
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}, [styles])
|
|
88
|
+
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
import { useAdminBrand } from './useAdminBrand'
|
|
6
|
+
|
|
7
|
+
const BeforeLogin: React.FC = () => {
|
|
8
|
+
const { siteName } = useAdminBrand()
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="admin-login-intro">
|
|
12
|
+
<span className="admin-login-intro__eyebrow">{siteName} · Trung tâm nội dung</span>
|
|
13
|
+
<h1>Chào mừng trở lại.</h1>
|
|
14
|
+
<p>Đăng nhập để quản lý nội dung, hình ảnh và xuất bản của {siteName}.</p>
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default BeforeLogin
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Image from 'next/image'
|
|
4
|
+
import React from 'react'
|
|
5
|
+
|
|
6
|
+
import { useAdminBrand } from './useAdminBrand'
|
|
7
|
+
|
|
8
|
+
export type AdminBrandAsset = {
|
|
9
|
+
alt: string
|
|
10
|
+
height: number
|
|
11
|
+
url: string
|
|
12
|
+
width: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BrandLogo = ({
|
|
16
|
+
asset,
|
|
17
|
+
className,
|
|
18
|
+
height,
|
|
19
|
+
maxWidth,
|
|
20
|
+
}: {
|
|
21
|
+
asset: AdminBrandAsset
|
|
22
|
+
className?: string
|
|
23
|
+
height: number
|
|
24
|
+
maxWidth: number
|
|
25
|
+
siteName: string
|
|
26
|
+
}) => (
|
|
27
|
+
<span
|
|
28
|
+
className={['brand-logo', className].filter(Boolean).join(' ')}
|
|
29
|
+
style={{ alignItems: 'center', display: 'inline-flex', height, maxWidth }}
|
|
30
|
+
>
|
|
31
|
+
<Image
|
|
32
|
+
alt=""
|
|
33
|
+
height={asset.height}
|
|
34
|
+
src={asset.url}
|
|
35
|
+
style={{ height: '100%', maxWidth: '100%', objectFit: 'contain', width: 'auto' }}
|
|
36
|
+
unoptimized
|
|
37
|
+
width={asset.width}
|
|
38
|
+
/>
|
|
39
|
+
</span>
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
export const AdminIcon: React.FC = () => {
|
|
43
|
+
const brand = useAdminBrand()
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<span className="aurea-brand aurea-brand--icon">
|
|
47
|
+
<BrandLogo
|
|
48
|
+
asset={brand.logo}
|
|
49
|
+
className="aurea-brand__image"
|
|
50
|
+
height={32}
|
|
51
|
+
maxWidth={32}
|
|
52
|
+
siteName={brand.siteName}
|
|
53
|
+
/>
|
|
54
|
+
</span>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const AdminLogo: React.FC = () => {
|
|
59
|
+
const brand = useAdminBrand()
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<span aria-label={brand.siteName} className="aurea-brand aurea-brand--logo" role="img">
|
|
63
|
+
<BrandLogo
|
|
64
|
+
asset={brand.logo}
|
|
65
|
+
className="aurea-brand__image"
|
|
66
|
+
height={42}
|
|
67
|
+
maxWidth={190}
|
|
68
|
+
siteName={brand.siteName}
|
|
69
|
+
/>
|
|
70
|
+
</span>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { configureAdminBrand, useAdminBrand, type AdminBrandSettings } from './useAdminBrand'
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Link, useAuth, useConfig, useNav } from '@payloadcms/ui'
|
|
4
|
+
import { Menu, Settings2 } from 'lucide-react'
|
|
5
|
+
import { formatAdminURL } from 'payload/shared'
|
|
6
|
+
import React from 'react'
|
|
7
|
+
|
|
8
|
+
import { AdminIcon } from './Brand'
|
|
9
|
+
import AdminThemeToggle from './ThemeToggle'
|
|
10
|
+
import AdminViewSite from './ViewSite'
|
|
11
|
+
|
|
12
|
+
type AdminUser = {
|
|
13
|
+
email?: string
|
|
14
|
+
name?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const getInitials = (name?: string, email?: string) => {
|
|
18
|
+
const source = name?.trim() || email?.split('@')[0] || 'AD'
|
|
19
|
+
|
|
20
|
+
return source
|
|
21
|
+
.split(/\s+/)
|
|
22
|
+
.slice(0, 2)
|
|
23
|
+
.map((part) => part.charAt(0).toUpperCase())
|
|
24
|
+
.join('')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const AdminHeader = () => {
|
|
28
|
+
const { user } = useAuth<AdminUser>()
|
|
29
|
+
const { setNavOpen } = useNav()
|
|
30
|
+
const {
|
|
31
|
+
config: {
|
|
32
|
+
admin: { routes: adminRoutes },
|
|
33
|
+
routes: { admin: adminRoute },
|
|
34
|
+
},
|
|
35
|
+
} = useConfig()
|
|
36
|
+
|
|
37
|
+
const userLabel = user?.name?.trim() || user?.email?.split('@')[0] || 'Quản trị viên'
|
|
38
|
+
const initials = getInitials(user?.name, user?.email)
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<header className="minimal-admin-header">
|
|
42
|
+
<div className="minimal-admin-header__left">
|
|
43
|
+
<button
|
|
44
|
+
aria-label="Mở menu quản trị"
|
|
45
|
+
className="minimal-admin-header__menu"
|
|
46
|
+
onClick={() => setNavOpen(true)}
|
|
47
|
+
type="button"
|
|
48
|
+
>
|
|
49
|
+
<Menu aria-hidden="true" />
|
|
50
|
+
</button>
|
|
51
|
+
<Link
|
|
52
|
+
aria-label="Về dashboard"
|
|
53
|
+
className="minimal-admin-header__mobile-brand"
|
|
54
|
+
href={adminRoute}
|
|
55
|
+
prefetch={false}
|
|
56
|
+
>
|
|
57
|
+
<AdminIcon />
|
|
58
|
+
</Link>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div className="minimal-admin-header__actions">
|
|
62
|
+
<AdminViewSite />
|
|
63
|
+
<AdminThemeToggle />
|
|
64
|
+
<Link
|
|
65
|
+
aria-label={`Mở tài khoản ${userLabel}`}
|
|
66
|
+
className="minimal-admin-header__settings"
|
|
67
|
+
href={formatAdminURL({ adminRoute, path: adminRoutes.account })}
|
|
68
|
+
prefetch={false}
|
|
69
|
+
title="Cài đặt tài khoản"
|
|
70
|
+
>
|
|
71
|
+
<Settings2 aria-hidden="true" />
|
|
72
|
+
</Link>
|
|
73
|
+
<Link
|
|
74
|
+
aria-label={`Mở tài khoản ${userLabel}`}
|
|
75
|
+
className="minimal-admin-header__account"
|
|
76
|
+
href={formatAdminURL({ adminRoute, path: adminRoutes.account })}
|
|
77
|
+
prefetch={false}
|
|
78
|
+
title={userLabel}
|
|
79
|
+
>
|
|
80
|
+
<span>{initials}</span>
|
|
81
|
+
</Link>
|
|
82
|
+
</div>
|
|
83
|
+
</header>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default AdminHeader
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Link, useConfig, useTranslation } from '@payloadcms/ui'
|
|
4
|
+
import { LogOut } from 'lucide-react'
|
|
5
|
+
import { formatAdminURL } from 'payload/shared'
|
|
6
|
+
import React from 'react'
|
|
7
|
+
|
|
8
|
+
type AdminLogoutButtonProps = {
|
|
9
|
+
tabIndex?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const AdminLogoutButton: React.FC<AdminLogoutButtonProps> = ({ tabIndex = 0 }) => {
|
|
13
|
+
const {
|
|
14
|
+
config: {
|
|
15
|
+
admin: {
|
|
16
|
+
routes: { logout: logoutRoute },
|
|
17
|
+
},
|
|
18
|
+
routes: { admin: adminRoute },
|
|
19
|
+
},
|
|
20
|
+
} = useConfig()
|
|
21
|
+
const { t } = useTranslation()
|
|
22
|
+
const label = t('authentication:logOut')
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Link
|
|
26
|
+
aria-label={label}
|
|
27
|
+
className="admin-sign-out"
|
|
28
|
+
href={formatAdminURL({ adminRoute, path: logoutRoute })}
|
|
29
|
+
prefetch={false}
|
|
30
|
+
tabIndex={tabIndex}
|
|
31
|
+
title={label}
|
|
32
|
+
>
|
|
33
|
+
<span aria-hidden="true" className="admin-sign-out__icon">
|
|
34
|
+
<LogOut strokeWidth={2} />
|
|
35
|
+
</span>
|
|
36
|
+
<span className="admin-sign-out__label">{label}</span>
|
|
37
|
+
</Link>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default AdminLogoutButton
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getNavGroups } from '@payloadcms/ui/shared'
|
|
2
|
+
import type { ServerProps, StaticLabel } from 'payload'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
import { AdminNavClient, type AdminNavGroup } from './NavClient'
|
|
6
|
+
|
|
7
|
+
const localizeLabel = (label: StaticLabel, language: string): string => {
|
|
8
|
+
if (typeof label === 'string') return label
|
|
9
|
+
|
|
10
|
+
return label[language] || Object.values(label)[0] || ''
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const AdminNav = ({ i18n, payload, permissions, visibleEntities }: ServerProps) => {
|
|
14
|
+
if (!permissions || !visibleEntities) return null
|
|
15
|
+
|
|
16
|
+
const groups: AdminNavGroup[] = getNavGroups(
|
|
17
|
+
permissions,
|
|
18
|
+
visibleEntities,
|
|
19
|
+
payload.config,
|
|
20
|
+
i18n,
|
|
21
|
+
).map((group) => ({
|
|
22
|
+
items: group.entities.map((entity) => ({
|
|
23
|
+
label: localizeLabel(entity.label, i18n.language),
|
|
24
|
+
slug: entity.slug,
|
|
25
|
+
type: entity.type,
|
|
26
|
+
})),
|
|
27
|
+
label: group.label,
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
return <AdminNavClient groups={groups} />
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default AdminNav
|
|
34
|
+
|
|
35
|
+
export { AdminNavClient, type AdminNavGroup } from './NavClient'
|