'use client' import { useState, useEffect, useRef, useCallback } from 'react' import { createPortal } from 'react-dom' import { motion, AnimatePresence } from 'framer-motion' 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 { useUnifiedSettings } from '@/lib/unified-settings-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' import { logger } from '@/lib/utils/logger' import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon' import { Gauge as GaugeIcon, Plugs as PlugsIcon, Tag as TagIcon } from '@phosphor-icons/react' import { LayoutDashboardIcon, PathIcon, FunnelIcon, CursorClickIcon, SearchIcon, CloudUploadIcon, HeartbeatIcon, SettingsIcon, ChevronUpDownIcon, PlusIcon, XIcon, BookOpenIcon, 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 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, pickerOpenCallback }: { sites: Site[]; siteId: string; collapsed: boolean onExpand: () => void; onCollapse: () => void; wasCollapsed: React.MutableRefObject pickerOpenCallback: React.MutableRefObject<(() => void) | null> }) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const [faviconFailed, setFaviconFailed] = useState(false) const [faviconLoaded, setFaviconLoaded] = useState(false) const ref = useRef(null) const panelRef = useRef(null) const buttonRef = useRef(null) const [fixedPos, setFixedPos] = useState<{ left: number; top: number } | null>(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 const updatePosition = useCallback(() => { if (buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect() if (collapsed) { // Collapsed: open to the right, like AppLauncher/UserMenu/Notifications let top = rect.top if (panelRef.current) { const maxTop = window.innerHeight - panelRef.current.offsetHeight - 8 top = Math.min(top, Math.max(8, maxTop)) } setFixedPos({ left: rect.right + 8, top }) } else { // Expanded: open below the button let top = rect.bottom + 4 if (panelRef.current) { const maxTop = window.innerHeight - panelRef.current.offsetHeight - 8 top = Math.min(top, Math.max(8, maxTop)) } setFixedPos({ left: rect.left, top }) } } }, [collapsed]) useEffect(() => { const handler = (e: MouseEvent) => { const target = e.target as Node if ( ref.current && !ref.current.contains(target) && (!panelRef.current || !panelRef.current.contains(target)) ) { if (open) { setOpen(false); setSearch('') } } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [open, onCollapse, wasCollapsed]) useEffect(() => { if (open) { updatePosition() requestAnimationFrame(() => updatePosition()) } }, [open, updatePosition]) const closePicker = () => { setOpen(false); setSearch('') } const switchSite = (id: string) => { router.push(`/sites/${id}${pathname.replace(/^\/sites\/[^/]+/, '')}`) closePicker() } const filtered = sites.filter( (s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.domain.toLowerCase().includes(search.toLowerCase()) ) const dropdown = ( {open && ( setSearch(e.target.value)} onKeyDown={(e) => { if (e.key === 'Escape') closePicker() }} className="w-full px-3 py-1.5 text-sm bg-white/[0.04] border border-white/[0.08] 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-white/[0.06]' }`} > {site.name} {site.domain} ))} {filtered.length === 0 && No sites found} closePicker()} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-white/[0.06] rounded-lg"> Add new site )} ) return ( 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-white/[0.06] overflow-hidden" > {faviconUrl && !faviconFailed ? ( <> {!faviconLoaded && } setFaviconLoaded(true)} onError={() => setFaviconFailed(true)} /> > ) : ( {currentSite?.name?.charAt(0).toUpperCase() || '?'} )} {currentSite?.name || ''} {typeof document !== 'undefined' ? createPortal(dropdown, document.body) : dropdown} ) } // ─── 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?.() }} className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${ active ? 'bg-brand-orange/10 text-brand-orange' : 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5' }`} > {item.label} {collapsed && ( {item.label} )} ) } // ─── Settings Button (opens unified modal instead of navigating) ───── function SettingsButton({ item, collapsed, onClick, settingsContext = 'site', }: { item: NavItem; collapsed: boolean; onClick?: () => void; settingsContext?: 'site' | 'workspace' }) { const { openUnifiedSettings } = useUnifiedSettings() return ( { openUnifiedSettings({ context: settingsContext, tab: 'general' }) onClick?.() }} className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5 w-full cursor-pointer" > {item.label} {collapsed && ( {item.label} )} ) } // ─── Home Nav Link (static href, no siteId) ─────────────── function HomeNavLink({ href, icon: Icon, label, collapsed, onClick, external, }: { href: string; icon: React.ComponentType<{ className?: string; weight?: IconWeight }> label: string; collapsed: boolean; onClick?: () => void; external?: boolean }) { const pathname = usePathname() const active = !external && pathname === href return ( {label} {collapsed && ( {label} )} ) } // ─── Home Site Link (favicon + name) ─────────────────────── function HomeSiteLink({ site, collapsed, onClick, }: { site: Site; collapsed: boolean; onClick?: () => void }) { const pathname = usePathname() const href = `/sites/${site.id}` const active = pathname.startsWith(href) return ( {site.name} {collapsed && ( {site.name} )} ) } // ─── Sidebar Content ──────────────────────────────────────── interface SidebarContentProps { isMobile: boolean collapsed: boolean siteId: string | null sites: Site[] canEdit: boolean pendingHref: string | null onNavigate: (href: string) => void onMobileClose: () => void onExpand: () => void onCollapse: () => void onToggle: () => void wasCollapsed: React.MutableRefObject pickerOpenCallbackRef: React.MutableRefObject<(() => void) | null> auth: ReturnType orgs: OrganizationMember[] onSwitchOrganization: (orgId: string | null) => Promise openSettings: () => void openOrgSettings: () => void } function SidebarContent({ isMobile, collapsed, siteId, sites, canEdit, pendingHref, onNavigate, onMobileClose, onExpand, onCollapse, onToggle, wasCollapsed, pickerOpenCallbackRef, auth, orgs, onSwitchOrganization, openSettings, openOrgSettings, }: 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 */} {siteId && ( )} {/* Nav Groups */} {siteId ? ( {NAV_GROUPS.map((group) => ( {c ? ( ) : ( {group.label} )} {group.items.map((item) => ( ))} {group.label === 'Infrastructure' && canEdit && ( )} ))} ) : ( {/* Your Sites */} {c ? ( ) : ( Your Sites )} {sites.map((site) => ( ))} {/* Workspace */} {c ? ( ) : ( Workspace )} '', icon: SettingsIcon, matchPrefix: false }} collapsed={c} onClick={isMobile ? onMobileClose : undefined} settingsContext="workspace" /> {/* Resources */} {c ? ( ) : ( Resources )} )} {/* Bottom — utility items */} {/* Notifications, Profile — same layout as nav items */} Notifications {c && ( Notifications )} router.push('/onboarding')} allowPersonalOrganization={false} onOpenSettings={openSettings} onOpenOrgSettings={openOrgSettings} compact anchor="right" > {user?.display_name?.trim() || 'Profile'} {c && ( {user?.display_name?.trim() || 'Profile'} )} ) } // ─── Main Sidebar ─────────────────────────────────────────── export default function Sidebar({ siteId, mobileOpen, onMobileClose, onMobileOpen, }: { siteId: string | null; 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 { openUnifiedSettings } = useUnifiedSettings() const [sites, setSites] = useState([]) const [orgs, setOrgs] = useState([]) const [pendingHref, setPendingHref] = useState(null) const [mobileClosing, setMobileClosing] = useState(false) const wasCollapsedRef = useRef(false) const pickerOpenCallbackRef = useRef<(() => void) | null>(null) const { collapsed, toggle, expand, collapse } = useSidebar() 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) await auth.refresh() router.push('/') } catch (err) { logger.error('Failed to switch organization', err) } } useEffect(() => { setPendingHref(null); onMobileClose() }, [pathname, onMobileClose]) const handleMobileClose = useCallback(() => { setMobileClosing(true) setTimeout(() => { setMobileClosing(false) onMobileClose() }, 200) }, [onMobileClose]) const handleNavigate = useCallback((href: string) => { setPendingHref(href) }, []) return ( <> {/* Desktop — ssr:false means this only renders on client, no hydration flash */} {/* Mobile overlay */} {(mobileOpen || mobileClosing) && ( <> > )} > ) }
No sites found
{group.label}
Your Sites
Workspace
Resources