@actuate-media/cms-admin 0.14.0 → 0.15.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.
@@ -1,9 +1,10 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
4
- import { Search, Bell, User, ChevronDown, Menu, Sun, Moon } from 'lucide-react'
3
+ import { useState, type ReactNode } from 'react'
4
+ import { Search, User, ChevronDown, Menu, Sun, Moon } from 'lucide-react'
5
5
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
6
6
  import { CommandPalette } from '../components/CommandPalette.js'
7
+ import { NotificationBell } from '../components/NotificationBell.js'
7
8
  import { useTheme } from '../components/ThemeProvider.js'
8
9
  import { LocaleSwitcher } from '../components/LocaleSwitcher.js'
9
10
 
@@ -11,9 +12,21 @@ export interface HeaderProps {
11
12
  onToggleSidebar: () => void
12
13
  session?: any
13
14
  onNavigate: (path: string) => void
15
+ /**
16
+ * Page-specific actions rendered on the far right of the top bar
17
+ * (after the search input + utility icons). Used to surface
18
+ * dashboard-level primary actions like "+ New Post" / "View Site"
19
+ * without forcing every view to render its own header.
20
+ *
21
+ * The Layout decides what to pass here based on `currentPath`.
22
+ * Keeping the slot on Header rather than per-view means the
23
+ * actions row stays consistent across navigation and never reflows
24
+ * on view-change.
25
+ */
26
+ pageActions?: ReactNode
14
27
  }
15
28
 
