"use client"; import { useState, useRef, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { Mail, Calendar, BookUser, HardDrive, Settings, Keyboard, Plus, Shield, LogOut, Check } from "lucide-react"; import { AccountSwitcher } from "./account-switcher"; import { icons as lucideIcons, type LucideIcon } from "lucide-react"; import { useConfig } from "@/hooks/use-config"; import { useThemeStore } from "@/stores/theme-store"; import { usePathname, Link, useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { useCalendarStore } from "@/stores/calendar-store"; import { useEmailStore } from "@/stores/email-store"; import { useWebDAVStore } from "@/stores/webdav-store"; import { useSettingsStore } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot"; import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils"; import { cn, formatFileSize } from "@/lib/utils"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { apiFetch } from "@/lib/browser-navigation"; interface NavItem { id: string; icon: typeof Mail; labelKey: string; href: string; hidden?: boolean; badge?: number; } interface NavigationRailProps { orientation?: "vertical" | "horizontal"; collapsed?: boolean; className?: string; quota?: { used: number; total: number } | null; isPushConnected?: boolean; onLogout?: () => void; onShowShortcuts?: () => void; onManageApps?: () => void; onInlineApp?: (appId: string, url: string, name: string) => void; onCloseInlineApp?: () => void; activeAppId?: string | null; } function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) { const t = useTranslations("sidebar"); const [open, setOpen] = useState(false); const buttonRef = useRef(null); const popoverRef = useRef(null); const [popoverStyle, setPopoverStyle] = useState({}); const updatePosition = useCallback(() => { if (!buttonRef.current) return; const rect = buttonRef.current.getBoundingClientRect(); setPopoverStyle({ position: "fixed", left: rect.right + 8, bottom: window.innerHeight - rect.bottom, }); }, []); useEffect(() => { if (!open) return; updatePosition(); const handleClick = (e: MouseEvent) => { if ( buttonRef.current?.contains(e.target as Node) || popoverRef.current?.contains(e.target as Node) ) return; setOpen(false); }; document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [open, updatePosition]); const free = quota.total - quota.used; const strokeColor = usagePercent > 90 ? "stroke-destructive" : usagePercent > 70 ? "stroke-warning" : "stroke-success"; return (
{open && createPortal(

{t("storage")}

{t("storage_used")} {formatFileSize(quota.used)}
{t("storage_free")} {formatFileSize(free)}
{t("storage_total")} {formatFileSize(quota.total)}
90 ? "bg-destructive" : usagePercent > 70 ? "bg-warning" : "bg-success" )} style={{ width: `${usagePercent}%` }} />

{Math.round(usagePercent)}% {t("storage_used").toLowerCase()}

, document.body )}
); } export function NavigationRail({ orientation = "vertical", collapsed = false, className, quota, isPushConnected, onLogout, onShowShortcuts, onManageApps, onInlineApp, onCloseInlineApp, activeAppId, }: NavigationRailProps) { const t = useTranslations("sidebar"); const pathname = usePathname(); const router = useRouter(); const { appLogoLightUrl, appLogoDarkUrl } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const { supportsCalendar } = useCalendarStore(); const { mailboxes } = useEmailStore(); const { supportsWebDAV } = useWebDAVStore(); const sidebarApps = useSettingsStore((s) => s.sidebarApps); const showRailAccountList = useSettingsStore((s) => s.showRailAccountList); const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled')); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : []; const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); const hasUpdate = useUpdateStore(selectHasUpdate); const updateSeverity = useUpdateStore((s) => s.status?.severity); const startUpdatePolling = useUpdateStore((s) => s.startPolling); useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]); const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated'; // Account list for rail const accounts = useAccountStore((s) => s.accounts); // Read activeAccountId from authStore so the rail's account row matches the actually-loaded // session - accountStore has its own persisted copy that can drift out of sync. const activeAccountId = useAuthStore((s) => s.activeAccountId); const switchAccount = useAuthStore((s) => s.switchAccount); const logout = useAuthStore((s) => s.logout); const logoutAll = useAuthStore((s) => s.logoutAll); const [logoutMenuOpen, setLogoutMenuOpen] = useState(false); const logoutBtnRef = useRef(null); const logoutPopoverRef = useRef(null); const [logoutPopoverStyle, setLogoutPopoverStyle] = useState({}); const [showShortcutsModal, setShowShortcutsModal] = useState(false); const updateLogoutPosition = useCallback(() => { if (!logoutBtnRef.current) return; const rect = logoutBtnRef.current.getBoundingClientRect(); setLogoutPopoverStyle({ position: "fixed", left: rect.right + 8, bottom: Math.max(8, window.innerHeight - rect.bottom), }); }, []); useEffect(() => { if (!logoutMenuOpen) return; updateLogoutPosition(); const handleClickOutside = (e: MouseEvent) => { if ( logoutBtnRef.current?.contains(e.target as Node) || logoutPopoverRef.current?.contains(e.target as Node) ) return; setLogoutMenuOpen(false); }; const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape") setLogoutMenuOpen(false); }; document.addEventListener("mousedown", handleClickOutside); document.addEventListener("keydown", handleEscape); return () => { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("keydown", handleEscape); }; }, [logoutMenuOpen, updateLogoutPosition]); useEffect(() => { let cancelled = false; const headers = getActiveAccountSlotHeaders(); if (!headers['X-JMAP-Cookie-Slot']) return; apiFetch('/api/admin/auth', { headers }) .then(res => res.json()) .then(data => { if (cancelled || !data.stalwartAdmin) return; setIsStalwartAdmin(true); if (!data.authenticated) { // Pre-create admin session so /admin works even after full page navigation apiFetch('/api/admin/auth', { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify({ stalwartAuth: true }), }).catch(() => {}); } }) .catch(() => {}); return () => { cancelled = true; }; }, []); const navItems: NavItem[] = [ { id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread }, { id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar }, { id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" }, { id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false || !filesEnabled }, ]; const isSettingsActive = !activeAppId && pathname.startsWith("/settings"); const visibleItems = navItems.filter((item) => !item.hidden); const getIsActive = (href: string) => { if (activeAppId) return false; if (href === "/") { return pathname === "/" || pathname === ""; } return pathname.startsWith(href); }; if (orientation === "horizontal") { return ( ); } const quotaUsagePercent = quota && quota.total > 0 ? Math.min((quota.used / quota.total) * 100, 100) : 0; return (
{(() => { const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl); return logoUrl ? (
) : null; })()} {/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
{isStalwartAdmin && ( {hasUpdate && ( )} )} onCloseInlineApp?.() : undefined} data-tour="nav-settings" className={cn( "flex items-center justify-center w-10 h-10 rounded-md transition-colors", isSettingsActive ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted" )} title={t("settings")} aria-current={isSettingsActive ? "page" : undefined} >
{onShowShortcuts && ( )} {!onShowShortcuts && ( <> setShowShortcutsModal(false)} /> )} {quota && quota.total > 0 && (
)} {onLogout && showRailAccountList && accounts.length > 0 && ( <>
{/* Account circles */}
{accounts.map((account) => { const isActive = account.id === activeAccountId; const initials = getInitials(account.displayName || account.label, account.email || account.username); return ( ); })} {accounts.length < MAX_ACCOUNTS && ( )}
{/* Logout button with popover */} {logoutMenuOpen && createPortal(
{accounts.length > 1 && ( )}
, document.body )} )} {onLogout && !showRailAccountList && ( )}
); }