@actuate-media/cms-admin 0.75.0 → 0.76.1

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 (76) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/__tests__/layout/sidebar-plugin-nav.test.d.ts +2 -0
  3. package/dist/__tests__/layout/sidebar-plugin-nav.test.d.ts.map +1 -0
  4. package/dist/__tests__/layout/sidebar-plugin-nav.test.js +46 -0
  5. package/dist/__tests__/layout/sidebar-plugin-nav.test.js.map +1 -0
  6. package/dist/__tests__/lib/post-editor-service.test.js +2 -0
  7. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  8. package/dist/__tests__/router/navigation-blocker.test.d.ts +2 -0
  9. package/dist/__tests__/router/navigation-blocker.test.d.ts.map +1 -0
  10. package/dist/__tests__/router/navigation-blocker.test.js +69 -0
  11. package/dist/__tests__/router/navigation-blocker.test.js.map +1 -0
  12. package/dist/__tests__/views/article-body-section.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/article-body-section.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/article-body-section.render.test.js +24 -0
  15. package/dist/__tests__/views/article-body-section.render.test.js.map +1 -0
  16. package/dist/__tests__/views/editor-top-bar.render.test.js +12 -0
  17. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  18. package/dist/__tests__/views/post-fields-modal.render.test.js +2 -0
  19. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  20. package/dist/actuate-admin.css +1 -1
  21. package/dist/layout/Sidebar.d.ts.map +1 -1
  22. package/dist/layout/Sidebar.js +17 -0
  23. package/dist/layout/Sidebar.js.map +1 -1
  24. package/dist/lib/page-editor-service.d.ts +2 -0
  25. package/dist/lib/page-editor-service.d.ts.map +1 -1
  26. package/dist/lib/page-editor-service.js +4 -0
  27. package/dist/lib/page-editor-service.js.map +1 -1
  28. package/dist/lib/post-editor-service.d.ts +3 -0
  29. package/dist/lib/post-editor-service.d.ts.map +1 -1
  30. package/dist/lib/post-editor-service.js +4 -0
  31. package/dist/lib/post-editor-service.js.map +1 -1
  32. package/dist/router/index.d.ts +13 -0
  33. package/dist/router/index.d.ts.map +1 -1
  34. package/dist/router/index.js +49 -0
  35. package/dist/router/index.js.map +1 -1
  36. package/dist/views/DocumentEdit.d.ts.map +1 -1
  37. package/dist/views/DocumentEdit.js +4 -9
  38. package/dist/views/DocumentEdit.js.map +1 -1
  39. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  40. package/dist/views/page-builder/PageBuilder.js +3 -0
  41. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  42. package/dist/views/page-editor/EditorTopBar.d.ts +7 -1
  43. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  44. package/dist/views/page-editor/EditorTopBar.js +35 -4
  45. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  46. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  47. package/dist/views/page-editor/PageSectionEditor.js +38 -13
  48. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  49. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
  50. package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
  51. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  52. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  53. package/dist/views/post-editor/PostSectionEditor.js +38 -12
  54. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  55. package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -1
  56. package/dist/views/post-editor/PostTemplateEditor.js +3 -10
  57. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
  58. package/package.json +4 -3
  59. package/src/__tests__/layout/sidebar-plugin-nav.test.ts +50 -0
  60. package/src/__tests__/lib/post-editor-service.test.ts +2 -0
  61. package/src/__tests__/router/navigation-blocker.test.ts +80 -0
  62. package/src/__tests__/views/article-body-section.render.test.tsx +32 -0
  63. package/src/__tests__/views/editor-top-bar.render.test.tsx +24 -0
  64. package/src/__tests__/views/post-fields-modal.render.test.tsx +2 -0
  65. package/src/layout/Sidebar.tsx +19 -0
  66. package/src/lib/page-editor-service.ts +8 -0
  67. package/src/lib/post-editor-service.ts +8 -0
  68. package/src/router/index.ts +61 -0
  69. package/src/styles/theme.css +91 -0
  70. package/src/views/DocumentEdit.tsx +4 -8
  71. package/src/views/page-builder/PageBuilder.tsx +4 -0
  72. package/src/views/page-editor/EditorTopBar.tsx +69 -0
  73. package/src/views/page-editor/PageSectionEditor.tsx +58 -10
  74. package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -4
  75. package/src/views/post-editor/PostSectionEditor.tsx +58 -10
  76. package/src/views/post-editor/PostTemplateEditor.tsx +3 -9
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
+ Calendar,
4
5
  Check,
