@actuate-media/cms-admin 0.15.0 → 0.16.1
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/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +148 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/posts-badges.test.d.ts +2 -0
- package/dist/__tests__/components/posts-badges.test.d.ts.map +1 -0
- package/dist/__tests__/components/posts-badges.test.js +68 -0
- package/dist/__tests__/components/posts-badges.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-posts.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-posts.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-posts.test.js +162 -0
- package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -0
- package/dist/__tests__/lib/page-editor-service.test.d.ts +2 -0
- package/dist/__tests__/lib/page-editor-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/page-editor-service.test.js +137 -0
- package/dist/__tests__/lib/page-editor-service.test.js.map +1 -0
- package/dist/__tests__/lib/pages-service.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-service.test.js +140 -0
- package/dist/__tests__/lib/pages-service.test.js.map +1 -0
- package/dist/__tests__/lib/post-editor-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-editor-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-editor-service.test.js +185 -0
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -0
- package/dist/__tests__/lib/posts-service.test.d.ts +2 -0
- package/dist/__tests__/lib/posts-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/posts-service.test.js +244 -0
- package/dist/__tests__/lib/posts-service.test.js.map +1 -0
- package/dist/__tests__/lib/useApiData.test.d.ts +2 -0
- package/dist/__tests__/lib/useApiData.test.d.ts.map +1 -0
- package/dist/__tests__/lib/useApiData.test.js +84 -0
- package/dist/__tests__/lib/useApiData.test.js.map +1 -0
- package/dist/__tests__/views/dashboard.test.d.ts +2 -0
- package/dist/__tests__/views/dashboard.test.d.ts.map +1 -0
- package/dist/__tests__/views/dashboard.test.js +138 -0
- package/dist/__tests__/views/dashboard.test.js.map +1 -0
- package/dist/__tests__/views/posts-list-view.test.d.ts +2 -0
- package/dist/__tests__/views/posts-list-view.test.d.ts.map +1 -0
- package/dist/__tests__/views/posts-list-view.test.js +98 -0
- package/dist/__tests__/views/posts-list-view.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/pages/tag-badges.d.ts +21 -0
- package/dist/components/pages/tag-badges.d.ts.map +1 -0
- package/dist/components/pages/tag-badges.js +56 -0
- package/dist/components/pages/tag-badges.js.map +1 -0
- package/dist/components/posts/badges.d.ts +25 -0
- package/dist/components/posts/badges.d.ts.map +1 -0
- package/dist/components/posts/badges.js +123 -0
- package/dist/components/posts/badges.js.map +1 -0
- package/dist/components/posts/icons.d.ts +12 -0
- package/dist/components/posts/icons.d.ts.map +1 -0
- package/dist/components/posts/icons.js +26 -0
- package/dist/components/posts/icons.js.map +1 -0
- package/dist/layout/Layout.js +36 -8
- package/dist/layout/Layout.js.map +1 -1
- package/dist/layout/Sidebar.d.ts +9 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +100 -48
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +45 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -0
- package/dist/lib/page-editor-service.js +153 -0
- package/dist/lib/page-editor-service.js.map +1 -0
- package/dist/lib/pages-service.d.ts +143 -0
- package/dist/lib/pages-service.d.ts.map +1 -0
- package/dist/lib/pages-service.js +444 -0
- package/dist/lib/pages-service.js.map +1 -0
- package/dist/lib/post-editor-service.d.ts +57 -0
- package/dist/lib/post-editor-service.d.ts.map +1 -0
- package/dist/lib/post-editor-service.js +234 -0
- package/dist/lib/post-editor-service.js.map +1 -0
- package/dist/lib/posts-service.d.ts +73 -0
- package/dist/lib/posts-service.d.ts.map +1 -0
- package/dist/lib/posts-service.js +245 -0
- package/dist/lib/posts-service.js.map +1 -0
- package/dist/lib/preview-link.d.ts +32 -0
- package/dist/lib/preview-link.d.ts.map +1 -0
- package/dist/lib/preview-link.js +40 -0
- package/dist/lib/preview-link.js.map +1 -0
- package/dist/router/index.d.ts +1 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +9 -1
- package/dist/router/index.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +64 -20
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/Pages/PageTagManager.d.ts +14 -0
- package/dist/views/Pages/PageTagManager.d.ts.map +1 -0
- package/dist/views/Pages/PageTagManager.js +145 -0
- package/dist/views/Pages/PageTagManager.js.map +1 -0
- package/dist/views/Pages/PagesListView.d.ts +18 -0
- package/dist/views/Pages/PagesListView.d.ts.map +1 -0
- package/dist/views/Pages/PagesListView.js +287 -0
- package/dist/views/Pages/PagesListView.js.map +1 -0
- package/dist/views/Posts/NewPostPicker.d.ts +16 -0
- package/dist/views/Posts/NewPostPicker.d.ts.map +1 -0
- package/dist/views/Posts/NewPostPicker.js +59 -0
- package/dist/views/Posts/NewPostPicker.js.map +1 -0
- package/dist/views/Posts/NewPostTypeInstructions.d.ts +18 -0
- package/dist/views/Posts/NewPostTypeInstructions.d.ts.map +1 -0
- package/dist/views/Posts/NewPostTypeInstructions.js +147 -0
- package/dist/views/Posts/NewPostTypeInstructions.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +26 -0
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -0
- package/dist/views/Posts/PostTypesView.js +52 -0
- package/dist/views/Posts/PostTypesView.js.map +1 -0
- package/dist/views/Posts/PostsListView.d.ts +25 -0
- package/dist/views/Posts/PostsListView.d.ts.map +1 -0
- package/dist/views/Posts/PostsListView.js +239 -0
- package/dist/views/Posts/PostsListView.js.map +1 -0
- package/dist/views/Posts/post-type-presets.d.ts +32 -0
- package/dist/views/Posts/post-type-presets.d.ts.map +1 -0
- package/dist/views/Posts/post-type-presets.js +72 -0
- package/dist/views/Posts/post-type-presets.js.map +1 -0
- package/dist/views/page-builder/Widgets.d.ts +13 -0
- package/dist/views/page-builder/Widgets.d.ts.map +1 -0
- package/dist/views/page-builder/Widgets.js +15 -0
- package/dist/views/page-builder/Widgets.js.map +1 -0
- package/dist/views/page-editor/AddSectionModal.d.ts +20 -0
- package/dist/views/page-editor/AddSectionModal.d.ts.map +1 -0
- package/dist/views/page-editor/AddSectionModal.js +22 -0
- package/dist/views/page-editor/AddSectionModal.js.map +1 -0
- package/dist/views/page-editor/EditorCanvas.d.ts +18 -0
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -0
- package/dist/views/page-editor/EditorCanvas.js +64 -0
- package/dist/views/page-editor/EditorCanvas.js.map +1 -0
- package/dist/views/page-editor/EditorTopBar.d.ts +30 -0
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -0
- package/dist/views/page-editor/EditorTopBar.js +42 -0
- package/dist/views/page-editor/EditorTopBar.js.map +1 -0
- package/dist/views/page-editor/PagePreview.d.ts +13 -0
- package/dist/views/page-editor/PagePreview.d.ts.map +1 -0
- package/dist/views/page-editor/PagePreview.js +37 -0
- package/dist/views/page-editor/PagePreview.js.map +1 -0
- package/dist/views/page-editor/PageSectionEditor.d.ts +16 -0
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -0
- package/dist/views/page-editor/PageSectionEditor.js +272 -0
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -0
- package/dist/views/page-editor/PageSettingsModal.d.ts +12 -0
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -0
- package/dist/views/page-editor/PageSettingsModal.js +52 -0
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -0
- package/dist/views/page-editor/SectionInspector.d.ts +17 -0
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -0
- package/dist/views/page-editor/SectionInspector.js +98 -0
- package/dist/views/page-editor/SectionInspector.js.map +1 -0
- package/dist/views/page-editor/SectionsPanel.d.ts +15 -0
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -0
- package/dist/views/page-editor/SectionsPanel.js +43 -0
- package/dist/views/page-editor/SectionsPanel.js.map +1 -0
- package/dist/views/page-editor/ValidationSummary.d.ts +9 -0
- package/dist/views/page-editor/ValidationSummary.d.ts.map +1 -0
- package/dist/views/page-editor/ValidationSummary.js +10 -0
- package/dist/views/page-editor/ValidationSummary.js.map +1 -0
- package/dist/views/page-editor/section-icons.d.ts +4 -0
- package/dist/views/page-editor/section-icons.d.ts.map +1 -0
- package/dist/views/page-editor/section-icons.js +20 -0
- package/dist/views/page-editor/section-icons.js.map +1 -0
- package/dist/views/page-editor/section-types.d.ts +11 -0
- package/dist/views/page-editor/section-types.d.ts.map +1 -0
- package/dist/views/page-editor/section-types.js +10 -0
- package/dist/views/page-editor/section-types.js.map +1 -0
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts +13 -0
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/ArticleBodySection.js +23 -0
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -0
- package/dist/views/page-editor/sections/AuthorBioSection.d.ts +8 -0
- package/dist/views/page-editor/sections/AuthorBioSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/AuthorBioSection.js +21 -0
- package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -0
- package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts +5 -0
- package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/ByTheNumbersSection.js +10 -0
- package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -0
- package/dist/views/page-editor/sections/CtaBannerSection.d.ts +5 -0
- package/dist/views/page-editor/sections/CtaBannerSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/CtaBannerSection.js +10 -0
- package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -0
- package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts +5 -0
- package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/FeatureHighlightSection.js +18 -0
- package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -0
- package/dist/views/page-editor/sections/GallerySection.d.ts +6 -0
- package/dist/views/page-editor/sections/GallerySection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/GallerySection.js +17 -0
- package/dist/views/page-editor/sections/GallerySection.js.map +1 -0
- package/dist/views/page-editor/sections/HeroBannerSection.d.ts +5 -0
- package/dist/views/page-editor/sections/HeroBannerSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/HeroBannerSection.js +11 -0
- package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -0
- package/dist/views/page-editor/sections/MissionSection.d.ts +6 -0
- package/dist/views/page-editor/sections/MissionSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/MissionSection.js +12 -0
- package/dist/views/page-editor/sections/MissionSection.js.map +1 -0
- package/dist/views/page-editor/sections/PullQuoteSection.d.ts +6 -0
- package/dist/views/page-editor/sections/PullQuoteSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/PullQuoteSection.js +13 -0
- package/dist/views/page-editor/sections/PullQuoteSection.js.map +1 -0
- package/dist/views/page-editor/sections/RelatedPostsSection.d.ts +12 -0
- package/dist/views/page-editor/sections/RelatedPostsSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/RelatedPostsSection.js +23 -0
- package/dist/views/page-editor/sections/RelatedPostsSection.js.map +1 -0
- package/dist/views/page-editor/sections/SectionRenderer.d.ts +22 -0
- package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -0
- package/dist/views/page-editor/sections/SectionRenderer.js +52 -0
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -0
- package/dist/views/page-editor/sections/index.d.ts +14 -0
- package/dist/views/page-editor/sections/index.d.ts.map +1 -0
- package/dist/views/page-editor/sections/index.js +12 -0
- package/dist/views/page-editor/sections/index.js.map +1 -0
- package/dist/views/page-editor/sections/parts.d.ts +55 -0
- package/dist/views/page-editor/sections/parts.d.ts.map +1 -0
- package/dist/views/page-editor/sections/parts.js +60 -0
- package/dist/views/page-editor/sections/parts.js.map +1 -0
- package/dist/views/page-editor/viewports.d.ts +14 -0
- package/dist/views/page-editor/viewports.d.ts.map +1 -0
- package/dist/views/page-editor/viewports.js +12 -0
- package/dist/views/page-editor/viewports.js.map +1 -0
- package/dist/views/post-editor/PostEditorCanvas.d.ts +21 -0
- package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -0
- package/dist/views/post-editor/PostEditorCanvas.js +58 -0
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -0
- package/dist/views/post-editor/PostFieldsModal.d.ts +12 -0
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -0
- package/dist/views/post-editor/PostFieldsModal.js +53 -0
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -0
- package/dist/views/post-editor/PostHeader.d.ts +15 -0
- package/dist/views/post-editor/PostHeader.d.ts.map +1 -0
- package/dist/views/post-editor/PostHeader.js +27 -0
- package/dist/views/post-editor/PostHeader.js.map +1 -0
- package/dist/views/post-editor/PostHeaderPanel.d.ts +11 -0
- package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -0
- package/dist/views/post-editor/PostHeaderPanel.js +21 -0
- package/dist/views/post-editor/PostHeaderPanel.js.map +1 -0
- package/dist/views/post-editor/PostPreview.d.ts +14 -0
- package/dist/views/post-editor/PostPreview.d.ts.map +1 -0
- package/dist/views/post-editor/PostPreview.js +53 -0
- package/dist/views/post-editor/PostPreview.js.map +1 -0
- package/dist/views/post-editor/PostSectionEditor.d.ts +17 -0
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -0
- package/dist/views/post-editor/PostSectionEditor.js +290 -0
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -0
- package/dist/views/post-editor/PostTemplateEditor.d.ts +9 -0
- package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -0
- package/dist/views/post-editor/PostTemplateEditor.js +144 -0
- package/dist/views/post-editor/PostTemplateEditor.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +214 -2
- package/src/__tests__/components/posts-badges.test.tsx +77 -0
- package/src/__tests__/layout/sidebar-posts.test.ts +179 -0
- package/src/__tests__/lib/page-editor-service.test.ts +173 -0
- package/src/__tests__/lib/pages-service.test.ts +157 -0
- package/src/__tests__/lib/post-editor-service.test.ts +215 -0
- package/src/__tests__/lib/posts-service.test.ts +279 -0
- package/src/__tests__/lib/useApiData.test.ts +105 -0
- package/src/__tests__/views/dashboard.test.tsx +180 -0
- package/src/__tests__/views/posts-list-view.test.ts +110 -0
- package/src/components/pages/tag-badges.tsx +104 -0
- package/src/components/posts/badges.tsx +170 -0
- package/src/components/posts/icons.ts +40 -0
- package/src/layout/Layout.tsx +66 -10
- package/src/layout/Sidebar.tsx +116 -50
- package/src/lib/page-editor-service.ts +233 -0
- package/src/lib/pages-service.ts +599 -0
- package/src/lib/post-editor-service.ts +342 -0
- package/src/lib/posts-service.ts +366 -0
- package/src/lib/preview-link.ts +63 -0
- package/src/router/index.ts +10 -1
- package/src/views/Dashboard.tsx +142 -89
- package/src/views/Pages/PageTagManager.tsx +436 -0
- package/src/views/Pages/PagesListView.tsx +944 -0
- package/src/views/Posts/NewPostPicker.tsx +147 -0
- package/src/views/Posts/NewPostTypeInstructions.tsx +344 -0
- package/src/views/Posts/PostTypesView.tsx +194 -0
- package/src/views/Posts/PostsListView.tsx +869 -0
- package/src/views/Posts/post-type-presets.ts +105 -0
- package/src/views/page-builder/Widgets.tsx +44 -0
- package/src/views/page-editor/AddSectionModal.tsx +56 -0
- package/src/views/page-editor/EditorCanvas.tsx +127 -0
- package/src/views/page-editor/EditorTopBar.tsx +233 -0
- package/src/views/page-editor/PagePreview.tsx +96 -0
- package/src/views/page-editor/PageSectionEditor.tsx +476 -0
- package/src/views/page-editor/PageSettingsModal.tsx +154 -0
- package/src/views/page-editor/SectionInspector.tsx +599 -0
- package/src/views/page-editor/SectionsPanel.tsx +270 -0
- package/src/views/page-editor/ValidationSummary.tsx +53 -0
- package/src/views/page-editor/section-icons.ts +33 -0
- package/src/views/page-editor/section-types.ts +27 -0
- package/src/views/page-editor/sections/ArticleBodySection.tsx +57 -0
- package/src/views/page-editor/sections/AuthorBioSection.tsx +61 -0
- package/src/views/page-editor/sections/ByTheNumbersSection.tsx +41 -0
- package/src/views/page-editor/sections/CtaBannerSection.tsx +29 -0
- package/src/views/page-editor/sections/FeatureHighlightSection.tsx +63 -0
- package/src/views/page-editor/sections/GallerySection.tsx +54 -0
- package/src/views/page-editor/sections/HeroBannerSection.tsx +44 -0
- package/src/views/page-editor/sections/MissionSection.tsx +22 -0
- package/src/views/page-editor/sections/PullQuoteSection.tsx +27 -0
- package/src/views/page-editor/sections/RelatedPostsSection.tsx +55 -0
- package/src/views/page-editor/sections/SectionRenderer.tsx +102 -0
- package/src/views/page-editor/sections/index.ts +13 -0
- package/src/views/page-editor/sections/parts.tsx +113 -0
- package/src/views/page-editor/viewports.ts +25 -0
- package/src/views/post-editor/PostEditorCanvas.tsx +123 -0
- package/src/views/post-editor/PostFieldsModal.tsx +196 -0
- package/src/views/post-editor/PostHeader.tsx +87 -0
- package/src/views/post-editor/PostHeaderPanel.tsx +107 -0
- package/src/views/post-editor/PostPreview.tsx +114 -0
- package/src/views/post-editor/PostSectionEditor.tsx +506 -0
- package/src/views/post-editor/PostTemplateEditor.tsx +347 -0
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
ChevronDown,
|
|
6
|
+
ChevronLeft,
|
|
7
|
+
ChevronRight,
|
|
8
|
+
ChevronUp,
|
|
9
|
+
Plus,
|
|
10
|
+
Search,
|
|
11
|
+
Tag as TagIcon,
|
|
12
|
+
Trash2,
|
|
13
|
+
X,
|
|
14
|
+
} from 'lucide-react'
|
|
15
|
+
import { toast } from 'sonner'
|
|
16
|
+
import {
|
|
17
|
+
bulkChangePageTags,
|
|
18
|
+
bulkDeletePages,
|
|
19
|
+
bulkUpdatePages,
|
|
20
|
+
fetchPages,
|
|
21
|
+
groupPages,
|
|
22
|
+
type Page,
|
|
23
|
+
type PageGroupBy,
|
|
24
|
+
type PageStatus,
|
|
25
|
+
type PagesResult,
|
|
26
|
+
type PageTag,
|
|
27
|
+
} from '../../lib/pages-service.js'
|
|
28
|
+
import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
|
|
29
|
+
import { PageTagChip, PageTagsCell } from '../../components/pages/tag-badges.js'
|
|
30
|
+
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pages management surface — table with a tag-chip filter bar, group-by,
|
|
34
|
+
* status filter, sortable headers, bulk actions, and pagination. Data
|
|
35
|
+
* flows through pages-service so a future server-side aggregate only
|
|
36
|
+
* touches the service file.
|
|
37
|
+
*
|
|
38
|
+
* Routing:
|
|
39
|
+
* - Row click → /pages/:id/edit (the page builder).
|
|
40
|
+
* - "+ New page" lives in the top bar (computePageActions).
|
|
41
|
+
* - "Manage tags" (in the tag bar) → /pages/tags.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
interface PagesListViewProps {
|
|
45
|
+
onNavigate: (path: string) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type SortBy = 'title' | 'author' | 'status' | 'modified' | 'seo'
|
|
49
|
+
type Dir = 'asc' | 'desc'
|
|
50
|
+
|
|
51
|
+
const PAGE_SIZE = 25
|
|
52
|
+
|
|
53
|
+
export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
54
|
+
const [search, setSearch] = useState('')
|
|
55
|
+
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
56
|
+
const [status, setStatus] = useState<PageStatus | 'all'>('all')
|
|
57
|
+
const [groupBy, setGroupBy] = useState<PageGroupBy>('none')
|
|
58
|
+
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set())
|
|
59
|
+
const [sortBy, setSortBy] = useState<SortBy>('title')
|
|
60
|
+
const [dir, setDir] = useState<Dir>('asc')
|
|
61
|
+
const [page, setPage] = useState(1)
|
|
62
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
63
|
+
|
|
64
|
+
const [data, setData] = useState<PagesResult | null>(null)
|
|
65
|
+
const [loading, setLoading] = useState(true)
|
|
66
|
+
const [error, setError] = useState<string | null>(null)
|
|
67
|
+
const [refreshToken, setRefreshToken] = useState(0)
|
|
68
|
+
const [pendingDelete, setPendingDelete] = useState(false)
|
|
69
|
+
|
|
70
|
+
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current)
|
|
73
|
+
debounceTimer.current = setTimeout(() => {
|
|
74
|
+
setDebouncedSearch(search.trim())
|
|
75
|
+
setPage(1)
|
|
76
|
+
}, 250)
|
|
77
|
+
return () => {
|
|
78
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current)
|
|
79
|
+
}
|
|
80
|
+
}, [search])
|
|
81
|
+
|
|
82
|
+
const tagIds = useMemo(() => [...selectedTags], [selectedTags])
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
let cancelled = false
|
|
86
|
+
setLoading(true)
|
|
87
|
+
setError(null)
|
|
88
|
+
fetchPages({
|
|
89
|
+
status,
|
|
90
|
+
tagIds,
|
|
91
|
+
search: debouncedSearch,
|
|
92
|
+
groupBy,
|
|
93
|
+
sortBy,
|
|
94
|
+
sortDirection: dir,
|
|
95
|
+
page,
|
|
96
|
+
pageSize: PAGE_SIZE,
|
|
97
|
+
})
|
|
98
|
+
.then((res) => {
|
|
99
|
+
if (cancelled) return
|
|
100
|
+
setData(res)
|
|
101
|
+
setLoading(false)
|
|
102
|
+
})
|
|
103
|
+
.catch((err: unknown) => {
|
|
104
|
+
if (cancelled) return
|
|
105
|
+
setError(err instanceof Error ? err.message : 'Failed to load pages')
|
|
106
|
+
setLoading(false)
|
|
107
|
+
})
|
|
108
|
+
return () => {
|
|
109
|
+
cancelled = true
|
|
110
|
+
}
|
|
111
|
+
}, [status, tagIds, debouncedSearch, groupBy, sortBy, dir, page, refreshToken])
|
|
112
|
+
|
|
113
|
+
// Reset paging/selection when the result set changes shape.
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
setPage(1)
|
|
116
|
+
setSelected(new Set())
|
|
117
|
+
}, [debouncedSearch, status, tagIds, groupBy])
|
|
118
|
+
|
|
119
|
+
const pages = data?.pages ?? []
|
|
120
|
+
const tags = data?.tags ?? []
|
|
121
|
+
const total = data?.total ?? 0
|
|
122
|
+
const totalAll = useMemo(() => tags.reduce((sum, t) => sum + t.pageCount, 0), [tags])
|
|
123
|
+
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
124
|
+
const grouped = groupBy !== 'none'
|
|
125
|
+
|
|
126
|
+
const tagBySlug = useMemo(() => {
|
|
127
|
+
const m = new Map<string, PageTag>()
|
|
128
|
+
for (const t of tags) m.set(t.slug, t)
|
|
129
|
+
return m
|
|
130
|
+
}, [tags])
|
|
131
|
+
|
|
132
|
+
const displayGroups = useMemo(() => groupPages(pages, groupBy, tags), [pages, groupBy, tags])
|
|
133
|
+
|
|
134
|
+
const handleSort = useCallback(
|
|
135
|
+
(field: SortBy) => {
|
|
136
|
+
if (sortBy === field) {
|
|
137
|
+
setDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
|
138
|
+
} else {
|
|
139
|
+
setSortBy(field)
|
|
140
|
+
setDir(field === 'modified' || field === 'seo' ? 'desc' : 'asc')
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
[sortBy],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const filtersAreActive = debouncedSearch !== '' || status !== 'all' || selectedTags.size > 0
|
|
147
|
+
|
|
148
|
+
const clearFilters = () => {
|
|
149
|
+
setSearch('')
|
|
150
|
+
setStatus('all')
|
|
151
|
+
setSelectedTags(new Set())
|
|
152
|
+
setPage(1)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const toggleTag = (slug: string) =>
|
|
156
|
+
setSelectedTags((s) => {
|
|
157
|
+
const next = new Set(s)
|
|
158
|
+
if (next.has(slug)) next.delete(slug)
|
|
159
|
+
else next.add(slug)
|
|
160
|
+
return next
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// ─── Selection ─────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
const toggleOne = (id: string) =>
|
|
166
|
+
setSelected((s) => {
|
|
167
|
+
const next = new Set(s)
|
|
168
|
+
if (next.has(id)) next.delete(id)
|
|
169
|
+
else next.add(id)
|
|
170
|
+
return next
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const allOnPageSelected = pages.length > 0 && pages.every((p) => selected.has(p.id))
|
|
174
|
+
const toggleAllOnPage = (checked: boolean) =>
|
|
175
|
+
setSelected((s) => {
|
|
176
|
+
const next = new Set(s)
|
|
177
|
+
if (checked) for (const p of pages) next.add(p.id)
|
|
178
|
+
else for (const p of pages) next.delete(p.id)
|
|
179
|
+
return next
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const selectedPages = useMemo(() => pages.filter((p) => selected.has(p.id)), [pages, selected])
|
|
183
|
+
const deletablePages = useMemo(() => selectedPages.filter((p) => !p.isHomepage), [selectedPages])
|
|
184
|
+
const blockedHomepage = selectedPages.length !== deletablePages.length
|
|
185
|
+
|
|
186
|
+
// ─── Bulk actions ──────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
const afterBulk = () => {
|
|
189
|
+
setSelected(new Set())
|
|
190
|
+
setRefreshToken((t) => t + 1)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const runBulkStatus = async (target: PageStatus) => {
|
|
194
|
+
const ids = selectedPages.map((p) => p.id)
|
|
195
|
+
if (ids.length === 0) return
|
|
196
|
+
const verb = target === 'PUBLISHED' ? 'Publishing' : 'Updating'
|
|
197
|
+
const promise = bulkUpdatePages(ids, { status: target })
|
|
198
|
+
toast.promise(promise, {
|
|
199
|
+
loading: `${verb} ${ids.length} page${ids.length === 1 ? '' : 's'}…`,
|
|
200
|
+
success: (r) => `${r.ok} updated${r.failed ? `, ${r.failed} failed` : ''}`,
|
|
201
|
+
error: 'Bulk update failed',
|
|
202
|
+
})
|
|
203
|
+
await promise
|
|
204
|
+
afterBulk()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const runBulkTag = async (slug: string, mode: 'add' | 'remove') => {
|
|
208
|
+
if (selectedPages.length === 0) return
|
|
209
|
+
const promise = bulkChangePageTags(
|
|
210
|
+
selectedPages.map((p) => ({ id: p.id, tags: p.tags, primaryTagId: p.primaryTagId })),
|
|
211
|
+
mode === 'add' ? { add: [slug] } : { remove: [slug] },
|
|
212
|
+
)
|
|
213
|
+
const tagName = tagBySlug.get(slug)?.name ?? slug
|
|
214
|
+
toast.promise(promise, {
|
|
215
|
+
loading: `${mode === 'add' ? 'Adding' : 'Removing'} "${tagName}"…`,
|
|
216
|
+
success: (r) =>
|
|
217
|
+
`${r.ok} page${r.ok === 1 ? '' : 's'} updated${r.failed ? `, ${r.failed} failed` : ''}`,
|
|
218
|
+
error: 'Bulk tag change failed',
|
|
219
|
+
})
|
|
220
|
+
await promise
|
|
221
|
+
afterBulk()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const runBulkDelete = async () => {
|
|
225
|
+
const ids = deletablePages.map((p) => p.id)
|
|
226
|
+
if (ids.length === 0) return
|
|
227
|
+
const promise = bulkDeletePages(ids)
|
|
228
|
+
toast.promise(promise, {
|
|
229
|
+
loading: `Deleting ${ids.length} page${ids.length === 1 ? '' : 's'}…`,
|
|
230
|
+
success: (r) => `${r.ok} deleted${r.failed ? `, ${r.failed} failed` : ''}`,
|
|
231
|
+
error: 'Bulk delete failed',
|
|
232
|
+
})
|
|
233
|
+
await promise
|
|
234
|
+
afterBulk()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Render ────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div className="flex h-full flex-col gap-4 p-4 sm:p-6">
|
|
241
|
+
{/* Header */}
|
|
242
|
+
<div className="flex flex-col gap-1">
|
|
243
|
+
<h1 className="text-foreground text-xl font-medium tracking-tight sm:text-2xl">Pages</h1>
|
|
244
|
+
<p className="text-muted-foreground text-sm">
|
|
245
|
+
{totalAll} page{totalAll === 1 ? '' : 's'} · organized by tags
|
|
246
|
+
</p>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{/* Controls row */}
|
|
250
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
251
|
+
<div className="relative max-w-sm flex-1">
|
|
252
|
+
<Search
|
|
253
|
+
className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
|
254
|
+
aria-hidden
|
|
255
|
+
/>
|
|
256
|
+
<input
|
|
257
|
+
type="search"
|
|
258
|
+
value={search}
|
|
259
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
260
|
+
aria-label="Search pages"
|
|
261
|
+
placeholder="Search pages…"
|
|
262
|
+
className="border-border bg-card text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-primary/30 w-full rounded-lg border py-2 pr-3 pl-9 text-sm shadow-sm focus:ring-2 focus:outline-none"
|
|
263
|
+
/>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<FilterSelect
|
|
267
|
+
label="Group by"
|
|
268
|
+
value={groupBy}
|
|
269
|
+
onChange={(v) => setGroupBy(v as PageGroupBy)}
|
|
270
|
+
options={[
|
|
271
|
+
{ value: 'none', label: 'None' },
|
|
272
|
+
{ value: 'tag', label: 'Tag' },
|
|
273
|
+
{ value: 'status', label: 'Status' },
|
|
274
|
+
{ value: 'author', label: 'Author' },
|
|
275
|
+
]}
|
|
276
|
+
/>
|
|
277
|
+
|
|
278
|
+
<FilterSelect
|
|
279
|
+
label="Status"
|
|
280
|
+
value={status}
|
|
281
|
+
onChange={(v) => {
|
|
282
|
+
setStatus(v as PageStatus | 'all')
|
|
283
|
+
setPage(1)
|
|
284
|
+
}}
|
|
285
|
+
options={[
|
|
286
|
+
{ value: 'all', label: 'All statuses' },
|
|
287
|
+
{ value: 'PUBLISHED', label: 'Published' },
|
|
288
|
+
{ value: 'DRAFT', label: 'Draft' },
|
|
289
|
+
{ value: 'SCHEDULED', label: 'Scheduled' },
|
|
290
|
+
]}
|
|
291
|
+
/>
|
|
292
|
+
|
|
293
|
+
<span className="text-muted-foreground ml-auto text-sm tabular-nums">
|
|
294
|
+
{total} of {totalAll}
|
|
295
|
+
</span>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{/* Tag filter bar */}
|
|
299
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
300
|
+
<span className="text-muted-foreground text-[10px] font-semibold tracking-wider uppercase">
|
|
301
|
+
Tags
|
|
302
|
+
</span>
|
|
303
|
+
{tags.map((t) => (
|
|
304
|
+
<PageTagChip
|
|
305
|
+
key={t.slug}
|
|
306
|
+
name={t.name}
|
|
307
|
+
color={t.color}
|
|
308
|
+
count={t.pageCount}
|
|
309
|
+
selected={selectedTags.has(t.slug)}
|
|
310
|
+
onToggle={() => toggleTag(t.slug)}
|
|
311
|
+
/>
|
|
312
|
+
))}
|
|
313
|
+
{selectedTags.size > 0 && (
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
onClick={() => setSelectedTags(new Set())}
|
|
317
|
+
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
|
|
318
|
+
>
|
|
319
|
+
<X className="h-3 w-3" aria-hidden />
|
|
320
|
+
Clear tags
|
|
321
|
+
</button>
|
|
322
|
+
)}
|
|
323
|
+
<button
|
|
324
|
+
type="button"
|
|
325
|
+
onClick={() => onNavigate('/pages/tags')}
|
|
326
|
+
className="text-primary ml-auto inline-flex items-center gap-1 text-xs font-medium hover:underline"
|
|
327
|
+
>
|
|
328
|
+
<TagIcon className="h-3 w-3" aria-hidden />
|
|
329
|
+
Manage tags
|
|
330
|
+
</button>
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
{/* Bulk action bar */}
|
|
334
|
+
{selected.size > 0 && (
|
|
335
|
+
<BulkActionBar
|
|
336
|
+
count={selected.size}
|
|
337
|
+
tags={tags}
|
|
338
|
+
blockedHomepage={blockedHomepage}
|
|
339
|
+
onClear={() => setSelected(new Set())}
|
|
340
|
+
onPublish={() => runBulkStatus('PUBLISHED')}
|
|
341
|
+
onDraft={() => runBulkStatus('DRAFT')}
|
|
342
|
+
onAddTag={(slug) => runBulkTag(slug, 'add')}
|
|
343
|
+
onRemoveTag={(slug) => runBulkTag(slug, 'remove')}
|
|
344
|
+
onDelete={() => setPendingDelete(true)}
|
|
345
|
+
deletableCount={deletablePages.length}
|
|
346
|
+
/>
|
|
347
|
+
)}
|
|
348
|
+
|
|
349
|
+
{/* Table card */}
|
|
350
|
+
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
|
|
351
|
+
<table className="w-full table-fixed" role="table" aria-label="Pages">
|
|
352
|
+
<colgroup>
|
|
353
|
+
<col style={{ width: '40px' }} />
|
|
354
|
+
<col />
|
|
355
|
+
<col style={{ width: '200px' }} />
|
|
356
|
+
<col style={{ width: '170px' }} />
|
|
357
|
+
<col style={{ width: '120px' }} />
|
|
358
|
+
<col style={{ width: '130px' }} />
|
|
359
|
+
<col style={{ width: '80px' }} />
|
|
360
|
+
</colgroup>
|
|
361
|
+
<thead className="border-border bg-muted/40 border-b">
|
|
362
|
+
<tr className="text-muted-foreground text-xs">
|
|
363
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
364
|
+
<input
|
|
365
|
+
type="checkbox"
|
|
366
|
+
aria-label="Select all pages on this page"
|
|
367
|
+
checked={allOnPageSelected}
|
|
368
|
+
onChange={(e) => toggleAllOnPage(e.target.checked)}
|
|
369
|
+
className="border-border accent-primary rounded"
|
|
370
|
+
/>
|
|
371
|
+
</th>
|
|
372
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
373
|
+
<SortHeader
|
|
374
|
+
label="Title"
|
|
375
|
+
field="title"
|
|
376
|
+
current={sortBy}
|
|
377
|
+
dir={dir}
|
|
378
|
+
onSort={handleSort}
|
|
379
|
+
/>
|
|
380
|
+
</th>
|
|
381
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
382
|
+
<span className="text-xs font-medium tracking-wider uppercase">Tags</span>
|
|
383
|
+
</th>
|
|
384
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
385
|
+
<SortHeader
|
|
386
|
+
label="Author"
|
|
387
|
+
field="author"
|
|
388
|
+
current={sortBy}
|
|
389
|
+
dir={dir}
|
|
390
|
+
onSort={handleSort}
|
|
391
|
+
/>
|
|
392
|
+
</th>
|
|
393
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
394
|
+
<SortHeader
|
|
395
|
+
label="Status"
|
|
396
|
+
field="status"
|
|
397
|
+
current={sortBy}
|
|
398
|
+
dir={dir}
|
|
399
|
+
onSort={handleSort}
|
|
400
|
+
/>
|
|
401
|
+
</th>
|
|
402
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
403
|
+
<SortHeader
|
|
404
|
+
label="Last Modified"
|
|
405
|
+
field="modified"
|
|
406
|
+
current={sortBy}
|
|
407
|
+
dir={dir}
|
|
408
|
+
onSort={handleSort}
|
|
409
|
+
/>
|
|
410
|
+
</th>
|
|
411
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
412
|
+
<SortHeader
|
|
413
|
+
label="SEO"
|
|
414
|
+
field="seo"
|
|
415
|
+
current={sortBy}
|
|
416
|
+
dir={dir}
|
|
417
|
+
onSort={handleSort}
|
|
418
|
+
/>
|
|
419
|
+
</th>
|
|
420
|
+
</tr>
|
|
421
|
+
</thead>
|
|
422
|
+
<tbody>
|
|
423
|
+
{loading && pages.length === 0 && <LoadingRows />}
|
|
424
|
+
{!loading && error && (
|
|
425
|
+
<tr>
|
|
426
|
+
<td colSpan={7} className="px-3 py-12 text-center">
|
|
427
|
+
<div className="text-foreground mb-2 text-sm font-medium">{error}</div>
|
|
428
|
+
<button
|
|
429
|
+
onClick={() => setRefreshToken((t) => t + 1)}
|
|
430
|
+
className="text-primary text-sm hover:underline"
|
|
431
|
+
>
|
|
432
|
+
Try again
|
|
433
|
+
</button>
|
|
434
|
+
</td>
|
|
435
|
+
</tr>
|
|
436
|
+
)}
|
|
437
|
+
{!loading && !error && pages.length === 0 && (
|
|
438
|
+
<EmptyRow
|
|
439
|
+
filtersActive={filtersAreActive}
|
|
440
|
+
onClear={clearFilters}
|
|
441
|
+
onNewPage={() => onNavigate('/pages/new')}
|
|
442
|
+
/>
|
|
443
|
+
)}
|
|
444
|
+
{!loading &&
|
|
445
|
+
!error &&
|
|
446
|
+
displayGroups.map((group) => (
|
|
447
|
+
<GroupBlock
|
|
448
|
+
key={group.key}
|
|
449
|
+
showHeader={grouped}
|
|
450
|
+
label={group.label}
|
|
451
|
+
color={group.color}
|
|
452
|
+
count={group.pages.length}
|
|
453
|
+
>
|
|
454
|
+
{group.pages.map((p) => (
|
|
455
|
+
<PageRow
|
|
456
|
+
key={p.id}
|
|
457
|
+
page={p}
|
|
458
|
+
selected={selected.has(p.id)}
|
|
459
|
+
resolveTag={(slug) => tagBySlug.get(slug)}
|
|
460
|
+
onSelect={() => toggleOne(p.id)}
|
|
461
|
+
onOpen={() => onNavigate(`/pages/${p.id}/edit`)}
|
|
462
|
+
/>
|
|
463
|
+
))}
|
|
464
|
+
</GroupBlock>
|
|
465
|
+
))}
|
|
466
|
+
</tbody>
|
|
467
|
+
</table>
|
|
468
|
+
|
|
469
|
+
{/* Pagination only for the flat (ungrouped) view. */}
|
|
470
|
+
{!grouped && (
|
|
471
|
+
<PaginationFooter
|
|
472
|
+
page={page}
|
|
473
|
+
totalPages={totalPages}
|
|
474
|
+
total={total}
|
|
475
|
+
pageSize={PAGE_SIZE}
|
|
476
|
+
onPage={setPage}
|
|
477
|
+
/>
|
|
478
|
+
)}
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
<ConfirmDialog
|
|
482
|
+
open={pendingDelete}
|
|
483
|
+
title={`Delete ${deletablePages.length} page${deletablePages.length === 1 ? '' : 's'}?`}
|
|
484
|
+
description={
|
|
485
|
+
blockedHomepage
|
|
486
|
+
? 'The homepage cannot be deleted and will be skipped. The remaining pages move to trash and can be restored for 30 days.'
|
|
487
|
+
: 'The selected pages move to trash and can be restored for 30 days.'
|
|
488
|
+
}
|
|
489
|
+
confirmLabel="Delete"
|
|
490
|
+
destructive
|
|
491
|
+
onClose={() => setPendingDelete(false)}
|
|
492
|
+
onConfirm={() => {
|
|
493
|
+
void runBulkDelete()
|
|
494
|
+
}}
|
|
495
|
+
/>
|
|
496
|
+
</div>
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ─── Sub-components ──────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
function SortHeader({
|
|
503
|
+
label,
|
|
504
|
+
field,
|
|
505
|
+
current,
|
|
506
|
+
dir,
|
|
507
|
+
onSort,
|
|
508
|
+
}: {
|
|
509
|
+
label: string
|
|
510
|
+
field: SortBy
|
|
511
|
+
current: SortBy
|
|
512
|
+
dir: Dir
|
|
513
|
+
onSort: (f: SortBy) => void
|
|
514
|
+
}) {
|
|
515
|
+
const active = current === field
|
|
516
|
+
const ariaSort = active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'
|
|
517
|
+
return (
|
|
518
|
+
<button
|
|
519
|
+
type="button"
|
|
520
|
+
onClick={() => onSort(field)}
|
|
521
|
+
aria-sort={ariaSort}
|
|
522
|
+
className={`hover:text-foreground inline-flex items-center gap-1 text-xs font-medium tracking-wider uppercase transition-colors ${
|
|
523
|
+
active ? 'text-foreground' : 'text-muted-foreground'
|
|
524
|
+
}`}
|
|
525
|
+
>
|
|
526
|
+
<span>{label}</span>
|
|
527
|
+
{active &&
|
|
528
|
+
(dir === 'asc' ? (
|
|
529
|
+
<ChevronUp className="h-3 w-3" aria-hidden />
|
|
530
|
+
) : (
|
|
531
|
+
<ChevronDown className="h-3 w-3" aria-hidden />
|
|
532
|
+
))}
|
|
533
|
+
</button>
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function FilterSelect({
|
|
538
|
+
label,
|
|
539
|
+
value,
|
|
540
|
+
onChange,
|
|
541
|
+
options,
|
|
542
|
+
}: {
|
|
543
|
+
label: string
|
|
544
|
+
value: string
|
|
545
|
+
onChange: (v: string) => void
|
|
546
|
+
options: { value: string; label: string }[]
|
|
547
|
+
}) {
|
|
548
|
+
return (
|
|
549
|
+
<label className="text-foreground inline-flex items-center gap-1.5 text-sm">
|
|
550
|
+
<span className="text-muted-foreground">{label}:</span>
|
|
551
|
+
<select
|
|
552
|
+
value={value}
|
|
553
|
+
onChange={(e) => onChange(e.target.value)}
|
|
554
|
+
className="border-border bg-card text-foreground focus:border-primary focus:ring-primary/30 rounded-lg border px-2.5 py-1.5 text-sm shadow-sm focus:ring-2 focus:outline-none"
|
|
555
|
+
>
|
|
556
|
+
{options.map((o) => (
|
|
557
|
+
<option key={o.value} value={o.value}>
|
|
558
|
+
{o.label}
|
|
559
|
+
</option>
|
|
560
|
+
))}
|
|
561
|
+
</select>
|
|
562
|
+
</label>
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function GroupBlock({
|
|
567
|
+
showHeader,
|
|
568
|
+
label,
|
|
569
|
+
color,
|
|
570
|
+
count,
|
|
571
|
+
children,
|
|
572
|
+
}: {
|
|
573
|
+
showHeader: boolean
|
|
574
|
+
label: string
|
|
575
|
+
color: string | null
|
|
576
|
+
count: number
|
|
577
|
+
children: React.ReactNode
|
|
578
|
+
}) {
|
|
579
|
+
return (
|
|
580
|
+
<>
|
|
581
|
+
{showHeader && (
|
|
582
|
+
<tr className="bg-muted/30 border-border border-b">
|
|
583
|
+
<td colSpan={7} className="px-3 py-2">
|
|
584
|
+
<span className="text-foreground inline-flex items-center gap-2 text-xs font-semibold tracking-wider uppercase">
|
|
585
|
+
{color && <span aria-hidden className={`h-2 w-2 rounded-full ${dotFor(color)}`} />}
|
|
586
|
+
{label}
|
|
587
|
+
<span className="text-muted-foreground tabular-nums">{count}</span>
|
|
588
|
+
</span>
|
|
589
|
+
</td>
|
|
590
|
+
</tr>
|
|
591
|
+
)}
|
|
592
|
+
{children}
|
|
593
|
+
</>
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function dotFor(color: string): string {
|
|
598
|
+
const map: Record<string, string> = {
|
|
599
|
+
purple: 'bg-violet-500',
|
|
600
|
+
green: 'bg-emerald-500',
|
|
601
|
+
orange: 'bg-amber-500',
|
|
602
|
+
red: 'bg-red-500',
|
|
603
|
+
blue: 'bg-blue-500',
|
|
604
|
+
pink: 'bg-pink-500',
|
|
605
|
+
}
|
|
606
|
+
return map[color] ?? 'bg-muted-foreground'
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function BulkActionBar({
|
|
610
|
+
count,
|
|
611
|
+
tags,
|
|
612
|
+
blockedHomepage,
|
|
613
|
+
deletableCount,
|
|
614
|
+
onClear,
|
|
615
|
+
onPublish,
|
|
616
|
+
onDraft,
|
|
617
|
+
onAddTag,
|
|
618
|
+
onRemoveTag,
|
|
619
|
+
onDelete,
|
|
620
|
+
}: {
|
|
621
|
+
count: number
|
|
622
|
+
tags: PageTag[]
|
|
623
|
+
blockedHomepage: boolean
|
|
624
|
+
deletableCount: number
|
|
625
|
+
onClear: () => void
|
|
626
|
+
onPublish: () => void
|
|
627
|
+
onDraft: () => void
|
|
628
|
+
onAddTag: (slug: string) => void
|
|
629
|
+
onRemoveTag: (slug: string) => void
|
|
630
|
+
onDelete: () => void
|
|
631
|
+
}) {
|
|
632
|
+
return (
|
|
633
|
+
<div className="border-primary/30 bg-primary/5 flex flex-wrap items-center gap-2 rounded-lg border px-4 py-2">
|
|
634
|
+
<span className="text-foreground text-sm font-medium">{count} selected</span>
|
|
635
|
+
<div className="bg-border h-4 w-px" aria-hidden />
|
|
636
|
+
<button
|
|
637
|
+
type="button"
|
|
638
|
+
onClick={onPublish}
|
|
639
|
+
className="text-foreground hover:bg-accent rounded-md px-2 py-1 text-sm transition-colors"
|
|
640
|
+
>
|
|
641
|
+
Publish
|
|
642
|
+
</button>
|
|
643
|
+
<button
|
|
644
|
+
type="button"
|
|
645
|
+
onClick={onDraft}
|
|
646
|
+
className="text-foreground hover:bg-accent rounded-md px-2 py-1 text-sm transition-colors"
|
|
647
|
+
>
|
|
648
|
+
Move to draft
|
|
649
|
+
</button>
|
|
650
|
+
<TagMenu label="Add tag" tags={tags} onPick={onAddTag} />
|
|
651
|
+
<TagMenu label="Remove tag" tags={tags} onPick={onRemoveTag} />
|
|
652
|
+
<button
|
|
653
|
+
type="button"
|
|
654
|
+
onClick={onDelete}
|
|
655
|
+
disabled={deletableCount === 0}
|
|
656
|
+
title={blockedHomepage ? 'The homepage is excluded from deletion' : undefined}
|
|
657
|
+
className="text-destructive hover:bg-destructive/10 inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
658
|
+
>
|
|
659
|
+
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
|
660
|
+
Delete
|
|
661
|
+
</button>
|
|
662
|
+
<button
|
|
663
|
+
type="button"
|
|
664
|
+
onClick={onClear}
|
|
665
|
+
className="text-muted-foreground hover:text-foreground ml-auto inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors"
|
|
666
|
+
>
|
|
667
|
+
<X className="h-3.5 w-3.5" aria-hidden />
|
|
668
|
+
Clear
|
|
669
|
+
</button>
|
|
670
|
+
</div>
|
|
671
|
+
)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function TagMenu({
|
|
675
|
+
label,
|
|
676
|
+
tags,
|
|
677
|
+
onPick,
|
|
678
|
+
}: {
|
|
679
|
+
label: string
|
|
680
|
+
tags: PageTag[]
|
|
681
|
+
onPick: (slug: string) => void
|
|
682
|
+
}) {
|
|
683
|
+
const [open, setOpen] = useState(false)
|
|
684
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
685
|
+
|
|
686
|
+
useEffect(() => {
|
|
687
|
+
if (!open) return
|
|
688
|
+
const onDoc = (e: MouseEvent) => {
|
|
689
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
690
|
+
}
|
|
691
|
+
document.addEventListener('mousedown', onDoc)
|
|
692
|
+
return () => document.removeEventListener('mousedown', onDoc)
|
|
693
|
+
}, [open])
|
|
694
|
+
|
|
695
|
+
return (
|
|
696
|
+
<div className="relative" ref={ref}>
|
|
697
|
+
<button
|
|
698
|
+
type="button"
|
|
699
|
+
onClick={() => setOpen((o) => !o)}
|
|
700
|
+
aria-haspopup="menu"
|
|
701
|
+
aria-expanded={open}
|
|
702
|
+
className="text-foreground hover:bg-accent inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors"
|
|
703
|
+
>
|
|
704
|
+
{label}
|
|
705
|
+
<ChevronDown className="h-3 w-3" aria-hidden />
|
|
706
|
+
</button>
|
|
707
|
+
{open && (
|
|
708
|
+
<div
|
|
709
|
+
role="menu"
|
|
710
|
+
className="border-border bg-popover absolute z-20 mt-1 max-h-64 w-44 overflow-auto rounded-lg border p-1 shadow-lg"
|
|
711
|
+
>
|
|
712
|
+
{tags.length === 0 && (
|
|
713
|
+
<div className="text-muted-foreground px-2 py-1.5 text-xs">No tags yet</div>
|
|
714
|
+
)}
|
|
715
|
+
{tags.map((t) => (
|
|
716
|
+
<button
|
|
717
|
+
key={t.slug}
|
|
718
|
+
type="button"
|
|
719
|
+
role="menuitem"
|
|
720
|
+
onClick={() => {
|
|
721
|
+
onPick(t.slug)
|
|
722
|
+
setOpen(false)
|
|
723
|
+
}}
|
|
724
|
+
className="text-foreground hover:bg-accent flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm"
|
|
725
|
+
>
|
|
726
|
+
<span aria-hidden className={`h-2 w-2 rounded-full ${dotFor(t.color ?? 'gray')}`} />
|
|
727
|
+
{t.name}
|
|
728
|
+
</button>
|
|
729
|
+
))}
|
|
730
|
+
</div>
|
|
731
|
+
)}
|
|
732
|
+
</div>
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function PageRow({
|
|
737
|
+
page,
|
|
738
|
+
selected,
|
|
739
|
+
resolveTag,
|
|
740
|
+
onSelect,
|
|
741
|
+
onOpen,
|
|
742
|
+
}: {
|
|
743
|
+
page: Page
|
|
744
|
+
selected: boolean
|
|
745
|
+
resolveTag: (slug: string) => { name: string; color: string | null } | undefined
|
|
746
|
+
onSelect: () => void
|
|
747
|
+
onOpen: () => void
|
|
748
|
+
}) {
|
|
749
|
+
return (
|
|
750
|
+
<tr
|
|
751
|
+
className={`border-border hover:bg-muted/30 group border-b transition-colors last:border-b-0 ${
|
|
752
|
+
selected ? 'bg-primary/5' : ''
|
|
753
|
+
}`}
|
|
754
|
+
>
|
|
755
|
+
<td className="px-3 py-3" onClick={(e) => e.stopPropagation()}>
|
|
756
|
+
<input
|
|
757
|
+
type="checkbox"
|
|
758
|
+
aria-label={`Select "${page.title}"`}
|
|
759
|
+
checked={selected}
|
|
760
|
+
onChange={onSelect}
|
|
761
|
+
className="border-border accent-primary rounded"
|
|
762
|
+
/>
|
|
763
|
+
</td>
|
|
764
|
+
<td className="px-3 py-3">
|
|
765
|
+
<button type="button" onClick={onOpen} className="flex w-full items-center gap-3 text-left">
|
|
766
|
+
<span aria-hidden className="bg-muted block h-9 w-9 shrink-0 rounded-md" />
|
|
767
|
+
<span className="min-w-0">
|
|
768
|
+
<span className="text-foreground group-hover:text-primary block truncate text-sm font-medium">
|
|
769
|
+
{page.title}
|
|
770
|
+
</span>
|
|
771
|
+
<span className="text-muted-foreground block truncate text-xs">{page.path}</span>
|
|
772
|
+
</span>
|
|
773
|
+
</button>
|
|
774
|
+
</td>
|
|
775
|
+
<td className="px-3 py-3">
|
|
776
|
+
<PageTagsCell tagSlugs={page.tags} primaryTagId={page.primaryTagId} resolve={resolveTag} />
|
|
777
|
+
</td>
|
|
778
|
+
<td className="px-3 py-3">
|
|
779
|
+
<div className="flex items-center gap-2">
|
|
780
|
+
<span
|
|
781
|
+
aria-hidden
|
|
782
|
+
className="bg-muted text-muted-foreground inline-flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-semibold"
|
|
783
|
+
>
|
|
784
|
+
{page.authorInitials}
|
|
785
|
+
</span>
|
|
786
|
+
<span className="text-foreground truncate text-sm">{page.authorName}</span>
|
|
787
|
+
</div>
|
|
788
|
+
</td>
|
|
789
|
+
<td className="px-3 py-3">
|
|
790
|
+
<PostStatusBadge status={page.status} />
|
|
791
|
+
</td>
|
|
792
|
+
<td className="text-muted-foreground px-3 py-3 text-sm tabular-nums">
|
|
793
|
+
{formatDateShort(page.lastModified)}
|
|
794
|
+
</td>
|
|
795
|
+
<td className="px-3 py-3">
|
|
796
|
+
<SeoScoreIndicator score={page.seoScore} />
|
|
797
|
+
</td>
|
|
798
|
+
</tr>
|
|
799
|
+
)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function LoadingRows() {
|
|
803
|
+
return (
|
|
804
|
+
<>
|
|
805
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
806
|
+
<tr key={i} className="border-border border-b last:border-b-0">
|
|
807
|
+
{Array.from({ length: 7 }).map((__, j) => (
|
|
808
|
+
<td key={j} className="px-3 py-3">
|
|
809
|
+
<div className="bg-muted h-4 w-full animate-pulse rounded" />
|
|
810
|
+
</td>
|
|
811
|
+
))}
|
|
812
|
+
</tr>
|
|
813
|
+
))}
|
|
814
|
+
</>
|
|
815
|
+
)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function EmptyRow({
|
|
819
|
+
filtersActive,
|
|
820
|
+
onClear,
|
|
821
|
+
onNewPage,
|
|
822
|
+
}: {
|
|
823
|
+
filtersActive: boolean
|
|
824
|
+
onClear: () => void
|
|
825
|
+
onNewPage: () => void
|
|
826
|
+
}) {
|
|
827
|
+
return (
|
|
828
|
+
<tr>
|
|
829
|
+
<td colSpan={7} className="px-3 py-16 text-center">
|
|
830
|
+
<div className="text-foreground mb-2 text-sm font-medium">
|
|
831
|
+
{filtersActive ? 'No pages match your filters' : 'No pages yet'}
|
|
832
|
+
</div>
|
|
833
|
+
<div className="text-muted-foreground mb-4 text-sm">
|
|
834
|
+
{filtersActive
|
|
835
|
+
? 'Try clearing the search, tags, or status filter above.'
|
|
836
|
+
: 'Create your first page to get started.'}
|
|
837
|
+
</div>
|
|
838
|
+
{filtersActive ? (
|
|
839
|
+
<button type="button" onClick={onClear} className="text-primary text-sm hover:underline">
|
|
840
|
+
Clear filters
|
|
841
|
+
</button>
|
|
842
|
+
) : (
|
|
843
|
+
<button
|
|
844
|
+
type="button"
|
|
845
|
+
onClick={onNewPage}
|
|
846
|
+
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"
|
|
847
|
+
>
|
|
848
|
+
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
849
|
+
New page
|
|
850
|
+
</button>
|
|
851
|
+
)}
|
|
852
|
+
</td>
|
|
853
|
+
</tr>
|
|
854
|
+
)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function PaginationFooter({
|
|
858
|
+
page,
|
|
859
|
+
totalPages,
|
|
860
|
+
total,
|
|
861
|
+
pageSize,
|
|
862
|
+
onPage,
|
|
863
|
+
}: {
|
|
864
|
+
page: number
|
|
865
|
+
totalPages: number
|
|
866
|
+
total: number
|
|
867
|
+
pageSize: number
|
|
868
|
+
onPage: (p: number) => void
|
|
869
|
+
}) {
|
|
870
|
+
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
|
|
871
|
+
const end = Math.min(total, page * pageSize)
|
|
872
|
+
return (
|
|
873
|
+
<div className="border-border flex items-center justify-between border-t px-4 py-3 text-sm">
|
|
874
|
+
<span className="text-muted-foreground tabular-nums">
|
|
875
|
+
{start}–{end} of {total} page{total === 1 ? '' : 's'}
|
|
876
|
+
</span>
|
|
877
|
+
<nav className="flex items-center gap-1" aria-label="Pages pagination">
|
|
878
|
+
<button
|
|
879
|
+
type="button"
|
|
880
|
+
onClick={() => onPage(Math.max(1, page - 1))}
|
|
881
|
+
disabled={page === 1}
|
|
882
|
+
aria-label="Previous page"
|
|
883
|
+
className="border-border bg-background text-foreground hover:bg-accent inline-flex h-8 w-8 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
884
|
+
>
|
|
885
|
+
<ChevronLeft className="h-4 w-4" aria-hidden />
|
|
886
|
+
</button>
|
|
887
|
+
{pageNumbers(page, totalPages).map((p, idx) =>
|
|
888
|
+
p === '…' ? (
|
|
889
|
+
<span
|
|
890
|
+
key={`gap-${idx}`}
|
|
891
|
+
aria-hidden
|
|
892
|
+
className="text-muted-foreground inline-flex h-8 w-8 items-center justify-center text-sm"
|
|
893
|
+
>
|
|
894
|
+
…
|
|
895
|
+
</span>
|
|
896
|
+
) : (
|
|
897
|
+
<button
|
|
898
|
+
key={p}
|
|
899
|
+
type="button"
|
|
900
|
+
onClick={() => onPage(p)}
|
|
901
|
+
aria-current={p === page ? 'page' : undefined}
|
|
902
|
+
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm tabular-nums transition-colors ${
|
|
903
|
+
p === page
|
|
904
|
+
? 'bg-primary text-primary-foreground'
|
|
905
|
+
: 'border-border bg-background text-foreground hover:bg-accent border'
|
|
906
|
+
}`}
|
|
907
|
+
>
|
|
908
|
+
{p}
|
|
909
|
+
</button>
|
|
910
|
+
),
|
|
911
|
+
)}
|
|
912
|
+
<button
|
|
913
|
+
type="button"
|
|
914
|
+
onClick={() => onPage(Math.min(totalPages, page + 1))}
|
|
915
|
+
disabled={page === totalPages}
|
|
916
|
+
aria-label="Next page"
|
|
917
|
+
className="border-border bg-background text-foreground hover:bg-accent inline-flex h-8 w-8 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
918
|
+
>
|
|
919
|
+
<ChevronRight className="h-4 w-4" aria-hidden />
|
|
920
|
+
</button>
|
|
921
|
+
</nav>
|
|
922
|
+
</div>
|
|
923
|
+
)
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
927
|
+
|
|
928
|
+
function formatDateShort(input: string): string {
|
|
929
|
+
const d = new Date(input)
|
|
930
|
+
if (Number.isNaN(d.getTime())) return '—'
|
|
931
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export function pageNumbers(current: number, total: number): Array<number | '…'> {
|
|
935
|
+
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
|
|
936
|
+
const out: Array<number | '…'> = [1]
|
|
937
|
+
const start = Math.max(2, current - 1)
|
|
938
|
+
const end = Math.min(total - 1, current + 1)
|
|
939
|
+
if (start > 2) out.push('…')
|
|
940
|
+
for (let i = start; i <= end; i++) out.push(i)
|
|
941
|
+
if (end < total - 1) out.push('…')
|
|
942
|
+
out.push(total)
|
|
943
|
+
return out
|
|
944
|
+
}
|