@actuate-media/cms-admin 0.17.0 → 0.19.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/__tests__/lib/page-editor-service-api.test.d.ts +2 -0
- package/dist/__tests__/lib/page-editor-service-api.test.d.ts.map +1 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js +155 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -0
- package/dist/__tests__/lib/pages-service-fetch.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-service-fetch.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js +125 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -0
- package/dist/__tests__/lib/post-editor-service.test.js +75 -23
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/posts-service.test.js +132 -142
- package/dist/__tests__/lib/posts-service.test.js.map +1 -1
- package/dist/__tests__/views/page-section-editor.test.d.ts +2 -0
- package/dist/__tests__/views/page-section-editor.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-section-editor.test.js +56 -0
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.d.ts +2 -0
- package/dist/__tests__/views/pages-list-view.test.d.ts.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +102 -0
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -0
- package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
- package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
- package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +1 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +55 -0
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/pages-service.d.ts +26 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +65 -38
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +6 -4
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +32 -37
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +91 -160
- package/dist/lib/posts-service.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +71 -46
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Posts/NewPostPicker.d.ts +5 -4
- package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
- package/dist/views/Posts/NewPostPicker.js +5 -4
- package/dist/views/Posts/NewPostPicker.js.map +1 -1
- package/dist/views/Posts/PostTypesView.d.ts +5 -4
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +8 -0
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/page-editor/PagePreview.d.ts.map +1 -1
- package/dist/views/page-editor/PagePreview.js +10 -1
- package/dist/views/page-editor/PagePreview.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +22 -2
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +7 -1
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +10 -4
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
- package/dist/views/post-editor/PostHeader.js +4 -3
- package/dist/views/post-editor/PostHeader.js.map +1 -1
- package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
- package/dist/views/post-editor/PostPreview.js +11 -1
- package/dist/views/post-editor/PostPreview.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +4 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/lib/page-editor-service-api.test.ts +180 -0
- package/src/__tests__/lib/pages-service-fetch.test.ts +160 -0
- package/src/__tests__/lib/post-editor-service.test.ts +78 -23
- package/src/__tests__/lib/posts-service.test.ts +134 -163
- package/src/__tests__/views/page-section-editor.test.tsx +65 -0
- package/src/__tests__/views/pages-list-view.test.tsx +119 -0
- package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
- package/src/index.ts +0 -2
- package/src/lib/page-editor-service.ts +61 -0
- package/src/lib/pages-service.ts +88 -35
- package/src/lib/post-editor-service.ts +41 -40
- package/src/lib/posts-service.ts +109 -180
- package/src/views/Pages/PagesListView.tsx +113 -82
- package/src/views/Posts/NewPostPicker.tsx +5 -4
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/Posts/PostsListView.tsx +9 -0
- package/src/views/page-editor/PagePreview.tsx +10 -1
- package/src/views/page-editor/PageSectionEditor.tsx +25 -1
- package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
- package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
- package/src/views/post-editor/PostFieldsModal.tsx +36 -21
- package/src/views/post-editor/PostHeader.tsx +4 -4
- package/src/views/post-editor/PostPreview.tsx +12 -1
- package/src/views/post-editor/PostSectionEditor.tsx +22 -5
- package/dist/views/PostEditor.d.ts +0 -6
- package/dist/views/PostEditor.d.ts.map +0 -1
- package/dist/views/PostEditor.js +0 -79
- package/dist/views/PostEditor.js.map +0 -1
- package/dist/views/Posts.d.ts +0 -5
- package/dist/views/Posts.d.ts.map +0 -1
- package/dist/views/Posts.js +0 -73
- package/dist/views/Posts.js.map +0 -1
- package/src/views/PostEditor.tsx +0 -257
- package/src/views/Posts.tsx +0 -375
|
@@ -1,25 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Posts service — thin client over the
|
|
3
|
-
*
|
|
2
|
+
* Posts service — thin client over the CMS REST endpoints, shaped around the
|
|
3
|
+
* data the Posts admin area needs.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* `fetchPosts` calls the server-side aggregate endpoint `GET /posts`, which
|
|
6
|
+
* does the cross-collection filter / sort / paginate at the DB level (one
|
|
7
|
+
* query + one count) and returns the post rows alongside dataset-wide type
|
|
8
|
+
* counts and the contributor list. This keeps the Posts list O(page) instead
|
|
9
|
+
* of pulling every document per type into the browser, and lets it scale past
|
|
10
|
+
* the old ~500-row-per-type client cap.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* across collections must respect per-collection `read` rules.
|
|
15
|
-
* The existing endpoint enforces this per-call; mixing them
|
|
16
|
-
* server-side would need a new auth boundary.
|
|
17
|
-
* - V1 traffic is small (the seed is 15 rows); a few extra
|
|
18
|
-
* parallel requests are well under the rate-limit budget.
|
|
19
|
-
*
|
|
20
|
-
* Promote to a single endpoint when:
|
|
21
|
-
* - Any post-type collection grows past ~5k rows, OR
|
|
22
|
-
* - Page-load latency exceeds 300ms in the admin metrics.
|
|
12
|
+
* Access control still lives server-side: the endpoint only returns rows from
|
|
13
|
+
* collections the caller's scope can read.
|
|
23
14
|
*/
|
|
24
15
|
import { cmsApi } from './api.js';
|
|
25
16
|
function isPostsCollection(c) {
|
|
@@ -54,41 +45,17 @@ function collectionToPostType(c, count = 0) {
|
|
|
54
45
|
active: true,
|
|
55
46
|
};
|
|
56
47
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
async function fetchAuthorsMap() {
|
|
67
|
-
const res = await cmsApi('/users?pageSize=200');
|
|
68
|
-
const list = Array.isArray(res.data)
|
|
69
|
-
? res.data
|
|
70
|
-
: (res.data?.users ?? []);
|
|
71
|
-
const map = new Map();
|
|
72
|
-
for (const u of list) {
|
|
73
|
-
map.set(u.id, {
|
|
74
|
-
id: u.id,
|
|
75
|
-
name: u.name?.trim() || u.email,
|
|
76
|
-
initials: initialsFromName(u.name, u.email),
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
return map;
|
|
80
|
-
}
|
|
81
|
-
function extractSeoScore(data) {
|
|
82
|
-
if (!data)
|
|
83
|
-
return null;
|
|
84
|
-
const candidates = ['seoScore', 'seo_score', 'metaScore'];
|
|
85
|
-
for (const k of candidates) {
|
|
86
|
-
const v = data[k];
|
|
87
|
-
if (typeof v === 'number' && Number.isFinite(v))
|
|
88
|
-
return v;
|
|
89
|
-
}
|
|
90
|
-
return null;
|
|
48
|
+
// ─── Authors ─────────────────────────────────────────────────────────
|
|
49
|
+
function initialsFromName(name) {
|
|
50
|
+
const trimmed = name?.trim();
|
|
51
|
+
if (!trimmed)
|
|
52
|
+
return '?';
|
|
53
|
+
const parts = trimmed.split(/\s+/);
|
|
54
|
+
const first = parts[0]?.[0] ?? '';
|
|
55
|
+
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : '';
|
|
56
|
+
return (first + last).toUpperCase() || '?';
|
|
91
57
|
}
|
|
58
|
+
/** Display date matches the old client behaviour: scheduled/published date when set, else updated. */
|
|
92
59
|
function pickDisplayDate(d) {
|
|
93
60
|
if (d.status === 'SCHEDULED' && d.scheduledAt)
|
|
94
61
|
return d.scheduledAt;
|
|
@@ -96,122 +63,86 @@ function pickDisplayDate(d) {
|
|
|
96
63
|
return d.publishedAt;
|
|
97
64
|
return d.updatedAt;
|
|
98
65
|
}
|
|
66
|
+
function buildPostsQueryString(query) {
|
|
67
|
+
const params = new URLSearchParams();
|
|
68
|
+
if (query.search?.trim())
|
|
69
|
+
params.set('search', query.search.trim());
|
|
70
|
+
if (query.postType && query.postType !== 'all')
|
|
71
|
+
params.set('postType', query.postType);
|
|
72
|
+
if (query.status && query.status !== 'all')
|
|
73
|
+
params.set('status', query.status);
|
|
74
|
+
if (query.author && query.author !== 'all')
|
|
75
|
+
params.set('author', query.author);
|
|
76
|
+
if (query.sortBy)
|
|
77
|
+
params.set('sortBy', query.sortBy);
|
|
78
|
+
if (query.sortDirection)
|
|
79
|
+
params.set('sortDirection', query.sortDirection);
|
|
80
|
+
params.set('page', String(Math.max(1, query.page ?? 1)));
|
|
81
|
+
params.set('pageSize', String(Math.max(1, query.pageSize ?? 10)));
|
|
82
|
+
const qs = params.toString();
|
|
83
|
+
return qs ? `?${qs}` : '';
|
|
84
|
+
}
|
|
99
85
|
export async function fetchPosts(query = {}) {
|
|
100
|
-
const cols = await fetchPostCollections();
|
|
101
|
-
const authorsMap = await fetchAuthorsMap();
|
|
102
|
-
// Pull every doc for each post type (cap at 500 to avoid runaway
|
|
103
|
-
// requests; pagination + filtering still happens client-side over
|
|
104
|
-
// this set). When a type grows past 500 docs we'll need the
|
|
105
|
-
// server-side aggregate endpoint described in this file's header.
|
|
106
|
-
const perTypeRaw = await Promise.all(cols.map(async (c) => {
|
|
107
|
-
const res = await cmsApi(`/collections/${encodeURIComponent(c.slug)}?pageSize=500&sort=updatedAt&order=desc`);
|
|
108
|
-
const docs = (res.data?.docs ?? []);
|
|
109
|
-
return { col: c, docs };
|
|
110
|
-
}));
|
|
111
|
-
// Map docs → Post objects.
|
|
112
|
-
const allPosts = [];
|
|
113
|
-
for (const { col, docs } of perTypeRaw) {
|
|
114
|
-
for (const d of docs) {
|
|
115
|
-
const author = (d.createdById && authorsMap.get(d.createdById)) ||
|
|
116
|
-
(d.updatedById && authorsMap.get(d.updatedById)) || {
|
|
117
|
-
id: '',
|
|
118
|
-
name: 'Unknown',
|
|
119
|
-
initials: '??',
|
|
120
|
-
};
|
|
121
|
-
allPosts.push({
|
|
122
|
-
id: d.id,
|
|
123
|
-
title: d.title ?? d.data?.title ?? '(untitled)',
|
|
124
|
-
slug: d.slug ?? d.data?.slug ?? '',
|
|
125
|
-
postTypeId: col.slug,
|
|
126
|
-
postTypeName: col.labels.singular,
|
|
127
|
-
postTypeAccentColor: col.color ?? null,
|
|
128
|
-
authorId: author.id,
|
|
129
|
-
authorName: author.name,
|
|
130
|
-
authorInitials: author.initials,
|
|
131
|
-
status: d.status,
|
|
132
|
-
publishDate: d.publishedAt ?? d.scheduledAt ?? null,
|
|
133
|
-
updatedDate: pickDisplayDate(d),
|
|
134
|
-
seoScore: extractSeoScore(d.data),
|
|
135
|
-
createdAt: d.createdAt,
|
|
136
|
-
updatedAt: d.updatedAt,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
// Compute per-type counts BEFORE filtering — the tabs at the top
|
|
141
|
-
// of the page reflect the dataset, not the active query.
|
|
142
|
-
const countsBySlug = new Map();
|
|
143
|
-
for (const p of allPosts) {
|
|
144
|
-
countsBySlug.set(p.postTypeId, (countsBySlug.get(p.postTypeId) ?? 0) + 1);
|
|
145
|
-
}
|
|
146
|
-
const types = cols.map((c) => collectionToPostType(c, countsBySlug.get(c.slug) ?? 0));
|
|
147
|
-
// Apply filters / search.
|
|
148
|
-
const search = (query.search ?? '').trim().toLowerCase();
|
|
149
|
-
const filtered = allPosts.filter((p) => {
|
|
150
|
-
if (query.postType && query.postType !== 'all' && p.postTypeId !== query.postType)
|
|
151
|
-
return false;
|
|
152
|
-
if (query.status && query.status !== 'all' && p.status !== query.status)
|
|
153
|
-
return false;
|
|
154
|
-
if (query.author && query.author !== 'all' && p.authorId !== query.author)
|
|
155
|
-
return false;
|
|
156
|
-
if (search) {
|
|
157
|
-
const haystack = `${p.title} ${p.slug} ${p.authorName} ${p.postTypeName}`.toLowerCase();
|
|
158
|
-
if (!haystack.includes(search))
|
|
159
|
-
return false;
|
|
160
|
-
}
|
|
161
|
-
return true;
|
|
162
|
-
});
|
|
163
|
-
// Sort.
|
|
164
|
-
const sortBy = query.sortBy ?? 'date';
|
|
165
|
-
const dir = query.sortDirection ?? 'desc';
|
|
166
|
-
filtered.sort((a, b) => {
|
|
167
|
-
const cmp = comparePostsFor(sortBy, a, b);
|
|
168
|
-
return dir === 'asc' ? cmp : -cmp;
|
|
169
|
-
});
|
|
170
|
-
// Paginate.
|
|
171
86
|
const page = Math.max(1, query.page ?? 1);
|
|
172
87
|
const pageSize = Math.max(1, query.pageSize ?? 10);
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// contributor, not just the currently visible page).
|
|
177
|
-
const authorIds = new Set();
|
|
178
|
-
const authors = [];
|
|
179
|
-
for (const p of allPosts) {
|
|
180
|
-
if (p.authorId && !authorIds.has(p.authorId)) {
|
|
181
|
-
authorIds.add(p.authorId);
|
|
182
|
-
authors.push({ id: p.authorId, name: p.authorName, initials: p.authorInitials });
|
|
183
|
-
}
|
|
88
|
+
const res = await cmsApi(`/posts${buildPostsQueryString(query)}`);
|
|
89
|
+
if (res.error || !res.data) {
|
|
90
|
+
throw new Error(res.error ?? 'Failed to load posts');
|
|
184
91
|
}
|
|
185
|
-
|
|
92
|
+
const r = res.data;
|
|
93
|
+
const typeBySlug = new Map();
|
|
94
|
+
for (const t of r.types)
|
|
95
|
+
typeBySlug.set(t.slug, t);
|
|
96
|
+
const types = r.types.map((t) => ({
|
|
97
|
+
id: t.slug,
|
|
98
|
+
slug: t.slug,
|
|
99
|
+
name: t.plural,
|
|
100
|
+
singularName: t.singular,
|
|
101
|
+
description: t.description ?? null,
|
|
102
|
+
urlBase: `/${t.urlPrefix || t.slug}`,
|
|
103
|
+
accentColor: t.color ?? null,
|
|
104
|
+
icon: t.icon ?? null,
|
|
105
|
+
postCount: t.count,
|
|
106
|
+
fieldCount: t.fieldCount,
|
|
107
|
+
active: true,
|
|
108
|
+
}));
|
|
109
|
+
const posts = r.posts.map((d) => {
|
|
110
|
+
const type = typeBySlug.get(d.collection);
|
|
111
|
+
const authorName = d.author?.name?.trim() || 'Unknown';
|
|
112
|
+
return {
|
|
113
|
+
id: d.id,
|
|
114
|
+
title: d.title ?? '(untitled)',
|
|
115
|
+
slug: d.slug ?? '',
|
|
116
|
+
postTypeId: d.collection,
|
|
117
|
+
postTypeName: type?.singular ?? d.collection,
|
|
118
|
+
postTypeAccentColor: type?.color ?? null,
|
|
119
|
+
authorId: d.author?.id ?? '',
|
|
120
|
+
authorName,
|
|
121
|
+
authorInitials: d.author ? initialsFromName(d.author.name) : '??',
|
|
122
|
+
status: d.status,
|
|
123
|
+
publishDate: d.publishedAt ?? d.scheduledAt ?? null,
|
|
124
|
+
updatedDate: pickDisplayDate(d),
|
|
125
|
+
seoScore: d.seoScore,
|
|
126
|
+
createdAt: d.createdAt,
|
|
127
|
+
updatedAt: d.updatedAt,
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
const authors = r.authors
|
|
131
|
+
.map((a) => ({
|
|
132
|
+
id: a.id,
|
|
133
|
+
name: a.name?.trim() || 'Unknown',
|
|
134
|
+
initials: initialsFromName(a.name),
|
|
135
|
+
}))
|
|
136
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
186
137
|
return {
|
|
187
|
-
posts
|
|
188
|
-
total:
|
|
189
|
-
page,
|
|
190
|
-
pageSize,
|
|
138
|
+
posts,
|
|
139
|
+
total: r.total,
|
|
140
|
+
page: r.page ?? page,
|
|
141
|
+
pageSize: r.pageSize ?? pageSize,
|
|
191
142
|
types,
|
|
192
143
|
authors,
|
|
193
144
|
};
|
|
194
145
|
}
|
|
195
|
-
function comparePostsFor(by, a, b) {
|
|
196
|
-
switch (by) {
|
|
197
|
-
case 'title':
|
|
198
|
-
return a.title.localeCompare(b.title);
|
|
199
|
-
case 'type':
|
|
200
|
-
return a.postTypeName.localeCompare(b.postTypeName);
|
|
201
|
-
case 'author':
|
|
202
|
-
return a.authorName.localeCompare(b.authorName);
|
|
203
|
-
case 'status':
|
|
204
|
-
return a.status.localeCompare(b.status);
|
|
205
|
-
case 'seo': {
|
|
206
|
-
const av = a.seoScore ?? -1;
|
|
207
|
-
const bv = b.seoScore ?? -1;
|
|
208
|
-
return av - bv;
|
|
209
|
-
}
|
|
210
|
-
case 'date':
|
|
211
|
-
default:
|
|
212
|
-
return new Date(a.updatedDate).getTime() - new Date(b.updatedDate).getTime();
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
146
|
// ─── Bulk mutations ──────────────────────────────────────────────────
|
|
216
147
|
export async function bulkUpdatePosts(ids, payload) {
|
|
217
148
|
let ok = 0;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"posts-service.js","sourceRoot":"","sources":["../../src/lib/posts-service.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"posts-service.js","sourceRoot":"","sources":["../../src/lib/posts-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PagesListView.d.ts","sourceRoot":"","sources":["../../../src/views/Pages/PagesListView.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"PagesListView.d.ts","sourceRoot":"","sources":["../../../src/views/Pages/PagesListView.tsx"],"names":[],"mappings":"AAkCA;;;;;;;;;;GAUG;AAEH,UAAU,kBAAkB;IAC1B,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CACnC;AAOD,wBAAgB,aAAa,CAAC,EAAE,UAAU,EAAE,EAAE,kBAAkB,2CA2e/D;AAkaD,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,KAAK,CAAC,MAAM,GAAG,GAAG,CAAC,CAU/E"}
|
|
@@ -3,7 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Plus, Search, Tag as TagIcon, Trash2, X, } from 'lucide-react';
|
|
5
5
|
import { toast } from 'sonner';
|
|
6
|
-
import
|
|
6
|
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
|
7
|
+
import { applyPagesQuery, bulkChangePageTags, bulkDeletePages, bulkUpdatePages, fetchPagesDataset, groupPages, } from '../../lib/pages-service.js';
|
|
7
8
|
import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js';
|
|
8
9
|
import { PageTagChip, PageTagsCell } from '../../components/pages/tag-badges.js';
|
|
9
10
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js';
|
|
@@ -18,7 +19,7 @@ export function PagesListView({ onNavigate }) {
|
|
|
18
19
|
const [dir, setDir] = useState('asc');
|
|
19
20
|
const [page, setPage] = useState(1);
|
|
20
21
|
const [selected, setSelected] = useState(new Set());
|
|
21
|
-
const [
|
|
22
|
+
const [dataset, setDataset] = useState(null);
|
|
22
23
|
const [loading, setLoading] = useState(true);
|
|
23
24
|
const [error, setError] = useState(null);
|
|
24
25
|
const [refreshToken, setRefreshToken] = useState(0);
|
|
@@ -37,24 +38,18 @@ export function PagesListView({ onNavigate }) {
|
|
|
37
38
|
};
|
|
38
39
|
}, [search]);
|
|
39
40
|
const tagIds = useMemo(() => [...selectedTags], [selectedTags]);
|
|
41
|
+
// Single network read. Search / sort / group / paging are applied in
|
|
42
|
+
// memory below, so changing any of those never re-fetches. The dataset
|
|
43
|
+
// refreshes only on mount and after a mutation (refreshToken bump).
|
|
40
44
|
useEffect(() => {
|
|
41
45
|
let cancelled = false;
|
|
42
46
|
setLoading(true);
|
|
43
47
|
setError(null);
|
|
44
|
-
|
|
45
|
-
status,
|
|
46
|
-
tagIds,
|
|
47
|
-
search: debouncedSearch,
|
|
48
|
-
groupBy,
|
|
49
|
-
sortBy,
|
|
50
|
-
sortDirection: dir,
|
|
51
|
-
page,
|
|
52
|
-
pageSize: PAGE_SIZE,
|
|
53
|
-
})
|
|
48
|
+
fetchPagesDataset()
|
|
54
49
|
.then((res) => {
|
|
55
50
|
if (cancelled)
|
|
56
51
|
return;
|
|
57
|
-
|
|
52
|
+
setDataset(res);
|
|
58
53
|
setLoading(false);
|
|
59
54
|
})
|
|
60
55
|
.catch((err) => {
|
|
@@ -66,18 +61,28 @@ export function PagesListView({ onNavigate }) {
|
|
|
66
61
|
return () => {
|
|
67
62
|
cancelled = true;
|
|
68
63
|
};
|
|
69
|
-
}, [
|
|
70
|
-
// Reset paging/selection when the
|
|
64
|
+
}, [refreshToken]);
|
|
65
|
+
// Reset paging/selection when the active filters change shape.
|
|
71
66
|
useEffect(() => {
|
|
72
67
|
setPage(1);
|
|
73
68
|
setSelected(new Set());
|
|
74
69
|
}, [debouncedSearch, status, tagIds, groupBy]);
|
|
75
|
-
const
|
|
76
|
-
const tags =
|
|
77
|
-
const
|
|
78
|
-
const totalAll = useMemo(() => tags.reduce((sum, t) => sum + t.pageCount, 0), [tags]);
|
|
79
|
-
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
70
|
+
const allPages = useMemo(() => dataset?.pages ?? [], [dataset]);
|
|
71
|
+
const tags = useMemo(() => dataset?.tags ?? [], [dataset]);
|
|
72
|
+
const grandTotal = allPages.length;
|
|
80
73
|
const grouped = groupBy !== 'none';
|
|
74
|
+
const filtered = useMemo(() => applyPagesQuery(allPages, {
|
|
75
|
+
status,
|
|
76
|
+
tagIds,
|
|
77
|
+
search: debouncedSearch,
|
|
78
|
+
sortBy,
|
|
79
|
+
sortDirection: dir,
|
|
80
|
+
}), [allPages, status, tagIds, debouncedSearch, sortBy, dir]);
|
|
81
|
+
const total = filtered.length;
|
|
82
|
+
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
83
|
+
// Pagination only applies to the flat view; grouped views render every
|
|
84
|
+
// match so the buckets stay complete.
|
|
85
|
+
const pages = useMemo(() => (grouped ? filtered : filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)), [filtered, grouped, page]);
|
|
81
86
|
const tagBySlug = useMemo(() => {
|
|
82
87
|
const m = new Map();
|
|
83
88
|
for (const t of tags)
|
|
@@ -94,6 +99,9 @@ export function PagesListView({ onNavigate }) {
|
|
|
94
99
|
setDir(field === 'modified' || field === 'seo' ? 'desc' : 'asc');
|
|
95
100
|
}
|
|
96
101
|
}, [sortBy]);
|
|
102
|
+
// `aria-sort` belongs on the column header cell (`<th>`), not the button
|
|
103
|
+
// inside it — assistive tech reads sort state from the header.
|
|
104
|
+
const ariaSortFor = (field) => sortBy === field ? (dir === 'asc' ? 'ascending' : 'descending') : 'none';
|
|
97
105
|
const filtersAreActive = debouncedSearch !== '' || status !== 'all' || selectedTags.size > 0;
|
|
98
106
|
const clearFilters = () => {
|
|
99
107
|
setSearch('');
|
|
@@ -137,48 +145,81 @@ export function PagesListView({ onNavigate }) {
|
|
|
137
145
|
setSelected(new Set());
|
|
138
146
|
setRefreshToken((t) => t + 1);
|
|
139
147
|
};
|
|
148
|
+
// Bulk service calls always resolve a { ok, failed } tally (they never
|
|
149
|
+
// reject), so we promote a *total* failure to a rejection — that's what
|
|
150
|
+
// makes `toast.promise`'s `error` branch reachable. The `await` is
|
|
151
|
+
// wrapped so a rejection can't escape as an unhandled promise, and
|
|
152
|
+
// `afterBulk` always runs to refresh the (possibly partially-changed) set.
|
|
153
|
+
const allFailed = (r) => r.ok === 0 && r.failed > 0;
|
|
140
154
|
const runBulkStatus = async (target) => {
|
|
141
155
|
const ids = selectedPages.map((p) => p.id);
|
|
142
156
|
if (ids.length === 0)
|
|
143
157
|
return;
|
|
144
158
|
const verb = target === 'PUBLISHED' ? 'Publishing' : 'Updating';
|
|
145
|
-
const promise = bulkUpdatePages(ids, { status: target })
|
|
159
|
+
const promise = bulkUpdatePages(ids, { status: target }).then((r) => {
|
|
160
|
+
if (allFailed(r))
|
|
161
|
+
throw new Error('Bulk update failed');
|
|
162
|
+
return r;
|
|
163
|
+
});
|
|
146
164
|
toast.promise(promise, {
|
|
147
165
|
loading: `${verb} ${ids.length} page${ids.length === 1 ? '' : 's'}…`,
|
|
148
166
|
success: (r) => `${r.ok} updated${r.failed ? `, ${r.failed} failed` : ''}`,
|
|
149
167
|
error: 'Bulk update failed',
|
|
150
168
|
});
|
|
151
|
-
|
|
169
|
+
try {
|
|
170
|
+
await promise;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
/* surfaced via toast.promise */
|
|
174
|
+
}
|
|
152
175
|
afterBulk();
|
|
153
176
|
};
|
|
154
177
|
const runBulkTag = async (slug, mode) => {
|
|
155
178
|
if (selectedPages.length === 0)
|
|
156
179
|
return;
|
|
157
|
-
const promise = bulkChangePageTags(selectedPages.map((p) => ({ id: p.id, tags: p.tags, primaryTagId: p.primaryTagId })), mode === 'add' ? { add: [slug] } : { remove: [slug] })
|
|
180
|
+
const promise = bulkChangePageTags(selectedPages.map((p) => ({ id: p.id, tags: p.tags, primaryTagId: p.primaryTagId })), mode === 'add' ? { add: [slug] } : { remove: [slug] }).then((r) => {
|
|
181
|
+
if (allFailed(r))
|
|
182
|
+
throw new Error('Bulk tag change failed');
|
|
183
|
+
return r;
|
|
184
|
+
});
|
|
158
185
|
const tagName = tagBySlug.get(slug)?.name ?? slug;
|
|
159
186
|
toast.promise(promise, {
|
|
160
187
|
loading: `${mode === 'add' ? 'Adding' : 'Removing'} "${tagName}"…`,
|
|
161
188
|
success: (r) => `${r.ok} page${r.ok === 1 ? '' : 's'} updated${r.failed ? `, ${r.failed} failed` : ''}`,
|
|
162
189
|
error: 'Bulk tag change failed',
|
|
163
190
|
});
|
|
164
|
-
|
|
191
|
+
try {
|
|
192
|
+
await promise;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
/* surfaced via toast.promise */
|
|
196
|
+
}
|
|
165
197
|
afterBulk();
|
|
166
198
|
};
|
|
167
199
|
const runBulkDelete = async () => {
|
|
168
200
|
const ids = deletablePages.map((p) => p.id);
|
|
169
201
|
if (ids.length === 0)
|
|
170
202
|
return;
|
|
171
|
-
const promise = bulkDeletePages(ids)
|
|
203
|
+
const promise = bulkDeletePages(ids).then((r) => {
|
|
204
|
+
if (allFailed(r))
|
|
205
|
+
throw new Error('Bulk delete failed');
|
|
206
|
+
return r;
|
|
207
|
+
});
|
|
172
208
|
toast.promise(promise, {
|
|
173
209
|
loading: `Deleting ${ids.length} page${ids.length === 1 ? '' : 's'}…`,
|
|
174
210
|
success: (r) => `${r.ok} deleted${r.failed ? `, ${r.failed} failed` : ''}`,
|
|
175
211
|
error: 'Bulk delete failed',
|
|
176
212
|
});
|
|
177
|
-
|
|
213
|
+
try {
|
|
214
|
+
await promise;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
/* surfaced via toast.promise */
|
|
218
|
+
}
|
|
178
219
|
afterBulk();
|
|
179
220
|
};
|
|
180
221
|
// ─── Render ────────────────────────────────────────────────────────
|
|
181
|
-
return (_jsxs("div", { className: "flex h-full flex-col gap-4 p-4 sm:p-6", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("h1", { className: "text-foreground text-xl font-medium tracking-tight sm:text-2xl", children: "Pages" }), _jsxs("p", { className: "text-muted-foreground text-sm", children: [
|
|
222
|
+
return (_jsxs("div", { className: "flex h-full flex-col gap-4 p-4 sm:p-6", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("h1", { className: "text-foreground text-xl font-medium tracking-tight sm:text-2xl", children: "Pages" }), _jsxs("p", { className: "text-muted-foreground text-sm", children: [grandTotal, " page", grandTotal === 1 ? '' : 's', " \u00B7 organized by tags"] })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsxs("div", { className: "relative max-w-sm flex-1", children: [_jsx(Search, { className: "text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2", "aria-hidden": true }), _jsx("input", { type: "search", value: search, onChange: (e) => setSearch(e.target.value), "aria-label": "Search pages", placeholder: "Search pages\u2026", 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" })] }), _jsx(FilterSelect, { label: "Group by", value: groupBy, onChange: (v) => setGroupBy(v), options: [
|
|
182
223
|
{ value: 'none', label: 'None' },
|
|
183
224
|
{ value: 'tag', label: 'Tag' },
|
|
184
225
|
{ value: 'status', label: 'Status' },
|
|
@@ -191,7 +232,7 @@ export function PagesListView({ onNavigate }) {
|
|
|
191
232
|
{ value: 'PUBLISHED', label: 'Published' },
|
|
192
233
|
{ value: 'DRAFT', label: 'Draft' },
|
|
193
234
|
{ value: 'SCHEDULED', label: 'Scheduled' },
|
|
194
|
-
] }), _jsxs("span", { className: "text-muted-foreground ml-auto text-sm tabular-nums", children: [total, " of ",
|
|
235
|
+
] }), _jsxs("span", { className: "text-muted-foreground ml-auto text-sm tabular-nums", children: [total, " of ", grandTotal] })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("span", { className: "text-muted-foreground text-[10px] font-semibold tracking-wider uppercase", children: "Tags" }), tags.map((t) => (_jsx(PageTagChip, { name: t.name, color: t.color, count: t.pageCount, selected: selectedTags.has(t.slug), onToggle: () => toggleTag(t.slug) }, t.slug))), selectedTags.size > 0 && (_jsxs("button", { type: "button", onClick: () => setSelectedTags(new Set()), className: "text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs", children: [_jsx(X, { className: "h-3 w-3", "aria-hidden": true }), "Clear tags"] })), _jsxs("button", { type: "button", onClick: () => onNavigate('/pages/tags'), className: "text-primary ml-auto inline-flex items-center gap-1 text-xs font-medium hover:underline", children: [_jsx(TagIcon, { className: "h-3 w-3", "aria-hidden": true }), "Manage tags"] })] }), selected.size > 0 && (_jsx(BulkActionBar, { count: selected.size, tags: tags, blockedHomepage: blockedHomepage, onClear: () => setSelected(new Set()), onPublish: () => runBulkStatus('PUBLISHED'), onDraft: () => runBulkStatus('DRAFT'), onAddTag: (slug) => runBulkTag(slug, 'add'), onRemoveTag: (slug) => runBulkTag(slug, 'remove'), onDelete: () => setPendingDelete(true), deletableCount: deletablePages.length })), _jsxs("div", { className: "bg-card border-border overflow-hidden rounded-xl border shadow-sm", children: [_jsxs("table", { className: "w-full table-fixed", role: "table", "aria-label": "Pages", children: [_jsxs("colgroup", { children: [_jsx("col", { style: { width: '40px' } }), _jsx("col", {}), _jsx("col", { style: { width: '200px' } }), _jsx("col", { style: { width: '170px' } }), _jsx("col", { style: { width: '120px' } }), _jsx("col", { style: { width: '130px' } }), _jsx("col", { style: { width: '80px' } })] }), _jsx("thead", { className: "border-border bg-muted/40 border-b", children: _jsxs("tr", { className: "text-muted-foreground text-xs", children: [_jsx("th", { scope: "col", className: "px-3 py-2.5 text-left", children: _jsx("input", { type: "checkbox", "aria-label": "Select all pages on this page", checked: allOnPageSelected, onChange: (e) => toggleAllOnPage(e.target.checked), className: "border-border accent-primary rounded" }) }), _jsx("th", { scope: "col", "aria-sort": ariaSortFor('title'), className: "px-3 py-2.5 text-left", children: _jsx(SortHeader, { label: "Title", field: "title", current: sortBy, dir: dir, onSort: handleSort }) }), _jsx("th", { scope: "col", className: "px-3 py-2.5 text-left", children: _jsx("span", { className: "text-xs font-medium tracking-wider uppercase", children: "Tags" }) }), _jsx("th", { scope: "col", "aria-sort": ariaSortFor('author'), className: "px-3 py-2.5 text-left", children: _jsx(SortHeader, { label: "Author", field: "author", current: sortBy, dir: dir, onSort: handleSort }) }), _jsx("th", { scope: "col", "aria-sort": ariaSortFor('status'), className: "px-3 py-2.5 text-left", children: _jsx(SortHeader, { label: "Status", field: "status", current: sortBy, dir: dir, onSort: handleSort }) }), _jsx("th", { scope: "col", "aria-sort": ariaSortFor('modified'), className: "px-3 py-2.5 text-left", children: _jsx(SortHeader, { label: "Last Modified", field: "modified", current: sortBy, dir: dir, onSort: handleSort }) }), _jsx("th", { scope: "col", "aria-sort": ariaSortFor('seo'), className: "px-3 py-2.5 text-left", children: _jsx(SortHeader, { label: "SEO", field: "seo", current: sortBy, dir: dir, onSort: handleSort }) })] }) }), _jsxs("tbody", { children: [loading && pages.length === 0 && _jsx(LoadingRows, {}), !loading && error && (_jsx("tr", { children: _jsxs("td", { colSpan: 7, className: "px-3 py-12 text-center", children: [_jsx("div", { className: "text-foreground mb-2 text-sm font-medium", children: error }), _jsx("button", { onClick: () => setRefreshToken((t) => t + 1), className: "text-primary text-sm hover:underline", children: "Try again" })] }) })), !loading && !error && pages.length === 0 && (_jsx(EmptyRow, { filtersActive: filtersAreActive, onClear: clearFilters, onNewPage: () => onNavigate('/pages/new') })), !loading &&
|
|
195
236
|
!error &&
|
|
196
237
|
displayGroups.map((group) => (_jsx(GroupBlock, { showHeader: grouped, label: group.label, color: group.color, count: group.pages.length, children: group.pages.map((p) => (_jsx(PageRow, { page: p, selected: selected.has(p.id), resolveTag: (slug) => tagBySlug.get(slug), onSelect: () => toggleOne(p.id), onOpen: () => onNavigate(`/pages/${p.id}/edit`) }, p.id))) }, group.key)))] })] }), !grouped && (_jsx(PaginationFooter, { page: page, totalPages: totalPages, total: total, pageSize: PAGE_SIZE, onPage: setPage }))] }), _jsx(ConfirmDialog, { open: pendingDelete, title: `Delete ${deletablePages.length} page${deletablePages.length === 1 ? '' : 's'}?`, description: blockedHomepage
|
|
197
238
|
? 'The homepage cannot be deleted and will be skipped. The remaining pages move to trash and can be restored for 30 days.'
|
|
@@ -202,8 +243,7 @@ export function PagesListView({ onNavigate }) {
|
|
|
202
243
|
// ─── Sub-components ──────────────────────────────────────────────────
|
|
203
244
|
function SortHeader({ label, field, current, dir, onSort, }) {
|
|
204
245
|
const active = current === field;
|
|
205
|
-
|
|
206
|
-
return (_jsxs("button", { type: "button", onClick: () => onSort(field), "aria-sort": ariaSort, className: `hover:text-foreground inline-flex items-center gap-1 text-xs font-medium tracking-wider uppercase transition-colors ${active ? 'text-foreground' : 'text-muted-foreground'}`, children: [_jsx("span", { children: label }), active &&
|
|
246
|
+
return (_jsxs("button", { type: "button", onClick: () => onSort(field), className: `hover:text-foreground inline-flex items-center gap-1 text-xs font-medium tracking-wider uppercase transition-colors ${active ? 'text-foreground' : 'text-muted-foreground'}`, children: [_jsx("span", { children: label }), active &&
|
|
207
247
|
(dir === 'asc' ? (_jsx(ChevronUp, { className: "h-3 w-3", "aria-hidden": true })) : (_jsx(ChevronDown, { className: "h-3 w-3", "aria-hidden": true })))] }));
|
|
208
248
|
}
|
|
209
249
|
function FilterSelect({ label, value, onChange, options, }) {
|
|
@@ -227,22 +267,7 @@ function BulkActionBar({ count, tags, blockedHomepage, deletableCount, onClear,
|
|
|
227
267
|
return (_jsxs("div", { className: "border-primary/30 bg-primary/5 flex flex-wrap items-center gap-2 rounded-lg border px-4 py-2", children: [_jsxs("span", { className: "text-foreground text-sm font-medium", children: [count, " selected"] }), _jsx("div", { className: "bg-border h-4 w-px", "aria-hidden": true }), _jsx("button", { type: "button", onClick: onPublish, className: "text-foreground hover:bg-accent rounded-md px-2 py-1 text-sm transition-colors", children: "Publish" }), _jsx("button", { type: "button", onClick: onDraft, className: "text-foreground hover:bg-accent rounded-md px-2 py-1 text-sm transition-colors", children: "Move to draft" }), _jsx(TagMenu, { label: "Add tag", tags: tags, onPick: onAddTag }), _jsx(TagMenu, { label: "Remove tag", tags: tags, onPick: onRemoveTag }), _jsxs("button", { type: "button", onClick: onDelete, disabled: deletableCount === 0, title: blockedHomepage ? 'The homepage is excluded from deletion' : undefined, className: "text-destructive hover:bg-destructive/10 inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40", children: [_jsx(Trash2, { className: "h-3.5 w-3.5", "aria-hidden": true }), "Delete"] }), _jsxs("button", { type: "button", onClick: onClear, 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", children: [_jsx(X, { className: "h-3.5 w-3.5", "aria-hidden": true }), "Clear"] })] }));
|
|
228
268
|
}
|
|
229
269
|
function TagMenu({ label, tags, onPick, }) {
|
|
230
|
-
|
|
231
|
-
const ref = useRef(null);
|
|
232
|
-
useEffect(() => {
|
|
233
|
-
if (!open)
|
|
234
|
-
return;
|
|
235
|
-
const onDoc = (e) => {
|
|
236
|
-
if (ref.current && !ref.current.contains(e.target))
|
|
237
|
-
setOpen(false);
|
|
238
|
-
};
|
|
239
|
-
document.addEventListener('mousedown', onDoc);
|
|
240
|
-
return () => document.removeEventListener('mousedown', onDoc);
|
|
241
|
-
}, [open]);
|
|
242
|
-
return (_jsxs("div", { className: "relative", ref: ref, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "menu", "aria-expanded": open, className: "text-foreground hover:bg-accent inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors", children: [label, _jsx(ChevronDown, { className: "h-3 w-3", "aria-hidden": true })] }), open && (_jsxs("div", { role: "menu", className: "border-border bg-popover absolute z-20 mt-1 max-h-64 w-44 overflow-auto rounded-lg border p-1 shadow-lg", children: [tags.length === 0 && (_jsx("div", { className: "text-muted-foreground px-2 py-1.5 text-xs", children: "No tags yet" })), tags.map((t) => (_jsxs("button", { type: "button", role: "menuitem", onClick: () => {
|
|
243
|
-
onPick(t.slug);
|
|
244
|
-
setOpen(false);
|
|
245
|
-
}, className: "text-foreground hover:bg-accent flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm", children: [_jsx("span", { "aria-hidden": true, className: `h-2 w-2 rounded-full ${dotFor(t.color ?? 'gray')}` }), t.name] }, t.slug)))] }))] }));
|
|
270
|
+
return (_jsxs(DropdownMenu.Root, { children: [_jsx(DropdownMenu.Trigger, { asChild: true, children: _jsxs("button", { type: "button", className: "text-foreground hover:bg-accent inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors", children: [label, _jsx(ChevronDown, { className: "h-3 w-3", "aria-hidden": true })] }) }), _jsx(DropdownMenu.Portal, { children: _jsx(DropdownMenu.Content, { align: "start", sideOffset: 4, className: "border-border bg-popover z-50 max-h-64 w-44 overflow-auto rounded-lg border p-1 shadow-md", children: tags.length === 0 ? (_jsx("div", { className: "text-muted-foreground px-2 py-1.5 text-xs", children: "No tags yet" })) : (tags.map((t) => (_jsxs(DropdownMenu.Item, { onSelect: () => onPick(t.slug), className: "text-foreground focus:bg-accent flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none", children: [_jsx("span", { "aria-hidden": true, className: `h-2 w-2 rounded-full ${dotFor(t.color ?? 'gray')}` }), t.name] }, t.slug)))) }) })] }));
|
|
246
271
|
}
|
|
247
272
|
function PageRow({ page, selected, resolveTag, onSelect, onOpen, }) {
|
|
248
273
|
return (_jsxs("tr", { className: `border-border hover:bg-muted/30 group border-b transition-colors last:border-b-0 ${selected ? 'bg-primary/5' : ''}`, children: [_jsx("td", { className: "px-3 py-3", onClick: (e) => e.stopPropagation(), children: _jsx("input", { type: "checkbox", "aria-label": `Select "${page.title}"`, checked: selected, onChange: onSelect, className: "border-border accent-primary rounded" }) }), _jsx("td", { className: "px-3 py-3", children: _jsxs("button", { type: "button", onClick: onOpen, className: "flex w-full items-center gap-3 text-left", children: [_jsx("span", { "aria-hidden": true, className: "bg-muted block h-9 w-9 shrink-0 rounded-md" }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "text-foreground group-hover:text-primary block truncate text-sm font-medium", children: page.title }), _jsx("span", { className: "text-muted-foreground block truncate text-xs", children: page.path })] })] }) }), _jsx("td", { className: "px-3 py-3", children: _jsx(PageTagsCell, { tagSlugs: page.tags, primaryTagId: page.primaryTagId, resolve: resolveTag }) }), _jsx("td", { className: "px-3 py-3", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { "aria-hidden": true, className: "bg-muted text-muted-foreground inline-flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-semibold", children: page.authorInitials }), _jsx("span", { className: "text-foreground truncate text-sm", children: page.authorName })] }) }), _jsx("td", { className: "px-3 py-3", children: _jsx(PostStatusBadge, { status: page.status }) }), _jsx("td", { className: "text-muted-foreground px-3 py-3 text-sm tabular-nums", children: formatDateShort(page.lastModified) }), _jsx("td", { className: "px-3 py-3", children: _jsx(SeoScoreIndicator, { score: page.seoScore }) })] }));
|