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