@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
|
@@ -6,6 +6,10 @@ import {
|
|
|
6
6
|
ChevronLeft,
|
|
7
7
|
ChevronRight,
|
|
8
8
|
ChevronUp,
|
|
9
|
+
Copy,
|
|
10
|
+
List,
|
|
11
|
+
ListTree,
|
|
12
|
+
PenLine,
|
|
9
13
|
Plus,
|
|
10
14
|
Search,
|
|
11
15
|
Tag as TagIcon,
|
|
@@ -20,6 +24,8 @@ import {
|
|
|
20
24
|
bulkChangePageTags,
|
|
21
25
|
bulkDeletePages,
|
|
22
26
|
bulkUpdatePages,
|
|
27
|
+
deletePage,
|
|
28
|
+
duplicatePage,
|
|
23
29
|
fetchPagesDataset,
|
|
24
30
|
groupPages,
|
|
25
31
|
type BulkResult,
|
|
@@ -29,9 +35,12 @@ import {
|
|
|
29
35
|
type PageStatus,
|
|
30
36
|
type PageTag,
|
|
31
37
|
} from '../../lib/pages-service.js'
|
|
38
|
+
import { buildPageTree, defaultExpandedIds, flattenPageTree } from '../../lib/pages-tree.js'
|
|
32
39
|
import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
|
|
33
40
|
import { PageTagChip, PageTagsCell } from '../../components/pages/tag-badges.js'
|
|
34
41
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
42
|
+
import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
|
|
43
|
+
import { PagesTreeTable } from './PagesTreeTable.js'
|
|
35
44
|
|
|
36
45
|
/**
|
|
37
46
|
* Pages management surface — table with a tag-chip filter bar, group-by,
|
|
@@ -51,8 +60,19 @@ interface PagesListViewProps {
|
|
|
51
60
|
|
|
52
61
|
type SortBy = 'title' | 'author' | 'status' | 'modified' | 'seo'
|
|
53
62
|
type Dir = 'asc' | 'desc'
|
|
63
|
+
type ViewMode = 'list' | 'tree'
|
|
54
64
|
|
|
55
65
|
const PAGE_SIZE = 25
|
|
66
|
+
const VIEW_MODE_KEY = 'actuate.pages.viewMode'
|
|
67
|
+
|
|
68
|
+
function readStoredViewMode(): ViewMode {
|
|
69
|
+
if (typeof window === 'undefined') return 'list'
|
|
70
|
+
try {
|
|
71
|
+
return localStorage.getItem(VIEW_MODE_KEY) === 'tree' ? 'tree' : 'list'
|
|
72
|
+
} catch {
|
|
73
|
+
return 'list'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
56
76
|
|
|
57
77
|
export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
58
78
|
const [search, setSearch] = useState('')
|
|
@@ -64,12 +84,17 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
64
84
|
const [dir, setDir] = useState<Dir>('asc')
|
|
65
85
|
const [page, setPage] = useState(1)
|
|
66
86
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
87
|
+
const [viewMode, setViewMode] = useState<ViewMode>(readStoredViewMode)
|
|
88
|
+
// null = "not yet touched" → falls back to the default expand state
|
|
89
|
+
// (all root nodes with children open) computed from the current tree.
|
|
90
|
+
const [expandedOverride, setExpandedOverride] = useState<Set<string> | null>(null)
|
|
67
91
|
|
|
68
92
|
const [dataset, setDataset] = useState<PagesDataset | null>(null)
|
|
69
93
|
const [loading, setLoading] = useState(true)
|
|
70
94
|
const [error, setError] = useState<string | null>(null)
|
|
71
95
|
const [refreshToken, setRefreshToken] = useState(0)
|
|
72
96
|
const [pendingDelete, setPendingDelete] = useState(false)
|
|
97
|
+
const [rowDelete, setRowDelete] = useState<Page | null>(null)
|
|
73
98
|
|
|
74
99
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
75
100
|
useEffect(() => {
|
|
@@ -148,6 +173,49 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
148
173
|
|
|
149
174
|
const displayGroups = useMemo(() => groupPages(pages, groupBy, tags), [pages, groupBy, tags])
|
|
150
175
|
|
|
176
|
+
// ─── Tree mode ─────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
const treeMode = viewMode === 'tree'
|
|
179
|
+
const treeRoots = useMemo(() => buildPageTree(allPages), [allPages])
|
|
180
|
+
const defaultExpanded = useMemo(() => defaultExpandedIds(treeRoots), [treeRoots])
|
|
181
|
+
const expanded = expandedOverride ?? defaultExpanded
|
|
182
|
+
|
|
183
|
+
// Per the design, an active search or status filter flattens the tree:
|
|
184
|
+
// every match renders at depth 0 and expand state is ignored. (Tag
|
|
185
|
+
// filters and group-by don't apply in tree mode — the hierarchy *is*
|
|
186
|
+
// the grouping.)
|
|
187
|
+
const treeFiltersActive = debouncedSearch !== '' || status !== 'all'
|
|
188
|
+
const treeRows = useMemo(() => {
|
|
189
|
+
if (treeFiltersActive) {
|
|
190
|
+
return applyPagesQuery(allPages, {
|
|
191
|
+
status,
|
|
192
|
+
search: debouncedSearch,
|
|
193
|
+
sortBy: 'title',
|
|
194
|
+
sortDirection: 'asc',
|
|
195
|
+
}).map((p) => ({ page: p, depth: 0, hasChildren: false }))
|
|
196
|
+
}
|
|
197
|
+
return flattenPageTree(treeRoots, expanded)
|
|
198
|
+
}, [treeFiltersActive, allPages, status, debouncedSearch, treeRoots, expanded])
|
|
199
|
+
|
|
200
|
+
const toggleExpand = (id: string) =>
|
|
201
|
+
setExpandedOverride((prev) => {
|
|
202
|
+
const next = new Set(prev ?? defaultExpanded)
|
|
203
|
+
if (next.has(id)) next.delete(id)
|
|
204
|
+
else next.add(id)
|
|
205
|
+
return next
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const changeViewMode = (mode: ViewMode) => {
|
|
209
|
+
if (mode === viewMode) return
|
|
210
|
+
setViewMode(mode)
|
|
211
|
+
setSelected(new Set())
|
|
212
|
+
try {
|
|
213
|
+
localStorage.setItem(VIEW_MODE_KEY, mode)
|
|
214
|
+
} catch {
|
|
215
|
+
/* storage unavailable (private mode) — the toggle still works for the session */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
151
219
|
const handleSort = useCallback(
|
|
152
220
|
(field: SortBy) => {
|
|
153
221
|
if (sortBy === field) {
|
|
@@ -192,16 +260,26 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
192
260
|
return next
|
|
193
261
|
})
|
|
194
262
|
|
|
195
|
-
|
|
263
|
+
// Selection operates over the rows currently visible in the active
|
|
264
|
+
// view mode (paged flat list, or visible tree rows).
|
|
265
|
+
const visiblePages = useMemo(
|
|
266
|
+
() => (treeMode ? treeRows.map((r) => r.page) : pages),
|
|
267
|
+
[treeMode, treeRows, pages],
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
const allOnPageSelected = visiblePages.length > 0 && visiblePages.every((p) => selected.has(p.id))
|
|
196
271
|
const toggleAllOnPage = (checked: boolean) =>
|
|
197
272
|
setSelected((s) => {
|
|
198
273
|
const next = new Set(s)
|
|
199
|
-
if (checked) for (const p of
|
|
200
|
-
else for (const p of
|
|
274
|
+
if (checked) for (const p of visiblePages) next.add(p.id)
|
|
275
|
+
else for (const p of visiblePages) next.delete(p.id)
|
|
201
276
|
return next
|
|
202
277
|
})
|
|
203
278
|
|
|
204
|
-
const selectedPages = useMemo(
|
|
279
|
+
const selectedPages = useMemo(
|
|
280
|
+
() => visiblePages.filter((p) => selected.has(p.id)),
|
|
281
|
+
[visiblePages, selected],
|
|
282
|
+
)
|
|
205
283
|
const deletablePages = useMemo(() => selectedPages.filter((p) => !p.isHomepage), [selectedPages])
|
|
206
284
|
const blockedHomepage = selectedPages.length !== deletablePages.length
|
|
207
285
|
|
|
@@ -284,16 +362,71 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
284
362
|
afterBulk()
|
|
285
363
|
}
|
|
286
364
|
|
|
365
|
+
// ─── Row actions ───────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
const runDuplicate = async (p: Page) => {
|
|
368
|
+
const promise = duplicatePage(p.id).then((r) => {
|
|
369
|
+
if (r.error) throw new Error(r.error)
|
|
370
|
+
return r
|
|
371
|
+
})
|
|
372
|
+
toast.promise(promise, {
|
|
373
|
+
loading: `Duplicating "${p.title}"…`,
|
|
374
|
+
success: `"${p.title}" duplicated`,
|
|
375
|
+
error: (e: Error) => e.message || 'Duplicate failed',
|
|
376
|
+
})
|
|
377
|
+
try {
|
|
378
|
+
await promise
|
|
379
|
+
setRefreshToken((t) => t + 1)
|
|
380
|
+
} catch {
|
|
381
|
+
/* surfaced via toast.promise */
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const runRowDelete = async (p: Page) => {
|
|
386
|
+
const promise = deletePage(p.id).then((r) => {
|
|
387
|
+
if (r.error) throw new Error(r.error)
|
|
388
|
+
})
|
|
389
|
+
toast.promise(promise, {
|
|
390
|
+
loading: `Deleting "${p.title}"…`,
|
|
391
|
+
success: `"${p.title}" moved to trash`,
|
|
392
|
+
error: (e: Error) => e.message || 'Delete failed',
|
|
393
|
+
})
|
|
394
|
+
try {
|
|
395
|
+
await promise
|
|
396
|
+
setSelected((s) => {
|
|
397
|
+
const next = new Set(s)
|
|
398
|
+
next.delete(p.id)
|
|
399
|
+
return next
|
|
400
|
+
})
|
|
401
|
+
setRefreshToken((t) => t + 1)
|
|
402
|
+
} catch {
|
|
403
|
+
/* surfaced via toast.promise */
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
287
407
|
// ─── Render ────────────────────────────────────────────────────────
|
|
288
408
|
|
|
289
409
|
return (
|
|
290
410
|
<div className="flex h-full flex-col gap-4 p-4 sm:p-6">
|
|
291
|
-
{/* Header
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
<
|
|
295
|
-
|
|
296
|
-
|
|
411
|
+
{/* Header — the "+ New page" primary action sits on the right, per
|
|
412
|
+
the design's `.page-hdr` / `.hdr-r` layout. */}
|
|
413
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
414
|
+
<div className="flex flex-col gap-1">
|
|
415
|
+
<h1 className="text-foreground">Pages</h1>
|
|
416
|
+
<p className="text-muted-foreground text-sm">
|
|
417
|
+
{treeMode
|
|
418
|
+
? `${grandTotal} page${grandTotal === 1 ? '' : 's'} total`
|
|
419
|
+
: `${grandTotal} page${grandTotal === 1 ? '' : 's'} · organized by tags`}
|
|
420
|
+
</p>
|
|
421
|
+
</div>
|
|
422
|
+
<button
|
|
423
|
+
type="button"
|
|
424
|
+
onClick={() => onNavigate('/pages/new')}
|
|
425
|
+
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"
|
|
426
|
+
>
|
|
427
|
+
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
428
|
+
New page
|
|
429
|
+
</button>
|
|
297
430
|
</div>
|
|
298
431
|
|
|
299
432
|
{/* Controls row */}
|
|
@@ -313,17 +446,21 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
313
446
|
/>
|
|
314
447
|
</div>
|
|
315
448
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
{
|
|
322
|
-
{
|
|
323
|
-
{
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
449
|
+
{/* Group-by only applies to the flat list — in tree mode the
|
|
450
|
+
hierarchy is the grouping. */}
|
|
451
|
+
{!treeMode && (
|
|
452
|
+
<FilterSelect
|
|
453
|
+
label="Group by"
|
|
454
|
+
value={groupBy}
|
|
455
|
+
onChange={(v) => setGroupBy(v as PageGroupBy)}
|
|
456
|
+
options={[
|
|
457
|
+
{ value: 'none', label: 'None' },
|
|
458
|
+
{ value: 'tag', label: 'Tag' },
|
|
459
|
+
{ value: 'status', label: 'Status' },
|
|
460
|
+
{ value: 'author', label: 'Author' },
|
|
461
|
+
]}
|
|
462
|
+
/>
|
|
463
|
+
)}
|
|
327
464
|
|
|
328
465
|
<FilterSelect
|
|
329
466
|
label="Status"
|
|
@@ -340,45 +477,54 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
340
477
|
]}
|
|
341
478
|
/>
|
|
342
479
|
|
|
480
|
+
<ViewModeToggle mode={viewMode} onChange={changeViewMode} />
|
|
481
|
+
|
|
343
482
|
<span className="text-muted-foreground ml-auto text-sm tabular-nums">
|
|
344
|
-
{
|
|
483
|
+
{treeMode
|
|
484
|
+
? treeFiltersActive
|
|
485
|
+
? `${treeRows.length} result${treeRows.length === 1 ? '' : 's'}`
|
|
486
|
+
: `${grandTotal} page${grandTotal === 1 ? '' : 's'}`
|
|
487
|
+
: `${total} of ${grandTotal}`}
|
|
345
488
|
</span>
|
|
346
489
|
</div>
|
|
347
490
|
|
|
348
|
-
{/* Tag filter bar
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
491
|
+
{/* Tag filter bar (flat list only — tree mode replaces tag grouping
|
|
492
|
+
with the page hierarchy) */}
|
|
493
|
+
{!treeMode && (
|
|
494
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
495
|
+
<span className="text-muted-foreground text-[10px] font-medium tracking-wider uppercase">
|
|
496
|
+
Tags
|
|
497
|
+
</span>
|
|
498
|
+
{tags.map((t) => (
|
|
499
|
+
<PageTagChip
|
|
500
|
+
key={t.slug}
|
|
501
|
+
name={t.name}
|
|
502
|
+
color={t.color}
|
|
503
|
+
count={t.pageCount}
|
|
504
|
+
selected={selectedTags.has(t.slug)}
|
|
505
|
+
onToggle={() => toggleTag(t.slug)}
|
|
506
|
+
/>
|
|
507
|
+
))}
|
|
508
|
+
{selectedTags.size > 0 && (
|
|
509
|
+
<button
|
|
510
|
+
type="button"
|
|
511
|
+
onClick={() => setSelectedTags(new Set())}
|
|
512
|
+
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
|
|
513
|
+
>
|
|
514
|
+
<X className="h-3 w-3" aria-hidden />
|
|
515
|
+
Clear tags
|
|
516
|
+
</button>
|
|
517
|
+
)}
|
|
364
518
|
<button
|
|
365
519
|
type="button"
|
|
366
|
-
onClick={() =>
|
|
367
|
-
className="text-
|
|
520
|
+
onClick={() => onNavigate('/pages/tags')}
|
|
521
|
+
className="text-primary ml-auto inline-flex items-center gap-1 text-xs font-medium hover:underline"
|
|
368
522
|
>
|
|
369
|
-
<
|
|
370
|
-
|
|
523
|
+
<TagIcon className="h-3 w-3" aria-hidden />
|
|
524
|
+
Manage tags
|
|
371
525
|
</button>
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
type="button"
|
|
375
|
-
onClick={() => onNavigate('/pages/tags')}
|
|
376
|
-
className="text-primary ml-auto inline-flex items-center gap-1 text-xs font-medium hover:underline"
|
|
377
|
-
>
|
|
378
|
-
<TagIcon className="h-3 w-3" aria-hidden />
|
|
379
|
-
Manage tags
|
|
380
|
-
</button>
|
|
381
|
-
</div>
|
|
526
|
+
</div>
|
|
527
|
+
)}
|
|
382
528
|
|
|
383
529
|
{/* Bulk action bar */}
|
|
384
530
|
{selected.size > 0 && (
|
|
@@ -398,144 +544,202 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
398
544
|
|
|
399
545
|
{/* Table card */}
|
|
400
546
|
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
|
|
401
|
-
{
|
|
547
|
+
{treeMode ? (
|
|
548
|
+
<PagesTreeTable
|
|
549
|
+
rows={treeRows}
|
|
550
|
+
loading={loading}
|
|
551
|
+
error={error}
|
|
552
|
+
flattened={treeFiltersActive}
|
|
553
|
+
selected={selected}
|
|
554
|
+
expanded={expanded}
|
|
555
|
+
onToggleExpand={toggleExpand}
|
|
556
|
+
onToggleSelect={toggleOne}
|
|
557
|
+
onToggleAll={toggleAllOnPage}
|
|
558
|
+
onOpen={(p) => onNavigate(`/pages/${p.id}/edit`)}
|
|
559
|
+
onAddSubPage={(p) => onNavigate(`/pages/new?parent=${encodeURIComponent(p.id)}`)}
|
|
560
|
+
onDuplicate={(p) => void runDuplicate(p)}
|
|
561
|
+
onDelete={(p) => setRowDelete(p)}
|
|
562
|
+
onRetry={() => setRefreshToken((t) => t + 1)}
|
|
563
|
+
onClearFilters={clearFilters}
|
|
564
|
+
onNewPage={() => onNavigate('/pages/new')}
|
|
565
|
+
/>
|
|
566
|
+
) : (
|
|
567
|
+
<>
|
|
568
|
+
{/* Horizontal scroll on narrow viewports: the fixed columns keep their
|
|
402
569
|
width and the table scrolls sideways instead of crushing content. */}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
<th scope="col" aria-sort={ariaSortFor('status')} className="px-3 py-2.5 text-left">
|
|
447
|
-
<SortHeader
|
|
448
|
-
label="Status"
|
|
449
|
-
field="status"
|
|
450
|
-
current={sortBy}
|
|
451
|
-
dir={dir}
|
|
452
|
-
onSort={handleSort}
|
|
453
|
-
/>
|
|
454
|
-
</th>
|
|
455
|
-
<th
|
|
456
|
-
scope="col"
|
|
457
|
-
aria-sort={ariaSortFor('modified')}
|
|
458
|
-
className="px-3 py-2.5 text-left"
|
|
459
|
-
>
|
|
460
|
-
<SortHeader
|
|
461
|
-
label="Last Modified"
|
|
462
|
-
field="modified"
|
|
463
|
-
current={sortBy}
|
|
464
|
-
dir={dir}
|
|
465
|
-
onSort={handleSort}
|
|
466
|
-
/>
|
|
467
|
-
</th>
|
|
468
|
-
<th scope="col" aria-sort={ariaSortFor('seo')} className="px-3 py-2.5 text-left">
|
|
469
|
-
<SortHeader
|
|
470
|
-
label="SEO"
|
|
471
|
-
field="seo"
|
|
472
|
-
current={sortBy}
|
|
473
|
-
dir={dir}
|
|
474
|
-
onSort={handleSort}
|
|
475
|
-
/>
|
|
476
|
-
</th>
|
|
477
|
-
</tr>
|
|
478
|
-
</thead>
|
|
479
|
-
<tbody>
|
|
480
|
-
{loading && pages.length === 0 && <LoadingRows />}
|
|
481
|
-
{!loading && error && (
|
|
482
|
-
<tr>
|
|
483
|
-
<td colSpan={7} className="px-3 py-12 text-center">
|
|
484
|
-
<div className="text-foreground mb-2 text-sm font-medium">{error}</div>
|
|
485
|
-
<button
|
|
486
|
-
onClick={() => setRefreshToken((t) => t + 1)}
|
|
487
|
-
className="text-primary text-sm hover:underline"
|
|
570
|
+
<div className="overflow-x-auto">
|
|
571
|
+
<table className="w-full min-w-[800px] table-fixed" role="table" aria-label="Pages">
|
|
572
|
+
<colgroup>
|
|
573
|
+
<col style={{ width: '40px' }} />
|
|
574
|
+
<col />
|
|
575
|
+
<col style={{ width: '200px' }} />
|
|
576
|
+
<col style={{ width: '170px' }} />
|
|
577
|
+
<col style={{ width: '120px' }} />
|
|
578
|
+
<col style={{ width: '130px' }} />
|
|
579
|
+
<col style={{ width: '80px' }} />
|
|
580
|
+
<col style={{ width: '110px' }} />
|
|
581
|
+
</colgroup>
|
|
582
|
+
<thead className="border-border bg-muted/40 border-b">
|
|
583
|
+
<tr className="text-muted-foreground text-xs">
|
|
584
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
585
|
+
<input
|
|
586
|
+
type="checkbox"
|
|
587
|
+
aria-label="Select all pages on this page"
|
|
588
|
+
checked={allOnPageSelected}
|
|
589
|
+
onChange={(e) => toggleAllOnPage(e.target.checked)}
|
|
590
|
+
className="border-border accent-primary rounded"
|
|
591
|
+
/>
|
|
592
|
+
</th>
|
|
593
|
+
<th
|
|
594
|
+
scope="col"
|
|
595
|
+
aria-sort={ariaSortFor('title')}
|
|
596
|
+
className="px-3 py-2.5 text-left"
|
|
597
|
+
>
|
|
598
|
+
<SortHeader
|
|
599
|
+
label="Title"
|
|
600
|
+
field="title"
|
|
601
|
+
current={sortBy}
|
|
602
|
+
dir={dir}
|
|
603
|
+
onSort={handleSort}
|
|
604
|
+
/>
|
|
605
|
+
</th>
|
|
606
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
607
|
+
<span className="text-xs font-medium tracking-wider uppercase">Tags</span>
|
|
608
|
+
</th>
|
|
609
|
+
<th
|
|
610
|
+
scope="col"
|
|
611
|
+
aria-sort={ariaSortFor('author')}
|
|
612
|
+
className="px-3 py-2.5 text-left"
|
|
488
613
|
>
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
<EmptyRow
|
|
496
|
-
filtersActive={filtersAreActive}
|
|
497
|
-
onClear={clearFilters}
|
|
498
|
-
onNewPage={() => onNavigate('/pages/new')}
|
|
499
|
-
/>
|
|
500
|
-
)}
|
|
501
|
-
{!loading &&
|
|
502
|
-
!error &&
|
|
503
|
-
displayGroups.map((group) => (
|
|
504
|
-
<GroupBlock
|
|
505
|
-
key={group.key}
|
|
506
|
-
showHeader={grouped}
|
|
507
|
-
label={group.label}
|
|
508
|
-
color={group.color}
|
|
509
|
-
count={group.pages.length}
|
|
510
|
-
>
|
|
511
|
-
{group.pages.map((p) => (
|
|
512
|
-
<PageRow
|
|
513
|
-
key={p.id}
|
|
514
|
-
page={p}
|
|
515
|
-
selected={selected.has(p.id)}
|
|
516
|
-
resolveTag={(slug) => tagBySlug.get(slug)}
|
|
517
|
-
onSelect={() => toggleOne(p.id)}
|
|
518
|
-
onOpen={() => onNavigate(`/pages/${p.id}/edit`)}
|
|
614
|
+
<SortHeader
|
|
615
|
+
label="Author"
|
|
616
|
+
field="author"
|
|
617
|
+
current={sortBy}
|
|
618
|
+
dir={dir}
|
|
619
|
+
onSort={handleSort}
|
|
519
620
|
/>
|
|
621
|
+
</th>
|
|
622
|
+
<th
|
|
623
|
+
scope="col"
|
|
624
|
+
aria-sort={ariaSortFor('status')}
|
|
625
|
+
className="px-3 py-2.5 text-left"
|
|
626
|
+
>
|
|
627
|
+
<SortHeader
|
|
628
|
+
label="Status"
|
|
629
|
+
field="status"
|
|
630
|
+
current={sortBy}
|
|
631
|
+
dir={dir}
|
|
632
|
+
onSort={handleSort}
|
|
633
|
+
/>
|
|
634
|
+
</th>
|
|
635
|
+
<th
|
|
636
|
+
scope="col"
|
|
637
|
+
aria-sort={ariaSortFor('modified')}
|
|
638
|
+
className="px-3 py-2.5 text-left"
|
|
639
|
+
>
|
|
640
|
+
<SortHeader
|
|
641
|
+
label="Last Modified"
|
|
642
|
+
field="modified"
|
|
643
|
+
current={sortBy}
|
|
644
|
+
dir={dir}
|
|
645
|
+
onSort={handleSort}
|
|
646
|
+
/>
|
|
647
|
+
</th>
|
|
648
|
+
<th
|
|
649
|
+
scope="col"
|
|
650
|
+
aria-sort={ariaSortFor('seo')}
|
|
651
|
+
className="px-3 py-2.5 text-left"
|
|
652
|
+
>
|
|
653
|
+
<SortHeader
|
|
654
|
+
label="SEO"
|
|
655
|
+
field="seo"
|
|
656
|
+
current={sortBy}
|
|
657
|
+
dir={dir}
|
|
658
|
+
onSort={handleSort}
|
|
659
|
+
/>
|
|
660
|
+
</th>
|
|
661
|
+
<th scope="col" className="px-3 py-2.5 text-right">
|
|
662
|
+
<span className="sr-only">Actions</span>
|
|
663
|
+
</th>
|
|
664
|
+
</tr>
|
|
665
|
+
</thead>
|
|
666
|
+
<tbody>
|
|
667
|
+
{loading && pages.length === 0 && <LoadingRows />}
|
|
668
|
+
{!loading && error && (
|
|
669
|
+
<tr>
|
|
670
|
+
<td colSpan={8} className="px-3 py-12 text-center">
|
|
671
|
+
<div className="text-foreground mb-2 text-sm font-medium">{error}</div>
|
|
672
|
+
<button
|
|
673
|
+
onClick={() => setRefreshToken((t) => t + 1)}
|
|
674
|
+
className="text-primary text-sm hover:underline"
|
|
675
|
+
>
|
|
676
|
+
Try again
|
|
677
|
+
</button>
|
|
678
|
+
</td>
|
|
679
|
+
</tr>
|
|
680
|
+
)}
|
|
681
|
+
{!loading && !error && pages.length === 0 && (
|
|
682
|
+
<EmptyRow
|
|
683
|
+
filtersActive={filtersAreActive}
|
|
684
|
+
onClear={clearFilters}
|
|
685
|
+
onNewPage={() => onNavigate('/pages/new')}
|
|
686
|
+
/>
|
|
687
|
+
)}
|
|
688
|
+
{!loading &&
|
|
689
|
+
!error &&
|
|
690
|
+
displayGroups.map((group) => (
|
|
691
|
+
<GroupBlock
|
|
692
|
+
key={group.key}
|
|
693
|
+
showHeader={grouped}
|
|
694
|
+
label={group.label}
|
|
695
|
+
color={group.color}
|
|
696
|
+
count={group.pages.length}
|
|
697
|
+
>
|
|
698
|
+
{group.pages.map((p) => (
|
|
699
|
+
<PageRow
|
|
700
|
+
key={p.id}
|
|
701
|
+
page={p}
|
|
702
|
+
selected={selected.has(p.id)}
|
|
703
|
+
resolveTag={(slug) => tagBySlug.get(slug)}
|
|
704
|
+
onSelect={() => toggleOne(p.id)}
|
|
705
|
+
onOpen={() => onNavigate(`/pages/${p.id}/edit`)}
|
|
706
|
+
onDuplicate={() => void runDuplicate(p)}
|
|
707
|
+
onDelete={() => setRowDelete(p)}
|
|
708
|
+
/>
|
|
709
|
+
))}
|
|
710
|
+
</GroupBlock>
|
|
520
711
|
))}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
</
|
|
524
|
-
</table>
|
|
525
|
-
</div>
|
|
712
|
+
</tbody>
|
|
713
|
+
</table>
|
|
714
|
+
</div>
|
|
526
715
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
716
|
+
{/* Pagination only for the flat (ungrouped) view. */}
|
|
717
|
+
{!grouped && (
|
|
718
|
+
<PaginationFooter
|
|
719
|
+
page={page}
|
|
720
|
+
totalPages={totalPages}
|
|
721
|
+
total={total}
|
|
722
|
+
pageSize={PAGE_SIZE}
|
|
723
|
+
onPage={setPage}
|
|
724
|
+
/>
|
|
725
|
+
)}
|
|
726
|
+
</>
|
|
536
727
|
)}
|
|
537
728
|
</div>
|
|
538
729
|
|
|
730
|
+
<ConfirmDialog
|
|
731
|
+
open={rowDelete !== null}
|
|
732
|
+
title={`Delete "${rowDelete?.title ?? ''}"?`}
|
|
733
|
+
description="The page moves to trash and can be restored for 30 days."
|
|
734
|
+
confirmLabel="Delete"
|
|
735
|
+
destructive
|
|
736
|
+
onClose={() => setRowDelete(null)}
|
|
737
|
+
onConfirm={() => {
|
|
738
|
+
if (rowDelete) void runRowDelete(rowDelete)
|
|
739
|
+
setRowDelete(null)
|
|
740
|
+
}}
|
|
741
|
+
/>
|
|
742
|
+
|
|
539
743
|
<ConfirmDialog
|
|
540
744
|
open={pendingDelete}
|
|
541
745
|
title={`Delete ${deletablePages.length} page${deletablePages.length === 1 ? '' : 's'}?`}
|
|
@@ -590,6 +794,49 @@ function SortHeader({
|
|
|
590
794
|
)
|
|
591
795
|
}
|
|
592
796
|
|
|
797
|
+
/**
|
|
798
|
+
* List / Tree segmented control (`.view-toggle` in the design). The
|
|
799
|
+
* choice persists in localStorage so the preferred mode survives reloads.
|
|
800
|
+
*/
|
|
801
|
+
function ViewModeToggle({
|
|
802
|
+
mode,
|
|
803
|
+
onChange,
|
|
804
|
+
}: {
|
|
805
|
+
mode: ViewMode
|
|
806
|
+
onChange: (mode: ViewMode) => void
|
|
807
|
+
}) {
|
|
808
|
+
const btn = (target: ViewMode, label: string, icon: React.ReactNode) => {
|
|
809
|
+
const active = mode === target
|
|
810
|
+
return (
|
|
811
|
+
<button
|
|
812
|
+
type="button"
|
|
813
|
+
aria-label={label}
|
|
814
|
+
title={label}
|
|
815
|
+
aria-pressed={active}
|
|
816
|
+
onClick={() => onChange(target)}
|
|
817
|
+
className={`flex h-[30px] w-[34px] items-center justify-center transition-colors duration-[var(--motion-fast)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none ${
|
|
818
|
+
active
|
|
819
|
+
? 'bg-[var(--acc-l)] text-[var(--acc)]'
|
|
820
|
+
: 'bg-[var(--card)] text-[var(--muted)] hover:text-[var(--acc)]'
|
|
821
|
+
}`}
|
|
822
|
+
>
|
|
823
|
+
{icon}
|
|
824
|
+
</button>
|
|
825
|
+
)
|
|
826
|
+
}
|
|
827
|
+
return (
|
|
828
|
+
<div
|
|
829
|
+
role="group"
|
|
830
|
+
aria-label="View mode"
|
|
831
|
+
className="flex overflow-hidden rounded-lg border border-[var(--bdr)] shadow-sm"
|
|
832
|
+
>
|
|
833
|
+
{btn('list', 'List view', <List size={15} aria-hidden />)}
|
|
834
|
+
<span aria-hidden className="w-px bg-[var(--bdr)]" />
|
|
835
|
+
{btn('tree', 'Tree view', <ListTree size={15} aria-hidden />)}
|
|
836
|
+
</div>
|
|
837
|
+
)
|
|
838
|
+
}
|
|
839
|
+
|
|
593
840
|
function FilterSelect({
|
|
594
841
|
label,
|
|
595
842
|
value,
|
|
@@ -636,7 +883,7 @@ function GroupBlock({
|
|
|
636
883
|
<>
|
|
637
884
|
{showHeader && (
|
|
638
885
|
<tr className="bg-muted/30 border-border border-b">
|
|
639
|
-
<td colSpan={
|
|
886
|
+
<td colSpan={8} className="px-3 py-2">
|
|
640
887
|
<span className="text-foreground inline-flex items-center gap-2 text-xs font-medium tracking-wider uppercase">
|
|
641
888
|
{color && <span aria-hidden className={`h-2 w-2 rounded-full ${dotFor(color)}`} />}
|
|
642
889
|
{label}
|
|
@@ -779,12 +1026,16 @@ function PageRow({
|
|
|
779
1026
|
resolveTag,
|
|
780
1027
|
onSelect,
|
|
781
1028
|
onOpen,
|
|
1029
|
+
onDuplicate,
|
|
1030
|
+
onDelete,
|
|
782
1031
|
}: {
|
|
783
1032
|
page: Page
|
|
784
1033
|
selected: boolean
|
|
785
1034
|
resolveTag: (slug: string) => { name: string; color: string | null } | undefined
|
|
786
1035
|
onSelect: () => void
|
|
787
1036
|
onOpen: () => void
|
|
1037
|
+
onDuplicate: () => void
|
|
1038
|
+
onDelete: () => void
|
|
788
1039
|
}) {
|
|
789
1040
|
return (
|
|
790
1041
|
<tr
|
|
@@ -835,6 +1086,29 @@ function PageRow({
|
|
|
835
1086
|
<td className="px-3 py-3">
|
|
836
1087
|
<SeoScoreIndicator score={page.seoScore} />
|
|
837
1088
|
</td>
|
|
1089
|
+
<td className="px-3 py-3">
|
|
1090
|
+
<RowActions>
|
|
1091
|
+
<RowActionButton aria-label={`Edit "${page.title}"`} title="Edit" onClick={onOpen}>
|
|
1092
|
+
<PenLine size={13} aria-hidden />
|
|
1093
|
+
</RowActionButton>
|
|
1094
|
+
<RowActionButton
|
|
1095
|
+
aria-label={`Duplicate "${page.title}"`}
|
|
1096
|
+
title="Duplicate"
|
|
1097
|
+
onClick={onDuplicate}
|
|
1098
|
+
>
|
|
1099
|
+
<Copy size={12} aria-hidden />
|
|
1100
|
+
</RowActionButton>
|
|
1101
|
+
<RowActionButton
|
|
1102
|
+
aria-label={`Delete "${page.title}"`}
|
|
1103
|
+
title={page.isHomepage ? 'The homepage cannot be deleted' : 'Delete'}
|
|
1104
|
+
danger
|
|
1105
|
+
disabled={page.isHomepage}
|
|
1106
|
+
onClick={onDelete}
|
|
1107
|
+
>
|
|
1108
|
+
<Trash2 size={12} aria-hidden />
|
|
1109
|
+
</RowActionButton>
|
|
1110
|
+
</RowActions>
|
|
1111
|
+
</td>
|
|
838
1112
|
</tr>
|
|
839
1113
|
)
|
|
840
1114
|
}
|
|
@@ -844,7 +1118,7 @@ function LoadingRows() {
|
|
|
844
1118
|
<>
|
|
845
1119
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
846
1120
|
<tr key={i} className="border-border border-b last:border-b-0">
|
|
847
|
-
{Array.from({ length:
|
|
1121
|
+
{Array.from({ length: 8 }).map((__, j) => (
|
|
848
1122
|
<td key={j} className="px-3 py-3">
|
|
849
1123
|
<div className="bg-muted h-4 w-full animate-pulse rounded" />
|
|
850
1124
|
</td>
|
|
@@ -866,7 +1140,7 @@ function EmptyRow({
|
|
|
866
1140
|
}) {
|
|
867
1141
|
return (
|
|
868
1142
|
<tr>
|
|
869
|
-
<td colSpan={
|
|
1143
|
+
<td colSpan={8} className="px-3 py-16 text-center">
|
|
870
1144
|
<div className="text-foreground mb-2 text-sm font-medium">
|
|
871
1145
|
{filtersActive ? 'No pages match your filters' : 'No pages yet'}
|
|
872
1146
|
</div>
|