diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index 5b97278..4369ef8 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -1,10 +1,12 @@ 'use client' import { useState, useCallback, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { motion, AnimatePresence } from 'framer-motion' import dynamic from 'next/dynamic' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' -import { formatUpdatedAgo } from '@ciphera-net/ui' +import { formatUpdatedAgo, PlusIcon } from '@ciphera-net/ui' import { CaretDown, CaretRight, SidebarSimple } from '@phosphor-icons/react' import { SidebarProvider, useSidebar } from '@/lib/sidebar-context' import { useRealtime } from '@/lib/swr/dashboard' @@ -60,8 +62,12 @@ const Sidebar = dynamic(() => import('./Sidebar'), { function BreadcrumbSitePicker({ currentSiteId, currentSiteName }: { currentSiteId: string; currentSiteName: string }) { const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') const [sites, setSites] = useState([]) 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() @@ -71,37 +77,79 @@ function BreadcrumbSitePicker({ currentSiteId, currentSiteName }: { currentSiteI } }, [open, sites.length]) + const updatePosition = useCallback(() => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect() + 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 }) + } + }, []) + useEffect(() => { const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + 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]) + + useEffect(() => { + if (open) { + updatePosition() + requestAnimationFrame(() => updatePosition()) + } + }, [open, updatePosition]) + + const closePicker = () => { setOpen(false); setSearch('') } const switchSite = (id: string) => { - // Navigate to same section on the new site router.push(`/sites/${id}${pathname.replace(/^\/sites\/[^/]+/, '')}`) - setOpen(false) + closePicker() } - return ( -
- + 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 + /> +
- {sites.map((site) => ( + {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}
) } diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 70662ba..5214f48 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -1,8 +1,6 @@ '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' @@ -24,7 +22,6 @@ import { CloudUploadIcon, HeartbeatIcon, SettingsIcon, - ChevronUpDownIcon, PlusIcon, XIcon, BookOpenIcon, @@ -112,183 +109,6 @@ function Label({ children, collapsed }: { children: React.ReactNode; collapsed: ) } -// ─── 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({ @@ -448,11 +268,7 @@ interface SidebarContentProps { 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 @@ -462,8 +278,8 @@ interface SidebarContentProps { function SidebarContent({ isMobile, collapsed, siteId, sites, canEdit, pendingHref, - onNavigate, onMobileClose, onExpand, onCollapse, onToggle, - wasCollapsed, pickerOpenCallbackRef, auth, orgs, onSwitchOrganization, openSettings, openOrgSettings, + onNavigate, onMobileClose, onToggle, + auth, orgs, onSwitchOrganization, openSettings, openOrgSettings, }: SidebarContentProps) { const router = useRouter() const c = isMobile ? false : collapsed @@ -491,11 +307,6 @@ function SidebarContent({ - {/* Site Picker */} - {siteId && ( - - )} - {/* Nav Groups */} {siteId ? (