@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.
- package/dist/actuate-admin.css +1 -1
- package/dist/components/NotificationBell.d.ts.map +1 -1
- package/dist/components/NotificationBell.js +32 -11
- package/dist/components/NotificationBell.js.map +1 -1
- package/dist/layout/Header.d.ts +14 -1
- package/dist/layout/Header.d.ts.map +1 -1
- package/dist/layout/Header.js +10 -3
- package/dist/layout/Header.js.map +1 -1
- package/dist/layout/Layout.d.ts.map +1 -1
- package/dist/layout/Layout.js +50 -2
- package/dist/layout/Layout.js.map +1 -1
- package/dist/layout/Sidebar.d.ts +7 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +121 -46
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +136 -62
- package/dist/views/Dashboard.js.map +1 -1
- package/package.json +2 -2
- package/src/components/NotificationBell.tsx +31 -11
- package/src/layout/Header.tsx +90 -43
- package/src/layout/Layout.tsx +110 -1
- package/src/layout/Sidebar.tsx +218 -98
- package/src/views/Dashboard.tsx +258 -131
package/src/layout/Header.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react'
|
|
4
|
-
import { Search,
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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=
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/src/layout/Layout.tsx
CHANGED
|
@@ -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
|
+
}
|