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 { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { toast } from "@/stores/toast-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 { Button } from "@/components/ui/button";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar"; import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
@@ -76,7 +76,13 @@ export default function CalendarPage() {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const tWebcalAction = useTranslations("calendar.webcal_action"); const tWebcalAction = useTranslations("calendar.webcal_action");
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded(); 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 { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore(); const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
@@ -406,6 +412,8 @@ export default function CalendarPage() {
setMobileReturnToMonth(true); setMobileReturnToMonth(true);
setViewMode("day"); setViewMode("day");
} }
// Close the narrow-pane sidebar overlay after the user picks a date.
setNarrowSidebarOpen(false);
}, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]); }, [setSelectedDate, isMobile, normalizedViewMode, setViewMode]);
const navigateBackToMonth = useCallback(() => { const navigateBackToMonth = useCallback(() => {
@@ -1221,7 +1229,7 @@ export default function CalendarPage() {
return ( return (
<div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}> <div className={cn("flex flex-col bg-background overflow-hidden pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}>
<AppTopBannerSlot /> <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) */} {/* Left Navigation Rail (hidden when embedded in Pro shell) */}
{!isMobile && !isEmbedded && ( {!isMobile && !isEmbedded && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}> <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" /> <InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)} )}
{/* Sidebar - full height */} {/* Narrow-pane backdrop: dim and close overlay sidebar */}
{!isMobile && !inlineApp && ( {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 <div
className={cn( className={cn(
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3", "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 <MiniCalendar
selectedDate={selectedDate} selectedDate={selectedDate}
@@ -1313,15 +1337,17 @@ export default function CalendarPage() {
client={client} client={client}
/> />
</div> </div>
<ResizeHandle {!isNarrow && (
onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }} <ResizeHandle
onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} onResizeStart={() => { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }}
onResizeEnd={() => { onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
setIsResizing(false); onResizeEnd={() => {
localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth)); setIsResizing(false);
}} localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth));
onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }} }}
/> onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }}
/>
)}
</> </>
)} )}
@@ -1343,6 +1369,7 @@ export default function CalendarPage() {
selectedCalendarIds={selectedCalendarIds} selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility} onToggleVisibility={toggleCalendarVisibility}
enableCalendarTasks={enableCalendarTasks} enableCalendarTasks={enableCalendarTasks}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/> />
<div <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 { useSidebarApps } from "@/hooks/use-sidebar-apps";
import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { ResizeHandle } from "@/components/layout/resize-handle"; 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 { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types"; import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
@@ -97,7 +97,13 @@ export default function ContactsPage() {
const hasFetched = useRef(false); const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded(); 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) // Panel resize state - sidebar (categories)
const [sidebarWidth, setSidebarWidth] = useState(() => { const [sidebarWidth, setSidebarWidth] = useState(() => {
@@ -198,6 +204,7 @@ export default function ContactsPage() {
} else { } else {
setSelectedGroupId(null); setSelectedGroupId(null);
} }
setNarrowSidebarOpen(false);
}, [clearSelection]); }, [clearSelection]);
const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => { const handleDropContacts = useCallback(async (contactIds: string[], addressBook: AddressBook) => {
@@ -689,18 +696,33 @@ export default function ContactsPage() {
{inlineApp && ( {inlineApp && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} /> <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 && ( {showListPanel && (
<> <>
{/* Panel 1: Categories sidebar */} {/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */}
{!isMobile && ( {(!isMobile || isNarrow) && (
<> <>
<div <div
className={cn( className={cn(
"border-r border-border flex flex-col flex-shrink-0", "border-r border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300" !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 <ContactsSidebar
groups={groups} groups={groups}
@@ -739,15 +761,17 @@ export default function ContactsPage() {
onRenameKeyword={(kw) => setRenamingKeyword(kw)} onRenameKeyword={(kw) => setRenamingKeyword(kw)}
/> />
</div> </div>
<ResizeHandle {!isNarrow && (
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }} <ResizeHandle
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))} onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
onResizeEnd={() => { onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
setIsSidebarResizing(false); onResizeEnd={() => {
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth)); setIsSidebarResizing(false);
}} localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }} }}
/> onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
/>
)}
</> </>
)} )}
@@ -780,6 +804,7 @@ export default function ContactsPage() {
onEditContact={handleEditContact} onEditContact={handleEditContact}
onDeleteContact={handleDeleteContact} onDeleteContact={handleDeleteContact}
onAddContactToGroup={handleAddContactToGroup} onAddContactToGroup={handleAddContactToGroup}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
/> />
</div> </div>
+62 -6
View File
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
@@ -33,6 +34,9 @@ export default function FilesPage() {
const t = useTranslations("files"); const t = useTranslations("files");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); 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 { 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();
@@ -130,13 +134,18 @@ export default function FilesPage() {
} }
}, [initialCheckDone, isAuthenticated, authLoading]); }, [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(() => { useEffect(() => {
if (isAuthenticated && client && !hasFetched.current) { if (!isAuthenticated || !client || hasFetched.current) return;
hasFetched.current = true; hasFetched.current = true;
initClient(client); 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) // Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh files via JMAP instead of reloading the page. // and refresh files via JMAP instead of reloading the page.
@@ -160,6 +169,17 @@ export default function FilesPage() {
}, [storeClient, supportsFiles, checkSupport, navigate]); }, [storeClient, supportsFiles, checkSupport, navigate]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => { 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) { if (resourceId !== undefined) {
// Direct ID-based navigation (directory click, breadcrumb dropdown folder) // Direct ID-based navigation (directory click, breadcrumb dropdown folder)
navigate(resourceId, path.split('/').pop() || ''); navigate(resourceId, path.split('/').pop() || '');
@@ -167,7 +187,7 @@ export default function FilesPage() {
// Path-based navigation (breadcrumbs, favorites, recent files) // Path-based navigation (breadcrumbs, favorites, recent files)
navigateByPath(path); navigateByPath(path);
} }
}, [navigate, navigateByPath]); }, [navigate, navigateByPath, isEmbedded]);
const handleCreateFolder = useCallback(async (name: string) => { const handleCreateFolder = useCallback(async (name: string) => {
try { try {
@@ -374,6 +394,38 @@ export default function FilesPage() {
setShowDetails(v => !v); 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; if (!isAuthenticated) return null;
return ( return (
@@ -479,6 +531,10 @@ export default function FilesPage() {
showDetails={showDetails} showDetails={showDetails}
onToggleDetails={handleToggleDetails} onToggleDetails={handleToggleDetails}
detailResource={detailResource} detailResource={detailResource}
accountFolders={accountFolders}
onSelectAccount={handleSelectAccount}
accountPickerMode={isAccountPicker}
accountLabel={currentAccountLabel}
/> />
</div> </div>
)} )}
+25 -1
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft } from "lucide-react"; import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
import { addDays, startOfWeek } from "date-fns"; import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store"; import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -26,6 +26,8 @@ interface CalendarToolbarProps {
selectedCalendarIds?: string[]; selectedCalendarIds?: string[];
onToggleVisibility?: (id: string) => void; onToggleVisibility?: (id: string) => void;
enableCalendarTasks?: boolean; enableCalendarTasks?: boolean;
/** Show a burger button at the start that opens the (overlay) sidebar. */
onMenuClick?: () => void;
} }
export function CalendarToolbar({ export function CalendarToolbar({
@@ -45,6 +47,7 @@ export function CalendarToolbar({
selectedCalendarIds, selectedCalendarIds,
onToggleVisibility, onToggleVisibility,
enableCalendarTasks, enableCalendarTasks,
onMenuClick,
}: CalendarToolbarProps) { }: CalendarToolbarProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const formatter = useFormatter(); const formatter = useFormatter();
@@ -115,11 +118,32 @@ export function CalendarToolbar({
return ( return (
<div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}> <div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
{/* Burger menu (rendered in pages that use a narrow overlay sidebar) */}
{onMenuClick && !isMobile && (
<Button
variant="ghost"
size="icon"
onClick={onMenuClick}
className="h-8 w-8 -ml-1 mr-1"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
</Button>
)}
{/* ── MOBILE TOOLBAR ── */} {/* ── MOBILE TOOLBAR ── */}
{isMobile && ( {isMobile && (
<div className="flex flex-col gap-1 px-2 py-2"> <div className="flex flex-col gap-1 px-2 py-2">
{/* Row 1: Back / Date nav / Today */} {/* Row 1: Back / Date nav / Today */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{onMenuClick && (
<button
onClick={onMenuClick}
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
</button>
)}
{onNavigateBack && ( {onNavigateBack && (
<button <button
onClick={onNavigateBack} onClick={onNavigateBack}
+14 -1
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslations, useLocale } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw } from "lucide-react"; import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item"; import { ContactListItem } from "./contact-list-item";
@@ -112,6 +112,8 @@ interface ContactListProps {
onEditContact: (id: string) => void; onEditContact: (id: string) => void;
onDeleteContact: (contact: ContactCard) => void; onDeleteContact: (contact: ContactCard) => void;
onAddContactToGroup: (id: string) => void; onAddContactToGroup: (id: string) => void;
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
onMenuClick?: () => void;
} }
export function ContactList({ export function ContactList({
@@ -133,6 +135,7 @@ export function ContactList({
onEditContact, onEditContact,
onDeleteContact, onDeleteContact,
onAddContactToGroup, onAddContactToGroup,
onMenuClick,
}: ContactListProps) { }: ContactListProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const locale = useLocale(); const locale = useLocale();
@@ -267,6 +270,16 @@ export function ContactList({
<div className="border-b border-border bg-background"> <div className="border-b border-border bg-background">
<div className="px-3 py-3"> <div className="px-3 py-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{onMenuClick && (
<button
type="button"
onClick={onMenuClick}
className="flex-shrink-0 p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("open_categories")}
>
<Menu className="w-4 h-4" />
</button>
)}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
+205 -87
View File
@@ -11,7 +11,9 @@ import {
AlertCircle, Star, Clock, FolderUp, AlertCircle, Star, Clock, FolderUp,
FileArchive, FileSpreadsheet, Presentation, FileCode, FileArchive, FileSpreadsheet, Presentation, FileCode,
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon, Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
Menu,
} from "lucide-react"; } from "lucide-react";
import { useIsDesktop } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { NewFolderDialog } from "@/components/files/new-folder-dialog"; import { NewFolderDialog } from "@/components/files/new-folder-dialog";
@@ -35,6 +37,13 @@ interface ClipboardState {
sourceParentId: string | null; sourceParentId: string | null;
} }
export interface AccountFolderEntry {
accountId: string;
label: string;
email: string;
avatarColor: string;
}
interface FileBrowserProps { interface FileBrowserProps {
currentPath: string; currentPath: string;
resources: FileResource[]; resources: FileResource[];
@@ -78,6 +87,13 @@ interface FileBrowserProps {
showDetails: boolean; showDetails: boolean;
onToggleDetails: () => void; onToggleDetails: () => void;
detailResource: FileResource | null; detailResource: FileResource | null;
/** Pro shell only: all connected accounts surfaced as top-level folders at the root. */
accountFolders?: AccountFolderEntry[];
onSelectAccount?: (accountId: string) => void;
/** Pro shell only: when true, the root is a pure account picker — hide the file toolbar and don't render a regular listing. */
accountPickerMode?: boolean;
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */
accountLabel?: string | null;
} }
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]); const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
@@ -321,6 +337,10 @@ export function FileBrowser({
onToggleDetails, onToggleDetails,
detailResource, detailResource,
clipboard, clipboard,
accountFolders,
onSelectAccount,
accountPickerMode,
accountLabel,
}: FileBrowserProps) { }: FileBrowserProps) {
const t = useTranslations("files"); const t = useTranslations("files");
const [showNewFolder, setShowNewFolder] = useState(false); const [showNewFolder, setShowNewFolder] = useState(false);
@@ -352,6 +372,13 @@ export function FileBrowser({
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256); const dragStartWidth = useRef(256);
const [dragTarget, setDragTarget] = useState<string | null>(null); const [dragTarget, setDragTarget] = useState<string | null>(null);
// Pane-aware: in a Pro split pane (or a narrow window) the folder tree
// sidebar collapses into a burger-toggled overlay so it doesn't crowd the
// file list.
const isDesktopPane = useIsDesktop();
const isNarrow = !isDesktopPane;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
// Sync showThumbnails and folderLayout when settings change // Sync showThumbnails and folderLayout when settings change
useEffect(() => { useEffect(() => {
@@ -442,8 +469,10 @@ export function FileBrowser({
return sorted; return sorted;
}, [resources, searchQuery, sortKey, sortDir, folderLayout]); }, [resources, searchQuery, sortKey, sortDir, folderLayout]);
// Build breadcrumb segments // Build breadcrumb segments. In Pro mode an account is mounted "between"
const breadcrumbs = currentPath === '/' // Home and the account's filesystem — surfaced as a non-clickable label
// (clicking the actual account again would be a no-op; Home detaches it).
const breadcrumbs: { name: string; path: string; isAccount?: boolean }[] = currentPath === '/'
? [{ name: t("breadcrumb_root"), path: '/' }] ? [{ name: t("breadcrumb_root"), path: '/' }]
: [ : [
{ name: t("breadcrumb_root"), path: '/' }, { name: t("breadcrumb_root"), path: '/' },
@@ -452,6 +481,9 @@ export function FileBrowser({
path: '/' + arr.slice(0, i + 1).join('/'), path: '/' + arr.slice(0, i + 1).join('/'),
})), })),
]; ];
if (accountLabel) {
breadcrumbs.splice(1, 0, { name: accountLabel, path: '', isAccount: true });
}
const handleNavigateUp = useCallback(() => { const handleNavigateUp = useCallback(() => {
if (currentPath === '/') return; if (currentPath === '/') return;
@@ -846,14 +878,27 @@ export function FileBrowser({
> >
{/* Toolbar */} {/* Toolbar */}
<div role="toolbar" aria-label={t("toolbar")} className="flex items-center gap-2 px-4 py-2 border-b border-border bg-background"> <div role="toolbar" aria-label={t("toolbar")} className="flex items-center gap-2 px-4 py-2 border-b border-border bg-background">
{isNarrow && folderLayout === "sidebar" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 -ml-2"
onClick={() => setNarrowSidebarOpen((v) => !v)}
aria-label={t("open_folder_tree")}
>
<Menu className="w-4 h-4" />
</Button>
)}
{/* Breadcrumbs */} {/* Breadcrumbs */}
<nav aria-label={t("breadcrumb_root")} className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-x-auto"> <nav aria-label={t("breadcrumb_root")} className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-x-auto">
{breadcrumbs.map((crumb, i) => ( {breadcrumbs.map((crumb, i) => (
<span key={crumb.path} className="flex items-center gap-1 shrink-0"> <span key={`${i}:${crumb.path}`} className="flex items-center gap-1 shrink-0">
{i > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />} {i > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />}
<button <button
onClick={() => onNavigate(crumb.path)} onClick={() => crumb.isAccount
onContextMenu={(e) => handleBreadcrumbRightClick(e, crumb.path)} ? onNavigate('/', '__account_root__')
: onNavigate(crumb.path)}
onContextMenu={(e) => crumb.isAccount ? undefined : handleBreadcrumbRightClick(e, crumb.path)}
className={cn( className={cn(
"px-1.5 py-0.5 rounded hover:bg-muted transition-colors", "px-1.5 py-0.5 rounded hover:bg-muted transition-colors",
i === breadcrumbs.length - 1 i === breadcrumbs.length - 1
@@ -902,15 +947,17 @@ export function FileBrowser({
{t("paste")} ({clipboard.names.length}) {t("paste")} ({clipboard.names.length})
</Button> </Button>
)} )}
<Button {!accountPickerMode && (
variant="ghost" <Button
size="icon" variant="ghost"
className="h-8 w-8" size="icon"
onClick={() => setShowSearch(v => !v)} className="h-8 w-8"
title={t("search_placeholder")} onClick={() => setShowSearch(v => !v)}
> title={t("search_placeholder")}
<Search className="w-4 h-4" /> >
</Button> <Search className="w-4 h-4" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -920,62 +967,66 @@ export function FileBrowser({
> >
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <LayoutList className="w-4 h-4" />} {viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <LayoutList className="w-4 h-4" />}
</Button> </Button>
<Button {!accountPickerMode && (
variant="ghost" <>
size="icon" <Button
className={cn("h-8 w-8", showDetails && "bg-muted")} variant="ghost"
onClick={onToggleDetails} size="icon"
title={t("details")} className={cn("h-8 w-8", showDetails && "bg-muted")}
> onClick={onToggleDetails}
<Info className="w-4 h-4" /> title={t("details")}
</Button> >
<Button <Info className="w-4 h-4" />
variant="ghost" </Button>
size="icon" <Button
className={cn("h-8 w-8", favorites.includes(currentPath) && "text-yellow-500")} variant="ghost"
onClick={() => onToggleFavorite(currentPath)} size="icon"
title={t("toggle_favorite")} className={cn("h-8 w-8", favorites.includes(currentPath) && "text-yellow-500")}
> onClick={() => onToggleFavorite(currentPath)}
<Star className={cn("w-4 h-4", favorites.includes(currentPath) && "fill-current")} /> title={t("toggle_favorite")}
</Button> >
<Button <Star className={cn("w-4 h-4", favorites.includes(currentPath) && "fill-current")} />
variant="ghost" </Button>
size="icon" <Button
className="h-8 w-8" variant="ghost"
onClick={() => fileInputRef.current?.click()} size="icon"
title={t("upload")} className="h-8 w-8"
disabled={isUploading} onClick={() => fileInputRef.current?.click()}
> title={t("upload")}
<Upload className="w-4 h-4" /> disabled={isUploading}
</Button> >
<Button <Upload className="w-4 h-4" />
variant="ghost" </Button>
size="icon" <Button
className="h-8 w-8" variant="ghost"
onClick={() => folderInputRef.current?.click()} size="icon"
title={t("upload_folder")} className="h-8 w-8"
disabled={isUploading} onClick={() => folderInputRef.current?.click()}
> title={t("upload_folder")}
<FolderUp className="w-4 h-4" /> disabled={isUploading}
</Button> >
<Button <FolderUp className="w-4 h-4" />
variant="ghost" </Button>
size="icon" <Button
className="h-8 w-8" variant="ghost"
onClick={() => setShowNewFolder(true)} size="icon"
title={t("new_folder")} className="h-8 w-8"
> onClick={() => setShowNewFolder(true)}
<FolderPlus className="w-4 h-4" /> title={t("new_folder")}
</Button> >
<Button <FolderPlus className="w-4 h-4" />
variant="ghost" </Button>
size="icon" <Button
className="h-8 w-8" variant="ghost"
onClick={() => setShowNewTextFile(true)} size="icon"
title={t("new_text_file")} className="h-8 w-8"
> onClick={() => setShowNewTextFile(true)}
<FilePlus className="w-4 h-4" /> title={t("new_text_file")}
</Button> >
<FilePlus className="w-4 h-4" />
</Button>
</>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -1100,26 +1151,59 @@ export function FileBrowser({
{/* File list */} {/* File list */}
<div className="flex-1 min-h-0 flex relative"> <div className="flex-1 min-h-0 flex relative">
{/* Narrow-pane backdrop for the overlay folder tree */}
{folderLayout === "sidebar" && isNarrow && narrowSidebarOpen && (
<div
className="absolute inset-0 bg-black/50 z-40"
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{/* Folder tree sidebar (when layout is sidebar) */} {/* Folder tree sidebar (when layout is sidebar) */}
{folderLayout === "sidebar" && ( {folderLayout === "sidebar" && (
<> isNarrow ? (
<FolderTreeSidebar <div
currentPath={currentPath} className={cn(
onNavigate={onNavigate} "absolute inset-y-0 left-0 z-50",
listByParentId={listByParentId} "transform transition-transform duration-300 ease-in-out",
width={sidebarWidth} !narrowSidebarOpen && "-translate-x-full"
isResizing={isResizing} )}
/> onClick={(e) => {
<ResizeHandle // Auto-close when the user taps a folder name. Chevrons stay
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }} // open so they can expand/collapse without dismissing.
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} const target = e.target as HTMLElement;
onResizeEnd={() => { const btn = target.closest('button');
setIsResizing(false); if (btn && !btn.querySelector('svg.lucide-chevron-right, svg.lucide-chevron-down')) {
localStorage.setItem("files-sidebar-width", String(sidebarWidth)); setNarrowSidebarOpen(false);
}
}} }}
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("files-sidebar-width", "256"); }} >
/> <FolderTreeSidebar
</> currentPath={currentPath}
onNavigate={onNavigate}
listByParentId={listByParentId}
width={288}
/>
</div>
) : (
<>
<FolderTreeSidebar
currentPath={currentPath}
onNavigate={onNavigate}
listByParentId={listByParentId}
width={sidebarWidth}
isResizing={isResizing}
/>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("files-sidebar-width", String(sidebarWidth));
}}
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("files-sidebar-width", "256"); }}
/>
</>
)
)} )}
{/* Favorites & Recent sidebar (when layout is inline) */} {/* Favorites & Recent sidebar (when layout is inline) */}
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && ( {folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
@@ -1198,6 +1282,40 @@ export function FileBrowser({
<SkeletonRow /> <SkeletonRow />
</tbody> </tbody>
</table> </table>
) : accountPickerMode && accountFolders && accountFolders.length > 0 && onSelectAccount ? (
/* ======= ACCOUNT PICKER (Pro mode root) ======= */
<div className="p-4">
<div
className="grid gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(11rem, 1fr))' }}
>
{accountFolders.map((acc) => (
<button
key={`__account__:${acc.accountId}`}
onClick={() => onSelectAccount(acc.accountId)}
title={acc.email}
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-left min-w-0"
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-base font-medium text-white shrink-0"
style={{ backgroundColor: acc.avatarColor }}
>
{(acc.label || acc.email).charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex flex-col">
<span className="truncate text-sm font-medium">{acc.label || acc.email}</span>
{acc.label && acc.label !== acc.email && (
<span className="truncate text-xs text-muted-foreground">{acc.email}</span>
)}
</div>
</button>
))}
</div>
</div>
) : accountPickerMode ? (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("no_accounts")}</p>
</div>
) : resources.length === 0 && !searchQuery && currentPath === '/' ? ( ) : resources.length === 0 && !searchQuery && currentPath === '/' ? (
<FileUploadArea <FileUploadArea
onUpload={async (files: File[]) => { onUpload={async (files: File[]) => {
+1 -1
View File
@@ -130,7 +130,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
return ( return (
<div <div
className={cn( className={cn(
"border-r border-border bg-secondary overflow-hidden shrink-0 hidden lg:flex flex-col", "border-r border-border bg-secondary overflow-hidden shrink-0 flex flex-col h-full",
!isResizing && "transition-[width] duration-300" !isResizing && "transition-[width] duration-300"
)} )}
style={{ width: `${width}px` }} style={{ width: `${width}px` }}
+5
View File
@@ -1962,6 +1962,7 @@
"delete_confirm": "Are you sure you want to delete this contact?", "delete_confirm": "Are you sure you want to delete this contact?",
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)", "local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
"back_to_contacts": "Back to contacts", "back_to_contacts": "Back to contacts",
"open_categories": "Open categories",
"tabs": { "tabs": {
"all": "All", "all": "All",
"groups": "Groups" "groups": "Groups"
@@ -2485,6 +2486,7 @@
}, },
"nav_prev": "Previous", "nav_prev": "Previous",
"nav_next": "Next", "nav_next": "Next",
"nav_open_menu": "Open menu",
"import": { "import": {
"title": "Import Calendar", "title": "Import Calendar",
"tab_file": "File", "tab_file": "File",
@@ -2729,6 +2731,8 @@
"file": "File", "file": "File",
"parent_directory": "Parent directory", "parent_directory": "Parent directory",
"breadcrumb_root": "Home", "breadcrumb_root": "Home",
"other_accounts": "Other accounts",
"no_accounts": "No connected accounts.",
"drop_files_here": "Drop files or folders here to upload", "drop_files_here": "Drop files or folders here to upload",
"uploading": "Uploading...", "uploading": "Uploading...",
"upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}", "upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}",
@@ -2780,6 +2784,7 @@
"undo_success": "Action undone", "undo_success": "Action undone",
"undo_error": "Failed to undo", "undo_error": "Failed to undo",
"toolbar": "File actions", "toolbar": "File actions",
"open_folder_tree": "Open folder tree",
"file_list": "Files and folders", "file_list": "Files and folders",
"context_menu": "Actions", "context_menu": "Actions",
"settings_title": "File Settings", "settings_title": "File Settings",
+25 -3
View File
@@ -48,6 +48,8 @@ interface FileState {
selectedResources: Set<string>; selectedResources: Set<string>;
uploadProgress: UploadProgress | null; uploadProgress: UploadProgress | null;
client: IJMAPClient | null; client: IJMAPClient | null;
/** Which connected account's files are being browsed. Pro shell only — null in single-account contexts. */
currentAccountId: string | null;
clipboard: ClipboardState | null; clipboard: ClipboardState | null;
uploadAbortController: AbortController | null; uploadAbortController: AbortController | null;
favorites: string[]; favorites: string[];
@@ -55,7 +57,9 @@ interface FileState {
lastAction: UndoAction | null; lastAction: UndoAction | null;
// Actions // Actions
initClient: (client: IJMAPClient) => void; initClient: (client: IJMAPClient, accountId?: string | null) => void;
/** Detach the current client and reset browse state. Used by the Pro shell to return to the cross-account picker. */
clearClient: () => void;
checkSupport: () => Promise<boolean>; checkSupport: () => Promise<boolean>;
navigate: (parentId: string | null, name?: string) => Promise<void>; navigate: (parentId: string | null, name?: string) => Promise<void>;
navigateByPath: (path: string) => Promise<void>; navigateByPath: (path: string) => Promise<void>;
@@ -168,6 +172,7 @@ export const useFileStore = create<FileState>((set, get) => ({
selectedResources: new Set<string>(), selectedResources: new Set<string>(),
uploadProgress: null, uploadProgress: null,
client: null, client: null,
currentAccountId: null,
clipboard: null, clipboard: null,
uploadAbortController: null, uploadAbortController: null,
lastAction: null, lastAction: null,
@@ -178,8 +183,25 @@ export const useFileStore = create<FileState>((set, get) => ({
try { return JSON.parse(localStorage.getItem('files-recent-files') || '[]'); } catch { return []; } try { return JSON.parse(localStorage.getItem('files-recent-files') || '[]'); } catch { return []; }
})(), })(),
initClient: (client: IJMAPClient) => { initClient: (client: IJMAPClient, accountId?: string | null) => {
set({ client }); const patch: Partial<FileState> = { client };
if (accountId !== undefined) patch.currentAccountId = accountId;
set(patch);
},
clearClient: () => {
set({
client: null,
currentAccountId: null,
supportsFiles: null,
pathStack: [{ id: null, name: '' }],
currentPath: '/',
currentParentId: null,
resources: [],
selectedResources: new Set<string>(),
error: null,
isLoading: false,
});
}, },
checkSupport: async () => { checkSupport: async () => {