{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,
onLogout,
onShowShortcuts,
onManageApps,
onInlineApp,
onCloseInlineApp,
activeAppId,
onNavigate,
activeItemId,
}: 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 client = useAuthStore((s) => s.client);
const supportsFiles = client?.supportsFiles() ?? false;
const supportsContacts = client?.supportsContacts() ?? false;
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 contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
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 || !calendarEnabled },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts || !contactsEnabled },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
];
// When the host (e.g. the Pro shell) takes over navigation via `onNavigate`,
// it tells us which item is active; otherwise we infer it from the URL.
const isSettingsActive = onNavigate
? activeItemId === 'settings'
: !activeAppId && pathname.startsWith("/settings");
const visibleItems = navItems.filter((item) => !item.hidden);
const getIsActive = (href: string, itemId: string) => {
if (activeAppId) return false;
if (onNavigate) {
return activeItemId === itemId;
}
if (href === "/") {
return pathname === "/" || pathname === "";
}
return pathname.startsWith(href);
};
const handleNavClick = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') =>
(e: React.MouseEvent) => {
if (onNavigate) {
const intercepted = onNavigate(itemId);
if (intercepted !== false) {
e.preventDefault();
}
return;
}
if (activeAppId) {
onCloseInlineApp?.();
}
};
if (orientation === "horizontal") {
return (
);
}
const quotaUsagePercent = quota && quota.total > 0 ? Math.min((quota.used / quota.total) * 100, 100) : 0;
return (
{(() => {
const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl));
return logoUrl ? (
) : null;
})()}
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
{isStalwartAdmin && (
{hasUpdate && (
)}
)}
{onShowShortcuts && (
)}
{!onShowShortcuts && (
<>
setShowShortcutsModal(false)}
/>
>
)}
{quota && quota.total > 0 && (
)}
{onLogout && showRailAccountList && accounts.length > 0 && (
<>
{/* Account circles */}
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
return (
);
})}
{accounts.length < getMaxAccounts() && (
)}
{/* Logout button with popover */}
{logoutMenuOpen && createPortal(
{accounts.length > 1 && (
)}
,
document.body
)}
>
)}
{onLogout && !showRailAccountList && (
)}
);
}