From 6fe4a98b0271a07ffbd793ed92100889cd10f700 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 17 Mar 2026 23:02:26 +0100
Subject: [PATCH] feat: add sidebar apps management feature
- Implemented sidebar apps functionality including adding, editing, and deleting apps.
- Created a modal for managing sidebar apps with forms for inputting app details.
- Added icon picker component for selecting app icons.
- Introduced inline app view for displaying apps within the sidebar.
- Updated translations for new sidebar apps feature in Dutch and Portuguese.
- Enhanced settings store to manage sidebar apps state.
- Added hooks for managing sidebar apps state and modal visibility.
---
app/[locale]/calendar/page.tsx | 33 +-
app/[locale]/contacts/page.tsx | 22 +-
app/[locale]/files/page.tsx | 22 +-
app/[locale]/page.tsx | 30 +-
app/[locale]/settings/page.tsx | 41 +-
components/layout/icon-picker.tsx | 149 ++++++++
components/layout/inline-app-view.tsx | 49 +++
components/layout/navigation-rail.tsx | 156 +++++++-
components/layout/sidebar-apps-modal.tsx | 355 ++++++++++++++++++
components/settings/sidebar-apps-settings.tsx | 280 ++++++++++++++
hooks/use-sidebar-apps.ts | 49 +++
locales/de/common.json | 44 ++-
locales/en/common.json | 44 ++-
locales/es/common.json | 44 ++-
locales/fr/common.json | 44 ++-
locales/it/common.json | 44 ++-
locales/ja/common.json | 44 ++-
locales/nl/common.json | 44 ++-
locales/pt/common.json | 44 ++-
stores/settings-store.ts | 48 +++
20 files changed, 1545 insertions(+), 41 deletions(-)
create mode 100644 components/layout/icon-picker.tsx
create mode 100644 components/layout/inline-app-view.tsx
create mode 100644 components/layout/sidebar-apps-modal.tsx
create mode 100644 components/settings/sidebar-apps-settings.tsx
create mode 100644 hooks/use-sidebar-apps.ts
diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx
index ac8c2e61..0679488a 100644
--- a/app/[locale]/calendar/page.tsx
+++ b/app/[locale]/calendar/page.tsx
@@ -30,6 +30,9 @@ import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
import { NavigationRail } from "@/components/layout/navigation-rail";
+import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
+import { InlineAppView } from "@/components/layout/inline-app-view";
+import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
@@ -48,6 +51,7 @@ export default function CalendarPage() {
const router = useRouter();
const t = useTranslations("calendar");
const isMobile = useIsMobile();
+ const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
@@ -703,12 +707,20 @@ export default function CalendarPage() {
quota={quota}
isPushConnected={isPushConnected}
onLogout={() => { logout(); router.push('/login'); }}
+ onManageApps={handleManageApps}
+ onInlineApp={handleInlineApp}
+ onCloseInlineApp={closeInlineApp}
+ activeAppId={inlineApp?.id ?? null}
/>
)}
+ {inlineApp && (
+
+ )}
+
{/* Sidebar - full height */}
- {!isMobile && (
+ {!isMobile && !inlineApp && (
<>
)}
+ {!inlineApp && (
)}
-
- {/* Mobile Bottom Navigation */}
- {isMobile && (
-
- )}
+ )}
+
+ {/* Mobile Bottom Navigation */}
+ {isMobile && (
+
+ )}
{detailEvent && detailAnchorRect && (
)}
+
useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const {
@@ -476,12 +480,19 @@ export default function ContactsPage() {
quota={quota}
isPushConnected={isPushConnected}
onLogout={() => { logout(); router.push('/login'); }}
+ onManageApps={handleManageApps}
+ onInlineApp={handleInlineApp}
+ onCloseInlineApp={closeInlineApp}
+ activeAppId={inlineApp?.id ?? null}
/>
)}
-
+ {inlineApp && (
+
+ )}
+
{showListPanel && (
<>
{/* Panel 1: Categories sidebar */}
@@ -582,10 +593,17 @@ export default function ContactsPage() {
{isMobile && (
-
+
)}
+
);
diff --git a/app/[locale]/files/page.tsx b/app/[locale]/files/page.tsx
index e660838d..c35cb02d 100644
--- a/app/[locale]/files/page.tsx
+++ b/app/[locale]/files/page.tsx
@@ -13,6 +13,9 @@ import { useFileStore } from "@/stores/file-store";
import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail";
+import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
+import { InlineAppView } from "@/components/layout/inline-app-view";
+import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsMobile } from "@/hooks/use-media-query";
import { FileBrowser } from "@/components/files/file-browser";
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
@@ -24,6 +27,7 @@ export default function FilesPage() {
const router = useRouter();
const t = useTranslations("files");
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
+ const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const {
@@ -354,12 +358,19 @@ export default function FilesPage() {
quota={quota}
isPushConnected={isPushConnected}
onLogout={() => { logout(); router.push('/login'); }}
+ onManageApps={handleManageApps}
+ onInlineApp={handleInlineApp}
+ onCloseInlineApp={closeInlineApp}
+ activeAppId={inlineApp?.id ?? null}
/>
)}
-
+ {inlineApp && (
+
+ )}
+
{folderLayout !== "sidebar" && (
@@ -433,7 +444,13 @@ export default function FilesPage() {
{isMobile && (
-
+
)}
@@ -457,6 +474,7 @@ export default function FilesPage() {
/>
)}
+
);
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index 11e14c39..4d76c768 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -35,6 +35,9 @@ import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { NavigationRail } from "@/components/layout/navigation-rail";
+import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
+import { InlineAppView } from "@/components/layout/inline-app-view";
+import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview";
@@ -53,6 +56,7 @@ export default function Home() {
const [composerDraftText, setComposerDraftText] = useState("");
const [pendingDraft, setPendingDraft] = useState
(null);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
+ const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
@@ -1007,12 +1011,20 @@ export default function Home() {
isPushConnected={isPushConnected}
onLogout={handleLogout}
onShowShortcuts={() => setShowShortcutsModal(true)}
+ onManageApps={handleManageApps}
+ onInlineApp={handleInlineApp}
+ onCloseInlineApp={closeInlineApp}
+ activeAppId={inlineApp?.id ?? null}
/>
)}
+ {inlineApp && (
+
+ )}
+
{/* Mobile/Tablet Sidebar Overlay Backdrop */}
- {(isMobile || isTablet) && sidebarOpen && (
+ {(isMobile || isTablet) && sidebarOpen && !inlineApp && (
setSidebarOpen(false)}
@@ -1029,7 +1041,8 @@ export default function Home() {
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
!sidebarOpen && "max-lg:-translate-x-full",
// Desktop: normal flow
- "lg:relative lg:translate-x-0"
+ "lg:relative lg:translate-x-0",
+ inlineApp && "hidden"
)}
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
>
@@ -1055,7 +1068,7 @@ export default function Home() {
{/* Sidebar resize handle (desktop only, hidden when collapsed) */}
- {!isMobile && !isTablet && !sidebarCollapsed && (
+ {!isMobile && !isTablet && !sidebarCollapsed && !inlineApp && (
{ dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSidebarWidth(dragStartWidth.current + delta)}
@@ -1065,7 +1078,7 @@ export default function Home() {
)}
{/* Main Content Area */}
-
+
{/* Email List - full width on mobile, fixed width on tablet/desktop */}
+
)}
@@ -1575,6 +1594,7 @@ export default function Home() {
{/* Screen reader live region for dynamic status announcements */}
+
diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx
index 4a682019..66a3e9a0 100644
--- a/app/[locale]/settings/page.tsx
+++ b/app/[locale]/settings/page.tsx
@@ -23,6 +23,7 @@ import {
Wrench,
BookUser,
KeyRound,
+ PanelLeftClose,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -42,15 +43,19 @@ import { AccountSecuritySettings } from '@/components/settings/account-security-
import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings';
+import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
+import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
+import { InlineAppView } from '@/components/layout/inline-app-view';
+import { useSidebarApps } from '@/hooks/use-sidebar-apps';
import { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config';
import { cn } from '@/lib/utils';
-type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced';
+type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
interface TabDef {
@@ -75,6 +80,7 @@ const tabIcons: Record = {
folders: FolderOpen,
keywords: Tags,
files: HardDrive,
+ sidebar_apps: PanelLeftClose,
advanced: Wrench,
};
@@ -85,6 +91,7 @@ export default function SettingsPage() {
const t = useTranslations('settings');
const tSidebar = useTranslations('sidebar');
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
+ const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
@@ -143,6 +150,7 @@ export default function SettingsPage() {
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
+ { id: 'sidebar_apps', label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' },
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
];
@@ -181,6 +189,7 @@ export default function SettingsPage() {
{activeTab === 'folders' && }
{activeTab === 'keywords' && }
{activeTab === 'files' && }
+ {activeTab === 'sidebar_apps' && }
{activeTab === 'advanced' && }
>
);
@@ -212,7 +221,14 @@ export default function SettingsPage() {
{/* Bottom Navigation */}
-
+
+
);
}
@@ -280,7 +296,14 @@ export default function SettingsPage() {
{/* Bottom Navigation */}
-
+
+
);
}
@@ -295,9 +318,18 @@ export default function SettingsPage() {
quota={quota}
isPushConnected={isPushConnected}
onLogout={() => { logout(); router.push('/login'); }}
+ onManageApps={handleManageApps}
+ onInlineApp={handleInlineApp}
+ onCloseInlineApp={closeInlineApp}
+ activeAppId={inlineApp?.id ?? null}
/>
+ {inlineApp && (
+
+ )}
+ {!inlineApp && (
+ <>
{/* Settings Sidebar */}
+ >
+ )}
+
);
}
diff --git a/components/layout/icon-picker.tsx b/components/layout/icon-picker.tsx
new file mode 100644
index 00000000..80fc1945
--- /dev/null
+++ b/components/layout/icon-picker.tsx
@@ -0,0 +1,149 @@
+'use client';
+
+import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
+import { useTranslations } from 'next-intl';
+import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
+import { Search, X } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Input } from '@/components/ui/input';
+
+// Curated list of commonly useful icons, organized by category
+const POPULAR_ICONS = [
+ // Communication
+ 'Globe', 'Rss', 'Radio', 'Podcast', 'MessageCircle', 'MessageSquare', 'MessagesSquare',
+ 'Phone', 'Video', 'Webcam', 'Headphones', 'Mic',
+ // Productivity
+ 'FileText', 'FileSpreadsheet', 'Notebook', 'BookOpen', 'ClipboardList',
+ 'ListTodo', 'CheckSquare', 'SquareKanban', 'Kanban', 'Trello',
+ 'PenLine', 'Pencil', 'Edit', 'NotebookPen',
+ // Dev / Tech
+ 'Code', 'Terminal', 'Braces', 'Bug', 'Database', 'Server', 'Cpu',
+ 'HardDrive', 'Monitor', 'Laptop', 'Smartphone', 'Tablet',
+ 'Wifi', 'Cloud', 'CloudDownload', 'CloudUpload',
+ // Social / People
+ 'Users', 'UserPlus', 'UserCircle', 'Contact', 'PersonStanding',
+ 'Heart', 'ThumbsUp', 'Star', 'Award', 'Trophy', 'Crown',
+ // Media
+ 'Image', 'Camera', 'Film', 'Music', 'Play', 'Tv', 'Youtube', 'Clapperboard',
+ 'Palette', 'Paintbrush', 'Brush',
+ // Navigation / Location
+ 'Map', 'MapPin', 'Navigation', 'Compass', 'Home', 'Building', 'Building2',
+ 'Landmark', 'Store', 'Warehouse',
+ // Finance
+ 'DollarSign', 'Euro', 'CreditCard', 'Wallet', 'Receipt', 'PiggyBank',
+ 'TrendingUp', 'BarChart', 'BarChart3', 'LineChart', 'PieChart',
+ // Security
+ 'Shield', 'ShieldCheck', 'Lock', 'Unlock', 'Key', 'Fingerprint', 'Eye',
+ // Science / Health
+ 'Beaker', 'Atom', 'Dna', 'Microscope', 'Stethoscope', 'HeartPulse', 'Pill',
+ 'Syringe', 'Thermometer',
+ // Nature
+ 'Sun', 'Moon', 'CloudSun', 'Snowflake', 'Zap', 'Flame',
+ 'TreePine', 'Flower', 'Leaf', 'Mountain', 'Waves',
+ // Tools
+ 'Wrench', 'Hammer', 'Scissors', 'Ruler', 'Magnet',
+ 'Package', 'Gift', 'Box', 'Archive',
+ // Transport
+ 'Car', 'Bike', 'Bus', 'Train', 'Plane', 'Ship', 'Rocket',
+ // Food
+ 'Coffee', 'Wine', 'Beer', 'Pizza', 'Apple', 'Cake', 'CookingPot',
+ // Misc
+ 'Gamepad2', 'Dice5', 'Puzzle', 'Sparkles', 'Wand2', 'Bot', 'BrainCircuit',
+ 'Lightbulb', 'Bookmark', 'Flag', 'Bell', 'Clock', 'Timer',
+ 'Link', 'ExternalLink', 'QrCode', 'Scan', 'LayoutGrid', 'Layers',
+ 'Aperture', 'CircleDot', 'Target', 'Crosshair',
+];
+
+interface IconPickerProps {
+ value: string;
+ onChange: (iconName: string) => void;
+ className?: string;
+}
+
+export function IconPicker({ value, onChange, className }: IconPickerProps) {
+ const t = useTranslations('sidebar_apps');
+ const [search, setSearch] = useState('');
+ const [showAll, setShowAll] = useState(false);
+ const gridRef = useRef(null);
+
+ // Get all available icon names
+ const allIconNames = useMemo(() => {
+ return Object.keys(lucideIcons).filter(
+ k => /^[A-Z]/.test(k) && k !== 'createLucideIcon' && k !== 'Icon'
+ ).sort();
+ }, []);
+
+ const filteredIcons = useMemo(() => {
+ const source = showAll ? allIconNames : POPULAR_ICONS.filter(name => name in lucideIcons);
+ if (!search.trim()) return source;
+ const q = search.toLowerCase();
+ return source.filter(name => name.toLowerCase().includes(q));
+ }, [search, showAll, allIconNames]);
+
+ const renderIcon = useCallback((name: string) => {
+ const IconComponent = lucideIcons[name as keyof typeof lucideIcons] as LucideIcon | undefined;
+ if (!IconComponent) return null;
+ return ;
+ }, []);
+
+ return (
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder={t('search_icons')}
+ className="pl-8 h-8 text-xs"
+ />
+ {search && (
+
+ )}
+
+
+
+
+ {filteredIcons.map(name => (
+
+ ))}
+ {filteredIcons.length === 0 && (
+
+ {t('no_icons_found')}
+
+ )}
+
+
+ );
+}
diff --git a/components/layout/inline-app-view.tsx b/components/layout/inline-app-view.tsx
new file mode 100644
index 00000000..af7dc32d
--- /dev/null
+++ b/components/layout/inline-app-view.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import { X } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { InlineAppState } from '@/hooks/use-sidebar-apps';
+
+interface InlineAppViewProps {
+ apps: InlineAppState[];
+ activeAppId: string;
+ onClose: () => void;
+ className?: string;
+}
+
+export function InlineAppView({ apps, activeAppId, onClose, className }: InlineAppViewProps) {
+ const activeApp = apps.find((a) => a.id === activeAppId);
+
+ return (
+
+ {/* Header bar */}
+
+
{activeApp?.name}
+
+
+ {/* Iframes - active one visible, rest hidden but alive */}
+
+ {apps.map((app) => (
+
+ ))}
+
+
+ );
+}
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index a8ba1ecb..13f7b933 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -2,12 +2,14 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
-import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard } from "lucide-react";
+import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard, Plus } from "lucide-react";
+import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { usePathname, Link } 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 { cn, formatFileSize } from "@/lib/utils";
interface NavItem {
@@ -27,6 +29,10 @@ interface NavigationRailProps {
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 }) {
@@ -138,12 +144,17 @@ export function NavigationRail({
isPushConnected,
onLogout,
onShowShortcuts,
+ onManageApps,
+ onInlineApp,
+ onCloseInlineApp,
+ activeAppId,
}: NavigationRailProps) {
const t = useTranslations("sidebar");
const pathname = usePathname();
const { supportsCalendar } = useCalendarStore();
const { mailboxes } = useEmailStore();
const { supportsWebDAV } = useWebDAVStore();
+ const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const navItems: NavItem[] = [
@@ -151,12 +162,14 @@ export function NavigationRail({
{ 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 },
- { id: "settings", icon: Settings, labelKey: "settings", href: "/settings" },
];
+ 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 === "";
}
@@ -177,6 +190,7 @@ export function NavigationRail({
onCloseInlineApp?.() : undefined}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
"transition-colors duration-150",
@@ -201,6 +215,52 @@ export function NavigationRail({
);
})}
+
+ {/* Custom sidebar apps */}
+ {sidebarApps.map((app) => {
+ const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
+ const isActive = activeAppId === app.id;
+ return (
+
+ );
+ })}
+
+ {/* Manage apps button */}
+ {onManageApps && (
+
+ )}
);
}
@@ -230,6 +290,7 @@ export function NavigationRail({
onCloseInlineApp?.() : undefined}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed
@@ -257,13 +318,90 @@ export function NavigationRail({
);
})}
+
+ {/* Custom sidebar apps */}
+ {sidebarApps.length > 0 && (
+
+ )}
+ {sidebarApps.map((app) => {
+ const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
+ const isActive = activeAppId === app.id;
+ return (
+
+ );
+ })}
+
+ {/* Manage apps button */}
+ {onManageApps && (
+
+ )}
- {/* Footer: Storage Quota + Sign Out + Push Status */}
-
- {quota && quota.total > 0 && (
-
- )}
+ {/* Footer: Settings + Help + Storage Quota + Sign Out + Push Status */}
+
+
onCloseInlineApp?.() : undefined}
+ 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 && (