diff --git a/components/dashboard/UtilityBar.tsx b/components/dashboard/ContentHeader.tsx similarity index 70% rename from components/dashboard/UtilityBar.tsx rename to components/dashboard/ContentHeader.tsx index d304451..26ba486 100644 --- a/components/dashboard/UtilityBar.tsx +++ b/components/dashboard/ContentHeader.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' -import { ThemeToggle, AppLauncher, UserMenu, type CipheraApp } from '@ciphera-net/ui' +import { ThemeToggle, AppLauncher, UserMenu, type CipheraApp, MenuIcon } from '@ciphera-net/ui' import { useAuth } from '@/lib/auth/context' import { useSettingsModal } from '@/lib/settings-modal-context' import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization' @@ -38,7 +38,11 @@ const CIPHERA_APPS: CipheraApp[] = [ }, ] -export default function UtilityBar() { +export default function ContentHeader({ + onMobileMenuOpen, +}: { + onMobileMenuOpen: () => void +}) { const auth = useAuth() const router = useRouter() const { openSettings } = useSettingsModal() @@ -48,7 +52,7 @@ export default function UtilityBar() { if (auth.user) { getUserOrganizations() .then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : [])) - .catch(err => logger.error('Failed to fetch orgs for utility bar', err)) + .catch(err => logger.error('Failed to fetch orgs', err)) } }, [auth.user]) @@ -65,33 +69,22 @@ export default function UtilityBar() { } return ( -
- {/* Left: Pulse logo */} +
+ {/* Left: mobile hamburger */}
- -
- Pulse Logo -
- - Pulse - - + +
{/* Right: actions */}
- + setMobileOpen(false), []) + const openMobile = useCallback(() => setMobileOpen(true), []) + return ( -
- -
- -
+
+ +
+ +
{children}
diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index cf6bed2..84acc61 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -1,17 +1,10 @@ 'use client' -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' -import { motion } from 'framer-motion' import { listSites, type Site } from '@/lib/api/sites' import { useAuth } from '@/lib/auth/context' -import { - Sidebar as SidebarPrimitive, - SidebarBody, - SidebarLink, - useSidebar, -} from '@/components/ui/sidebar' import { LayoutDashboardIcon, PathIcon, @@ -21,14 +14,22 @@ import { CloudUploadIcon, HeartbeatIcon, SettingsIcon, + CollapseLeftIcon, + CollapseRightIcon, ChevronUpDownIcon, PlusIcon, + XIcon, + MenuIcon, } from '@ciphera-net/ui' +const SIDEBAR_KEY = 'pulse_sidebar_collapsed' + +type IconWeight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' + interface NavItem { label: string href: (siteId: string) => string - icon: React.ComponentType<{ className?: string; weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' }> + icon: React.ComponentType<{ className?: string; weight?: IconWeight }> matchPrefix?: boolean } @@ -64,61 +65,72 @@ const SETTINGS_ITEM: NavItem = { matchPrefix: true, } -function SitePicker({ sites, siteId }: { sites: Site[]; siteId: string }) { - const { open: sidebarOpen } = useSidebar() +// ─── Site Picker ──────────────────────────────────────────── + +function SitePicker({ + sites, + siteId, + collapsed, +}: { + sites: Site[] + siteId: 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 === siteId) + const initial = currentSite?.name?.charAt(0)?.toUpperCase() || '?' useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { + const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false) setSearch('') } } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) }, []) + const switchSite = (id: string) => { + router.push(`/sites/${id}${pathname.replace(/^\/sites\/[^/]+/, '')}`) + setOpen(false) + setSearch('') + } + const filtered = sites.filter( (s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.domain.toLowerCase().includes(search.toLowerCase()) ) - const switchSite = (id: string) => { - const currentPageType = pathname.replace(/^\/sites\/[^/]+/, '') - router.push(`/sites/${id}${currentPageType}`) - setOpen(false) - setSearch('') - } - - const initial = currentSite?.name?.charAt(0)?.toUpperCase() || '?' - return ( -
+
{open && ( -
+
void +}) { const pathname = usePathname() + const href = item.href(siteId) + const active = item.matchPrefix ? pathname.startsWith(href) : pathname === href + + return ( + + + + {item.label} + + + ) +} + +// ─── Main Sidebar ─────────────────────────────────────────── + +export default function Sidebar({ + siteId, + mobileOpen, + onMobileClose, + onMobileOpen, +}: { + siteId: string + mobileOpen: boolean + onMobileClose: () => void + onMobileOpen: () => void +}) { + const { user } = useAuth() const canEdit = user?.role === 'owner' || user?.role === 'admin' + const pathname = usePathname() const [sites, setSites] = useState([]) + const [collapsed, setCollapsed] = useState(() => { + if (typeof window === 'undefined') return false + return localStorage.getItem(SIDEBAR_KEY) === 'true' + }) useEffect(() => { listSites().then(setSites).catch(() => {}) }, []) - const isActive = (item: NavItem) => { - const href = item.href(siteId) - return item.matchPrefix ? pathname.startsWith(href) : pathname === href + // Close mobile on navigation + useEffect(() => { + onMobileClose() + }, [pathname, onMobileClose]) + + // Keyboard shortcut: [ to toggle + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === '[' && !e.metaKey && !e.ctrlKey && !e.altKey) { + const tag = (e.target as HTMLElement)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return + e.preventDefault() + toggle() + } + } + 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 sidebarContent = (isMobile: boolean) => { + const isCollapsed = isMobile ? false : collapsed + + return ( +
+ {/* Logo */} + + Pulse + + Pulse + + + + {/* Site Picker */} + + + {/* Nav Groups */} + + + {/* Bottom */} +
+ {canEdit && ( + + )} + {!isMobile && ( + + )} +
+
+ ) } return ( <> -
- {/* Site Picker */} - + {/* Desktop */} + - {/* Nav Groups */} - {NAV_GROUPS.map((group) => ( -
- {open && ( - - {group.label} - - )} -
- {group.items.map((item) => ( - - ), - }} - className={ - isActive(item) - ? 'bg-brand-orange/10 text-brand-orange rounded-lg px-1' - : 'rounded-lg px-1' - } - /> - ))} -
-
- ))} -
- - {/* Bottom: Settings */} -
- {canEdit && ( - - ), - }} - className={ - isActive(SETTINGS_ITEM) - ? 'bg-brand-orange/10 text-brand-orange rounded-lg px-1' - : 'rounded-lg px-1' - } + {/* Mobile overlay */} + {mobileOpen && ( + <> +
- )} -
+ + + )} ) } - -export default function PulseSidebar({ siteId }: { siteId: string }) { - const [open, setOpen] = useState(false) - - return ( - - - - - - ) -} diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx deleted file mode 100644 index e7ab5dd..0000000 --- a/components/ui/sidebar.tsx +++ /dev/null @@ -1,191 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import Link, { LinkProps } from "next/link"; -import React, { useState, createContext, useContext } from "react"; -import { AnimatePresence, motion } from "framer-motion"; -import { List as Menu, X } from "@phosphor-icons/react"; - -interface Links { - label: string; - href: string; - icon: React.JSX.Element | React.ReactNode; -} - -interface SidebarContextProps { - open: boolean; - setOpen: React.Dispatch>; - animate: boolean; -} - -const SidebarContext = createContext( - undefined -); - -export const useSidebar = () => { - const context = useContext(SidebarContext); - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider"); - } - return context; -}; - -export const SidebarProvider = ({ - children, - open: openProp, - setOpen: setOpenProp, - animate = true, -}: { - children: React.ReactNode; - open?: boolean; - setOpen?: React.Dispatch>; - animate?: boolean; -}) => { - const [openState, setOpenState] = useState(false); - - const open = openProp !== undefined ? openProp : openState; - const setOpen = setOpenProp !== undefined ? setOpenProp : setOpenState; - - return ( - - {children} - - ); -}; - -export const Sidebar = ({ - children, - open, - setOpen, - animate, -}: { - children: React.ReactNode; - open?: boolean; - setOpen?: React.Dispatch>; - animate?: boolean; -}) => { - return ( - - {children} - - ); -}; - -export const SidebarBody = (props: React.ComponentProps) => { - return ( - <> - - )} /> - - ); -}; - -export const DesktopSidebar = ({ - className, - children, - ...props -}: React.ComponentProps) => { - const { open, setOpen, animate } = useSidebar(); - return ( - - ); -}; - -export const MobileSidebar = ({ - className, - children, - ...props -}: React.ComponentProps<"div">) => { - const { open, setOpen } = useSidebar(); - return ( - <> -
-
- setOpen(!open)} - /> -
- - {open && ( - -
setOpen(!open)} - > - -
- {children} -
- )} -
-
- - ); -}; - -export const SidebarLink = ({ - link, - className, - active, - ...props -}: { - link: Links; - className?: string; - active?: boolean; - props?: LinkProps; -}) => { - const { open, animate } = useSidebar(); - return ( - - {link.icon} - - {link.label} - - - ); -}; diff --git a/lib/sidebar-context.tsx b/lib/sidebar-context.tsx deleted file mode 100644 index 482c3ba..0000000 --- a/lib/sidebar-context.tsx +++ /dev/null @@ -1,31 +0,0 @@ -'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) -}