'use client' import { useState, useEffect, useRef, useCallback } 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 { 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 { FAVICON_SERVICE_URL } from '@/lib/utils/favicon' import { Gauge as GaugeIcon } from '@phosphor-icons/react' import { LayoutDashboardIcon, PathIcon, FunnelIcon, CursorClickIcon, SearchIcon, CloudUploadIcon, HeartbeatIcon, SettingsIcon, CollapseLeftIcon, CollapseRightIcon, ChevronUpDownIcon, PlusIcon, XIcon, AppLauncher, UserMenu, type CipheraApp, } from '@ciphera-net/ui' import NotificationCenter from '@/components/notifications/NotificationCenter' const CIPHERA_APPS: CipheraApp[] = [ { id: 'pulse', name: 'Pulse', description: 'Your current app — Privacy-first analytics', icon: 'https://ciphera.net/pulse_icon_no_margins.png', href: 'https://pulse.ciphera.net', isAvailable: false, }, { id: 'drop', name: 'Drop', description: 'Secure file sharing', icon: 'https://ciphera.net/drop_icon_no_margins.png', href: 'https://drop.ciphera.net', isAvailable: true, }, { id: 'auth', name: 'Auth', description: 'Your Ciphera account settings', icon: 'https://ciphera.net/auth_icon_no_margins.png', href: 'https://auth.ciphera.net', isAvailable: true, }, ] const SIDEBAR_KEY = 'pulse_sidebar_collapsed' const EXPANDED = 256 const COLLAPSED = 64 type IconWeight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' interface NavItem { label: string href: (siteId: string) => string icon: React.ComponentType<{ className?: string; weight?: IconWeight }> 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 }, { label: 'PageSpeed', href: (id) => `/sites/${id}/pagespeed`, icon: GaugeIcon, matchPrefix: true }, ], }, ] const SETTINGS_ITEM: NavItem = { label: 'Site Settings', href: (id) => `/sites/${id}/settings`, icon: SettingsIcon, matchPrefix: true, } // Label that fades with the sidebar — always in the DOM, never removed function Label({ children, collapsed }: { children: React.ReactNode; collapsed: boolean }) { return ( {children} ) } // ─── Site Picker ──────────────────────────────────────────── function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollapsed }: { sites: Site[]; siteId: string; collapsed: boolean onExpand: () => void; onCollapse: () => void; wasCollapsed: React.MutableRefObject }) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const [faviconFailed, setFaviconFailed] = useState(false) const [faviconLoaded, setFaviconLoaded] = useState(false) const ref = useRef(null) const pathname = usePathname() const router = useRouter() const currentSite = sites.find((s) => s.id === siteId) const faviconUrl = currentSite?.domain ? `${FAVICON_SERVICE_URL}?domain=${currentSite.domain}&sz=64` : null useEffect(() => { const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { if (open) { setOpen(false); setSearch('') // Re-collapse if we auto-expanded if (wasCollapsed.current) { onCollapse(); wasCollapsed.current = false } } } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [open, onCollapse, wasCollapsed]) const switchSite = (id: string) => { router.push(`/sites/${id}${pathname.replace(/^\/sites\/[^/]+/, '')}`) setOpen(false); setSearch('') // Re-collapse if we auto-expanded if (wasCollapsed.current) { onCollapse(); wasCollapsed.current = false } } const filtered = sites.filter( (s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.domain.toLowerCase().includes(search.toLowerCase()) ) return ( { if (collapsed) { wasCollapsed.current = true onExpand() // Open picker after sidebar expands setTimeout(() => setOpen(true), 220) } else { setOpen(!open) } }} className="w-full flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-200 hover:bg-neutral-800 overflow-hidden" > {faviconUrl && !faviconFailed ? ( <> {!faviconLoaded && } setFaviconLoaded(true)} onError={() => setFaviconFailed(true)} /> > ) : null} {currentSite?.name || ''} {open && ( setSearch(e.target.value)} className="w-full px-3 py-1.5 text-sm bg-neutral-800 border border-neutral-700 rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-white placeholder:text-neutral-400" autoFocus /> {filtered.map((site) => ( switchSite(site.id)} 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-300 hover:bg-neutral-800' }`} > {site.name} {site.domain} ))} {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-800 rounded-lg"> Add new site )} ) } // ─── Nav Item ─────────────────────────────────────────────── function NavLink({ item, siteId, collapsed, onClick, pendingHref, onNavigate, }: { item: NavItem; siteId: string; collapsed: boolean; onClick?: () => void pendingHref: string | null; onNavigate: (href: string) => void }) { const pathname = usePathname() const href = item.href(siteId) const matchesPathname = item.matchPrefix ? pathname.startsWith(href) : pathname === href const matchesPending = pendingHref !== null && (item.matchPrefix ? pendingHref.startsWith(href) : pendingHref === href) const active = matchesPathname || matchesPending return ( { onNavigate(href); onClick?.() }} title={collapsed ? item.label : undefined} className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden ${ active ? 'bg-brand-orange/10 text-brand-orange' : 'text-neutral-400 hover:text-white hover:bg-neutral-800' }`} > {item.label} ) } // ─── Sidebar Content ──────────────────────────────────────── interface SidebarContentProps { isMobile: boolean collapsed: boolean siteId: string sites: Site[] canEdit: boolean pendingHref: string | null onNavigate: (href: string) => void onMobileClose: () => void onExpand: () => void onCollapse: () => void onToggle: () => void wasCollapsed: React.MutableRefObject auth: ReturnType orgs: OrganizationMember[] onSwitchOrganization: (orgId: string | null) => Promise openSettings: () => void } function SidebarContent({ isMobile, collapsed, siteId, sites, canEdit, pendingHref, onNavigate, onMobileClose, onExpand, onCollapse, onToggle, wasCollapsed, auth, orgs, onSwitchOrganization, openSettings, }: SidebarContentProps) { const router = useRouter() const c = isMobile ? false : collapsed const { user } = auth return ( {/* App Switcher — top of sidebar (scope-level switch) */} Ciphera {/* Logo — fixed layout, text fades */} Pulse {/* Site Picker */} {/* Nav Groups */} {NAV_GROUPS.map((group) => ( {group.label} {group.items.map((item) => ( ))} {group.label === 'Infrastructure' && canEdit && ( )} ))} {/* Bottom — utility items */} {/* Notifications, Profile — same layout as nav items */} Notifications router.push('/onboarding')} allowPersonalOrganization={false} onOpenSettings={openSettings} compact anchor="right" > {user?.display_name?.trim() || 'Profile'} {/* Settings + Collapse */} {!isMobile && ( Collapse )} ) } // ─── Main Sidebar ─────────────────────────────────────────── export default function Sidebar({ siteId, mobileOpen, onMobileClose, onMobileOpen, }: { siteId: string; mobileOpen: boolean; onMobileClose: () => void; onMobileOpen: () => void }) { const auth = useAuth() const { user } = auth const canEdit = user?.role === 'owner' || user?.role === 'admin' const pathname = usePathname() const router = useRouter() const { openSettings } = useSettingsModal() const [sites, setSites] = useState([]) const [orgs, setOrgs] = useState([]) const [pendingHref, setPendingHref] = useState(null) const wasCollapsedRef = useRef(false) // Safe to read localStorage directly — this component is loaded with ssr:false const [collapsed, setCollapsed] = useState(() => { return localStorage.getItem(SIDEBAR_KEY) !== 'false' }) useEffect(() => { listSites().then(setSites).catch(() => {}) }, []) useEffect(() => { if (user) { getUserOrganizations() .then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : [])) .catch(err => logger.error('Failed to fetch orgs', err)) } }, [user]) const handleSwitchOrganization = async (orgId: string | null) => { if (!orgId) return try { const { access_token } = await switchContext(orgId) await setSessionAction(access_token) sessionStorage.setItem('pulse_switching_org', 'true') window.location.reload() } catch (err) { logger.error('Failed to switch organization', err) } } useEffect(() => { setPendingHref(null); onMobileClose() }, [pathname, onMobileClose]) 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 expand = useCallback(() => { setCollapsed(false); localStorage.setItem(SIDEBAR_KEY, 'false') }, []) const collapse = useCallback(() => { setCollapsed(true); localStorage.setItem(SIDEBAR_KEY, 'true') }, []) const handleNavigate = useCallback((href: string) => { setPendingHref(href) }, []) return ( <> {/* Desktop — ssr:false means this only renders on client, no hydration flash */} {/* Mobile overlay */} {mobileOpen && ( <> > )} > ) }
No sites found
{group.label}