@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,202 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
|
4
|
+
import { $forEachSelectedTextNode } from '@lexical/selection'
|
|
5
|
+
import type { ToolbarGroupItem } from '@payloadcms/richtext-lexical'
|
|
6
|
+
import { createClientFeature } from '@payloadcms/richtext-lexical/client'
|
|
7
|
+
import {
|
|
8
|
+
$getNodeByKey,
|
|
9
|
+
$getSelection,
|
|
10
|
+
$getState,
|
|
11
|
+
$isRangeSelection,
|
|
12
|
+
$isTextNode,
|
|
13
|
+
$setSelection,
|
|
14
|
+
$setState,
|
|
15
|
+
createState,
|
|
16
|
+
type LexicalEditor,
|
|
17
|
+
type RangeSelection,
|
|
18
|
+
TextNode,
|
|
19
|
+
} from 'lexical'
|
|
20
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
getTextStyleCSS,
|
|
24
|
+
normalizeTextStyleValue,
|
|
25
|
+
TEXT_FONT_OPTIONS,
|
|
26
|
+
TEXT_LINE_HEIGHT_OPTIONS,
|
|
27
|
+
type TextFontFamily,
|
|
28
|
+
type TextLineHeight,
|
|
29
|
+
} from '../textStyles'
|
|
30
|
+
|
|
31
|
+
const fontFamilyState = createState('fontFamily', {
|
|
32
|
+
parse: (value) => normalizeTextStyleValue({ fontFamily: value }).fontFamily,
|
|
33
|
+
})
|
|
34
|
+
const lineHeightState = createState('lineHeight', {
|
|
35
|
+
parse: (value) => normalizeTextStyleValue({ lineHeight: value }).lineHeight,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
type TypographyValue = {
|
|
39
|
+
fontFamily: TextFontFamily
|
|
40
|
+
lineHeight: TextLineHeight
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_TYPOGRAPHY: TypographyValue = {
|
|
44
|
+
fontFamily: 'default',
|
|
45
|
+
lineHeight: 'default',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const readSelectedTypography = (editor: LexicalEditor): TypographyValue =>
|
|
49
|
+
editor.getEditorState().read(() => {
|
|
50
|
+
const selection = $getSelection()
|
|
51
|
+
if (!$isRangeSelection(selection)) return DEFAULT_TYPOGRAPHY
|
|
52
|
+
|
|
53
|
+
const textNode = selection.getNodes().find($isTextNode)
|
|
54
|
+
if (!textNode) return DEFAULT_TYPOGRAPHY
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
fontFamily: $getState(textNode, fontFamilyState) ?? 'default',
|
|
58
|
+
lineHeight: $getState(textNode, lineHeightState) ?? 'default',
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const updateSelectedTypography = (
|
|
63
|
+
editor: LexicalEditor,
|
|
64
|
+
property: keyof TypographyValue,
|
|
65
|
+
value: TextFontFamily | TextLineHeight,
|
|
66
|
+
savedSelection: null | RangeSelection,
|
|
67
|
+
) => {
|
|
68
|
+
editor.update(() => {
|
|
69
|
+
if (!$isRangeSelection($getSelection()) && savedSelection) {
|
|
70
|
+
$setSelection(savedSelection.clone())
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
$forEachSelectedTextNode((textNode) => {
|
|
74
|
+
if (property === 'fontFamily') {
|
|
75
|
+
const fontFamily = value as TextFontFamily
|
|
76
|
+
$setState(textNode, fontFamilyState, fontFamily === 'default' ? undefined : fontFamily)
|
|
77
|
+
} else {
|
|
78
|
+
const lineHeight = value as TextLineHeight
|
|
79
|
+
$setState(textNode, lineHeightState, lineHeight === 'default' ? undefined : lineHeight)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const TypographyToolbarItem = ({ editor }: { editor: LexicalEditor }) => {
|
|
86
|
+
const [value, setValue] = useState<TypographyValue>(DEFAULT_TYPOGRAPHY)
|
|
87
|
+
const savedSelection = useRef<null | RangeSelection>(null)
|
|
88
|
+
|
|
89
|
+
const captureSelection = useCallback(() => {
|
|
90
|
+
editor.getEditorState().read(() => {
|
|
91
|
+
const selection = $getSelection()
|
|
92
|
+
if ($isRangeSelection(selection)) savedSelection.current = selection.clone()
|
|
93
|
+
})
|
|
94
|
+
}, [editor])
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const syncValue = () => {
|
|
98
|
+
captureSelection()
|
|
99
|
+
setValue(readSelectedTypography(editor))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
syncValue()
|
|
103
|
+
return editor.registerUpdateListener(syncValue)
|
|
104
|
+
}, [captureSelection, editor])
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
aria-label="Kiểu chữ"
|
|
109
|
+
className="wikex-typography-selects"
|
|
110
|
+
onMouseDown={(event) => {
|
|
111
|
+
event.stopPropagation()
|
|
112
|
+
captureSelection()
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
<select
|
|
116
|
+
aria-label="Phông chữ"
|
|
117
|
+
className="wikex-minimal-select wikex-minimal-select--font"
|
|
118
|
+
onChange={(event) => {
|
|
119
|
+
const fontFamily = event.target.value as TextFontFamily
|
|
120
|
+
setValue((current) => ({ ...current, fontFamily }))
|
|
121
|
+
updateSelectedTypography(editor, 'fontFamily', fontFamily, savedSelection.current)
|
|
122
|
+
}}
|
|
123
|
+
title="Phông chữ"
|
|
124
|
+
value={value.fontFamily}
|
|
125
|
+
>
|
|
126
|
+
{TEXT_FONT_OPTIONS.map((option) => (
|
|
127
|
+
<option key={option.value} value={option.value}>
|
|
128
|
+
{option.label}
|
|
129
|
+
</option>
|
|
130
|
+
))}
|
|
131
|
+
</select>
|
|
132
|
+
<select
|
|
133
|
+
aria-label="Chiều cao dòng"
|
|
134
|
+
className="wikex-minimal-select wikex-minimal-select--line-height"
|
|
135
|
+
onChange={(event) => {
|
|
136
|
+
const lineHeight = event.target.value as TextLineHeight
|
|
137
|
+
setValue((current) => ({ ...current, lineHeight }))
|
|
138
|
+
updateSelectedTypography(editor, 'lineHeight', lineHeight, savedSelection.current)
|
|
139
|
+
}}
|
|
140
|
+
title="Chiều cao dòng"
|
|
141
|
+
value={value.lineHeight}
|
|
142
|
+
>
|
|
143
|
+
{TEXT_LINE_HEIGHT_OPTIONS.map((option) => (
|
|
144
|
+
<option key={option.value} value={option.value}>
|
|
145
|
+
{option.label}
|
|
146
|
+
</option>
|
|
147
|
+
))}
|
|
148
|
+
</select>
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const TypographyStatePlugin = () => {
|
|
154
|
+
const [editor] = useLexicalComposerContext()
|
|
155
|
+
|
|
156
|
+
useEffect(
|
|
157
|
+
() =>
|
|
158
|
+
editor.registerMutationListener(TextNode, (mutatedNodes) => {
|
|
159
|
+
editor.getEditorState().read(() => {
|
|
160
|
+
for (const [nodeKey, mutation] of mutatedNodes) {
|
|
161
|
+
if (mutation === 'destroyed') continue
|
|
162
|
+
|
|
163
|
+
const node = $getNodeByKey(nodeKey)
|
|
164
|
+
const element = editor.getElementByKey(nodeKey)
|
|
165
|
+
if (!$isTextNode(node) || !element) continue
|
|
166
|
+
|
|
167
|
+
const style = getTextStyleCSS({
|
|
168
|
+
fontFamily: $getState(node, fontFamilyState),
|
|
169
|
+
lineHeight: $getState(node, lineHeightState),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
if (style.fontFamily) element.style.fontFamily = style.fontFamily
|
|
173
|
+
else element.style.removeProperty('font-family')
|
|
174
|
+
|
|
175
|
+
if (style.lineHeight) element.style.lineHeight = style.lineHeight
|
|
176
|
+
else element.style.removeProperty('line-height')
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
}),
|
|
180
|
+
[editor],
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const toolbarItem: ToolbarGroupItem = {
|
|
187
|
+
Component: TypographyToolbarItem,
|
|
188
|
+
key: 'wikex-typography-controls',
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const typographyToolbarGroup = {
|
|
192
|
+
items: [toolbarItem],
|
|
193
|
+
key: 'wikexTypography',
|
|
194
|
+
order: 34,
|
|
195
|
+
type: 'buttons' as const,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const RichTextTypographyFeatureClient = createClientFeature({
|
|
199
|
+
plugins: [{ Component: TypographyStatePlugin, position: 'normal' }],
|
|
200
|
+
toolbarFixed: { groups: [typographyToolbarGroup] },
|
|
201
|
+
toolbarInline: { groups: [typographyToolbarGroup] },
|
|
202
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createServerFeature } from '@payloadcms/richtext-lexical'
|
|
2
|
+
|
|
3
|
+
const TypographyFeature = createServerFeature({
|
|
4
|
+
feature: {
|
|
5
|
+
ClientFeature: '@wikex/admin-kit/rich-text/client#RichTextTypographyFeatureClient',
|
|
6
|
+
},
|
|
7
|
+
key: 'wikexTypography',
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export const RichTextTypographyFeature = () => TypographyFeature()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { configureAdminBrand } from '../admin/Brand'
|
|
4
|
+
import LivePreviewEditor, { configureLivePreview } from '../live-preview'
|
|
5
|
+
import type { WikexStarter } from '../starter'
|
|
6
|
+
|
|
7
|
+
type LivePreviewConfig = WikexStarter['livePreview'] & {
|
|
8
|
+
getVisualEditorRoute: WikexStarter['getVisualEditorRoute']
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Configure and return the shared client-side Live Preview provider for a Wikex starter. */
|
|
12
|
+
export const createWikexLivePreviewAdapter = ({
|
|
13
|
+
brand,
|
|
14
|
+
getVisualEditorRoute,
|
|
15
|
+
pageBlocks,
|
|
16
|
+
postBlocks,
|
|
17
|
+
}: LivePreviewConfig) => {
|
|
18
|
+
configureAdminBrand({ logo: { alt: brand.name, ...brand.logo }, siteName: brand.name })
|
|
19
|
+
configureLivePreview({ getVisualEditorRoute, pageBlocks, postBlocks })
|
|
20
|
+
|
|
21
|
+
return LivePreviewEditor
|
|
22
|
+
}
|
package/src/starter.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createContentModule, type ContentModuleOptions } from '@wikex/content-kit'
|
|
2
|
+
import { adminExperiencePlugin } from './adminExperience'
|
|
3
|
+
import type { BlockPickerOption, VisualEditorRoute } from './live-preview/runtimeConfig'
|
|
4
|
+
import { createVisualEditorRouteResolver, defineWikexProject } from './project'
|
|
5
|
+
import { wikexAdminPlugin } from './plugin'
|
|
6
|
+
|
|
7
|
+
export type WikexStarterBrand = {
|
|
8
|
+
description: string
|
|
9
|
+
logo: {
|
|
10
|
+
height: number
|
|
11
|
+
url: string
|
|
12
|
+
width: number
|
|
13
|
+
}
|
|
14
|
+
name: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type WikexStarterOptions = {
|
|
18
|
+
brand: WikexStarterBrand
|
|
19
|
+
content: ContentModuleOptions
|
|
20
|
+
globals?: Readonly<Record<string, 'content' | 'footer' | 'header'>>
|
|
21
|
+
id: string
|
|
22
|
+
modules?: readonly string[]
|
|
23
|
+
provider: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type WikexStarter = {
|
|
27
|
+
collections: ReturnType<typeof createContentModule>['collections']
|
|
28
|
+
getVisualEditorRoute: (pathname: string) => VisualEditorRoute | null
|
|
29
|
+
livePreview: {
|
|
30
|
+
brand: WikexStarterBrand
|
|
31
|
+
pageBlocks: readonly BlockPickerOption[]
|
|
32
|
+
postBlocks: readonly BlockPickerOption[]
|
|
33
|
+
}
|
|
34
|
+
plugins: ReturnType<typeof createContentModule>['plugins']
|
|
35
|
+
project: ReturnType<typeof defineWikexProject>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const getLabel = (block: { labels?: unknown; slug: string }) => {
|
|
39
|
+
if (typeof block.labels === 'object' && block.labels && 'singular' in block.labels) {
|
|
40
|
+
const singular = block.labels.singular
|
|
41
|
+
if (typeof singular === 'string') return singular
|
|
42
|
+
}
|
|
43
|
+
return block.slug
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isStarterBlock = (block: unknown): block is { labels?: unknown; slug: string } =>
|
|
47
|
+
typeof block === 'object' && block !== null && 'slug' in block && typeof block.slug === 'string'
|
|
48
|
+
|
|
49
|
+
const toBlockPickerOptions = (blocks: unknown): readonly BlockPickerOption[] => {
|
|
50
|
+
if (!Array.isArray(blocks)) return []
|
|
51
|
+
return blocks.filter(isStarterBlock).map((block) => {
|
|
52
|
+
const label = getLabel(block)
|
|
53
|
+
return { description: label, label, payloadLabel: label, slug: block.slug }
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One factory for the conventional Wikex Payload setup. The consuming project still owns
|
|
59
|
+
* its visual block components and frontend renderer, but no longer has to wire plugins,
|
|
60
|
+
* content schema, branding and preview routing independently.
|
|
61
|
+
*/
|
|
62
|
+
export const createWikexStarter = (options: WikexStarterOptions): WikexStarter => {
|
|
63
|
+
const seo = options.content.seo === undefined ? { siteName: options.brand.name } : options.content.seo
|
|
64
|
+
const content = createContentModule({ ...options.content, seo })
|
|
65
|
+
const collections = [content.pageSlug, content.postSlug].filter(
|
|
66
|
+
(slug): slug is string => Boolean(slug),
|
|
67
|
+
)
|
|
68
|
+
const getVisualEditorRoute = createVisualEditorRouteResolver({
|
|
69
|
+
collections,
|
|
70
|
+
globals: options.globals,
|
|
71
|
+
})
|
|
72
|
+
const project = defineWikexProject({
|
|
73
|
+
brand: options.brand,
|
|
74
|
+
id: options.id,
|
|
75
|
+
modules: options.modules ?? ['website', 'blog'],
|
|
76
|
+
})
|
|
77
|
+
const pageBlocks = toBlockPickerOptions(
|
|
78
|
+
options.content.page === false ? undefined : options.content.page?.blocks,
|
|
79
|
+
)
|
|
80
|
+
const postBlocks = toBlockPickerOptions(
|
|
81
|
+
options.content.post === false ? undefined : options.content.post?.blocks,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
collections: content.collections,
|
|
86
|
+
getVisualEditorRoute,
|
|
87
|
+
livePreview: { brand: options.brand, pageBlocks, postBlocks },
|
|
88
|
+
plugins: [
|
|
89
|
+
adminExperiencePlugin,
|
|
90
|
+
...content.plugins,
|
|
91
|
+
wikexAdminPlugin({ components: { provider: options.provider } }),
|
|
92
|
+
],
|
|
93
|
+
project,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/* Self-hosted Google fonts used by the Lexical typography controls. */
|
|
2
|
+
@mixin wikex-font($family, $file, $weight, $range) {
|
|
3
|
+
@font-face {
|
|
4
|
+
font-family: $family;
|
|
5
|
+
font-style: normal;
|
|
6
|
+
font-weight: $weight;
|
|
7
|
+
font-display: swap;
|
|
8
|
+
src: url('/fonts/google/#{$file}.woff2') format('woff2');
|
|
9
|
+
unicode-range: $range;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
$vietnamese-range:
|
|
14
|
+
U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301,
|
|
15
|
+
U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
|
16
|
+
$latin-range:
|
|
17
|
+
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
|
18
|
+
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
19
|
+
|
|
20
|
+
@include wikex-font('Wikex Inter', 'inter-vietnamese', 100 900, $vietnamese-range);
|
|
21
|
+
@include wikex-font('Wikex Inter', 'inter-latin', 100 900, $latin-range);
|
|
22
|
+
@include wikex-font('Wikex Lora', 'lora-vietnamese', 400 700, $vietnamese-range);
|
|
23
|
+
@include wikex-font('Wikex Lora', 'lora-latin', 400 700, $latin-range);
|
|
24
|
+
@include wikex-font('Wikex Manrope', 'manrope-vietnamese', 200 800, $vietnamese-range);
|
|
25
|
+
@include wikex-font('Wikex Manrope', 'manrope-latin', 200 800, $latin-range);
|
|
26
|
+
@include wikex-font('Wikex Montserrat', 'montserrat-vietnamese', 100 900, $vietnamese-range);
|
|
27
|
+
@include wikex-font('Wikex Montserrat', 'montserrat-latin', 100 900, $latin-range);
|
|
28
|
+
@include wikex-font(
|
|
29
|
+
'Wikex Playfair Display',
|
|
30
|
+
'playfair-display-vietnamese',
|
|
31
|
+
400 900,
|
|
32
|
+
$vietnamese-range
|
|
33
|
+
);
|
|
34
|
+
@include wikex-font('Wikex Playfair Display', 'playfair-display-latin', 400 900, $latin-range);
|
|
35
|
+
@include wikex-font('Wikex Roboto', 'roboto-vietnamese', 100 900, $vietnamese-range);
|
|
36
|
+
@include wikex-font('Wikex Roboto', 'roboto-latin', 100 900, $latin-range);
|
|
37
|
+
|
|
38
|
+
:root {
|
|
39
|
+
--font-inter: 'Wikex Inter', sans-serif;
|
|
40
|
+
--font-lora: 'Wikex Lora', serif;
|
|
41
|
+
--font-manrope: 'Wikex Manrope', sans-serif;
|
|
42
|
+
--font-montserrat: 'Wikex Montserrat', sans-serif;
|
|
43
|
+
--font-playfair-display: 'Wikex Playfair Display', serif;
|
|
44
|
+
--font-roboto: 'Wikex Roboto', sans-serif;
|
|
45
|
+
}
|