feat: sidebar utility items match NavLink styling
Use variant='sidebar' for ThemeToggle, NotificationCenter, and compact UserMenu so they render with the same icon+label layout as nav items. Fixed dropdown positioning uses fixed to escape sidebar overflow:hidden.
This commit is contained in:
@@ -386,31 +386,28 @@ export default function Sidebar({
|
|||||||
|
|
||||||
{/* Bottom — utility items */}
|
{/* Bottom — utility items */}
|
||||||
<div className="border-t border-neutral-200/60 dark:border-neutral-800/60 px-2 py-3 shrink-0">
|
<div className="border-t border-neutral-200/60 dark:border-neutral-800/60 px-2 py-3 shrink-0">
|
||||||
{/* Theme, Notifications, Profile */}
|
{/* Theme, Notifications, Profile — same layout as nav items */}
|
||||||
<div className="space-y-0.5 mb-1">
|
<div className="space-y-0.5 mb-1">
|
||||||
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1 overflow-hidden">
|
<ThemeToggle variant="sidebar">
|
||||||
<ThemeToggle />
|
|
||||||
<Label collapsed={c}>Theme</Label>
|
<Label collapsed={c}>Theme</Label>
|
||||||
</div>
|
</ThemeToggle>
|
||||||
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1 overflow-hidden">
|
<NotificationCenter anchor="right" variant="sidebar">
|
||||||
<NotificationCenter anchor="right" />
|
|
||||||
<Label collapsed={c}>Notifications</Label>
|
<Label collapsed={c}>Notifications</Label>
|
||||||
</div>
|
</NotificationCenter>
|
||||||
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1 overflow-hidden">
|
<UserMenu
|
||||||
<UserMenu
|
auth={auth}
|
||||||
auth={auth}
|
LinkComponent={Link}
|
||||||
LinkComponent={Link}
|
orgs={orgs}
|
||||||
orgs={orgs}
|
activeOrgId={auth.user?.org_id}
|
||||||
activeOrgId={auth.user?.org_id}
|
onSwitchOrganization={handleSwitchOrganization}
|
||||||
onSwitchOrganization={handleSwitchOrganization}
|
onCreateOrganization={() => router.push('/onboarding')}
|
||||||
onCreateOrganization={() => router.push('/onboarding')}
|
allowPersonalOrganization={false}
|
||||||
allowPersonalOrganization={false}
|
onOpenSettings={openSettings}
|
||||||
onOpenSettings={openSettings}
|
compact
|
||||||
compact
|
anchor="right"
|
||||||
anchor="right"
|
>
|
||||||
/>
|
|
||||||
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
|
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
|
||||||
</div>
|
</UserMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings + Collapse */}
|
{/* Settings + Collapse */}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* @file Notification center: bell icon with dropdown of recent notifications.
|
* @file Notification center: bell icon with dropdown of recent notifications.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications'
|
import { listNotifications, markNotificationRead, markAllNotificationsRead, type Notification } from '@/lib/api/notifications'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
@@ -37,13 +37,31 @@ function BellIcon({ className }: { className?: string }) {
|
|||||||
const LOADING_DELAY_MS = 250
|
const LOADING_DELAY_MS = 250
|
||||||
const POLL_INTERVAL_MS = 90_000
|
const POLL_INTERVAL_MS = 90_000
|
||||||
|
|
||||||
export default function NotificationCenter({ anchor = 'bottom' }: { anchor?: 'bottom' | 'right' }) {
|
interface NotificationCenterProps {
|
||||||
|
/** Where the dropdown opens. 'right' uses fixed positioning to escape overflow:hidden containers. */
|
||||||
|
anchor?: 'bottom' | 'right'
|
||||||
|
/** Render variant. 'sidebar' matches NavLink styling. */
|
||||||
|
variant?: 'default' | 'sidebar'
|
||||||
|
/** Optional label content rendered after the icon (useful for sidebar variant with fading labels). */
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationCenter({ anchor = 'bottom', variant = 'default', children }: NotificationCenterProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||||
const [unreadCount, setUnreadCount] = useState(0)
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const [fixedPos, setFixedPos] = useState<{ left: number; top: number } | null>(null)
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (anchor === 'right' && buttonRef.current) {
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect()
|
||||||
|
setFixedPos({ left: rect.right + 8, top: rect.top })
|
||||||
|
}
|
||||||
|
}, [anchor])
|
||||||
|
|
||||||
const fetchUnreadCount = async () => {
|
const fetchUnreadCount = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -74,8 +92,9 @@ export default function NotificationCenter({ anchor = 'bottom' }: { anchor?: 'bo
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
fetchNotifications()
|
fetchNotifications()
|
||||||
|
updatePosition()
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open, updatePosition])
|
||||||
|
|
||||||
// * Poll unread count in background (when authenticated)
|
// * Poll unread count in background (when authenticated)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -130,20 +149,40 @@ export default function NotificationCenter({ anchor = 'bottom' }: { anchor?: 'bo
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSidebar = variant === 'sidebar'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-controls={open ? 'notification-dropdown' : undefined}
|
aria-controls={open ? 'notification-dropdown' : undefined}
|
||||||
className="relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-colors"
|
className={isSidebar
|
||||||
|
? 'relative flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 w-full overflow-hidden transition-colors'
|
||||||
|
: 'relative p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-colors'
|
||||||
|
}
|
||||||
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
|
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : 'Notifications'}
|
||||||
>
|
>
|
||||||
<BellIcon />
|
{isSidebar ? (
|
||||||
{unreadCount > 0 && (
|
<>
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" aria-hidden="true" />
|
<span className="w-7 h-7 flex items-center justify-center shrink-0 relative">
|
||||||
|
<BellIcon className="h-[18px] w-[18px]" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-orange rounded-full" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BellIcon />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-orange rounded-full" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -152,11 +191,12 @@ export default function NotificationCenter({ anchor = 'bottom' }: { anchor?: 'bo
|
|||||||
id="notification-dropdown"
|
id="notification-dropdown"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
className={`fixed left-4 right-4 top-16 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100] ${
|
className={`bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-xl overflow-hidden z-[100] ${
|
||||||
anchor === 'right'
|
anchor === 'right'
|
||||||
? 'sm:absolute sm:left-full sm:top-0 sm:ml-2 sm:right-auto sm:w-96'
|
? 'fixed w-96 origin-top-left'
|
||||||
: 'sm:absolute sm:left-auto sm:right-0 sm:top-full sm:mt-2 sm:w-96'
|
: 'fixed left-4 right-4 top-16 sm:absolute sm:left-auto sm:right-0 sm:top-full sm:mt-2 sm:w-96'
|
||||||
}`}
|
}`}
|
||||||
|
style={anchor === 'right' && fixedPos ? { left: fixedPos.left, top: fixedPos.top } : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>
|
<h3 className="font-semibold text-neutral-900 dark:text-white">Notifications</h3>
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -8,7 +8,7 @@
|
|||||||
"name": "pulse-frontend",
|
"name": "pulse-frontend",
|
||||||
"version": "0.15.0-alpha",
|
"version": "0.15.0-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ciphera-net/ui": "^0.2.12",
|
"@ciphera-net/ui": "^0.2.13",
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
@@ -1670,9 +1670,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ciphera-net/ui": {
|
"node_modules/@ciphera-net/ui": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.13",
|
||||||
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.12/d6780a19e3f0128431d6ddb3166798f01fba28dc",
|
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.13/710acb1221e5906b8fd622ff6c77b469781c449d",
|
||||||
"integrity": "sha512-LQCG2ErTXBGB7PDRoco9uK6cE+9IenER9Yrxkc0BfvSG46irUwmI19H1WPzLohMQdy2Wu99eu/PP+0X7fQw3wA==",
|
"integrity": "sha512-pHo0DkkjAr7qlnPy1GBtHm3XFQlUC8b/FQM5PCu9JvW3pHg6znufyCP/8Wqbu6jOIt4C+c6CSKvMKzeYPKi42Q==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ciphera-net/ui": "^0.2.12",
|
"@ciphera-net/ui": "^0.2.13",
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user