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) => ( +