diff --git a/app/layout-content.tsx b/app/layout-content.tsx index 47d8698..b40ab9b 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, MenuIcon } from '@ciphera-net/ui' +import { Header, type CipheraApp } from '@ciphera-net/ui' import NotificationCenter from '@/components/notifications/NotificationCenter' import { useAuth } from '@/lib/auth/context' import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus' @@ -16,7 +16,6 @@ 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 } from '@/lib/sidebar-context' const ORG_SWITCH_KEY = 'pulse_switching_org' @@ -180,9 +179,7 @@ function LayoutInner({ children }: { children: React.ReactNode }) { export default function LayoutContent({ children }: { children: React.ReactNode }) { return ( - - {children} - + {children} ) } diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index 3914598..a50b048 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -1,8 +1,7 @@ 'use client' -import Sidebar from './Sidebar' +import PulseSidebar from './Sidebar' import UtilityBar from './UtilityBar' -import { useSidebar } from '@/lib/sidebar-context' export default function DashboardShell({ siteId, @@ -11,15 +10,11 @@ export default function DashboardShell({ siteId: string children: React.ReactNode }) { - const { mobileOpen, closeMobile } = useSidebar() - return (
- {/* Top bar: full width — logo left, actions right */} - {/* Below: sidebar + content */}
- +
{children}
diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index c4cd017..cf6bed2 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -3,8 +3,15 @@ import { useState, useEffect, useRef } 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, @@ -14,16 +21,10 @@ import { 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 @@ -63,22 +64,15 @@ const SETTINGS_ITEM: NavItem = { matchPrefix: true, } -function SitePicker({ - sites, - currentSiteId, - collapsed, -}: { - sites: Site[] - currentSiteId: string - collapsed: boolean -}) { +function SitePicker({ sites, siteId }: { sites: Site[]; siteId: string }) { + const { open: sidebarOpen } = useSidebar() 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) + const currentSite = sites.find((s) => s.id === siteId) useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -97,10 +91,9 @@ function SitePicker({ s.domain.toLowerCase().includes(search.toLowerCase()) ) - const switchSite = (siteId: string) => { - // Preserve current page type + const switchSite = (id: string) => { const currentPageType = pathname.replace(/^\/sites\/[^/]+/, '') - router.push(`/sites/${siteId}${currentPageType}`) + router.push(`/sites/${id}${currentPageType}`) setOpen(false) setSearch('') } @@ -108,18 +101,15 @@ function SitePicker({ const initial = currentSite?.name?.charAt(0)?.toUpperCase() || '?' return ( -
+
{open && ( -
+
switchSite(site.id)} - className={`w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors text-left ${ - site.id === currentSiteId + className={`w-full flex items-center gap-2.5 px-4 py-2 text-sm text-left ${ + site.id === siteId ? 'bg-brand-orange/10 text-brand-orange font-medium' : 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800' }`} @@ -167,7 +157,7 @@ function SitePicker({ 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" + 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" > Add new site @@ -179,186 +169,110 @@ function SitePicker({ ) } -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 -}) { +function SidebarContent({ siteId }: { siteId: string }) { 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 { open } = useSidebar() const pathname = usePathname() - - // Close mobile drawer on navigation - useEffect(() => { - onMobileClose() - }, [pathname, onMobileClose]) + const canEdit = user?.role === 'owner' || user?.role === 'admin' + const [sites, setSites] = useState([]) useEffect(() => { - listSites() - .then(setSites) - .catch(() => {}) + 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 && ( - - )} -
-
- ) + const isActive = (item: NavItem) => { + const href = item.href(siteId) + return item.matchPrefix ? pathname.startsWith(href) : pathname === href } return ( <> - {/* Mobile hamburger trigger — rendered in the header via leftActions */} +
+ {/* Site Picker */} + - {/* Desktop sidebar */} - - - {/* Mobile overlay drawer */} - {mobileOpen && ( - <> -
onMobileClose()} - /> - - - )} +
+ ))} +
+ + {/* Bottom: Settings */} +
+ {canEdit && ( + + ), + }} + className={ + isActive(SETTINGS_ITEM) + ? 'bg-brand-orange/10 text-brand-orange rounded-lg px-1' + : 'rounded-lg px-1' + } + /> + )} +
) } -export function SidebarMobileToggle({ onClick }: { onClick: () => void }) { +export default function PulseSidebar({ siteId }: { siteId: string }) { + const [open, setOpen] = useState(false) + return ( - + + + + + ) } diff --git a/components/dashboard/UtilityBar.tsx b/components/dashboard/UtilityBar.tsx index 36359f9..d304451 100644 --- a/components/dashboard/UtilityBar.tsx +++ b/components/dashboard/UtilityBar.tsx @@ -3,13 +3,12 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' -import { ThemeToggle, AppLauncher, UserMenu, type CipheraApp, MenuIcon } from '@ciphera-net/ui' +import { ThemeToggle, AppLauncher, UserMenu, type CipheraApp } 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' import { setSessionAction } from '@/app/actions/auth' import { logger } from '@/lib/utils/logger' -import { useSidebar } from '@/lib/sidebar-context' import NotificationCenter from '@/components/notifications/NotificationCenter' const CIPHERA_APPS: CipheraApp[] = [ @@ -43,7 +42,6 @@ export default function UtilityBar() { const auth = useAuth() const router = useRouter() const { openSettings } = useSettingsModal() - const { openMobile } = useSidebar() const [orgs, setOrgs] = useState([]) useEffect(() => { @@ -68,15 +66,8 @@ export default function UtilityBar() { return (
- {/* Left: Pulse logo + mobile toggle */} + {/* Left: Pulse logo */}
- >; + 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/package-lock.json b/package-lock.json index 2385d58..778b994 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@tanstack/react-virtual": "^3.13.21", "@types/d3": "^7.4.3", "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "cobe": "^0.6.5", "country-flag-icons": "^1.6.4", "d3": "^7.9.0", @@ -36,6 +37,7 @@ "sonner": "^2.0.7", "svg-dotted-map": "^2.0.1", "swr": "^2.3.3", + "tailwind-merge": "^3.5.0", "xlsx": "^0.18.5" }, "devDependencies": { @@ -1684,6 +1686,16 @@ "react-dom": ">=18" } }, + "node_modules/@ciphera-net/ui/node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -14352,9 +14364,9 @@ "license": "MIT" }, "node_modules/tailwind-merge": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", - "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", "license": "MIT", "funding": { "type": "github", diff --git a/package.json b/package.json index 635fb5f..4b352b5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@tanstack/react-virtual": "^3.13.21", "@types/d3": "^7.4.3", "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "cobe": "^0.6.5", "country-flag-icons": "^1.6.4", "d3": "^7.9.0", @@ -40,6 +41,7 @@ "sonner": "^2.0.7", "svg-dotted-map": "^2.0.1", "swr": "^2.3.3", + "tailwind-merge": "^3.5.0", "xlsx": "^0.18.5" }, "overrides": {