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 { 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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[]) => {
|
||||||
|
|||||||
@@ -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` }}
|
||||||
|
|||||||
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user