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>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
@@ -26,6 +26,8 @@ interface CalendarToolbarProps {
|
||||
selectedCalendarIds?: string[];
|
||||
onToggleVisibility?: (id: string) => void;
|
||||
enableCalendarTasks?: boolean;
|
||||
/** Show a burger button at the start that opens the (overlay) sidebar. */
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
export function CalendarToolbar({
|
||||
@@ -45,6 +47,7 @@ export function CalendarToolbar({
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
enableCalendarTasks,
|
||||
onMenuClick,
|
||||
}: CalendarToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const formatter = useFormatter();
|
||||
@@ -115,11 +118,32 @@ export function CalendarToolbar({
|
||||
|
||||
return (
|
||||
<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 ── */}
|
||||
{isMobile && (
|
||||
<div className="flex flex-col gap-1 px-2 py-2">
|
||||
{/* Row 1: Back / Date nav / Today */}
|
||||
<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 && (
|
||||
<button
|
||||
onClick={onNavigateBack}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
@@ -112,6 +112,8 @@ interface ContactListProps {
|
||||
onEditContact: (id: string) => void;
|
||||
onDeleteContact: (contact: ContactCard) => void;
|
||||
onAddContactToGroup: (id: string) => void;
|
||||
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
export function ContactList({
|
||||
@@ -133,6 +135,7 @@ export function ContactList({
|
||||
onEditContact,
|
||||
onDeleteContact,
|
||||
onAddContactToGroup,
|
||||
onMenuClick,
|
||||
}: ContactListProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const locale = useLocale();
|
||||
@@ -267,6 +270,16 @@ export function ContactList({
|
||||
<div className="border-b border-border bg-background">
|
||||
<div className="px-3 py-3">
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
AlertCircle, Star, Clock, FolderUp,
|
||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||
Menu,
|
||||
} from "lucide-react";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { NewFolderDialog } from "@/components/files/new-folder-dialog";
|
||||
@@ -35,6 +37,13 @@ interface ClipboardState {
|
||||
sourceParentId: string | null;
|
||||
}
|
||||
|
||||
export interface AccountFolderEntry {
|
||||
accountId: string;
|
||||
label: string;
|
||||
email: string;
|
||||
avatarColor: string;
|
||||
}
|
||||
|
||||
interface FileBrowserProps {
|
||||
currentPath: string;
|
||||
resources: FileResource[];
|
||||
@@ -78,6 +87,13 @@ interface FileBrowserProps {
|
||||
showDetails: boolean;
|
||||
onToggleDetails: () => void;
|
||||
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"]);
|
||||
@@ -321,6 +337,10 @@ export function FileBrowser({
|
||||
onToggleDetails,
|
||||
detailResource,
|
||||
clipboard,
|
||||
accountFolders,
|
||||
onSelectAccount,
|
||||
accountPickerMode,
|
||||
accountLabel,
|
||||
}: FileBrowserProps) {
|
||||
const t = useTranslations("files");
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
@@ -352,6 +372,13 @@ export function FileBrowser({
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const dragStartWidth = useRef(256);
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -442,8 +469,10 @@ export function FileBrowser({
|
||||
return sorted;
|
||||
}, [resources, searchQuery, sortKey, sortDir, folderLayout]);
|
||||
|
||||
// Build breadcrumb segments
|
||||
const breadcrumbs = currentPath === '/'
|
||||
// Build breadcrumb segments. In Pro mode an account is mounted "between"
|
||||
// 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: '/' },
|
||||
@@ -452,6 +481,9 @@ export function FileBrowser({
|
||||
path: '/' + arr.slice(0, i + 1).join('/'),
|
||||
})),
|
||||
];
|
||||
if (accountLabel) {
|
||||
breadcrumbs.splice(1, 0, { name: accountLabel, path: '', isAccount: true });
|
||||
}
|
||||
|
||||
const handleNavigateUp = useCallback(() => {
|
||||
if (currentPath === '/') return;
|
||||
@@ -846,14 +878,27 @@ export function FileBrowser({
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<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 */}
|
||||
<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) => (
|
||||
<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" />}
|
||||
<button
|
||||
onClick={() => onNavigate(crumb.path)}
|
||||
onContextMenu={(e) => handleBreadcrumbRightClick(e, crumb.path)}
|
||||
onClick={() => crumb.isAccount
|
||||
? onNavigate('/', '__account_root__')
|
||||
: onNavigate(crumb.path)}
|
||||
onContextMenu={(e) => crumb.isAccount ? undefined : handleBreadcrumbRightClick(e, crumb.path)}
|
||||
className={cn(
|
||||
"px-1.5 py-0.5 rounded hover:bg-muted transition-colors",
|
||||
i === breadcrumbs.length - 1
|
||||
@@ -902,15 +947,17 @@ export function FileBrowser({
|
||||
{t("paste")} ({clipboard.names.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowSearch(v => !v)}
|
||||
title={t("search_placeholder")}
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
{!accountPickerMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowSearch(v => !v)}
|
||||
title={t("search_placeholder")}
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -920,62 +967,66 @@ export function FileBrowser({
|
||||
>
|
||||
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <LayoutList className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8", showDetails && "bg-muted")}
|
||||
onClick={onToggleDetails}
|
||||
title={t("details")}
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8", favorites.includes(currentPath) && "text-yellow-500")}
|
||||
onClick={() => onToggleFavorite(currentPath)}
|
||||
title={t("toggle_favorite")}
|
||||
>
|
||||
<Star className={cn("w-4 h-4", favorites.includes(currentPath) && "fill-current")} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t("upload")}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
title={t("upload_folder")}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<FolderUp className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowNewFolder(true)}
|
||||
title={t("new_folder")}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowNewTextFile(true)}
|
||||
title={t("new_text_file")}
|
||||
>
|
||||
<FilePlus className="w-4 h-4" />
|
||||
</Button>
|
||||
{!accountPickerMode && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8", showDetails && "bg-muted")}
|
||||
onClick={onToggleDetails}
|
||||
title={t("details")}
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8", favorites.includes(currentPath) && "text-yellow-500")}
|
||||
onClick={() => onToggleFavorite(currentPath)}
|
||||
title={t("toggle_favorite")}
|
||||
>
|
||||
<Star className={cn("w-4 h-4", favorites.includes(currentPath) && "fill-current")} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t("upload")}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
title={t("upload_folder")}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<FolderUp className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowNewFolder(true)}
|
||||
title={t("new_folder")}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShowNewTextFile(true)}
|
||||
title={t("new_text_file")}
|
||||
>
|
||||
<FilePlus className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -1100,26 +1151,59 @@ export function FileBrowser({
|
||||
|
||||
{/* File list */}
|
||||
<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) */}
|
||||
{folderLayout === "sidebar" && (
|
||||
<>
|
||||
<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));
|
||||
isNarrow ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 z-50",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
!narrowSidebarOpen && "-translate-x-full"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
// Auto-close when the user taps a folder name. Chevrons stay
|
||||
// open so they can expand/collapse without dismissing.
|
||||
const target = e.target as HTMLElement;
|
||||
const btn = target.closest('button');
|
||||
if (btn && !btn.querySelector('svg.lucide-chevron-right, svg.lucide-chevron-down')) {
|
||||
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) */}
|
||||
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
|
||||
@@ -1198,6 +1282,40 @@ export function FileBrowser({
|
||||
<SkeletonRow />
|
||||
</tbody>
|
||||
</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 === '/' ? (
|
||||
<FileUploadArea
|
||||
onUpload={async (files: File[]) => {
|
||||
|
||||
@@ -130,7 +130,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
)}
|
||||
style={{ width: `${width}px` }}
|
||||
|
||||
@@ -1962,6 +1962,7 @@
|
||||
"delete_confirm": "Are you sure you want to delete this contact?",
|
||||
"local_mode": "Contacts are stored locally (server does not support JMAP Contacts)",
|
||||
"back_to_contacts": "Back to contacts",
|
||||
"open_categories": "Open categories",
|
||||
"tabs": {
|
||||
"all": "All",
|
||||
"groups": "Groups"
|
||||
@@ -2485,6 +2486,7 @@
|
||||
},
|
||||
"nav_prev": "Previous",
|
||||
"nav_next": "Next",
|
||||
"nav_open_menu": "Open menu",
|
||||
"import": {
|
||||
"title": "Import Calendar",
|
||||
"tab_file": "File",
|
||||
@@ -2729,6 +2731,8 @@
|
||||
"file": "File",
|
||||
"parent_directory": "Parent directory",
|
||||
"breadcrumb_root": "Home",
|
||||
"other_accounts": "Other accounts",
|
||||
"no_accounts": "No connected accounts.",
|
||||
"drop_files_here": "Drop files or folders here to upload",
|
||||
"uploading": "Uploading...",
|
||||
"upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}",
|
||||
@@ -2780,6 +2784,7 @@
|
||||
"undo_success": "Action undone",
|
||||
"undo_error": "Failed to undo",
|
||||
"toolbar": "File actions",
|
||||
"open_folder_tree": "Open folder tree",
|
||||
"file_list": "Files and folders",
|
||||
"context_menu": "Actions",
|
||||
"settings_title": "File Settings",
|
||||
|
||||
+25
-3
@@ -48,6 +48,8 @@ interface FileState {
|
||||
selectedResources: Set<string>;
|
||||
uploadProgress: UploadProgress | 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;
|
||||
uploadAbortController: AbortController | null;
|
||||
favorites: string[];
|
||||
@@ -55,7 +57,9 @@ interface FileState {
|
||||
lastAction: UndoAction | null;
|
||||
|
||||
// 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>;
|
||||
navigate: (parentId: string | null, name?: string) => Promise<void>;
|
||||
navigateByPath: (path: string) => Promise<void>;
|
||||
@@ -168,6 +172,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
selectedResources: new Set<string>(),
|
||||
uploadProgress: null,
|
||||
client: null,
|
||||
currentAccountId: null,
|
||||
clipboard: null,
|
||||
uploadAbortController: 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 []; }
|
||||
})(),
|
||||
|
||||
initClient: (client: IJMAPClient) => {
|
||||
set({ client });
|
||||
initClient: (client: IJMAPClient, accountId?: string | null) => {
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user