@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
|
@@ -33,6 +33,20 @@ export interface AuditMetaSource {
|
|
|
33
33
|
hasOgImage: boolean
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Post identity the public template renders outside the section list.
|
|
38
|
+
*
|
|
39
|
+
* The audit engine only reads `sections`. Posts put the H1, opening prose, and
|
|
40
|
+
* featured image on the document itself, so both audit callers (saved record
|
|
41
|
+
* and live SEO pane) fold these into synthetic sections here — one mapping,
|
|
42
|
+
* so the header chip and the rings cannot disagree.
|
|
43
|
+
*/
|
|
44
|
+
export interface PostAuditExtras {
|
|
45
|
+
title?: string
|
|
46
|
+
bodyHtml?: string
|
|
47
|
+
featuredImage?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
export interface BuildAuditInputArgs {
|
|
37
51
|
sections: PageSection[]
|
|
38
52
|
meta: AuditMetaSource
|
|
@@ -41,6 +55,57 @@ export interface BuildAuditInputArgs {
|
|
|
41
55
|
record: Pick<SeoContentRecord, 'title' | 'siteName' | 'titleTemplate' | 'url'> | null
|
|
42
56
|
/** Fallback title when there is no saved record yet. */
|
|
43
57
|
fallbackTitle?: string
|
|
58
|
+
/** Post-only slots prepended so the same engine sees title / body / image. */
|
|
59
|
+
post?: PostAuditExtras
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const POST_TITLE_SECTION_ID = '__post-title'
|
|
63
|
+
const POST_IMAGE_SECTION_ID = '__post-featured-image'
|
|
64
|
+
|
|
65
|
+
function stripHtml(html: string): string {
|
|
66
|
+
return html
|
|
67
|
+
.replace(/<[^>]*>/g, ' ')
|
|
68
|
+
.replace(/\s+/g, ' ')
|
|
69
|
+
.trim()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Prepend the post's own title, body, and featured image as sections the
|
|
74
|
+
* audit already knows how to read (H1, opening prose, image slot).
|
|
75
|
+
*
|
|
76
|
+
* A `text` section carries title + body so we do not invent a phantom hero
|
|
77
|
+
* image slot. The featured image is a separate `feature` section, and only
|
|
78
|
+
* when one is set — otherwise `imageSlots` would count an empty media field
|
|
79
|
+
* and fail alt-text for an image that is not on the page.
|
|
80
|
+
*/
|
|
81
|
+
export function withPostAuditSlots(sections: PageSection[], post?: PostAuditExtras): PageSection[] {
|
|
82
|
+
if (!post) return sections
|
|
83
|
+
|
|
84
|
+
const extras: PageSection[] = []
|
|
85
|
+
const title = post.title?.trim() ?? ''
|
|
86
|
+
const body = post.bodyHtml ? stripHtml(post.bodyHtml) : ''
|
|
87
|
+
if (title || body) {
|
|
88
|
+
extras.push({
|
|
89
|
+
id: POST_TITLE_SECTION_ID,
|
|
90
|
+
sectionType: 'text',
|
|
91
|
+
name: 'Post',
|
|
92
|
+
visible: true,
|
|
93
|
+
content: { heading: title, body },
|
|
94
|
+
settings: {},
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
const image = post.featuredImage?.trim() ?? ''
|
|
98
|
+
if (image) {
|
|
99
|
+
extras.push({
|
|
100
|
+
id: POST_IMAGE_SECTION_ID,
|
|
101
|
+
sectionType: 'feature',
|
|
102
|
+
name: 'Featured image',
|
|
103
|
+
visible: true,
|
|
104
|
+
content: { imageUrl: image, imageAlt: '' },
|
|
105
|
+
settings: {},
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
return extras.length ? [...extras, ...sections] : sections
|
|
44
109
|
}
|
|
45
110
|
|
|
46
111
|
export function buildPageAuditInput({
|
|
@@ -49,6 +114,7 @@ export function buildPageAuditInput({
|
|
|
49
114
|
slug,
|
|
50
115
|
record,
|
|
51
116
|
fallbackTitle,
|
|
117
|
+
post,
|
|
52
118
|
}: BuildAuditInputArgs): PageAuditInput {
|
|
53
119
|
// Measure the title a searcher actually sees — the page meta title with the
|
|
54
120
|
// site's template applied — not the raw field. Checking the raw field would
|
|
@@ -65,7 +131,7 @@ export function buildPageAuditInput({
|
|
|
65
131
|
: meta.metaTitle.trim() || fallbackTitle || ''
|
|
66
132
|
|
|
67
133
|
return {
|
|
68
|
-
sections,
|
|
134
|
+
sections: withPostAuditSlots(sections, post),
|
|
69
135
|
seo: {
|
|
70
136
|
title: renderedTitle,
|
|
71
137
|
description: meta.metaDescription,
|
|
@@ -35,6 +35,11 @@ export interface PageVersion {
|
|
|
35
35
|
author: VersionAuthor
|
|
36
36
|
/** Full page content as of this version — what the canvas renders. */
|
|
37
37
|
sections: PageSection[]
|
|
38
|
+
/** Post identity fields, when the snapshot is a post. */
|
|
39
|
+
title?: string
|
|
40
|
+
excerpt?: string
|
|
41
|
+
featuredImage?: string
|
|
42
|
+
body?: string
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
/** Versions sharing a calendar day, newest day first. */
|
|
@@ -87,6 +92,20 @@ function toAuthor(row: VersionApiRow): VersionAuthor {
|
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
function optionalString(value: unknown): string | undefined {
|
|
96
|
+
return typeof value === 'string' ? value : undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function optionalFeaturedImage(value: unknown): string | undefined {
|
|
100
|
+
if (typeof value === 'string') return value
|
|
101
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
102
|
+
const obj = value as Record<string, unknown>
|
|
103
|
+
const url = obj.url ?? obj.publicUrl ?? obj.src
|
|
104
|
+
if (typeof url === 'string') return url
|
|
105
|
+
}
|
|
106
|
+
return undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
90
109
|
export function toPageVersion(row: VersionApiRow): PageVersion {
|
|
91
110
|
const data = row.data ?? {}
|
|
92
111
|
return {
|
|
@@ -98,6 +117,10 @@ export function toPageVersion(row: VersionApiRow): PageVersion {
|
|
|
98
117
|
// snapshot more strictly than the current page would make an unmanaged
|
|
99
118
|
// section look like it was deleted in every version that contains one.
|
|
100
119
|
sections: coerceSections(data.sections, { onUnknown: 'preserve' }),
|
|
120
|
+
title: optionalString(data.title),
|
|
121
|
+
excerpt: optionalString(data.excerpt),
|
|
122
|
+
featuredImage: optionalFeaturedImage(data.featuredImage),
|
|
123
|
+
body: optionalString(data.body),
|
|
101
124
|
}
|
|
102
125
|
}
|
|
103
126
|
|
|
@@ -231,8 +231,8 @@ export function EditorTopBar({
|
|
|
231
231
|
<button
|
|
232
232
|
type="button"
|
|
233
233
|
onClick={onOpenSettings}
|
|
234
|
-
aria-label=
|
|
235
|
-
title=
|
|
234
|
+
aria-label={backLabel === 'Posts' ? 'Post settings' : 'Page settings'}
|
|
235
|
+
title={backLabel === 'Posts' ? 'Post settings' : 'Page settings'}
|
|
236
236
|
className="prenta-touch-target text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg p-2"
|
|
237
237
|
>
|
|
238
238
|
<Settings2 className="h-4 w-4" aria-hidden />
|
|
@@ -24,6 +24,19 @@ interface PostEditorCanvasProps {
|
|
|
24
24
|
onAddSection?: () => void
|
|
25
25
|
/** Section types rendered as placeholders — shows the "Structure view" notice. */
|
|
26
26
|
structuralTypes?: string[]
|
|
27
|
+
/**
|
|
28
|
+
* Read-only render of a past version. Suppresses selection and the editing
|
|
29
|
+
* affordances, and turns on the changed-section outlines.
|
|
30
|
+
*/
|
|
31
|
+
reviewing?: boolean
|
|
32
|
+
/** Sections that differ from the version before the one being reviewed. */
|
|
33
|
+
changedSectionIds?: ReadonlySet<string>
|
|
34
|
+
/**
|
|
35
|
+
* Rendered in place of the editing tip / structure notice while reviewing.
|
|
36
|
+
* Passed in rather than built here because the review bar's actions belong
|
|
37
|
+
* to the editor's history state, which the canvas knows nothing about.
|
|
38
|
+
*/
|
|
39
|
+
reviewBar?: React.ReactNode
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
/**
|
|
@@ -51,6 +64,9 @@ export function PostEditorCanvas({
|
|
|
51
64
|
onCanvasElement,
|
|
52
65
|
onAddSection,
|
|
53
66
|
structuralTypes = [],
|
|
67
|
+
reviewing = false,
|
|
68
|
+
changedSectionIds,
|
|
69
|
+
reviewBar,
|
|
54
70
|
}: PostEditorCanvasProps) {
|
|
55
71
|
const registerRef = useSectionRefs(selectedId)
|
|
56
72
|
|
|
@@ -62,15 +78,19 @@ export function PostEditorCanvas({
|
|
|
62
78
|
onScaleChange={onScaleChange}
|
|
63
79
|
onCanvasElement={onCanvasElement}
|
|
64
80
|
chrome={
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
reviewing ? (
|
|
82
|
+
reviewBar
|
|
83
|
+
) : (
|
|
84
|
+
<>
|
|
85
|
+
{sections.length > 0 && <CanvasEditTip />}
|
|
86
|
+
<StructureNotice structuralTypes={structuralTypes} />
|
|
87
|
+
</>
|
|
88
|
+
)
|
|
69
89
|
}
|
|
70
90
|
>
|
|
71
91
|
<PostHeader config={header} context={context} />
|
|
72
92
|
{sections.length === 0 ? (
|
|
73
|
-
<EmptyBody onAddSection={onAddSection} />
|
|
93
|
+
<EmptyBody onAddSection={reviewing ? undefined : onAddSection} />
|
|
74
94
|
) : (
|
|
75
95
|
sections.map((section) => (
|
|
76
96
|
<SectionRenderer
|
|
@@ -78,6 +98,8 @@ export function PostEditorCanvas({
|
|
|
78
98
|
section={section}
|
|
79
99
|
context={context}
|
|
80
100
|
editable
|
|
101
|
+
reviewing={reviewing}
|
|
102
|
+
changed={changedSectionIds?.has(section.id) ?? false}
|
|
81
103
|
selected={section.id === selectedId}
|
|
82
104
|
onSelect={onSelect}
|
|
83
105
|
registerRef={registerRef}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Loader2, Sparkles } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import { normalizeSlug, validateSlug } from '@prenta/core/client'
|
|
7
|
+
import { MediaPickerModal } from '../../components/MediaPickerModal.js'
|
|
8
|
+
import { TagInput } from '../../components/TagInput.js'
|
|
9
|
+
import { coauthorText } from '../../lib/coauthor-client.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The slice of a collection's field config the form reads to pick controls:
|
|
13
|
+
* a `select` category renders a dropdown (structured vocabulary), an `array`
|
|
14
|
+
* tags field renders the chip input with autocomplete.
|
|
15
|
+
*/
|
|
16
|
+
export interface PostFieldConfig {
|
|
17
|
+
type?: string
|
|
18
|
+
options?: Array<{ label: string; value: string }>
|
|
19
|
+
maxRows?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PostFieldsFormValues {
|
|
23
|
+
title: string
|
|
24
|
+
slug: string
|
|
25
|
+
excerpt: string
|
|
26
|
+
featuredImage: string
|
|
27
|
+
category: string
|
|
28
|
+
tags: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PostFieldsFormProps {
|
|
32
|
+
values: PostFieldsFormValues
|
|
33
|
+
canEdit: boolean
|
|
34
|
+
/** Field definitions from the post type's collection config. */
|
|
35
|
+
fields?: Record<string, PostFieldConfig | undefined>
|
|
36
|
+
postType: string
|
|
37
|
+
/** HTML body, used as AI excerpt context. */
|
|
38
|
+
bodyHtml?: string
|
|
39
|
+
slugTouched: boolean
|
|
40
|
+
onSlugTouched: () => void
|
|
41
|
+
onChange: (patch: Partial<PostFieldsFormValues>) => void
|
|
42
|
+
titleRef?: React.Ref<HTMLInputElement>
|
|
43
|
+
/** Prefix for input ids so the modal and the rail tab can coexist. */
|
|
44
|
+
idPrefix?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const LABEL = 'text-foreground mb-1 block text-xs font-medium'
|
|
48
|
+
/** Match TipTap body editor borders — `border-input` is transparent in theme.css. */
|
|
49
|
+
const INPUT =
|
|
50
|
+
'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'
|
|
51
|
+
|
|
52
|
+
const slugify = normalizeSlug
|
|
53
|
+
|
|
54
|
+
function stripHtml(html: string): string {
|
|
55
|
+
return html
|
|
56
|
+
.replace(/<[^>]*>/g, ' ')
|
|
57
|
+
.replace(/\s+/g, ' ')
|
|
58
|
+
.trim()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildContentContext(title: string, body: string, excerpt: string): string {
|
|
62
|
+
const plain = stripHtml(body)
|
|
63
|
+
const parts = [title.trim(), excerpt.trim(), plain].filter(Boolean)
|
|
64
|
+
return parts.join('\n\n')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function AiFieldButton({
|
|
68
|
+
label,
|
|
69
|
+
busy,
|
|
70
|
+
disabled,
|
|
71
|
+
onClick,
|
|
72
|
+
}: {
|
|
73
|
+
label: string
|
|
74
|
+
busy: boolean
|
|
75
|
+
disabled?: boolean
|
|
76
|
+
onClick: () => void
|
|
77
|
+
}) {
|
|
78
|
+
return (
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
aria-label={label}
|
|
82
|
+
title={label}
|
|
83
|
+
disabled={disabled || busy}
|
|
84
|
+
onClick={onClick}
|
|
85
|
+
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"
|
|
86
|
+
>
|
|
87
|
+
{busy ? (
|
|
88
|
+
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
|
|
89
|
+
) : (
|
|
90
|
+
<Sparkles className="h-3 w-3" aria-hidden />
|
|
91
|
+
)}
|
|
92
|
+
{busy ? 'Generating…' : 'AI'}
|
|
93
|
+
</button>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Title, slug, excerpt, featured image, category, and tags. SEO lives in the SEO tab. */
|
|
98
|
+
export function PostFieldsForm({
|
|
99
|
+
values,
|
|
100
|
+
canEdit,
|
|
101
|
+
fields,
|
|
102
|
+
postType,
|
|
103
|
+
bodyHtml = '',
|
|
104
|
+
slugTouched,
|
|
105
|
+
onSlugTouched,
|
|
106
|
+
onChange,
|
|
107
|
+
titleRef,
|
|
108
|
+
idPrefix = 'pf',
|
|
109
|
+
}: PostFieldsFormProps) {
|
|
110
|
+
const [mediaOpen, setMediaOpen] = useState(false)
|
|
111
|
+
const [aiBusy, setAiBusy] = useState(false)
|
|
112
|
+
|
|
113
|
+
const categoryField = fields?.category
|
|
114
|
+
const categoryOptions =
|
|
115
|
+
categoryField?.type === 'select' && Array.isArray(categoryField.options)
|
|
116
|
+
? categoryField.options
|
|
117
|
+
: null
|
|
118
|
+
const tagsField = fields?.tags
|
|
119
|
+
const hasTags = tagsField?.type === 'array'
|
|
120
|
+
const slugError = validateSlug(values.slug)
|
|
121
|
+
|
|
122
|
+
function handleTitle(next: string) {
|
|
123
|
+
const patch: Partial<PostFieldsFormValues> = { title: next }
|
|
124
|
+
if (!slugTouched) patch.slug = slugify(next)
|
|
125
|
+
onChange(patch)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function handleAiGenerateExcerpt() {
|
|
129
|
+
const context = buildContentContext(values.title, bodyHtml, values.excerpt)
|
|
130
|
+
if (!context.trim()) {
|
|
131
|
+
toast.error(
|
|
132
|
+
'Add a title or write body content in the Article Body section before generating with AI.',
|
|
133
|
+
)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setAiBusy(true)
|
|
138
|
+
try {
|
|
139
|
+
const outcome = await coauthorText('compress', context, {
|
|
140
|
+
instructions:
|
|
141
|
+
'Write a 1–2 sentence excerpt for blog listings and cards. Return only the excerpt text.',
|
|
142
|
+
targetLength: 45,
|
|
143
|
+
})
|
|
144
|
+
if (!outcome.ok) {
|
|
145
|
+
toast.error(outcome.error)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
onChange({ excerpt: outcome.result.text.trim() })
|
|
149
|
+
toast.success('Excerpt generated — review before saving.')
|
|
150
|
+
} catch {
|
|
151
|
+
toast.error('AI request failed.')
|
|
152
|
+
} finally {
|
|
153
|
+
setAiBusy(false)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const titleId = `${idPrefix}-title`
|
|
158
|
+
const slugId = `${idPrefix}-slug`
|
|
159
|
+
const categoryId = `${idPrefix}-category`
|
|
160
|
+
const tagsId = `${idPrefix}-tags`
|
|
161
|
+
const excerptId = `${idPrefix}-excerpt`
|
|
162
|
+
const imageId = `${idPrefix}-image`
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<>
|
|
166
|
+
<div className="space-y-3">
|
|
167
|
+
<div>
|
|
168
|
+
<label className={LABEL} htmlFor={titleId}>
|
|
169
|
+
Title <span className="text-destructive">*</span>
|
|
170
|
+
</label>
|
|
171
|
+
<input
|
|
172
|
+
id={titleId}
|
|
173
|
+
ref={titleRef}
|
|
174
|
+
className={INPUT}
|
|
175
|
+
value={values.title}
|
|
176
|
+
disabled={!canEdit}
|
|
177
|
+
placeholder="e.g. Announcing our new product"
|
|
178
|
+
onChange={(e) => handleTitle(e.target.value)}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div className="grid grid-cols-2 gap-3">
|
|
183
|
+
<div>
|
|
184
|
+
<label className={LABEL} htmlFor={slugId}>
|
|
185
|
+
Slug <span className="text-destructive">*</span>
|
|
186
|
+
</label>
|
|
187
|
+
<input
|
|
188
|
+
id={slugId}
|
|
189
|
+
className={INPUT}
|
|
190
|
+
value={values.slug}
|
|
191
|
+
disabled={!canEdit}
|
|
192
|
+
placeholder={slugTouched ? undefined : 'Generated from title…'}
|
|
193
|
+
aria-invalid={Boolean(slugError)}
|
|
194
|
+
aria-describedby={slugError ? `${slugId}-error` : undefined}
|
|
195
|
+
onChange={(e) => {
|
|
196
|
+
onSlugTouched()
|
|
197
|
+
onChange({ slug: slugify(e.target.value) })
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
{slugError && (
|
|
201
|
+
<p id={`${slugId}-error`} className="text-destructive mt-1 text-xs">
|
|
202
|
+
{slugError}
|
|
203
|
+
</p>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div>
|
|
208
|
+
<label className={LABEL} htmlFor={categoryId}>
|
|
209
|
+
Category
|
|
210
|
+
</label>
|
|
211
|
+
{categoryOptions ? (
|
|
212
|
+
<select
|
|
213
|
+
id={categoryId}
|
|
214
|
+
className={INPUT}
|
|
215
|
+
value={values.category}
|
|
216
|
+
disabled={!canEdit}
|
|
217
|
+
onChange={(e) => onChange({ category: e.target.value })}
|
|
218
|
+
>
|
|
219
|
+
<option value="">No category</option>
|
|
220
|
+
{categoryOptions.map((o) => (
|
|
221
|
+
<option key={o.value} value={o.value}>
|
|
222
|
+
{o.label}
|
|
223
|
+
</option>
|
|
224
|
+
))}
|
|
225
|
+
{values.category && !categoryOptions.some((o) => o.value === values.category) && (
|
|
226
|
+
<option value={values.category}>{values.category}</option>
|
|
227
|
+
)}
|
|
228
|
+
</select>
|
|
229
|
+
) : (
|
|
230
|
+
<input
|
|
231
|
+
id={categoryId}
|
|
232
|
+
className={INPUT}
|
|
233
|
+
value={values.category}
|
|
234
|
+
disabled={!canEdit}
|
|
235
|
+
placeholder="e.g. Product"
|
|
236
|
+
onChange={(e) => onChange({ category: e.target.value })}
|
|
237
|
+
/>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{hasTags && (
|
|
243
|
+
<div>
|
|
244
|
+
<label className={LABEL} htmlFor={tagsId}>
|
|
245
|
+
Tags
|
|
246
|
+
</label>
|
|
247
|
+
<TagInput
|
|
248
|
+
id={tagsId}
|
|
249
|
+
value={values.tags}
|
|
250
|
+
onChange={(tags) => onChange({ tags })}
|
|
251
|
+
collection={postType}
|
|
252
|
+
field="tags"
|
|
253
|
+
disabled={!canEdit}
|
|
254
|
+
maxTags={tagsField?.maxRows}
|
|
255
|
+
/>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
<div>
|
|
260
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
261
|
+
<label className={`${LABEL} mb-0`} htmlFor={excerptId}>
|
|
262
|
+
Excerpt
|
|
263
|
+
</label>
|
|
264
|
+
<AiFieldButton
|
|
265
|
+
label="Generate excerpt from content"
|
|
266
|
+
busy={aiBusy}
|
|
267
|
+
disabled={!canEdit}
|
|
268
|
+
onClick={() => void handleAiGenerateExcerpt()}
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
<textarea
|
|
272
|
+
id={excerptId}
|
|
273
|
+
className={`${INPUT} min-h-[72px] resize-y`}
|
|
274
|
+
value={values.excerpt}
|
|
275
|
+
disabled={!canEdit}
|
|
276
|
+
placeholder="A short summary shown in listings and the header."
|
|
277
|
+
onChange={(e) => onChange({ excerpt: e.target.value })}
|
|
278
|
+
/>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<div>
|
|
282
|
+
<label className={LABEL} htmlFor={imageId}>
|
|
283
|
+
Featured image
|
|
284
|
+
</label>
|
|
285
|
+
<div className="flex gap-2">
|
|
286
|
+
<input
|
|
287
|
+
id={imageId}
|
|
288
|
+
className={`${INPUT} min-w-0 flex-1`}
|
|
289
|
+
value={values.featuredImage}
|
|
290
|
+
disabled={!canEdit}
|
|
291
|
+
placeholder="https://… or pick from the media library"
|
|
292
|
+
onChange={(e) => onChange({ featuredImage: e.target.value })}
|
|
293
|
+
/>
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
disabled={!canEdit}
|
|
297
|
+
onClick={() => setMediaOpen(true)}
|
|
298
|
+
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"
|
|
299
|
+
>
|
|
300
|
+
Browse
|
|
301
|
+
</button>
|
|
302
|
+
</div>
|
|
303
|
+
{values.featuredImage && (
|
|
304
|
+
<div className="border-border bg-muted mt-2 overflow-hidden rounded-md border">
|
|
305
|
+
<img src={values.featuredImage} alt="" className="h-28 w-full object-cover" />
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<MediaPickerModal
|
|
312
|
+
open={mediaOpen}
|
|
313
|
+
onClose={() => setMediaOpen(false)}
|
|
314
|
+
onSelect={(url) => {
|
|
315
|
+
onChange({ featuredImage: url })
|
|
316
|
+
setMediaOpen(false)
|
|
317
|
+
}}
|
|
318
|
+
accept="image/*"
|
|
319
|
+
/>
|
|
320
|
+
</>
|
|
321
|
+
)
|
|
322
|
+
}
|