@actuate-media/cms-admin 0.65.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/ui/RowActions.d.ts +19 -0
- package/dist/components/ui/RowActions.d.ts.map +1 -0
- package/dist/components/ui/RowActions.js +16 -0
- package/dist/components/ui/RowActions.js.map +1 -0
- package/dist/components/ui/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/layout/Header.js +1 -1
- package/dist/layout/Header.js.map +1 -1
- package/dist/layout/Layout.js +8 -38
- package/dist/layout/Layout.js.map +1 -1
- package/dist/lib/pages-service.d.ts +9 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +14 -0
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/posts-service.d.ts +11 -0
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +13 -0
- package/dist/lib/posts-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +3 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +9 -1
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +28 -1
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +59 -10
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +66 -9
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +15 -0
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/package.json +3 -3
- package/src/components/ui/RowActions.tsx +50 -0
- package/src/components/ui/index.ts +2 -0
- package/src/layout/Header.tsx +2 -2
- package/src/layout/Layout.tsx +8 -80
- package/src/lib/pages-service.ts +18 -0
- package/src/lib/posts-service.ts +26 -0
- package/src/lib/seo-service.ts +3 -0
- package/src/views/Dashboard.tsx +48 -18
- package/src/views/MediaBrowser.tsx +32 -1
- package/src/views/Pages/PagesListView.tsx +115 -10
- package/src/views/Posts/PostsListView.tsx +135 -10
- package/src/views/seo/TechnicalTab.tsx +15 -0
package/src/layout/Layout.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useMemo, useState, useEffect, type ReactNode } from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import { Plus } from 'lucide-react'
|
|
5
5
|
import { Sidebar } from './Sidebar.js'
|
|
6
6
|
import { Header } from './Header.js'
|
|
7
7
|
import { Breadcrumbs } from '../components/Breadcrumbs.js'
|
|
@@ -102,8 +102,9 @@ interface PageActionsContext {
|
|
|
102
102
|
* primary/secondary pair that mirrors the reference dashboard.
|
|
103
103
|
*
|
|
104
104
|
* Resolution rules:
|
|
105
|
-
* - Dashboard /
|
|
106
|
-
*
|
|
105
|
+
* - Dashboard / Posts / Pages → nothing here; those views render their
|
|
106
|
+
* hero CTAs inside the page header per the design
|
|
107
|
+
* - Other list views → just "+ New <Type>"
|
|
107
108
|
* - Editor views → no top-bar action (editor renders its own toolbar)
|
|
108
109
|
*/
|
|
109
110
|
function computePageActions({
|
|
@@ -112,56 +113,12 @@ function computePageActions({
|
|
|
112
113
|
onNavigate,
|
|
113
114
|
}: PageActionsContext): ReactNode | null {
|
|
114
115
|
const collections = collectionList(config)
|
|
115
|
-
const postCollections = collections.filter((c) => c.type === 'post')
|
|
116
|
-
const newPostPath = resolveNewPostPath(postCollections)
|
|
117
|
-
const siteUrl: string | null = config?.site?.url ?? config?.seo?.siteUrl ?? null
|
|
118
116
|
|
|
117
|
+
// Dashboard, /posts, and /pages render their own hero CTAs in the page
|
|
118
|
+
// header (design `.page-hdr` → `.hdr-r`), so the top bar stays empty there.
|
|
119
119
|
const isDashboard = currentPath === '/' || currentPath === '/dashboard'
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// a single Post Type we route straight to its editor; with several we
|
|
123
|
-
// route to the picker so the user can choose the type first.
|
|
124
|
-
if (isDashboard) {
|
|
125
|
-
return (
|
|
126
|
-
<>
|
|
127
|
-
<button
|
|
128
|
-
type="button"
|
|
129
|
-
onClick={() => onNavigate(newPostPath)}
|
|
130
|
-
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
|
|
131
|
-
>
|
|
132
|
-
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
133
|
-
New Post
|
|
134
|
-
</button>
|
|
135
|
-
{siteUrl ? (
|
|
136
|
-
<a
|
|
137
|
-
href={siteUrl}
|
|
138
|
-
target="_blank"
|
|
139
|
-
rel="noopener noreferrer"
|
|
140
|
-
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"
|
|
141
|
-
>
|
|
142
|
-
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
|
143
|
-
View Site
|
|
144
|
-
</a>
|
|
145
|
-
) : null}
|
|
146
|
-
</>
|
|
147
|
-
)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Posts area — "+ New Post" + "Manage types" on the list page;
|
|
151
|
-
// "+ New Post Type" on the types page. The buttons live in the top
|
|
152
|
-
// bar so the page header stays clean. See the dashboard rule for
|
|
153
|
-
// why these come from a single config-aware helper.
|
|
154
|
-
if (currentPath === '/posts') {
|
|
155
|
-
return (
|
|
156
|
-
<button
|
|
157
|
-
type="button"
|
|
158
|
-
onClick={() => onNavigate(newPostPath)}
|
|
159
|
-
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
|
|
160
|
-
>
|
|
161
|
-
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
162
|
-
New Post
|
|
163
|
-
</button>
|
|
164
|
-
)
|
|
120
|
+
if (isDashboard || currentPath === '/posts' || currentPath === '/pages') {
|
|
121
|
+
return null
|
|
165
122
|
}
|
|
166
123
|
|
|
167
124
|
if (currentPath === '/posts/types') {
|
|
@@ -177,22 +134,6 @@ function computePageActions({
|
|
|
177
134
|
)
|
|
178
135
|
}
|
|
179
136
|
|
|
180
|
-
// Pages list — "+ New page" routes to the page creation flow. Handled
|
|
181
|
-
// explicitly (not via the collection loop) so the label/route stay
|
|
182
|
-
// exact even though `pages` is a reserved route.
|
|
183
|
-
if (currentPath === '/pages') {
|
|
184
|
-
return (
|
|
185
|
-
<button
|
|
186
|
-
type="button"
|
|
187
|
-
onClick={() => onNavigate('/pages/new')}
|
|
188
|
-
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
|
|
189
|
-
>
|
|
190
|
-
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
191
|
-
New page
|
|
192
|
-
</button>
|
|
193
|
-
)
|
|
194
|
-
}
|
|
195
|
-
|
|
196
137
|
// List views — surface "+ New X" so authors can keep creating without
|
|
197
138
|
// hunting for a per-list button. Skip on editor views; those have their
|
|
198
139
|
// own toolbar.
|
|
@@ -221,19 +162,6 @@ interface CollectionMeta {
|
|
|
221
162
|
admin?: { hidden?: boolean }
|
|
222
163
|
}
|
|
223
164
|
|
|
224
|
-
/**
|
|
225
|
-
* Where "+ New Post" should land, given the configured post-type
|
|
226
|
-
* collections:
|
|
227
|
-
* - exactly one → straight to that type's editor (`/<slug>/new`)
|
|
228
|
-
* - more than one → the type picker (`/posts/new`)
|
|
229
|
-
* - none → the Post Type setup instructions
|
|
230
|
-
*/
|
|
231
|
-
function resolveNewPostPath(postCollections: CollectionMeta[]): string {
|
|
232
|
-
if (postCollections.length === 1) return `/${postCollections[0]!.slug}/new`
|
|
233
|
-
if (postCollections.length > 1) return '/posts/new'
|
|
234
|
-
return '/posts/types/new'
|
|
235
|
-
}
|
|
236
|
-
|
|
237
165
|
function collectionList(config: any): CollectionMeta[] {
|
|
238
166
|
if (!config?.collections) return []
|
|
239
167
|
const raw = config.collections
|
package/src/lib/pages-service.ts
CHANGED
|
@@ -510,6 +510,24 @@ export async function bulkUpdatePages(
|
|
|
510
510
|
})
|
|
511
511
|
}
|
|
512
512
|
|
|
513
|
+
/** Duplicate a single page. Server resets status to DRAFT and appends "(Copy)". */
|
|
514
|
+
export async function duplicatePage(id: string): Promise<{ id?: string; error?: string }> {
|
|
515
|
+
const res = await cmsApi<DocumentApiRow>(
|
|
516
|
+
`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}/duplicate`,
|
|
517
|
+
{ method: 'POST' },
|
|
518
|
+
)
|
|
519
|
+
if (res.error) return { error: res.error }
|
|
520
|
+
return { id: res.data?.id }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Soft-delete a single page. */
|
|
524
|
+
export async function deletePage(id: string): Promise<{ error?: string }> {
|
|
525
|
+
const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
|
|
526
|
+
method: 'DELETE',
|
|
527
|
+
})
|
|
528
|
+
return res.error ? { error: res.error } : {}
|
|
529
|
+
}
|
|
530
|
+
|
|
513
531
|
export async function bulkDeletePages(ids: string[]): Promise<BulkResult> {
|
|
514
532
|
return mapWithConcurrency(ids, 5, async (id) => {
|
|
515
533
|
const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
|
package/src/lib/posts-service.ts
CHANGED
|
@@ -50,6 +50,8 @@ export interface Post {
|
|
|
50
50
|
publishDate: string | null
|
|
51
51
|
updatedDate: string
|
|
52
52
|
seoScore: number | null
|
|
53
|
+
/** Featured-image URL for the list thumbnail, when the document has one. */
|
|
54
|
+
imageUrl: string | null
|
|
53
55
|
createdAt: string
|
|
54
56
|
updatedAt: string
|
|
55
57
|
}
|
|
@@ -151,6 +153,7 @@ interface PostsApiPost {
|
|
|
151
153
|
createdAt: string
|
|
152
154
|
updatedAt: string
|
|
153
155
|
seoScore: number | null
|
|
156
|
+
image?: string | null
|
|
154
157
|
author: { id: string; name: string } | null
|
|
155
158
|
}
|
|
156
159
|
|
|
@@ -240,6 +243,7 @@ export async function fetchPosts(query: PostsQuery = {}): Promise<PostsResult> {
|
|
|
240
243
|
publishDate: d.publishedAt ?? d.scheduledAt ?? null,
|
|
241
244
|
updatedDate: pickDisplayDate(d),
|
|
242
245
|
seoScore: d.seoScore,
|
|
246
|
+
imageUrl: d.image ?? null,
|
|
243
247
|
createdAt: d.createdAt,
|
|
244
248
|
updatedAt: d.updatedAt,
|
|
245
249
|
}
|
|
@@ -282,6 +286,28 @@ export async function bulkUpdatePosts(
|
|
|
282
286
|
return { ok, failed }
|
|
283
287
|
}
|
|
284
288
|
|
|
289
|
+
/** Duplicate a single post. Server resets status to DRAFT and appends "(Copy)". */
|
|
290
|
+
export async function duplicatePost(
|
|
291
|
+
postTypeId: string,
|
|
292
|
+
id: string,
|
|
293
|
+
): Promise<{ id?: string; error?: string }> {
|
|
294
|
+
const res = await cmsApi<{ id?: string }>(
|
|
295
|
+
`/collections/${encodeURIComponent(postTypeId)}/${encodeURIComponent(id)}/duplicate`,
|
|
296
|
+
{ method: 'POST' },
|
|
297
|
+
)
|
|
298
|
+
if (res.error) return { error: res.error }
|
|
299
|
+
return { id: res.data?.id }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Soft-delete a single post. */
|
|
303
|
+
export async function deletePost(postTypeId: string, id: string): Promise<{ error?: string }> {
|
|
304
|
+
const res = await cmsApi(
|
|
305
|
+
`/collections/${encodeURIComponent(postTypeId)}/${encodeURIComponent(id)}`,
|
|
306
|
+
{ method: 'DELETE' },
|
|
307
|
+
)
|
|
308
|
+
return res.error ? { error: res.error } : {}
|
|
309
|
+
}
|
|
310
|
+
|
|
285
311
|
export async function bulkDeletePosts(
|
|
286
312
|
ids: Array<{ postTypeId: string; id: string }>,
|
|
287
313
|
): Promise<{ ok: number; failed: number }> {
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -635,6 +635,9 @@ export interface CrawlSettings {
|
|
|
635
635
|
indexEntireSite: boolean
|
|
636
636
|
includeImagesInSitemap: boolean
|
|
637
637
|
includePostsInSitemap: boolean
|
|
638
|
+
sitemapEnabled: boolean
|
|
639
|
+
autoRegenerateSitemapOnPublish: boolean
|
|
640
|
+
pingSearchEnginesOnPublish: boolean
|
|
638
641
|
}
|
|
639
642
|
|
|
640
643
|
export async function fetchSitemapStatus(): Promise<SitemapStatus | null> {
|
package/src/views/Dashboard.tsx
CHANGED
|
@@ -287,6 +287,14 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
287
287
|
|
|
288
288
|
const collections = useMemo(() => resolveCollections(config), [config])
|
|
289
289
|
|
|
290
|
+
// Hero CTA targets (mirrors the quick-action "New Post" resolution).
|
|
291
|
+
const heroNewPostPath = useMemo(() => {
|
|
292
|
+
const postTypes = collections.filter((c) => c.type === 'post')
|
|
293
|
+
if (postTypes.length === 1) return `/${postTypes[0]!.slug}/new`
|
|
294
|
+
return postTypes.length > 1 ? '/posts/new' : '/posts/types/new'
|
|
295
|
+
}, [collections])
|
|
296
|
+
const siteUrl: string | null = config?.site?.url ?? config?.seo?.siteUrl ?? null
|
|
297
|
+
|
|
290
298
|
// ── Stat cards ──────────────────────────────────────────────────────────
|
|
291
299
|
const statCards: StatCardData[] = useMemo(() => {
|
|
292
300
|
const counts = stats?.collectionCounts ?? {}
|
|
@@ -561,24 +569,46 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
561
569
|
)}
|
|
562
570
|
|
|
563
571
|
{/* Hero greeting ─────────────────────────────────────────────────
|
|
564
|
-
|
|
565
|
-
(
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
572
|
+
Per the design, the primary "+ New Post" and "View Site" actions
|
|
573
|
+
sit on the right of the hero (`.dash-hdr` → `.dh-r`), not in the
|
|
574
|
+
top bar. The quick-action row below keeps the wider authoring
|
|
575
|
+
shortcuts. */}
|
|
576
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
577
|
+
<div className="flex flex-col gap-1 sm:gap-0.5">
|
|
578
|
+
<h1 className="text-foreground">
|
|
579
|
+
{greeting}, {userName} <span aria-hidden>👋</span>
|
|
580
|
+
</h1>
|
|
581
|
+
<p className="text-muted-foreground text-sm">
|
|
582
|
+
{dateStr}
|
|
583
|
+
{totalIssues > 0 && (
|
|
584
|
+
<span className="hidden sm:inline">
|
|
585
|
+
{' · '}
|
|
586
|
+
{totalIssues} content item{totalIssues === 1 ? '' : 's'} need attention
|
|
587
|
+
</span>
|
|
588
|
+
)}
|
|
589
|
+
</p>
|
|
590
|
+
</div>
|
|
591
|
+
<div className="flex items-center gap-2">
|
|
592
|
+
<button
|
|
593
|
+
type="button"
|
|
594
|
+
onClick={() => nav(heroNewPostPath)}
|
|
595
|
+
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
|
|
596
|
+
>
|
|
597
|
+
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
598
|
+
New Post
|
|
599
|
+
</button>
|
|
600
|
+
{siteUrl ? (
|
|
601
|
+
<a
|
|
602
|
+
href={siteUrl}
|
|
603
|
+
target="_blank"
|
|
604
|
+
rel="noopener noreferrer"
|
|
605
|
+
className="border-border bg-card 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"
|
|
606
|
+
>
|
|
607
|
+
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
|
608
|
+
View Site
|
|
609
|
+
</a>
|
|
610
|
+
) : null}
|
|
611
|
+
</div>
|
|
582
612
|
</div>
|
|
583
613
|
|
|
584
614
|
{/* Quick actions ──────────────────────────────────────────────── */}
|
|
@@ -31,6 +31,7 @@ import { useApiData } from '../lib/useApiData.js'
|
|
|
31
31
|
import { cmsApi } from '../lib/api.js'
|
|
32
32
|
import { FolderTree, type FolderSelection } from '../components/FolderTree.js'
|
|
33
33
|
import { StatusBadge } from '../components/ui/StatusBadge.js'
|
|
34
|
+
import { StatPill } from '../components/ui/StatPill.js'
|
|
34
35
|
|
|
35
36
|
interface MediaItem {
|
|
36
37
|
id: number | string
|
|
@@ -63,6 +64,14 @@ function matchesMediaType(item: MediaItem, filterType: string): boolean {
|
|
|
63
64
|
return item.type === filterType
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
function formatStorage(bytes: number): string {
|
|
68
|
+
if (bytes <= 0) return '0 B'
|
|
69
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
70
|
+
const i = Math.min(units.length - 1, Math.floor(Math.log2(bytes) / 10))
|
|
71
|
+
const value = bytes / 1024 ** i
|
|
72
|
+
return `${value >= 100 || i === 0 ? Math.round(value) : value.toFixed(1)} ${units[i]}`
|
|
73
|
+
}
|
|
74
|
+
|
|
66
75
|
function MediaPreview({ item }: { item: MediaItem }) {
|
|
67
76
|
if (isImageMedia(item)) {
|
|
68
77
|
return (
|
|
@@ -150,6 +159,22 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
150
159
|
return results
|
|
151
160
|
}, [mediaItems, searchQuery, filterType, sortConfig])
|
|
152
161
|
|
|
162
|
+
// Header stat pills (design `.mc-stats`): counts over the current folder
|
|
163
|
+
// scope, before search/type filters, so the numbers describe the library.
|
|
164
|
+
const mediaStats = useMemo(() => {
|
|
165
|
+
let images = 0
|
|
166
|
+
let videos = 0
|
|
167
|
+
let documents = 0
|
|
168
|
+
let bytes = 0
|
|
169
|
+
for (const item of mediaItems) {
|
|
170
|
+
if (item.type?.startsWith('image/') || isImageMedia(item)) images++
|
|
171
|
+
else if (item.type?.startsWith('video/')) videos++
|
|
172
|
+
else documents++
|
|
173
|
+
bytes += item.sizeBytes || 0
|
|
174
|
+
}
|
|
175
|
+
return { total: mediaItems.length, images, videos, documents, bytes }
|
|
176
|
+
}, [mediaItems])
|
|
177
|
+
|
|
153
178
|
const openDetail = (item: MediaItem) => {
|
|
154
179
|
setActiveItem(item)
|
|
155
180
|
setEditAlt(item.altTag ?? '')
|
|
@@ -413,7 +438,13 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
413
438
|
</button>
|
|
414
439
|
<div>
|
|
415
440
|
<h1 className="text-foreground mb-1">Media Library</h1>
|
|
416
|
-
<
|
|
441
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
442
|
+
<StatPill value={mediaStats.total} label="Total Files" />
|
|
443
|
+
<StatPill value={mediaStats.images} label="Images" />
|
|
444
|
+
<StatPill value={mediaStats.videos} label="Videos" />
|
|
445
|
+
<StatPill value={mediaStats.documents} label="Documents" />
|
|
446
|
+
<StatPill value={formatStorage(mediaStats.bytes)} label="Storage used" />
|
|
447
|
+
</div>
|
|
417
448
|
</div>
|
|
418
449
|
</div>
|
|
419
450
|
<div>
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
ChevronLeft,
|
|
7
7
|
ChevronRight,
|
|
8
8
|
ChevronUp,
|
|
9
|
+
Copy,
|
|
10
|
+
PenLine,
|
|
9
11
|
Plus,
|
|
10
12
|
Search,
|
|
11
13
|
Tag as TagIcon,
|
|
@@ -20,6 +22,8 @@ import {
|
|
|
20
22
|
bulkChangePageTags,
|
|
21
23
|
bulkDeletePages,
|
|
22
24
|
bulkUpdatePages,
|
|
25
|
+
deletePage,
|
|
26
|
+
duplicatePage,
|
|
23
27
|
fetchPagesDataset,
|
|
24
28
|
groupPages,
|
|
25
29
|
type BulkResult,
|
|
@@ -32,6 +36,7 @@ import {
|
|
|
32
36
|
import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
|
|
33
37
|
import { PageTagChip, PageTagsCell } from '../../components/pages/tag-badges.js'
|
|
34
38
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
39
|
+
import { RowActionButton, RowActions } from '../../components/ui/RowActions.js'
|
|
35
40
|
|
|
36
41
|
/**
|
|
37
42
|
* Pages management surface — table with a tag-chip filter bar, group-by,
|
|
@@ -70,6 +75,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
70
75
|
const [error, setError] = useState<string | null>(null)
|
|
71
76
|
const [refreshToken, setRefreshToken] = useState(0)
|
|
72
77
|
const [pendingDelete, setPendingDelete] = useState(false)
|
|
78
|
+
const [rowDelete, setRowDelete] = useState<Page | null>(null)
|
|
73
79
|
|
|
74
80
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
75
81
|
useEffect(() => {
|
|
@@ -284,16 +290,69 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
284
290
|
afterBulk()
|
|
285
291
|
}
|
|
286
292
|
|
|
293
|
+
// ─── Row actions ───────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
const runDuplicate = async (p: Page) => {
|
|
296
|
+
const promise = duplicatePage(p.id).then((r) => {
|
|
297
|
+
if (r.error) throw new Error(r.error)
|
|
298
|
+
return r
|
|
299
|
+
})
|
|
300
|
+
toast.promise(promise, {
|
|
301
|
+
loading: `Duplicating "${p.title}"…`,
|
|
302
|
+
success: `"${p.title}" duplicated`,
|
|
303
|
+
error: (e: Error) => e.message || 'Duplicate failed',
|
|
304
|
+
})
|
|
305
|
+
try {
|
|
306
|
+
await promise
|
|
307
|
+
setRefreshToken((t) => t + 1)
|
|
308
|
+
} catch {
|
|
309
|
+
/* surfaced via toast.promise */
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const runRowDelete = async (p: Page) => {
|
|
314
|
+
const promise = deletePage(p.id).then((r) => {
|
|
315
|
+
if (r.error) throw new Error(r.error)
|
|
316
|
+
})
|
|
317
|
+
toast.promise(promise, {
|
|
318
|
+
loading: `Deleting "${p.title}"…`,
|
|
319
|
+
success: `"${p.title}" moved to trash`,
|
|
320
|
+
error: (e: Error) => e.message || 'Delete failed',
|
|
321
|
+
})
|
|
322
|
+
try {
|
|
323
|
+
await promise
|
|
324
|
+
setSelected((s) => {
|
|
325
|
+
const next = new Set(s)
|
|
326
|
+
next.delete(p.id)
|
|
327
|
+
return next
|
|
328
|
+
})
|
|
329
|
+
setRefreshToken((t) => t + 1)
|
|
330
|
+
} catch {
|
|
331
|
+
/* surfaced via toast.promise */
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
287
335
|
// ─── Render ────────────────────────────────────────────────────────
|
|
288
336
|
|
|
289
337
|
return (
|
|
290
338
|
<div className="flex h-full flex-col gap-4 p-4 sm:p-6">
|
|
291
|
-
{/* Header
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
<
|
|
295
|
-
|
|
296
|
-
|
|
339
|
+
{/* Header — the "+ New page" primary action sits on the right, per
|
|
340
|
+
the design's `.page-hdr` / `.hdr-r` layout. */}
|
|
341
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
342
|
+
<div className="flex flex-col gap-1">
|
|
343
|
+
<h1 className="text-foreground">Pages</h1>
|
|
344
|
+
<p className="text-muted-foreground text-sm">
|
|
345
|
+
{grandTotal} page{grandTotal === 1 ? '' : 's'} · organized by tags
|
|
346
|
+
</p>
|
|
347
|
+
</div>
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
onClick={() => onNavigate('/pages/new')}
|
|
351
|
+
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
|
|
352
|
+
>
|
|
353
|
+
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
354
|
+
New page
|
|
355
|
+
</button>
|
|
297
356
|
</div>
|
|
298
357
|
|
|
299
358
|
{/* Controls row */}
|
|
@@ -410,6 +469,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
410
469
|
<col style={{ width: '120px' }} />
|
|
411
470
|
<col style={{ width: '130px' }} />
|
|
412
471
|
<col style={{ width: '80px' }} />
|
|
472
|
+
<col style={{ width: '110px' }} />
|
|
413
473
|
</colgroup>
|
|
414
474
|
<thead className="border-border bg-muted/40 border-b">
|
|
415
475
|
<tr className="text-muted-foreground text-xs">
|
|
@@ -474,13 +534,16 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
474
534
|
onSort={handleSort}
|
|
475
535
|
/>
|
|
476
536
|
</th>
|
|
537
|
+
<th scope="col" className="px-3 py-2.5 text-right">
|
|
538
|
+
<span className="sr-only">Actions</span>
|
|
539
|
+
</th>
|
|
477
540
|
</tr>
|
|
478
541
|
</thead>
|
|
479
542
|
<tbody>
|
|
480
543
|
{loading && pages.length === 0 && <LoadingRows />}
|
|
481
544
|
{!loading && error && (
|
|
482
545
|
<tr>
|
|
483
|
-
<td colSpan={
|
|
546
|
+
<td colSpan={8} className="px-3 py-12 text-center">
|
|
484
547
|
<div className="text-foreground mb-2 text-sm font-medium">{error}</div>
|
|
485
548
|
<button
|
|
486
549
|
onClick={() => setRefreshToken((t) => t + 1)}
|
|
@@ -516,6 +579,8 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
516
579
|
resolveTag={(slug) => tagBySlug.get(slug)}
|
|
517
580
|
onSelect={() => toggleOne(p.id)}
|
|
518
581
|
onOpen={() => onNavigate(`/pages/${p.id}/edit`)}
|
|
582
|
+
onDuplicate={() => void runDuplicate(p)}
|
|
583
|
+
onDelete={() => setRowDelete(p)}
|
|
519
584
|
/>
|
|
520
585
|
))}
|
|
521
586
|
</GroupBlock>
|
|
@@ -536,6 +601,19 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
536
601
|
)}
|
|
537
602
|
</div>
|
|
538
603
|
|
|
604
|
+
<ConfirmDialog
|
|
605
|
+
open={rowDelete !== null}
|
|
606
|
+
title={`Delete "${rowDelete?.title ?? ''}"?`}
|
|
607
|
+
description="The page moves to trash and can be restored for 30 days."
|
|
608
|
+
confirmLabel="Delete"
|
|
609
|
+
destructive
|
|
610
|
+
onClose={() => setRowDelete(null)}
|
|
611
|
+
onConfirm={() => {
|
|
612
|
+
if (rowDelete) void runRowDelete(rowDelete)
|
|
613
|
+
setRowDelete(null)
|
|
614
|
+
}}
|
|
615
|
+
/>
|
|
616
|
+
|
|
539
617
|
<ConfirmDialog
|
|
540
618
|
open={pendingDelete}
|
|
541
619
|
title={`Delete ${deletablePages.length} page${deletablePages.length === 1 ? '' : 's'}?`}
|
|
@@ -636,7 +714,7 @@ function GroupBlock({
|
|
|
636
714
|
<>
|
|
637
715
|
{showHeader && (
|
|
638
716
|
<tr className="bg-muted/30 border-border border-b">
|
|
639
|
-
<td colSpan={
|
|
717
|
+
<td colSpan={8} className="px-3 py-2">
|
|
640
718
|
<span className="text-foreground inline-flex items-center gap-2 text-xs font-medium tracking-wider uppercase">
|
|
641
719
|
{color && <span aria-hidden className={`h-2 w-2 rounded-full ${dotFor(color)}`} />}
|
|
642
720
|
{label}
|
|
@@ -779,12 +857,16 @@ function PageRow({
|
|
|
779
857
|
resolveTag,
|
|
780
858
|
onSelect,
|
|
781
859
|
onOpen,
|
|
860
|
+
onDuplicate,
|
|
861
|
+
onDelete,
|
|
782
862
|
}: {
|
|
783
863
|
page: Page
|
|
784
864
|
selected: boolean
|
|
785
865
|
resolveTag: (slug: string) => { name: string; color: string | null } | undefined
|
|
786
866
|
onSelect: () => void
|
|
787
867
|
onOpen: () => void
|
|
868
|
+
onDuplicate: () => void
|
|
869
|
+
onDelete: () => void
|
|
788
870
|
}) {
|
|
789
871
|
return (
|
|
790
872
|
<tr
|
|
@@ -835,6 +917,29 @@ function PageRow({
|
|
|
835
917
|
<td className="px-3 py-3">
|
|
836
918
|
<SeoScoreIndicator score={page.seoScore} />
|
|
837
919
|
</td>
|
|
920
|
+
<td className="px-3 py-3">
|
|
921
|
+
<RowActions>
|
|
922
|
+
<RowActionButton aria-label={`Edit "${page.title}"`} title="Edit" onClick={onOpen}>
|
|
923
|
+
<PenLine size={13} aria-hidden />
|
|
924
|
+
</RowActionButton>
|
|
925
|
+
<RowActionButton
|
|
926
|
+
aria-label={`Duplicate "${page.title}"`}
|
|
927
|
+
title="Duplicate"
|
|
928
|
+
onClick={onDuplicate}
|
|
929
|
+
>
|
|
930
|
+
<Copy size={12} aria-hidden />
|
|
931
|
+
</RowActionButton>
|
|
932
|
+
<RowActionButton
|
|
933
|
+
aria-label={`Delete "${page.title}"`}
|
|
934
|
+
title={page.isHomepage ? 'The homepage cannot be deleted' : 'Delete'}
|
|
935
|
+
danger
|
|
936
|
+
disabled={page.isHomepage}
|
|
937
|
+
onClick={onDelete}
|
|
938
|
+
>
|
|
939
|
+
<Trash2 size={12} aria-hidden />
|
|
940
|
+
</RowActionButton>
|
|
941
|
+
</RowActions>
|
|
942
|
+
</td>
|
|
838
943
|
</tr>
|
|
839
944
|
)
|
|
840
945
|
}
|
|
@@ -844,7 +949,7 @@ function LoadingRows() {
|
|
|
844
949
|
<>
|
|
845
950
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
846
951
|
<tr key={i} className="border-border border-b last:border-b-0">
|
|
847
|
-
{Array.from({ length:
|
|
952
|
+
{Array.from({ length: 8 }).map((__, j) => (
|
|
848
953
|
<td key={j} className="px-3 py-3">
|
|
849
954
|
<div className="bg-muted h-4 w-full animate-pulse rounded" />
|
|
850
955
|
</td>
|
|
@@ -866,7 +971,7 @@ function EmptyRow({
|
|
|
866
971
|
}) {
|
|
867
972
|
return (
|
|
868
973
|
<tr>
|
|
869
|
-
<td colSpan={
|
|
974
|
+
<td colSpan={8} className="px-3 py-16 text-center">
|
|
870
975
|
<div className="text-foreground mb-2 text-sm font-medium">
|
|
871
976
|
{filtersActive ? 'No pages match your filters' : 'No pages yet'}
|
|
872
977
|
</div>
|