5
6
  ExternalLink,
6
7
  Eye,
@@ -65,6 +66,12 @@ interface EditorTopBarProps {
65
66
  seoScore?: number
66
67
  onSaveDraft: () => void
67
68
  onPublish: () => void
69
+ /** Open the schedule-publish dialog. Omitted when the document is unsaved. */
70
+ onSchedule?: () => void
71
+ /** When false, Schedule stays disabled (e.g. unsaved document). Defaults to true. */
72
+ scheduleReady?: boolean
73
+ scheduledAt?: string | null
74
+ scheduledUnpublishAt?: string | null
68
75
  /**
69
76
  * Section types the canvas renders as placeholders (custom / unmanaged).
70
77
  * When non-empty the editor is in "structure" mode: the eye preview is
@@ -113,6 +120,10 @@ export function EditorTopBar({
113
120
  seoScore = 0,
114
121
  onSaveDraft,
115
122
  onPublish,
123
+ onSchedule,
124
+ scheduleReady = true,
125
+ scheduledAt = null,
126
+ scheduledUnpublishAt = null,
116
127
  structuralTypes = [],
117
128
  }: EditorTopBarProps) {
118
129
  const statusInfo = STATUS_TEXT[status]
@@ -135,6 +146,26 @@ export function EditorTopBar({
135
146
  ? 'You do not have permission to publish'
136
147
  : undefined
137
148
 
149
+ const scheduleBlocked =
150
+ !onSchedule ||
151
+ !canPublish ||
152
+ !publishReady ||
153
+ !scheduleReady ||
154
+ publishing ||
155
+ saveState === 'saving'
156
+ const scheduleTitle =
157
+ saveState === 'saving'
158
+ ? 'Wait for the current save to finish'
159
+ : !scheduleReady
160
+ ? 'Save before scheduling'
161
+ : canPublish && !publishReady && publishDisabledReason
162
+ ? publishDisabledReason
163
+ : !canPublish
164
+ ? 'You do not have permission to schedule'
165
+ : scheduledAt
166
+ ? `Reschedule — currently publishes ${formatScheduleLabel(scheduledAt)}`
167
+ : 'Schedule a future publish date'
168
+
138
169
  return (
139
170
  <header className="bg-card border-border flex h-14 shrink-0 items-center justify-between gap-4 border-b px-4">
140
171
  {/* Breadcrumb + status */}
