@actuate-media/cms-admin 0.65.0 → 0.67.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 +39 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +7 -4
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/inspector-pane.test.d.ts +2 -0
- package/dist/__tests__/components/inspector-pane.test.d.ts.map +1 -0
- package/dist/__tests__/components/inspector-pane.test.js +31 -0
- package/dist/__tests__/components/inspector-pane.test.js.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.js +173 -0
- package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
- package/dist/__tests__/views/dashboard.test.js +7 -3
- package/dist/__tests__/views/dashboard.test.js.map +1 -1
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
- package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +77 -1
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
- package/dist/components/ui/InspectorPane.d.ts +51 -0
- package/dist/components/ui/InspectorPane.d.ts.map +1 -0
- package/dist/components/ui/InspectorPane.js +42 -0
- package/dist/components/ui/InspectorPane.js.map +1 -0
- package/dist/components/ui/RowActions.d.ts +19 -0
- package/dist/components/ui/RowActions.d.ts.map +1 -0
- package/dist/components/ui/RowActions.js +16 -0
- package/dist/components/ui/RowActions.js.map +1 -0
- package/dist/components/ui/index.d.ts +4 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +2 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/layout/Header.js +1 -1
- package/dist/layout/Header.js.map +1 -1
- package/dist/layout/Layout.js +8 -38
- package/dist/layout/Layout.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +11 -2
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +8 -2
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/pages-service.d.ts +9 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +14 -0
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/pages-tree.d.ts +44 -0
- package/dist/lib/pages-tree.d.ts.map +1 -0
- package/dist/lib/pages-tree.js +106 -0
- package/dist/lib/pages-tree.js.map +1 -0
- package/dist/lib/posts-service.d.ts +11 -0
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +13 -0
- package/dist/lib/posts-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +3 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +40 -3
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +55 -2
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/MediaSeoAudit.d.ts +25 -0
- package/dist/views/MediaSeoAudit.d.ts.map +1 -0
- package/dist/views/MediaSeoAudit.js +97 -0
- package/dist/views/MediaSeoAudit.js.map +1 -0
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +145 -16
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
- package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
- package/dist/views/Pages/PagesTreeTable.js +28 -0
- package/dist/views/Pages/PagesTreeTable.js.map +1 -0
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +66 -9
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/ScriptTags.d.ts +5 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +32 -7
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
- package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
- package/dist/views/dashboard/OnboardingBlock.js +71 -0
- package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
- package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +3 -3
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +42 -3
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +15 -0
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/package.json +3 -3
- package/src/AdminRoot.tsx +7 -3
- package/src/__tests__/components/inspector-pane.test.tsx +64 -0
- package/src/__tests__/lib/pages-tree.test.ts +197 -0
- package/src/__tests__/views/dashboard.test.tsx +7 -3
- package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
- package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
- package/src/__tests__/views/pages-list-view.test.tsx +93 -1
- package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
- package/src/components/ui/InspectorPane.tsx +129 -0
- package/src/components/ui/RowActions.tsx +50 -0
- package/src/components/ui/index.ts +12 -0
- package/src/layout/Header.tsx +2 -2
- package/src/layout/Layout.tsx +8 -80
- package/src/lib/page-editor-service.ts +16 -2
- package/src/lib/pages-service.ts +18 -0
- package/src/lib/pages-tree.ts +145 -0
- package/src/lib/posts-service.ts +26 -0
- package/src/lib/seo-service.ts +3 -0
- package/src/views/Dashboard.tsx +286 -214
- package/src/views/MediaBrowser.tsx +673 -512
- package/src/views/MediaSeoAudit.tsx +320 -0
- package/src/views/Pages/PagesListView.tsx +459 -185
- package/src/views/Pages/PagesTreeTable.tsx +339 -0
- package/src/views/Posts/PostsListView.tsx +135 -10
- package/src/views/ScriptTags.tsx +204 -129
- package/src/views/dashboard/OnboardingBlock.tsx +233 -0
- package/src/views/page-editor/PageSectionEditor.tsx +9 -2
- package/src/views/page-editor/PageSettingsModal.tsx +75 -2
- package/src/views/seo/TechnicalTab.tsx +15 -0
- package/dist/views/ScriptTagEditor.d.ts +0 -6
- package/dist/views/ScriptTagEditor.d.ts.map +0 -1
- package/dist/views/ScriptTagEditor.js +0 -111
- package/dist/views/ScriptTagEditor.js.map +0 -1
- package/src/views/ScriptTagEditor.tsx +0 -353
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ChevronDown, Copy, PenLine, Plus, Trash2 } from 'lucide-react'
|
|
4
|
+
import type { Page } from '../../lib/pages-service.js'
|
|
5
|
+
import type { PageTreeRow } from '../../lib/pages-tree.js'
|
|
6
|
+
import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
|
|
7
|
+
import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Tree-mode table for the Pages list ("Pages (tree version)" design):
|
|
11
|
+
* nested parent/child rows with indentation (24px per level), a 20×20
|
|
12
|
+
* expand chevron that rotates -90° when collapsed, 56px rows, and hover
|
|
13
|
+
* actions (labeled Edit + Add sub-page / Duplicate / Delete icons).
|
|
14
|
+
*
|
|
15
|
+
* When `flattened` is true (search or status filter active) every match
|
|
16
|
+
* renders at depth 0 and expand state is ignored — the caller passes
|
|
17
|
+
* pre-flattened rows, this component just skips the leaf spacers' tree
|
|
18
|
+
* semantics.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface PagesTreeTableProps {
|
|
22
|
+
rows: PageTreeRow[]
|
|
23
|
+
loading: boolean
|
|
24
|
+
error: string | null
|
|
25
|
+
/** true when search/status filters flattened the tree */
|
|
26
|
+
flattened: boolean
|
|
27
|
+
selected: Set<string>
|
|
28
|
+
expanded: Set<string>
|
|
29
|
+
onToggleExpand: (id: string) => void
|
|
30
|
+
onToggleSelect: (id: string) => void
|
|
31
|
+
onToggleAll: (checked: boolean) => void
|
|
32
|
+
onOpen: (page: Page) => void
|
|
33
|
+
onAddSubPage: (page: Page) => void
|
|
34
|
+
onDuplicate: (page: Page) => void
|
|
35
|
+
onDelete: (page: Page) => void
|
|
36
|
+
onRetry: () => void
|
|
37
|
+
onClearFilters: () => void
|
|
38
|
+
onNewPage: () => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const TH_CLASS =
|
|
42
|
+
'px-3 py-2.5 text-left text-[11.5px] font-semibold tracking-[0.4px] uppercase text-[var(--muted)]'
|
|
43
|
+
|
|
44
|
+
export function PagesTreeTable({
|
|
45
|
+
rows,
|
|
46
|
+
loading,
|
|
47
|
+
error,
|
|
48
|
+
flattened,
|
|
49
|
+
selected,
|
|
50
|
+
expanded,
|
|
51
|
+
onToggleExpand,
|
|
52
|
+
onToggleSelect,
|
|
53
|
+
onToggleAll,
|
|
54
|
+
onOpen,
|
|
55
|
+
onAddSubPage,
|
|
56
|
+
onDuplicate,
|
|
57
|
+
onDelete,
|
|
58
|
+
onRetry,
|
|
59
|
+
onClearFilters,
|
|
60
|
+
onNewPage,
|
|
61
|
+
}: PagesTreeTableProps) {
|
|
62
|
+
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.page.id))
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="overflow-x-auto">
|
|
66
|
+
<table className="w-full min-w-[800px]" role="table" aria-label="Pages tree">
|
|
67
|
+
<colgroup>
|
|
68
|
+
<col style={{ width: '40px' }} />
|
|
69
|
+
<col />
|
|
70
|
+
<col style={{ width: '130px' }} />
|
|
71
|
+
<col style={{ width: '150px' }} />
|
|
72
|
+
<col style={{ width: '120px' }} />
|
|
73
|
+
<col style={{ width: '115px' }} />
|
|
74
|
+
<col style={{ width: '70px' }} />
|
|
75
|
+
<col style={{ width: '170px' }} />
|
|
76
|
+
</colgroup>
|
|
77
|
+
<thead className="border-b border-[var(--bdr)]">
|
|
78
|
+
<tr>
|
|
79
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
80
|
+
<input
|
|
81
|
+
type="checkbox"
|
|
82
|
+
aria-label="Select all visible pages"
|
|
83
|
+
checked={allSelected}
|
|
84
|
+
onChange={(e) => onToggleAll(e.target.checked)}
|
|
85
|
+
className="border-border accent-primary rounded"
|
|
86
|
+
/>
|
|
87
|
+
</th>
|
|
88
|
+
<th scope="col" className={`${TH_CLASS} min-w-[280px]`}>
|
|
89
|
+
Title
|
|
90
|
+
</th>
|
|
91
|
+
<th scope="col" className={TH_CLASS}>
|
|
92
|
+
Template
|
|
93
|
+
</th>
|
|
94
|
+
<th scope="col" className={TH_CLASS}>
|
|
95
|
+
Author
|
|
96
|
+
</th>
|
|
97
|
+
<th scope="col" className={TH_CLASS}>
|
|
98
|
+
Status
|
|
99
|
+
</th>
|
|
100
|
+
<th scope="col" className={TH_CLASS}>
|
|
101
|
+
Last Modified
|
|
102
|
+
</th>
|
|
103
|
+
<th scope="col" className={TH_CLASS}>
|
|
104
|
+
SEO
|
|
105
|
+
</th>
|
|
106
|
+
<th scope="col" className="px-3 py-2.5 text-right">
|
|
107
|
+
<span className="sr-only">Actions</span>
|
|
108
|
+
</th>
|
|
109
|
+
</tr>
|
|
110
|
+
</thead>
|
|
111
|
+
<tbody>
|
|
112
|
+
{loading && rows.length === 0 && <TreeLoadingRows />}
|
|
113
|
+
{!loading && error && (
|
|
114
|
+
<tr>
|
|
115
|
+
<td colSpan={8} className="px-3 py-12 text-center">
|
|
116
|
+
<div className="text-foreground mb-2 text-sm font-medium">{error}</div>
|
|
117
|
+
<button onClick={onRetry} className="text-primary text-sm hover:underline">
|
|
118
|
+
Try again
|
|
119
|
+
</button>
|
|
120
|
+
</td>
|
|
121
|
+
</tr>
|
|
122
|
+
)}
|
|
123
|
+
{!loading && !error && rows.length === 0 && (
|
|
124
|
+
<tr>
|
|
125
|
+
<td colSpan={8} className="px-3 py-16 text-center">
|
|
126
|
+
<div className="text-foreground mb-2 text-sm font-medium">
|
|
127
|
+
{flattened ? 'No pages match your filters' : 'No pages yet'}
|
|
128
|
+
</div>
|
|
129
|
+
<div className="text-muted-foreground mb-4 text-sm">
|
|
130
|
+
{flattened
|
|
131
|
+
? 'Try clearing the search or status filter above.'
|
|
132
|
+
: 'Create your first page to get started.'}
|
|
133
|
+
</div>
|
|
134
|
+
{flattened ? (
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={onClearFilters}
|
|
138
|
+
className="text-primary text-sm hover:underline"
|
|
139
|
+
>
|
|
140
|
+
Clear filters
|
|
141
|
+
</button>
|
|
142
|
+
) : (
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
onClick={onNewPage}
|
|
146
|
+
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors"
|
|
147
|
+
>
|
|
148
|
+
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
149
|
+
New page
|
|
150
|
+
</button>
|
|
151
|
+
)}
|
|
152
|
+
</td>
|
|
153
|
+
</tr>
|
|
154
|
+
)}
|
|
155
|
+
{!loading &&
|
|
156
|
+
!error &&
|
|
157
|
+
rows.map((row) => (
|
|
158
|
+
<TreeRow
|
|
159
|
+
key={row.page.id}
|
|
160
|
+
row={row}
|
|
161
|
+
isSelected={selected.has(row.page.id)}
|
|
162
|
+
isExpanded={expanded.has(row.page.id)}
|
|
163
|
+
onToggleExpand={() => onToggleExpand(row.page.id)}
|
|
164
|
+
onToggleSelect={() => onToggleSelect(row.page.id)}
|
|
165
|
+
onOpen={() => onOpen(row.page)}
|
|
166
|
+
onAddSubPage={() => onAddSubPage(row.page)}
|
|
167
|
+
onDuplicate={() => onDuplicate(row.page)}
|
|
168
|
+
onDelete={() => onDelete(row.page)}
|
|
169
|
+
/>
|
|
170
|
+
))}
|
|
171
|
+
</tbody>
|
|
172
|
+
</table>
|
|
173
|
+
</div>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function TreeRow({
|
|
178
|
+
row,
|
|
179
|
+
isSelected,
|
|
180
|
+
isExpanded,
|
|
181
|
+
onToggleExpand,
|
|
182
|
+
onToggleSelect,
|
|
183
|
+
onOpen,
|
|
184
|
+
onAddSubPage,
|
|
185
|
+
onDuplicate,
|
|
186
|
+
onDelete,
|
|
187
|
+
}: {
|
|
188
|
+
row: PageTreeRow
|
|
189
|
+
isSelected: boolean
|
|
190
|
+
isExpanded: boolean
|
|
191
|
+
onToggleExpand: () => void
|
|
192
|
+
onToggleSelect: () => void
|
|
193
|
+
onOpen: () => void
|
|
194
|
+
onAddSubPage: () => void
|
|
195
|
+
onDuplicate: () => void
|
|
196
|
+
onDelete: () => void
|
|
197
|
+
}) {
|
|
198
|
+
const { page, depth, hasChildren } = row
|
|
199
|
+
return (
|
|
200
|
+
<tr
|
|
201
|
+
className={`group h-14 border-b border-[var(--bdr)] transition-colors last:border-b-0 hover:bg-[var(--acc-l)] ${
|
|
202
|
+
isSelected ? 'bg-[var(--acc-l)]' : ''
|
|
203
|
+
}`}
|
|
204
|
+
>
|
|
205
|
+
<td className="px-3 align-middle">
|
|
206
|
+
<input
|
|
207
|
+
type="checkbox"
|
|
208
|
+
aria-label={`Select "${page.title}"`}
|
|
209
|
+
checked={isSelected}
|
|
210
|
+
onChange={onToggleSelect}
|
|
211
|
+
className="border-border accent-primary rounded"
|
|
212
|
+
/>
|
|
213
|
+
</td>
|
|
214
|
+
<td className="px-3 align-middle">
|
|
215
|
+
{/* Indentation is data-driven (24px per depth level), so it must be
|
|
216
|
+
an inline style rather than a class. */}
|
|
217
|
+
<div className="flex items-center" style={{ paddingLeft: depth * 24 }}>
|
|
218
|
+
{hasChildren ? (
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
aria-expanded={isExpanded}
|
|
222
|
+
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${page.title}`}
|
|
223
|
+
onClick={onToggleExpand}
|
|
224
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[var(--muted)] transition-colors duration-[var(--motion-fast)] hover:bg-[var(--acc-l)] hover:text-[var(--acc)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
|
|
225
|
+
>
|
|
226
|
+
<ChevronDown
|
|
227
|
+
size={13}
|
|
228
|
+
aria-hidden
|
|
229
|
+
className={`motion-safe:transition-transform motion-safe:duration-150 ${
|
|
230
|
+
isExpanded ? '' : '-rotate-90'
|
|
231
|
+
}`}
|
|
232
|
+
/>
|
|
233
|
+
</button>
|
|
234
|
+
) : (
|
|
235
|
+
<span className="w-5 shrink-0" aria-hidden />
|
|
236
|
+
)}
|
|
237
|
+
<div className="min-w-0">
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
onClick={onOpen}
|
|
241
|
+
className="block max-w-[280px] truncate text-left text-[13.5px] font-[550] text-[var(--txt)] transition-colors duration-[var(--motion-fast)] hover:text-[var(--acc)]"
|
|
242
|
+
>
|
|
243
|
+
{page.title}
|
|
244
|
+
</button>
|
|
245
|
+
<div className="max-w-[280px] truncate font-mono text-[11.5px] text-[var(--muted)]">
|
|
246
|
+
{page.path}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</td>
|
|
251
|
+
<td className="px-3 align-middle">
|
|
252
|
+
{page.templateId ? (
|
|
253
|
+
<span className="rounded-[5px] border border-[var(--bdr)] bg-[var(--bg)] px-2 py-0.5 text-[11.5px] whitespace-nowrap text-[var(--sub)]">
|
|
254
|
+
{page.templateId}
|
|
255
|
+
</span>
|
|
256
|
+
) : (
|
|
257
|
+
<span className="text-[var(--muted)]">—</span>
|
|
258
|
+
)}
|
|
259
|
+
</td>
|
|
260
|
+
<td className="px-3 align-middle">
|
|
261
|
+
<div className="flex items-center gap-[7px]">
|
|
262
|
+
<span
|
|
263
|
+
aria-hidden
|
|
264
|
+
className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-[var(--acc-l)] text-[9px] font-medium text-[var(--acc)]"
|
|
265
|
+
>
|
|
266
|
+
{page.authorInitials}
|
|
267
|
+
</span>
|
|
268
|
+
<span className="truncate text-[13px] text-[var(--txt)]">{page.authorName}</span>
|
|
269
|
+
</div>
|
|
270
|
+
</td>
|
|
271
|
+
<td className="px-3 align-middle">
|
|
272
|
+
<PostStatusBadge status={page.status} />
|
|
273
|
+
</td>
|
|
274
|
+
<td className="px-3 align-middle text-[12.5px] text-[var(--sub)] tabular-nums">
|
|
275
|
+
{formatDateShort(page.lastModified)}
|
|
276
|
+
</td>
|
|
277
|
+
<td className="px-3 align-middle">
|
|
278
|
+
<SeoScoreIndicator score={page.seoScore} />
|
|
279
|
+
</td>
|
|
280
|
+
<td className="px-3 align-middle">
|
|
281
|
+
<RowActions>
|
|
282
|
+
<button
|
|
283
|
+
type="button"
|
|
284
|
+
onClick={onOpen}
|
|
285
|
+
className="flex items-center gap-[5px] rounded-[6px] border border-[var(--bdr)] bg-[var(--card)] px-[9px] py-[5px] text-[12px] font-medium whitespace-nowrap text-[var(--txt)] transition-colors duration-[var(--motion-fast)] hover:border-[var(--acc)] hover:bg-[var(--acc-l)] hover:text-[var(--acc)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
|
|
286
|
+
>
|
|
287
|
+
<PenLine size={12} aria-hidden />
|
|
288
|
+
Edit
|
|
289
|
+
</button>
|
|
290
|
+
<RowActionButton
|
|
291
|
+
aria-label={`Add sub-page under "${page.title}"`}
|
|
292
|
+
title="Add sub-page"
|
|
293
|
+
onClick={onAddSubPage}
|
|
294
|
+
>
|
|
295
|
+
<Plus size={12} aria-hidden />
|
|
296
|
+
</RowActionButton>
|
|
297
|
+
<RowActionButton
|
|
298
|
+
aria-label={`Duplicate "${page.title}"`}
|
|
299
|
+
title="Duplicate"
|
|
300
|
+
onClick={onDuplicate}
|
|
301
|
+
>
|
|
302
|
+
<Copy size={12} aria-hidden />
|
|
303
|
+
</RowActionButton>
|
|
304
|
+
<RowActionButton
|
|
305
|
+
aria-label={`Delete "${page.title}"`}
|
|
306
|
+
title={page.isHomepage ? 'The homepage cannot be deleted' : 'Delete'}
|
|
307
|
+
danger
|
|
308
|
+
disabled={page.isHomepage}
|
|
309
|
+
onClick={onDelete}
|
|
310
|
+
>
|
|
311
|
+
<Trash2 size={12} aria-hidden />
|
|
312
|
+
</RowActionButton>
|
|
313
|
+
</RowActions>
|
|
314
|
+
</td>
|
|
315
|
+
</tr>
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function TreeLoadingRows() {
|
|
320
|
+
return (
|
|
321
|
+
<>
|
|
322
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
323
|
+
<tr key={i} className="h-14 border-b border-[var(--bdr)] last:border-b-0">
|
|
324
|
+
{Array.from({ length: 8 }).map((__, j) => (
|
|
325
|
+
<td key={j} className="px-3 align-middle">
|
|
326
|
+
<div className="bg-muted h-4 w-full animate-pulse rounded" />
|
|
327
|
+
</td>
|
|
328
|
+
))}
|
|
329
|
+
</tr>
|
|
330
|
+
))}
|
|
331
|
+
</>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function formatDateShort(input: string): string {
|
|
336
|
+
const d = new Date(input)
|
|
337
|
+
if (Number.isNaN(d.getTime())) return '—'
|
|
338
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
|
339
|
+
}
|
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
ChevronLeft,
|
|
7
7
|
ChevronRight,
|
|
8
8
|
ChevronUp,
|
|
9
|
+
Copy,
|
|
9
10
|
Loader2,
|
|
11
|
+
PenLine,
|
|
12
|
+
Plus,
|
|
10
13
|
Search,
|
|
11
14
|
Settings,
|
|
12
15
|
Trash2,
|
|
@@ -16,6 +19,8 @@ import { toast } from 'sonner'
|
|
|
16
19
|
import {
|
|
17
20
|
bulkDeletePosts,
|
|
18
21
|
bulkUpdatePosts,
|
|
22
|
+
deletePost,
|
|
23
|
+
duplicatePost,
|
|
19
24
|
fetchPosts,
|
|
20
25
|
type Post,
|
|
21
26
|
type PostStatus,
|
|
@@ -29,6 +34,7 @@ import {
|
|
|
29
34
|
paletteFor,
|
|
30
35
|
} from '../../components/posts/badges.js'
|
|
31
36
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
37
|
+
import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
|
|
32
38
|
|
|
33
39
|
/**
|
|
34
40
|
* All Posts management surface — table with type tabs, controls row,
|
|
@@ -69,6 +75,7 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
|
|
|
69
75
|
const [error, setError] = useState<string | null>(null)
|
|
70
76
|
const [refreshToken, setRefreshToken] = useState(0)
|
|
71
77
|
const [pendingBulk, setPendingBulk] = useState<null | 'delete' | 'publish' | 'draft'>(null)
|
|
78
|
+
const [rowDelete, setRowDelete] = useState<Post | null>(null)
|
|
72
79
|
|
|
73
80
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
74
81
|
useEffect(() => {
|
|
@@ -132,6 +139,14 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
|
|
|
132
139
|
() => (data?.types ?? []).reduce((sum, t) => sum + t.postCount, 0),
|
|
133
140
|
[data?.types],
|
|
134
141
|
)
|
|
142
|
+
|
|
143
|
+
// Header CTA target: one Post Type → its editor, several → the picker,
|
|
144
|
+
// none → the Post Type setup instructions.
|
|
145
|
+
const newPostPath = useMemo(() => {
|
|
146
|
+
const typeList = data?.types ?? []
|
|
147
|
+
if (typeList.length === 1) return `/${typeList[0]!.slug}/new`
|
|
148
|
+
return typeList.length > 1 ? '/posts/new' : '/posts/types/new'
|
|
149
|
+
}, [data?.types])
|
|
135
150
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
136
151
|
const posts = data?.posts ?? []
|
|
137
152
|
const types = data?.types ?? []
|
|
@@ -210,16 +225,69 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
|
|
|
210
225
|
setRefreshToken((t) => t + 1)
|
|
211
226
|
}
|
|
212
227
|
|
|
228
|
+
// ─── Row actions ──────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
const runDuplicate = async (p: Post) => {
|
|
231
|
+
const promise = duplicatePost(p.postTypeId, p.id).then((r) => {
|
|
232
|
+
if (r.error) throw new Error(r.error)
|
|
233
|
+
return r
|
|
234
|
+
})
|
|
235
|
+
toast.promise(promise, {
|
|
236
|
+
loading: `Duplicating "${p.title}"…`,
|
|
237
|
+
success: `"${p.title}" duplicated`,
|
|
238
|
+
error: (e: Error) => e.message || 'Duplicate failed',
|
|
239
|
+
})
|
|
240
|
+
try {
|
|
241
|
+
await promise
|
|
242
|
+
setRefreshToken((t) => t + 1)
|
|
243
|
+
} catch {
|
|
244
|
+
/* surfaced via toast.promise */
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const runRowDelete = async (p: Post) => {
|
|
249
|
+
const promise = deletePost(p.postTypeId, p.id).then((r) => {
|
|
250
|
+
if (r.error) throw new Error(r.error)
|
|
251
|
+
})
|
|
252
|
+
toast.promise(promise, {
|
|
253
|
+
loading: `Deleting "${p.title}"…`,
|
|
254
|
+
success: `"${p.title}" moved to trash`,
|
|
255
|
+
error: (e: Error) => e.message || 'Delete failed',
|
|
256
|
+
})
|
|
257
|
+
try {
|
|
258
|
+
await promise
|
|
259
|
+
setSelected((s) => {
|
|
260
|
+
const next = new Set(s)
|
|
261
|
+
next.delete(p.id)
|
|
262
|
+
return next
|
|
263
|
+
})
|
|
264
|
+
setRefreshToken((t) => t + 1)
|
|
265
|
+
} catch {
|
|
266
|
+
/* surfaced via toast.promise */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
213
270
|
// ─── Render ───────────────────────────────────────────────────────
|
|
214
271
|
|
|
215
272
|
return (
|
|
216
273
|
<div className="flex h-full flex-col gap-4 p-4 sm:p-6">
|
|
217
|
-
{/* Header
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
274
|
+
{/* Header — the "+ New Post" primary action sits on the right, per
|
|
275
|
+
the design's `.page-hdr` / `.hdr-r` layout. */}
|
|
276
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
277
|
+
<div className="flex flex-col gap-1">
|
|
278
|
+
<h1 className="text-foreground">Posts</h1>
|
|
279
|
+
<p className="text-muted-foreground text-sm">
|
|
280
|
+
{totalAll} total post{totalAll === 1 ? '' : 's'} across all types
|
|
281
|
+
</p>
|
|
282
|
+
</div>
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
onClick={() => onNavigate(newPostPath)}
|
|
286
|
+
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
|
|
287
|
+
>
|
|
288
|
+
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
289
|
+
New Post
|
|
290
|
+
</button>
|
|
223
291
|
</div>
|
|
224
292
|
|
|
225
293
|
{/* Type tabs */}
|
|
@@ -269,6 +337,7 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
|
|
|
269
337
|
<col style={{ width: '130px' }} />
|
|
270
338
|
<col style={{ width: '120px' }} />
|
|
271
339
|
<col style={{ width: '90px' }} />
|
|
340
|
+
<col style={{ width: '110px' }} />
|
|
272
341
|
</colgroup>
|
|
273
342
|
<thead className="border-border bg-muted/40 border-b">
|
|
274
343
|
<tr className="text-muted-foreground text-xs">
|
|
@@ -335,13 +404,16 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
|
|
|
335
404
|
onSort={handleSort}
|
|
336
405
|
/>
|
|
337
406
|
</th>
|
|
407
|
+
<th scope="col" className="px-3 py-2.5 text-right">
|
|
408
|
+
<span className="sr-only">Actions</span>
|
|
409
|
+
</th>
|
|
338
410
|
</tr>
|
|
339
411
|
</thead>
|
|
340
412
|
<tbody>
|
|
341
413
|
{loading && posts.length === 0 && <LoadingRows />}
|
|
342
414
|
{!loading && error && (
|
|
343
415
|
<tr>
|
|
344
|
-
<td colSpan={
|
|
416
|
+
<td colSpan={8} className="px-3 py-12 text-center">
|
|
345
417
|
<div className="text-foreground mb-2 text-sm font-medium">{error}</div>
|
|
346
418
|
<button
|
|
347
419
|
onClick={() => setRefreshToken((t) => t + 1)}
|
|
@@ -368,6 +440,8 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
|
|
|
368
440
|
selected={selected.has(p.id)}
|
|
369
441
|
onSelect={() => toggleOne(p.id)}
|
|
370
442
|
onOpen={() => onNavigate(`/posts/${p.postTypeId}/${p.id}/edit`)}
|
|
443
|
+
onDuplicate={() => void runDuplicate(p)}
|
|
444
|
+
onDelete={() => setRowDelete(p)}
|
|
371
445
|
/>
|
|
372
446
|
))}
|
|
373
447
|
</tbody>
|
|
@@ -384,6 +458,19 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
|
|
|
384
458
|
/>
|
|
385
459
|
</div>
|
|
386
460
|
|
|
461
|
+
<ConfirmDialog
|
|
462
|
+
open={rowDelete !== null}
|
|
463
|
+
title={`Delete "${rowDelete?.title ?? ''}"?`}
|
|
464
|
+
description="The post moves to trash and can be restored for 30 days."
|
|
465
|
+
confirmLabel="Delete"
|
|
466
|
+
destructive
|
|
467
|
+
onClose={() => setRowDelete(null)}
|
|
468
|
+
onConfirm={() => {
|
|
469
|
+
if (rowDelete) void runRowDelete(rowDelete)
|
|
470
|
+
setRowDelete(null)
|
|
471
|
+
}}
|
|
472
|
+
/>
|
|
473
|
+
|
|
387
474
|
<ConfirmDialog
|
|
388
475
|
open={pendingBulk === 'delete'}
|
|
389
476
|
title={`Delete ${selected.size} post${selected.size === 1 ? '' : 's'}?`}
|
|
@@ -669,11 +756,15 @@ function PostRow({
|
|
|
669
756
|
selected,
|
|
670
757
|
onSelect,
|
|
671
758
|
onOpen,
|
|
759
|
+
onDuplicate,
|
|
760
|
+
onDelete,
|
|
672
761
|
}: {
|
|
673
762
|
post: Post
|
|
674
763
|
selected: boolean
|
|
675
764
|
onSelect: () => void
|
|
676
765
|
onOpen: () => void
|
|
766
|
+
onDuplicate: () => void
|
|
767
|
+
onDelete: () => void
|
|
677
768
|
}) {
|
|
678
769
|
const p = paletteFor(post.postTypeAccentColor)
|
|
679
770
|
return (
|
|
@@ -693,7 +784,19 @@ function PostRow({
|
|
|
693
784
|
</td>
|
|
694
785
|
<td className="px-3 py-3">
|
|
695
786
|
<button type="button" onClick={onOpen} className="flex w-full items-center gap-3 text-left">
|
|
696
|
-
|
|
787
|
+
{post.imageUrl ? (
|
|
788
|
+
<img
|
|
789
|
+
src={post.imageUrl}
|
|
790
|
+
alt=""
|
|
791
|
+
loading="lazy"
|
|
792
|
+
className="block h-9 w-9 shrink-0 rounded-md object-cover"
|
|
793
|
+
/>
|
|
794
|
+
) : (
|
|
795
|
+
<span
|
|
796
|
+
aria-hidden
|
|
797
|
+
className={`block h-9 w-9 shrink-0 rounded-md ${p.bg.split(' ')[0]}`}
|
|
798
|
+
/>
|
|
799
|
+
)}
|
|
697
800
|
<span className="min-w-0">
|
|
698
801
|
<span className="text-foreground group-hover:text-primary block truncate text-sm font-medium">
|
|
699
802
|
{post.title}
|
|
@@ -727,6 +830,28 @@ function PostRow({
|
|
|
727
830
|
<td className="px-3 py-3">
|
|
728
831
|
<SeoScoreIndicator score={post.seoScore} />
|
|
729
832
|
</td>
|
|
833
|
+
<td className="px-3 py-3">
|
|
834
|
+
<RowActions>
|
|
835
|
+
<RowActionButton aria-label={`Edit "${post.title}"`} title="Edit" onClick={onOpen}>
|
|
836
|
+
<PenLine size={13} aria-hidden />
|
|
837
|
+
</RowActionButton>
|
|
838
|
+
<RowActionButton
|
|
839
|
+
aria-label={`Duplicate "${post.title}"`}
|
|
840
|
+
title="Duplicate"
|
|
841
|
+
onClick={onDuplicate}
|
|
842
|
+
>
|
|
843
|
+
<Copy size={12} aria-hidden />
|
|
844
|
+
</RowActionButton>
|
|
845
|
+
<RowActionButton
|
|
846
|
+
aria-label={`Delete "${post.title}"`}
|
|
847
|
+
title="Delete"
|
|
848
|
+
danger
|
|
849
|
+
onClick={onDelete}
|
|
850
|
+
>
|
|
851
|
+
<Trash2 size={12} aria-hidden />
|
|
852
|
+
</RowActionButton>
|
|
853
|
+
</RowActions>
|
|
854
|
+
</td>
|
|
730
855
|
</tr>
|
|
731
856
|
)
|
|
732
857
|
}
|
|
@@ -737,7 +862,7 @@ function LoadingRows() {
|
|
|
737
862
|
<>
|
|
738
863
|
{rows.map((_, i) => (
|
|
739
864
|
<tr key={i} className="border-border border-b last:border-b-0">
|
|
740
|
-
{Array.from({ length:
|
|
865
|
+
{Array.from({ length: 8 }).map((__, j) => (
|
|
741
866
|
<td key={j} className="px-3 py-3">
|
|
742
867
|
<div className="bg-muted h-4 w-full animate-pulse rounded" />
|
|
743
868
|
</td>
|
|
@@ -759,7 +884,7 @@ function EmptyRow({
|
|
|
759
884
|
}) {
|
|
760
885
|
return (
|
|
761
886
|
<tr>
|
|
762
|
-
<td colSpan={
|
|
887
|
+
<td colSpan={8} className="px-3 py-16 text-center">
|
|
763
888
|
<div className="text-foreground mb-2 text-sm font-medium">
|
|
764
889
|
{filtersActive ? 'No posts match the current filters.' : 'No posts yet.'}
|
|
765
890
|
</div>
|