From 80ae8311dc7b60af1dc79799eb7163ba5f7546ef Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 15:30:17 +0100 Subject: [PATCH] feat: static header + collapsible sidebar navigation Replace floating pill header with static variant for authenticated views. Add collapsible sidebar with site picker, grouped navigation (Analytics/Infrastructure), and mobile overlay drawer. Remove horizontal SiteNav tab bar. --- app/layout-content.tsx | 68 +++-- app/sites/[id]/SiteLayoutShell.tsx | 9 +- components/dashboard/DashboardShell.tsx | 23 ++ components/dashboard/Sidebar.tsx | 364 ++++++++++++++++++++++++ lib/sidebar-context.tsx | 31 ++ package-lock.json | 8 +- package.json | 2 +- 7 files changed, 474 insertions(+), 31 deletions(-) create mode 100644 components/dashboard/DashboardShell.tsx create mode 100644 components/dashboard/Sidebar.tsx create mode 100644 lib/sidebar-context.tsx diff --git a/app/layout-content.tsx b/app/layout-content.tsx index e5f2caa..ccf5075 100644 --- a/app/layout-content.tsx +++ b/app/layout-content.tsx @@ -2,7 +2,7 @@ import { OfflineBanner } from '@/components/OfflineBanner' import { Footer } from '@/components/Footer' -import { Header, type CipheraApp } from '@ciphera-net/ui' +import { Header, type CipheraApp, MenuIcon } from '@ciphera-net/ui' import NotificationCenter from '@/components/notifications/NotificationCenter' import { useAuth } from '@/lib/auth/context' import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus' @@ -15,6 +15,7 @@ import { LoadingOverlay } from '@ciphera-net/ui' import { useRouter } from 'next/navigation' import { SettingsModalProvider, useSettingsModal } from '@/lib/settings-modal-context' import SettingsModalWrapper from '@/components/settings/SettingsModalWrapper' +import { SidebarProvider, useSidebar } from '@/lib/sidebar-context' const ORG_SWITCH_KEY = 'pulse_switching_org' @@ -46,6 +47,19 @@ const CIPHERA_APPS: CipheraApp[] = [ }, ] +function MobileSidebarToggle() { + const { openMobile } = useSidebar() + return ( + + ) +} + function LayoutInner({ children }: { children: React.ReactNode }) { const auth = useAuth() const router = useRouter() @@ -91,23 +105,22 @@ function LayoutInner({ children }: { children: React.ReactNode }) { router.push('/onboarding') } - const showOfflineBar = Boolean(auth.user && !isOnline); - const barHeightRem = 2.5; - const headerHeightRem = 6; - const mainTopPaddingRem = barHeightRem + headerHeightRem; + const isAuthenticated = !!auth.user + const showOfflineBar = Boolean(auth.user && !isOnline) if (isSwitchingOrg) { return } return ( - <> +
{auth.user && }
: null} apps={CIPHERA_APPS} currentAppId="pulse" onOpenSettings={openSettings} + leftActions={isAuthenticated ? : undefined} customNavItems={ <> {!auth.user && ( @@ -134,26 +148,40 @@ function LayoutInner({ children }: { children: React.ReactNode }) { } /> -
- {children} -
-
+ {isAuthenticated ? ( + // Authenticated: sidebar layout — children include DashboardShell + <>{children} + ) : ( + // Public: standard content with footer + <> +
+ {children} +
+
+ + )} + {isAuthenticated && ( +
+ )} - +
) } export default function LayoutContent({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ) } diff --git a/app/sites/[id]/SiteLayoutShell.tsx b/app/sites/[id]/SiteLayoutShell.tsx index 1bc2a07..8879945 100644 --- a/app/sites/[id]/SiteLayoutShell.tsx +++ b/app/sites/[id]/SiteLayoutShell.tsx @@ -1,6 +1,6 @@ 'use client' -import SiteNav from '@/components/dashboard/SiteNav' +import DashboardShell from '@/components/dashboard/DashboardShell' export default function SiteLayoutShell({ siteId, @@ -10,11 +10,8 @@ export default function SiteLayoutShell({ children: React.ReactNode }) { return ( - <> -
- -
+ {children} - + ) } diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx new file mode 100644 index 0000000..0488ba5 --- /dev/null +++ b/components/dashboard/DashboardShell.tsx @@ -0,0 +1,23 @@ +'use client' + +import Sidebar from './Sidebar' +import { useSidebar } from '@/lib/sidebar-context' + +export default function DashboardShell({ + siteId, + children, +}: { + siteId: string + children: React.ReactNode +}) { + const { mobileOpen, closeMobile } = useSidebar() + + return ( +
+ +
+ {children} +
+
+ ) +} diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx new file mode 100644 index 0000000..34e9f9c --- /dev/null +++ b/components/dashboard/Sidebar.tsx @@ -0,0 +1,364 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import Link from 'next/link' +import { usePathname, useRouter } from 'next/navigation' +import { listSites, type Site } from '@/lib/api/sites' +import { useAuth } from '@/lib/auth/context' +import { + LayoutDashboardIcon, + PathIcon, + FunnelIcon, + CursorClickIcon, + SearchIcon, + CloudUploadIcon, + HeartbeatIcon, + SettingsIcon, + CollapseLeftIcon, + CollapseRightIcon, + ChevronUpDownIcon, + PlusIcon, + XIcon, + MenuIcon, +} from '@ciphera-net/ui' + +const SIDEBAR_COLLAPSED_KEY = 'pulse_sidebar_collapsed' + +interface NavItem { + label: string + href: (siteId: string) => string + icon: React.ComponentType<{ className?: string; weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' }> + matchPrefix?: boolean +} + +interface NavGroup { + label: string + items: NavItem[] +} + +const NAV_GROUPS: NavGroup[] = [ + { + label: 'Analytics', + items: [ + { label: 'Dashboard', href: (id) => `/sites/${id}`, icon: LayoutDashboardIcon }, + { label: 'Journeys', href: (id) => `/sites/${id}/journeys`, icon: PathIcon, matchPrefix: true }, + { label: 'Funnels', href: (id) => `/sites/${id}/funnels`, icon: FunnelIcon, matchPrefix: true }, + { label: 'Behavior', href: (id) => `/sites/${id}/behavior`, icon: CursorClickIcon, matchPrefix: true }, + { label: 'Search', href: (id) => `/sites/${id}/search`, icon: SearchIcon, matchPrefix: true }, + ], + }, + { + label: 'Infrastructure', + items: [ + { label: 'CDN', href: (id) => `/sites/${id}/cdn`, icon: CloudUploadIcon, matchPrefix: true }, + { label: 'Uptime', href: (id) => `/sites/${id}/uptime`, icon: HeartbeatIcon, matchPrefix: true }, + ], + }, +] + +const SETTINGS_ITEM: NavItem = { + label: 'Settings', + href: (id) => `/sites/${id}/settings`, + icon: SettingsIcon, + matchPrefix: true, +} + +function SitePicker({ + sites, + currentSiteId, + collapsed, +}: { + sites: Site[] + currentSiteId: string + collapsed: boolean +}) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const ref = useRef(null) + const pathname = usePathname() + const router = useRouter() + + const currentSite = sites.find((s) => s.id === currentSiteId) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false) + setSearch('') + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const filtered = sites.filter( + (s) => + s.name.toLowerCase().includes(search.toLowerCase()) || + s.domain.toLowerCase().includes(search.toLowerCase()) + ) + + const switchSite = (siteId: string) => { + // Preserve current page type + const currentPageType = pathname.replace(/^\/sites\/[^/]+/, '') + router.push(`/sites/${siteId}${currentPageType}`) + setOpen(false) + setSearch('') + } + + const initial = currentSite?.name?.charAt(0)?.toUpperCase() || '?' + + return ( +
+ + + {open && ( +
+
+ setSearch(e.target.value)} + className="w-full px-3 py-1.5 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-neutral-900 dark:text-white placeholder:text-neutral-400" + autoFocus + /> +
+
+ {filtered.map((site) => ( + + ))} + {filtered.length === 0 && ( +

No sites found

+ )} +
+
+ setOpen(false)} + className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg transition-colors" + > + + Add new site + +
+
+ )} +
+ ) +} + +function NavItemLink({ + item, + siteId, + collapsed, + onClick, +}: { + item: NavItem + siteId: string + collapsed: boolean + onClick?: () => void +}) { + const pathname = usePathname() + const href = item.href(siteId) + const isActive = item.matchPrefix ? pathname.startsWith(href) : pathname === href + + return ( + + + {!collapsed && {item.label}} + + ) +} + +export default function Sidebar({ + siteId, + mobileOpen, + onMobileClose, +}: { + siteId: string + mobileOpen: boolean + onMobileClose: () => void +}) { + const { user } = useAuth() + const canEdit = user?.role === 'owner' || user?.role === 'admin' + const [collapsed, setCollapsed] = useState(() => { + if (typeof window === 'undefined') return false + return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true' + }) + const [sites, setSites] = useState([]) + const pathname = usePathname() + + // Close mobile drawer on navigation + useEffect(() => { + onMobileClose() + }, [pathname, onMobileClose]) + + useEffect(() => { + listSites() + .then(setSites) + .catch(() => {}) + }, []) + + const toggleCollapsed = () => { + const next = !collapsed + setCollapsed(next) + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next)) + } + + const sidebarContent = (isMobile: boolean) => { + const isCollapsed = isMobile ? false : collapsed + + return ( +
+ {/* Site Picker */} +
+ +
+ + {/* Nav Groups */} + + + {/* Bottom: Settings + Collapse toggle */} +
+ {canEdit && ( + onMobileClose() : undefined} + /> + )} + {!isMobile && ( + + )} +
+
+ ) + } + + return ( + <> + {/* Mobile hamburger trigger — rendered in the header via leftActions */} + + {/* Desktop sidebar */} + + + {/* Mobile overlay drawer */} + {mobileOpen && ( + <> +
onMobileClose()} + /> + + + )} + + ) +} + +export function SidebarMobileToggle({ onClick }: { onClick: () => void }) { + return ( + + ) +} diff --git a/lib/sidebar-context.tsx b/lib/sidebar-context.tsx new file mode 100644 index 0000000..482c3ba --- /dev/null +++ b/lib/sidebar-context.tsx @@ -0,0 +1,31 @@ +'use client' + +import { createContext, useCallback, useContext, useState } from 'react' + +interface SidebarContextValue { + mobileOpen: boolean + openMobile: () => void + closeMobile: () => void +} + +const SidebarContext = createContext({ + mobileOpen: false, + openMobile: () => {}, + closeMobile: () => {}, +}) + +export function SidebarProvider({ children }: { children: React.ReactNode }) { + const [mobileOpen, setMobileOpen] = useState(false) + const openMobile = useCallback(() => setMobileOpen(true), []) + const closeMobile = useCallback(() => setMobileOpen(false), []) + + return ( + + {children} + + ) +} + +export function useSidebar() { + return useContext(SidebarContext) +} diff --git a/package-lock.json b/package-lock.json index c4243a9..2385d58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.15.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.2.8", + "@ciphera-net/ui": "^0.2.10", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1668,9 +1668,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.2.8", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.8/3a78342207ee2351625b9469ec6030033df183cc", - "integrity": "sha512-I6B7fE2YXjJaipmcVS60q2pzhsy/NKM4sfvHIv4awi6mcrXjGag8FznW0sI1SbsplFWpoT5iSMtWIi/lZdFhbA==", + "version": "0.2.10", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.10/aeae8c3cb25cc9b5193bfba47ce2e444ac82f1d7", + "integrity": "sha512-yWHitk43epGjtwUxGVrKwGYZb+VtJhauy7fgmqYfDC8tq33eVlH+yOdi44J/OiWDl8ONSlt8i5Xptz3k79UuXQ==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", diff --git a/package.json b/package.json index bf3e35d..635fb5f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.2.8", + "@ciphera-net/ui": "^0.2.10", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2",