'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 } from '@phosphor-icons/react' import { LayoutDashboardIcon, PathIcon, FunnelIcon, CursorClickIcon, SearchIcon, CloudUploadIcon, HeartbeatIcon, SettingsIcon, 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 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) => ( ))} {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 (
{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' }`} > {collapsed && ( {item.label} )}
) } // ─── Settings Button (opens unified modal instead of navigating) ───── function SettingsButton({ item, collapsed, onClick, }: { item: NavItem; collapsed: boolean; onClick?: () => void }) { const { openUnifiedSettings } = useUnifiedSettings() return (
{collapsed && ( {item.label} )}
) } // ─── 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) */}
{/* Logo — fixed layout, text fades */} Pulse Pulse {/* Site Picker */} {siteId && ( )} {/* Nav Groups */} {siteId ? ( ) : (
)} {/* Bottom — utility items */}
{/* Notifications, Profile — same layout as nav items */}
{c && ( Notifications )}
router.push('/onboarding')} allowPersonalOrganization={false} onOpenSettings={openSettings} onOpenOrgSettings={openOrgSettings} compact anchor="right" > {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) && ( <>
)} ) }