@actuate-media/cms-admin 0.80.1 → 0.82.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.
@@ -0,0 +1,178 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Shows CMS Published vs publish-webhook delivery for a document (#677).
5
+ * Never claims the public host is "live" — only webhook notify state.
6
+ */
7
+ import { useCallback, useEffect, useState } from 'react'
8
+ import { AlertTriangle, Info, RefreshCw, RotateCcw } from 'lucide-react'
9
+ import { toast } from 'sonner'
10
+ import { cmsApi } from '../lib/api.js'
11
+
12
+ export interface PublishSyncStatus {
13
+ documentId: string
14
+ cmsStatus: string
15
+ publishedAt: string | null
16
+ publishWebhookConfigured: boolean
17
+ webhookDelivery: {
18
+ state: 'not_configured' | 'no_delivery' | 'pending' | 'retrying' | 'delivered' | 'failed'
19
+ label: string
20
+ deliveries: Array<{
21
+ id: string
22
+ status: string
23
+ endpointName: string | null
24
+ endpointUrl: string
25
+ attempts: number
26
+ maxAttempts: number
27
+ }>
28
+ }
29
+ publicHostNote: string
30
+ }
31
+
32
+ export interface PublishSyncCardProps {
33
+ documentId: string
34
+ /** When status flips to PUBLISHED, refresh sync state. */
35
+ docStatus: string
36
+ onNavigate?: (path: string) => void
37
+ }
38
+
39
+ function stateClass(state: PublishSyncStatus['webhookDelivery']['state']): string {
40
+ switch (state) {
41
+ case 'failed':
42
+ return 'border-destructive/30 bg-destructive/10 text-destructive'
43
+ case 'retrying':
44
+ case 'pending':
45
+ return 'border-border bg-accent text-accent-foreground'
46
+ case 'delivered':
47
+ return 'border-border bg-muted text-foreground'
48
+ default:
49
+ return 'border-border bg-muted text-muted-foreground'
50
+ }
51
+ }
52
+
53
+ export function PublishSyncCard({ documentId, docStatus, onNavigate }: PublishSyncCardProps) {
54
+ const [status, setStatus] = useState<PublishSyncStatus | null>(null)
55
+ const [loading, setLoading] = useState(false)
56
+ const [retryingId, setRetryingId] = useState<string | null>(null)
57
+
58
+ const load = useCallback(async () => {
59
+ if (!documentId || docStatus !== 'PUBLISHED') {
60
+ setStatus(null)
61
+ return
62
+ }
63
+ setLoading(true)
64
+ const res = await cmsApi<PublishSyncStatus>(
65
+ `/documents/${encodeURIComponent(documentId)}/publish-sync`,
66
+ )
67
+ setLoading(false)
68
+ if (res.error) {
69
+ setStatus(null)
70
+ return
71
+ }
72
+ setStatus(res.data ?? null)
73
+ }, [documentId, docStatus])
74
+
75
+ useEffect(() => {
76
+ void load()
77
+ }, [load])
78
+
79
+ if (docStatus !== 'PUBLISHED') return null
80
+
81
+ async function retry(deliveryId: string) {
82
+ setRetryingId(deliveryId)
83
+ const res = await cmsApi<{ status: string; error?: string }>(
84
+ `/webhooks/deliveries/${encodeURIComponent(deliveryId)}/retry`,
85
+ { method: 'POST' },
86
+ )
87
+ setRetryingId(null)
88
+ if (res.error) {
89
+ toast.error(res.error)
90
+ return
91
+ }
92
+ if (res.data?.status === 'success') {
93
+ toast.success('Publish webhook delivered')
94
+ } else {
95
+ toast.error(res.data?.error ?? 'Webhook delivery still failing')
96
+ }
97
+ void load()
98
+ }
99
+
100
+ const state = status?.webhookDelivery.state
101
+ const failed = status?.webhookDelivery.deliveries.filter(
102
+ (d) => d.status === 'failed' || d.status === 'retrying',
103
+ )
104
+
105
+ return (
106
+ <div className="border-border bg-card rounded-lg border p-4">
107
+ <div className="mb-3 flex items-center justify-between gap-2">
108
+ <h3 className="text-foreground font-medium">Public host sync</h3>
109
+ <button
110
+ type="button"
111
+ onClick={() => void load()}
112
+ className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs"
113
+ aria-label="Refresh publish sync status"
114
+ >
115
+ <RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
116
+ Refresh
117
+ </button>
118
+ </div>
119
+
120
+ <p className="text-foreground mb-2 text-sm">
121
+ <span className="font-medium">CMS:</span> Published
122
+ </p>
123
+
124
+ {status ? (
125
+ <>
126
+ <div
127
+ className={`mb-2 rounded-md border px-3 py-2 text-sm ${stateClass(status.webhookDelivery.state)}`}
128
+ role="status"
129
+ >
130
+ {state === 'failed' || state === 'retrying' ? (
131
+ <AlertTriangle size={16} className="mb-1 inline" aria-hidden />
132
+ ) : (
133
+ <Info size={16} className="mb-1 inline" aria-hidden />
134
+ )}{' '}
135
+ {status.webhookDelivery.label}
136
+ </div>
137
+ <p className="text-muted-foreground text-xs">{status.publicHostNote}</p>
138
+ {failed && failed.length > 0 && (
139
+ <ul className="mt-3 space-y-2">
140
+ {failed.map((d) => (
141
+ <li
142
+ key={d.id}
143
+ className="text-foreground flex items-center justify-between gap-2 text-xs"
144
+ >
145
+ <span className="truncate">
146
+ {d.endpointName || d.endpointUrl} · {d.status} ({d.attempts}/{d.maxAttempts})
147
+ </span>
148
+ <button
149
+ type="button"
150
+ onClick={() => void retry(d.id)}
151
+ disabled={retryingId === d.id}
152
+ className="border-border hover:bg-accent inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-1 disabled:opacity-50"
153
+ >
154
+ <RotateCcw size={12} />
155
+ Retry
156
+ </button>
157
+ </li>
158
+ ))}
159
+ </ul>
160
+ )}
161
+ {onNavigate && (
162
+ <button
163
+ type="button"
164
+ onClick={() => onNavigate('/settings?tab=webhooks')}
165
+ className="text-primary mt-3 text-xs font-medium hover:underline"
166
+ >
167
+ Manage publish webhooks
168
+ </button>
169
+ )}
170
+ </>
171
+ ) : (
172
+ <p className="text-muted-foreground text-sm">
173
+ {loading ? 'Checking webhook delivery…' : 'Unable to load sync status.'}
174
+ </p>
175
+ )}
176
+ </div>
177
+ )
178
+ }
@@ -1,7 +1,9 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useEffect, useState } from 'react'
4
+ import { AlertTriangle, Plus, Trash2 } from 'lucide-react'
4
5
  import { Button } from '../components/ui/Button.js'
