@actuate-media/cms-admin 0.15.0 → 0.16.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/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__/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 +14 -3
- 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 +213 -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__/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 +15 -3
- 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,869 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
ChevronDown,
|
|
6
|
+
ChevronLeft,
|
|
7
|
+
ChevronRight,
|
|
8
|
+
ChevronUp,
|
|
9
|
+
Loader2,
|
|
10
|
+
Search,
|
|
11
|
+
Settings,
|
|
12
|
+
Trash2,
|
|
13
|
+
X,
|
|
14
|
+
} from 'lucide-react'
|
|
15
|
+
import { toast } from 'sonner'
|
|
16
|
+
import {
|
|
17
|
+
bulkDeletePosts,
|
|
18
|
+
bulkUpdatePosts,
|
|
19
|
+
fetchPosts,
|
|
20
|
+
type Post,
|
|
21
|
+
type PostStatus,
|
|
22
|
+
type PostsResult,
|
|
23
|
+
type PostType,
|
|
24
|
+
} from '../../lib/posts-service.js'
|
|
25
|
+
import {
|
|
26
|
+
PostStatusBadge,
|
|
27
|
+
PostTypeBadge,
|
|
28
|
+
SeoScoreIndicator,
|
|
29
|
+
paletteFor,
|
|
30
|
+
} from '../../components/posts/badges.js'
|
|
31
|
+
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* All Posts management surface — table with type tabs, controls row,
|
|
35
|
+
* sortable headers, bulk actions, pagination. Data flows through the
|
|
36
|
+
* posts-service so swapping to a server endpoint later only touches
|
|
37
|
+
* the service file.
|
|
38
|
+
*
|
|
39
|
+
* Routing:
|
|
40
|
+
* - Row click → /posts/{postTypeId}/{postId}/edit (the section-based Post
|
|
41
|
+
* editor, seeded from the post type's template).
|
|
42
|
+
* - "Manage types" → /posts/types.
|
|
43
|
+
* - "+ New Post" lives in the top bar (computePageActions); the page
|
|
44
|
+
* itself doesn't render that button.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
interface PostsListViewProps {
|
|
48
|
+
onNavigate: (path: string) => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type SortBy = 'title' | 'type' | 'author' | 'status' | 'date' | 'seo'
|
|
52
|
+
type Dir = 'asc' | 'desc'
|
|
53
|
+
|
|
54
|
+
const PAGE_SIZE = 10
|
|
55
|
+
|
|
56
|
+
export function PostsListView({ onNavigate }: PostsListViewProps) {
|
|
57
|
+
const [activeType, setActiveType] = useState<string>('all')
|
|
58
|
+
const [search, setSearch] = useState('')
|
|
59
|
+
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
60
|
+
const [status, setStatus] = useState<PostStatus | 'all'>('all')
|
|
61
|
+
const [author, setAuthor] = useState<string>('all')
|
|
62
|
+
const [sortBy, setSortBy] = useState<SortBy>('date')
|
|
63
|
+
const [dir, setDir] = useState<Dir>('desc')
|
|
64
|
+
const [page, setPage] = useState(1)
|
|
65
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
66
|
+
|
|
67
|
+
const [data, setData] = useState<PostsResult | null>(null)
|
|
68
|
+
const [loading, setLoading] = useState(true)
|
|
69
|
+
const [error, setError] = useState<string | null>(null)
|
|
70
|
+
const [refreshToken, setRefreshToken] = useState(0)
|
|
71
|
+
const [pendingBulk, setPendingBulk] = useState<null | 'delete' | 'publish' | 'draft'>(null)
|
|
72
|
+
|
|
73
|
+
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current)
|
|
76
|
+
debounceTimer.current = setTimeout(() => {
|
|
77
|
+
setDebouncedSearch(search.trim())
|
|
78
|
+
setPage(1)
|
|
79
|
+
}, 250)
|
|
80
|
+
return () => {
|
|
81
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current)
|
|
82
|
+
}
|
|
83
|
+
}, [search])
|
|
84
|
+
|
|
85
|
+
// Re-fetch when any input that affects the result set changes.
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
let cancelled = false
|
|
88
|
+
setLoading(true)
|
|
89
|
+
setError(null)
|
|
90
|
+
fetchPosts({
|
|
91
|
+
postType: activeType,
|
|
92
|
+
status,
|
|
93
|
+
author,
|
|
94
|
+
search: debouncedSearch,
|
|
95
|
+
sortBy,
|
|
96
|
+
sortDirection: dir,
|
|
97
|
+
page,
|
|
98
|
+
pageSize: PAGE_SIZE,
|
|
99
|
+
})
|
|
100
|
+
.then((res) => {
|
|
101
|
+
if (cancelled) return
|
|
102
|
+
setData(res)
|
|
103
|
+
setLoading(false)
|
|
104
|
+
})
|
|
105
|
+
.catch((err: unknown) => {
|
|
106
|
+
if (cancelled) return
|
|
107
|
+
setError(err instanceof Error ? err.message : 'Failed to load posts')
|
|
108
|
+
setLoading(false)
|
|
109
|
+
})
|
|
110
|
+
return () => {
|
|
111
|
+
cancelled = true
|
|
112
|
+
}
|
|
113
|
+
}, [activeType, status, author, debouncedSearch, sortBy, dir, page, refreshToken])
|
|
114
|
+
|
|
115
|
+
// Whenever the active type tab changes, reset paging to page 1.
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
setPage(1)
|
|
118
|
+
setSelected(new Set())
|
|
119
|
+
}, [activeType, debouncedSearch, status, author])
|
|
120
|
+
|
|
121
|
+
const total = data?.total ?? 0
|
|
122
|
+
const totalAll = useMemo(
|
|
123
|
+
() => (data?.types ?? []).reduce((sum, t) => sum + t.postCount, 0),
|
|
124
|
+
[data?.types],
|
|
125
|
+
)
|
|
126
|
+
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
127
|
+
const posts = data?.posts ?? []
|
|
128
|
+
const types = data?.types ?? []
|
|
129
|
+
const authors = data?.authors ?? []
|
|
130
|
+
|
|
131
|
+
const handleSort = useCallback(
|
|
132
|
+
(field: SortBy) => {
|
|
133
|
+
if (sortBy === field) {
|
|
134
|
+
setDir(dir === 'asc' ? 'desc' : 'asc')
|
|
135
|
+
} else {
|
|
136
|
+
setSortBy(field)
|
|
137
|
+
setDir(field === 'date' || field === 'seo' ? 'desc' : 'asc')
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
[sortBy, dir],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const clearFilters = () => {
|
|
144
|
+
setSearch('')
|
|
145
|
+
setStatus('all')
|
|
146
|
+
setAuthor('all')
|
|
147
|
+
setActiveType('all')
|
|
148
|
+
setPage(1)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const filtersAreActive =
|
|
152
|
+
debouncedSearch !== '' || status !== 'all' || author !== 'all' || activeType !== 'all'
|
|
153
|
+
|
|
154
|
+
// ─── Selection / bulk actions ─────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
const toggleOne = (id: string) =>
|
|
157
|
+
setSelected((s) => {
|
|
158
|
+
const next = new Set(s)
|
|
159
|
+
if (next.has(id)) next.delete(id)
|
|
160
|
+
else next.add(id)
|
|
161
|
+
return next
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const allOnPageSelected = posts.length > 0 && posts.every((p) => selected.has(p.id))
|
|
165
|
+
const toggleAllOnPage = (checked: boolean) =>
|
|
166
|
+
setSelected((s) => {
|
|
167
|
+
const next = new Set(s)
|
|
168
|
+
if (checked) for (const p of posts) next.add(p.id)
|
|
169
|
+
else for (const p of posts) next.delete(p.id)
|
|
170
|
+
return next
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const selectedPosts = useMemo(() => posts.filter((p) => selected.has(p.id)), [posts, selected])
|
|
174
|
+
|
|
175
|
+
const runBulkUpdate = async (target: PostStatus) => {
|
|
176
|
+
const ids = selectedPosts.map((p) => ({ postTypeId: p.postTypeId, id: p.id }))
|
|
177
|
+
if (ids.length === 0) return
|
|
178
|
+
const verb = target === 'PUBLISHED' ? 'Publishing' : 'Updating'
|
|
179
|
+
const promise = bulkUpdatePosts(ids, { status: target })
|
|
180
|
+
toast.promise(promise, {
|
|
181
|
+
loading: `${verb} ${ids.length} post${ids.length === 1 ? '' : 's'}…`,
|
|
182
|
+
success: (r) => `${r.ok} updated${r.failed ? `, ${r.failed} failed` : ''}`,
|
|
183
|
+
error: 'Bulk update failed',
|
|
184
|
+
})
|
|
185
|
+
await promise
|
|
186
|
+
setSelected(new Set())
|
|
187
|
+
setRefreshToken((t) => t + 1)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const runBulkDelete = async () => {
|
|
191
|
+
const ids = selectedPosts.map((p) => ({ postTypeId: p.postTypeId, id: p.id }))
|
|
192
|
+
if (ids.length === 0) return
|
|
193
|
+
const promise = bulkDeletePosts(ids)
|
|
194
|
+
toast.promise(promise, {
|
|
195
|
+
loading: `Deleting ${ids.length} post${ids.length === 1 ? '' : 's'}…`,
|
|
196
|
+
success: (r) => `${r.ok} deleted${r.failed ? `, ${r.failed} failed` : ''}`,
|
|
197
|
+
error: 'Bulk delete failed',
|
|
198
|
+
})
|
|
199
|
+
await promise
|
|
200
|
+
setSelected(new Set())
|
|
201
|
+
setRefreshToken((t) => t + 1)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Render ───────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div className="flex h-full flex-col gap-4 p-4 sm:p-6">
|
|
208
|
+
{/* Header */}
|
|
209
|
+
<div className="flex flex-col gap-1">
|
|
210
|
+
<h1 className="text-foreground text-xl font-medium tracking-tight sm:text-2xl">Posts</h1>
|
|
211
|
+
<p className="text-muted-foreground text-sm">
|
|
212
|
+
{totalAll} total post{totalAll === 1 ? '' : 's'} across all types
|
|
213
|
+
</p>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{/* Type tabs */}
|
|
217
|
+
<TypeTabs types={types} totalAll={totalAll} active={activeType} onChange={setActiveType} />
|
|
218
|
+
|
|
219
|
+
{/* Controls row */}
|
|
220
|
+
<ControlsRow
|
|
221
|
+
search={search}
|
|
222
|
+
onSearchChange={setSearch}
|
|
223
|
+
status={status}
|
|
224
|
+
onStatusChange={(s) => {
|
|
225
|
+
setStatus(s)
|
|
226
|
+
setPage(1)
|
|
227
|
+
}}
|
|
228
|
+
author={author}
|
|
229
|
+
onAuthorChange={(a) => {
|
|
230
|
+
setAuthor(a)
|
|
231
|
+
setPage(1)
|
|
232
|
+
}}
|
|
233
|
+
authors={authors}
|
|
234
|
+
resultCount={total}
|
|
235
|
+
onManageTypes={() => onNavigate('/posts/types')}
|
|
236
|
+
/>
|
|
237
|
+
|
|
238
|
+
{/* Bulk action bar */}
|
|
239
|
+
{selected.size > 0 && (
|
|
240
|
+
<BulkActionBar
|
|
241
|
+
count={selected.size}
|
|
242
|
+
onClear={() => setSelected(new Set())}
|
|
243
|
+
onPublish={() => runBulkUpdate('PUBLISHED')}
|
|
244
|
+
onDraft={() => runBulkUpdate('DRAFT')}
|
|
245
|
+
onDelete={() => setPendingBulk('delete')}
|
|
246
|
+
/>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{/* Table card */}
|
|
250
|
+
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
|
|
251
|
+
<table className="w-full table-fixed" role="table" aria-label="Posts">
|
|
252
|
+
<colgroup>
|
|
253
|
+
<col style={{ width: '40px' }} />
|
|
254
|
+
<col />
|
|
255
|
+
<col style={{ width: '140px' }} />
|
|
256
|
+
<col style={{ width: '180px' }} />
|
|
257
|
+
<col style={{ width: '130px' }} />
|
|
258
|
+
<col style={{ width: '120px' }} />
|
|
259
|
+
<col style={{ width: '90px' }} />
|
|
260
|
+
</colgroup>
|
|
261
|
+
<thead className="border-border bg-muted/40 border-b">
|
|
262
|
+
<tr className="text-muted-foreground text-xs">
|
|
263
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
264
|
+
<input
|
|
265
|
+
type="checkbox"
|
|
266
|
+
aria-label="Select all posts on this page"
|
|
267
|
+
checked={allOnPageSelected}
|
|
268
|
+
onChange={(e) => toggleAllOnPage(e.target.checked)}
|
|
269
|
+
className="border-border accent-primary rounded"
|
|
270
|
+
/>
|
|
271
|
+
</th>
|
|
272
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
273
|
+
<SortHeader
|
|
274
|
+
label="Title"
|
|
275
|
+
field="title"
|
|
276
|
+
current={sortBy}
|
|
277
|
+
dir={dir}
|
|
278
|
+
onSort={handleSort}
|
|
279
|
+
/>
|
|
280
|
+
</th>
|
|
281
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
282
|
+
<SortHeader
|
|
283
|
+
label="Type"
|
|
284
|
+
field="type"
|
|
285
|
+
current={sortBy}
|
|
286
|
+
dir={dir}
|
|
287
|
+
onSort={handleSort}
|
|
288
|
+
/>
|
|
289
|
+
</th>
|
|
290
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
291
|
+
<SortHeader
|
|
292
|
+
label="Author"
|
|
293
|
+
field="author"
|
|
294
|
+
current={sortBy}
|
|
295
|
+
dir={dir}
|
|
296
|
+
onSort={handleSort}
|
|
297
|
+
/>
|
|
298
|
+
</th>
|
|
299
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
300
|
+
<SortHeader
|
|
301
|
+
label="Status"
|
|
302
|
+
field="status"
|
|
303
|
+
current={sortBy}
|
|
304
|
+
dir={dir}
|
|
305
|
+
onSort={handleSort}
|
|
306
|
+
/>
|
|
307
|
+
</th>
|
|
308
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
309
|
+
<SortHeader
|
|
310
|
+
label="Date"
|
|
311
|
+
field="date"
|
|
312
|
+
current={sortBy}
|
|
313
|
+
dir={dir}
|
|
314
|
+
onSort={handleSort}
|
|
315
|
+
/>
|
|
316
|
+
</th>
|
|
317
|
+
<th scope="col" className="px-3 py-2.5 text-left">
|
|
318
|
+
<SortHeader
|
|
319
|
+
label="SEO"
|
|
320
|
+
field="seo"
|
|
321
|
+
current={sortBy}
|
|
322
|
+
dir={dir}
|
|
323
|
+
onSort={handleSort}
|
|
324
|
+
/>
|
|
325
|
+
</th>
|
|
326
|
+
</tr>
|
|
327
|
+
</thead>
|
|
328
|
+
<tbody>
|
|
329
|
+
{loading && posts.length === 0 && <LoadingRows />}
|
|
330
|
+
{!loading && error && (
|
|
331
|
+
<tr>
|
|
332
|
+
<td colSpan={7} className="px-3 py-12 text-center">
|
|
333
|
+
<div className="text-foreground mb-2 text-sm font-medium">{error}</div>
|
|
334
|
+
<button
|
|
335
|
+
onClick={() => setRefreshToken((t) => t + 1)}
|
|
336
|
+
className="text-primary text-sm hover:underline"
|
|
337
|
+
>
|
|
338
|
+
Try again
|
|
339
|
+
</button>
|
|
340
|
+
</td>
|
|
341
|
+
</tr>
|
|
342
|
+
)}
|
|
343
|
+
{!loading && !error && posts.length === 0 && (
|
|
344
|
+
<EmptyRow
|
|
345
|
+
filtersActive={filtersAreActive}
|
|
346
|
+
onClear={clearFilters}
|
|
347
|
+
onNewPost={() => onNavigate('/posts/new')}
|
|
348
|
+
/>
|
|
349
|
+
)}
|
|
350
|
+
{!loading &&
|
|
351
|
+
!error &&
|
|
352
|
+
posts.map((p) => (
|
|
353
|
+
<PostRow
|
|
354
|
+
key={p.id}
|
|
355
|
+
post={p}
|
|
356
|
+
selected={selected.has(p.id)}
|
|
357
|
+
onSelect={() => toggleOne(p.id)}
|
|
358
|
+
onOpen={() => onNavigate(`/posts/${p.postTypeId}/${p.id}/edit`)}
|
|
359
|
+
/>
|
|
360
|
+
))}
|
|
361
|
+
</tbody>
|
|
362
|
+
</table>
|
|
363
|
+
|
|
364
|
+
{/* Pagination footer */}
|
|
365
|
+
<PaginationFooter
|
|
366
|
+
page={page}
|
|
367
|
+
totalPages={totalPages}
|
|
368
|
+
total={total}
|
|
369
|
+
pageSize={PAGE_SIZE}
|
|
370
|
+
onPage={setPage}
|
|
371
|
+
/>
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
<ConfirmDialog
|
|
375
|
+
open={pendingBulk === 'delete'}
|
|
376
|
+
title={`Delete ${selected.size} post${selected.size === 1 ? '' : 's'}?`}
|
|
377
|
+
description="This moves the selected posts to trash. They can be restored from the per-type list for 30 days."
|
|
378
|
+
confirmLabel="Delete"
|
|
379
|
+
destructive
|
|
380
|
+
onClose={() => setPendingBulk(null)}
|
|
381
|
+
onConfirm={() => {
|
|
382
|
+
void runBulkDelete()
|
|
383
|
+
}}
|
|
384
|
+
/>
|
|
385
|
+
</div>
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── Sub-components ──────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
function SortHeader({
|
|
392
|
+
label,
|
|
393
|
+
field,
|
|
394
|
+
current,
|
|
395
|
+
dir,
|
|
396
|
+
onSort,
|
|
397
|
+
}: {
|
|
398
|
+
label: string
|
|
399
|
+
field: SortBy
|
|
400
|
+
current: SortBy
|
|
401
|
+
dir: Dir
|
|
402
|
+
onSort: (f: SortBy) => void
|
|
403
|
+
}) {
|
|
404
|
+
const active = current === field
|
|
405
|
+
const ariaSort = active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'
|
|
406
|
+
return (
|
|
407
|
+
<button
|
|
408
|
+
type="button"
|
|
409
|
+
onClick={() => onSort(field)}
|
|
410
|
+
aria-sort={ariaSort}
|
|
411
|
+
className={`hover:text-foreground inline-flex items-center gap-1 text-xs font-medium tracking-wider uppercase transition-colors ${
|
|
412
|
+
active ? 'text-foreground' : 'text-muted-foreground'
|
|
413
|
+
}`}
|
|
414
|
+
>
|
|
415
|
+
<span>{label}</span>
|
|
416
|
+
{active &&
|
|
417
|
+
(dir === 'asc' ? (
|
|
418
|
+
<ChevronUp className="h-3 w-3" aria-hidden />
|
|
419
|
+
) : (
|
|
420
|
+
<ChevronDown className="h-3 w-3" aria-hidden />
|
|
421
|
+
))}
|
|
422
|
+
</button>
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function TypeTabs({
|
|
427
|
+
types,
|
|
428
|
+
totalAll,
|
|
429
|
+
active,
|
|
430
|
+
onChange,
|
|
431
|
+
}: {
|
|
432
|
+
types: PostType[]
|
|
433
|
+
totalAll: number
|
|
434
|
+
active: string
|
|
435
|
+
onChange: (slug: string) => void
|
|
436
|
+
}) {
|
|
437
|
+
return (
|
|
438
|
+
<div
|
|
439
|
+
role="tablist"
|
|
440
|
+
aria-label="Filter posts by type"
|
|
441
|
+
className="border-border flex flex-wrap items-center gap-1 border-b pb-px"
|
|
442
|
+
>
|
|
443
|
+
<TabPill
|
|
444
|
+
label="All Posts"
|
|
445
|
+
count={totalAll}
|
|
446
|
+
active={active === 'all'}
|
|
447
|
+
onClick={() => onChange('all')}
|
|
448
|
+
/>
|
|
449
|
+
{types.map((t) => (
|
|
450
|
+
<TabPill
|
|
451
|
+
key={t.slug}
|
|
452
|
+
label={t.name}
|
|
453
|
+
count={t.postCount}
|
|
454
|
+
active={active === t.slug}
|
|
455
|
+
onClick={() => onChange(t.slug)}
|
|
456
|
+
/>
|
|
457
|
+
))}
|
|
458
|
+
</div>
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function TabPill({
|
|
463
|
+
label,
|
|
464
|
+
count,
|
|
465
|
+
active,
|
|
466
|
+
onClick,
|
|
467
|
+
}: {
|
|
468
|
+
label: string
|
|
469
|
+
count: number
|
|
470
|
+
active: boolean
|
|
471
|
+
onClick: () => void
|
|
472
|
+
}) {
|
|
473
|
+
return (
|
|
474
|
+
<button
|
|
475
|
+
type="button"
|
|
476
|
+
role="tab"
|
|
477
|
+
aria-selected={active}
|
|
478
|
+
onClick={onClick}
|
|
479
|
+
className={`relative inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
|
480
|
+
active
|
|
481
|
+
? 'bg-primary/10 text-primary'
|
|
482
|
+
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
|
483
|
+
}`}
|
|
484
|
+
>
|
|
485
|
+
<span>{label}</span>
|
|
486
|
+
<span
|
|
487
|
+
className={`rounded-full px-1.5 text-xs tabular-nums ${
|
|
488
|
+
active ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'
|
|
489
|
+
}`}
|
|
490
|
+
>
|
|
491
|
+
{count}
|
|
492
|
+
</span>
|
|
493
|
+
</button>
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function ControlsRow({
|
|
498
|
+
search,
|
|
499
|
+
onSearchChange,
|
|
500
|
+
status,
|
|
501
|
+
onStatusChange,
|
|
502
|
+
author,
|
|
503
|
+
onAuthorChange,
|
|
504
|
+
authors,
|
|
505
|
+
resultCount,
|
|
506
|
+
onManageTypes,
|
|
507
|
+
}: {
|
|
508
|
+
search: string
|
|
509
|
+
onSearchChange: (v: string) => void
|
|
510
|
+
status: PostStatus | 'all'
|
|
511
|
+
onStatusChange: (s: PostStatus | 'all') => void
|
|
512
|
+
author: string
|
|
513
|
+
onAuthorChange: (a: string) => void
|
|
514
|
+
authors: { id: string; name: string }[]
|
|
515
|
+
resultCount: number
|
|
516
|
+
onManageTypes: () => void
|
|
517
|
+
}) {
|
|
518
|
+
return (
|
|
519
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
520
|
+
<div className="relative max-w-sm flex-1">
|
|
521
|
+
<Search
|
|
522
|
+
className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
|
523
|
+
aria-hidden
|
|
524
|
+
/>
|
|
525
|
+
<input
|
|
526
|
+
type="search"
|
|
527
|
+
value={search}
|
|
528
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
529
|
+
aria-label="Search posts"
|
|
530
|
+
placeholder="Search posts…"
|
|
531
|
+
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"
|
|
532
|
+
/>
|
|
533
|
+
</div>
|
|
534
|
+
|
|
535
|
+
<FilterSelect
|
|
536
|
+
label="Status"
|
|
537
|
+
value={status}
|
|
538
|
+
onChange={(v) => onStatusChange(v as PostStatus | 'all')}
|
|
539
|
+
options={[
|
|
540
|
+
{ value: 'all', label: 'All' },
|
|
541
|
+
{ value: 'PUBLISHED', label: 'Published' },
|
|
542
|
+
{ value: 'DRAFT', label: 'Draft' },
|
|
543
|
+
{ value: 'SCHEDULED', label: 'Scheduled' },
|
|
544
|
+
]}
|
|
545
|
+
/>
|
|
546
|
+
|
|
547
|
+
<FilterSelect
|
|
548
|
+
label="Author"
|
|
549
|
+
value={author}
|
|
550
|
+
onChange={onAuthorChange}
|
|
551
|
+
options={[
|
|
552
|
+
{ value: 'all', label: 'All' },
|
|
553
|
+
...authors.map((a) => ({ value: a.id, label: a.name })),
|
|
554
|
+
]}
|
|
555
|
+
/>
|
|
556
|
+
|
|
557
|
+
<div className="ml-auto flex items-center gap-3">
|
|
558
|
+
<button
|
|
559
|
+
type="button"
|
|
560
|
+
onClick={onManageTypes}
|
|
561
|
+
className="border-border bg-background text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors"
|
|
562
|
+
>
|
|
563
|
+
<Settings className="h-3.5 w-3.5" aria-hidden />
|
|
564
|
+
Manage types
|
|
565
|
+
</button>
|
|
566
|
+
<span className="text-muted-foreground text-sm tabular-nums">
|
|
567
|
+
{resultCount} {resultCount === 1 ? 'result' : 'results'}
|
|
568
|
+
</span>
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function FilterSelect({
|
|
575
|
+
label,
|
|
576
|
+
value,
|
|
577
|
+
onChange,
|
|
578
|
+
options,
|
|
579
|
+
}: {
|
|
580
|
+
label: string
|
|
581
|
+
value: string
|
|
582
|
+
onChange: (v: string) => void
|
|
583
|
+
options: { value: string; label: string }[]
|
|
584
|
+
}) {
|
|
585
|
+
return (
|
|
586
|
+
<label className="text-foreground inline-flex items-center gap-1.5 text-sm">
|
|
587
|
+
<span className="text-muted-foreground">{label}:</span>
|
|
588
|
+
<select
|
|
589
|
+
value={value}
|
|
590
|
+
onChange={(e) => onChange(e.target.value)}
|
|
591
|
+
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"
|
|
592
|
+
>
|
|
593
|
+
{options.map((o) => (
|
|
594
|
+
<option key={o.value} value={o.value}>
|
|
595
|
+
{o.label}
|
|
596
|
+
</option>
|
|
597
|
+
))}
|
|
598
|
+
</select>
|
|
599
|
+
</label>
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function BulkActionBar({
|
|
604
|
+
count,
|
|
605
|
+
onClear,
|
|
606
|
+
onPublish,
|
|
607
|
+
onDraft,
|
|
608
|
+
onDelete,
|
|
609
|
+
}: {
|
|
610
|
+
count: number
|
|
611
|
+
onClear: () => void
|
|
612
|
+
onPublish: () => void
|
|
613
|
+
onDraft: () => void
|
|
614
|
+
onDelete: () => void
|
|
615
|
+
}) {
|
|
616
|
+
return (
|
|
617
|
+
<div className="border-primary/30 bg-primary/5 flex items-center gap-2 rounded-lg border px-4 py-2">
|
|
618
|
+
<span className="text-foreground text-sm font-medium">{count} selected</span>
|
|
619
|
+
<div className="bg-border h-4 w-px" aria-hidden />
|
|
620
|
+
<button
|
|
621
|
+
type="button"
|
|
622
|
+
onClick={onPublish}
|
|
623
|
+
className="text-foreground hover:bg-accent rounded-md px-2 py-1 text-sm transition-colors"
|
|
624
|
+
>
|
|
625
|
+
Publish
|
|
626
|
+
</button>
|
|
627
|
+
<button
|
|
628
|
+
type="button"
|
|
629
|
+
onClick={onDraft}
|
|
630
|
+
className="text-foreground hover:bg-accent rounded-md px-2 py-1 text-sm transition-colors"
|
|
631
|
+
>
|
|
632
|
+
Move to draft
|
|
633
|
+
</button>
|
|
634
|
+
<button
|
|
635
|
+
type="button"
|
|
636
|
+
onClick={onDelete}
|
|
637
|
+
className="text-destructive hover:bg-destructive/10 inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors"
|
|
638
|
+
>
|
|
639
|
+
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
|
640
|
+
Delete
|
|
641
|
+
</button>
|
|
642
|
+
<button
|
|
643
|
+
type="button"
|
|
644
|
+
onClick={onClear}
|
|
645
|
+
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"
|
|
646
|
+
>
|
|
647
|
+
<X className="h-3.5 w-3.5" aria-hidden />
|
|
648
|
+
Clear
|
|
649
|
+
</button>
|
|
650
|
+
</div>
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function PostRow({
|
|
655
|
+
post,
|
|
656
|
+
selected,
|
|
657
|
+
onSelect,
|
|
658
|
+
onOpen,
|
|
659
|
+
}: {
|
|
660
|
+
post: Post
|
|
661
|
+
selected: boolean
|
|
662
|
+
onSelect: () => void
|
|
663
|
+
onOpen: () => void
|
|
664
|
+
}) {
|
|
665
|
+
const p = paletteFor(post.postTypeAccentColor)
|
|
666
|
+
return (
|
|
667
|
+
<tr
|
|
668
|
+
className={`border-border hover:bg-muted/30 group border-b transition-colors last:border-b-0 ${
|
|
669
|
+
selected ? 'bg-primary/5' : ''
|
|
670
|
+
}`}
|
|
671
|
+
>
|
|
672
|
+
<td className="px-3 py-3" onClick={(e) => e.stopPropagation()}>
|
|
673
|
+
<input
|
|
674
|
+
type="checkbox"
|
|
675
|
+
aria-label={`Select "${post.title}"`}
|
|
676
|
+
checked={selected}
|
|
677
|
+
onChange={onSelect}
|
|
678
|
+
className="border-border accent-primary rounded"
|
|
679
|
+
/>
|
|
680
|
+
</td>
|
|
681
|
+
<td className="px-3 py-3">
|
|
682
|
+
<button type="button" onClick={onOpen} className="flex w-full items-center gap-3 text-left">
|
|
683
|
+
<span aria-hidden className={`block h-9 w-9 shrink-0 rounded-md ${p.bg.split(' ')[0]}`} />
|
|
684
|
+
<span className="min-w-0">
|
|
685
|
+
<span className="text-foreground group-hover:text-primary block truncate text-sm font-medium">
|
|
686
|
+
{post.title}
|
|
687
|
+
</span>
|
|
688
|
+
<span className="text-muted-foreground block truncate text-xs">
|
|
689
|
+
/{post.postTypeId}/{post.slug || '—'}
|
|
690
|
+
</span>
|
|
691
|
+
</span>
|
|
692
|
+
</button>
|
|
693
|
+
</td>
|
|
694
|
+
<td className="px-3 py-3">
|
|
695
|
+
<PostTypeBadge label={post.postTypeName} color={post.postTypeAccentColor} />
|
|
696
|
+
</td>
|
|
697
|
+
<td className="px-3 py-3">
|
|
698
|
+
<div className="flex items-center gap-2">
|
|
699
|
+
<span
|
|
700
|
+
aria-hidden
|
|
701
|
+
className="bg-muted text-muted-foreground inline-flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-semibold"
|
|
702
|
+
>
|
|
703
|
+
{post.authorInitials}
|
|
704
|
+
</span>
|
|
705
|
+
<span className="text-foreground truncate text-sm">{post.authorName}</span>
|
|
706
|
+
</div>
|
|
707
|
+
</td>
|
|
708
|
+
<td className="px-3 py-3">
|
|
709
|
+
<PostStatusBadge status={post.status} />
|
|
710
|
+
</td>
|
|
711
|
+
<td className="text-muted-foreground px-3 py-3 text-sm tabular-nums">
|
|
712
|
+
{formatDateShort(post.updatedDate)}
|
|
713
|
+
</td>
|
|
714
|
+
<td className="px-3 py-3">
|
|
715
|
+
<SeoScoreIndicator score={post.seoScore} />
|
|
716
|
+
</td>
|
|
717
|
+
</tr>
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function LoadingRows() {
|
|
722
|
+
const rows = Array.from({ length: 8 })
|
|
723
|
+
return (
|
|
724
|
+
<>
|
|
725
|
+
{rows.map((_, i) => (
|
|
726
|
+
<tr key={i} className="border-border border-b last:border-b-0">
|
|
727
|
+
{Array.from({ length: 7 }).map((__, j) => (
|
|
728
|
+
<td key={j} className="px-3 py-3">
|
|
729
|
+
<div className="bg-muted h-4 w-full animate-pulse rounded" />
|
|
730
|
+
</td>
|
|
731
|
+
))}
|
|
732
|
+
</tr>
|
|
733
|
+
))}
|
|
734
|
+
</>
|
|
735
|
+
)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function EmptyRow({
|
|
739
|
+
filtersActive,
|
|
740
|
+
onClear,
|
|
741
|
+
onNewPost,
|
|
742
|
+
}: {
|
|
743
|
+
filtersActive: boolean
|
|
744
|
+
onClear: () => void
|
|
745
|
+
onNewPost: () => void
|
|
746
|
+
}) {
|
|
747
|
+
return (
|
|
748
|
+
<tr>
|
|
749
|
+
<td colSpan={7} className="px-3 py-16 text-center">
|
|
750
|
+
<div className="text-foreground mb-2 text-sm font-medium">
|
|
751
|
+
{filtersActive ? 'No posts match the current filters.' : 'No posts yet.'}
|
|
752
|
+
</div>
|
|
753
|
+
<div className="text-muted-foreground mb-4 text-sm">
|
|
754
|
+
{filtersActive
|
|
755
|
+
? 'Try clearing the search or filters above.'
|
|
756
|
+
: 'Create your first post to get started.'}
|
|
757
|
+
</div>
|
|
758
|
+
{filtersActive ? (
|
|
759
|
+
<button type="button" onClick={onClear} className="text-primary text-sm hover:underline">
|
|
760
|
+
Clear filters
|
|
761
|
+
</button>
|
|
762
|
+
) : (
|
|
763
|
+
<button
|
|
764
|
+
type="button"
|
|
765
|
+
onClick={onNewPost}
|
|
766
|
+
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"
|
|
767
|
+
>
|
|
768
|
+
New Post
|
|
769
|
+
</button>
|
|
770
|
+
)}
|
|
771
|
+
</td>
|
|
772
|
+
</tr>
|
|
773
|
+
)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function PaginationFooter({
|
|
777
|
+
page,
|
|
778
|
+
totalPages,
|
|
779
|
+
total,
|
|
780
|
+
pageSize,
|
|
781
|
+
onPage,
|
|
782
|
+
}: {
|
|
783
|
+
page: number
|
|
784
|
+
totalPages: number
|
|
785
|
+
total: number
|
|
786
|
+
pageSize: number
|
|
787
|
+
onPage: (p: number) => void
|
|
788
|
+
}) {
|
|
789
|
+
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
|
|
790
|
+
const end = Math.min(total, page * pageSize)
|
|
791
|
+
|
|
792
|
+
return (
|
|
793
|
+
<div className="border-border flex items-center justify-between border-t px-4 py-3 text-sm">
|
|
794
|
+
<span className="text-muted-foreground tabular-nums">
|
|
795
|
+
{start}–{end} of {total} post{total === 1 ? '' : 's'}
|
|
796
|
+
</span>
|
|
797
|
+
<nav className="flex items-center gap-1" aria-label="Posts pagination">
|
|
798
|
+
<button
|
|
799
|
+
type="button"
|
|
800
|
+
onClick={() => onPage(Math.max(1, page - 1))}
|
|
801
|
+
disabled={page === 1}
|
|
802
|
+
aria-label="Previous page"
|
|
803
|
+
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"
|
|
804
|
+
>
|
|
805
|
+
<ChevronLeft className="h-4 w-4" aria-hidden />
|
|
806
|
+
</button>
|
|
807
|
+
{pageNumbers(page, totalPages).map((p, idx) =>
|
|
808
|
+
p === '…' ? (
|
|
809
|
+
<span
|
|
810
|
+
key={`gap-${idx}`}
|
|
811
|
+
aria-hidden
|
|
812
|
+
className="text-muted-foreground inline-flex h-8 w-8 items-center justify-center text-sm"
|
|
813
|
+
>
|
|
814
|
+
…
|
|
815
|
+
</span>
|
|
816
|
+
) : (
|
|
817
|
+
<button
|
|
818
|
+
key={p}
|
|
819
|
+
type="button"
|
|
820
|
+
onClick={() => onPage(p)}
|
|
821
|
+
aria-current={p === page ? 'page' : undefined}
|
|
822
|
+
className={`inline-flex h-8 w-8 items-center justify-center rounded-md text-sm tabular-nums transition-colors ${
|
|
823
|
+
p === page
|
|
824
|
+
? 'bg-primary text-primary-foreground'
|
|
825
|
+
: 'border-border bg-background text-foreground hover:bg-accent border'
|
|
826
|
+
}`}
|
|
827
|
+
>
|
|
828
|
+
{p}
|
|
829
|
+
</button>
|
|
830
|
+
),
|
|
831
|
+
)}
|
|
832
|
+
<button
|
|
833
|
+
type="button"
|
|
834
|
+
onClick={() => onPage(Math.min(totalPages, page + 1))}
|
|
835
|
+
disabled={page === totalPages}
|
|
836
|
+
aria-label="Next page"
|
|
837
|
+
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"
|
|
838
|
+
>
|
|
839
|
+
<ChevronRight className="h-4 w-4" aria-hidden />
|
|
840
|
+
</button>
|
|
841
|
+
</nav>
|
|
842
|
+
</div>
|
|
843
|
+
)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
847
|
+
|
|
848
|
+
function formatDateShort(input: string): string {
|
|
849
|
+
const d = new Date(input)
|
|
850
|
+
if (Number.isNaN(d.getTime())) return '—'
|
|
851
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Return up to 7 page-control entries: a sliding window around the
|
|
856
|
+
* current page with ellipsis markers. Keeps the footer narrow even on
|
|
857
|
+
* collections with hundreds of pages.
|
|
858
|
+
*/
|
|
859
|
+
export function pageNumbers(current: number, total: number): Array<number | '…'> {
|
|
860
|
+
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
|
|
861
|
+
const out: Array<number | '…'> = [1]
|
|
862
|
+
const start = Math.max(2, current - 1)
|
|
863
|
+
const end = Math.min(total - 1, current + 1)
|
|
864
|
+
if (start > 2) out.push('…')
|
|
865
|
+
for (let i = start; i <= end; i++) out.push(i)
|
|
866
|
+
if (end < total - 1) out.push('…')
|
|
867
|
+
out.push(total)
|
|
868
|
+
return out
|
|
869
|
+
}
|