Files
webmail/components/layout/navigation-rail.tsx
T

887 lines
36 KiB
TypeScript

"use client";
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Mail, CalendarEvent, Users, Folder, Settings, Keyboard, Plus, Shield, LogOut, Check, Search, Puzzle } from "@/components/icons";
import { AccountSwitcher } from "./account-switcher";
import { iconForName } from "@/components/icons";
import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
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 { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useResolvedSidebarApps } from "@/hooks/use-resolved-sidebar-apps";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getMaxAccounts } from "@/lib/account-utils";
import { isDocumentRTL } from "@/i18n/direction";
import { cn, formatFileSize } from "@/lib/utils";
import { quotaLevel, type StorageQuota } from "@/lib/quota";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useStalwartAdmin } from "@/hooks/use-stalwart-admin";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { Avatar } from "@/components/ui/avatar";
import { toUnicodeEmail } from "@/lib/idn";
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
import { NotificationBell } from "@/components/notifications/notification-center";
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?: StorageQuota | null;
isPushConnected?: boolean;
onLogout?: () => void;
onShowShortcuts?: () => void;
onManageApps?: () => void;
onInlineApp?: (appId: string, url: string, name: string) => void;
onCloseInlineApp?: () => void;
activeAppId?: string | null;
/**
* If provided, intercepts the rail's built-in route navigation. Return
* `true` to prevent the underlying `<Link>` from navigating - used by the
* Pro interface to open the route as a tab instead. The visual rail is
* unchanged.
*/
onNavigate?: (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => boolean | void;
/**
* When `onNavigate` is in use, this controls which nav item the rail
* highlights as active (since the URL alone no longer reflects the
* active app).
*/
activeItemId?: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null;
/**
* The admin panel's own rail: shows the Admin entry as the current one,
* for password admins too, who are not Stalwart admins.
*/
adminActive?: boolean;
/**
* Pro shell only: renders a Search entry that opens the global search
* palette (#641) instead of navigating anywhere.
*/
onOpenSearch?: () => void;
}
function StorageQuotaCircle({ quota, usagePercent }: { quota: StorageQuota; usagePercent: number }) {
const t = useTranslations("sidebar");
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
setPopoverStyle(
isDocumentRTL()
? {
position: "fixed",
right: window.innerWidth - rect.left + 8,
bottom: window.innerHeight - rect.bottom,
}
: {
position: "fixed",
left: rect.right + 8,
bottom: window.innerHeight - rect.bottom,
}
);
}, []);
// Position before the first paint - the portalled popover would otherwise
// render one frame as an unpositioned block at the end of <body>, shifting
// the page layout for a split second.
useLayoutEffect(() => {
if (open) updatePosition();
}, [open, updatePosition]);
useEffect(() => {
if (!open) return;
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]);
// Usage can legitimately exceed the quota (e.g. limit lowered after the fact)
const free = Math.max(0, quota.total - quota.used);
const level = quotaLevel(quota);
const strokeColor = level === "full"
? "stroke-destructive"
: level === "warn"
? "stroke-warning"
: "stroke-success";
return (
<div className="relative">
<button
ref={buttonRef}
onClick={() => setOpen(!open)}
className="relative w-8 h-8 flex items-center justify-center rounded-full hover:bg-muted transition-colors cursor-pointer"
aria-label={t("storage")}
>
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="12" fill="none" className="stroke-muted" strokeWidth="3" />
<circle
cx="16" cy="16" r="12" fill="none"
className={cn(strokeColor)}
strokeWidth="3" strokeLinecap="round"
strokeDasharray={`${(usagePercent / 100) * 75.4} 75.4`}
style={{ transition: "stroke-dasharray 0.3s" }}
/>
</svg>
<span className="absolute text-[7px] font-bold text-muted-foreground tabular-nums">
{Math.round(usagePercent)}%
</span>
</button>
{open && createPortal(
<div ref={popoverRef} style={popoverStyle} className="w-52 rounded-lg border border-border bg-background text-foreground shadow-lg p-3 z-50">
<p className="text-xs font-semibold mb-2">{t("storage")}</p>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">{t("storage_used")}</span>
<span className="font-medium tabular-nums">{formatFileSize(quota.used)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("storage_free")}</span>
<span className="font-medium tabular-nums">{formatFileSize(free)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("storage_total")}</span>
<span className="font-medium tabular-nums">{formatFileSize(quota.total)}</span>
</div>
</div>
<div className="mt-2.5 w-full bg-muted rounded-full h-1.5">
<div
className={cn(
"h-1.5 rounded-full transition-all",
level === "full"
? "bg-destructive"
: level === "warn"
? "bg-warning"
: "bg-success"
)}
style={{ width: `${usagePercent}%` }}
/>
</div>
<p className="text-[10px] text-muted-foreground mt-1 tabular-nums">
{Math.round(usagePercent)}% {t("storage_used").toLowerCase()}
</p>
</div>,
document.body
)}
</div>
);
}
export function NavigationRail({
orientation = "vertical",
collapsed = false,
className,
quota,
onLogout,
onShowShortcuts,
onManageApps,
onInlineApp,
onCloseInlineApp,
activeAppId,
onNavigate,
activeItemId,
onOpenSearch,
adminActive = false,
}: NavigationRailProps) {
const t = useTranslations("sidebar");
const tGlobalSearch = useTranslations("global_search");
const pathname = usePathname();
const router = useRouter();
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar })));
const { mailboxes } = useEmailStore(useShallow((state) => ({ mailboxes: state.mailboxes })));
const client = useAuthStore((s) => s.client);
const supportsFiles = client?.supportsFiles() ?? false;
const supportsContacts = client?.supportsContacts() ?? false;
const showRailAccountList = useSettingsStore((s) => s.showRailAccountList);
const mountainView = useSettingsStore((s) => s.interfaceLayout === 'mountain-view');
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'));
// Operator-provided apps (#931) plus the user's own; the gate above only
// removes the user's, so a pinned set still shows when custom apps are off.
const visibleSidebarApps = useResolvedSidebarApps();
// Pages plugins added (api.ui.setPages); listed after the built-in apps.
const pluginPages = usePluginPages();
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const isStalwartAdmin = useStalwartAdmin();
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<HTMLButtonElement>(null);
// Portalled to <body>, so without explicit focus handling the menu is
// unreachable for keyboard and screen reader users (#719).
const closeLogoutMenu = useCallback(() => setLogoutMenuOpen(false), []);
const { menuRef: logoutPopoverRef, onKeyDown: onLogoutMenuKeyDown } = useMenuNavigation<HTMLDivElement>({
open: logoutMenuOpen,
onClose: closeLogoutMenu,
triggerRef: logoutBtnRef,
});
const [logoutPopoverStyle, setLogoutPopoverStyle] = useState<React.CSSProperties>({});
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
const updateLogoutPosition = useCallback(() => {
if (!logoutBtnRef.current) return;
const rect = logoutBtnRef.current.getBoundingClientRect();
setLogoutPopoverStyle(
isDocumentRTL()
? {
position: "fixed",
right: window.innerWidth - rect.left + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom),
}
: {
position: "fixed",
left: rect.right + 8,
bottom: Math.max(8, window.innerHeight - rect.bottom),
}
);
}, []);
// Position before the first paint (same reasoning as the quota popover above).
useLayoutEffect(() => {
if (logoutMenuOpen) updateLogoutPosition();
}, [logoutMenuOpen, updateLogoutPosition]);
useEffect(() => {
if (!logoutMenuOpen) return;
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, logoutPopoverRef]);
const navItems: NavItem[] = [
{ id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread },
{ id: "calendar", icon: CalendarEvent, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar || !calendarEnabled },
{ id: "contacts", icon: Users, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts || !contactsEnabled },
{ id: "files", icon: Folder, 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 === "/") {
// Mountain View's bottom bar marks the mail tab on mail routes too.
return pathname === "/" || pathname === "" || (mountainView && pathname.startsWith("/mail"));
}
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 (
<nav
data-mobile-navigation=""
className={cn(
"flex items-center bg-background border-t border-border shrink-0 overflow-x-auto mobile-scroll-hidden pb-[calc(env(safe-area-inset-bottom)/2)]",
mountainView && "min-h-16 border-t-0 bg-secondary shadow-[0_-1px_3px_rgb(0_0_0/0.08)]",
className
)}
role="navigation"
aria-label={t("nav_label")}
>
{visibleItems.map((item) => {
const isActive = getIsActive(item.href, item.id);
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
>
<div className={cn(
"relative",
mountainView && isActive && "min-w-14 rounded-full bg-primary/15 px-4 py-[3px]"
)}>
<Icon className="w-5 h-5" />
{item.badge != null && item.badge > 0 && (
<span aria-hidden="true" className="absolute -top-1.5 -right-2.5 flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1">
{item.badge > 99 ? "99+" : item.badge}
</span>
)}
{isActive && !mountainView && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t(item.labelKey)}</span>
</Link>
);
})}
{/* Plugin pages, after the built-in apps */}
{pluginPages.map((page) => {
const isActive = !activeAppId && isPluginPageActive(pathname, page);
const PageIcon = iconForName(page.icon) ?? Puzzle;
return (
<Link
key={`plugin-page:${page.pluginId}:${page.id}`}
href={pluginPageHref(page)}
onClick={() => { if (activeAppId) onCloseInlineApp?.(); }}
data-plugin-page-link={`${page.pluginId}/${page.id}`}
title={page.pluginName}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
>
<div className={cn(
"relative",
mountainView && isActive && "min-w-14 rounded-full bg-primary/15 px-4 py-[3px]"
)}>
<PageIcon className="w-5 h-5" />
{isActive && !mountainView && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{page.title}</span>
</Link>
);
})}
{/* Custom sidebar apps (per-app mobile visibility) */}
{visibleSidebarApps.filter((app) => app.showOnMobile).map((app) => {
const AppIcon = iconForName(app.icon);
const isActive = activeAppId === app.id;
return (
<button
key={app.id}
onClick={() => {
if (isActive) {
onCloseInlineApp?.();
} else if (app.openMode === 'tab') {
window.open(app.url, '_blank', 'noopener,noreferrer');
} else {
onInlineApp?.(app.id, app.url, app.name);
}
}}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
<div className="relative">
{AppIcon ? <AppIcon className="w-5 h-5" /> : null}
{isActive && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{app.name}</span>
</button>
);
})}
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
{(isStalwartAdmin || adminActive) && (
<a
href={`${getPathPrefix()}/admin`}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
adminActive ? "text-primary" : "text-muted-foreground hover:text-foreground"
)}
aria-current={adminActive ? "page" : undefined}
>
<span className="relative">
<Shield className="w-5 h-5" />
{adminActive && !mountainView && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
{hasUpdate && (
<span
className={cn(
"absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
/>
)}
</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
</a>
)}
{/* Settings */}
<Link
href="/settings"
onClick={handleNavClick('settings')}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
isSettingsActive
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
aria-current={isSettingsActive ? "page" : undefined}
>
<div className="relative">
<Settings className="w-5 h-5" />
{isSettingsActive && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("settings")}</span>
</Link>
</nav>
);
}
const quotaUsagePercent = quota && quota.total > 0 ? Math.min((quota.used / quota.total) * 100, 100) : 0;
return (
<div
className={cn(
"flex flex-col h-full",
collapsed ? "items-center" : "",
className
)}
>
{(() => {
const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl));
return logoUrl ? (
<div className="flex items-center justify-center py-3 px-1">
<img
src={logoUrl}
alt=""
className="w-8 h-8 object-contain"
/>
</div>
) : null;
})()}
<nav
className={cn(
"flex flex-col flex-1 min-h-0 overflow-y-auto scroll-hidden",
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
)}
role="navigation"
aria-label={t("nav_label")}
>
{onOpenSearch && (
<button
type="button"
onClick={onOpenSearch}
data-tour="nav-search"
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150 cursor-pointer",
collapsed ? "justify-center w-10 h-10" : "px-2.5 text-sm",
"max-lg:min-h-[44px]",
"text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={collapsed ? tGlobalSearch("title") : undefined}
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
>
<Search className="w-[18px] h-[18px] flex-shrink-0" />
{!collapsed && <span className="truncate">{tGlobalSearch("title")}</span>}
</button>
)}
{visibleItems.map((item) => {
const isActive = getIsActive(item.href, item.id);
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
data-tour={`nav-${item.id}`}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
"max-lg:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
aria-label={t(item.labelKey)}
title={collapsed ? t(item.labelKey) : undefined}
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
>
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} />
{!collapsed && <span className="truncate">{t(item.labelKey)}</span>}
{item.badge != null && item.badge > 0 && (
<span aria-hidden="true" className={cn(
"absolute flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1",
collapsed ? "-top-0.5 -right-0.5" : "right-1.5"
)}>
{item.badge > 99 ? "99+" : item.badge}
</span>
)}
</Link>
);
})}
{/* Plugin pages, after the built-in apps */}
{pluginPages.map((page) => {
const isActive = !activeAppId && isPluginPageActive(pathname, page);
const PageIcon = iconForName(page.icon) ?? Puzzle;
return (
<Link
key={`plugin-page:${page.pluginId}:${page.id}`}
href={pluginPageHref(page)}
onClick={() => { if (activeAppId) onCloseInlineApp?.(); }}
data-plugin-page-link={`${page.pluginId}/${page.id}`}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
"max-lg:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
aria-label={collapsed ? page.title : undefined}
title={collapsed ? `${page.title} (${page.pluginName})` : page.pluginName}
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
>
<PageIcon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} />
{!collapsed && <span className="truncate">{page.title}</span>}
</Link>
);
})}
{/* Custom sidebar apps */}
{visibleSidebarApps.length > 0 && (
<div
className={cn(
"border-t",
collapsed ? "w-8 mx-auto my-1 pt-1" : "mx-2 my-0.5 pt-0.5"
)}
style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }}
/>
)}
{visibleSidebarApps.map((app) => {
const AppIcon = iconForName(app.icon);
const isActive = activeAppId === app.id;
return (
<button
key={app.id}
onClick={() => {
if (isActive) {
onCloseInlineApp?.();
} else if (app.openMode === 'tab') {
window.open(app.url, '_blank', 'noopener,noreferrer');
} else {
onInlineApp?.(app.id, app.url, app.name);
}
}}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
"max-lg:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={collapsed ? app.name : undefined}
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
>
{AppIcon ? <AppIcon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} /> : null}
{!collapsed && <span className="truncate">{app.name}</span>}
</button>
);
})}
{/* Manage apps button */}
{sidebarAppsEnabled && onManageApps && (
<button
onClick={onManageApps}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
"max-lg:min-h-[44px]",
"text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={collapsed ? t("add_app") : undefined}
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
>
<Plus className="w-[18px] h-[18px] flex-shrink-0" />
{!collapsed && <span className="truncate">{t("add_app")}</span>}
</button>
)}
</nav>
<PluginSlot name="navigation-rail-bottom" />
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
{(isStalwartAdmin || adminActive) && (
<a
href={`${getPathPrefix()}/admin`}
className={cn(
"flex items-center justify-center w-10 h-10 rounded-md transition-colors relative",
adminActive ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
title={t("admin") || "Admin"}
aria-current={adminActive ? "page" : undefined}
>
<Shield className="w-[18px] h-[18px]" />
{hasUpdate && (
<span
className={cn(
"absolute top-2 right-2 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
/>
)}
</a>
)}
<NotificationBell placement="side" className="w-10 h-10 rounded-md" />
<Link
href="/settings"
onClick={handleNavClick('settings')}
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}
>
<Settings className="w-[18px] h-[18px]" />
</Link>
<div className="w-8 border-t" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
{onShowShortcuts && (
<button
onClick={onShowShortcuts}
data-tour="nav-shortcuts"
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("keyboard_shortcuts")}
>
<Keyboard className="w-[18px] h-[18px]" />
</button>
)}
{!onShowShortcuts && (
<>
<button
onClick={() => setShowShortcutsModal(true)}
data-tour="nav-shortcuts"
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("keyboard_shortcuts")}
>
<Keyboard className="w-[18px] h-[18px]" />
</button>
<KeyboardShortcutsModal
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
</>
)}
{quota && quota.total > 0 && (
<div data-tour="storage-quota">
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
</div>
)}
{onLogout && showRailAccountList && accounts.length > 0 && (
<>
<div className="w-8 border-t my-1" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
{/* Account circles */}
<div className="flex flex-col items-center gap-3">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
return (
<button
key={account.id}
onClick={() => {
if (!isActive) switchAccount(account.id);
}}
className={cn(
"relative w-8 h-8 rounded-full transition-all flex-shrink-0",
isActive
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "opacity-70 hover:opacity-100"
)}
title={`${toUnicodeEmail(account.displayName || account.label)} (${toUnicodeEmail(account.email || account.username)})`}
>
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="sm"
disableFavicon
fallbackColor={account.avatarColor}
/>
{isActive && (
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2 h-2 text-primary-foreground" />
</span>
)}
</button>
);
})}
{accounts.length < getMaxAccounts() && (
<button
onClick={() => router.push(`/login?mode=add-account` as never)}
className="flex items-center justify-center w-8 h-8 rounded-full border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-foreground hover:text-foreground hover:bg-muted transition-colors flex-shrink-0"
title={t("add_account")}
aria-label={t("add_account")}
>
<Plus className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Logout button with popover */}
<button
ref={logoutBtnRef}
onClick={() => setLogoutMenuOpen(!logoutMenuOpen)}
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("sign_out")}
aria-label={t("sign_out")}
aria-expanded={logoutMenuOpen}
aria-haspopup="menu"
>
<LogOut className="w-4 h-4" />
</button>
{logoutMenuOpen && createPortal(
<div
ref={logoutPopoverRef}
onKeyDown={onLogoutMenuKeyDown}
style={logoutPopoverStyle}
className="w-56 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
role="menu"
aria-label={t("sign_out")}
>
<button
onClick={() => { setLogoutMenuOpen(false); logout(); }}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-foreground hover:bg-muted transition-colors"
role="menuitem"
>
<LogOut className="w-4 h-4" />
{t("sign_out")}
</button>
{accounts.length > 1 && (
<button
onClick={() => { setLogoutMenuOpen(false); logoutAll(); }}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-destructive hover:bg-muted transition-colors"
role="menuitem"
>
<LogOut className="w-4 h-4" />
{t("sign_out_all")}
</button>
)}
</div>,
document.body
)}
</>
)}
{onLogout && !showRailAccountList && (
<AccountSwitcher variant="rail" />
)}
</div>
</div>
);
}