From 102551b1ce6382e25d5add89e8bf0456d28edf8b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 24 Mar 2026 22:57:41 +0100 Subject: [PATCH] feat: content header with collapse toggle + realtime indicator - New SidebarProvider context for shared collapse state - ContentHeader visible on desktop: collapse icon left, "Live" right - Collapse button removed from sidebar bottom (moved to header) - Keyboard shortcut [ handled by context, not sidebar - Realtime indicator polls every 5s, ticks every 1s for freshness --- components/dashboard/ContentHeader.tsx | 64 +++++++++++++++++++---- components/dashboard/DashboardShell.tsx | 32 ++++++------ components/dashboard/Sidebar.tsx | 63 ++--------------------- lib/sidebar-context.tsx | 68 +++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 83 deletions(-) create mode 100644 lib/sidebar-context.tsx diff --git a/components/dashboard/ContentHeader.tsx b/components/dashboard/ContentHeader.tsx index 417fc13..91732f4 100644 --- a/components/dashboard/ContentHeader.tsx +++ b/components/dashboard/ContentHeader.tsx @@ -1,21 +1,67 @@ 'use client' -import { MenuIcon } from '@ciphera-net/ui' +import { useState, useEffect, useRef } from 'react' +import { MenuIcon, CollapseLeftIcon, formatUpdatedAgo } from '@ciphera-net/ui' +import { useSidebar } from '@/lib/sidebar-context' +import { useRealtime } from '@/lib/swr/dashboard' export default function ContentHeader({ + siteId, onMobileMenuOpen, }: { + siteId: string onMobileMenuOpen: () => void }) { + const { collapsed, toggle } = useSidebar() + const { data: realtime } = useRealtime(siteId) + const lastUpdatedRef = useRef(null) + const [, setTick] = useState(0) + + // Track when realtime data last changed + useEffect(() => { + if (realtime) lastUpdatedRef.current = Date.now() + }, [realtime]) + + // Tick every second to keep "X seconds ago" fresh + useEffect(() => { + if (lastUpdatedRef.current == null) return + const timer = setInterval(() => setTick((t) => t + 1), 1000) + return () => clearInterval(timer) + }, [realtime]) + return ( -
- +
+ {/* Left — mobile hamburger or desktop collapse toggle */} +
+ {/* Mobile hamburger */} + + + {/* Desktop collapse toggle */} + +
+ + {/* Right — realtime indicator */} + {lastUpdatedRef.current != null && ( +
+ + + + + Live · {formatUpdatedAgo(lastUpdatedRef.current)} +
+ )}
) } diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index 58e620f..78ebcc3 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -2,13 +2,12 @@ import { useState, useCallback } from 'react' import dynamic from 'next/dynamic' +import { SidebarProvider } from '@/lib/sidebar-context' import ContentHeader from './ContentHeader' // Load sidebar only on the client — prevents SSR flash const Sidebar = dynamic(() => import('./Sidebar'), { ssr: false, - // Placeholder reserves the sidebar's space in the server HTML - // so page content never occupies the sidebar zone loading: () => (
setMobileOpen(true), []) return ( -
- - {/* Content panel — rounded corners, inset from edges. The left border doubles as the sidebar's right edge. */} -
- -
- {children} -
+ +
+ +
+ +
+ {children} +
+
-
+ ) } diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 5149d97..8a265f0 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -8,6 +8,7 @@ import { usePathname, useRouter } from 'next/navigation' import { listSites, type Site } from '@/lib/api/sites' import { useAuth } from '@/lib/auth/context' import { useSettingsModal } from '@/lib/settings-modal-context' +import { useSidebar } from '@/lib/sidebar-context' // `,` shortcut handled globally by UnifiedSettingsModal import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization' import { setSessionAction } from '@/app/actions/auth' @@ -23,8 +24,6 @@ import { CloudUploadIcon, HeartbeatIcon, SettingsIcon, - CollapseLeftIcon, - CollapseRightIcon, ChevronUpDownIcon, PlusIcon, XIcon, @@ -61,7 +60,6 @@ const CIPHERA_APPS: CipheraApp[] = [ }, ] -const SIDEBAR_KEY = 'pulse_sidebar_collapsed' const EXPANDED = 256 const COLLAPSED = 64 @@ -342,7 +340,6 @@ interface SidebarContentProps { onMobileClose: () => void onExpand: () => void onCollapse: () => void - onToggle: () => void wasCollapsed: React.MutableRefObject pickerOpenCallbackRef: React.MutableRefObject<(() => void) | null> auth: ReturnType @@ -353,7 +350,7 @@ interface SidebarContentProps { function SidebarContent({ isMobile, collapsed, siteId, sites, canEdit, pendingHref, - onNavigate, onMobileClose, onExpand, onCollapse, onToggle, + onNavigate, onMobileClose, onExpand, onCollapse, wasCollapsed, pickerOpenCallbackRef, auth, orgs, onSwitchOrganization, openSettings, }: SidebarContentProps) { const router = useRouter() @@ -446,28 +443,6 @@ function SidebarContent({ )}
- - {/* Settings + Collapse */} -
- {!isMobile && ( -
- - {c && ( - - Expand (press [) - - )} -
- )} -
) @@ -492,10 +467,7 @@ export default function Sidebar({ const [mobileClosing, setMobileClosing] = useState(false) const wasCollapsedRef = useRef(false) const pickerOpenCallbackRef = useRef<(() => void) | null>(null) - // Safe to read localStorage directly — this component is loaded with ssr:false - const [collapsed, setCollapsed] = useState(() => { - return localStorage.getItem(SIDEBAR_KEY) !== 'false' - }) + const { collapsed, toggle, expand, collapse } = useSidebar() useEffect(() => { listSites().then(setSites).catch(() => {}) }, []) useEffect(() => { @@ -519,31 +491,6 @@ export default function Sidebar({ } useEffect(() => { setPendingHref(null); onMobileClose() }, [pathname, onMobileClose]) - useEffect(() => { - const handler = (e: KeyboardEvent) => { - const tag = (e.target as HTMLElement)?.tagName - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return - if (e.key === '[' && !e.metaKey && !e.ctrlKey && !e.altKey) { - e.preventDefault(); toggle() - } - // `,` shortcut is handled globally by UnifiedSettingsModal — not here - } - document.addEventListener('keydown', handler) - return () => document.removeEventListener('keydown', handler) - }, [collapsed]) - - const toggle = useCallback(() => { - setCollapsed((prev) => { const next = !prev; localStorage.setItem(SIDEBAR_KEY, String(next)); return next }) - }, []) - - const expand = useCallback(() => { - setCollapsed(false); localStorage.setItem(SIDEBAR_KEY, 'false') - }, []) - - const collapse = useCallback(() => { - setCollapsed(true); localStorage.setItem(SIDEBAR_KEY, 'true') - }, []) - const handleMobileClose = useCallback(() => { setMobileClosing(true) setTimeout(() => { @@ -578,7 +525,7 @@ export default function Sidebar({ onMobileClose={onMobileClose} onExpand={expand} onCollapse={collapse} - onToggle={toggle} + wasCollapsed={wasCollapsedRef} pickerOpenCallbackRef={pickerOpenCallbackRef} auth={auth} @@ -621,7 +568,7 @@ export default function Sidebar({ onMobileClose={handleMobileClose} onExpand={expand} onCollapse={collapse} - onToggle={toggle} + wasCollapsed={wasCollapsedRef} pickerOpenCallbackRef={pickerOpenCallbackRef} auth={auth} diff --git a/lib/sidebar-context.tsx b/lib/sidebar-context.tsx new file mode 100644 index 0000000..6691ac9 --- /dev/null +++ b/lib/sidebar-context.tsx @@ -0,0 +1,68 @@ +'use client' + +import { createContext, useContext, useState, useCallback, useEffect } from 'react' + +const SIDEBAR_KEY = 'pulse_sidebar_collapsed' + +interface SidebarState { + collapsed: boolean + toggle: () => void + expand: () => void + collapse: () => void +} + +const SidebarContext = createContext({ + collapsed: true, + toggle: () => {}, + expand: () => {}, + collapse: () => {}, +}) + +export function SidebarProvider({ children }: { children: React.ReactNode }) { + const [collapsed, setCollapsed] = useState(() => { + if (typeof window === 'undefined') return true + return localStorage.getItem(SIDEBAR_KEY) !== 'false' + }) + + const toggle = useCallback(() => { + setCollapsed((prev) => { + const next = !prev + localStorage.setItem(SIDEBAR_KEY, String(next)) + return next + }) + }, []) + + const expand = useCallback(() => { + setCollapsed(false) + localStorage.setItem(SIDEBAR_KEY, 'false') + }, []) + + const collapse = useCallback(() => { + setCollapsed(true) + localStorage.setItem(SIDEBAR_KEY, 'true') + }, []) + + // Keyboard shortcut: [ to toggle + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return + if (e.key === '[' && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault() + toggle() + } + } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [toggle]) + + return ( + + {children} + + ) +} + +export function useSidebar() { + return useContext(SidebarContext) +}