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:
Usman Baig
2026-03-18 22:48:52 +01:00
parent 0b545eaa76
commit 2fa498fb8f
4 changed files with 73 additions and 36 deletions

View File

@@ -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 */}

View File

@@ -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
View File

@@ -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",

View File

@@ -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",