feat: narrow-pane sidebars and cross-account file picker in Pro shell
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user