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
+24 -3
View File
@@ -30,6 +30,9 @@ import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog"; import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
import { NavigationRail } from "@/components/layout/navigation-rail"; 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 { ResizeHandle } from "@/components/layout/resize-handle";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
@@ -48,6 +51,7 @@ export default function CalendarPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
@@ -703,12 +707,20 @@ export default function CalendarPage() {
quota={quota} quota={quota}
isPushConnected={isPushConnected} isPushConnected={isPushConnected}
onLogout={() => { logout(); router.push('/login'); }} onLogout={() => { logout(); router.push('/login'); }}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/> />
</div> </div>
)} )}
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{/* Sidebar - full height */} {/* Sidebar - full height */}
{!isMobile && ( {!isMobile && !inlineApp && (
<> <>
<div <div
className={cn( className={cn(
@@ -748,6 +760,7 @@ export default function CalendarPage() {
</> </>
)} )}
{!inlineApp && (
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<CalendarToolbar <CalendarToolbar
selectedDate={selectedDate} selectedDate={selectedDate}
@@ -802,12 +815,19 @@ export default function CalendarPage() {
</Button> </Button>
)} )}
</div> </div>
</div>
)}
{/* Mobile Bottom Navigation */} {/* Mobile Bottom Navigation */}
{isMobile && ( {isMobile && (
<NavigationRail orientation="horizontal" /> <NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)} )}
</div>
{detailEvent && detailAnchorRect && ( {detailEvent && detailAnchorRect && (
<EventDetailPopover <EventDetailPopover
@@ -859,6 +879,7 @@ export default function CalendarPage() {
/> />
)} )}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<RecurrenceScopeDialog <RecurrenceScopeDialog
isOpen={!!pendingScopeAction} isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"} 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 { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail"; 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 { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsMobile } from "@/hooks/use-media-query";
import type { ContactCard } from "@/lib/jmap/types"; import type { ContactCard } from "@/lib/jmap/types";
@@ -38,6 +41,7 @@ export default function ContactsPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); 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 [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
const { const {
@@ -476,12 +480,19 @@ export default function ContactsPage() {
quota={quota} quota={quota}
isPushConnected={isPushConnected} isPushConnected={isPushConnected}
onLogout={() => { logout(); router.push('/login'); }} onLogout={() => { logout(); router.push('/login'); }}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/> />
</div> </div>
)} )}
<div className="flex flex-col flex-1 min-w-0"> <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 && ( {showListPanel && (
<> <>
{/* Panel 1: Categories sidebar */} {/* Panel 1: Categories sidebar */}
@@ -582,10 +593,17 @@ export default function ContactsPage() {
</div> </div>
{isMobile && ( {isMobile && (
<NavigationRail orientation="horizontal" /> <NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)} )}
</div> </div>
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
</div> </div>
); );
+20 -2
View File
@@ -13,6 +13,9 @@ import { useFileStore } from "@/stores/file-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NavigationRail } from "@/components/layout/navigation-rail"; 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 { useIsMobile } from "@/hooks/use-media-query";
import { FileBrowser } from "@/components/files/file-browser"; import { FileBrowser } from "@/components/files/file-browser";
import { ImagePreviewModal } from "@/components/files/image-preview-modal"; import { ImagePreviewModal } from "@/components/files/image-preview-modal";
@@ -24,6 +27,7 @@ export default function FilesPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("files"); const t = useTranslations("files");
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); 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 [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
const { const {
@@ -354,12 +358,19 @@ export default function FilesPage() {
quota={quota} quota={quota}
isPushConnected={isPushConnected} isPushConnected={isPushConnected}
onLogout={() => { logout(); router.push('/login'); }} onLogout={() => { logout(); router.push('/login'); }}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/> />
</div> </div>
)} )}
<div className="flex flex-col flex-1 min-w-0"> <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"> <div className="flex-1 min-w-0 flex flex-col">
{folderLayout !== "sidebar" && ( {folderLayout !== "sidebar" && (
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}> <div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
@@ -433,7 +444,13 @@ export default function FilesPage() {
</div> </div>
{isMobile && ( {isMobile && (
<NavigationRail orientation="horizontal" /> <NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)} )}
</div> </div>
@@ -457,6 +474,7 @@ export default function FilesPage() {
/> />
)} )}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
</div> </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 { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
import { WelcomeBanner } from "@/components/ui/welcome-banner"; import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { NavigationRail } from "@/components/layout/navigation-rail"; 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 { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
@@ -53,6 +56,7 @@ export default function Home() {
const [composerDraftText, setComposerDraftText] = useState(""); const [composerDraftText, setComposerDraftText] = useState("");
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null); const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); 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 [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const [showShortcutsModal, setShowShortcutsModal] = useState(false); const [showShortcutsModal, setShowShortcutsModal] = useState(false);
const [showAdvancedFields, setShowAdvancedFields] = useState(false); const [showAdvancedFields, setShowAdvancedFields] = useState(false);
@@ -1007,12 +1011,20 @@ export default function Home() {
isPushConnected={isPushConnected} isPushConnected={isPushConnected}
onLogout={handleLogout} onLogout={handleLogout}
onShowShortcuts={() => setShowShortcutsModal(true)} onShowShortcuts={() => setShowShortcutsModal(true)}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/> />
</div> </div>
)} )}
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{/* Mobile/Tablet Sidebar Overlay Backdrop */} {/* Mobile/Tablet Sidebar Overlay Backdrop */}
{(isMobile || isTablet) && sidebarOpen && ( {(isMobile || isTablet) && sidebarOpen && !inlineApp && (
<div <div
className="fixed inset-0 bg-black/50 z-40 lg:hidden" className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)} 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", "max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
!sidebarOpen && "max-lg:-translate-x-full", !sidebarOpen && "max-lg:-translate-x-full",
// Desktop: normal flow // 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} style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
> >
@@ -1055,7 +1068,7 @@ export default function Home() {
</div> </div>
{/* Sidebar resize handle (desktop only, hidden when collapsed) */} {/* Sidebar resize handle (desktop only, hidden when collapsed) */}
{!isMobile && !isTablet && !sidebarCollapsed && ( {!isMobile && !isTablet && !sidebarCollapsed && !inlineApp && (
<ResizeHandle <ResizeHandle
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }} onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSidebarWidth(dragStartWidth.current + delta)} onResize={(delta) => setSidebarWidth(dragStartWidth.current + delta)}
@@ -1065,7 +1078,7 @@ export default function Home() {
)} )}
{/* Main Content Area */} {/* 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"> <div className="flex flex-1 min-h-0">
{/* Email List - full width on mobile, fixed width on tablet/desktop */} {/* Email List - full width on mobile, fixed width on tablet/desktop */}
<div <div
@@ -1552,7 +1565,13 @@ export default function Home() {
{/* Bottom Navigation - mobile and tablet */} {/* Bottom Navigation - mobile and tablet */}
{(isMobile || isTablet) && activeView !== "viewer" && ( {(isMobile || isTablet) && activeView !== "viewer" && (
<NavigationRail orientation="horizontal" /> <NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
)} )}
</div> </div>
</div> </div>
@@ -1575,6 +1594,7 @@ export default function Home() {
{/* Screen reader live region for dynamic status announcements */} {/* Screen reader live region for dynamic status announcements */}
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" /> <div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
</div> </div>
</DragDropProvider> </DragDropProvider>
+38 -3
View File
@@ -23,6 +23,7 @@ import {
Wrench, Wrench,
BookUser, BookUser,
KeyRound, KeyRound,
PanelLeftClose,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; 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 { FilesSettingsComponent } from '@/components/settings/files-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings'; import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store'; import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query'; import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail'; 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 { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { cn } from '@/lib/utils'; 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'; type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
interface TabDef { interface TabDef {
@@ -75,6 +80,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
folders: FolderOpen, folders: FolderOpen,
keywords: Tags, keywords: Tags,
files: HardDrive, files: HardDrive,
sidebar_apps: PanelLeftClose,
advanced: Wrench, advanced: Wrench,
}; };
@@ -85,6 +91,7 @@ export default function SettingsPage() {
const t = useTranslations('settings'); const t = useTranslations('settings');
const tSidebar = useTranslations('sidebar'); const tSidebar = useTranslations('sidebar');
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); 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 [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore(); const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig(); 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 }] : []), ...(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' }, { 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 }] : []), ...(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' }, { id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
]; ];
@@ -181,6 +189,7 @@ export default function SettingsPage() {
{activeTab === 'folders' && <FolderSettings />} {activeTab === 'folders' && <FolderSettings />}
{activeTab === 'keywords' && <KeywordSettings />} {activeTab === 'keywords' && <KeywordSettings />}
{activeTab === 'files' && <FilesSettingsComponent />} {activeTab === 'files' && <FilesSettingsComponent />}
{activeTab === 'sidebar_apps' && <SidebarAppsSettings />}
{activeTab === 'advanced' && <AdvancedSettings />} {activeTab === 'advanced' && <AdvancedSettings />}
</> </>
); );
@@ -212,7 +221,14 @@ export default function SettingsPage() {
</div> </div>
{/* Bottom Navigation */} {/* Bottom Navigation */}
<NavigationRail orientation="horizontal" /> <NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div> </div>
); );
} }
@@ -280,7 +296,14 @@ export default function SettingsPage() {
</div> </div>
{/* Bottom Navigation */} {/* Bottom Navigation */}
<NavigationRail orientation="horizontal" /> <NavigationRail
orientation="horizontal"
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/>
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div> </div>
); );
} }
@@ -295,9 +318,18 @@ export default function SettingsPage() {
quota={quota} quota={quota}
isPushConnected={isPushConnected} isPushConnected={isPushConnected}
onLogout={() => { logout(); router.push('/login'); }} onLogout={() => { logout(); router.push('/login'); }}
onManageApps={handleManageApps}
onInlineApp={handleInlineApp}
onCloseInlineApp={closeInlineApp}
activeAppId={inlineApp?.id ?? null}
/> />
</div> </div>
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{!inlineApp && (
<>
{/* Settings Sidebar */} {/* Settings Sidebar */}
<div <div
className={cn( className={cn(
@@ -385,6 +417,9 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
</div> </div>
</>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
</div> </div>
); );
} }
+149
View File
@@ -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<HTMLDivElement>(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 <IconComponent className="w-5 h-5" />;
}, []);
return (
<div className={cn('space-y-2', className)}>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('search_icons')}
className="pl-8 h-8 text-xs"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="w-3 h-3" />
</button>
)}
</div>
<button
onClick={() => setShowAll(!showAll)}
className={cn(
'text-xs px-2 py-1 rounded-md border transition-colors whitespace-nowrap',
showAll
? 'bg-primary/10 text-primary border-primary/30'
: 'bg-muted text-muted-foreground border-border hover:text-foreground'
)}
>
{showAll ? t('show_popular') : t('show_all')}
</button>
</div>
<div
ref={gridRef}
className="grid grid-cols-8 gap-1 max-h-[200px] overflow-y-auto p-1 border rounded-md bg-muted/30"
>
{filteredIcons.map(name => (
<button
key={name}
type="button"
onClick={() => onChange(name)}
title={name}
className={cn(
'flex items-center justify-center w-8 h-8 rounded-md transition-colors',
value === name
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
)}
>
{renderIcon(name)}
</button>
))}
{filteredIcons.length === 0 && (
<p className="col-span-8 py-4 text-center text-xs text-muted-foreground">
{t('no_icons_found')}
</p>
)}
</div>
</div>
);
}
+49
View File
@@ -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 (
<div className={cn('flex flex-col h-full bg-background', className)}>
{/* Header bar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
<h3 className="text-sm font-medium truncate">{activeApp?.name}</h3>
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Iframes - active one visible, rest hidden but alive */}
<div className="flex-1 relative">
{apps.map((app) => (
<iframe
key={app.id}
src={app.url}
title={app.name}
className={cn(
'absolute inset-0 w-full h-full border-0',
app.id !== activeAppId && 'hidden'
)}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
referrerPolicy="no-referrer"
loading="lazy"
/>
))}
</div>
</div>
);
}
+148 -6
View File
@@ -2,12 +2,14 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom"; 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 { usePathname, Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useWebDAVStore } from "@/stores/webdav-store"; import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
interface NavItem { interface NavItem {
@@ -27,6 +29,10 @@ interface NavigationRailProps {
isPushConnected?: boolean; isPushConnected?: boolean;
onLogout?: () => void; onLogout?: () => void;
onShowShortcuts?: () => 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 }) { function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) {
@@ -138,12 +144,17 @@ export function NavigationRail({
isPushConnected, isPushConnected,
onLogout, onLogout,
onShowShortcuts, onShowShortcuts,
onManageApps,
onInlineApp,
onCloseInlineApp,
activeAppId,
}: NavigationRailProps) { }: NavigationRailProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const pathname = usePathname(); const pathname = usePathname();
const { supportsCalendar } = useCalendarStore(); const { supportsCalendar } = useCalendarStore();
const { mailboxes } = useEmailStore(); const { mailboxes } = useEmailStore();
const { supportsWebDAV } = useWebDAVStore(); const { supportsWebDAV } = useWebDAVStore();
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
const navItems: NavItem[] = [ const navItems: NavItem[] = [
@@ -151,12 +162,14 @@ export function NavigationRail({
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar }, { id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" }, { id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false }, { 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 visibleItems = navItems.filter((item) => !item.hidden);
const getIsActive = (href: string) => { const getIsActive = (href: string) => {
if (activeAppId) return false;
if (href === "/") { if (href === "/") {
return pathname === "/" || pathname === ""; return pathname === "/" || pathname === "";
} }
@@ -177,6 +190,7 @@ export function NavigationRail({
<Link <Link
key={item.id} key={item.id}
href={item.href} href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className={cn( className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]", "flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
"transition-colors duration-150", "transition-colors duration-150",
@@ -201,6 +215,52 @@ export function NavigationRail({
</Link> </Link>
); );
})} })}
{/* Custom sidebar apps */}
{sidebarApps.map((app) => {
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
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-3 min-w-[64px] min-h-[44px]",
"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-[64px]">{app.name}</span>
</button>
);
})}
{/* Manage apps button */}
{onManageApps && (
<button
onClick={onManageApps}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
>
<Plus className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t("add_app")}</span>
</button>
)}
</nav> </nav>
); );
} }
@@ -230,6 +290,7 @@ export function NavigationRail({
<Link <Link
key={item.id} key={item.id}
href={item.href} href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
className={cn( className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150", "relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed collapsed
@@ -257,13 +318,90 @@ export function NavigationRail({
</Link> </Link>
); );
})} })}
{/* Custom sidebar apps */}
{sidebarApps.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)' }}
/>
)}
{sidebarApps.map((app) => {
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
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 */}
{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> </nav>
{/* Footer: Storage Quota + Sign Out + Push Status */} {/* Footer: Settings + Help + Storage Quota + Sign Out + Push Status */}
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1 border-t border-border pt-2"> <div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
{quota && quota.total > 0 && ( <Link
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} /> href="/settings"
onClick={activeAppId ? () => 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}
>
<Settings className="w-[18px] h-[18px]" />
</Link>
<div className="w-8 border-t" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
{onShowShortcuts && ( {onShowShortcuts && (
<button <button
@@ -275,6 +413,10 @@ export function NavigationRail({
</button> </button>
)} )}
{quota && quota.total > 0 && (
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
)}
{isPushConnected != null && ( {isPushConnected != null && (
<span <span
className="relative group" className="relative group"
+355
View File
@@ -0,0 +1,355 @@
'use client';
import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { X, Plus, Pencil, Trash2, GripVertical, ExternalLink, PanelRight } from 'lucide-react';
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { IconPicker } from './icon-picker';
import { useSettingsStore, type SidebarApp } from '@/stores/settings-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
interface SidebarAppFormData {
name: string;
url: string;
icon: string;
openMode: 'tab' | 'inline';
}
function SidebarAppForm({
app,
onSave,
onCancel,
}: {
app?: SidebarApp;
onSave: (data: SidebarAppFormData) => void;
onCancel: () => void;
}) {
const t = useTranslations('sidebar_apps');
const isEditing = !!app;
const [formData, setFormData] = useState<SidebarAppFormData>({
name: app?.name || '',
url: app?.url || '',
icon: app?.icon || 'Globe',
openMode: app?.openMode || 'tab',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = t('name_required');
}
if (!formData.url.trim()) {
newErrors.url = t('url_required');
} else {
try {
const parsed = new URL(formData.url);
if (!['http:', 'https:'].includes(parsed.protocol)) {
newErrors.url = t('url_invalid');
}
} catch {
newErrors.url = t('url_invalid');
}
}
if (!formData.icon) {
newErrors.icon = t('icon_required');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
onSave(formData);
};
const SelectedIcon = formData.icon
? (lucideIcons[formData.icon as keyof typeof lucideIcons] as LucideIcon | undefined)
: null;
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div>
<label htmlFor="app-name" className="block text-sm font-medium mb-1">
{t('name_label')} <span className="text-destructive">*</span>
</label>
<Input
id="app-name"
type="text"
maxLength={50}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t('name_placeholder')}
className={errors.name ? 'border-destructive' : ''}
/>
{errors.name && (
<p className="text-sm text-destructive mt-1">{errors.name}</p>
)}
</div>
{/* URL */}
<div>
<label htmlFor="app-url" className="block text-sm font-medium mb-1">
{t('url_label')} <span className="text-destructive">*</span>
</label>
<Input
id="app-url"
type="url"
maxLength={2048}
value={formData.url}
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
placeholder="https://example.com"
className={errors.url ? 'border-destructive' : ''}
/>
{errors.url && (
<p className="text-sm text-destructive mt-1">{errors.url}</p>
)}
</div>
{/* Open Mode */}
<div>
<label className="block text-sm font-medium mb-2">{t('open_mode_label')}</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setFormData({ ...formData, openMode: 'tab' })}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors flex-1',
formData.openMode === 'tab'
? 'bg-primary/10 border-primary/30 text-primary'
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground'
)}
>
<ExternalLink className="w-4 h-4" />
{t('open_new_tab')}
</button>
<button
type="button"
onClick={() => setFormData({ ...formData, openMode: 'inline' })}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors flex-1',
formData.openMode === 'inline'
? 'bg-primary/10 border-primary/30 text-primary'
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground'
)}
>
<PanelRight className="w-4 h-4" />
{t('open_inline')}
</button>
</div>
</div>
{/* Icon Picker */}
<div>
<label className="block text-sm font-medium mb-2">
{t('icon_label')} <span className="text-destructive">*</span>
{SelectedIcon && (
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
<SelectedIcon className="w-4 h-4" /> {formData.icon}
</span>
)}
</label>
<IconPicker
value={formData.icon}
onChange={(icon) => setFormData({ ...formData, icon })}
/>
{errors.icon && (
<p className="text-sm text-destructive mt-1">{errors.icon}</p>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>
{t('cancel')}
</Button>
<Button type="submit">
{isEditing ? t('update') : t('add')}
</Button>
</div>
</form>
);
}
interface SidebarAppsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
const t = useTranslations('sidebar_apps');
const { sidebarApps, addSidebarApp, updateSidebarApp, removeSidebarApp } = useSettingsStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const modalRef = useFocusTrap({
isActive: isOpen,
onEscape: () => {
if (isCreating || editingId) {
setIsCreating(false);
setEditingId(null);
} else {
onClose();
}
},
restoreFocus: true,
});
const handleCreate = useCallback((data: SidebarAppFormData) => {
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
addSidebarApp({ id, ...data });
setIsCreating(false);
}, [addSidebarApp]);
const handleUpdate = useCallback((id: string, data: SidebarAppFormData) => {
updateSidebarApp(id, data);
setEditingId(null);
}, [updateSidebarApp]);
const handleDelete = useCallback(async (app: SidebarApp) => {
const confirmed = await confirmDialog({
title: t('delete_confirm_title'),
message: t('delete_confirm', { name: app.name }),
confirmText: t('delete'),
variant: 'destructive',
});
if (!confirmed) return;
removeSidebarApp(app.id);
}, [removeSidebarApp, confirmDialog, t]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="sidebar-apps-modal-title"
className={cn(
'bg-background border border-border rounded-lg shadow-xl',
'w-full max-w-2xl max-h-[90vh] overflow-hidden',
'animate-in zoom-in-95 duration-200'
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 id="sidebar-apps-modal-title" className="text-lg font-semibold text-foreground">
{t('modal_title')}
</h2>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
{/* Create form */}
{isCreating && (
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
<h3 className="text-sm font-semibold mb-4">{t('add_new')}</h3>
<SidebarAppForm
onSave={handleCreate}
onCancel={() => setIsCreating(false)}
/>
</div>
)}
{/* Add button */}
{!isCreating && !editingId && (
<Button
onClick={() => setIsCreating(true)}
className="mb-6 w-full sm:w-auto"
>
<Plus className="w-4 h-4 mr-2" />
{t('add_new')}
</Button>
)}
{/* Apps list */}
<div className="space-y-3">
{sidebarApps.map((app) => {
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
if (editingId === app.id) {
return (
<div key={app.id} className="p-4 border border-border rounded-lg bg-muted/30">
<h3 className="text-sm font-semibold mb-4">{t('edit_app')}</h3>
<SidebarAppForm
app={app}
onSave={(data) => handleUpdate(app.id, data)}
onCancel={() => setEditingId(null)}
/>
</div>
);
}
return (
<div
key={app.id}
className="flex items-center gap-3 p-3 border border-border rounded-lg"
>
<div className="flex items-center justify-center w-9 h-9 rounded-md bg-muted">
{AppIcon ? <AppIcon className="w-5 h-5 text-muted-foreground" /> : null}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{app.name}</p>
<p className="text-xs text-muted-foreground truncate">{app.url}</p>
</div>
<span className={cn(
'text-[10px] px-1.5 py-0.5 rounded-full font-medium',
app.openMode === 'inline'
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
)}>
{app.openMode === 'inline' ? t('inline_badge') : t('tab_badge')}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingId(app.id)}
disabled={!!editingId || isCreating}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(app)}
disabled={!!editingId || isCreating}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
);
})}
{sidebarApps.length === 0 && !isCreating && (
<div className="text-center py-12 text-muted-foreground">
<Plus className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">{t('no_apps')}</p>
<p className="text-xs mt-1">{t('no_apps_hint')}</p>
</div>
)}
</div>
</div>
</div>
<ConfirmDialog {...confirmDialogProps} />
</div>
);
}
@@ -0,0 +1,280 @@
"use client";
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Plus, Pencil, Trash2, ExternalLink, PanelRight, GripVertical } from "lucide-react";
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SettingsSection, SettingItem, ToggleSwitch } from "./settings-section";
import { IconPicker } from "@/components/layout/icon-picker";
import { useSettingsStore, type SidebarApp } from "@/stores/settings-store";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { cn } from "@/lib/utils";
interface SidebarAppFormData {
name: string;
url: string;
icon: string;
openMode: "tab" | "inline";
}
function AppForm({
app,
onSave,
onCancel,
}: {
app?: SidebarApp;
onSave: (data: SidebarAppFormData) => void;
onCancel: () => void;
}) {
const t = useTranslations("sidebar_apps");
const isEditing = !!app;
const [formData, setFormData] = useState<SidebarAppFormData>({
name: app?.name || "",
url: app?.url || "",
icon: app?.icon || "Globe",
openMode: app?.openMode || "tab",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = t("name_required");
}
if (!formData.url.trim()) {
newErrors.url = t("url_required");
} else {
try {
const parsed = new URL(formData.url);
if (!["http:", "https:"].includes(parsed.protocol)) {
newErrors.url = t("url_invalid");
}
} catch {
newErrors.url = t("url_invalid");
}
}
if (!formData.icon) {
newErrors.icon = t("icon_required");
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
onSave(formData);
};
const SelectedIcon = formData.icon
? (lucideIcons[formData.icon as keyof typeof lucideIcons] as LucideIcon | undefined)
: null;
return (
<form onSubmit={handleSubmit} className="space-y-4 p-4 border border-border rounded-lg bg-secondary/30">
<div>
<label className="text-sm font-medium">{t("name_label")}</label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t("name_placeholder")}
className="mt-1"
/>
{errors.name && <p className="text-xs text-destructive mt-1">{errors.name}</p>}
</div>
<div>
<label className="text-sm font-medium">{t("url_label")}</label>
<Input
value={formData.url}
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
placeholder="https://example.com"
className="mt-1"
/>
{errors.url && <p className="text-xs text-destructive mt-1">{errors.url}</p>}
</div>
<div>
<label className="text-sm font-medium block mb-1">{t("icon_label")}</label>
<div className="flex items-center gap-2 mb-2">
{SelectedIcon && (
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center">
<SelectedIcon className="w-4 h-4" />
</div>
)}
<span className="text-sm text-muted-foreground">{formData.icon}</span>
</div>
<IconPicker value={formData.icon} onChange={(icon) => setFormData({ ...formData, icon })} />
{errors.icon && <p className="text-xs text-destructive mt-1">{errors.icon}</p>}
</div>
<div>
<label className="text-sm font-medium block mb-2">{t("open_mode_label")}</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setFormData({ ...formData, openMode: "tab" })}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm border transition-colors",
formData.openMode === "tab"
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
<ExternalLink className="w-4 h-4" />
{t("open_new_tab")}
</button>
<button
type="button"
onClick={() => setFormData({ ...formData, openMode: "inline" })}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm border transition-colors",
formData.openMode === "inline"
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
<PanelRight className="w-4 h-4" />
{t("open_inline")}
</button>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
{t("cancel")}
</Button>
<Button type="submit" size="sm">
{isEditing ? t("update") : t("add")}
</Button>
</div>
</form>
);
}
export function SidebarAppsSettings() {
const t = useTranslations("settings.sidebar_apps");
const tApps = useTranslations("sidebar_apps");
const { sidebarApps, keepAppsLoaded, addSidebarApp, updateSidebarApp, removeSidebarApp, updateSetting } = useSettingsStore();
const [editingApp, setEditingApp] = useState<string | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const handleAdd = useCallback((data: SidebarAppFormData) => {
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
addSidebarApp({ id, ...data });
setShowAddForm(false);
}, [addSidebarApp]);
const handleUpdate = useCallback((id: string, data: SidebarAppFormData) => {
updateSidebarApp(id, data);
setEditingApp(null);
}, [updateSidebarApp]);
const handleDelete = useCallback(async (app: SidebarApp) => {
const confirmed = await confirmDialog({
title: tApps("delete_confirm_title"),
message: tApps("delete_confirm", { name: app.name }),
confirmText: tApps("delete"),
variant: 'destructive',
});
if (!confirmed) return;
removeSidebarApp(app.id);
}, [confirmDialog, tApps, removeSidebarApp]);
return (
<>
<SettingsSection title={t("title")} description={t("description")}>
<SettingItem label={t("keep_loaded")} description={t("keep_loaded_description")}>
<ToggleSwitch
checked={keepAppsLoaded}
onChange={(v) => updateSetting("keepAppsLoaded", v)}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t("manage_title")} description={t("manage_description")}>
<div className="space-y-3">
{sidebarApps.length === 0 && !showAddForm && (
<p className="text-sm text-muted-foreground py-4 text-center">{tApps("no_apps_hint")}</p>
)}
{sidebarApps.map((app) => {
if (editingApp === app.id) {
return (
<AppForm
key={app.id}
app={app}
onSave={(data) => handleUpdate(app.id, data)}
onCancel={() => setEditingApp(null)}
/>
);
}
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
return (
<div
key={app.id}
className="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors"
>
<GripVertical className="w-4 h-4 text-muted-foreground/50 flex-shrink-0" />
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center flex-shrink-0">
{AppIcon ? <AppIcon className="w-4 h-4" /> : null}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{app.name}</div>
<div className="text-xs text-muted-foreground truncate">{app.url}</div>
</div>
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0",
app.openMode === "inline"
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
: "bg-muted text-muted-foreground"
)}>
{app.openMode === "inline" ? tApps("inline_badge") : tApps("tab_badge")}
</span>
<button
onClick={() => setEditingApp(app.id)}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(app)}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
);
})}
{showAddForm && (
<AppForm
onSave={handleAdd}
onCancel={() => setShowAddForm(false)}
/>
)}
{!showAddForm && !editingApp && (
<Button
variant="outline"
size="sm"
onClick={() => setShowAddForm(true)}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
{tApps("add_new")}
</Button>
)}
</div>
</SettingsSection>
<ConfirmDialog {...confirmDialogProps} />
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useState, useCallback } from 'react';
import { useSettingsStore } from '@/stores/settings-store';
export interface InlineAppState {
id: string;
url: string;
name: string;
}
export function useSidebarApps() {
const [showAppsModal, setShowAppsModal] = useState(false);
const [inlineApp, setInlineApp] = useState<InlineAppState | null>(null);
const [loadedApps, setLoadedApps] = useState<InlineAppState[]>([]);
const keepAppsLoaded = useSettingsStore((s) => s.keepAppsLoaded);
const handleManageApps = useCallback(() => {
setShowAppsModal(true);
}, []);
const handleInlineApp = useCallback((appId: string, url: string, name: string) => {
const app = { id: appId, url, name };
setInlineApp(app);
setLoadedApps((prev) => {
if (prev.some((a) => a.id === appId)) return prev;
return [...prev, app];
});
}, []);
const closeInlineApp = useCallback(() => {
if (!keepAppsLoaded) {
setLoadedApps((prev) => prev.filter((a) => a.id !== inlineApp?.id));
}
setInlineApp(null);
}, [keepAppsLoaded, inlineApp]);
const closeAppsModal = useCallback(() => {
setShowAppsModal(false);
}, []);
return {
showAppsModal,
inlineApp,
loadedApps: keepAppsLoaded ? loadedApps : (inlineApp ? [inlineApp] : []),
handleManageApps,
handleInlineApp,
closeInlineApp,
closeAppsModal,
};
}
+42 -2
View File
@@ -98,7 +98,38 @@
"vacation_active": "Abwesenheitsnotiz ist aktiv", "vacation_active": "Abwesenheitsnotiz ist aktiv",
"tags": "Tags", "tags": "Tags",
"mail": "E-Mail", "mail": "E-Mail",
"nav_label": "Navigation" "nav_label": "Navigation",
"add_app": "Apps"
},
"sidebar_apps": {
"modal_title": "Sidebar-Apps",
"add_new": "App hinzufügen",
"edit_app": "App bearbeiten",
"name_label": "Name",
"name_placeholder": "Meine App",
"name_required": "Name ist erforderlich",
"url_label": "URL",
"url_required": "URL ist erforderlich",
"url_invalid": "Geben Sie eine gültige http- oder https-URL ein",
"icon_label": "Symbol",
"icon_required": "Symbol ist erforderlich",
"open_mode_label": "Öffnungsmodus",
"open_new_tab": "Neuer Tab",
"open_inline": "Eingebettet",
"cancel": "Abbrechen",
"add": "Hinzufügen",
"update": "Aktualisieren",
"delete": "Löschen",
"delete_confirm_title": "App löschen",
"delete_confirm": "Möchten Sie \"{name}\" wirklich löschen?",
"no_apps": "Noch keine Apps hinzugefügt",
"no_apps_hint": "Fügen Sie benutzerdefinierte Apps und Links zur Seitenleiste hinzu",
"search_icons": "Symbole suchen...",
"show_popular": "Beliebt",
"show_all": "Alle",
"no_icons_found": "Keine Symbole gefunden",
"inline_badge": "Eingebettet",
"tab_badge": "Tab"
}, },
"email_list": { "email_list": {
"no_emails": "Keine Nachrichten gefunden", "no_emails": "Keine Nachrichten gefunden",
@@ -563,7 +594,8 @@
"security": "Sicherheit", "security": "Sicherheit",
"encryption": "Verschlüsselung", "encryption": "Verschlüsselung",
"files": "Dateien", "files": "Dateien",
"contacts": "Contacts" "contacts": "Contacts",
"sidebar_apps": "Sidebar-Apps"
}, },
"tab_groups": { "tab_groups": {
"general": "Allgemein", "general": "Allgemein",
@@ -1004,6 +1036,14 @@
"button": "Importieren" "button": "Importieren"
} }
}, },
"sidebar_apps": {
"title": "Sidebar-Apps",
"description": "Benutzerdefinierte Apps und Links in der Seitenleiste verwalten",
"keep_loaded": "Apps geladen halten",
"keep_loaded_description": "Inline-Apps beim Wechsel im Hintergrund laufen lassen, um Neuladen zu vermeiden",
"manage_title": "Benutzerdefinierte Apps",
"manage_description": "Apps in der Seitenleiste hinzufügen, bearbeiten oder entfernen"
},
"contacts": { "contacts": {
"title": "Contacts", "title": "Contacts",
"description": "Import and export your contacts", "description": "Import and export your contacts",
+42 -2
View File
@@ -98,7 +98,38 @@
"vacation_active": "Vacation responder is active", "vacation_active": "Vacation responder is active",
"tags": "Tags", "tags": "Tags",
"mail": "Mail", "mail": "Mail",
"nav_label": "Navigation" "nav_label": "Navigation",
"add_app": "Apps"
},
"sidebar_apps": {
"modal_title": "Sidebar Apps",
"add_new": "Add App",
"edit_app": "Edit App",
"name_label": "Name",
"name_placeholder": "My App",
"name_required": "Name is required",
"url_label": "URL",
"url_required": "URL is required",
"url_invalid": "Enter a valid http or https URL",
"icon_label": "Icon",
"icon_required": "Icon is required",
"open_mode_label": "Open Mode",
"open_new_tab": "New Tab",
"open_inline": "Inline",
"cancel": "Cancel",
"add": "Add",
"update": "Update",
"delete": "Delete",
"delete_confirm_title": "Delete App",
"delete_confirm": "Are you sure you want to delete \"{name}\"?",
"no_apps": "No apps added yet",
"no_apps_hint": "Add custom apps and links to your sidebar",
"search_icons": "Search icons...",
"show_popular": "Popular",
"show_all": "All",
"no_icons_found": "No icons found",
"inline_badge": "Inline",
"tab_badge": "Tab"
}, },
"email_list": { "email_list": {
"no_emails": "No messages found", "no_emails": "No messages found",
@@ -563,7 +594,8 @@
"security": "Security", "security": "Security",
"files": "Files", "files": "Files",
"contacts": "Contacts", "contacts": "Contacts",
"encryption": "Encryption" "encryption": "Encryption",
"sidebar_apps": "Sidebar Apps"
}, },
"tab_groups": { "tab_groups": {
"general": "General", "general": "General",
@@ -1004,6 +1036,14 @@
"button": "Import" "button": "Import"
} }
}, },
"sidebar_apps": {
"title": "Sidebar Apps",
"description": "Manage custom apps and links in your sidebar",
"keep_loaded": "Keep Apps Loaded",
"keep_loaded_description": "Keep inline apps running in the background when switching between them to avoid reloading",
"manage_title": "Custom Apps",
"manage_description": "Add, edit, or remove custom apps from your sidebar"
},
"contacts": { "contacts": {
"title": "Contacts", "title": "Contacts",
"description": "Import and export your contacts", "description": "Import and export your contacts",
+42 -2
View File
@@ -98,7 +98,38 @@
"vacation_active": "Respuesta automática activa", "vacation_active": "Respuesta automática activa",
"tags": "Etiquetas", "tags": "Etiquetas",
"mail": "Correo", "mail": "Correo",
"nav_label": "Navegación" "nav_label": "Navegación",
"add_app": "Apps"
},
"sidebar_apps": {
"modal_title": "Aplicaciones de la barra lateral",
"add_new": "Añadir aplicación",
"edit_app": "Editar aplicación",
"name_label": "Nombre",
"name_placeholder": "Mi aplicación",
"name_required": "El nombre es obligatorio",
"url_label": "URL",
"url_required": "La URL es obligatoria",
"url_invalid": "Introduce una URL http o https válida",
"icon_label": "Icono",
"icon_required": "El icono es obligatorio",
"open_mode_label": "Modo de apertura",
"open_new_tab": "Nueva pestaña",
"open_inline": "Integrado",
"cancel": "Cancelar",
"add": "Añadir",
"update": "Actualizar",
"delete": "Eliminar",
"delete_confirm_title": "Eliminar aplicación",
"delete_confirm": "¿Estás seguro de que quieres eliminar \"{name}\"?",
"no_apps": "No se han añadido aplicaciones",
"no_apps_hint": "Añade aplicaciones y enlaces personalizados a tu barra lateral",
"search_icons": "Buscar iconos...",
"show_popular": "Populares",
"show_all": "Todos",
"no_icons_found": "No se encontraron iconos",
"inline_badge": "Integrado",
"tab_badge": "Pestaña"
}, },
"email_list": { "email_list": {
"no_emails": "No se encontraron mensajes", "no_emails": "No se encontraron mensajes",
@@ -563,7 +594,8 @@
"security": "Seguridad", "security": "Seguridad",
"encryption": "Cifrado", "encryption": "Cifrado",
"files": "Archivos", "files": "Archivos",
"contacts": "Contacts" "contacts": "Contacts",
"sidebar_apps": "Apps de barra lateral"
}, },
"tab_groups": { "tab_groups": {
"general": "General", "general": "General",
@@ -1004,6 +1036,14 @@
"button": "Importar" "button": "Importar"
} }
}, },
"sidebar_apps": {
"title": "Apps de barra lateral",
"description": "Gestionar aplicaciones y enlaces personalizados en la barra lateral",
"keep_loaded": "Mantener apps cargadas",
"keep_loaded_description": "Mantener las apps en línea ejecutándose en segundo plano al cambiar entre ellas para evitar recargas",
"manage_title": "Apps personalizadas",
"manage_description": "Agregar, editar o eliminar apps personalizadas de la barra lateral"
},
"contacts": { "contacts": {
"title": "Contacts", "title": "Contacts",
"description": "Import and export your contacts", "description": "Import and export your contacts",
+42 -2
View File
@@ -98,7 +98,38 @@
"vacation_active": "Répondeur d'absence activé", "vacation_active": "Répondeur d'absence activé",
"tags": "Étiquettes", "tags": "Étiquettes",
"mail": "Messagerie", "mail": "Messagerie",
"nav_label": "Navigation" "nav_label": "Navigation",
"add_app": "Apps"
},
"sidebar_apps": {
"modal_title": "Applications de la barre latérale",
"add_new": "Ajouter une application",
"edit_app": "Modifier l'application",
"name_label": "Nom",
"name_placeholder": "Mon application",
"name_required": "Le nom est requis",
"url_label": "URL",
"url_required": "L'URL est requise",
"url_invalid": "Entrez une URL http ou https valide",
"icon_label": "Icône",
"icon_required": "L'icône est requise",
"open_mode_label": "Mode d'ouverture",
"open_new_tab": "Nouvel onglet",
"open_inline": "Intégré",
"cancel": "Annuler",
"add": "Ajouter",
"update": "Mettre à jour",
"delete": "Supprimer",
"delete_confirm_title": "Supprimer l'application",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer \"{name}\" ?",
"no_apps": "Aucune application ajoutée",
"no_apps_hint": "Ajoutez des applications et liens personnalisés à votre barre latérale",
"search_icons": "Rechercher des icônes...",
"show_popular": "Populaires",
"show_all": "Toutes",
"no_icons_found": "Aucune icône trouvée",
"inline_badge": "Intégré",
"tab_badge": "Onglet"
}, },
"email_list": { "email_list": {
"no_emails": "Aucun message trouvé", "no_emails": "Aucun message trouvé",
@@ -563,7 +594,8 @@
"security": "Sécurité", "security": "Sécurité",
"encryption": "Chiffrement", "encryption": "Chiffrement",
"files": "Fichiers", "files": "Fichiers",
"contacts": "Contacts" "contacts": "Contacts",
"sidebar_apps": "Apps de la barre latérale"
}, },
"tab_groups": { "tab_groups": {
"general": "Général", "general": "Général",
@@ -1004,6 +1036,14 @@
"button": "Importer" "button": "Importer"
} }
}, },
"sidebar_apps": {
"title": "Apps de la barre latérale",
"description": "Gérer les applications et liens personnalisés dans la barre latérale",
"keep_loaded": "Garder les apps chargées",
"keep_loaded_description": "Garder les apps en ligne en arrière-plan lors du basculement pour éviter le rechargement",
"manage_title": "Apps personnalisées",
"manage_description": "Ajouter, modifier ou supprimer des apps personnalisées de la barre latérale"
},
"contacts": { "contacts": {
"title": "Contacts", "title": "Contacts",
"description": "Import and export your contacts", "description": "Import and export your contacts",
+42 -2
View File
@@ -98,7 +98,38 @@
"vacation_active": "Risponditore automatico attivo", "vacation_active": "Risponditore automatico attivo",
"tags": "Etichette", "tags": "Etichette",
"mail": "Posta", "mail": "Posta",
"nav_label": "Navigazione" "nav_label": "Navigazione",
"add_app": "App"
},
"sidebar_apps": {
"modal_title": "App della barra laterale",
"add_new": "Aggiungi app",
"edit_app": "Modifica app",
"name_label": "Nome",
"name_placeholder": "La mia app",
"name_required": "Il nome è obbligatorio",
"url_label": "URL",
"url_required": "L'URL è obbligatorio",
"url_invalid": "Inserisci un URL http o https valido",
"icon_label": "Icona",
"icon_required": "L'icona è obbligatoria",
"open_mode_label": "Modalità di apertura",
"open_new_tab": "Nuova scheda",
"open_inline": "Integrato",
"cancel": "Annulla",
"add": "Aggiungi",
"update": "Aggiorna",
"delete": "Elimina",
"delete_confirm_title": "Elimina app",
"delete_confirm": "Sei sicuro di voler eliminare \"{name}\"?",
"no_apps": "Nessuna app aggiunta",
"no_apps_hint": "Aggiungi app e link personalizzati alla barra laterale",
"search_icons": "Cerca icone...",
"show_popular": "Popolari",
"show_all": "Tutte",
"no_icons_found": "Nessuna icona trovata",
"inline_badge": "Integrato",
"tab_badge": "Scheda"
}, },
"email_list": { "email_list": {
"no_emails": "Nessun messaggio trovato", "no_emails": "Nessun messaggio trovato",
@@ -563,7 +594,8 @@
"security": "Sicurezza", "security": "Sicurezza",
"encryption": "Cifratura", "encryption": "Cifratura",
"files": "File", "files": "File",
"contacts": "Contacts" "contacts": "Contacts",
"sidebar_apps": "App nella barra laterale"
}, },
"tab_groups": { "tab_groups": {
"general": "Generale", "general": "Generale",
@@ -1004,6 +1036,14 @@
"button": "Importa" "button": "Importa"
} }
}, },
"sidebar_apps": {
"title": "App nella barra laterale",
"description": "Gestisci app e link personalizzati nella barra laterale",
"keep_loaded": "Mantieni app caricate",
"keep_loaded_description": "Mantieni le app inline in esecuzione in background durante il passaggio per evitare il ricaricamento",
"manage_title": "App personalizzate",
"manage_description": "Aggiungi, modifica o rimuovi app personalizzate dalla barra laterale"
},
"contacts": { "contacts": {
"title": "Contacts", "title": "Contacts",
"description": "Import and export your contacts", "description": "Import and export your contacts",
+42 -2
View File
@@ -98,7 +98,38 @@
"vacation_active": "不在応答が有効です", "vacation_active": "不在応答が有効です",
"tags": "タグ", "tags": "タグ",
"mail": "メール", "mail": "メール",
"nav_label": "ナビゲーション" "nav_label": "ナビゲーション",
"add_app": "アプリ"
},
"sidebar_apps": {
"modal_title": "サイドバーアプリ",
"add_new": "アプリを追加",
"edit_app": "アプリを編集",
"name_label": "名前",
"name_placeholder": "マイアプリ",
"name_required": "名前は必須です",
"url_label": "URL",
"url_required": "URLは必須です",
"url_invalid": "有効なhttpまたはhttps URLを入力してください",
"icon_label": "アイコン",
"icon_required": "アイコンは必須です",
"open_mode_label": "開き方",
"open_new_tab": "新しいタブ",
"open_inline": "埋め込み",
"cancel": "キャンセル",
"add": "追加",
"update": "更新",
"delete": "削除",
"delete_confirm_title": "アプリを削除",
"delete_confirm": "「{name}」を削除してもよろしいですか?",
"no_apps": "アプリが追加されていません",
"no_apps_hint": "カスタムアプリやリンクをサイドバーに追加できます",
"search_icons": "アイコンを検索...",
"show_popular": "人気",
"show_all": "すべて",
"no_icons_found": "アイコンが見つかりません",
"inline_badge": "埋め込み",
"tab_badge": "タブ"
}, },
"email_list": { "email_list": {
"no_emails": "メッセージが見つかりません", "no_emails": "メッセージが見つかりません",
@@ -563,7 +594,8 @@
"security": "セキュリティ", "security": "セキュリティ",
"encryption": "暗号化", "encryption": "暗号化",
"files": "ファイル", "files": "ファイル",
"contacts": "Contacts" "contacts": "Contacts",
"sidebar_apps": "サイドバーアプリ"
}, },
"tab_groups": { "tab_groups": {
"general": "一般", "general": "一般",
@@ -1004,6 +1036,14 @@
"button": "インポート" "button": "インポート"
} }
}, },
"sidebar_apps": {
"title": "サイドバーアプリ",
"description": "サイドバーのカスタムアプリとリンクを管理",
"keep_loaded": "アプリを読み込んだまま保持",
"keep_loaded_description": "切り替え時にインラインアプリをバックグラウンドで実行し続けて再読み込みを回避",
"manage_title": "カスタムアプリ",
"manage_description": "サイドバーのカスタムアプリを追加、編集、削除"
},
"contacts": { "contacts": {
"title": "Contacts", "title": "Contacts",
"description": "Import and export your contacts", "description": "Import and export your contacts",
+42 -2
View File
@@ -98,7 +98,38 @@
"vacation_active": "Afwezigheidsmelder is actief", "vacation_active": "Afwezigheidsmelder is actief",
"tags": "Labels", "tags": "Labels",
"mail": "E-mail", "mail": "E-mail",
"nav_label": "Navigatie" "nav_label": "Navigatie",
"add_app": "Apps"
},
"sidebar_apps": {
"modal_title": "Zijbalk-apps",
"add_new": "App toevoegen",
"edit_app": "App bewerken",
"name_label": "Naam",
"name_placeholder": "Mijn app",
"name_required": "Naam is vereist",
"url_label": "URL",
"url_required": "URL is vereist",
"url_invalid": "Voer een geldige http- of https-URL in",
"icon_label": "Pictogram",
"icon_required": "Pictogram is vereist",
"open_mode_label": "Openingsmodus",
"open_new_tab": "Nieuw tabblad",
"open_inline": "Ingesloten",
"cancel": "Annuleren",
"add": "Toevoegen",
"update": "Bijwerken",
"delete": "Verwijderen",
"delete_confirm_title": "App verwijderen",
"delete_confirm": "Weet je zeker dat je \"{name}\" wilt verwijderen?",
"no_apps": "Nog geen apps toegevoegd",
"no_apps_hint": "Voeg aangepaste apps en links toe aan je zijbalk",
"search_icons": "Pictogrammen zoeken...",
"show_popular": "Populair",
"show_all": "Alle",
"no_icons_found": "Geen pictogrammen gevonden",
"inline_badge": "Ingesloten",
"tab_badge": "Tabblad"
}, },
"email_list": { "email_list": {
"no_emails": "Geen berichten gevonden", "no_emails": "Geen berichten gevonden",
@@ -563,7 +594,8 @@
"security": "Beveiliging", "security": "Beveiliging",
"encryption": "Versleuteling", "encryption": "Versleuteling",
"files": "Bestanden", "files": "Bestanden",
"contacts": "Contacts" "contacts": "Contacts",
"sidebar_apps": "Zijbalk-apps"
}, },
"tab_groups": { "tab_groups": {
"general": "Algemeen", "general": "Algemeen",
@@ -1004,6 +1036,14 @@
"button": "Importeren" "button": "Importeren"
} }
}, },
"sidebar_apps": {
"title": "Zijbalk-apps",
"description": "Aangepaste apps en links in de zijbalk beheren",
"keep_loaded": "Apps geladen houden",
"keep_loaded_description": "Inline-apps op de achtergrond laten draaien bij het wisselen om herladen te voorkomen",
"manage_title": "Aangepaste apps",
"manage_description": "Aangepaste apps toevoegen, bewerken of verwijderen uit de zijbalk"
},
"contacts": { "contacts": {
"title": "Contacts", "title": "Contacts",
"description": "Import and export your contacts", "description": "Import and export your contacts",
+42 -2
View File
@@ -98,7 +98,38 @@
"vacation_active": "Resposta automática ativa", "vacation_active": "Resposta automática ativa",
"tags": "Etiquetas", "tags": "Etiquetas",
"mail": "E-mail", "mail": "E-mail",
"nav_label": "Navegação" "nav_label": "Navegação",
"add_app": "Apps"
},
"sidebar_apps": {
"modal_title": "Apps da barra lateral",
"add_new": "Adicionar app",
"edit_app": "Editar app",
"name_label": "Nome",
"name_placeholder": "Meu app",
"name_required": "Nome é obrigatório",
"url_label": "URL",
"url_required": "URL é obrigatório",
"url_invalid": "Insira uma URL http ou https válida",
"icon_label": "Ícone",
"icon_required": "Ícone é obrigatório",
"open_mode_label": "Modo de abertura",
"open_new_tab": "Nova aba",
"open_inline": "Integrado",
"cancel": "Cancelar",
"add": "Adicionar",
"update": "Atualizar",
"delete": "Excluir",
"delete_confirm_title": "Excluir app",
"delete_confirm": "Tem certeza de que deseja excluir \"{name}\"?",
"no_apps": "Nenhum app adicionado",
"no_apps_hint": "Adicione apps e links personalizados à sua barra lateral",
"search_icons": "Pesquisar ícones...",
"show_popular": "Populares",
"show_all": "Todos",
"no_icons_found": "Nenhum ícone encontrado",
"inline_badge": "Integrado",
"tab_badge": "Aba"
}, },
"email_list": { "email_list": {
"no_emails": "Nenhuma mensagem encontrada", "no_emails": "Nenhuma mensagem encontrada",
@@ -563,7 +594,8 @@
"security": "Segurança", "security": "Segurança",
"encryption": "Criptografia", "encryption": "Criptografia",
"files": "Arquivos", "files": "Arquivos",
"contacts": "Contacts" "contacts": "Contacts",
"sidebar_apps": "Apps da barra lateral"
}, },
"tab_groups": { "tab_groups": {
"general": "Geral", "general": "Geral",
@@ -1004,6 +1036,14 @@
"button": "Importar" "button": "Importar"
} }
}, },
"sidebar_apps": {
"title": "Apps da barra lateral",
"description": "Gerenciar aplicativos e links personalizados na barra lateral",
"keep_loaded": "Manter apps carregados",
"keep_loaded_description": "Manter apps inline em execução em segundo plano ao alternar entre eles para evitar recarregamento",
"manage_title": "Apps personalizados",
"manage_description": "Adicionar, editar ou remover apps personalizados da barra lateral"
},
"contacts": { "contacts": {
"title": "Contacts", "title": "Contacts",
"description": "Import and export your contacts", "description": "Import and export your contacts",
+48
View File
@@ -38,6 +38,14 @@ export interface KeywordDefinition {
color: string; // Key from KEYWORD_PALETTE color: string; // Key from KEYWORD_PALETTE
} }
export interface SidebarApp {
id: string;
name: string;
url: string;
icon: string; // Lucide icon name (e.g. 'Globe', 'Rss')
openMode: 'tab' | 'inline'; // Open in new tab or embed inline
}
// Available color palette for keywords // Available color palette for keywords
export const KEYWORD_PALETTE: Record<string, { dot: string; bg: string }> = { export const KEYWORD_PALETTE: Record<string, { dot: string; bg: string }> = {
red: { dot: 'bg-red-500', bg: 'bg-red-50 dark:bg-red-950/30' }, red: { dot: 'bg-red-500', bg: 'bg-red-50 dark:bg-red-950/30' },
@@ -113,6 +121,11 @@ interface SettingsState {
// Keywords (labels/tags) // Keywords (labels/tags)
emailKeywords: KeywordDefinition[]; emailKeywords: KeywordDefinition[];
// Sidebar Apps
sidebarApps: SidebarApp[];
keepAppsLoaded: boolean;
// Advanced // Advanced
debugMode: boolean; debugMode: boolean;
settingsSyncDisabled: boolean; settingsSyncDisabled: boolean;
@@ -142,6 +155,12 @@ interface SettingsState {
reorderKeywords: (keywords: KeywordDefinition[]) => void; reorderKeywords: (keywords: KeywordDefinition[]) => void;
getKeywordById: (id: string) => KeywordDefinition | undefined; getKeywordById: (id: string) => KeywordDefinition | undefined;
// Sidebar Apps
addSidebarApp: (app: SidebarApp) => void;
updateSidebarApp: (id: string, updates: Partial<Omit<SidebarApp, 'id'>>) => void;
removeSidebarApp: (id: string) => void;
reorderSidebarApps: (apps: SidebarApp[]) => void;
// Settings sync // Settings sync
enableSync: (username: string, serverUrl: string) => void; enableSync: (username: string, serverUrl: string) => void;
disableSync: () => void; disableSync: () => void;
@@ -197,6 +216,10 @@ const DEFAULT_SETTINGS = {
// Keywords // Keywords
emailKeywords: DEFAULT_KEYWORDS, emailKeywords: DEFAULT_KEYWORDS,
// Sidebar Apps
sidebarApps: [] as SidebarApp[],
keepAppsLoaded: false,
// Advanced // Advanced
debugMode: false, debugMode: false,
settingsSyncDisabled: false, settingsSyncDisabled: false,
@@ -261,6 +284,8 @@ export const useSettingsStore = create<SettingsState>()(
senderFavicons: state.senderFavicons, senderFavicons: state.senderFavicons,
folderIcons: state.folderIcons, folderIcons: state.folderIcons,
emailKeywords: state.emailKeywords, emailKeywords: state.emailKeywords,
sidebarApps: state.sidebarApps,
keepAppsLoaded: state.keepAppsLoaded,
debugMode: state.debugMode, debugMode: state.debugMode,
settingsSyncDisabled: state.settingsSyncDisabled, settingsSyncDisabled: state.settingsSyncDisabled,
// Cross-store settings // Cross-store settings
@@ -364,6 +389,29 @@ export const useSettingsStore = create<SettingsState>()(
return get().emailKeywords.find(k => k.id === id); return get().emailKeywords.find(k => k.id === id);
}, },
// Sidebar Apps methods
addSidebarApp: (app: SidebarApp) => {
const current = get().sidebarApps;
if (current.some(a => a.id === app.id)) return;
set({ sidebarApps: [...current, app] });
},
updateSidebarApp: (id: string, updates: Partial<Omit<SidebarApp, 'id'>>) => {
set({
sidebarApps: get().sidebarApps.map(a =>
a.id === id ? { ...a, ...updates } : a
),
});
},
removeSidebarApp: (id: string) => {
set({ sidebarApps: get().sidebarApps.filter(a => a.id !== id) });
},
reorderSidebarApps: (apps: SidebarApp[]) => {
set({ sidebarApps: apps });
},
// Settings sync methods // Settings sync methods
enableSync: (username: string, serverUrl: string) => { enableSync: (username: string, serverUrl: string) => {
syncUsername = username; syncUsername = username;