mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
850 lines
35 KiB
TypeScript
850 lines
35 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||
import { useTranslations } from "next-intl";
|
||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
|
||
import { useAccountStore } from "@/stores/account-store";
|
||
import { useEmailStore } from "@/stores/email-store";
|
||
import { useFileStore, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
|
||
import { fileErrorKind, isPartialCopy } from "@/lib/file-errors";
|
||
import { toast } from "@/stores/toast-store";
|
||
import { cn, formatFileSize } from "@/lib/utils";
|
||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||
import { useIsFocusedProTab } from "@/hooks/use-pane-context";
|
||
import { useIsMobile } from "@/hooks/use-media-query";
|
||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||
import { usePolicyStore } from "@/stores/policy-store";
|
||
import { FileBrowser } from "@/components/files/file-browser";
|
||
import type { FileNodeRights } from "@/lib/jmap/types";
|
||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||
import { WopiEditor } from "@/components/files/wopi-editor";
|
||
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
|
||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||
import { AlertTriangle, Loader2 } from "@/components/icons";
|
||
import { isFilePreviewable } from "@/lib/file-preview";
|
||
import { isImageFile } from "@/components/files/file-icons";
|
||
import { appPath, buildFilesPath, buildSharedFilesPath, parseFilesPath, type FilesDeepLink } from "@/lib/deep-links";
|
||
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
|
||
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||
|
||
export interface FilesAppProps {
|
||
/** Path segments after `/files` - the folder path, one segment per level. */
|
||
linkSegments?: string[];
|
||
}
|
||
|
||
export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||
// Static Lite build: the route params are empty, read the link from the URL.
|
||
const linkSegments = useLiteLinkSegments('files', routeSegments);
|
||
const t = useTranslations("files");
|
||
const tSidebar = useTranslations("sidebar");
|
||
useDocumentTitle(tSidebar("files"));
|
||
const tDeepLink = useTranslations("deep_link");
|
||
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();
|
||
const {
|
||
currentPath,
|
||
resources,
|
||
isLoading,
|
||
error,
|
||
supportsFiles,
|
||
selectedResources,
|
||
uploadProgress,
|
||
migrationProgress,
|
||
clipboard,
|
||
initClient,
|
||
checkSupport,
|
||
migrateLegacyFlatNodes,
|
||
navigate,
|
||
navigateByPath,
|
||
refresh,
|
||
createDirectory,
|
||
uploadFile: _uploadFile,
|
||
uploadFiles,
|
||
uploadFolder,
|
||
deleteResource,
|
||
deleteResources,
|
||
renameResource,
|
||
downloadResource,
|
||
getImageUrl,
|
||
getFileContent,
|
||
createTextFile,
|
||
duplicateResource,
|
||
downloadResources,
|
||
moveToFolderId,
|
||
cutResources,
|
||
copyResources,
|
||
pasteResources,
|
||
toggleSelect,
|
||
clearSelection,
|
||
setSelection,
|
||
favorites,
|
||
recentFiles,
|
||
toggleFavorite,
|
||
addRecentFile,
|
||
revealNode,
|
||
renameNode,
|
||
deleteNode,
|
||
downloadNode,
|
||
removeRecentFile,
|
||
cancelUpload,
|
||
undoLastAction,
|
||
lastAction,
|
||
shareResource,
|
||
openSharedNode,
|
||
} = useFileStore();
|
||
|
||
const isMobile = useIsMobile();
|
||
const isEmbedded = useIsEmbedded();
|
||
// Mountain View's desktop chrome: its top bar in place of the left rail.
|
||
const mvShell = useMountainViewShell(isEmbedded);
|
||
// The account the files client was last initialised for (`undefined` = never).
|
||
// Tracked so a Pro-shell account switch re-initialises instead of showing the
|
||
// previous account's files until a manual Home click.
|
||
const initedAccountRef = useRef<string | null | undefined>(undefined);
|
||
const filesBootstrapRef = useRef(false);
|
||
|
||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||
const [previewFile, setPreviewFile] = useState<string | null>(null);
|
||
// WOPI document editing (#425): name of the file open in the editor overlay.
|
||
const [editFile, setEditFile] = useState<string | null>(null);
|
||
const wopiStatus = useWopiStatus(filesEnabled);
|
||
const isOfficeEditable = useCallback((name: string) => canWopiOpen(wopiStatus, name), [wopiStatus]);
|
||
|
||
// Check auth on mount – skip when already authenticated so that navigating
|
||
// between routes doesn't retrigger checkAuth's transient `{ client: null,
|
||
// isLoading: true }` reset, which was flashing the spinner on every nav.
|
||
useEffect(() => {
|
||
const state = useAuthStore.getState();
|
||
if (state.isAuthenticated && state.client) {
|
||
setInitialCheckDone(true);
|
||
return;
|
||
}
|
||
checkAuth().finally(() => {
|
||
setInitialCheckDone(true);
|
||
});
|
||
}, [checkAuth]);
|
||
|
||
// Redirect if not authenticated
|
||
useEffect(() => {
|
||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||
saveRedirectAfterLogin();
|
||
redirectToLogin();
|
||
}
|
||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||
|
||
// 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) return;
|
||
// Re-run when the ACTIVE account changes (Pro multi-account switch), not
|
||
// just once - otherwise the previous account's files linger until a manual
|
||
// Home click.
|
||
if (initedAccountRef.current === activeAccountId) return;
|
||
const switched = initedAccountRef.current !== undefined;
|
||
initedAccountRef.current = activeAccountId;
|
||
if (isEmbedded) {
|
||
useFileStore.getState().clearClient();
|
||
} else {
|
||
if (switched) {
|
||
// Drop the previous account's attached view so its files don't linger,
|
||
// and let the bootstrap effect below re-list for the new account.
|
||
useFileStore.getState().clearClient();
|
||
filesBootstrapRef.current = false;
|
||
}
|
||
initClient(client, activeAccountId);
|
||
}
|
||
}, [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.
|
||
const { indicator: refreshIndicator } = useRefreshGesture({
|
||
enabled: isAuthenticated && !!client && supportsFiles === true,
|
||
onRefresh: async () => {
|
||
await refresh();
|
||
},
|
||
});
|
||
|
||
// ---- Deep links (#733) ---------------------------------------------------
|
||
// `/files/<folder>/<sub>` walks the drive to that folder; `?preview=<name>`
|
||
// opens a file there once the listing arrives. The link is parked on mount
|
||
// and claimed by whichever of the two paths below gets there first: the
|
||
// bootstrap that runs after a capability check on a cold load, or the effect
|
||
// for when the store already knows files are supported.
|
||
const filesLinkRef = useRef<FilesDeepLink | null>(null);
|
||
// The file to preview once its listing arrives: `name`, and for a link
|
||
// into a shared drive the id the link names (`?shared=`), see the preview
|
||
// effect below.
|
||
const pendingPreviewRef = useRef<{ name: string; sharedId?: string } | null>(null);
|
||
useEffect(() => {
|
||
// Inside the Pro shell the route is /pro, so the segments arrive through
|
||
// the handoff the redirect parked rather than as route params.
|
||
const entry = linkSegments ? { segments: linkSegments } : consumePendingDeepLinkEntry('files');
|
||
if (!entry) return;
|
||
const link = parseFilesPath(entry.segments, new URLSearchParams(entry.search ?? window.location.search));
|
||
// Never overwrite with null: this effect re-runs (twice on mount under
|
||
// StrictMode) and the handoff only yields its segments once.
|
||
if (link) filesLinkRef.current = link;
|
||
}, [linkSegments]);
|
||
|
||
const takeFilesLink = useCallback(() => {
|
||
const link = filesLinkRef.current;
|
||
filesLinkRef.current = null;
|
||
if (link?.preview) pendingPreviewRef.current = { name: link.preview, sharedId: link.shared };
|
||
return link;
|
||
}, []);
|
||
|
||
// A node of a drive shared with the user has no path under the user's own
|
||
// root, so its link names it by id and the store opens where it is listed.
|
||
// False when it is gone or no longer shared.
|
||
const openSharedLink = useCallback(async (id: string): Promise<boolean> => {
|
||
if (await openSharedNode(id, (name) => t("shared_files_from", { name }))) return true;
|
||
toast.error(tDeepLink(pendingPreviewRef.current !== null ? "file_not_found" : "folder_not_found"));
|
||
// Not a file of the same name in whatever folder is open instead.
|
||
pendingPreviewRef.current = null;
|
||
return false;
|
||
}, [openSharedNode, t, tDeepLink]);
|
||
const openSharedLinkRef = useRef(openSharedLink);
|
||
openSharedLinkRef.current = openSharedLink;
|
||
|
||
// Links arriving after mount are delivered live. The Pro shell keeps this
|
||
// surface mounted for the whole session, and so does Next for the standard
|
||
// page when a link changes only the query (`?shared=`), while the route is
|
||
// read on mount only. The bootstrap has already run by then - walk the
|
||
// drive directly and let the preview effect below claim `pendingPreviewRef`
|
||
// once the listing lands. While the Pro interface is on, the standard
|
||
// instance renders only during a cold-load redirect and must not steal the
|
||
// link parked for the Pro one.
|
||
const proInterfaceActive = useProInterfaceActive();
|
||
const navigateByPathRef = useRef(navigateByPath);
|
||
navigateByPathRef.current = navigateByPath;
|
||
useEffect(() => {
|
||
if (!isEmbedded && proInterfaceActive) return;
|
||
return subscribePendingDeepLink('files', (segments, search) => {
|
||
const link = parseFilesPath(segments, new URLSearchParams(search ?? window.location.search));
|
||
if (!link) return;
|
||
if (link.preview) pendingPreviewRef.current = { name: link.preview, sharedId: link.shared };
|
||
if (link.shared) void openSharedLinkRef.current(link.shared);
|
||
else void navigateByPathRef.current(link.path);
|
||
});
|
||
}, [isEmbedded, proInterfaceActive]);
|
||
|
||
// Check support and load the first listing after the client is initialized.
|
||
// One effect, run once: `checkSupport` publishes `supportsFiles` before it
|
||
// resolves, so splitting the cold path from the warm one would have them
|
||
// race - the warm one following the deep link while the cold one, a tick
|
||
// later, listed the root on top of it.
|
||
const storeClient = useFileStore(s => s.client);
|
||
useEffect(() => {
|
||
if (filesBootstrapRef.current) return;
|
||
if (!storeClient) return;
|
||
filesBootstrapRef.current = true;
|
||
|
||
void (async () => {
|
||
const firstCheck = supportsFiles === null;
|
||
if (firstCheck) {
|
||
if (!await checkSupport()) return;
|
||
// Upgrade any files created by older builds (flat path-encoded names)
|
||
// into the real FileNode hierarchy before the first listing.
|
||
await migrateLegacyFlatNodes();
|
||
} else if (supportsFiles !== true) {
|
||
return;
|
||
}
|
||
|
||
// A deep link replaces the root listing rather than following it -
|
||
// listing the root first would strand the user there and rewrite the
|
||
// URL they arrived on.
|
||
const link = takeFilesLink();
|
||
if (link?.shared && await openSharedLinkRef.current(link.shared)) return;
|
||
if (link && !link.shared && link.path !== '/') await navigateByPath(link.path);
|
||
else if (firstCheck) await navigate(null);
|
||
})();
|
||
}, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate, navigateByPath, takeFilesLink]);
|
||
|
||
// The preview waits for the folder listing: the modal is keyed by file name
|
||
// and can only render once the file is in `resources` - found by the id a
|
||
// link into a shared drive names it by (linkedPreviewResource).
|
||
useEffect(() => {
|
||
const pending = pendingPreviewRef.current;
|
||
if (!pending) return;
|
||
const resource = linkedPreviewResource(resources, pending, useFileStore.getState().currentParentId);
|
||
if (!resource) return;
|
||
pendingPreviewRef.current = null;
|
||
if (!resource.isSymlink && isFilePreviewable(resource.name)) {
|
||
setPreviewFile(resource.name);
|
||
} else {
|
||
toast.error(tDeepLink('file_not_found'));
|
||
}
|
||
}, [resources, tDeepLink]);
|
||
|
||
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() || '');
|
||
} else {
|
||
// Path-based navigation (breadcrumbs, favorites, recent files)
|
||
navigateByPath(path);
|
||
}
|
||
}, [navigate, navigateByPath, isEmbedded]);
|
||
|
||
// What the server said went wrong, as the second line under an action's
|
||
// own error message; undefined when it said nothing specific.
|
||
const serverReason = useCallback((err: unknown): string | undefined => {
|
||
switch (fileErrorKind(err)) {
|
||
case "overQuota": return t("error_over_quota");
|
||
case "tooDeep": return t("error_too_deep");
|
||
case "invalidName": return t("error_invalid_name");
|
||
case "forbidden": return t("error_forbidden");
|
||
case "tooLarge": return t("error_too_large");
|
||
default: return undefined;
|
||
}
|
||
}, [t]);
|
||
// A copy that stopped part-way and could not all be taken back says so too.
|
||
const failureReason = useCallback((err: unknown): string | undefined => {
|
||
const reason = serverReason(err);
|
||
if (!isPartialCopy(err)) return reason;
|
||
return reason ? `${reason} ${t("error_partial_copy")}` : t("error_partial_copy");
|
||
}, [serverReason, t]);
|
||
|
||
// An upload goes on past a file the server refuses; the refused ones are
|
||
// reported once it is done.
|
||
const showUploadReport = useCallback((report: UploadReport) => {
|
||
if (report.canceled) {
|
||
toast.info(t("upload_canceled"), report.uploaded > 0 ? t("upload_canceled_partial", { count: report.uploaded }) : undefined);
|
||
} else if (report.uploaded > 0) {
|
||
toast.success(t("upload_success", { count: report.uploaded }));
|
||
}
|
||
if (report.failed.length === 0) return;
|
||
const names = report.failed.slice(0, 3).map((f) => f.name).join(", ")
|
||
+ (report.failed.length > 3 ? ", …" : "");
|
||
const reason = failureReason(report.failed[0].error);
|
||
toast.error(t("upload_failed", { count: report.failed.length }), reason ? `${names}: ${reason}` : names);
|
||
}, [t, failureReason]);
|
||
|
||
const handleCreateFolder = useCallback(async (name: string) => {
|
||
try {
|
||
await createDirectory(name);
|
||
toast.success(t("create_folder_success"));
|
||
} catch (err) {
|
||
console.error("Failed to create folder:", err);
|
||
toast.error(t("create_folder_error"), failureReason(err));
|
||
}
|
||
}, [createDirectory, t, failureReason]);
|
||
|
||
const maxSizeUpload = client?.getMaxSizeUpload() || 0;
|
||
|
||
const handleUploadFiles = useCallback(async (files: File[]) => {
|
||
if (maxSizeUpload > 0) {
|
||
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||
files = files.filter(f => f.size <= maxSizeUpload);
|
||
if (oversized.length > 0) {
|
||
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
|
||
}
|
||
}
|
||
if (files.length === 0) return;
|
||
try {
|
||
showUploadReport(await uploadFiles(files));
|
||
} catch (err) {
|
||
console.error("Failed to upload files:", err);
|
||
toast.error(t("upload_error"), failureReason(err));
|
||
}
|
||
}, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason]);
|
||
|
||
const handleUploadFolder = useCallback(async (files: File[]) => {
|
||
if (maxSizeUpload > 0) {
|
||
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||
files = files.filter(f => f.size <= maxSizeUpload);
|
||
if (oversized.length > 0) {
|
||
toast.error(t("file_too_large", { name: oversized[0].name, max: formatFileSize(maxSizeUpload) }));
|
||
}
|
||
}
|
||
if (files.length === 0) return;
|
||
try {
|
||
showUploadReport(await uploadFolder(files));
|
||
} catch (err) {
|
||
console.error("Failed to upload folder:", err);
|
||
toast.error(t("upload_error"), failureReason(err));
|
||
}
|
||
}, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason]);
|
||
|
||
const handleDelete = useCallback(async (name: string) => {
|
||
const confirmed = await confirmDialog({
|
||
title: t("delete_confirm_title"),
|
||
message: t("delete_confirm_message", { name }),
|
||
confirmText: t("delete"),
|
||
variant: "destructive",
|
||
});
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
if (!await deleteResource(name)) return;
|
||
toast.success(t("delete_success"));
|
||
} catch (err) {
|
||
console.error("Failed to delete:", err);
|
||
toast.error(t("delete_error"), failureReason(err));
|
||
}
|
||
}, [deleteResource, confirmDialog, t, failureReason]);
|
||
|
||
const handleBatchDelete = useCallback(async (names: string[]) => {
|
||
const confirmed = await confirmDialog({
|
||
title: t("delete_confirm_title"),
|
||
message: t("batch_delete_confirm_message", { count: names.length }),
|
||
confirmText: t("delete"),
|
||
variant: "destructive",
|
||
});
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
if (!await deleteResources(names)) return;
|
||
toast.success(t("batch_delete_success", { count: names.length }));
|
||
} catch (err) {
|
||
console.error("Failed to batch delete:", err);
|
||
toast.error(t("delete_error"), failureReason(err));
|
||
}
|
||
}, [deleteResources, confirmDialog, t, failureReason]);
|
||
|
||
const handleUndo = useCallback(async () => {
|
||
try {
|
||
await undoLastAction();
|
||
toast.success(t("undo_success"));
|
||
} catch (err) {
|
||
console.error("Failed to undo:", err);
|
||
toast.error(t("undo_error"), failureReason(err));
|
||
}
|
||
}, [undoLastAction, t, failureReason]);
|
||
|
||
const handleRename = useCallback(async (oldName: string, newName: string) => {
|
||
try {
|
||
if (!await renameResource(oldName, newName)) return;
|
||
toast.success(t("rename_success"), {
|
||
action: { label: t("undo"), onClick: handleUndo },
|
||
});
|
||
} catch (err) {
|
||
console.error("Failed to rename:", err);
|
||
toast.error(t("rename_error"), failureReason(err));
|
||
}
|
||
}, [renameResource, t, handleUndo, failureReason]);
|
||
|
||
const findResourceId = useCallback((name: string) => {
|
||
const r = resources.find(res => res.name === name);
|
||
return r?.id || name;
|
||
}, [resources]);
|
||
|
||
const handleDownload = useCallback(async (name: string) => {
|
||
try {
|
||
await downloadResource(name);
|
||
addRecentFile(name, findResourceId(name));
|
||
} catch (err) {
|
||
console.error("Failed to download:", err);
|
||
toast.error(t("download_error"));
|
||
}
|
||
}, [downloadResource, addRecentFile, findResourceId, t]);
|
||
|
||
const handleBatchDownload = useCallback(async (names: string[]) => {
|
||
try {
|
||
await downloadResources(names);
|
||
} catch (err) {
|
||
console.error("Failed to batch download:", err);
|
||
toast.error(t("download_error"));
|
||
}
|
||
}, [downloadResources, t]);
|
||
|
||
const handleCreateTextFile = useCallback(async (name: string) => {
|
||
try {
|
||
await createTextFile(name);
|
||
toast.success(t("create_file_success"));
|
||
} catch (err) {
|
||
console.error("Failed to create file:", err);
|
||
toast.error(t("create_file_error"), failureReason(err));
|
||
}
|
||
}, [createTextFile, t, failureReason]);
|
||
|
||
const handleDuplicate = useCallback(async (name: string) => {
|
||
try {
|
||
await duplicateResource(name);
|
||
toast.success(t("duplicate_success"));
|
||
} catch (err) {
|
||
console.error("Failed to duplicate:", err);
|
||
toast.error(t("duplicate_error"), failureReason(err));
|
||
}
|
||
}, [duplicateResource, t, failureReason]);
|
||
|
||
const handleMoveTo = useCallback(async (names: string[], targetId: string | null) => {
|
||
try {
|
||
if (!await moveToFolderId(names, targetId)) return;
|
||
toast.success(t("move_success", { count: names.length }), {
|
||
action: { label: t("undo"), onClick: handleUndo },
|
||
});
|
||
} catch (err) {
|
||
console.error("Failed to move:", err);
|
||
toast.error(t("move_error"), failureReason(err));
|
||
}
|
||
}, [moveToFolderId, t, handleUndo, failureReason]);
|
||
|
||
const handlePaste = useCallback(async () => {
|
||
try {
|
||
if (!await pasteResources()) return;
|
||
toast.success(t("paste_success"), {
|
||
action: lastAction ? { label: t("undo"), onClick: handleUndo } : undefined,
|
||
});
|
||
} catch (err) {
|
||
console.error("Failed to paste:", err);
|
||
toast.error(t("paste_error"), failureReason(err));
|
||
}
|
||
}, [pasteResources, t, lastAction, handleUndo, failureReason]);
|
||
|
||
const handlePreviewImage = useCallback((name: string) => {
|
||
setPreviewImage(name);
|
||
addRecentFile(name, findResourceId(name));
|
||
}, [addRecentFile, findResourceId]);
|
||
|
||
const handlePreviewFile = useCallback((name: string) => {
|
||
setPreviewFile(name);
|
||
addRecentFile(name, findResourceId(name));
|
||
}, [addRecentFile, findResourceId]);
|
||
|
||
const handleRenameNode = useCallback(async (id: string, newName: string) => {
|
||
try {
|
||
if (!await renameNode(id, newName)) return;
|
||
toast.success(t("rename_success"), {
|
||
action: { label: t("undo"), onClick: handleUndo },
|
||
});
|
||
} catch (err) {
|
||
console.error("Failed to rename:", err);
|
||
toast.error(t("rename_error"));
|
||
}
|
||
}, [renameNode, t, handleUndo]);
|
||
|
||
const handleDeleteNode = useCallback(async (id: string, name: string) => {
|
||
const confirmed = await confirmDialog({
|
||
title: t("delete_confirm_title"),
|
||
message: t("delete_confirm_message", { name }),
|
||
confirmText: t("delete"),
|
||
variant: "destructive",
|
||
});
|
||
if (!confirmed) return;
|
||
try {
|
||
if (!await deleteNode(id)) return;
|
||
toast.success(t("delete_success"));
|
||
} catch (err) {
|
||
console.error("Failed to delete:", err);
|
||
toast.error(t("delete_error"));
|
||
}
|
||
}, [deleteNode, confirmDialog, t]);
|
||
|
||
const handleDownloadNode = useCallback(async (id: string) => {
|
||
try {
|
||
await downloadNode(id);
|
||
} catch (err) {
|
||
console.error("Failed to download:", err);
|
||
toast.error(t("download_error"));
|
||
}
|
||
}, [downloadNode, t]);
|
||
|
||
const handleRevealNode = useCallback(async (id: string) => {
|
||
if (!await revealNode(id)) toast.error(tDeepLink("file_not_found"));
|
||
}, [revealNode, tDeepLink]);
|
||
|
||
// Recent files can live in any folder: open that folder, then the file.
|
||
const handleOpenRecent = useCallback(async (id: string) => {
|
||
const name = await revealNode(id);
|
||
if (!name) {
|
||
toast.error(tDeepLink("file_not_found"));
|
||
return;
|
||
}
|
||
addRecentFile(name, id);
|
||
if (isOfficeEditable(name)) setEditFile(name);
|
||
else if (isImageFile(name)) setPreviewImage(name);
|
||
else if (isFilePreviewable(name)) setPreviewFile(name);
|
||
}, [revealNode, addRecentFile, isOfficeEditable, tDeepLink]);
|
||
|
||
// The permalink for the folder on screen. In the Pro shell only the focused
|
||
// tab writes the address bar; the standard instance that renders while Pro
|
||
// takes over a route stays silent.
|
||
const isFocusedProTab = useIsFocusedProTab();
|
||
// A shared drive's folders and pages are linked by id (see openSharedLink).
|
||
const currentParentId = useFileStore((s) => s.currentParentId);
|
||
const filesLinkPath = appPath(isSharedLocation(currentParentId)
|
||
? buildSharedFilesPath(currentParentId, previewFile)
|
||
: buildFilesPath(currentPath, previewFile));
|
||
|
||
// The previewed file as plugins see it (file-preview-section).
|
||
const previewedName = previewFile ?? previewImage;
|
||
const previewedView = useMemo(() => {
|
||
const resource = previewedName ? resources.find((r) => r.name === previewedName) : undefined;
|
||
return resource ? resourceToView(resource, currentPath) : null;
|
||
}, [previewedName, resources, currentPath]);
|
||
useDeepLinkUrl(
|
||
isEmbedded
|
||
? (isFocusedProTab ? filesLinkPath : null)
|
||
: proInterfaceActive ? null : filesLinkPath,
|
||
);
|
||
|
||
const currentFilesAccountId = useFileStore((s) => s.currentAccountId);
|
||
|
||
// Sharing: the browsing client (store-attached) drives the principal picker
|
||
// and share mutations. supportsPrincipals() gates the whole Share affordance.
|
||
const sharingEnabled = !!storeClient?.supportsPrincipals();
|
||
const filesAccountId = storeClient?.getFilesAccountId() ?? null;
|
||
const handleShare = useCallback(async (id: string, principalId: string, rights: FileNodeRights | null) => {
|
||
await shareResource(id, principalId, rights);
|
||
}, [shareResource]);
|
||
|
||
// 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 (
|
||
<div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||
<AppTopBannerSlot />
|
||
{refreshIndicator}
|
||
{mvShell && (
|
||
<MvTopBar
|
||
showMenuButton={false}
|
||
onManageApps={handleManageApps}
|
||
onInlineApp={handleInlineApp}
|
||
onCloseInlineApp={closeInlineApp}
|
||
activeAppId={inlineApp?.id ?? null}
|
||
/>
|
||
)}
|
||
<div data-mv-card={mvShell ? "" : undefined} className={cn("flex flex-1 min-h-0 overflow-hidden", mvShell && "mb-4 mx-4")}>
|
||
{!isMobile && !isEmbedded && !mvShell && (
|
||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||
<NavigationRail
|
||
collapsed
|
||
quota={quota}
|
||
isPushConnected={isPushConnected}
|
||
onLogout={logout}
|
||
onManageApps={handleManageApps}
|
||
onInlineApp={handleInlineApp}
|
||
onCloseInlineApp={closeInlineApp}
|
||
activeAppId={inlineApp?.id ?? null}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-col flex-1 min-w-0">
|
||
{inlineApp && (
|
||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||
)}
|
||
<div className={cn("flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||
<div className="flex-1 min-w-0 flex flex-col">
|
||
<div className="flex-1 min-h-0 flex flex-col">
|
||
{!filesEnabled ? (
|
||
<div className="flex items-center justify-center h-full">
|
||
<div className="max-w-lg text-center space-y-3 px-4">
|
||
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
|
||
<p className="text-sm font-medium">{t("disabled_title")}</p>
|
||
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
|
||
</div>
|
||
</div>
|
||
) : supportsFiles === false ? (
|
||
<div className="flex items-center justify-center h-full">
|
||
<p className="text-sm text-muted-foreground">{t("not_available")}</p>
|
||
</div>
|
||
) : (
|
||
<FileBrowser
|
||
currentPath={currentPath}
|
||
resources={resources}
|
||
isLoading={isLoading}
|
||
error={error}
|
||
selectedResources={selectedResources}
|
||
uploadProgress={uploadProgress}
|
||
clipboard={clipboard}
|
||
onNavigate={handleNavigate}
|
||
onCreateFolder={handleCreateFolder}
|
||
onUploadFiles={handleUploadFiles}
|
||
onUploadFolder={handleUploadFolder}
|
||
onCancelUpload={cancelUpload}
|
||
onDelete={handleDelete}
|
||
onBatchDelete={handleBatchDelete}
|
||
onRename={handleRename}
|
||
onDownload={handleDownload}
|
||
onBatchDownload={handleBatchDownload}
|
||
onRefresh={refresh}
|
||
onToggleSelect={toggleSelect}
|
||
onClearSelection={clearSelection}
|
||
onSetSelection={setSelection}
|
||
onCut={cutResources}
|
||
onCopy={copyResources}
|
||
onPaste={handlePaste}
|
||
onMoveTo={handleMoveTo}
|
||
onPreviewImage={handlePreviewImage}
|
||
onPreviewFile={handlePreviewFile}
|
||
isOfficeEditable={isOfficeEditable}
|
||
onEditFile={setEditFile}
|
||
onCreateTextFile={handleCreateTextFile}
|
||
onDuplicate={handleDuplicate}
|
||
getImageUrl={getImageUrl}
|
||
favorites={favorites}
|
||
recentFiles={recentFiles}
|
||
onToggleFavorite={toggleFavorite}
|
||
onOpenRecent={handleOpenRecent}
|
||
onRenameNode={handleRenameNode}
|
||
onDeleteNode={handleDeleteNode}
|
||
onDownloadNode={handleDownloadNode}
|
||
onRevealNode={handleRevealNode}
|
||
onRemoveRecent={removeRecentFile}
|
||
accountFolders={accountFolders}
|
||
onSelectAccount={handleSelectAccount}
|
||
accountPickerMode={isAccountPicker}
|
||
accountLabel={currentAccountLabel}
|
||
client={storeClient}
|
||
ownAccountId={filesAccountId}
|
||
sharingEnabled={sharingEnabled}
|
||
onShare={handleShare}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{isMobile && !isEmbedded && (
|
||
<NavigationRail
|
||
orientation="horizontal"
|
||
onManageApps={handleManageApps}
|
||
onInlineApp={handleInlineApp}
|
||
onCloseInlineApp={closeInlineApp}
|
||
activeAppId={inlineApp?.id ?? null}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Image preview modal */}
|
||
{previewImage && (
|
||
<ImagePreviewModal
|
||
name={previewImage}
|
||
onClose={() => setPreviewImage(null)}
|
||
onDownload={handleDownload}
|
||
getImageUrl={getImageUrl}
|
||
file={previewedView}
|
||
/>
|
||
)}
|
||
|
||
{/* File preview modal (text, PDF, audio, video, markdown) */}
|
||
{previewFile && (
|
||
<FilePreviewModal
|
||
name={previewFile}
|
||
onClose={() => setPreviewFile(null)}
|
||
onDownload={() => handleDownload(previewFile)}
|
||
getFileContent={() => getFileContent(previewFile)}
|
||
file={previewedView}
|
||
/>
|
||
)}
|
||
|
||
{/* WOPI document editor overlay (#425) */}
|
||
{editFile && (() => {
|
||
const editResource = resources.find(r => r.name === editFile);
|
||
if (!editResource) return null;
|
||
// A node shared with the user opens in its owner's account, by its
|
||
// own id (#1094).
|
||
const node = resourceServerRef(editResource);
|
||
// The Pro shell may be browsing a login other than the active one.
|
||
const slot = currentFilesAccountId
|
||
? accounts.find((a) => a.id === currentFilesAccountId)?.cookieSlot
|
||
: undefined;
|
||
return (
|
||
<WopiEditor
|
||
target={{ kind: "file", id: node.id, name: editResource.name }}
|
||
accountId={node.accountId ?? filesAccountId}
|
||
slot={slot}
|
||
onClose={() => {
|
||
setEditFile(null);
|
||
// The editor may have saved new content - pick up size/mtime.
|
||
refresh();
|
||
}}
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
{/* Legacy file migration progress (issue #379) */}
|
||
{migrationProgress && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||
<div className="w-[22rem] max-w-[90vw] rounded-lg border border-border bg-background p-6 shadow-xl">
|
||
<div className="flex items-center gap-3">
|
||
<Loader2 className="w-5 h-5 text-primary animate-spin shrink-0" />
|
||
<div>
|
||
<p className="text-sm font-medium">{t("migration_title")}</p>
|
||
<p className="text-xs text-muted-foreground">{t("migration_description")}</p>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 h-1.5 bg-primary/20 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-primary rounded-full transition-all duration-300"
|
||
style={{ width: migrationProgress.total > 0
|
||
? `${(migrationProgress.current / migrationProgress.total) * 100}%`
|
||
: '0%' }}
|
||
/>
|
||
</div>
|
||
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-end">
|
||
{migrationProgress.current} / {migrationProgress.total}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||
<ConfirmDialog {...confirmDialogProps} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|