6
+ import { cmsApi } from '../lib/api.js'
5
7
 
6
8
  interface NavNode {
7
9
  id: string
@@ -17,44 +19,135 @@ export interface NavBuilderFieldProps {
17
19
  helpText?: string
18
20
  }
19
21
 
22
+ function coerceNodes(raw: unknown): NavNode[] {
23
+ if (!Array.isArray(raw)) return []
24
+ return raw
25
+ .filter((n): n is Record<string, unknown> => typeof n === 'object' && n !== null)
26
+ .map((n) => ({
27
+ id: typeof n.id === 'string' && n.id ? n.id : crypto.randomUUID(),
28
+ label: typeof n.label === 'string' ? n.label : 'New Item',
29
+ url: typeof n.url === 'string' ? n.url : '/',
30
+ children: coerceNodes(n.children),
31
+ }))
32
+ }
33
+
34
+ function updateNode(nodes: NavNode[], id: string, patch: Partial<NavNode>): NavNode[] {
35
+ return nodes.map((n) => {
36
+ if (n.id === id) return { ...n, ...patch, children: n.children }
37
+ return { ...n, children: updateNode(n.children, id, patch) }
38
+ })
39
+ }
40
+
41
+ function removeNode(nodes: NavNode[], id: string): NavNode[] {
42
+ return nodes
43
+ .filter((n) => n.id !== id)
44
+ .map((n) => ({ ...n, children: removeNode(n.children, id) }))
45
+ }
46
+
47
+ function addChild(nodes: NavNode[], parentId: string): NavNode[] {
48
+ return nodes.map((n) => {
49
+ if (n.id === parentId) {
50
+ return {
51
+ ...n,
52
+ children: [
53
+ ...n.children,
54
+ { id: crypto.randomUUID(), label: 'New Item', url: '/', children: [] },
55
+ ],
56
+ }
57
+ }
58
+ return { ...n, children: addChild(n.children, parentId) }
59
+ })
60
+ }
61
+
20
62
  export function NavBuilderField({ label, value = [], onChange, helpText }: NavBuilderFieldProps) {
21
- function addItem() {
22
- onChange([...value, { id: crypto.randomUUID(), label: 'New Item', url: '/', children: [] }])
23
- }
63
+ const nodes = coerceNodes(value)
64
+ const [warnings, setWarnings] = useState<string[]>([])
65
+ const [checking, setChecking] = useState(false)
66
+
67
+ const treeKey = JSON.stringify(nodes)
24
68
 
25
- function removeItem(id: string) {
26
- onChange(value.filter((n) => n.id !== id))
69
+ useEffect(() => {
70
+ let cancelled = false
71
+ const timer = window.setTimeout(() => {
72
+ setChecking(true)
73
+ void cmsApi<{ warnings?: string[] }>('/navigations/validate-targets', {
74
+ method: 'POST',
75
+ body: treeKey
76
+ ? JSON.stringify({ items: JSON.parse(treeKey) as NavNode[] })
77
+ : '{"items":[]}',
78
+ }).then((res) => {
79
+ if (cancelled) return
80
+ setWarnings(Array.isArray(res.data?.warnings) ? res.data.warnings : [])
81
+ setChecking(false)
82
+ })
83
+ }, 400)
84
+ return () => {
85
+ cancelled = true
86
+ window.clearTimeout(timer)
87
+ }
88
+ }, [treeKey])
89
+
90
+ function addItem() {
91
+ onChange([...nodes, { id: crypto.randomUUID(), label: 'New Item', url: '/', children: [] }])
27
92
  }
28
93
 
29
94
  function renderNode(node: NavNode, depth: number) {
95
+ const itemWarnings = warnings.filter(
96
+ (w) => w.includes(`"${node.label}"`) || w.includes(node.url),
97
+ )
30
98
  return (
31
99
  <div
32
100
  key={node.id}
33
- className="rounded-md border border-[var(--border)] bg-[var(--card)] p-3"
101
+ className="border-border bg-card rounded-md border p-3"
34
102
  style={{ marginLeft: depth * 24 }}
35
103
  >
36
- <div className="flex items-center justify-between">
37
- <div className="flex items-center gap-2 text-sm">
38
- <svg
39
- className="h-4 w-4 text-[var(--muted-foreground)]"
40
- fill="none"
41
- viewBox="0 0 24 24"
42
- stroke="currentColor"
43
- strokeWidth={2}
104
+ <div className="flex flex-wrap items-end gap-2">
105
+ <label className="min-w-[8rem] flex-1">
106
+ <span className="text-muted-foreground mb-1 block text-sm">Label</span>
107
+ <input
108
+ type="text"
109
+ value={node.label}
110
+ onChange={(e) => onChange(updateNode(nodes, node.id, { label: e.target.value }))}
111
+ className="border-border bg-input-background text-foreground w-full rounded-md border px-2 py-1.5 text-sm"
112
+ />
113
+ </label>
114
+ <label className="min-w-[10rem] flex-[1.2]">
115
+ <span className="text-muted-foreground mb-1 block text-sm">URL</span>
116
+ <input
117
+ type="text"
118
+ value={node.url}
119
+ onChange={(e) => onChange(updateNode(nodes, node.id, { url: e.target.value }))}
120
+ className="border-border bg-input-background text-foreground w-full rounded-md border px-2 py-1.5 font-mono text-sm"
121
+ aria-invalid={itemWarnings.length > 0}
122
+ />
123
+ </label>
124
+ <div className="flex gap-1 pb-0.5">
125
+ <Button
126
+ type="button"
127
+ variant="secondary"
128
+ size="sm"
129
+ onClick={() => onChange(addChild(nodes, node.id))}
130
+ aria-label={`Add child under ${node.label}`}
131
+ >
132
+ <Plus size={16} />
133
+ </Button>
134
+ <Button
135
+ type="button"
136
+ variant="secondary"
137
+ size="sm"
138
+ onClick={() => onChange(removeNode(nodes, node.id))}
139
+ aria-label={`Remove ${node.label}`}
44
140
  >
45
- <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
46
- </svg>
47
- <span className="font-medium">{node.label}</span>
48
- <span className="text-xs text-[var(--muted-foreground)]">{node.url}</span>
141
+ <Trash2 size={16} />
142
+ </Button>
49
143
  </div>
50
- <button
51
- type="button"
52
- onClick={() => removeItem(node.id)}
53
- className="text-xs text-[var(--muted-foreground)] hover:text-[var(--destructive)]"
54
- >
55
- Remove
56
- </button>
57
144
  </div>
145
+ {itemWarnings.length > 0 && (
146
+ <p className="text-destructive mt-2 flex items-start gap-1.5 text-sm" role="status">
147
+ <AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden />
148
+ <span>No published page matches this internal URL.</span>
149
+ </p>
150
+ )}
58
151
  {node.children.length > 0 && (
59
152
  <div className="mt-2 space-y-2">
60
153
  {node.children.map((child) => renderNode(child, depth + 1))}
@@ -66,12 +159,30 @@ export function NavBuilderField({ label, value = [], onChange, helpText }: NavBu
66
159
 
67
160
  return (
68
161
  <div>
69
- <label className="mb-2 block text-sm font-medium">{label}</label>
70
- <div className="space-y-2">{value.map((node) => renderNode(node, 0))}</div>
162
+ <label className="text-foreground mb-2 block text-sm font-medium">{label}</label>
163
+ {warnings.length > 0 && (
164
+ <div
165
+ className="border-border bg-muted text-foreground mb-3 rounded-md border p-3 text-sm"
166
+ role="status"
167
+ aria-live="polite"
168
+ >
169
+ <p className="mb-1 flex items-center gap-1.5 font-medium">
170
+ <AlertTriangle size={16} aria-hidden />
171
+ {warnings.length} navigation link{warnings.length === 1 ? '' : 's'} may 404
172
+ {checking ? ' (checking…)' : ''}
173
+ </p>
174
+ <ul className="text-muted-foreground list-inside list-disc">
175
+ {warnings.map((w) => (
176
+ <li key={w}>{w}</li>
177
+ ))}
178
+ </ul>
179
+ </div>
180
+ )}
181
+ <div className="space-y-2">{nodes.map((node) => renderNode(node, 0))}</div>
71
182
  <Button variant="secondary" size="sm" onClick={addItem} className="mt-2">
72
183
  Add Nav Item
73
184
  </Button>
74
- {helpText && <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>}
185
+ {helpText && <p className="text-muted-foreground mt-1 text-xs">{helpText}</p>}
75
186
  </div>
76
187
  )
77
188
  }
@@ -26,6 +26,7 @@ import { SEOPanel } from '../components/SEOPanel.js'
26
26
  import type { SEOData } from '../components/SEOPanel.js'
27
27
  import { SchedulePublishDialog } from '../components/SchedulePublishDialog.js'
28
28
  import { SharePreviewLinkDialog } from '../components/SharePreviewLinkDialog.js'
29
+ import { PublishSyncCard } from '../components/PublishSyncCard.js'
29
30
  import { cmsApi } from '../lib/api.js'
30
31
 
31
32
  export interface DocumentEditProps {
@@ -226,7 +227,7 @@ export function DocumentEdit({
226
227
  if (res.error) {
227
228
  toast.error(res.error)
228
229
  } else {
229
- toast.success('Document published')
230
+ toast.success('Published in CMS')
230
231
  setDocStatus('PUBLISHED')
231
232
  setInitialValues(values)
232
233
  setInitialSeoData(seoData)
@@ -386,6 +387,13 @@ export function DocumentEdit({
386
387
  </div>
387
388
 
388
389
  <div className="space-y-6">
390
+ {!isNew && documentId && (
391
+ <PublishSyncCard
392
+ documentId={documentId}
393
+ docStatus={docStatus}
394
+ onNavigate={onNavigate}
395
+ />
396
+ )}
389
397
  <div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
390
398
  <h3 className="mb-4 font-medium">Status</h3>
391
399
  <div className="space-y-3">
@@ -25,6 +25,7 @@ import { AppearanceTab } from './settings/AppearanceTab.js'
25
25
  import { SecurityTab } from './settings/SecurityTab.js'
26
26
  import { SettingsCard } from './settings/components.js'
27
27
  import { UpdatesTab } from './settings/UpdatesTab.js'
28
+ import { WebhooksTab } from './settings/WebhooksTab.js'
28
29
  import { Users as UsersView } from './Users.js'
29
30
  import { ApiKeys as ApiKeysView } from './ApiKeys.js'
30
31
 
@@ -93,6 +94,7 @@ export function Settings({
93
94
  'api-keys',
94
95
  'seo',
95
96
  'email',
97
+ 'webhooks',
96
98
  'ai',
97
99
  'updates',
98
100
  ] as const,
@@ -228,6 +230,9 @@ export function Settings({
228
230
  Email
229
231
  </span>
230
232
  </Tabs.Trigger>
233
+ <Tabs.Trigger value="webhooks" className={tabTriggerClass}>
234
+ Webhooks
235
+ </Tabs.Trigger>
231
236
  <Tabs.Trigger value="ai" className={tabTriggerClass}>
232
237
  <span className="flex items-center gap-1.5">
233
238
  <Bot className="h-4 w-4" />
@@ -329,6 +334,10 @@ export function Settings({
329
334
  <EmailSettingsTab role={session?.user?.role} />
330
335
  </Tabs.Content>
331
336
 
337
+ <Tabs.Content value="webhooks" className="space-y-4">
338
+ <WebhooksTab role={session?.user?.role} />
339
+ </Tabs.Content>
340
+
332
341
  <Tabs.Content value="ai" className="space-y-4">
333
342
  <AISettingsTab role={session?.user?.role} config={config} />
334
343
  </Tabs.Content>