16
- export function Header({ onToggleSidebar, session, onNavigate }: HeaderProps) {
29
+ export function Header({ onToggleSidebar, session, onNavigate, pageActions }: HeaderProps) {
17
30
  const [showCommandPalette, setShowCommandPalette] = useState(false)
18
31
  const [searchQuery, setSearchQuery] = useState('')
19
32
  const { resolvedTheme, setTheme } = useTheme()
@@ -22,9 +35,14 @@ export function Header({ onToggleSidebar, session, onNavigate }: HeaderProps) {
22
35
  setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
23
36
  }
24
37
 
38
+ const initial =
39
+ session?.user?.name?.charAt(0)?.toUpperCase() ??
40
+ session?.user?.email?.charAt(0)?.toUpperCase() ??
41
+ 'A'
42
+
25
43
  return (
26
44
  <>
27
- <header className="border-border bg-background flex h-14 items-center justify-between gap-4 border-b px-4">
45
+ <header className="border-border bg-background flex h-14 items-center gap-3 border-b px-4">
28
46
  <button
29
47
  onClick={onToggleSidebar}
30
48
  className="hover:bg-accent rounded-lg p-2 transition-colors md:hidden"
@@ -33,39 +51,50 @@ export function Header({ onToggleSidebar, session, onNavigate }: HeaderProps) {
33
51
  <Menu className="text-foreground h-5 w-5" strokeWidth={2} />
34
52
  </button>
35
53
 
36
- <div className="flex items-center md:hidden">
37
- <span className="text-foreground text-lg font-semibold">Actuate</span>
54
+ {/* Search: takes available width on desktop so it visually anchors
55
+ the top bar like the reference design. */}
56
+ <div className="relative hidden flex-1 md:block">
57
+ <Search
58
+ className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
59
+ aria-hidden
60
+ />
61
+ <input
62
+ type="text"
63
+ placeholder="Search..."
64
+ aria-label="Search content"
65
+ value={searchQuery}
66
+ onChange={(e) => setSearchQuery(e.target.value)}
67
+ onFocus={() => setShowCommandPalette(true)}
68
+ onKeyDown={(e) => {
69
+ if (e.key === 'Escape') (e.target as HTMLInputElement).blur()
70
+ }}
71
+ className="border-border bg-input-background text-foreground placeholder:text-muted-foreground focus:ring-ring h-9 w-full max-w-md rounded-lg border py-1.5 pr-14 pl-9 text-sm focus:border-transparent focus:ring-2 focus:outline-none"
72
+ />
73
+ {/* Keyboard hint pill. Hidden on narrow widths to avoid overlap. */}
74
+ <span
75
+ className="border-border bg-background text-muted-foreground pointer-events-none absolute top-1/2 right-2 hidden -translate-y-1/2 rounded border px-1.5 py-0.5 font-mono text-[10px] sm:inline-flex"
76
+ aria-hidden
77
+ >
78
+ ⌘K
79
+ </span>
38
80
  </div>
39
81
 
40
- <div className="flex-1" />
41
-
42
- <div className="flex items-center gap-2 sm:gap-3">
43
- <div className="relative hidden md:block">
44
- <Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
45
- <input
46
- type="text"
47
- placeholder="Search... (⌘K)"
48
- value={searchQuery}
49
- onChange={(e) => setSearchQuery(e.target.value)}
50
- onFocus={() => setShowCommandPalette(true)}
51
- className="border-border bg-input-background text-foreground focus:ring-ring w-64 rounded-lg border py-1.5 pr-3 pl-9 text-sm focus:border-transparent focus:ring-2 focus:outline-none"
52
- />
53
- </div>
54
-
55
- <button
56
- onClick={() => setShowCommandPalette(true)}
57
- className="hover:bg-accent rounded-lg p-2 transition-colors md:hidden"
58
- aria-label="Search"
59
- >
60
- <Search className="text-muted-foreground h-5 w-5" />
61
- </button>
82
+ {/* Mobile search trigger (icon-only). */}
83
+ <button
84
+ onClick={() => setShowCommandPalette(true)}
85
+ className="hover:bg-accent rounded-lg p-2 transition-colors md:hidden"
86
+ aria-label="Search"
87
+ >
88
+ <Search className="text-muted-foreground h-5 w-5" />
89
+ </button>
62
90
 
91
+ <div className="ml-auto flex items-center gap-1.5">
63
92
  <LocaleSwitcher />
64
93
 
65
94
  <button
66
95
  onClick={toggleTheme}
67
96
  className="hover:bg-accent rounded-lg p-2 transition-colors"
68
- aria-label="Toggle theme"
97
+ aria-label={resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
69
98
  >
70
99
  {resolvedTheme === 'dark' ? (
71
100
  <Sun className="text-muted-foreground h-5 w-5" />
@@ -74,23 +103,24 @@ export function Header({ onToggleSidebar, session, onNavigate }: HeaderProps) {
74
103
  )}
75
104
  </button>
76
105
 
77
- <button
78
- className="hover:bg-accent relative rounded-lg p-2 transition-colors"
79
- aria-label="Notifications"
80
- >
81
- <Bell className="text-muted-foreground h-5 w-5" />
82
- <span className="bg-destructive absolute top-1.5 right-1.5 h-2 w-2 rounded-full" />
83
- </button>
106
+ {/* Real notification bell — replaces the previous inert decoration.
107
+ Renders its own dropdown panel, SSE-backed unread counter, and
108
+ click-to-mark-read flow. */}
109
+ <NotificationBell />
84
110
 
85
111
  <DropdownMenu.Root>
86
112
  <DropdownMenu.Trigger asChild>
87
- <button className="hover:bg-accent flex items-center gap-2 rounded-lg p-1.5 transition-colors">
113
+ <button
114
+ className="hover:bg-accent flex items-center gap-1.5 rounded-lg p-1 transition-colors"
115
+ aria-label="Account menu"
116
+ >
88
117
  <div className="flex h-8 w-8 items-center justify-center rounded-full bg-linear-to-br from-blue-500 to-purple-600">
89
- <span className="text-sm font-medium text-white">
90
- {session?.user?.name?.charAt(0)?.toUpperCase() ?? 'A'}
91
- </span>
118
+ <span className="text-sm font-medium text-white">{initial}</span>
92
119
  </div>
93
- <ChevronDown className="text-muted-foreground hidden h-4 w-4 sm:block" />
120
+ <ChevronDown
121
+ className="text-muted-foreground hidden h-4 w-4 sm:block"
122
+ aria-hidden
123
+ />
94
124
  </button>
95
125
  </DropdownMenu.Trigger>
96
126
 
@@ -106,7 +136,10 @@ export function Header({ onToggleSidebar, session, onNavigate }: HeaderProps) {
106
136
  {session?.user?.email ?? 'admin@example.com'}
107
137
  </p>
108
138
  </div>
109
- <DropdownMenu.Item className="hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-3 py-2 text-sm outline-none">
139
+ <DropdownMenu.Item
140
+ className="hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-3 py-2 text-sm outline-none"
141
+ onSelect={() => onNavigate('/profile')}
142
+ >
110
143
  <User className="h-4 w-4" />
111
144
  Profile
112
145
  </DropdownMenu.Item>
@@ -117,12 +150,26 @@ export function Header({ onToggleSidebar, session, onNavigate }: HeaderProps) {
117
150
  Settings
118
151
  </DropdownMenu.Item>
119
152
  <DropdownMenu.Separator className="bg-border my-1 h-px" />
120
- <DropdownMenu.Item className="text-destructive hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-3 py-2 text-sm outline-none">
153
+ <DropdownMenu.Item
154
+ className="text-destructive hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-3 py-2 text-sm outline-none"
155
+ onSelect={() => onNavigate('/logout')}
156
+ >
121
157
  Logout
122
158
  </DropdownMenu.Item>
123
159
  </DropdownMenu.Content>
124
160
  </DropdownMenu.Portal>
125
161
  </DropdownMenu.Root>
162
+
163
+ {/* Page actions slot — sits to the right of the utility icons so
164
+ the primary CTA is the rightmost element, mirroring the
165
+ reference. Hidden on mobile because narrow viewports lack the
166
+ room; pages can re-surface the same action in their hero
167
+ there. */}
168
+ {pageActions && (
169
+ <div className="border-border ml-2 hidden items-center gap-2 border-l pl-3 md:flex">
170
+ {pageActions}
171
+ </div>
172
+ )}
126
173
  </div>
127
174
  </header>
128
175
 
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
- import { useState, useEffect, type ReactNode } from 'react'
3
+ import { useMemo, useState, useEffect, type ReactNode } from 'react'
4
+ import { ExternalLink, Plus } from 'lucide-react'
4
5
  import { Sidebar } from './Sidebar.js'
5
6
  import { Header } from './Header.js'
6
7
  import { Breadcrumbs } from '../components/Breadcrumbs.js'
@@ -37,6 +38,11 @@ export function Layout({ config, session, currentPath, onNavigate, children }: L
37
38
  setMobileSidebarOpen(false)
38
39
  }, [currentPath])
39
40
 
41
+ const pageActions = useMemo(
42
+ () => computePageActions({ currentPath, config, onNavigate }),
43
+ [currentPath, config, onNavigate],
44
+ )
45
+
40
46
  return (
41
47
  <>
42
48
  <Toaster position="bottom-right" />
@@ -57,6 +63,7 @@ export function Layout({ config, session, currentPath, onNavigate, children }: L
57
63
  onToggleSidebar={() => setMobileSidebarOpen(!mobileSidebarOpen)}
58
64
  session={session}
59
65
  onNavigate={onNavigate}
66
+ pageActions={pageActions}
60
67
  />
61
68
  }
62
69
  breadcrumbs={<Breadcrumbs currentPath={currentPath} onNavigate={onNavigate} />}
@@ -66,3 +73,105 @@ export function Layout({ config, session, currentPath, onNavigate, children }: L
66
73
  </>
67
74
  )
68
75
  }
76
+
77
+ // ─── Page actions ────────────────────────────────────────────────────────────
78
+
79
+ interface PageActionsContext {
80
+ currentPath: string
81
+ config: any
82
+ onNavigate: (path: string) => void
83
+ }
84
+
85
+ /**
86
+ * Per-route primary actions that render on the right of the top bar.
87
+ *
88
+ * Centralised here so the top bar's actions row stays consistent
89
+ * across view changes — no flicker on navigation, no per-view header
90
+ * reimplementation. Views that need view-local actions (e.g. a
91
+ * "Save draft" button on the post editor) continue to render them
92
+ * inside their own hero; this slot is for the highest-level
93
+ * primary/secondary pair that mirrors the reference dashboard.
94
+ *
95
+ * Resolution rules:
96
+ * - Dashboard / home → "+ New Post" + "View Site"
97
+ * - List views → just "+ New <Type>"
98
+ * - Editor views → no top-bar action (editor renders its own toolbar)
99
+ */
100
+ function computePageActions({
101
+ currentPath,
102
+ config,
103
+ onNavigate,
104
+ }: PageActionsContext): ReactNode | null {
105
+ // Resolve the "primary" content collection (used for both the dashboard
106
+ // and any explicit /posts route). Prefer a real `posts` or `post`-type
107
+ // collection over the hard-coded slug so admin-managed CMS configs work.
108
+ const collections = collectionList(config)
109
+ const postsCol =
110
+ collections.find((c) => c.slug === 'posts' || c.type === 'post') ??
111
+ collections.find((c) => c.type === 'post')
112
+ const postsSlug = postsCol?.slug ?? 'posts'
113
+ const postsLabel = postsCol?.labels?.singular ?? 'Post'
114
+ const siteUrl: string | null = config?.site?.url ?? config?.seo?.siteUrl ?? null
115
+
116
+ const isDashboard = currentPath === '/' || currentPath === '/dashboard'
117
+
118
+ if (isDashboard) {
119
+ return (
120
+ <>
121
+ <button
122
+ type="button"
123
+ onClick={() => onNavigate(`/${postsSlug}/new`)}
124
+ 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"
125
+ >
126
+ <Plus className="h-3.5 w-3.5" aria-hidden />
127
+ New {postsLabel}
128
+ </button>
129
+ {siteUrl ? (
130
+ <a
131
+ href={siteUrl}
132
+ target="_blank"
133
+ rel="noopener noreferrer"
134
+ 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"
135
+ >
136
+ <ExternalLink className="h-3.5 w-3.5" aria-hidden />
137
+ View Site
138
+ </a>
139
+ ) : null}
140
+ </>
141
+ )
142
+ }
143
+
144
+ // List views — surface "+ New X" so authors can keep creating without
145
+ // hunting for a per-list button. Skip on editor views; those have their
146
+ // own toolbar.
147
+ for (const c of collections) {
148
+ if (currentPath === `/${c.slug}`) {
149
+ return (
150
+ <button
151
+ type="button"
152
+ onClick={() => onNavigate(`/${c.slug}/new`)}
153
+ 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"
154
+ >
155
+ <Plus className="h-3.5 w-3.5" aria-hidden />
156
+ New {c.labels?.singular ?? c.slug}
157
+ </button>
158
+ )
159
+ }
160
+ }
161
+
162
+ return null
163
+ }
164
+
165
+ interface CollectionMeta {
166
+ slug: string
167
+ type?: string
168
+ labels?: { singular?: string; plural?: string }
169
+ admin?: { hidden?: boolean }
170
+ }
171
+
172
+ function collectionList(config: any): CollectionMeta[] {
173
+ if (!config?.collections) return []
174
+ const raw = config.collections
175
+ const list: any[] = Array.isArray(raw) ? raw : Object.values(raw)
176
+ return list.filter((c) => !c?.admin?.hidden)
177
+ }