@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,599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pages service — thin client over the existing REST endpoints, shaped
|
|
3
|
+
* around the data the Pages admin area needs.
|
|
4
|
+
*
|
|
5
|
+
* Data model (v1):
|
|
6
|
+
* - Pages are documents in the `pages` collection.
|
|
7
|
+
* · `data.path` — full URL path, e.g. "/about/story" (falls
|
|
8
|
+
* back to "/" + slug)
|
|
9
|
+
* · `data.tags` — string[] of tag slugs
|
|
10
|
+
* · `data.primaryTag` — tag slug used for "Group by tag"
|
|
11
|
+
* · `data.seoScore` / `data.metaTitle` / `data.metaDescription`
|
|
12
|
+
* - Tags are documents in the hidden `page-tags` collection.
|
|
13
|
+
* · title = tag name, slug = tag slug
|
|
14
|
+
* · `data.color` / `data.description` / `data.sortOrder` / `data.active`
|
|
15
|
+
*
|
|
16
|
+
* Why DB-backed tags (not code-defined like Post Types)?
|
|
17
|
+
* Tags are editorial data that teams create/rename/merge/delete at
|
|
18
|
+
* runtime. Post Types are content *models* (schema) and must be
|
|
19
|
+
* code-defined; tags are *labels* and belong in the database. Storing
|
|
20
|
+
* them as documents reuses the existing collection CRUD + access
|
|
21
|
+
* control with zero new endpoints.
|
|
22
|
+
*
|
|
23
|
+
* Filtering/sorting/grouping happen client-side over the fetched set
|
|
24
|
+
* (the seed is 13 rows). Promote to a server-side `/api/cms/pages`
|
|
25
|
+
* aggregate when the collection grows past ~5k rows or page-load
|
|
26
|
+
* latency exceeds 300ms — only this file changes; callers keep the same
|
|
27
|
+
* `Page[]` / `PageTag[]` shapes.
|
|
28
|
+
*/
|
|
29
|
+
import { cmsApi } from './api.js'
|
|
30
|
+
|
|
31
|
+
export type PageStatus = 'DRAFT' | 'PUBLISHED' | 'SCHEDULED' | 'ARCHIVED'
|
|
32
|
+
|
|
33
|
+
export const PAGES_COLLECTION = 'pages'
|
|
34
|
+
export const PAGE_TAGS_COLLECTION = 'page-tags'
|
|
35
|
+
|
|
36
|
+
export interface PageTag {
|
|
37
|
+
id: string // == slug (stable join key; pages store slugs in data.tags)
|
|
38
|
+
docId: string // underlying document id — required for tag CRUD
|
|
39
|
+
name: string
|
|
40
|
+
slug: string
|
|
41
|
+
description: string | null
|
|
42
|
+
color: string | null
|
|
43
|
+
pageCount: number
|
|
44
|
+
sortOrder: number
|
|
45
|
+
active: boolean
|
|
46
|
+
createdAt: string | null
|
|
47
|
+
updatedAt: string | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PageAuthor {
|
|
51
|
+
id: string
|
|
52
|
+
name: string
|
|
53
|
+
initials: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface Page {
|
|
57
|
+
id: string
|
|
58
|
+
title: string
|
|
59
|
+
path: string
|
|
60
|
+
slug: string
|
|
61
|
+
parentPageId: string | null
|
|
62
|
+
tags: string[] // tag slugs
|
|
63
|
+
primaryTagId: string | null // tag slug
|
|
64
|
+
authorId: string
|
|
65
|
+
authorName: string
|
|
66
|
+
authorInitials: string
|
|
67
|
+
status: PageStatus
|
|
68
|
+
publishDate: string | null
|
|
69
|
+
scheduledDate: string | null
|
|
70
|
+
lastModified: string
|
|
71
|
+
seoScore: number | null
|
|
72
|
+
seoTitle: string | null
|
|
73
|
+
seoDescription: string | null
|
|
74
|
+
templateId: string | null
|
|
75
|
+
isHomepage: boolean
|
|
76
|
+
isSystemPage: boolean
|
|
77
|
+
createdAt: string
|
|
78
|
+
updatedAt: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type PageGroupBy = 'none' | 'tag' | 'status' | 'author'
|
|
82
|
+
|
|
83
|
+
export interface PagesQuery {
|
|
84
|
+
search?: string
|
|
85
|
+
tagIds?: string[] // tag slugs; OR semantics
|
|
86
|
+
status?: PageStatus | 'all'
|
|
87
|
+
authorId?: string | 'all'
|
|
88
|
+
groupBy?: PageGroupBy
|
|
89
|
+
sortBy?: 'title' | 'author' | 'status' | 'modified' | 'seo'
|
|
90
|
+
sortDirection?: 'asc' | 'desc'
|
|
91
|
+
page?: number
|
|
92
|
+
pageSize?: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PagesResult {
|
|
96
|
+
pages: Page[]
|
|
97
|
+
total: number
|
|
98
|
+
page: number
|
|
99
|
+
pageSize: number
|
|
100
|
+
tags: PageTag[]
|
|
101
|
+
authors: PageAuthor[]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── API row shapes ──────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
interface DocumentApiRow {
|
|
107
|
+
id: string
|
|
108
|
+
collection: string
|
|
109
|
+
title: string | null
|
|
110
|
+
slug: string | null
|
|
111
|
+
status: PageStatus
|
|
112
|
+
createdById: string | null
|
|
113
|
+
updatedById: string | null
|
|
114
|
+
publishedAt: string | null
|
|
115
|
+
scheduledAt: string | null
|
|
116
|
+
createdAt: string
|
|
117
|
+
updatedAt: string
|
|
118
|
+
data: Record<string, unknown> | null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface ListEnvelope {
|
|
122
|
+
docs: DocumentApiRow[]
|
|
123
|
+
total: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface UserApiRow {
|
|
127
|
+
id: string
|
|
128
|
+
name?: string | null
|
|
129
|
+
email: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
function initialsFromName(name: string | null | undefined, email: string): string {
|
|
135
|
+
if (name && name.trim().length > 0) {
|
|
136
|
+
const parts = name.trim().split(/\s+/)
|
|
137
|
+
const first = parts[0]?.[0] ?? ''
|
|
138
|
+
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : ''
|
|
139
|
+
return (first + last).toUpperCase() || (email[0] ?? '?').toUpperCase()
|
|
140
|
+
}
|
|
141
|
+
return (email[0] ?? '?').toUpperCase()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function fetchAuthorsMap(): Promise<Map<string, PageAuthor>> {
|
|
145
|
+
const res = await cmsApi<{ users: UserApiRow[] } | UserApiRow[]>('/users?pageSize=200')
|
|
146
|
+
const list: UserApiRow[] = Array.isArray(res.data)
|
|
147
|
+
? res.data
|
|
148
|
+
: ((res.data as { users?: UserApiRow[] } | null)?.users ?? [])
|
|
149
|
+
const map = new Map<string, PageAuthor>()
|
|
150
|
+
for (const u of list) {
|
|
151
|
+
map.set(u.id, {
|
|
152
|
+
id: u.id,
|
|
153
|
+
name: u.name?.trim() || u.email,
|
|
154
|
+
initials: initialsFromName(u.name, u.email),
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
return map
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function asString(v: unknown): string | null {
|
|
161
|
+
return typeof v === 'string' && v.length > 0 ? v : null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function asStringArray(v: unknown): string[] {
|
|
165
|
+
if (!Array.isArray(v)) return []
|
|
166
|
+
return v.filter((x): x is string => typeof x === 'string' && x.length > 0)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function extractSeoScore(data: Record<string, unknown> | null): number | null {
|
|
170
|
+
if (!data) return null
|
|
171
|
+
for (const k of ['seoScore', 'seo_score', 'metaScore']) {
|
|
172
|
+
const val = data[k]
|
|
173
|
+
if (typeof val === 'number' && Number.isFinite(val)) return val
|
|
174
|
+
}
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function pageDisplayDate(d: DocumentApiRow): string {
|
|
179
|
+
if (d.status === 'SCHEDULED' && d.scheduledAt) return d.scheduledAt
|
|
180
|
+
if (d.status === 'PUBLISHED' && d.publishedAt) return d.publishedAt
|
|
181
|
+
return d.updatedAt
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Tags ────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
function docToTag(d: DocumentApiRow, pageCount: number): PageTag {
|
|
187
|
+
const data = d.data ?? {}
|
|
188
|
+
return {
|
|
189
|
+
id: d.slug ?? d.id,
|
|
190
|
+
docId: d.id,
|
|
191
|
+
name: d.title ?? d.slug ?? '(untitled tag)',
|
|
192
|
+
slug: d.slug ?? d.id,
|
|
193
|
+
description: asString(data.description),
|
|
194
|
+
color: asString(data.color),
|
|
195
|
+
pageCount,
|
|
196
|
+
sortOrder: typeof data.sortOrder === 'number' ? data.sortOrder : 0,
|
|
197
|
+
active: data.active !== false,
|
|
198
|
+
createdAt: d.createdAt ?? null,
|
|
199
|
+
updatedAt: d.updatedAt ?? null,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function fetchTagDocs(): Promise<DocumentApiRow[]> {
|
|
204
|
+
const res = await cmsApi<ListEnvelope>(
|
|
205
|
+
`/collections/${PAGE_TAGS_COLLECTION}?pageSize=500&sort=updatedAt&order=desc`,
|
|
206
|
+
)
|
|
207
|
+
return (res.data?.docs ?? []) as DocumentApiRow[]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Fetch all tags. `pageCounts` (slug → count) is optional so the tag
|
|
212
|
+
* manager / pickers can render before page data is loaded.
|
|
213
|
+
*/
|
|
214
|
+
export async function fetchPageTags(pageCounts?: Map<string, number>): Promise<PageTag[]> {
|
|
215
|
+
const docs = await fetchTagDocs()
|
|
216
|
+
const tags = docs.map((d) => docToTag(d, pageCounts?.get(d.slug ?? d.id) ?? 0))
|
|
217
|
+
tags.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
|
|
218
|
+
return tags
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function fetchAuthors(): Promise<PageAuthor[]> {
|
|
222
|
+
const map = await fetchAuthorsMap()
|
|
223
|
+
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─── Pages ───────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
function docToPage(d: DocumentApiRow, authors: Map<string, PageAuthor>): Page {
|
|
229
|
+
const data = d.data ?? {}
|
|
230
|
+
const slug = d.slug ?? asString(data.slug) ?? ''
|
|
231
|
+
const path = asString(data.path) ?? (slug ? `/${slug}`.replace(/\/+/g, '/') : '/')
|
|
232
|
+
const author = (d.createdById && authors.get(d.createdById)) ||
|
|
233
|
+
(d.updatedById && authors.get(d.updatedById)) || { id: '', name: 'Unknown', initials: '??' }
|
|
234
|
+
const tags = asStringArray(data.tags)
|
|
235
|
+
const primaryTagId = asString(data.primaryTag) ?? tags[0] ?? null
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
id: d.id,
|
|
239
|
+
title: d.title ?? asString(data.title) ?? '(untitled)',
|
|
240
|
+
path,
|
|
241
|
+
slug,
|
|
242
|
+
parentPageId: asString(data.parentPageId),
|
|
243
|
+
tags,
|
|
244
|
+
primaryTagId,
|
|
245
|
+
authorId: author.id,
|
|
246
|
+
authorName: author.name,
|
|
247
|
+
authorInitials: author.initials,
|
|
248
|
+
status: d.status,
|
|
249
|
+
publishDate: d.publishedAt ?? null,
|
|
250
|
+
scheduledDate: d.scheduledAt ?? null,
|
|
251
|
+
lastModified: pageDisplayDate(d),
|
|
252
|
+
seoScore: extractSeoScore(data),
|
|
253
|
+
seoTitle: asString(data.metaTitle) ?? asString(data.seoTitle),
|
|
254
|
+
seoDescription: asString(data.metaDescription) ?? asString(data.seoDescription),
|
|
255
|
+
templateId: asString(data.templateId),
|
|
256
|
+
isHomepage: path === '/' || data.isHomepage === true,
|
|
257
|
+
isSystemPage: data.isSystemPage === true,
|
|
258
|
+
createdAt: d.createdAt,
|
|
259
|
+
updatedAt: d.updatedAt,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function comparePagesFor(by: NonNullable<PagesQuery['sortBy']>, a: Page, b: Page): number {
|
|
264
|
+
switch (by) {
|
|
265
|
+
case 'author':
|
|
266
|
+
return a.authorName.localeCompare(b.authorName)
|
|
267
|
+
case 'status':
|
|
268
|
+
return a.status.localeCompare(b.status)
|
|
269
|
+
case 'seo': {
|
|
270
|
+
const av = a.seoScore ?? -1
|
|
271
|
+
const bv = b.seoScore ?? -1
|
|
272
|
+
return av - bv
|
|
273
|
+
}
|
|
274
|
+
case 'modified':
|
|
275
|
+
return new Date(a.lastModified).getTime() - new Date(b.lastModified).getTime()
|
|
276
|
+
case 'title':
|
|
277
|
+
default:
|
|
278
|
+
return a.title.localeCompare(b.title)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Pure filter+sort over an in-memory page set. Exported for unit tests
|
|
284
|
+
* (keeps filtering logic decoupled from the network).
|
|
285
|
+
*/
|
|
286
|
+
export function applyPagesQuery(all: Page[], query: PagesQuery): Page[] {
|
|
287
|
+
const search = (query.search ?? '').trim().toLowerCase()
|
|
288
|
+
const tagFilter = query.tagIds && query.tagIds.length > 0 ? new Set(query.tagIds) : null
|
|
289
|
+
|
|
290
|
+
const filtered = all.filter((p) => {
|
|
291
|
+
if (query.status && query.status !== 'all' && p.status !== query.status) return false
|
|
292
|
+
if (query.authorId && query.authorId !== 'all' && p.authorId !== query.authorId) return false
|
|
293
|
+
// Tag chips use OR semantics: keep a page if it has any selected tag.
|
|
294
|
+
if (tagFilter && !p.tags.some((t) => tagFilter.has(t))) return false
|
|
295
|
+
if (search) {
|
|
296
|
+
const haystack = [
|
|
297
|
+
p.title,
|
|
298
|
+
p.path,
|
|
299
|
+
p.slug,
|
|
300
|
+
p.authorName,
|
|
301
|
+
p.status,
|
|
302
|
+
p.seoTitle ?? '',
|
|
303
|
+
p.seoDescription ?? '',
|
|
304
|
+
p.tags.join(' '),
|
|
305
|
+
]
|
|
306
|
+
.join(' ')
|
|
307
|
+
.toLowerCase()
|
|
308
|
+
if (!haystack.includes(search)) return false
|
|
309
|
+
}
|
|
310
|
+
return true
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const sortBy = query.sortBy ?? 'title'
|
|
314
|
+
const dir = query.sortDirection ?? 'asc'
|
|
315
|
+
filtered.sort((a, b) => {
|
|
316
|
+
const cmp = comparePagesFor(sortBy, a, b)
|
|
317
|
+
return dir === 'asc' ? cmp : -cmp
|
|
318
|
+
})
|
|
319
|
+
return filtered
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface PageGroup {
|
|
323
|
+
key: string
|
|
324
|
+
label: string
|
|
325
|
+
color: string | null
|
|
326
|
+
pages: Page[]
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Group an already-filtered/sorted page list. A page appears in exactly
|
|
331
|
+
* ONE group (no duplicates): for `tag`, its primary tag (or first tag,
|
|
332
|
+
* or "Untagged"). Returns a single "All pages" group for `none`.
|
|
333
|
+
*/
|
|
334
|
+
export function groupPages(pages: Page[], groupBy: PageGroupBy, tags: PageTag[]): PageGroup[] {
|
|
335
|
+
if (groupBy === 'none') {
|
|
336
|
+
return [{ key: 'all', label: 'All pages', color: null, pages }]
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const tagBySlug = new Map(tags.map((t) => [t.slug, t]))
|
|
340
|
+
const buckets = new Map<string, PageGroup>()
|
|
341
|
+
const order: string[] = []
|
|
342
|
+
|
|
343
|
+
const ensure = (key: string, label: string, color: string | null) => {
|
|
344
|
+
if (!buckets.has(key)) {
|
|
345
|
+
buckets.set(key, { key, label, color, pages: [] })
|
|
346
|
+
order.push(key)
|
|
347
|
+
}
|
|
348
|
+
return buckets.get(key)!
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const p of pages) {
|
|
352
|
+
if (groupBy === 'tag') {
|
|
353
|
+
const slug = p.primaryTagId ?? p.tags[0] ?? null
|
|
354
|
+
if (!slug) {
|
|
355
|
+
ensure('__untagged', 'Untagged', null).pages.push(p)
|
|
356
|
+
} else {
|
|
357
|
+
const tag = tagBySlug.get(slug)
|
|
358
|
+
ensure(slug, tag?.name ?? slug, tag?.color ?? null).pages.push(p)
|
|
359
|
+
}
|
|
360
|
+
} else if (groupBy === 'status') {
|
|
361
|
+
ensure(p.status, statusLabel(p.status), null).pages.push(p)
|
|
362
|
+
} else if (groupBy === 'author') {
|
|
363
|
+
ensure(p.authorId || '__unknown', p.authorName, null).pages.push(p)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return order.map((k) => buckets.get(k)!)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function statusLabel(s: PageStatus): string {
|
|
371
|
+
return s.charAt(0) + s.slice(1).toLowerCase()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function fetchPages(query: PagesQuery = {}): Promise<PagesResult> {
|
|
375
|
+
const authorsMap = await fetchAuthorsMap()
|
|
376
|
+
|
|
377
|
+
const [pagesRes, tagDocs] = await Promise.all([
|
|
378
|
+
cmsApi<ListEnvelope>(`/collections/${PAGES_COLLECTION}?pageSize=500&sort=updatedAt&order=desc`),
|
|
379
|
+
fetchTagDocs(),
|
|
380
|
+
])
|
|
381
|
+
|
|
382
|
+
const allPages = ((pagesRes.data?.docs ?? []) as DocumentApiRow[]).map((d) =>
|
|
383
|
+
docToPage(d, authorsMap),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
// Tag page counts come from the full (unfiltered) set so chips and the
|
|
387
|
+
// tag manager reflect the dataset, not the active query.
|
|
388
|
+
const counts = new Map<string, number>()
|
|
389
|
+
for (const p of allPages) {
|
|
390
|
+
for (const slug of p.tags) counts.set(slug, (counts.get(slug) ?? 0) + 1)
|
|
391
|
+
}
|
|
392
|
+
const tags = tagDocs
|
|
393
|
+
.map((d) => docToTag(d, counts.get(d.slug ?? d.id) ?? 0))
|
|
394
|
+
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
|
|
395
|
+
|
|
396
|
+
const filtered = applyPagesQuery(allPages, query)
|
|
397
|
+
|
|
398
|
+
// Pagination only applies to the flat (ungrouped) view; grouped views
|
|
399
|
+
// render every match so the buckets stay complete.
|
|
400
|
+
const grouped = query.groupBy && query.groupBy !== 'none'
|
|
401
|
+
const page = Math.max(1, query.page ?? 1)
|
|
402
|
+
const pageSize = Math.max(1, query.pageSize ?? 25)
|
|
403
|
+
const paged = grouped ? filtered : filtered.slice((page - 1) * pageSize, page * pageSize)
|
|
404
|
+
|
|
405
|
+
// Authors present in the dataset (for the Author filter dropdown).
|
|
406
|
+
const seen = new Set<string>()
|
|
407
|
+
const authors: PageAuthor[] = []
|
|
408
|
+
for (const p of allPages) {
|
|
409
|
+
if (p.authorId && !seen.has(p.authorId)) {
|
|
410
|
+
seen.add(p.authorId)
|
|
411
|
+
authors.push({ id: p.authorId, name: p.authorName, initials: p.authorInitials })
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
authors.sort((a, b) => a.name.localeCompare(b.name))
|
|
415
|
+
|
|
416
|
+
return { pages: paged, total: filtered.length, page, pageSize, tags, authors }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function fetchPageById(pageId: string): Promise<Page | null> {
|
|
420
|
+
const res = await cmsApi<DocumentApiRow>(
|
|
421
|
+
`/collections/${PAGES_COLLECTION}/${encodeURIComponent(pageId)}`,
|
|
422
|
+
)
|
|
423
|
+
if (res.error || !res.data) return null
|
|
424
|
+
const authors = await fetchAuthorsMap()
|
|
425
|
+
return docToPage(res.data, authors)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ─── Bulk page mutations ─────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
export interface BulkResult {
|
|
431
|
+
ok: number
|
|
432
|
+
failed: number
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export async function bulkUpdatePages(
|
|
436
|
+
ids: string[],
|
|
437
|
+
payload: { status?: PageStatus; data?: Record<string, unknown> },
|
|
438
|
+
): Promise<BulkResult> {
|
|
439
|
+
let ok = 0
|
|
440
|
+
let failed = 0
|
|
441
|
+
for (const id of ids) {
|
|
442
|
+
const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
|
|
443
|
+
method: 'PUT',
|
|
444
|
+
body: JSON.stringify(payload),
|
|
445
|
+
})
|
|
446
|
+
if (res.error) failed++
|
|
447
|
+
else ok++
|
|
448
|
+
}
|
|
449
|
+
return { ok, failed }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export async function bulkDeletePages(ids: string[]): Promise<BulkResult> {
|
|
453
|
+
let ok = 0
|
|
454
|
+
let failed = 0
|
|
455
|
+
for (const id of ids) {
|
|
456
|
+
const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
|
|
457
|
+
method: 'DELETE',
|
|
458
|
+
})
|
|
459
|
+
if (res.error) failed++
|
|
460
|
+
else ok++
|
|
461
|
+
}
|
|
462
|
+
return { ok, failed }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Add or remove tag slugs across many pages. We fetch each page's
|
|
467
|
+
* current tags, merge/strip, then PUT the new `data.tags`. Done
|
|
468
|
+
* per-page because tags live in the document JSON (no bulk-tag endpoint
|
|
469
|
+
* yet). `primaryTag` is cleared if it was removed.
|
|
470
|
+
*/
|
|
471
|
+
export async function bulkChangePageTags(
|
|
472
|
+
pages: Pick<Page, 'id' | 'tags' | 'primaryTagId'>[],
|
|
473
|
+
change: { add?: string[]; remove?: string[] },
|
|
474
|
+
): Promise<BulkResult> {
|
|
475
|
+
let ok = 0
|
|
476
|
+
let failed = 0
|
|
477
|
+
const add = new Set(change.add ?? [])
|
|
478
|
+
const remove = new Set(change.remove ?? [])
|
|
479
|
+
for (const p of pages) {
|
|
480
|
+
const next = new Set(p.tags)
|
|
481
|
+
for (const t of add) next.add(t)
|
|
482
|
+
for (const t of remove) next.delete(t)
|
|
483
|
+
const tags = [...next]
|
|
484
|
+
const primaryTag =
|
|
485
|
+
p.primaryTagId && next.has(p.primaryTagId) ? p.primaryTagId : (tags[0] ?? null)
|
|
486
|
+
const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(p.id)}`, {
|
|
487
|
+
method: 'PUT',
|
|
488
|
+
body: JSON.stringify({ data: { tags, primaryTag } }),
|
|
489
|
+
})
|
|
490
|
+
if (res.error) failed++
|
|
491
|
+
else ok++
|
|
492
|
+
}
|
|
493
|
+
return { ok, failed }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ─── Tag CRUD ────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
export interface PageTagInput {
|
|
499
|
+
name: string
|
|
500
|
+
slug?: string
|
|
501
|
+
color?: string
|
|
502
|
+
description?: string
|
|
503
|
+
sortOrder?: number
|
|
504
|
+
active?: boolean
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function slugify(input: string): string {
|
|
508
|
+
return input
|
|
509
|
+
.toLowerCase()
|
|
510
|
+
.trim()
|
|
511
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
512
|
+
.replace(/^-+|-+$/g, '')
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export async function createPageTag(input: PageTagInput): Promise<{ error?: string }> {
|
|
516
|
+
const slug = input.slug?.trim() || slugify(input.name)
|
|
517
|
+
if (!slug) return { error: 'Tag name is required' }
|
|
518
|
+
const res = await cmsApi(`/collections/${PAGE_TAGS_COLLECTION}`, {
|
|
519
|
+
method: 'POST',
|
|
520
|
+
body: JSON.stringify({
|
|
521
|
+
title: input.name,
|
|
522
|
+
slug,
|
|
523
|
+
status: 'PUBLISHED',
|
|
524
|
+
data: {
|
|
525
|
+
color: input.color ?? 'gray',
|
|
526
|
+
description: input.description ?? '',
|
|
527
|
+
sortOrder: input.sortOrder ?? 0,
|
|
528
|
+
active: input.active !== false,
|
|
529
|
+
},
|
|
530
|
+
}),
|
|
531
|
+
})
|
|
532
|
+
return res.error ? { error: res.error } : {}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export async function updatePageTag(
|
|
536
|
+
tagDocId: string,
|
|
537
|
+
input: Partial<PageTagInput>,
|
|
538
|
+
): Promise<{ error?: string }> {
|
|
539
|
+
const data: Record<string, unknown> = {}
|
|
540
|
+
if (input.color !== undefined) data.color = input.color
|
|
541
|
+
if (input.description !== undefined) data.description = input.description
|
|
542
|
+
if (input.sortOrder !== undefined) data.sortOrder = input.sortOrder
|
|
543
|
+
if (input.active !== undefined) data.active = input.active
|
|
544
|
+
const body: Record<string, unknown> = { data }
|
|
545
|
+
if (input.name !== undefined) body.title = input.name
|
|
546
|
+
const res = await cmsApi(`/collections/${PAGE_TAGS_COLLECTION}/${encodeURIComponent(tagDocId)}`, {
|
|
547
|
+
method: 'PUT',
|
|
548
|
+
body: JSON.stringify(body),
|
|
549
|
+
})
|
|
550
|
+
return res.error ? { error: res.error } : {}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export async function deletePageTag(tagDocId: string): Promise<{ error?: string }> {
|
|
554
|
+
const res = await cmsApi(`/collections/${PAGE_TAGS_COLLECTION}/${encodeURIComponent(tagDocId)}`, {
|
|
555
|
+
method: 'DELETE',
|
|
556
|
+
})
|
|
557
|
+
return res.error ? { error: res.error } : {}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Delete a tag and detach it from every page that uses it (so no
|
|
562
|
+
* dangling slugs remain in page `data.tags`). Used by the tag manager's
|
|
563
|
+
* "remove from all pages" deletion path.
|
|
564
|
+
*/
|
|
565
|
+
export async function deletePageTagWithDetach(tag: PageTag): Promise<{ error?: string }> {
|
|
566
|
+
if (tag.pageCount > 0) {
|
|
567
|
+
const res = await fetchPages({ tagIds: [tag.slug], pageSize: 1000 })
|
|
568
|
+
if (res.pages.length > 0) {
|
|
569
|
+
await bulkChangePageTags(
|
|
570
|
+
res.pages.map((p) => ({ id: p.id, tags: p.tags, primaryTagId: p.primaryTagId })),
|
|
571
|
+
{ remove: [tag.slug] },
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return deletePageTag(tag.docId)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Merge `source` into `target`: every page tagged with the source slug
|
|
580
|
+
* gets the target slug (primary tag re-pointed too), then the source
|
|
581
|
+
* tag document is deleted.
|
|
582
|
+
*/
|
|
583
|
+
export async function mergePageTags(source: PageTag, target: PageTag): Promise<{ error?: string }> {
|
|
584
|
+
if (source.slug === target.slug) return { error: 'Cannot merge a tag into itself' }
|
|
585
|
+
const res = await fetchPages({ tagIds: [source.slug], pageSize: 1000 })
|
|
586
|
+
for (const p of res.pages) {
|
|
587
|
+
const next = new Set(p.tags)
|
|
588
|
+
next.delete(source.slug)
|
|
589
|
+
next.add(target.slug)
|
|
590
|
+
const tags = [...next]
|
|
591
|
+
const primaryTag = p.primaryTagId === source.slug ? target.slug : p.primaryTagId
|
|
592
|
+
const put = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(p.id)}`, {
|
|
593
|
+
method: 'PUT',
|
|
594
|
+
body: JSON.stringify({ data: { tags, primaryTag } }),
|
|
595
|
+
})
|
|
596
|
+
if (put.error) return { error: put.error }
|
|
597
|
+
}
|
|
598
|
+
return deletePageTag(source.docId)
|
|
599
|
+
}
|