feat: narrow-pane sidebars and cross-account file picker in Pro shell

This commit is contained in:
Linus Rath
2026-05-21 17:54:03 +02:00
parent 15a67a14b3
commit 33e655bcce
9 changed files with 420 additions and 130 deletions
+42 -15
View File
@@ -17,7 +17,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { toast } from "@/stores/toast-store";
import { useIsMobile } from "@/hooks/use-media-query";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
@@ -76,7 +76,13 @@ export default function CalendarPage() {
const t = useTranslations("calendar");
const tWebcalAction = useTranslations("calendar.webcal_action");
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
// When the pane (Pro shell) or window is narrower than `lg`, the sidebar
// collapses into a burger-toggled overlay instead of taking inline space.
const isNarrow = !isDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -406,6 +412,8 @@ export default function CalendarPage() {
setMobileReturnToMonth(true);
setViewMode("day");
}
// Close the narrow-pane sidebar overlay after the user picks a date.
setNarrowSidebarOpen(false);
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
const navigateBackToMonth = useCallback(() => {
@@ -1221,7 +1229,7 @@ export default function CalendarPage() {
return (
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot />
<div className={cn("flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
<div className={cn("relative flex flex-1 min-h-0 overflow-hidden", isMobile && "flex-col")}>
{/* Left Navigation Rail (hidden when embedded in Pro shell) */}
{!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
@@ -1242,15 +1250,31 @@ export default function CalendarPage() {
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{/* Sidebar - full height */}
{!isMobile && !inlineApp && (
{/* Narrow-pane backdrop: dim and close overlay sidebar */}
{isNarrow && narrowSidebarOpen && !inlineApp && (
<div
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed"
)}
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{/* Sidebar - in-flow when desktop pane, overlay when narrow */}
{!inlineApp && (
<>
<div
className={cn(
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
!isResizing && "transition-[width] duration-300"
!isResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full"
)
)}
style={{ width: `${calSidebarWidth}px` }}
style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
>
<MiniCalendar
selectedDate={selectedDate}
@@ -1313,15 +1337,17 @@ export default function CalendarPage() {
client={client}
/>
</div>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
}}
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
/>
{!isNarrow && (
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
}}
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
/>
)}
</>
)}
@@ -1343,6 +1369,7 @@ export default function CalendarPage() {
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
enableCalendarTasks={enableCalendarTasks}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/>
<div
+41 -16
View File
@@ -28,7 +28,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view";
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { useIsMobile } from "@/hooks/use-media-query";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
@@ -97,7 +97,13 @@ export default function ContactsPage() {
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
// Narrow pane (Pro split or small window): the categories sidebar collapses
// into a burger-toggled overlay.
const isNarrow = !isDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
// Panel resize state - sidebar (categories)
const [sidebarWidth, setSidebarWidth] = useState(() => {
@@ -198,6 +204,7 @@ export default function ContactsPage() {
} else {
setSelectedGroupId(null);
}
setNarrowSidebarOpen(false);
}, [clearSelection]);
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
@@ -689,18 +696,33 @@ export default function ContactsPage() {
{inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)}
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
<div className={cn("relative flex flex-1 min-h-0", inlineApp && "hidden")}>
{/* Narrow-pane backdrop for the overlay categories sidebar */}
{isNarrow && narrowSidebarOpen && (
<div
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed"
)}
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{showListPanel && (
<>
{/* Panel 1: Categories sidebar */}
{!isMobile && (
{/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */}
{(!isMobile || isNarrow) && (
<>
<div
className={cn(
"border-r border-border flex flex-col flex-shrink-0",
!isSidebarResizing && "transition-[width] duration-300"
"border-r border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full"
)
)}
style={{ width: `${sidebarWidth}px` }}
style={isNarrow ? undefined : { width: `${sidebarWidth}px` }}
>
<ContactsSidebar
groups={groups}
@@ -739,15 +761,17 @@ export default function ContactsPage() {
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
/>
</div>
<ResizeHandle
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsSidebarResizing(false);
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
}}
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
/>
{!isNarrow && (
<ResizeHandle
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsSidebarResizing(false);
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
}}
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
/>
)}
</>
)}
@@ -780,6 +804,7 @@ export default function ContactsPage() {
onEditContact={handleEditContact}
onDeleteContact={handleDeleteContact}
onAddContactToGroup={handleAddContactToGroup}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/>
</div>
+62 -6
View File
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useFileStore } from "@/stores/file-store";
import { toast } from "@/stores/toast-store";
@@ -33,6 +34,9 @@ export default function FilesPage() {
const t = useTranslations("files");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const getClientForAccount = useAuthStore((s) => s.getClientForAccount);
const accounts = useAccountStore((s) => s.accounts);
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const { quota, isPushConnected } = useEmailStore();
@@ -130,13 +134,18 @@ export default function FilesPage() {
}
}, [initialCheckDone, isAuthenticated, authLoading]);
// Initialize JMAP files client
// Initialize JMAP files client. In the Pro shell, all connected accounts
// are surfaced as top-level folders at the root, so we *don't* auto-attach
// to the active account — the user picks one explicitly.
useEffect(() => {
if (isAuthenticated && client && !hasFetched.current) {
hasFetched.current = true;
initClient(client);
if (!isAuthenticated || !client || hasFetched.current) return;
hasFetched.current = true;
if (isEmbedded) {
useFileStore.getState().clearClient();
} else {
initClient(client, activeAccountId);
}
}, [isAuthenticated, client, initClient]);
}, [isAuthenticated, client, initClient, activeAccountId, isEmbedded]);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh files via JMAP instead of reloading the page.
@@ -160,6 +169,17 @@ export default function FilesPage() {
}, [storeClient, supportsFiles, checkSupport, navigate]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
// Pro shell only: the Account breadcrumb segment signals "go to this
// account's filesystem root" via a sentinel, distinguishing it from a
// Home click (which detaches the account and returns to the picker).
if (resourceId === '__account_root__') {
void navigate(null);
return;
}
if (isEmbedded && path === '/' && resourceId === undefined) {
useFileStore.getState().clearClient();
return;
}
if (resourceId !== undefined) {
// Direct ID-based navigation (directory click, breadcrumb dropdown folder)
navigate(resourceId, path.split('/').pop() || '');
@@ -167,7 +187,7 @@ export default function FilesPage() {
// Path-based navigation (breadcrumbs, favorites, recent files)
navigateByPath(path);
}
}, [navigate, navigateByPath]);
}, [navigate, navigateByPath, isEmbedded]);
const handleCreateFolder = useCallback(async (name: string) => {
try {
@@ -374,6 +394,38 @@ export default function FilesPage() {
setShowDetails(v => !v);
}, []);
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
// Pro shell only: all connected accounts are equal top-level entries at
// the root. The root path "/" itself is a cross-account picker — no
// account's files are shown until the user enters one.
const accountFolders = isEmbedded
? accounts
.filter((a) => a.isConnected)
.map((a) => ({
accountId: a.id,
label: a.label || a.email,
email: a.email,
avatarColor: a.avatarColor,
}))
: [];
const isAccountPicker = isEmbedded && currentFilesAccountId === null;
const currentAccountLabel = isEmbedded && currentFilesAccountId
? (accounts.find((a) => a.id === currentFilesAccountId)?.label
|| accounts.find((a) => a.id === currentFilesAccountId)?.email
|| null)
: null;
const handleSelectAccount = useCallback((accountId: string) => {
const nextClient = getClientForAccount(accountId);
if (!nextClient) return;
const store = useFileStore.getState();
store.initClient(nextClient, accountId);
// Reset supportsFiles so the existing checkSupport effect re-runs for
// the freshly-attached client and triggers the initial navigate(null).
useFileStore.setState({ supportsFiles: null });
}, [getClientForAccount]);
if (!isAuthenticated) return null;
return (
@@ -479,6 +531,10 @@ export default function FilesPage() {
showDetails={showDetails}
onToggleDetails={handleToggleDetails}
detailResource={detailResource}
accountFolders={accountFolders}
onSelectAccount={handleSelectAccount}
accountPickerMode={isAccountPicker}
accountLabel={currentAccountLabel}
/>
</div>
)}