@prenta/admin 0.93.0 → 0.95.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 +152 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js +37 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
- package/dist/__tests__/lib/version-diff.test.d.ts +2 -0
- package/dist/__tests__/lib/version-diff.test.d.ts.map +1 -0
- package/dist/__tests__/lib/version-diff.test.js +230 -0
- package/dist/__tests__/lib/version-diff.test.js.map +1 -0
- package/dist/__tests__/lib/version-history.test.d.ts +2 -0
- package/dist/__tests__/lib/version-history.test.d.ts.map +1 -0
- package/dist/__tests__/lib/version-history.test.js +125 -0
- package/dist/__tests__/lib/version-history.test.js.map +1 -0
- package/dist/__tests__/styles/responsive-layer.test.d.ts +2 -0
- package/dist/__tests__/styles/responsive-layer.test.d.ts.map +1 -0
- package/dist/__tests__/styles/responsive-layer.test.js +127 -0
- package/dist/__tests__/styles/responsive-layer.test.js.map +1 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.d.ts +2 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.js +70 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js +29 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/section-edit-form.render.test.d.ts +2 -0
- package/dist/__tests__/views/section-edit-form.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/section-edit-form.render.test.js +124 -0
- package/dist/__tests__/views/section-edit-form.render.test.js.map +1 -0
- package/dist/__tests__/views/section-inspector-media.test.js +1 -1
- package/dist/__tests__/views/section-inspector-media.test.js.map +1 -1
- package/dist/__tests__/views/section-inspector-repeater.render.test.js +2 -1
- package/dist/__tests__/views/section-inspector-repeater.render.test.js.map +1 -1
- package/dist/__tests__/views/version-history-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/version-history-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/version-history-panel.render.test.js +153 -0
- package/dist/__tests__/views/version-history-panel.render.test.js.map +1 -0
- package/dist/components/CanvasEditTip.js +1 -1
- package/dist/components/CanvasEditTip.js.map +1 -1
- package/dist/components/EditorRail.js +3 -3
- package/dist/components/EditorRail.js.map +1 -1
- package/dist/components/page-tab/DirectiveSwitch.d.ts.map +1 -1
- package/dist/components/page-tab/DirectiveSwitch.js +5 -1
- package/dist/components/page-tab/DirectiveSwitch.js.map +1 -1
- package/dist/layout/primitives/AdminShell.d.ts.map +1 -1
- package/dist/layout/primitives/AdminShell.js +2 -2
- package/dist/layout/primitives/AdminShell.js.map +1 -1
- package/dist/lib/version-diff.d.ts +50 -0
- package/dist/lib/version-diff.d.ts.map +1 -0
- package/dist/lib/version-diff.js +294 -0
- package/dist/lib/version-diff.js.map +1 -0
- package/dist/lib/version-history.d.ts +73 -0
- package/dist/lib/version-history.d.ts.map +1 -0
- package/dist/lib/version-history.js +126 -0
- package/dist/lib/version-history.js.map +1 -0
- package/dist/prenta-admin.css +1 -1
- package/dist/views/page-editor/EditorCanvas.d.ts +14 -1
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
- package/dist/views/page-editor/EditorCanvas.js +3 -3
- package/dist/views/page-editor/EditorCanvas.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +8 -2
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +8 -6
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +129 -40
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/ReviewBar.d.ts +21 -0
- package/dist/views/page-editor/ReviewBar.d.ts.map +1 -0
- package/dist/views/page-editor/ReviewBar.js +22 -0
- package/dist/views/page-editor/ReviewBar.js.map +1 -0
- package/dist/views/page-editor/SectionEditForm.d.ts +42 -0
- package/dist/views/page-editor/SectionEditForm.d.ts.map +1 -0
- package/dist/views/page-editor/SectionEditForm.js +48 -0
- package/dist/views/page-editor/SectionEditForm.js.map +1 -0
- package/dist/views/page-editor/SectionInspector.d.ts +0 -20
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +6 -224
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/page-editor/SectionsPanel.d.ts +10 -1
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
- package/dist/views/page-editor/SectionsPanel.js +11 -5
- package/dist/views/page-editor/SectionsPanel.js.map +1 -1
- package/dist/views/page-editor/VersionHistoryPanel.d.ts +29 -0
- package/dist/views/page-editor/VersionHistoryPanel.d.ts.map +1 -0
- package/dist/views/page-editor/VersionHistoryPanel.js +81 -0
- package/dist/views/page-editor/VersionHistoryPanel.js.map +1 -0
- package/dist/views/page-editor/section-fields.d.ts +43 -0
- package/dist/views/page-editor/section-fields.d.ts.map +1 -0
- package/dist/views/page-editor/section-fields.js +236 -0
- package/dist/views/page-editor/section-fields.js.map +1 -0
- package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.d.ts +13 -1
- package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.js +23 -8
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +11 -9
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/lib/page-editor-service-api.test.ts +44 -0
- package/src/__tests__/lib/version-diff.test.ts +278 -0
- package/src/__tests__/lib/version-history.test.ts +166 -0
- package/src/__tests__/styles/responsive-layer.test.ts +152 -0
- package/src/__tests__/views/canvas-review-mode.render.test.tsx +94 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -0
- package/src/__tests__/views/section-edit-form.render.test.tsx +162 -0
- package/src/__tests__/views/section-inspector-media.test.ts +1 -1
- package/src/__tests__/views/section-inspector-repeater.render.test.tsx +2 -1
- package/src/__tests__/views/version-history-panel.render.test.tsx +195 -0
- package/src/components/CanvasEditTip.tsx +3 -3
- package/src/components/EditorRail.tsx +5 -5
- package/src/components/page-tab/DirectiveSwitch.tsx +4 -1
- package/src/layout/primitives/AdminShell.tsx +13 -1
- package/src/lib/version-diff.ts +376 -0
- package/src/lib/version-history.ts +192 -0
- package/src/styles/responsive.css +281 -0
- package/src/styles/theme.css +8 -0
- package/src/views/page-editor/EditorCanvas.tsx +34 -7
- package/src/views/page-editor/EditorTopBar.tsx +20 -8
- package/src/views/page-editor/PageSectionEditor.tsx +182 -65
- package/src/views/page-editor/ReviewBar.tsx +76 -0
- package/src/views/page-editor/SectionEditForm.tsx +161 -0
- package/src/views/page-editor/SectionInspector.tsx +9 -553
- package/src/views/page-editor/SectionsPanel.tsx +72 -16
- package/src/views/page-editor/VersionHistoryPanel.tsx +302 -0
- package/src/views/page-editor/section-fields.tsx +563 -0
- package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -1
- package/src/views/page-editor/sections/SectionRenderer.tsx +53 -12
- package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
- package/src/views/post-editor/PostSectionEditor.tsx +18 -26
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
ChevronDown,
|
|
4
5
|
Copy,
|
|
5
6
|
Eye,
|
|
6
7
|
EyeOff,
|
|
@@ -56,6 +57,15 @@ interface SectionsPanelProps {
|
|
|
56
57
|
onDuplicate?: (id: string) => void
|
|
57
58
|
onRequestDelete?: (id: string) => void
|
|
58
59
|
onAdd?: () => void
|
|
60
|
+
/**
|
|
61
|
+
* Renders the selected section's editor as the body of its own row.
|
|
62
|
+
*
|
|
63
|
+
* A render prop rather than the form itself, so this list stays a list: it
|
|
64
|
+
* knows how to open a row and nothing about what a section's fields are.
|
|
65
|
+
* Omitted by the structural (template) list, which edits through the docked
|
|
66
|
+
* inspector instead.
|
|
67
|
+
*/
|
|
68
|
+
renderEditor?: (section: PageSection) => React.ReactNode
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
/**
|
|
@@ -98,6 +108,7 @@ export function SectionsList({
|
|
|
98
108
|
onDuplicate,
|
|
99
109
|
onRequestDelete,
|
|
100
110
|
onAdd,
|
|
111
|
+
renderEditor,
|
|
101
112
|
}: SectionsPanelProps) {
|
|
102
113
|
return (
|
|
103
114
|
<>
|
|
@@ -121,17 +132,30 @@ export function SectionsList({
|
|
|
121
132
|
/>
|
|
122
133
|
) : (
|
|
123
134
|
<ul className="space-y-1">
|
|
124
|
-
{sections.map((section) =>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
{sections.map((section) => {
|
|
136
|
+
const open = section.id === selectedId
|
|
137
|
+
return (
|
|
138
|
+
<li key={section.id}>
|
|
139
|
+
<SectionRow
|
|
140
|
+
section={section}
|
|
141
|
+
selected={open}
|
|
142
|
+
canEdit={canEdit}
|
|
143
|
+
structural={false}
|
|
144
|
+
expandable={renderEditor !== undefined}
|
|
145
|
+
onSelect={onSelect}
|
|
146
|
+
onToggleVisibility={onToggleVisibility}
|
|
147
|
+
/>
|
|
148
|
+
{open && renderEditor && (
|
|
149
|
+
<div
|
|
150
|
+
id={`section-editor-${section.id}`}
|
|
151
|
+
className="border-border overflow-hidden rounded-b-lg border border-t-0"
|
|
152
|
+
>
|
|
153
|
+
{renderEditor(section)}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</li>
|
|
157
|
+
)
|
|
158
|
+
})}
|
|
135
159
|
</ul>
|
|
136
160
|
)}
|
|
137
161
|
</div>
|
|
@@ -244,6 +268,8 @@ interface RowProps {
|
|
|
244
268
|
/** Drag affordance, supplied only by the sortable wrapper. */
|
|
245
269
|
dragHandle?: React.ReactNode
|
|
246
270
|
dragging?: boolean
|
|
271
|
+
/** The row opens an editor beneath it, so it gets a disclosure control. */
|
|
272
|
+
expandable?: boolean
|
|
247
273
|
}
|
|
248
274
|
|
|
249
275
|
function SectionRow({
|
|
@@ -251,6 +277,7 @@ function SectionRow({
|
|
|
251
277
|
selected,
|
|
252
278
|
canEdit,
|
|
253
279
|
structural,
|
|
280
|
+
expandable = false,
|
|
254
281
|
onSelect,
|
|
255
282
|
onToggleVisibility,
|
|
256
283
|
onDuplicate,
|
|
@@ -266,15 +293,21 @@ function SectionRow({
|
|
|
266
293
|
|
|
267
294
|
return (
|
|
268
295
|
<div
|
|
269
|
-
className={`group flex items-center gap-2
|
|
270
|
-
|
|
271
|
-
|
|
296
|
+
className={`group flex items-center gap-2 border px-2 py-2 ${
|
|
297
|
+
// Square off the bottom while the editor is open beneath, so row and
|
|
298
|
+
// form read as one object rather than two stacked cards.
|
|
299
|
+
selected && expandable ? 'rounded-t-lg' : 'rounded-lg'
|
|
300
|
+
} ${selected ? 'border-primary bg-accent' : 'hover:bg-accent/60 border-transparent'} ${
|
|
301
|
+
dragging ? 'shadow-md' : ''
|
|
302
|
+
}`}
|
|
272
303
|
>
|
|
273
304
|
{dragHandle}
|
|
274
305
|
|
|
275
306
|
<button
|
|
276
307
|
type="button"
|
|
277
308
|
onClick={() => onSelect(section.id)}
|
|
309
|
+
aria-expanded={expandable ? selected : undefined}
|
|
310
|
+
aria-controls={expandable && selected ? `section-editor-${section.id}` : undefined}
|
|
278
311
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
|
279
312
|
>
|
|
280
313
|
<span className="bg-muted text-muted-foreground flex h-7 w-7 shrink-0 items-center justify-center rounded-md">
|
|
@@ -305,7 +338,7 @@ function SectionRow({
|
|
|
305
338
|
disabled={!canEdit}
|
|
306
339
|
aria-pressed={!section.visible}
|
|
307
340
|
aria-label={section.visible ? `Hide ${section.name}` : `Show ${section.name}`}
|
|
308
|
-
className="text-muted-foreground hover:text-foreground shrink-0 rounded p-1 disabled:opacity-40"
|
|
341
|
+
className="prenta-touch-target text-muted-foreground hover:text-foreground shrink-0 rounded p-1 disabled:opacity-40"
|
|
309
342
|
>
|
|
310
343
|
{section.visible ? (
|
|
311
344
|
<Eye className="h-4 w-4" aria-hidden />
|
|
@@ -314,6 +347,29 @@ function SectionRow({
|
|
|
314
347
|
)}
|
|
315
348
|
</button>
|
|
316
349
|
|
|
350
|
+
{/* Disclosure. The row itself already toggles, but a visible chevron
|
|
351
|
+
is what says the row opens at all — without one the list looks
|
|
352
|
+
like a set of links to somewhere else, which is what it used to
|
|
353
|
+
be when the editor lived in a docked panel. */}
|
|
354
|
+
{expandable && (
|
|
355
|
+
<button
|
|
356
|
+
type="button"
|
|
357
|
+
onClick={() => onSelect(section.id)}
|
|
358
|
+
aria-expanded={selected}
|
|
359
|
+
aria-controls={selected ? `section-editor-${section.id}` : undefined}
|
|
360
|
+
aria-label={selected ? `Close ${section.name}` : `Edit ${section.name}`}
|
|
361
|
+
title="Edit content"
|
|
362
|
+
className="prenta-touch-target text-muted-foreground hover:text-foreground shrink-0 rounded p-1"
|
|
363
|
+
>
|
|
364
|
+
<ChevronDown
|
|
365
|
+
className={`h-4 w-4 transition-transform motion-reduce:transition-none ${
|
|
366
|
+
selected ? 'rotate-180' : ''
|
|
367
|
+
}`}
|
|
368
|
+
aria-hidden
|
|
369
|
+
/>
|
|
370
|
+
</button>
|
|
371
|
+
)}
|
|
372
|
+
|
|
317
373
|
{structural && (
|
|
318
374
|
<DropdownMenu.Root>
|
|
319
375
|
<DropdownMenu.Trigger asChild>
|
|
@@ -321,7 +377,7 @@ function SectionRow({
|
|
|
321
377
|
type="button"
|
|
322
378
|
disabled={!canEdit}
|
|
323
379
|
aria-label={`More actions for ${section.name}`}
|
|
324
|
-
className="text-muted-foreground hover:text-foreground shrink-0 rounded p-1 disabled:opacity-40"
|
|
380
|
+
className="prenta-touch-target text-muted-foreground hover:text-foreground shrink-0 rounded p-1 disabled:opacity-40"
|
|
325
381
|
>
|
|
326
382
|
<MoreVertical className="h-4 w-4" aria-hidden />
|
|
327
383
|
</button>
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
import { History, Loader2, RotateCcw, X } from 'lucide-react'
|
|
5
|
+
import {
|
|
6
|
+
diffVersions,
|
|
7
|
+
groupChangesBySection,
|
|
8
|
+
type FieldChange,
|
|
9
|
+
type SectionChangeGroup,
|
|
10
|
+
} from '../../lib/version-diff.js'
|
|
11
|
+
import {
|
|
12
|
+
groupVersionsByDay,
|
|
13
|
+
initials,
|
|
14
|
+
timeLabel,
|
|
15
|
+
versionKind,
|
|
16
|
+
type PageVersion,
|
|
17
|
+
} from '../../lib/version-history.js'
|
|
18
|
+
|
|
19
|
+
export interface VersionHistoryPanelProps {
|
|
20
|
+
versions: PageVersion[]
|
|
21
|
+
loading: boolean
|
|
22
|
+
error: string | null
|
|
23
|
+
/** Index into `versions`; 0 is the newest, which is "current". */
|
|
24
|
+
selectedIndex: number
|
|
25
|
+
onSelect: (index: number) => void
|
|
26
|
+
onClose: () => void
|
|
27
|
+
onRestore: (version: PageVersion) => void
|
|
28
|
+
restoring: boolean
|
|
29
|
+
canRestore: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Version history, as a takeover of the editing rail.
|
|
34
|
+
*
|
|
35
|
+
* It is deliberately NOT a dialog. History here is about comparing *renders*:
|
|
36
|
+
* picking a version puts it on the canvas, and the changed sections light up
|
|
37
|
+
* there. A modal would cover the very thing it exists to let you look at, and
|
|
38
|
+
* a right-hand drawer would push the canvas around while you compare against
|
|
39
|
+
* it. Overlaying the rail leaves the canvas exactly where it was, at exactly
|
|
40
|
+
* the size it was, so the only thing that changes between two versions is the
|
|
41
|
+
* page itself.
|
|
42
|
+
*
|
|
43
|
+
* Absolutely positioned over the rail's own box (the rail is `relative`) so it
|
|
44
|
+
* inherits the user's chosen rail width and needs no width of its own.
|
|
45
|
+
*/
|
|
46
|
+
export function VersionHistoryPanel({
|
|
47
|
+
versions,
|
|
48
|
+
loading,
|
|
49
|
+
error,
|
|
50
|
+
selectedIndex,
|
|
51
|
+
onSelect,
|
|
52
|
+
onClose,
|
|
53
|
+
onRestore,
|
|
54
|
+
restoring,
|
|
55
|
+
canRestore,
|
|
56
|
+
}: VersionHistoryPanelProps) {
|
|
57
|
+
/**
|
|
58
|
+
* Disclosure FOLLOWS selection rather than being tracked separately.
|
|
59
|
+
*
|
|
60
|
+
* They are the same act: the selected version is what the canvas renders,
|
|
61
|
+
* and the expanded diff is the list of what to look for on it. Held apart,
|
|
62
|
+
* the two drift — the parent can move the selection (the review bar's "Back
|
|
63
|
+
* to current" does exactly that), and the diff then describes one version
|
|
64
|
+
* while the canvas shows another, with Restore stranded on a row nobody can
|
|
65
|
+
* see. Only "did the user collapse the row they are on" needs remembering.
|
|
66
|
+
*/
|
|
67
|
+
const [collapsed, setCollapsed] = useState(false)
|
|
68
|
+
const expandedId = collapsed ? null : (versions[selectedIndex]?.id ?? null)
|
|
69
|
+
|
|
70
|
+
const days = useMemo(() => groupVersionsByDay(versions), [versions])
|
|
71
|
+
|
|
72
|
+
// Diff each version against the one before it, so a row reports what that
|
|
73
|
+
// save DID rather than how it differs from the present.
|
|
74
|
+
const changesByVersion = useMemo(() => {
|
|
75
|
+
const map = new Map<string, FieldChange[]>()
|
|
76
|
+
versions.forEach((version, index) => {
|
|
77
|
+
map.set(version.id, diffVersions(version.sections, versions[index + 1]?.sections))
|
|
78
|
+
})
|
|
79
|
+
return map
|
|
80
|
+
}, [versions])
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
className="bg-card absolute inset-0 z-45 flex flex-col"
|
|
85
|
+
role="region"
|
|
86
|
+
aria-label="Version history"
|
|
87
|
+
>
|
|
88
|
+
<div className="border-border flex shrink-0 items-center gap-2 border-b py-2.5 pr-2.5 pl-3.5">
|
|
89
|
+
<History className="text-brand h-3.5 w-3.5" aria-hidden />
|
|
90
|
+
<h2 className="text-foreground flex-1 text-[12.5px] font-semibold">Version history</h2>
|
|
91
|
+
<span className="text-muted-foreground text-[11px] tabular-nums">
|
|
92
|
+
{versions.length} version{versions.length === 1 ? '' : 's'}
|
|
93
|
+
</span>
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={onClose}
|
|
97
|
+
aria-label="Close version history"
|
|
98
|
+
title="Close version history"
|
|
99
|
+
className="prenta-touch-target text-muted-foreground hover:bg-accent hover:text-accent-foreground flex h-6.5 w-6.5 items-center justify-center rounded-md transition-colors"
|
|
100
|
+
>
|
|
101
|
+
<X className="h-3.5 w-3.5" aria-hidden />
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
106
|
+
{loading ? (
|
|
107
|
+
<div className="flex items-center justify-center py-16">
|
|
108
|
+
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" aria-hidden />
|
|
109
|
+
<span className="sr-only">Loading version history</span>
|
|
110
|
+
</div>
|
|
111
|
+
) : error ? (
|
|
112
|
+
<p className="text-destructive px-3.5 py-10 text-center text-sm">{error}</p>
|
|
113
|
+
) : versions.length === 0 ? (
|
|
114
|
+
<p className="text-muted-foreground px-3.5 py-10 text-center text-sm">
|
|
115
|
+
No versions yet. Every save adds one.
|
|
116
|
+
</p>
|
|
117
|
+
) : (
|
|
118
|
+
days.map((day) => (
|
|
119
|
+
// Keyed on the group's first version, NOT on its label. Grouping
|
|
120
|
+
// walks consecutive runs, so an out-of-order page yields two
|
|
121
|
+
// non-adjacent groups both labelled "Today" — `groupVersionsByDay`
|
|
122
|
+
// has a test asserting exactly that — and a label key would collide.
|
|
123
|
+
<section key={day.versions[0]?.id ?? day.label}>
|
|
124
|
+
<h3 className="text-muted-foreground bg-muted border-border sticky top-0 z-2 border-b px-3.5 pt-2.5 pb-1.5 text-[10px] font-bold tracking-[0.7px] uppercase">
|
|
125
|
+
{day.label}
|
|
126
|
+
</h3>
|
|
127
|
+
{day.versions.map((version) => {
|
|
128
|
+
const index = versions.indexOf(version)
|
|
129
|
+
return (
|
|
130
|
+
<VersionRow
|
|
131
|
+
key={version.id}
|
|
132
|
+
version={version}
|
|
133
|
+
index={index}
|
|
134
|
+
changes={changesByVersion.get(version.id) ?? []}
|
|
135
|
+
selected={index === selectedIndex}
|
|
136
|
+
expanded={expandedId === version.id}
|
|
137
|
+
onToggle={() => {
|
|
138
|
+
// Re-clicking the row you are on collapses it; clicking
|
|
139
|
+
// any other selects it, which is also what opens it.
|
|
140
|
+
if (index === selectedIndex) setCollapsed((prev) => !prev)
|
|
141
|
+
else {
|
|
142
|
+
setCollapsed(false)
|
|
143
|
+
onSelect(index)
|
|
144
|
+
}
|
|
145
|
+
}}
|
|
146
|
+
onRestore={() => onRestore(version)}
|
|
147
|
+
restoring={restoring}
|
|
148
|
+
canRestore={canRestore}
|
|
149
|
+
/>
|
|
150
|
+
)
|
|
151
|
+
})}
|
|
152
|
+
</section>
|
|
153
|
+
))
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const TONE_CLASS: Record<string, string> = {
|
|
161
|
+
live: 'bg-success/15 text-success',
|
|
162
|
+
draft: 'bg-warning/15 text-warning',
|
|
163
|
+
neutral: 'bg-muted text-muted-foreground',
|
|
164
|
+
danger: 'bg-destructive/15 text-destructive',
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function VersionRow({
|
|
168
|
+
version,
|
|
169
|
+
index,
|
|
170
|
+
changes,
|
|
171
|
+
selected,
|
|
172
|
+
expanded,
|
|
173
|
+
onToggle,
|
|
174
|
+
onRestore,
|
|
175
|
+
restoring,
|
|
176
|
+
canRestore,
|
|
177
|
+
}: {
|
|
178
|
+
version: PageVersion
|
|
179
|
+
index: number
|
|
180
|
+
changes: FieldChange[]
|
|
181
|
+
selected: boolean
|
|
182
|
+
expanded: boolean
|
|
183
|
+
onToggle: () => void
|
|
184
|
+
onRestore: () => void
|
|
185
|
+
restoring: boolean
|
|
186
|
+
canRestore: boolean
|
|
187
|
+
}) {
|
|
188
|
+
const kind = versionKind(version.changeType)
|
|
189
|
+
const isCurrent = index === 0
|
|
190
|
+
const groups = groupChangesBySection(changes)
|
|
191
|
+
const time = timeLabel(version.createdAt)
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="border-border border-b">
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
onClick={onToggle}
|
|
198
|
+
aria-expanded={expanded}
|
|
199
|
+
className={`flex w-full items-center gap-2.5 py-2.5 pr-2.5 pl-3 text-left transition-colors ${
|
|
200
|
+
selected ? 'bg-accent shadow-[inset_2px_0_0_var(--acc)]' : 'hover:bg-accent/60'
|
|
201
|
+
}`}
|
|
202
|
+
>
|
|
203
|
+
<span
|
|
204
|
+
className="bg-muted text-muted-foreground flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[9.5px] font-semibold"
|
|
205
|
+
aria-hidden
|
|
206
|
+
>
|
|
207
|
+
{initials(version.author.name)}
|
|
208
|
+
</span>
|
|
209
|
+
|
|
210
|
+
<span className="min-w-0 flex-1">
|
|
211
|
+
<span className="text-foreground flex items-center gap-1.5 text-xs font-semibold">
|
|
212
|
+
{time}
|
|
213
|
+
{isCurrent && (
|
|
214
|
+
<span className="bg-accent text-accent-foreground rounded px-1.5 py-px text-[8.5px] font-bold tracking-[0.4px] uppercase">
|
|
215
|
+
Current
|
|
216
|
+
</span>
|
|
217
|
+
)}
|
|
218
|
+
</span>
|
|
219
|
+
<span className="text-muted-foreground mt-px block truncate text-[10.5px]">
|
|
220
|
+
{version.author.name} ·{' '}
|
|
221
|
+
{changes.length
|
|
222
|
+
? `${changes.length} change${changes.length === 1 ? '' : 's'}`
|
|
223
|
+
: 'no content changes'}
|
|
224
|
+
</span>
|
|
225
|
+
</span>
|
|
226
|
+
|
|
227
|
+
<span
|
|
228
|
+
className={`shrink-0 rounded px-1.5 py-px text-[8.5px] font-bold tracking-[0.4px] uppercase ${
|
|
229
|
+
TONE_CLASS[kind.tone] ?? TONE_CLASS.neutral
|
|
230
|
+
}`}
|
|
231
|
+
>
|
|
232
|
+
{kind.label}
|
|
233
|
+
</span>
|
|
234
|
+
</button>
|
|
235
|
+
|
|
236
|
+
{/*
|
|
237
|
+
* Opens on `expanded` alone — NOT on `expanded && changes.length`.
|
|
238
|
+
*
|
|
239
|
+
* Restore lives in here, and a version can legitimately have no computed
|
|
240
|
+
* diff: the oldest one held has no predecessor to compare against, and a
|
|
241
|
+
* metadata-only save changes no section. Gating the whole block on a
|
|
242
|
+
* non-empty diff makes exactly those versions reviewable but not
|
|
243
|
+
* restorable, which is the wrong way round — the oldest version is the
|
|
244
|
+
* one a rollback most often wants.
|
|
245
|
+
*/}
|
|
246
|
+
{expanded && (
|
|
247
|
+
<div className="flex flex-col gap-2.5 pt-0 pr-3 pb-3 pl-[38px]">
|
|
248
|
+
{groups.length > 0 ? (
|
|
249
|
+
groups.map((group) => <ChangeGroup key={group.sectionId} group={group} />)
|
|
250
|
+
) : (
|
|
251
|
+
<p className="text-muted-foreground text-[11px] leading-[1.45]">
|
|
252
|
+
No section changes to show against the version before it.
|
|
253
|
+
</p>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{/* Restore is offered only on the row being reviewed, and never on the
|
|
257
|
+
newest one — "restore the version you are already looking at" is a
|
|
258
|
+
no-op dressed up as an action. */}
|
|
259
|
+
{selected && !isCurrent && canRestore && (
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
onClick={onRestore}
|
|
263
|
+
disabled={restoring}
|
|
264
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center justify-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
265
|
+
>
|
|
266
|
+
{restoring ? (
|
|
267
|
+
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
|
|
268
|
+
) : (
|
|
269
|
+
<RotateCcw className="h-3 w-3" aria-hidden />
|
|
270
|
+
)}
|
|
271
|
+
Restore this version
|
|
272
|
+
</button>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function ChangeGroup({ group }: { group: SectionChangeGroup }) {
|
|
281
|
+
return (
|
|
282
|
+
<div>
|
|
283
|
+
<h4 className="text-foreground/80 mb-1 text-[9.5px] font-bold tracking-[0.4px] uppercase">
|
|
284
|
+
{group.sectionName}
|
|
285
|
+
</h4>
|
|
286
|
+
{group.changes.map((change, i) => (
|
|
287
|
+
<div key={`${change.field}-${i}`} className={i > 0 ? 'mt-1.5' : undefined}>
|
|
288
|
+
<div className="text-muted-foreground text-[9.5px] font-bold tracking-[0.3px]">
|
|
289
|
+
{change.field}
|
|
290
|
+
</div>
|
|
291
|
+
{/* Colour is not the only carrier: the old value is struck through
|
|
292
|
+
and the pair is ordered old-then-new, both of which survive a
|
|
293
|
+
reader who cannot separate red from green. */}
|
|
294
|
+
<div className="text-destructive text-[11px] leading-[1.45] line-through decoration-1">
|
|
295
|
+
{change.from}
|
|
296
|
+
</div>
|
|
297
|
+
<div className="text-success text-[11px] leading-[1.45]">{change.to}</div>
|
|
298
|
+
</div>
|
|
299
|
+
))}
|
|
300
|
+
</div>
|
|
301
|
+
)
|
|
302
|
+
}
|