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.
This commit is contained in:
Linus Rath
2026-03-17 23:02:26 +01:00
parent 3186198fad
commit 6fe4a98b02
20 changed files with 1545 additions and 41 deletions
+27 -6
View File
@@ -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}
/>
</div>
)}
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{/* Sidebar - full height */}
{!isMobile && (
{!isMobile && !inlineApp && (
<>
<div
className={cn(
@@ -748,6 +760,7 @@ export default function CalendarPage() {
</>
)}
{!inlineApp && (
<div className="flex flex-col flex-1 min-w-0">
<CalendarToolbar
selectedDate={selectedDate}
@@ -802,12 +815,19 @@ export default function CalendarPage() {
</Button>
)}
</div>
{/* Mobile Bottom Navigation */}
{isMobile && (
<NavigationRail orientation="horizontal" />
)}
</div>
)}
{/* Mobile Bottom Navigation */}
{isMobile && (
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
{detailEvent && detailAnchorRect && (
<EventDetailPopover
@@ -859,6 +879,7 @@ export default function CalendarPage() {
/>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<RecurrenceScopeDialog
isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"}
+20 -2
View File
@@ -20,6 +20,9 @@ import { useEmailStore } from "@/stores/email-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 { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query";
import type { ContactCard } from "@/lib/jmap/types";
@@ -38,6 +41,7 @@ export default function ContactsPage() {
const router = useRouter();
const t = useTranslations("contacts");
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 {
@@ -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}
/>
</div>
)}
<div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-1 min-h-0">
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)}
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
{showListPanel && (
<>
{/* Panel 1: Categories sidebar */}
@@ -582,10 +593,17 @@ export default function ContactsPage() {
</div>
{isMobile && (
<NavigationRail orientation="horizontal" />
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
</div>
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
</div>
);
+20 -2
View File
@@ -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}
/>
</div>
)}
<div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-1 min-h-0">
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)}
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
<div className="flex-1 min-w-0 flex flex-col">
{folderLayout !== "sidebar" && (
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
@@ -433,7 +444,13 @@ export default function FilesPage() {
</div>
{isMobile && (
<NavigationRail orientation="horizontal" />
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
</div>
@@ -457,6 +474,7 @@ export default function FilesPage() {
/>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
</div>
);
+25 -5
View File
@@ -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<ComposerDraftData | null>(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}
/>
</div>
)}
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{/* Mobile/Tablet Sidebar Overlay Backdrop */}
{(isMobile || isTablet) && sidebarOpen && (
{(isMobile || isTablet) && sidebarOpen && !inlineApp && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => 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() {
</div>
{/* Sidebar resize handle (desktop only, hidden when collapsed) */}
{!isMobile && !isTablet && !sidebarCollapsed && (
{!isMobile && !isTablet && !sidebarCollapsed && !inlineApp && (
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSidebarWidth(dragStartWidth.current + delta)}
@@ -1065,7 +1078,7 @@ export default function Home() {
)}
{/* Main Content Area */}
<div className="flex flex-col flex-1 min-w-0 h-full">
<div className={cn("flex flex-col flex-1 min-w-0 h-full", inlineApp && "hidden")}>
<div className="flex flex-1 min-h-0">
{/* Email List - full width on mobile, fixed width on tablet/desktop */}
<div
@@ -1552,7 +1565,13 @@ export default function Home() {
{/* Bottom Navigation - mobile and tablet */}
{(isMobile || isTablet) && activeView !== "viewer" && (
<NavigationRail orientation="horizontal" />
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)}
</div>
</div>
@@ -1575,6 +1594,7 @@ export default function Home() {
{/* Screen reader live region for dynamic status announcements */}
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
</div>
</DragDropProvider>
+38 -3
View File
@@ -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<Tab, LucideIcon> = {
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' && <FolderSettings />}
{activeTab === 'keywords' && <KeywordSettings />}
{activeTab === 'files' && <FilesSettingsComponent />}
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
{activeTab === 'advanced' && <AdvancedSettings />}
</>
);
@@ -212,7 +221,14 @@ export default function SettingsPage() {
</div>
{/* Bottom Navigation */}
<NavigationRail orientation="horizontal" />
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
}
@@ -280,7 +296,14 @@ export default function SettingsPage() {
</div>
{/* Bottom Navigation */}
<NavigationRail orientation="horizontal" />
<NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
}
@@ -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}
/>
</div>
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{!inlineApp && (
<>
{/* Settings Sidebar */}
<div
className={cn(
@@ -385,6 +417,9 @@ export default function SettingsPage() {
</div>
</div>
</div>
</>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div>
);
}