@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/actuate-admin.css +1 -1
  3. package/dist/components/ui/RowActions.d.ts +19 -0
  4. package/dist/components/ui/RowActions.d.ts.map +1 -0
  5. package/dist/components/ui/RowActions.js +16 -0
  6. package/dist/components/ui/RowActions.js.map +1 -0
  7. package/dist/components/ui/index.d.ts +2 -0
  8. package/dist/components/ui/index.d.ts.map +1 -1
  9. package/dist/components/ui/index.js +1 -0
  10. package/dist/components/ui/index.js.map +1 -1
  11. package/dist/layout/Header.js +1 -1
  12. package/dist/layout/Header.js.map +1 -1
  13. package/dist/layout/Layout.js +8 -38
  14. package/dist/layout/Layout.js.map +1 -1
  15. package/dist/lib/pages-service.d.ts +9 -0
  16. package/dist/lib/pages-service.d.ts.map +1 -1
  17. package/dist/lib/pages-service.js +14 -0
  18. package/dist/lib/pages-service.js.map +1 -1
  19. package/dist/lib/posts-service.d.ts +11 -0
  20. package/dist/lib/posts-service.d.ts.map +1 -1
  21. package/dist/lib/posts-service.js +13 -0
  22. package/dist/lib/posts-service.js.map +1 -1
  23. package/dist/lib/seo-service.d.ts +3 -0
  24. package/dist/lib/seo-service.d.ts.map +1 -1
  25. package/dist/lib/seo-service.js.map +1 -1
  26. package/dist/views/Dashboard.d.ts.map +1 -1
  27. package/dist/views/Dashboard.js +9 -1
  28. package/dist/views/Dashboard.js.map +1 -1
  29. package/dist/views/MediaBrowser.d.ts.map +1 -1
  30. package/dist/views/MediaBrowser.js +28 -1
  31. package/dist/views/MediaBrowser.js.map +1 -1
  32. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  33. package/dist/views/Pages/PagesListView.js +59 -10
  34. package/dist/views/Pages/PagesListView.js.map +1 -1
  35. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  36. package/dist/views/Posts/PostsListView.js +66 -9
  37. package/dist/views/Posts/PostsListView.js.map +1 -1
  38. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  39. package/dist/views/seo/TechnicalTab.js +15 -0
  40. package/dist/views/seo/TechnicalTab.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/components/ui/RowActions.tsx +50 -0
  43. package/src/components/ui/index.ts +2 -0
  44. package/src/layout/Header.tsx +2 -2
  45. package/src/layout/Layout.tsx +8 -80
  46. package/src/lib/pages-service.ts +18 -0
  47. package/src/lib/posts-service.ts +26 -0
  48. package/src/lib/seo-service.ts +3 -0
  49. package/src/views/Dashboard.tsx +48 -18
  50. package/src/views/MediaBrowser.tsx +32 -1
  51. package/src/views/Pages/PagesListView.tsx +115 -10
  52. package/src/views/Posts/PostsListView.tsx +135 -10
  53. package/src/views/seo/TechnicalTab.tsx +15 -0
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useMemo, useState, useEffect, type ReactNode } from 'react'
4
- import { ExternalLink, Plus } from 'lucide-react'
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 / home"+ New Post" + "View Site"
106
- * - List views just "+ New <Type>"
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
- // Dashboard: keep the existing "+ New Post" / "View Site" pair. With
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
@@ -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)}`, {
@@ -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 }> {
@@ -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> {
@@ -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
- Primary "+ New Post" and "View Site" actions live in the top bar
565
- (Header.tsx page-action slot), not here, so the hero is purely
566
- informational and the dashboard's chrome-level affordances stay
567
- consistent across navigation. The on-page secondary action row
568
- below covers the same creation paths plus authoring shortcuts. */}
569
- <div className="flex flex-col gap-1 sm:gap-0.5">
570
- <h1 className="text-foreground">
571
- {greeting}, {userName} <span aria-hidden>👋</span>
572
- </h1>
573
- <p className="text-muted-foreground text-sm">
574
- {dateStr}
575
- {totalIssues > 0 && (
576
- <span className="hidden sm:inline">
577
- {' · '}
578
- {totalIssues} content item{totalIssues === 1 ? '' : 's'} need attention
579
- </span>
580
- )}
581
- </p>
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
- <p className="text-muted-foreground text-sm">{filteredAndSorted.length} files</p>
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
- <div className="flex flex-col gap-1">
293
- <h1 className="text-foreground">Pages</h1>
294
- <p className="text-muted-foreground text-sm">
295
- {grandTotal} page{grandTotal === 1 ? '' : 's'} · organized by tags
296
- </p>
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={7} className="px-3 py-12 text-center">
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={7} className="px-3 py-2">
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: 7 }).map((__, j) => (
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={7} className="px-3 py-16 text-center">
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>