@prenta/admin 1.2.0 → 1.3.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 +6 -0
- package/dist/__tests__/lib/page-audit-input.test.js +81 -0
- package/dist/__tests__/lib/page-audit-input.test.js.map +1 -1
- package/dist/__tests__/lib/version-history.test.js +18 -0
- package/dist/__tests__/lib/version-history.test.js.map +1 -1
- package/dist/__tests__/no-server-barrel-imports.test.js +3 -4
- package/dist/__tests__/no-server-barrel-imports.test.js.map +1 -1
- package/dist/__tests__/views/editor-top-bar.render.test.js +5 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +38 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/post-tab.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-tab.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-tab.render.test.js +86 -0
- package/dist/__tests__/views/post-tab.render.test.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +9 -1
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +5 -2
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/hooks/usePageAudit.d.ts +4 -1
- package/dist/hooks/usePageAudit.d.ts.map +1 -1
- package/dist/hooks/usePageAudit.js +3 -3
- package/dist/hooks/usePageAudit.js.map +1 -1
- package/dist/lib/page-audit-input.d.ts +26 -1
- package/dist/lib/page-audit-input.d.ts.map +1 -1
- package/dist/lib/page-audit-input.js +48 -2
- package/dist/lib/page-audit-input.js.map +1 -1
- package/dist/lib/version-history.d.ts +5 -0
- package/dist/lib/version-history.d.ts.map +1 -1
- package/dist/lib/version-history.js +18 -0
- package/dist/lib/version-history.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +1 -1
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.d.ts +14 -1
- package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +2 -2
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostFieldsForm.d.ts +39 -0
- package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -0
- package/dist/views/post-editor/PostFieldsForm.js +85 -0
- package/dist/views/post-editor/PostFieldsForm.js.map +1 -0
- package/dist/views/post-editor/PostFieldsModal.d.ts +2 -14
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +20 -81
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +122 -34
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostTab.d.ts +17 -0
- package/dist/views/post-editor/PostTab.d.ts.map +1 -0
- package/dist/views/post-editor/PostTab.js +23 -0
- package/dist/views/post-editor/PostTab.js.map +1 -0
- package/package.json +6 -6
- package/src/__tests__/lib/page-audit-input.test.ts +99 -0
- package/src/__tests__/lib/version-history.test.ts +20 -0
- package/src/__tests__/no-server-barrel-imports.test.ts +3 -4
- package/src/__tests__/views/editor-top-bar.render.test.tsx +6 -0
- package/src/__tests__/views/post-section-editor.test.tsx +59 -0
- package/src/__tests__/views/post-tab.render.test.tsx +116 -0
- package/src/components/seo/SeoEditorPane.tsx +16 -1
- package/src/hooks/usePageAudit.ts +9 -2
- package/src/lib/page-audit-input.ts +67 -1
- package/src/lib/version-history.ts +23 -0
- package/src/views/page-editor/EditorTopBar.tsx +2 -2
- package/src/views/post-editor/PostEditorCanvas.tsx +27 -5
- package/src/views/post-editor/PostFieldsForm.tsx +322 -0
- package/src/views/post-editor/PostFieldsModal.tsx +30 -281
- package/src/views/post-editor/PostSectionEditor.tsx +204 -59
- package/src/views/post-editor/PostTab.tsx +46 -0
|
@@ -1,27 +1,18 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
-
import {
|
|
5
|
-
import { toast } from 'sonner'
|
|
6
|
-
import { normalizeSlug, validateSlug } from '@prenta/core/client'
|
|
4
|
+
import { validateSlug } from '@prenta/core/client'
|
|
7
5
|
import { Modal } from '../../components/ui/Modal.js'
|
|
8
6
|
import { Button } from '../../components/ui/Button.js'
|
|
9
7
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
10
|
-
import { MediaPickerModal } from '../../components/MediaPickerModal.js'
|
|
11
|
-
import { TagInput } from '../../components/TagInput.js'
|
|
12
|
-
import { coauthorText } from '../../lib/coauthor-client.js'
|
|
13
8
|
import type { EditorPost } from '../../lib/post-editor-service.js'
|
|
9
|
+
import {
|
|
10
|
+
PostFieldsForm,
|
|
11
|
+
type PostFieldConfig,
|
|
12
|
+
type PostFieldsFormValues,
|
|
13
|
+
} from './PostFieldsForm.js'
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
* The slice of a collection's field config the modal reads to pick controls:
|
|
17
|
-
* a `select` category renders a dropdown (structured vocabulary), an `array`
|
|
18
|
-
* tags field renders the chip input with autocomplete.
|
|
19
|
-
*/
|
|
20
|
-
export interface PostFieldConfig {
|
|
21
|
-
type?: string
|
|
22
|
-
options?: Array<{ label: string; value: string }>
|
|
23
|
-
maxRows?: number
|
|
24
|
-
}
|
|
15
|
+
export type { PostFieldConfig }
|
|
25
16
|
|
|
26
17
|
interface PostFieldsModalProps {
|
|
27
18
|
open: boolean
|
|
@@ -33,13 +24,6 @@ interface PostFieldsModalProps {
|
|
|
33
24
|
onSave: (patch: Partial<EditorPost>) => void
|
|
34
25
|
}
|
|
35
26
|
|
|
36
|
-
const LABEL = 'text-foreground mb-1 block text-xs font-medium'
|
|
37
|
-
/** Match TipTap body editor borders — `border-input` is transparent in theme.css. */
|
|
38
|
-
const INPUT =
|
|
39
|
-
'border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
|
|
40
|
-
|
|
41
|
-
const slugify = normalizeSlug
|
|
42
|
-
|
|
43
27
|
type FormSnapshot = {
|
|
44
28
|
title: string
|
|
45
29
|
slug: string
|
|
@@ -60,63 +44,13 @@ function snapshotFromPost(post: EditorPost): FormSnapshot {
|
|
|
60
44
|
}
|
|
61
45
|
}
|
|
62
46
|
|
|
63
|
-
function snapshotFromFields(input: {
|
|
64
|
-
title: string
|
|
65
|
-
slug: string
|
|
66
|
-
excerpt: string
|
|
67
|
-
featuredImage: string
|
|
68
|
-
category: string
|
|
69
|
-
tags: string[]
|
|
70
|
-
}): FormSnapshot {
|
|
47
|
+
function snapshotFromFields(input: PostFieldsFormValues): FormSnapshot {
|
|
71
48
|
return {
|
|
72
49
|
...input,
|
|
73
50
|
tags: [...input.tags].sort().join('\0'),
|
|
74
51
|
}
|
|
75
52
|
}
|
|
76
53
|
|
|
77
|
-
function stripHtml(html: string): string {
|
|
78
|
-
return html
|
|
79
|
-
.replace(/<[^>]*>/g, ' ')
|
|
80
|
-
.replace(/\s+/g, ' ')
|
|
81
|
-
.trim()
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function buildContentContext(title: string, body: string, excerpt: string): string {
|
|
85
|
-
const plain = stripHtml(body)
|
|
86
|
-
const parts = [title.trim(), excerpt.trim(), plain].filter(Boolean)
|
|
87
|
-
return parts.join('\n\n')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function AiFieldButton({
|
|
91
|
-
label,
|
|
92
|
-
busy,
|
|
93
|
-
disabled,
|
|
94
|
-
onClick,
|
|
95
|
-
}: {
|
|
96
|
-
label: string
|
|
97
|
-
busy: boolean
|
|
98
|
-
disabled?: boolean
|
|
99
|
-
onClick: () => void
|
|
100
|
-
}) {
|
|
101
|
-
return (
|
|
102
|
-
<button
|
|
103
|
-
type="button"
|
|
104
|
-
aria-label={label}
|
|
105
|
-
title={label}
|
|
106
|
-
disabled={disabled || busy}
|
|
107
|
-
onClick={onClick}
|
|
108
|
-
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-[var(--acc)] transition-colors duration-[var(--motion-fast)] hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:opacity-60"
|
|
109
|
-
>
|
|
110
|
-
{busy ? (
|
|
111
|
-
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
|
|
112
|
-
) : (
|
|
113
|
-
<Sparkles className="h-3 w-3" aria-hidden />
|
|
114
|
-
)}
|
|
115
|
-
{busy ? 'Generating…' : 'AI'}
|
|
116
|
-
</button>
|
|
117
|
-
)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
54
|
/** Edits post metadata: title, slug, excerpt, featured image, category, and tags. SEO lives in the SEO tab. */
|
|
121
55
|
export function PostFieldsModal({
|
|
122
56
|
open,
|
|
@@ -133,21 +67,11 @@ export function PostFieldsModal({
|
|
|
133
67
|
const [category, setCategory] = useState(post.category)
|
|
134
68
|
const [tags, setTags] = useState<string[]>(post.tags)
|
|
135
69
|
const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
|
|
136
|
-
const [mediaOpen, setMediaOpen] = useState(false)
|
|
137
|
-
const [aiBusy, setAiBusy] = useState(false)
|
|
138
70
|
const [confirmDiscard, setConfirmDiscard] = useState(false)
|
|
139
71
|
const baselineRef = useRef<FormSnapshot | null>(null)
|
|
140
72
|
// Land the user in the Title field (name-it-first flow for new posts).
|
|
141
73
|
const titleRef = useRef<HTMLInputElement | null>(null)
|
|
142
74
|
|
|
143
|
-
const categoryField = fields?.category
|
|
144
|
-
const categoryOptions =
|
|
145
|
-
categoryField?.type === 'select' && Array.isArray(categoryField.options)
|
|
146
|
-
? categoryField.options
|
|
147
|
-
: null
|
|
148
|
-
const tagsField = fields?.tags
|
|
149
|
-
const hasTags = tagsField?.type === 'array'
|
|
150
|
-
|
|
151
75
|
useEffect(() => {
|
|
152
76
|
if (!open) {
|
|
153
77
|
baselineRef.current = null
|
|
@@ -161,22 +85,15 @@ export function PostFieldsModal({
|
|
|
161
85
|
setCategory(post.category)
|
|
162
86
|
setTags(post.tags)
|
|
163
87
|
setSlugTouched(Boolean(post.slug))
|
|
164
|
-
setAiBusy(false)
|
|
165
88
|
baselineRef.current = snapshotFromPost(post)
|
|
166
89
|
}, [open, post])
|
|
167
90
|
|
|
91
|
+
const values: PostFieldsFormValues = { title, slug, excerpt, featuredImage, category, tags }
|
|
92
|
+
|
|
168
93
|
const isDirty = useMemo(() => {
|
|
169
94
|
if (!open || !baselineRef.current) return false
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
slug,
|
|
173
|
-
excerpt,
|
|
174
|
-
featuredImage,
|
|
175
|
-
category,
|
|
176
|
-
tags,
|
|
177
|
-
})
|
|
178
|
-
return JSON.stringify(current) !== JSON.stringify(baselineRef.current)
|
|
179
|
-
}, [open, title, slug, excerpt, featuredImage, category, tags])
|
|
95
|
+
return JSON.stringify(snapshotFromFields(values)) !== JSON.stringify(baselineRef.current)
|
|
96
|
+
}, [open, values])
|
|
180
97
|
|
|
181
98
|
function requestDismiss() {
|
|
182
99
|
if (isDirty) {
|
|
@@ -186,42 +103,17 @@ export function PostFieldsModal({
|
|
|
186
103
|
onClose()
|
|
187
104
|
}
|
|
188
105
|
|
|
189
|
-
function
|
|
190
|
-
setTitle(
|
|
191
|
-
if (
|
|
106
|
+
function handleChange(patch: Partial<PostFieldsFormValues>) {
|
|
107
|
+
if (patch.title !== undefined) setTitle(patch.title)
|
|
108
|
+
if (patch.slug !== undefined) setSlug(patch.slug)
|
|
109
|
+
if (patch.excerpt !== undefined) setExcerpt(patch.excerpt)
|
|
110
|
+
if (patch.featuredImage !== undefined) setFeaturedImage(patch.featuredImage)
|
|
111
|
+
if (patch.category !== undefined) setCategory(patch.category)
|
|
112
|
+
if (patch.tags !== undefined) setTags(patch.tags)
|
|
192
113
|
}
|
|
193
114
|
|
|
194
115
|
const slugError = validateSlug(slug)
|
|
195
116
|
|
|
196
|
-
async function handleAiGenerateExcerpt() {
|
|
197
|
-
const context = buildContentContext(title, post.body, excerpt)
|
|
198
|
-
if (!context.trim()) {
|
|
199
|
-
toast.error(
|
|
200
|
-
'Add a title or write body content in the Article Body section before generating with AI.',
|
|
201
|
-
)
|
|
202
|
-
return
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
setAiBusy(true)
|
|
206
|
-
try {
|
|
207
|
-
const outcome = await coauthorText('compress', context, {
|
|
208
|
-
instructions:
|
|
209
|
-
'Write a 1–2 sentence excerpt for blog listings and cards. Return only the excerpt text.',
|
|
210
|
-
targetLength: 45,
|
|
211
|
-
})
|
|
212
|
-
if (!outcome.ok) {
|
|
213
|
-
toast.error(outcome.error)
|
|
214
|
-
return
|
|
215
|
-
}
|
|
216
|
-
setExcerpt(outcome.result.text.trim())
|
|
217
|
-
toast.success('Excerpt generated — review before saving.')
|
|
218
|
-
} catch {
|
|
219
|
-
toast.error('AI request failed.')
|
|
220
|
-
} finally {
|
|
221
|
-
setAiBusy(false)
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
117
|
function handleSave() {
|
|
226
118
|
if (slugError) return
|
|
227
119
|
onSave({ title, slug, excerpt, featuredImage, category, tags })
|
|
@@ -252,162 +144,19 @@ export function PostFieldsModal({
|
|
|
252
144
|
</>
|
|
253
145
|
}
|
|
254
146
|
>
|
|
255
|
-
<
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
placeholder="e.g. Announcing our new product"
|
|
267
|
-
onChange={(e) => handleTitle(e.target.value)}
|
|
268
|
-
/>
|
|
269
|
-
</div>
|
|
270
|
-
|
|
271
|
-
<div className="grid grid-cols-2 gap-3">
|
|
272
|
-
<div>
|
|
273
|
-
<label className={LABEL} htmlFor="pf-slug">
|
|
274
|
-
Slug <span className="text-destructive">*</span>
|
|
275
|
-
</label>
|
|
276
|
-
<input
|
|
277
|
-
id="pf-slug"
|
|
278
|
-
className={INPUT}
|
|
279
|
-
value={slug}
|
|
280
|
-
disabled={!canEdit}
|
|
281
|
-
placeholder={slugTouched ? undefined : 'Generated from title…'}
|
|
282
|
-
aria-invalid={Boolean(slugError)}
|
|
283
|
-
aria-describedby={slugError ? 'pf-slug-error' : undefined}
|
|
284
|
-
onChange={(e) => {
|
|
285
|
-
setSlugTouched(true)
|
|
286
|
-
setSlug(slugify(e.target.value))
|
|
287
|
-
}}
|
|
288
|
-
/>
|
|
289
|
-
{slugError && (
|
|
290
|
-
<p id="pf-slug-error" className="text-destructive mt-1 text-xs">
|
|
291
|
-
{slugError}
|
|
292
|
-
</p>
|
|
293
|
-
)}
|
|
294
|
-
</div>
|
|
295
|
-
|
|
296
|
-
<div>
|
|
297
|
-
<label className={LABEL} htmlFor="pf-category">
|
|
298
|
-
Category
|
|
299
|
-
</label>
|
|
300
|
-
{categoryOptions ? (
|
|
301
|
-
<select
|
|
302
|
-
id="pf-category"
|
|
303
|
-
className={INPUT}
|
|
304
|
-
value={category}
|
|
305
|
-
disabled={!canEdit}
|
|
306
|
-
onChange={(e) => setCategory(e.target.value)}
|
|
307
|
-
>
|
|
308
|
-
<option value="">No category</option>
|
|
309
|
-
{categoryOptions.map((o) => (
|
|
310
|
-
<option key={o.value} value={o.value}>
|
|
311
|
-
{o.label}
|
|
312
|
-
</option>
|
|
313
|
-
))}
|
|
314
|
-
{category && !categoryOptions.some((o) => o.value === category) && (
|
|
315
|
-
<option value={category}>{category}</option>
|
|
316
|
-
)}
|
|
317
|
-
</select>
|
|
318
|
-
) : (
|
|
319
|
-
<input
|
|
320
|
-
id="pf-category"
|
|
321
|
-
className={INPUT}
|
|
322
|
-
value={category}
|
|
323
|
-
disabled={!canEdit}
|
|
324
|
-
placeholder="e.g. Product"
|
|
325
|
-
onChange={(e) => setCategory(e.target.value)}
|
|
326
|
-
/>
|
|
327
|
-
)}
|
|
328
|
-
</div>
|
|
329
|
-
</div>
|
|
330
|
-
|
|
331
|
-
{hasTags && (
|
|
332
|
-
<div>
|
|
333
|
-
<label className={LABEL} htmlFor="pf-tags">
|
|
334
|
-
Tags
|
|
335
|
-
</label>
|
|
336
|
-
<TagInput
|
|
337
|
-
id="pf-tags"
|
|
338
|
-
value={tags}
|
|
339
|
-
onChange={setTags}
|
|
340
|
-
collection={post.postType}
|
|
341
|
-
field="tags"
|
|
342
|
-
disabled={!canEdit}
|
|
343
|
-
maxTags={tagsField?.maxRows}
|
|
344
|
-
/>
|
|
345
|
-
</div>
|
|
346
|
-
)}
|
|
347
|
-
|
|
348
|
-
<div>
|
|
349
|
-
<div className="mb-1 flex items-center justify-between gap-2">
|
|
350
|
-
<label className={`${LABEL} mb-0`} htmlFor="pf-excerpt">
|
|
351
|
-
Excerpt
|
|
352
|
-
</label>
|
|
353
|
-
<AiFieldButton
|
|
354
|
-
label="Generate excerpt from content"
|
|
355
|
-
busy={aiBusy}
|
|
356
|
-
disabled={!canEdit}
|
|
357
|
-
onClick={() => void handleAiGenerateExcerpt()}
|
|
358
|
-
/>
|
|
359
|
-
</div>
|
|
360
|
-
<textarea
|
|
361
|
-
id="pf-excerpt"
|
|
362
|
-
className={`${INPUT} min-h-[72px] resize-y`}
|
|
363
|
-
value={excerpt}
|
|
364
|
-
disabled={!canEdit}
|
|
365
|
-
placeholder="A short summary shown in listings and the header."
|
|
366
|
-
onChange={(e) => setExcerpt(e.target.value)}
|
|
367
|
-
/>
|
|
368
|
-
</div>
|
|
369
|
-
|
|
370
|
-
<div>
|
|
371
|
-
<label className={LABEL} htmlFor="pf-image">
|
|
372
|
-
Featured image
|
|
373
|
-
</label>
|
|
374
|
-
<div className="flex gap-2">
|
|
375
|
-
<input
|
|
376
|
-
id="pf-image"
|
|
377
|
-
className={`${INPUT} min-w-0 flex-1`}
|
|
378
|
-
value={featuredImage}
|
|
379
|
-
disabled={!canEdit}
|
|
380
|
-
placeholder="https://… or pick from the media library"
|
|
381
|
-
onChange={(e) => setFeaturedImage(e.target.value)}
|
|
382
|
-
/>
|
|
383
|
-
<button
|
|
384
|
-
type="button"
|
|
385
|
-
disabled={!canEdit}
|
|
386
|
-
onClick={() => setMediaOpen(true)}
|
|
387
|
-
className="bg-accent text-foreground border-border hover:bg-accent/80 shrink-0 rounded-md border px-3 py-1.5 text-xs font-medium disabled:opacity-50"
|
|
388
|
-
>
|
|
389
|
-
Browse
|
|
390
|
-
</button>
|
|
391
|
-
</div>
|
|
392
|
-
{featuredImage && (
|
|
393
|
-
<div className="border-border bg-muted mt-2 overflow-hidden rounded-md border">
|
|
394
|
-
<img src={featuredImage} alt="" className="h-28 w-full object-cover" />
|
|
395
|
-
</div>
|
|
396
|
-
)}
|
|
397
|
-
</div>
|
|
398
|
-
</div>
|
|
147
|
+
<PostFieldsForm
|
|
148
|
+
values={values}
|
|
149
|
+
canEdit={canEdit}
|
|
150
|
+
fields={fields}
|
|
151
|
+
postType={post.postType}
|
|
152
|
+
bodyHtml={post.body}
|
|
153
|
+
slugTouched={slugTouched}
|
|
154
|
+
onSlugTouched={() => setSlugTouched(true)}
|
|
155
|
+
onChange={handleChange}
|
|
156
|
+
titleRef={titleRef}
|
|
157
|
+
/>
|
|
399
158
|
</Modal>
|
|
400
159
|
|
|
401
|
-
<MediaPickerModal
|
|
402
|
-
open={mediaOpen}
|
|
403
|
-
onClose={() => setMediaOpen(false)}
|
|
404
|
-
onSelect={(url) => {
|
|
405
|
-
setFeaturedImage(url)
|
|
406
|
-
setMediaOpen(false)
|
|
407
|
-
}}
|
|
408
|
-
accept="image/*"
|
|
409
|
-
/>
|
|
410
|
-
|
|
411
160
|
<ConfirmDialog
|
|
412
161
|
open={confirmDiscard}
|
|
413
162
|
onClose={() => setConfirmDiscard(false)}
|