@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,288 @@
1
+ 'use client'
2
+
3
+ import { Link, useAuth, useConfig, useNav } from '@payloadcms/ui'
4
+ import {
5
+ BookOpenText,
6
+ BriefcaseBusiness,
7
+ ChevronLeft,
8
+ ChevronRight,
9
+ FileText,
10
+ FolderKanban,
11
+ GalleryVerticalEnd,
12
+ Home,
13
+ Image,
14
+ LayoutDashboard,
15
+ Library,
16
+ Menu,
17
+ PanelBottom,
18
+ PanelTop,
19
+ Settings2,
20
+ Tags,
21
+ Users,
22
+ X,
23
+ } from 'lucide-react'
24
+ import type { LucideIcon } from 'lucide-react'
25
+ import { usePathname } from 'next/navigation'
26
+ import { formatAdminURL } from 'payload/shared'
27
+ import React, { useEffect, useMemo, useState } from 'react'
28
+
29
+ import { AdminIcon, AdminLogo } from './Brand'
30
+ import { useAdminBrand } from './useAdminBrand'
31
+ import AdminLogoutButton from './LogoutButton'
32
+
33
+ export type AdminNavGroup = {
34
+ items: Array<{
35
+ label: string
36
+ slug: string
37
+ type: 'collections' | 'globals'
38
+ }>
39
+ label: string
40
+ }
41
+
42
+ type AdminNavClientProps = {
43
+ groups: AdminNavGroup[]
44
+ }
45
+
46
+ type AdminUser = {
47
+ email?: string
48
+ name?: string
49
+ }
50
+
51
+ const iconBySlug: Record<string, LucideIcon> = {
52
+ categories: Tags,
53
+ footer: PanelBottom,
54
+ header: PanelTop,
55
+ homepage: Home,
56
+ media: Image,
57
+ pages: FileText,
58
+ posts: BookOpenText,
59
+ projects: FolderKanban,
60
+ services: BriefcaseBusiness,
61
+ settings: Settings2,
62
+ users: Users,
63
+ }
64
+
65
+ const translateGroupLabel = (label: string) =>
66
+ label === 'Collections' ? 'Bộ sưu tập' : label === 'Globals' ? 'Thiết lập chung' : label
67
+
68
+ const getInitials = (name?: string, email?: string) => {
69
+ const source = name?.trim() || email?.split('@')[0] || 'AD'
70
+
71
+ return source
72
+ .split(/\s+/)
73
+ .slice(0, 2)
74
+ .map((part) => part.charAt(0).toUpperCase())
75
+ .join('')
76
+ }
77
+
78
+ export const AdminNavClient = ({ groups }: AdminNavClientProps) => {
79
+ const pathname = usePathname()
80
+ const brand = useAdminBrand()
81
+ const { user } = useAuth<AdminUser>()
82
+ const { hydrated, navOpen, navRef, setNavOpen, shouldAnimate } = useNav()
83
+ const {
84
+ config: {
85
+ admin: { routes: adminRoutes },
86
+ folders,
87
+ routes: { admin: adminRoute },
88
+ },
89
+ } = useConfig()
90
+ const [isMini, setIsMini] = useState(false)
91
+ const [isMobile, setIsMobile] = useState(false)
92
+
93
+ useEffect(() => {
94
+ const storedValue = window.localStorage.getItem('minimal-admin-nav-mini')
95
+ setIsMini(storedValue === 'true')
96
+ }, [])
97
+
98
+ useEffect(() => {
99
+ const mediaQuery = window.matchMedia('(max-width: 768px)')
100
+ const syncViewport = () => setIsMobile(mediaQuery.matches)
101
+
102
+ syncViewport()
103
+ mediaQuery.addEventListener('change', syncViewport)
104
+
105
+ return () => mediaQuery.removeEventListener('change', syncViewport)
106
+ }, [])
107
+
108
+ useEffect(() => {
109
+ document.documentElement.style.setProperty('--nav-width', isMini ? '88px' : '280px')
110
+ window.localStorage.setItem('minimal-admin-nav-mini', String(isMini))
111
+
112
+ return () => {
113
+ document.documentElement.style.removeProperty('--nav-width')
114
+ }
115
+ }, [isMini])
116
+
117
+ const userLabel = user?.name?.trim() || user?.email?.split('@')[0] || 'Quản trị viên'
118
+ const initials = useMemo(() => getInitials(user?.name, user?.email), [user?.email, user?.name])
119
+
120
+ const closeMobileNav = () => {
121
+ if (window.matchMedia('(max-width: 768px)').matches) setNavOpen(false)
122
+ }
123
+
124
+ const classes = [
125
+ 'minimal-nav',
126
+ navOpen && 'minimal-nav--open',
127
+ isMini && 'minimal-nav--mini',
128
+ shouldAnimate && 'minimal-nav--animate',
129
+ hydrated && 'minimal-nav--hydrated',
130
+ ]
131
+ .filter(Boolean)
132
+ .join(' ')
133
+
134
+ const isActive = (href: string) =>
135
+ pathname === href || (href !== adminRoute && pathname.startsWith(`${href}/`))
136
+
137
+ return (
138
+ <aside
139
+ aria-label="Điều hướng quản trị"
140
+ className={classes}
141
+ inert={(isMobile && !navOpen) || undefined}
142
+ >
143
+ <div className="minimal-nav__scroll" ref={navRef}>
144
+ <div className="minimal-nav__brand-row">
145
+ <Link
146
+ aria-label={`Về dashboard ${brand.siteName}`}
147
+ className="minimal-nav__brand"
148
+ href={adminRoute}
149
+ onClick={closeMobileNav}
150
+ prefetch={false}
151
+ >
152
+ <span className="minimal-nav__brand-full">
153
+ <AdminLogo />
154
+ </span>
155
+ <span className="minimal-nav__brand-icon">
156
+ <AdminIcon />
157
+ </span>
158
+ </Link>
159
+ <button
160
+ aria-label="Đóng menu"
161
+ className="minimal-nav__mobile-close"
162
+ onClick={() => setNavOpen(false)}
163
+ type="button"
164
+ >
165
+ <X aria-hidden="true" />
166
+ </button>
167
+ </div>
168
+
169
+ <Link
170
+ className="minimal-nav__workspace"
171
+ href={adminRoute}
172
+ onClick={closeMobileNav}
173
+ prefetch={false}
174
+ title={brand.siteName}
175
+ >
176
+ <span className="minimal-nav__workspace-icon">
177
+ <GalleryVerticalEnd aria-hidden="true" />
178
+ </span>
179
+ <span className="minimal-nav__workspace-copy">
180
+ <small>Không gian làm việc</small>
181
+ <strong>{brand.siteName}</strong>
182
+ </span>
183
+ <Menu aria-hidden="true" className="minimal-nav__workspace-menu" />
184
+ </Link>
185
+
186
+ <nav className="minimal-nav__menu">
187
+ <div className="minimal-nav__group">
188
+ <span className="minimal-nav__group-label">Tổng quan</span>
189
+ <Link
190
+ aria-current={pathname === adminRoute ? 'page' : undefined}
191
+ className={`minimal-nav__item ${pathname === adminRoute ? 'minimal-nav__item--active' : ''}`}
192
+ href={adminRoute}
193
+ onClick={closeMobileNav}
194
+ prefetch={false}
195
+ title="Dashboard"
196
+ >
197
+ <LayoutDashboard aria-hidden="true" />
198
+ <span>Tổng quan</span>
199
+ </Link>
200
+ </div>
201
+
202
+ {groups.map((group) => (
203
+ <div className="minimal-nav__group" key={group.label}>
204
+ <span className="minimal-nav__group-label">{translateGroupLabel(group.label)}</span>
205
+ {group.items.map((item) => {
206
+ const href = formatAdminURL({
207
+ adminRoute,
208
+ path: `/${item.type}/${item.slug}`,
209
+ })
210
+ const Icon = iconBySlug[item.slug] || FileText
211
+ const active = isActive(href)
212
+
213
+ return (
214
+ <Link
215
+ aria-current={active ? 'page' : undefined}
216
+ className={`minimal-nav__item ${active ? 'minimal-nav__item--active' : ''}`}
217
+ href={href}
218
+ key={`${item.type}-${item.slug}`}
219
+ onClick={closeMobileNav}
220
+ prefetch={false}
221
+ title={item.label}
222
+ >
223
+ <Icon aria-hidden="true" />
224
+ <span>{item.label}</span>
225
+ </Link>
226
+ )
227
+ })}
228
+ </div>
229
+ ))}
230
+
231
+ {folders && folders.browseByFolder && (
232
+ <div className="minimal-nav__group">
233
+ <span className="minimal-nav__group-label">Thư viện</span>
234
+ <Link
235
+ aria-current={
236
+ isActive(formatAdminURL({ adminRoute, path: adminRoutes.browseByFolder }))
237
+ ? 'page'
238
+ : undefined
239
+ }
240
+ className={`minimal-nav__item ${
241
+ isActive(formatAdminURL({ adminRoute, path: adminRoutes.browseByFolder }))
242
+ ? 'minimal-nav__item--active'
243
+ : ''
244
+ }`}
245
+ href={formatAdminURL({ adminRoute, path: adminRoutes.browseByFolder })}
246
+ onClick={closeMobileNav}
247
+ prefetch={false}
248
+ title="Duyệt theo thư mục"
249
+ >
250
+ <Library aria-hidden="true" />
251
+ <span>Duyệt theo thư mục</span>
252
+ </Link>
253
+ </div>
254
+ )}
255
+ </nav>
256
+
257
+ <div className="minimal-nav__footer">
258
+ <Link
259
+ className="minimal-nav__account"
260
+ href={formatAdminURL({ adminRoute, path: adminRoutes.account })}
261
+ onClick={closeMobileNav}
262
+ prefetch={false}
263
+ title={userLabel}
264
+ >
265
+ <span className="minimal-nav__avatar">{initials}</span>
266
+ <span className="minimal-nav__account-copy">
267
+ <strong>{userLabel}</strong>
268
+ <small>{user?.email || 'Tài khoản quản trị'}</small>
269
+ </span>
270
+ </Link>
271
+ <div className="minimal-nav__logout">
272
+ <AdminLogoutButton />
273
+ </div>
274
+ </div>
275
+ </div>
276
+
277
+ <button
278
+ aria-label={isMini ? 'Mở rộng menu' : 'Thu gọn menu'}
279
+ className="minimal-nav__collapse"
280
+ onClick={() => setIsMini((current) => !current)}
281
+ title={isMini ? 'Mở rộng menu' : 'Thu gọn menu'}
282
+ type="button"
283
+ >
284
+ {isMini ? <ChevronRight aria-hidden="true" /> : <ChevronLeft aria-hidden="true" />}
285
+ </button>
286
+ </aside>
287
+ )
288
+ }
@@ -0,0 +1,31 @@
1
+ 'use client'
2
+
3
+ import { useTheme } from '@payloadcms/ui'
4
+ import { Moon, Sun } from 'lucide-react'
5
+ import React from 'react'
6
+
7
+ const AdminThemeToggle: React.FC = () => {
8
+ const { setTheme, theme } = useTheme()
9
+ const isDark = theme === 'dark'
10
+ const label = isDark ? 'Chuyển sang giao diện sáng' : 'Chuyển sang giao diện tối'
11
+
12
+ return (
13
+ <button
14
+ aria-checked={isDark}
15
+ aria-label={label}
16
+ className="admin-theme-toggle"
17
+ onClick={() => setTheme(isDark ? 'light' : 'dark')}
18
+ role="switch"
19
+ title={label}
20
+ type="button"
21
+ >
22
+ <span aria-hidden="true" className="admin-theme-toggle__track">
23
+ <span className="admin-theme-toggle__thumb">
24
+ {isDark ? <Moon strokeWidth={2.2} /> : <Sun strokeWidth={2.2} />}
25
+ </span>
26
+ </span>
27
+ </button>
28
+ )
29
+ }
30
+
31
+ export default AdminThemeToggle
@@ -0,0 +1,11 @@
1
+ import { ArrowUpRight } from 'lucide-react'
2
+ import React from 'react'
3
+
4
+ const AdminViewSite: React.FC = () => (
5
+ <a className="admin-view-site" data-slot="button" href="/" rel="noreferrer" target="_blank">
6
+ Xem website
7
+ <ArrowUpRight aria-hidden="true" />
8
+ </a>
9
+ )
10
+
11
+ export default AdminViewSite
@@ -0,0 +1,81 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ import type { AdminBrandAsset } from './Brand'
6
+
7
+ export type AdminBrandSettings = {
8
+ logo: AdminBrandAsset
9
+ siteName: string
10
+ }
11
+
12
+ type SettingsResponse = {
13
+ logo?:
14
+ | {
15
+ alt?: null | string
16
+ height?: null | number
17
+ updatedAt?: null | string
18
+ url?: null | string
19
+ width?: null | number
20
+ }
21
+ | number
22
+ | null
23
+ siteName?: null | string
24
+ }
25
+
26
+ let defaultBrand: AdminBrandSettings = {
27
+ logo: { alt: 'Wikex', height: 64, url: '/favicon.svg', width: 64 },
28
+ siteName: 'Wikex',
29
+ }
30
+
31
+ export const configureAdminBrand = (brand: AdminBrandSettings) => {
32
+ defaultBrand = brand
33
+ }
34
+
35
+ const normalizeBrand = (settings?: SettingsResponse | null): AdminBrandSettings => {
36
+ const logo = settings?.logo
37
+ const resolvedLogo =
38
+ logo && typeof logo !== 'number' && logo.url
39
+ ? {
40
+ alt: logo.alt || settings?.siteName || defaultBrand.siteName,
41
+ height: logo.height || 64,
42
+ url: logo.updatedAt ? `${logo.url}?${encodeURIComponent(logo.updatedAt)}` : logo.url,
43
+ width: logo.width || 240,
44
+ }
45
+ : defaultBrand.logo
46
+
47
+ return {
48
+ logo: resolvedLogo,
49
+ siteName: settings?.siteName?.trim() || defaultBrand.siteName,
50
+ }
51
+ }
52
+
53
+ export const useAdminBrand = (): AdminBrandSettings => {
54
+ const [brand, setBrand] = useState<AdminBrandSettings>(() => normalizeBrand())
55
+
56
+ useEffect(() => {
57
+ const controller = new AbortController()
58
+
59
+ const loadBrand = async () => {
60
+ try {
61
+ const response = await fetch('/api/globals/settings?depth=1', {
62
+ cache: 'no-store',
63
+ signal: controller.signal,
64
+ })
65
+
66
+ if (!response.ok) return
67
+ setBrand(normalizeBrand((await response.json()) as SettingsResponse))
68
+ } catch (error) {
69
+ if (!(error instanceof DOMException && error.name === 'AbortError')) {
70
+ console.error('Unable to load admin branding', error)
71
+ }
72
+ }
73
+ }
74
+
75
+ void loadBrand()
76
+
77
+ return () => controller.abort()
78
+ }, [])
79
+
80
+ return brand
81
+ }
@@ -0,0 +1,117 @@
1
+ import type { Config, Field, Plugin } from 'payload'
2
+
3
+ export const IMAGE_UPLOAD_GUIDANCE =
4
+ 'Yêu cầu để ảnh hiển thị rõ: hero/banner nên từ 1920 × 1080 px; ảnh nội dung nên rộng tối thiểu 1200 px. Ưu tiên WebP hoặc JPEG, dung lượng dưới 500 KB.'
5
+
6
+ const labelTranslations: Record<string, string> = {
7
+ 'Archive Block': 'Khối lưu trữ',
8
+ Content: 'Nội dung',
9
+ Description: 'Mô tả',
10
+ Hero: 'Mở đầu',
11
+ 'Hero Image': 'Ảnh đại diện',
12
+ Image: 'Hình ảnh',
13
+ Layout: 'Bố cục',
14
+ Link: 'Liên kết',
15
+ Media: 'Hình ảnh',
16
+ Meta: 'Thông tin bổ sung',
17
+ Title: 'Tiêu đề',
18
+ }
19
+
20
+ const fieldNameLabels: Record<string, string> = {
21
+ alt: 'Văn bản thay thế',
22
+ authors: 'Tác giả',
23
+ caption: 'Chú thích',
24
+ categories: 'Chuyên mục',
25
+ content: 'Nội dung',
26
+ coverImage: 'Ảnh bìa',
27
+ defaultOgImage: 'Ảnh chia sẻ mặc định',
28
+ description: 'Mô tả',
29
+ excerpt: 'Tóm tắt',
30
+ featuredImage: 'Ảnh nổi bật',
31
+ heroImage: 'Ảnh đại diện',
32
+ image: 'Hình ảnh',
33
+ logo: 'Logo',
34
+ media: 'Hình ảnh',
35
+ appearance: 'Kiểu hiển thị',
36
+ links: 'Liên kết',
37
+ newTab: 'Mở trong tab mới',
38
+ publishedAt: 'Ngày xuất bản',
39
+ relatedPosts: 'Bài viết liên quan',
40
+ slug: 'Đường dẫn',
41
+ summary: 'Mô tả ngắn',
42
+ title: 'Tiêu đề',
43
+ type: 'Loại',
44
+ }
45
+
46
+ const entityLabels: Record<string, { plural: string; singular: string }> = {
47
+ 'form-submissions': { plural: 'Dữ liệu biểu mẫu', singular: 'Dữ liệu biểu mẫu' },
48
+ forms: { plural: 'Biểu mẫu', singular: 'Biểu mẫu' },
49
+ redirects: { plural: 'Chuyển hướng', singular: 'Chuyển hướng' },
50
+ search: { plural: 'Kết quả tìm kiếm', singular: 'Kết quả tìm kiếm' },
51
+ }
52
+
53
+ const translateLabel = <T>(label: T): T =>
54
+ (typeof label === 'string' ? labelTranslations[label] || label : label) as T
55
+
56
+ const enhanceFields = (fields: Field[]) => {
57
+ for (const field of fields) {
58
+ if ('name' in field) {
59
+ field.label =
60
+ field.label === undefined ? fieldNameLabels[field.name] : translateLabel(field.label)
61
+ } else if ('label' in field) {
62
+ field.label = translateLabel(field.label)
63
+ }
64
+
65
+ if (field.type === 'upload' && field.relationTo === 'media') {
66
+ const currentDescription = field.admin?.description
67
+ field.admin = {
68
+ ...field.admin,
69
+ description:
70
+ typeof currentDescription === 'string' &&
71
+ !currentDescription.includes(IMAGE_UPLOAD_GUIDANCE)
72
+ ? `${currentDescription} ${IMAGE_UPLOAD_GUIDANCE}`
73
+ : IMAGE_UPLOAD_GUIDANCE,
74
+ }
75
+ }
76
+
77
+ if (field.type === 'tabs') {
78
+ for (const tab of field.tabs) {
79
+ tab.label = translateLabel(tab.label)
80
+ enhanceFields(tab.fields)
81
+ }
82
+ } else if ('fields' in field && Array.isArray(field.fields)) {
83
+ enhanceFields(field.fields)
84
+ }
85
+
86
+ if (field.type === 'blocks') {
87
+ for (const block of field.blocks ?? []) {
88
+ if (block.labels) {
89
+ block.labels = {
90
+ plural: translateLabel(block.labels.plural),
91
+ singular: translateLabel(block.labels.singular),
92
+ }
93
+ }
94
+ enhanceFields(block.fields)
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ export const adminExperiencePlugin: Plugin = (config: Config): Config => {
101
+ for (const collection of config.collections ?? []) {
102
+ const translatedLabels = entityLabels[collection.slug]
103
+ if (translatedLabels) collection.labels = translatedLabels
104
+ enhanceFields(collection.fields)
105
+
106
+ if (collection.slug === 'media') {
107
+ collection.admin = {
108
+ ...collection.admin,
109
+ description: IMAGE_UPLOAD_GUIDANCE,
110
+ }
111
+ }
112
+ }
113
+
114
+ for (const global of config.globals ?? []) enhanceFields(global.fields)
115
+
116
+ return config
117
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { adminExperiencePlugin, IMAGE_UPLOAD_GUIDANCE } from './adminExperience'
2
+ export { wikexAdminPlugin, type WikexAdminPluginOptions } from './plugin'
3
+ export {
4
+ createVisualEditorRouteResolver,
5
+ defineWikexProject,
6
+ type VisualEditorRouteRegistry,
7
+ type WikexProjectDefinition,
8
+ } from './project'
9
+ export { RichTextTypographyFeature } from './rich-text'
10
+ export { TextStyleRuntime } from './TextStyleRuntime'
11
+ export * from './textStyles'