+
+
+
+
{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
+
+
+
+ {/* 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 (
- setOpen(true)}
- onMouseLeave={() => setOpen(false)}
- {...props}
- >
- {children}
-
- );
-};
-
-export const MobileSidebar = ({
- className,
- children,
- ...props
-}: React.ComponentProps<"div">) => {
- const { open, setOpen } = useSidebar();
- return (
- <>
-
-
-
-
- {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)
-}