@@ -158,6 +189,18 @@ export function EditorTopBar({
158
189
  >
159
190
  {statusInfo.label}
160
191
  </span>
192
+ {scheduledAt && (
193
+ <span
194
+ className="text-muted-foreground ml-1 hidden shrink-0 text-xs sm:inline"
195
+ title={
196
+ scheduledUnpublishAt
197
+ ? `Publishes ${formatScheduleLabel(scheduledAt)} · Unpublishes ${formatScheduleLabel(scheduledUnpublishAt)}`
198
+ : `Publishes ${formatScheduleLabel(scheduledAt)}`
199
+ }
200
+ >
201
+ · {formatScheduleLabel(scheduledAt)}
202
+ </span>
203
+ )}
161
204
  <SaveStatus state={saveState} dirty={dirty} />
162
205
  </div>
163
206
 
@@ -294,6 +337,20 @@ export function EditorTopBar({
294
337
  {saveState === 'saving' ? 'Saving…' : 'Save Draft'}
295
338
  </button>
296
339
 
340
+ {onSchedule && (
341
+ <button
342
+ type="button"
343
+ onClick={onSchedule}
344
+ disabled={scheduleBlocked}
345
+ title={scheduleTitle}
346
+ aria-label={scheduledAt ? 'Reschedule publish' : 'Schedule publish'}
347
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
348
+ >
349
+ <Calendar className="h-3.5 w-3.5" aria-hidden />
350
+ <span className="hidden sm:inline">{scheduledAt ? 'Reschedule' : 'Schedule'}</span>
351
+ </button>
352
+ )}
353
+
297
354
  <button
298
355
  type="button"
299
356
  onClick={onPublish}
@@ -353,3 +410,15 @@ function SaveStatus({ state, dirty }: { state: SaveState; dirty: boolean }) {
353
410
  }
354
411
  return null
355
412
  }
413
+
414
+ function formatScheduleLabel(iso: string): string {
415
+ const d = new Date(iso)
416
+ if (Number.isNaN(d.getTime())) return iso
417
+ return d.toLocaleString(undefined, {
418
+ month: 'short',
419
+ day: 'numeric',
420
+ year: 'numeric',
421
+ hour: 'numeric',
422
+ minute: '2-digit',
423
+ })
424
+ }
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
4
4
  import { AlertTriangle, Blocks, Loader2, Lock } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
7
+ import { useNavigationBlocker } from '../../router/index.js'
8
+ import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js'
7
9
  import { EditorSeoAside } from '../../components/EditorSeoAside.js'
8
10
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
9
11
  import { VersionHistory } from '../../components/VersionHistory.js'
@@ -16,6 +18,7 @@ import {
16
18
  missingPageFields,
17
19
  moveSection,
18
20
  publishPage,
21
+ PAGES_COLLECTION,
19
22
  removeSection,
20
23
  reorderSections,
21
24
  savePageDraft,
@@ -103,6 +106,7 @@ export function PageSectionEditor({
103
106
  const [historyOpen, setHistoryOpen] = useState(false)
104
107
  const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
105
108
  const [confirmPublish, setConfirmPublish] = useState(false)
109
+ const [scheduleOpen, setScheduleOpen] = useState(false)
106
110
  const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
107
111
  const [validation, setValidation] = useState<ValidationResult | null>(null)
108
112
 
@@ -187,16 +191,8 @@ export function PageSectionEditor({
187
191
  }
188
192
  }, [documentId, initialParentId, schemaError])
189
193
 
190
- // ─── Unsaved-changes guard (browser unload) ──────────────────────────
191
- useEffect(() => {
192
- if (!dirty) return
193
- function onBeforeUnload(e: BeforeUnloadEvent) {
194
- e.preventDefault()
195
- e.returnValue = ''
196
- }
197
- window.addEventListener('beforeunload', onBeforeUnload)
198
- return () => window.removeEventListener('beforeunload', onBeforeUnload)
199
- }, [dirty])
194
+ // ─── Unsaved-changes guard (client-side navigation + browser unload) ──
195
+ useNavigationBlocker(dirty)
200
196
 
201
197
  // ─── Section mutations ───────────────────────────────────────────────
202
198
  const mutateSections = useCallback(
@@ -324,6 +320,42 @@ export function PageSectionEditor({
324
320
  setConfirmPublish(true)
325
321
  }, [page])
326
322
 
323
+ const requestSchedule = useCallback(async () => {
324
+ if (!page) return
325
+ if (!page.id) {
326
+ toast.error('Save the page before scheduling')
327
+ return
328
+ }
329
+ const result = validatePage(page, true)
330
+ setValidation(result.errors.length || result.warnings.length ? result : null)
331
+ if (result.errors.length > 0) {
332
+ toast.error('Resolve the listed issues before scheduling')
333
+ return
334
+ }
335
+ if (dirty) {
336
+ const ok = await handleSaveDraft()
337
+ if (!ok) return
338
+ }
339
+ setScheduleOpen(true)
340
+ }, [page, dirty, handleSaveDraft])
341
+
342
+ const handleScheduled = useCallback(
343
+ (next: { status: string; scheduledAt: string | null; scheduledUnpublishAt: string | null }) => {
344
+ setPage((prev) =>
345
+ prev
346
+ ? {
347
+ ...prev,
348
+ status: next.status as EditorPage['status'],
349
+ scheduledAt: next.scheduledAt,
350
+ scheduledUnpublishAt: next.scheduledUnpublishAt,
351
+ }
352
+ : prev,
353
+ )
354
+ setValidation(null)
355
+ },
356
+ [],
357
+ )
358
+
327
359
  const doPublish = useCallback(async () => {
328
360
  if (!page) return
329
361
  setPublishing(true)
@@ -533,6 +565,10 @@ export function PageSectionEditor({
533
565
  seoScore={liveSeoScore}
534
566
  onSaveDraft={handleSaveDraft}
535
567
  onPublish={requestPublish}
568
+ onSchedule={page.id ? requestSchedule : undefined}
569
+ scheduleReady={Boolean(page.id)}
570
+ scheduledAt={page.scheduledAt}
571
+ scheduledUnpublishAt={page.scheduledUnpublishAt}
536
572
  structuralTypes={structuralTypes}
537
573
  />
538
574
 
@@ -687,6 +723,18 @@ export function PageSectionEditor({
687
723
  confirmLabel="Publish"
688
724
  />
689
725
 
726
+ {page.id && (
727
+ <SchedulePublishDialog
728
+ collectionSlug={PAGES_COLLECTION}
729
+ documentId={page.id}
730
+ scheduledAt={page.scheduledAt}
731
+ scheduledUnpublishAt={page.scheduledUnpublishAt}
732
+ open={scheduleOpen}
733
+ onClose={() => setScheduleOpen(false)}
734
+ onScheduled={handleScheduled}
735
+ />
736
+ )}
737
+
690
738
  <ConfirmDialog
691
739
  open={leaveTarget !== null}
692
740
  onClose={() => setLeaveTarget(null)}
@@ -37,10 +37,7 @@ export function ArticleBodySection({
37
37
  </div>
38
38
  )}
39
39
  {html.trim() ? (
40
- <div
41
- className="prose prose-zinc text-foreground max-w-none text-base leading-relaxed"
42
- dangerouslySetInnerHTML={{ __html: html }}
43
- />
40
+ <div className="actuate-rich-text max-w-none" dangerouslySetInnerHTML={{ __html: html }} />
44
41
  ) : (
45
42
  <p className="text-muted-foreground text-base leading-relaxed italic">
46
43
  {source === 'field'
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
4
4
  import { AlertTriangle, Loader2, Lock } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
7
+ import { useNavigationBlocker } from '../../router/index.js'
8
+ import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js'
7
9
  import { EditorSeoAside } from '../../components/EditorSeoAside.js'
8
10
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
9
11
  import { VersionHistory } from '../../components/VersionHistory.js'
@@ -128,6 +130,7 @@ export function PostSectionEditor({
128
130
  const [historyOpen, setHistoryOpen] = useState(false)
129
131
  const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
130
132
  const [confirmPublish, setConfirmPublish] = useState(false)
133
+ const [scheduleOpen, setScheduleOpen] = useState(false)
131
134
  const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
132
135
  const [validation, setValidation] = useState<ValidationResult | null>(null)
133
136
 
@@ -225,16 +228,8 @@ export function PostSectionEditor({
225
228
  }
226
229
  }, [postType, documentId, schemaError])
227
230
 
228
- // ─── Unsaved-changes guard ───────────────────────────────────────────
229
- useEffect(() => {
230
- if (!dirty) return
231
- function onBeforeUnload(e: BeforeUnloadEvent) {
232
- e.preventDefault()
233
- e.returnValue = ''
234
- }
235
- window.addEventListener('beforeunload', onBeforeUnload)
236
- return () => window.removeEventListener('beforeunload', onBeforeUnload)
237
- }, [dirty])
231
+ // ─── Unsaved-changes guard (client-side navigation + browser unload) ──
232
+ useNavigationBlocker(dirty)
238
233
 
239
234
  const context: PostRenderContext = useMemo(
240
235
  () => ({
@@ -375,6 +370,43 @@ export function PostSectionEditor({
375
370
  setConfirmPublish(true)
376
371
  }, [post])
377
372
 
373
+ const requestSchedule = useCallback(async () => {
374
+ if (!post) return
375
+ if (!post.id) {
376
+ toast.error('Save the post before scheduling')
377
+ return
378
+ }
379
+ const result = validatePost(post, true)
380
+ setValidation(result.errors.length || result.warnings.length ? result : null)
381
+ if (result.errors.length > 0) {
382
+ toast.error('Resolve the listed issues before scheduling')
383
+ return
384
+ }
385
+ if (dirty) {
386
+ const ok = await handleSaveDraft()
387
+ if (!ok) return
388
+ }
389
+ setScheduleOpen(true)
390
+ }, [post, dirty, handleSaveDraft])
391
+
392
+ const handleScheduled = useCallback(
393
+ (next: { status: string; scheduledAt: string | null; scheduledUnpublishAt: string | null }) => {
394
+ setPost((prev) =>
395
+ prev
396
+ ? {
397
+ ...prev,
398
+ status: next.status as EditorPost['status'],
399
+ scheduledAt: next.scheduledAt,
400
+ scheduledUnpublishAt: next.scheduledUnpublishAt,
401
+ publishDate: next.scheduledAt ?? prev.publishDate,
402
+ }
403
+ : prev,
404
+ )
405
+ setValidation(null)
406
+ },
407
+ [],
408
+ )
409
+
378
410
  const doPublish = useCallback(async () => {
379
411
  if (!post) return
380
412
  setPublishing(true)
@@ -576,6 +608,10 @@ export function PostSectionEditor({
576
608
  seoScore={liveSeoScore}
577
609
  onSaveDraft={handleSaveDraft}
578
610
  onPublish={requestPublish}
611
+ onSchedule={post.id ? requestSchedule : undefined}
612
+ scheduleReady={Boolean(post.id)}
613
+ scheduledAt={post.scheduledAt}
614
+ scheduledUnpublishAt={post.scheduledUnpublishAt}
579
615
  structuralTypes={structuralTypes}
580
616
  />
581
617
 
@@ -718,6 +754,18 @@ export function PostSectionEditor({
718
754
  confirmLabel="Publish"
719
755
  />
720
756
 
757
+ {post.id && (
758
+ <SchedulePublishDialog
759
+ collectionSlug={postType}
760
+ documentId={post.id}
761
+ scheduledAt={post.scheduledAt}
762
+ scheduledUnpublishAt={post.scheduledUnpublishAt}
763
+ open={scheduleOpen}
764
+ onClose={() => setScheduleOpen(false)}
765
+ onScheduled={handleScheduled}
766
+ />
767
+ )}
768
+
721
769
  <ConfirmDialog
722
770
  open={leaveTarget !== null}
723
771
  onClose={() => setLeaveTarget(null)}
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
4
4
  import { AlertTriangle, ArrowLeft, Layout, Loader2, Save } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
7
+ import { useNavigationBlocker } from '../../router/index.js'
7
8
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
8
9
  import {
9
10
  addSection,
@@ -99,15 +100,8 @@ export function PostTemplateEditor({
99
100
  }
100
101
  }, [postType])
101
102
 
102
- useEffect(() => {
103
- if (!dirty) return
104
- function onBeforeUnload(e: BeforeUnloadEvent) {
105
- e.preventDefault()
106
- e.returnValue = ''
107
- }
108
- window.addEventListener('beforeunload', onBeforeUnload)
109
- return () => window.removeEventListener('beforeunload', onBeforeUnload)
110
- }, [dirty])
103
+ // Unsaved-changes guard (client-side navigation + browser unload).
104
+ useNavigationBlocker(dirty)
111
105
 
112
106
  const mutateSections = useCallback(
113
107
  (fn: (sections: EditorPostTemplate['sections']) => EditorPostTemplate['sections']) => {