@actuate-media/cms-admin 0.66.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 +21 -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/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.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-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/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +31 -2
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +28 -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 +90 -10
- 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/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/package.json +2 -2
- 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/index.ts +10 -0
- package/src/lib/page-editor-service.ts +16 -2
- package/src/lib/pages-tree.ts +145 -0
- package/src/views/Dashboard.tsx +238 -196
- package/src/views/MediaBrowser.tsx +641 -511
- package/src/views/MediaSeoAudit.tsx +320 -0
- package/src/views/Pages/PagesListView.tsx +352 -183
- package/src/views/Pages/PagesTreeTable.tsx +339 -0
- 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/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
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
ChevronRight,
|
|
8
8
|
ChevronUp,
|
|
9
9
|
Copy,
|
|
10
|
+
List,
|
|
11
|
+
ListTree,
|
|
10
12
|
PenLine,
|
|
11
13
|
Plus,
|
|
12
14
|
Search,
|
|
@@ -33,10 +35,12 @@ import {
|
|
|
33
35
|
type PageStatus,
|
|
34
36
|
type PageTag,
|
|
35
37
|
} from '../../lib/pages-service.js'
|
|
38
|
+
import { buildPageTree, defaultExpandedIds, flattenPageTree } from '../../lib/pages-tree.js'
|
|
36
39
|
import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
|
|
37
40
|
import { PageTagChip, PageTagsCell } from '../../components/pages/tag-badges.js'
|
|
38
41
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
39
42
|
import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
|
|
43
|
+
import { PagesTreeTable } from './PagesTreeTable.js'
|
|
40
44
|
|
|
41
45
|
/**
|
|
42
46
|
* Pages management surface — table with a tag-chip filter bar, group-by,
|
|
@@ -56,8 +60,19 @@ interface PagesListViewProps {
|
|
|
56
60
|
|
|
57
61
|
type SortBy = 'title' | 'author' | 'status' | 'modified' | 'seo'
|
|
58
62
|
type Dir = 'asc' | 'desc'
|
|
63
|
+
type ViewMode = 'list' | 'tree'
|
|
59
64
|
|
|
60
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
|
+
}
|
|
61
76
|
|
|
62
77
|
export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
63
78
|
const [search, setSearch] = useState('')
|
|
@@ -69,6 +84,10 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
69
84
|
const [dir, setDir] = useState<Dir>('asc')
|
|
70
85
|
const [page, setPage] = useState(1)
|
|
71
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)
|
|
72
91
|
|
|
73
92
|
const [dataset, setDataset] = useState<PagesDataset | null>(null)
|
|
74
93
|
const [loading, setLoading] = useState(true)
|
|
@@ -154,6 +173,49 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
154
173
|
|
|
155
174
|
const displayGroups = useMemo(() => groupPages(pages, groupBy, tags), [pages, groupBy, tags])
|
|
156
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
|
+
|
|
157
219
|
const handleSort = useCallback(
|
|
158
220
|
(field: SortBy) => {
|
|
159
221
|
if (sortBy === field) {
|
|
@@ -198,16 +260,26 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
198
260
|
return next
|
|
199
261
|
})
|
|
200
262
|
|
|
201
|
-
|
|
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))
|
|
202
271
|
const toggleAllOnPage = (checked: boolean) =>
|
|
203
272
|
setSelected((s) => {
|
|
204
273
|
const next = new Set(s)
|
|
205
|
-
if (checked) for (const p of
|
|
206
|
-
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)
|
|
207
276
|
return next
|
|
208
277
|
})
|
|
209
278
|
|
|
210
|
-
const selectedPages = useMemo(
|
|
279
|
+
const selectedPages = useMemo(
|
|
280
|
+
() => visiblePages.filter((p) => selected.has(p.id)),
|
|
281
|
+
[visiblePages, selected],
|
|
282
|
+
)
|
|
211
283
|
const deletablePages = useMemo(() => selectedPages.filter((p) => !p.isHomepage), [selectedPages])
|
|
212
284
|
const blockedHomepage = selectedPages.length !== deletablePages.length
|
|
213
285
|
|
|
@@ -342,7 +414,9 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
342
414
|
<div className="flex flex-col gap-1">
|
|
343
415
|
<h1 className="text-foreground">Pages</h1>
|
|
344
416
|
<p className="text-muted-foreground text-sm">
|
|
345
|
-
{
|
|
417
|
+
{treeMode
|
|
418
|
+
? `${grandTotal} page${grandTotal === 1 ? '' : 's'} total`
|
|
419
|
+
: `${grandTotal} page${grandTotal === 1 ? '' : 's'} · organized by tags`}
|
|
346
420
|
</p>
|
|
347
421
|
</div>
|
|
348
422
|
<button
|
|
@@ -372,17 +446,21 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
372
446
|
/>
|
|
373
447
|
</div>
|
|
374
448
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
{
|
|
381
|
-
{
|
|
382
|
-
{
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
+
)}
|
|
386
464
|
|
|
387
465
|
<FilterSelect
|
|
388
466
|
label="Status"
|
|
@@ -399,45 +477,54 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
399
477
|
]}
|
|
400
478
|
/>
|
|
401
479
|
|
|
480
|
+
<ViewModeToggle mode={viewMode} onChange={changeViewMode} />
|
|
481
|
+
|
|
402
482
|
<span className="text-muted-foreground ml-auto text-sm tabular-nums">
|
|
403
|
-
{
|
|
483
|
+
{treeMode
|
|
484
|
+
? treeFiltersActive
|
|
485
|
+
? `${treeRows.length} result${treeRows.length === 1 ? '' : 's'}`
|
|
486
|
+
: `${grandTotal} page${grandTotal === 1 ? '' : 's'}`
|
|
487
|
+
: `${total} of ${grandTotal}`}
|
|
404
488
|
</span>
|
|
405
489
|
</div>
|
|
406
490
|
|
|
407
|
-
{/* Tag filter bar
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
+
)}
|
|
423
518
|
<button
|
|
424
519
|
type="button"
|
|
425
|
-
onClick={() =>
|
|
426
|
-
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"
|
|
427
522
|
>
|
|
428
|
-
<
|
|
429
|
-
|
|
523
|
+
<TagIcon className="h-3 w-3" aria-hidden />
|
|
524
|
+
Manage tags
|
|
430
525
|
</button>
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
type="button"
|
|
434
|
-
onClick={() => onNavigate('/pages/tags')}
|
|
435
|
-
className="text-primary ml-auto inline-flex items-center gap-1 text-xs font-medium hover:underline"
|
|
436
|
-
>
|
|
437
|
-
<TagIcon className="h-3 w-3" aria-hidden />
|
|
438
|
-
Manage tags
|
|
439
|
-
</button>
|
|
440
|
-
</div>
|
|
526
|
+
</div>
|
|
527
|
+
)}
|
|
441
528
|
|
|
442
529
|
{/* Bulk action bar */}
|
|
443
530
|
{selected.size > 0 && (
|
|
@@ -457,147 +544,186 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
457
544
|
|
|
458
545
|
{/* Table card */}
|
|
459
546
|
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
|
|
460
|
-
{
|
|
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
|
|
461
569
|
width and the table scrolls sideways instead of crushing content. */}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
</th>
|
|
506
|
-
<th scope="col" aria-sort={ariaSortFor('status')} className="px-3 py-2.5 text-left">
|
|
507
|
-
<SortHeader
|
|
508
|
-
label="Status"
|
|
509
|
-
field="status"
|
|
510
|
-
current={sortBy}
|
|
511
|
-
dir={dir}
|
|
512
|
-
onSort={handleSort}
|
|
513
|
-
/>
|
|
514
|
-
</th>
|
|
515
|
-
<th
|
|
516
|
-
scope="col"
|
|
517
|
-
aria-sort={ariaSortFor('modified')}
|
|
518
|
-
className="px-3 py-2.5 text-left"
|
|
519
|
-
>
|
|
520
|
-
<SortHeader
|
|
521
|
-
label="Last Modified"
|
|
522
|
-
field="modified"
|
|
523
|
-
current={sortBy}
|
|
524
|
-
dir={dir}
|
|
525
|
-
onSort={handleSort}
|
|
526
|
-
/>
|
|
527
|
-
</th>
|
|
528
|
-
<th scope="col" aria-sort={ariaSortFor('seo')} className="px-3 py-2.5 text-left">
|
|
529
|
-
<SortHeader
|
|
530
|
-
label="SEO"
|
|
531
|
-
field="seo"
|
|
532
|
-
current={sortBy}
|
|
533
|
-
dir={dir}
|
|
534
|
-
onSort={handleSort}
|
|
535
|
-
/>
|
|
536
|
-
</th>
|
|
537
|
-
<th scope="col" className="px-3 py-2.5 text-right">
|
|
538
|
-
<span className="sr-only">Actions</span>
|
|
539
|
-
</th>
|
|
540
|
-
</tr>
|
|
541
|
-
</thead>
|
|
542
|
-
<tbody>
|
|
543
|
-
{loading && pages.length === 0 && <LoadingRows />}
|
|
544
|
-
{!loading && error && (
|
|
545
|
-
<tr>
|
|
546
|
-
<td colSpan={8} className="px-3 py-12 text-center">
|
|
547
|
-
<div className="text-foreground mb-2 text-sm font-medium">{error}</div>
|
|
548
|
-
<button
|
|
549
|
-
onClick={() => setRefreshToken((t) => t + 1)}
|
|
550
|
-
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"
|
|
551
613
|
>
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
<EmptyRow
|
|
559
|
-
filtersActive={filtersAreActive}
|
|
560
|
-
onClear={clearFilters}
|
|
561
|
-
onNewPage={() => onNavigate('/pages/new')}
|
|
562
|
-
/>
|
|
563
|
-
)}
|
|
564
|
-
{!loading &&
|
|
565
|
-
!error &&
|
|
566
|
-
displayGroups.map((group) => (
|
|
567
|
-
<GroupBlock
|
|
568
|
-
key={group.key}
|
|
569
|
-
showHeader={grouped}
|
|
570
|
-
label={group.label}
|
|
571
|
-
color={group.color}
|
|
572
|
-
count={group.pages.length}
|
|
573
|
-
>
|
|
574
|
-
{group.pages.map((p) => (
|
|
575
|
-
<PageRow
|
|
576
|
-
key={p.id}
|
|
577
|
-
page={p}
|
|
578
|
-
selected={selected.has(p.id)}
|
|
579
|
-
resolveTag={(slug) => tagBySlug.get(slug)}
|
|
580
|
-
onSelect={() => toggleOne(p.id)}
|
|
581
|
-
onOpen={() => onNavigate(`/pages/${p.id}/edit`)}
|
|
582
|
-
onDuplicate={() => void runDuplicate(p)}
|
|
583
|
-
onDelete={() => setRowDelete(p)}
|
|
614
|
+
<SortHeader
|
|
615
|
+
label="Author"
|
|
616
|
+
field="author"
|
|
617
|
+
current={sortBy}
|
|
618
|
+
dir={dir}
|
|
619
|
+
onSort={handleSort}
|
|
584
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>
|
|
585
711
|
))}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
</
|
|
589
|
-
</table>
|
|
590
|
-
</div>
|
|
712
|
+
</tbody>
|
|
713
|
+
</table>
|
|
714
|
+
</div>
|
|
591
715
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
+
</>
|
|
601
727
|
)}
|
|
602
728
|
</div>
|
|
603
729
|
|
|
@@ -668,6 +794,49 @@ function SortHeader({
|
|
|
668
794
|
)
|
|
669
795
|
}
|
|
670
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
|
+
|
|
671
840
|
function FilterSelect({
|
|
672
841
|
label,
|
|
673
842
|
value,
|