@actuate-media/cms-admin 0.72.0 → 0.72.2
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 +38 -0
- package/dist/__tests__/lib/cv.test.js +9 -0
- package/dist/__tests__/lib/cv.test.js.map +1 -1
- package/dist/__tests__/views/page-section-editor.test.js +11 -0
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +14 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/ui/Modal.d.ts +8 -2
- package/dist/components/ui/Modal.d.ts.map +1 -1
- package/dist/components/ui/Modal.js +9 -2
- package/dist/components/ui/Modal.js.map +1 -1
- package/dist/lib/cv.d.ts.map +1 -1
- package/dist/lib/cv.js +9 -1
- package/dist/lib/cv.js.map +1 -1
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
- package/dist/views/page-editor/EditorCanvas.js +1 -1
- package/dist/views/page-editor/EditorCanvas.js.map +1 -1
- package/dist/views/page-editor/PagePreview.js +1 -1
- package/dist/views/page-editor/PagePreview.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +4 -0
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +8 -2
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +4 -2
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostPreview.js +1 -1
- package/dist/views/post-editor/PostPreview.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +5 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/lib/cv.test.ts +10 -0
- package/src/__tests__/views/page-section-editor.test.tsx +15 -0
- package/src/__tests__/views/post-section-editor.test.tsx +26 -0
- package/src/components/ui/Modal.tsx +29 -3
- package/src/lib/cv.ts +8 -1
- package/src/styles/theme.css +34 -8
- package/src/views/page-editor/EditorCanvas.tsx +4 -1
- package/src/views/page-editor/PagePreview.tsx +1 -1
- package/src/views/page-editor/PageSectionEditor.tsx +4 -0
- package/src/views/page-editor/PageSettingsModal.tsx +27 -4
- package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
- package/src/views/post-editor/PostFieldsModal.tsx +6 -1
- package/src/views/post-editor/PostPreview.tsx +1 -1
- package/src/views/post-editor/PostSectionEditor.tsx +6 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as Dialog from '@radix-ui/react-dialog'
|
|
4
4
|
import { X } from 'lucide-react'
|
|
5
|
-
import { type ReactNode } from 'react'
|
|
5
|
+
import { type ReactNode, type RefObject } from 'react'
|
|
6
6
|
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
7
7
|
|
|
8
8
|
export interface ModalProps {
|
|
@@ -16,6 +16,12 @@ export interface ModalProps {
|
|
|
16
16
|
* omit when the body content is self-explanatory.
|
|
17
17
|
*/
|
|
18
18
|
description?: string
|
|
19
|
+
/**
|
|
20
|
+
* Element to focus when the dialog opens, instead of Radix's default
|
|
21
|
+
* (the first tabbable element, which is the header close button). Use for
|
|
22
|
+
* form dialogs where the user should land in the first input.
|
|
23
|
+
*/
|
|
24
|
+
initialFocusRef?: RefObject<HTMLElement | null>
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -29,12 +35,32 @@ export interface ModalProps {
|
|
|
29
35
|
* dialog inherits theme tokens and dark mode instead of rendering unstyled on
|
|
30
36
|
* `document.body`.
|
|
31
37
|
*/
|
|
32
|
-
export function Modal({
|
|
38
|
+
export function Modal({
|
|
39
|
+
open,
|
|
40
|
+
onClose,
|
|
41
|
+
title,
|
|
42
|
+
children,
|
|
43
|
+
actions,
|
|
44
|
+
description,
|
|
45
|
+
initialFocusRef,
|
|
46
|
+
}: ModalProps) {
|
|
33
47
|
return (
|
|
34
48
|
<Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
|
|
35
49
|
<Dialog.Portal container={adminPortalContainer()}>
|
|
36
50
|
<Dialog.Overlay className="fixed inset-0 z-60 bg-black/50" />
|
|
37
|
-
<Dialog.Content
|
|
51
|
+
<Dialog.Content
|
|
52
|
+
className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-60 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none"
|
|
53
|
+
onOpenAutoFocus={
|
|
54
|
+
initialFocusRef
|
|
55
|
+
? (event) => {
|
|
56
|
+
if (initialFocusRef.current) {
|
|
57
|
+
event.preventDefault()
|
|
58
|
+
initialFocusRef.current.focus()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
: undefined
|
|
62
|
+
}
|
|
63
|
+
>
|
|
38
64
|
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
39
65
|
<Dialog.Title className="text-foreground text-lg font-medium">{title}</Dialog.Title>
|
|
40
66
|
<Dialog.Close asChild>
|
package/src/lib/cv.ts
CHANGED
|
@@ -63,7 +63,14 @@ export function cv<V extends VariantsConfig>(base: ClassValue, config: CvConfig<
|
|
|
63
63
|
return function variantClass(
|
|
64
64
|
props?: VariantSelection<V> & { class?: ClassValue; className?: ClassValue },
|
|
65
65
|
): string {
|
|
66
|
-
|
|
66
|
+
// Per the cva contract, an explicit `undefined` for a variant key must
|
|
67
|
+
// fall back to the default — spreading props naively would clobber the
|
|
68
|
+
// default and drop the variant's classes entirely (e.g. a Button without
|
|
69
|
+
// a `size` prop losing all height/padding).
|
|
70
|
+
const selected = { ...config.defaultVariants } as Record<string, unknown>
|
|
71
|
+
for (const [key, value] of Object.entries(props ?? {})) {
|
|
72
|
+
if (value !== undefined) selected[key] = value
|
|
73
|
+
}
|
|
67
74
|
|
|
68
75
|
const variantClasses: ClassValue[] = []
|
|
69
76
|
for (const [key, valuesMap] of Object.entries(config.variants)) {
|
package/src/styles/theme.css
CHANGED
|
@@ -137,7 +137,19 @@
|
|
|
137
137
|
@apply border-border outline-ring/50;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
/*
|
|
141
|
+
* Base typography is written with doubled `.actuate-admin.actuate-admin`
|
|
142
|
+
* specificity on purpose. Consumer apps commonly import their site
|
|
143
|
+
* stylesheet into the admin route (for the page-editor canvas preview) and
|
|
144
|
+
* those sheets sometimes style `.actuate-admin` or plain `h1`/`body` in
|
|
145
|
+
* their own `@layer base` — with equal specificity, whichever file loads
|
|
146
|
+
* last wins, and the admin shell inherits site fonts/colors ("washed out"
|
|
147
|
+
* admin). Doubling the class wins those same-layer ties without `!important`
|
|
148
|
+
* while Tailwind utilities (a later layer) still override as usual.
|
|
149
|
+
* Consumer styles that should reach the canvas preview belong under
|
|
150
|
+
* `.actuate-admin .actuate-canvas` — see EditorCanvas.
|
|
151
|
+
*/
|
|
152
|
+
.actuate-admin.actuate-admin {
|
|
141
153
|
@apply bg-background text-foreground;
|
|
142
154
|
font-family: var(--actuate-admin-font, var(--font-sans)) !important;
|
|
143
155
|
font-size: var(--font-size);
|
|
@@ -148,44 +160,44 @@
|
|
|
148
160
|
-moz-osx-font-smoothing: grayscale;
|
|
149
161
|
}
|
|
150
162
|
|
|
151
|
-
.actuate-admin h1 {
|
|
163
|
+
.actuate-admin.actuate-admin h1 {
|
|
152
164
|
font-size: var(--fs-page);
|
|
153
165
|
font-weight: 700;
|
|
154
166
|
letter-spacing: -0.5px;
|
|
155
167
|
line-height: 1.5;
|
|
156
168
|
}
|
|
157
169
|
|
|
158
|
-
.actuate-admin h2 {
|
|
170
|
+
.actuate-admin.actuate-admin h2 {
|
|
159
171
|
font-size: var(--fs-card);
|
|
160
172
|
font-weight: 600;
|
|
161
173
|
line-height: 1.5;
|
|
162
174
|
}
|
|
163
175
|
|
|
164
|
-
.actuate-admin h3 {
|
|
176
|
+
.actuate-admin.actuate-admin h3 {
|
|
165
177
|
font-size: var(--fs-card);
|
|
166
178
|
font-weight: 600;
|
|
167
179
|
line-height: 1.5;
|
|
168
180
|
}
|
|
169
181
|
|
|
170
|
-
.actuate-admin h4 {
|
|
182
|
+
.actuate-admin.actuate-admin h4 {
|
|
171
183
|
font-size: var(--fs-body);
|
|
172
184
|
font-weight: 600;
|
|
173
185
|
line-height: 1.5;
|
|
174
186
|
}
|
|
175
187
|
|
|
176
|
-
.actuate-admin label {
|
|
188
|
+
.actuate-admin.actuate-admin label {
|
|
177
189
|
font-size: var(--fs-body);
|
|
178
190
|
font-weight: var(--font-weight-medium);
|
|
179
191
|
line-height: 1.5;
|
|
180
192
|
}
|
|
181
193
|
|
|
182
|
-
.actuate-admin button {
|
|
194
|
+
.actuate-admin.actuate-admin button {
|
|
183
195
|
font-size: var(--fs-body);
|
|
184
196
|
font-weight: var(--font-weight-medium);
|
|
185
197
|
line-height: 1.5;
|
|
186
198
|
}
|
|
187
199
|
|
|
188
|
-
.actuate-admin input {
|
|
200
|
+
.actuate-admin.actuate-admin input {
|
|
189
201
|
font-size: var(--fs-body);
|
|
190
202
|
font-weight: var(--font-weight-normal);
|
|
191
203
|
line-height: 1.5;
|
|
@@ -198,6 +210,20 @@
|
|
|
198
210
|
text-transform: uppercase;
|
|
199
211
|
color: var(--sub);
|
|
200
212
|
}
|
|
213
|
+
|
|
214
|
+
/*
|
|
215
|
+
* The sanctioned hook for consumer brand styles inside the editor canvas
|
|
216
|
+
* preview. Anything the site stylesheet must render in the preview should
|
|
217
|
+
* be scoped `.actuate-admin .actuate-canvas …` instead of `.actuate-admin`.
|
|
218
|
+
* The canvas re-establishes the site's typographic context: headings and
|
|
219
|
+
* body text fall back to inheritance rather than the admin scale above.
|
|
220
|
+
*/
|
|
221
|
+
.actuate-admin .actuate-canvas :is(h1, h2, h3, h4) {
|
|
222
|
+
font-size: revert-layer;
|
|
223
|
+
font-weight: revert-layer;
|
|
224
|
+
letter-spacing: revert-layer;
|
|
225
|
+
line-height: revert-layer;
|
|
226
|
+
}
|
|
201
227
|
}
|
|
202
228
|
|
|
203
229
|
.actuate-admin.reduce-motion,
|
|
@@ -88,9 +88,12 @@ export function EditorCanvas({
|
|
|
88
88
|
className="mx-auto"
|
|
89
89
|
style={{ width: vp.width * scale, height: surfaceHeight ?? undefined }}
|
|
90
90
|
>
|
|
91
|
+
{/* `actuate-canvas` is the public hook for consumer brand CSS:
|
|
92
|
+
site styles that should render in this preview scope to
|
|
93
|
+
`.actuate-admin .actuate-canvas` instead of the admin root. */}
|
|
91
94
|
<div
|
|
92
95
|
ref={surfaceRef}
|
|
93
|
-
className="border-border bg-card origin-top overflow-hidden rounded-xl border shadow-sm"
|
|
96
|
+
className="actuate-canvas border-border bg-card origin-top overflow-hidden rounded-xl border shadow-sm"
|
|
94
97
|
style={{
|
|
95
98
|
width: vp.width,
|
|
96
99
|
transform: `scale(${scale})`,
|
|
@@ -91,7 +91,7 @@ export function PagePreview({ documentId, onNavigate }: PagePreviewProps) {
|
|
|
91
91
|
|
|
92
92
|
{/* The canvas is intentionally white regardless of the admin theme:
|
|
93
93
|
it emulates the public page surface, not admin chrome. */}
|
|
94
|
-
<div className="bg-card flex-1 overflow-auto">
|
|
94
|
+
<div className="actuate-canvas bg-card flex-1 overflow-auto">
|
|
95
95
|
{visible.length === 0 ? (
|
|
96
96
|
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
|
97
97
|
This page has no visible sections yet.
|
|
@@ -145,6 +145,10 @@ export function PageSectionEditor({
|
|
|
145
145
|
if (!documentId) {
|
|
146
146
|
setPage(emptyPage(initialParentId ?? null))
|
|
147
147
|
setLoading(false)
|
|
148
|
+
// Start a brand-new page the way WordPress does: name it first. The
|
|
149
|
+
// settings modal opens with the title focused and the slug/path
|
|
150
|
+
// deriving live, so the required fields are done before building.
|
|
151
|
+
setSettingsOpen(true)
|
|
148
152
|
return
|
|
149
153
|
}
|
|
150
154
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from 'react'
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
4
|
import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
|
|
5
5
|
import { Modal } from '../../components/ui/Modal.js'
|
|
6
6
|
import { Button } from '../../components/ui/Button.js'
|
|
@@ -49,6 +49,9 @@ export function PageSettingsModal({
|
|
|
49
49
|
// Auto-derive slug/path from title until the user edits slug manually.
|
|
50
50
|
const [slugTouched, setSlugTouched] = useState(Boolean(page.slug))
|
|
51
51
|
const [parentOptions, setParentOptions] = useState<ParentOption[] | null>(null)
|
|
52
|
+
// Land the user in the Title field (WordPress-style "name it first"),
|
|
53
|
+
// rather than Radix's default focus on the header close button.
|
|
54
|
+
const titleRef = useRef<HTMLInputElement | null>(null)
|
|
52
55
|
|
|
53
56
|
useEffect(() => {
|
|
54
57
|
if (!open) return
|
|
@@ -118,29 +121,49 @@ export function PageSettingsModal({
|
|
|
118
121
|
open={open}
|
|
119
122
|
onClose={onClose}
|
|
120
123
|
title="Page settings"
|
|
124
|
+
initialFocusRef={titleRef}
|
|
121
125
|
actions={
|
|
122
126
|
<>
|
|
123
127
|
<Button variant="ghost" onClick={onClose}>
|
|
124
128
|
Cancel
|
|
125
129
|
</Button>
|
|
126
|
-
<Button
|
|
130
|
+
<Button
|
|
131
|
+
variant="primary"
|
|
132
|
+
form="page-settings-form"
|
|
133
|
+
type="submit"
|
|
134
|
+
disabled={!canEdit || Boolean(slugError)}
|
|
135
|
+
>
|
|
127
136
|
Done
|
|
128
137
|
</Button>
|
|
129
138
|
</>
|
|
130
139
|
}
|
|
131
140
|
>
|
|
132
|
-
|
|
141
|
+
{/* A real form so Enter in any field confirms the dialog — matters
|
|
142
|
+
for the new-page flow where this modal is the first stop. */}
|
|
143
|
+
<form
|
|
144
|
+
id="page-settings-form"
|
|
145
|
+
className="space-y-3"
|
|
146
|
+
onSubmit={(e) => {
|
|
147
|
+
e.preventDefault()
|
|
148
|
+
handleSave()
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
133
151
|
<div>
|
|
134
152
|
<label className={LABEL} htmlFor="ps-title">
|
|
135
153
|
Title <span className="text-destructive">*</span>
|
|
136
154
|
</label>
|
|
137
155
|
<input
|
|
138
156
|
id="ps-title"
|
|
157
|
+
ref={titleRef}
|
|
139
158
|
className={INPUT}
|
|
140
159
|
value={title}
|
|
141
160
|
disabled={!canEdit}
|
|
161
|
+
placeholder="e.g. About Us"
|
|
142
162
|
onChange={(e) => handleTitle(e.target.value)}
|
|
143
163
|
/>
|
|
164
|
+
<p className="text-muted-foreground mt-1 text-xs">
|
|
165
|
+
The slug and path below follow the title until you edit them.
|
|
166
|
+
</p>
|
|
144
167
|
</div>
|
|
145
168
|
<div className="grid grid-cols-2 gap-3">
|
|
146
169
|
<div>
|
|
@@ -240,7 +263,7 @@ export function PageSettingsModal({
|
|
|
240
263
|
onChange={(e) => setSeoDescription(e.target.value)}
|
|
241
264
|
/>
|
|
242
265
|
</div>
|
|
243
|
-
</
|
|
266
|
+
</form>
|
|
244
267
|
</Modal>
|
|
245
268
|
)
|
|
246
269
|
}
|
|
@@ -87,7 +87,7 @@ export function PostEditorCanvas({
|
|
|
87
87
|
>
|
|
88
88
|
<div
|
|
89
89
|
ref={surfaceRef}
|
|
90
|
-
className="border-border bg-card origin-top overflow-hidden rounded-xl border shadow-sm"
|
|
90
|
+
className="actuate-canvas border-border bg-card origin-top overflow-hidden rounded-xl border shadow-sm"
|
|
91
91
|
style={{ width: vp.width, transform: `scale(${scale})`, transformOrigin: 'top left' }}
|
|
92
92
|
>
|
|
93
93
|
<PostHeader config={header} context={context} />
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from 'react'
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
4
|
import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
|
|
5
5
|
import { Modal } from '../../components/ui/Modal.js'
|
|
6
6
|
import { Button } from '../../components/ui/Button.js'
|
|
@@ -62,6 +62,8 @@ export function PostFieldsModal({
|
|
|
62
62
|
const [seoDescription, setSeoDescription] = useState(post.seoDescription)
|
|
63
63
|
const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
|
|
64
64
|
const [mediaOpen, setMediaOpen] = useState(false)
|
|
65
|
+
// Land the user in the Title field (name-it-first flow for new posts).
|
|
66
|
+
const titleRef = useRef<HTMLInputElement | null>(null)
|
|
65
67
|
|
|
66
68
|
const categoryField = fields?.category
|
|
67
69
|
const categoryOptions =
|
|
@@ -103,6 +105,7 @@ export function PostFieldsModal({
|
|
|
103
105
|
open={open}
|
|
104
106
|
onClose={onClose}
|
|
105
107
|
title="Post details"
|
|
108
|
+
initialFocusRef={titleRef}
|
|
106
109
|
actions={
|
|
107
110
|
<>
|
|
108
111
|
<Button variant="ghost" onClick={onClose}>
|
|
@@ -121,9 +124,11 @@ export function PostFieldsModal({
|
|
|
121
124
|
</label>
|
|
122
125
|
<input
|
|
123
126
|
id="pf-title"
|
|
127
|
+
ref={titleRef}
|
|
124
128
|
className={INPUT}
|
|
125
129
|
value={title}
|
|
126
130
|
disabled={!canEdit}
|
|
131
|
+
placeholder="e.g. Announcing our new product"
|
|
127
132
|
onChange={(e) => handleTitle(e.target.value)}
|
|
128
133
|
/>
|
|
129
134
|
</div>
|
|
@@ -114,7 +114,7 @@ export function PostPreview({ postType, documentId, session, onNavigate }: PostP
|
|
|
114
114
|
</button>
|
|
115
115
|
</div>
|
|
116
116
|
|
|
117
|
-
<div className="bg-card flex-1 overflow-auto">
|
|
117
|
+
<div className="actuate-canvas bg-card flex-1 overflow-auto">
|
|
118
118
|
<PostHeader config={header} context={context} />
|
|
119
119
|
{visible.map((section) => (
|
|
120
120
|
<SectionRenderer key={section.id} section={section} context={context} />
|
|
@@ -191,7 +191,12 @@ export function PostSectionEditor({
|
|
|
191
191
|
if (cancelled) return
|
|
192
192
|
setPost(p)
|
|
193
193
|
setSelectedId(null)
|
|
194
|
-
if (!documentId)
|
|
194
|
+
if (!documentId) {
|
|
195
|
+
setDirty(true)
|
|
196
|
+
// Name-it-first flow (matches the Page editor): a new post opens
|
|
197
|
+
// straight into the fields modal so title/slug are set up front.
|
|
198
|
+
setFieldsOpen(true)
|
|
199
|
+
}
|
|
195
200
|
})
|
|
196
201
|
.catch((err) => {
|
|
197
202
|
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load post')
|