@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/__tests__/lib/page-audit-input.test.js +81 -0
  3. package/dist/__tests__/lib/page-audit-input.test.js.map +1 -1
  4. package/dist/__tests__/lib/version-history.test.js +18 -0
  5. package/dist/__tests__/lib/version-history.test.js.map +1 -1
  6. package/dist/__tests__/no-server-barrel-imports.test.js +3 -4
  7. package/dist/__tests__/no-server-barrel-imports.test.js.map +1 -1
  8. package/dist/__tests__/views/editor-top-bar.render.test.js +5 -0
  9. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  10. package/dist/__tests__/views/post-section-editor.test.js +38 -0
  11. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  12. package/dist/__tests__/views/post-tab.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/post-tab.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/post-tab.render.test.js +86 -0
  15. package/dist/__tests__/views/post-tab.render.test.js.map +1 -0
  16. package/dist/components/seo/SeoEditorPane.d.ts +9 -1
  17. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  18. package/dist/components/seo/SeoEditorPane.js +5 -2
  19. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  20. package/dist/hooks/usePageAudit.d.ts +4 -1
  21. package/dist/hooks/usePageAudit.d.ts.map +1 -1
  22. package/dist/hooks/usePageAudit.js +3 -3
  23. package/dist/hooks/usePageAudit.js.map +1 -1
  24. package/dist/lib/page-audit-input.d.ts +26 -1
  25. package/dist/lib/page-audit-input.d.ts.map +1 -1
  26. package/dist/lib/page-audit-input.js +48 -2
  27. package/dist/lib/page-audit-input.js.map +1 -1
  28. package/dist/lib/version-history.d.ts +5 -0
  29. package/dist/lib/version-history.d.ts.map +1 -1
  30. package/dist/lib/version-history.js +18 -0
  31. package/dist/lib/version-history.js.map +1 -1
  32. package/dist/views/page-editor/EditorTopBar.js +1 -1
  33. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  34. package/dist/views/post-editor/PostEditorCanvas.d.ts +14 -1
  35. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  36. package/dist/views/post-editor/PostEditorCanvas.js +2 -2
  37. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  38. package/dist/views/post-editor/PostFieldsForm.d.ts +39 -0
  39. package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -0
  40. package/dist/views/post-editor/PostFieldsForm.js +85 -0
  41. package/dist/views/post-editor/PostFieldsForm.js.map +1 -0
  42. package/dist/views/post-editor/PostFieldsModal.d.ts +2 -14
  43. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  44. package/dist/views/post-editor/PostFieldsModal.js +20 -81
  45. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  46. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  47. package/dist/views/post-editor/PostSectionEditor.js +122 -34
  48. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  49. package/dist/views/post-editor/PostTab.d.ts +17 -0
  50. package/dist/views/post-editor/PostTab.d.ts.map +1 -0
  51. package/dist/views/post-editor/PostTab.js +23 -0
  52. package/dist/views/post-editor/PostTab.js.map +1 -0
  53. package/package.json +6 -6
  54. package/src/__tests__/lib/page-audit-input.test.ts +99 -0
  55. package/src/__tests__/lib/version-history.test.ts +20 -0
  56. package/src/__tests__/no-server-barrel-imports.test.ts +3 -4
  57. package/src/__tests__/views/editor-top-bar.render.test.tsx +6 -0
  58. package/src/__tests__/views/post-section-editor.test.tsx +59 -0
  59. package/src/__tests__/views/post-tab.render.test.tsx +116 -0
  60. package/src/components/seo/SeoEditorPane.tsx +16 -1
  61. package/src/hooks/usePageAudit.ts +9 -2
  62. package/src/lib/page-audit-input.ts +67 -1
  63. package/src/lib/version-history.ts +23 -0
  64. package/src/views/page-editor/EditorTopBar.tsx +2 -2
  65. package/src/views/post-editor/PostEditorCanvas.tsx +27 -5
  66. package/src/views/post-editor/PostFieldsForm.tsx +322 -0
  67. package/src/views/post-editor/PostFieldsModal.tsx +30 -281
  68. package/src/views/post-editor/PostSectionEditor.tsx +204 -59
  69. 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 { Loader2, Sparkles } from 'lucide-react'
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
- const current = snapshotFromFields({
171
- title,
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 handleTitle(next: string) {
190
- setTitle(next)
191
- if (!slugTouched) setSlug(slugify(next))
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
- <div className="space-y-3">
256
- <div>
257
- <label className={LABEL} htmlFor="pf-title">
258
- Title <span className="text-destructive">*</span>
259
- </label>
260
- <input
261
- id="pf-title"
262
- ref={titleRef}
263
- className={INPUT}
264
- value={title}
265
- disabled={!canEdit}
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)}