Files
Stirling-PDF/frontend/src/core/components/shared/FileSidebar.tsx
T

590 lines
21 KiB
TypeScript

import React, {
useState,
useCallback,
useRef,
useEffect,
forwardRef,
} from "react";
import { Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFileState, useFileActions } from "@app/contexts/file/fileHooks";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useGoogleDrivePicker } from "@app/hooks/useGoogleDrivePicker";
import {
useNavigationState,
useNavigationActions,
useNavigationGuard,
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { accountService } from "@app/services/accountService";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import type { StirlingFileStub } from "@app/types/fileContext";
import MenuIcon from "@mui/icons-material/Menu";
import SearchIcon from "@mui/icons-material/Search";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import CloseIcon from "@mui/icons-material/Close";
import AddIcon from "@mui/icons-material/Add";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import SettingsIcon from "@mui/icons-material/Settings";
import type { FileId } from "@app/types/file";
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
import "@app/components/shared/FileSidebar.css";
const COLLAPSED_WIDTH = "3.5rem";
const EXPANDED_WIDTH = "16.25rem"; // ~260px
export interface FileSidebarProps {
collapsed?: boolean;
onToggleCollapse?: () => void;
onOpenSettings?: () => void;
}
const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
function FileSidebar(
{ collapsed = false, onToggleCollapse, onOpenSettings },
ref,
) {
const { t } = useTranslation();
const [searchActive, setSearchActive] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const nativeFileInputRef = useRef<HTMLInputElement>(null);
// State (not ref) so setting it triggers a re-render — avoids racing addFiles state updates.
const [pendingViewFileId, setPendingViewFileId] = useState<string | null>(
null,
);
const { openFilesModal } = useFilesModalContext();
const { config } = useAppConfig();
const {
isEnabled: isGoogleDriveEnabled,
openPicker: openGoogleDrivePicker,
} = useGoogleDrivePicker();
const { state } = useFileState();
const { actions: fileActions } = useFileActions();
const { actions: navActions } = useNavigationActions();
const { workbench: currentWorkbench, selectedTool } = useNavigationState();
const isMultiTool =
currentWorkbench === "pageEditor" && selectedTool === "multiTool";
const { requestNavigation } = useNavigationGuard();
const { activeFileId, setActiveFileId } = useViewer();
const { addFiles } = useFileHandler();
const indexedDB = useIndexedDB();
const [displayName, setDisplayName] = useState<string>("Guest");
useEffect(() => {
if (!config?.enableLogin) return;
accountService
.getAccountData()
.then((data) => {
if (data?.username) setDisplayName(data.username);
})
.catch(() => {
/* not logged in or security disabled */
});
}, [config?.enableLogin]);
// Leaf files = user-visible files (excludes intermediate tool outputs)
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
const [stubsLoaded, setStubsLoaded] = useState(false);
const refreshStubs = useCallback(async () => {
// Leaf files from IDB — same source as the file selection modal.
const stubs = await indexedDB.loadLeafMetadata();
const idbIds = new Set(stubs.map((s) => s.id as string));
// Also include workbench files not yet flushed to IDB.
const pendingStubs = state.files.ids
.map((id) => state.files.byId[id])
.filter(
(stub): stub is NonNullable<typeof stub> =>
!!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string),
);
const allStubs = [...stubs, ...pendingStubs];
setAllFileStubs(
allStubs.sort((a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0)),
);
setStubsLoaded(true);
}, [indexedDB, state.files.ids, state.files.byId]);
// Refresh on mount, workbench changes, or external IndexedDB writes
useEffect(() => {
refreshStubs();
}, [refreshStubs, indexedDB.revision]);
// Once a pending file lands in state, open it in the viewer.
useEffect(() => {
if (!pendingViewFileId) return;
const isInWorkbench = state.files.ids.some(
(id) => (id as string) === pendingViewFileId,
);
if (isInWorkbench) {
setPendingViewFileId(null);
setActiveFileId(pendingViewFileId);
navActions.setWorkbench("viewer");
}
}, [pendingViewFileId, state.files.ids, setActiveFileId, navActions]);
const filteredFileStubs = searchQuery.trim()
? allFileStubs.filter((stub) =>
stub.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
: allFileStubs;
// Handle search activation
const handleSearchClick = useCallback(() => {
if (collapsed && onToggleCollapse) {
onToggleCollapse();
}
setSearchActive(true);
}, [collapsed, onToggleCollapse]);
const handleSearchClose = useCallback(() => {
setSearchActive(false);
setSearchQuery("");
}, []);
useEffect(() => {
if (searchActive && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [searchActive]);
// Handle Google Drive
const handleGoogleDriveClick = useCallback(async () => {
if (!isGoogleDriveEnabled) return;
const files = await openGoogleDrivePicker({ multiple: true });
if (files.length > 0) {
await addFiles(files);
if (!isMultiTool) {
navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor");
}
}
}, [
isGoogleDriveEnabled,
openGoogleDrivePicker,
addFiles,
navActions,
isMultiTool,
]);
// Toggle file in/out of workbench
const handleFileClick = useCallback(
async (fileId: FileId) => {
const stub = allFileStubs.find((s) => s.id === fileId);
if (!stub) return;
const workbenchFileId = state.files.ids.find(
(id) => (id as string) === (stub.id as string),
);
if (workbenchFileId) {
// If this is the file currently open in the viewer, route through the
// navigation guard so the save modal fires when there are unsaved changes.
const isCurrentlyViewed = workbenchFileId === viewedWorkbenchId;
if (isCurrentlyViewed) {
requestNavigation(() => {
void fileActions.removeFiles([workbenchFileId], false);
});
return;
}
await fileActions.removeFiles([workbenchFileId], false);
} else {
// Re-add by stub to preserve its ID — addFiles() would create a new UUID + IDB entry.
const workbenchCount = state.files.ids.length;
if (workbenchCount > 0 && currentWorkbench === "viewer") {
navActions.setWorkbench("fileEditor");
}
await fileActions.addStirlingFileStubs([stub]);
if (isMultiTool) {
fileActions.setSelectedFiles([
...state.ui.selectedFileIds,
stub.id,
]);
} else {
if (workbenchCount === 0) {
navActions.setWorkbench("viewer");
} else {
navActions.setWorkbench("fileEditor");
}
}
}
},
[
allFileStubs,
state.files.ids,
state.ui.selectedFileIds,
fileActions,
navActions,
currentWorkbench,
activeFileId,
requestNavigation,
isMultiTool,
],
);
// Which file is currently open in the viewer — stable ID, never index-derived.
const viewedWorkbenchId =
currentWorkbench === "viewer" ? activeFileId : null;
const handleEyeClick = useCallback(
async (fileId: FileId, _e: React.MouseEvent) => {
const stub = allFileStubs.find((s) => s.id === fileId);
if (!stub) return;
const isCurrentlyViewed = !!(
viewedWorkbenchId &&
(viewedWorkbenchId as string) === (stub.id as string)
);
if (isCurrentlyViewed) {
// Closing the currently-viewed file — guard against unsaved changes.
navActions.setWorkbench("fileEditor");
return;
}
// Switching to a different file while viewer is open — guard against unsaved changes.
const performSwitch = async () => {
const alreadyInWorkbench = state.files.ids.some(
(id) => (id as string) === (stub.id as string),
);
if (!alreadyInWorkbench) {
// Leave viewer before mutating workbench (prevents PSPDFKit crash).
if (state.files.ids.length > 0 && currentWorkbench === "viewer") {
navActions.setWorkbench("fileEditor");
}
await fileActions.addStirlingFileStubs([stub]);
}
// Route through pendingViewFileId so both setActiveFileIndex + setWorkbench fire together.
setPendingViewFileId(stub.id as string);
};
if (currentWorkbench === "viewer" && viewedWorkbenchId) {
requestNavigation(() => {
void performSwitch();
});
} else {
await performSwitch();
}
},
[
allFileStubs,
viewedWorkbenchId,
state.files.ids,
fileActions,
navActions,
currentWorkbench,
setPendingViewFileId,
requestNavigation,
],
);
const handleNativeFilePick = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (files.length > 0) {
await addFiles(files);
if (!isMultiTool) {
navActions.setWorkbench(
files.length === 1 ? "viewer" : "fileEditor",
);
}
}
e.target.value = "";
},
[addFiles, navActions, isMultiTool],
);
const shouldHideGoogleDrive =
!isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive;
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
return (
<div
ref={ref}
className="file-sidebar"
style={{ width, minWidth: width, maxWidth: width }}
data-collapsed={collapsed}
data-sidebar="file-sidebar"
>
<div className="file-sidebar-inner">
{/* Header: hamburger + branding */}
<div
className="file-sidebar-header"
onClick={() => onToggleCollapse?.()}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onToggleCollapse?.()}
aria-label={
collapsed
? t("fileSidebar.expand", "Expand sidebar")
: t("fileSidebar.collapse", "Collapse sidebar")
}
>
<MenuIcon className="file-sidebar-menu-icon" />
{!collapsed && (
<span className="file-sidebar-brand-text sidebar-content-fade">
Stirling PDF
</span>
)}
</div>
{/* Search row */}
{
<div
className={`file-sidebar-search-row${searchActive && !collapsed ? " active" : ""}`}
onClick={!searchActive ? handleSearchClick : undefined}
role={!searchActive ? "button" : undefined}
tabIndex={!searchActive ? 0 : undefined}
onKeyDown={
!searchActive
? (e) => e.key === "Enter" && handleSearchClick()
: undefined
}
>
{searchActive && !collapsed ? (
<CloseIcon
className="file-sidebar-search-icon"
onClick={(e) => {
e.stopPropagation();
handleSearchClose();
}}
/>
) : (
<SearchIcon className="file-sidebar-search-icon" />
)}
{!collapsed &&
(searchActive ? (
<input
ref={searchInputRef}
className="file-sidebar-search-input sidebar-content-fade"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t(
"fileSidebar.searchPlaceholder",
"Search files...",
)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="file-sidebar-search-label sidebar-content-fade">
{t("fileSidebar.search", "Search")}
</span>
))}
</div>
}
{/* Scrollable content */}
<div className="file-sidebar-scroll">
{/* Open from Computer + Google Drive */}
{
<>
<div
className="file-sidebar-action-row"
data-testid="files-button"
onClick={() => {
if (collapsed && onToggleCollapse) onToggleCollapse();
openFilesModal();
}}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && openFilesModal()}
>
<FolderOpenIcon className="file-sidebar-action-icon" />
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.openFromComputer", "Open from computer")}
</span>
)}
</div>
{!shouldHideGoogleDrive && (
<div
className={`file-sidebar-cloud-row${!isGoogleDriveEnabled ? " disabled" : ""}`}
onClick={handleGoogleDriveClick}
role="button"
tabIndex={isGoogleDriveEnabled ? 0 : -1}
aria-disabled={!isGoogleDriveEnabled}
title={
!isGoogleDriveEnabled
? t(
"fileSidebar.googleDriveDisabled",
"Google Drive is not configured",
)
: t("fileSidebar.googleDrive", "Open from Google Drive")
}
>
<div className="file-sidebar-cloud-icon-wrapper">
<GoogleDriveIcon
className="file-sidebar-cloud-icon-gray"
style={{ color: "var(--text-secondary)" }}
/>
{isGoogleDriveEnabled && (
<GoogleDriveIcon
colored
className="file-sidebar-cloud-icon-color"
/>
)}
</div>
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.googleDrive", "Google Drive")}
</span>
)}
</div>
)}
</>
}
{/* Files section - always visible when expanded */}
{!collapsed && (
<div className="file-sidebar-files-section sidebar-content-fade">
<div className="file-sidebar-section-header">
<span className="file-sidebar-section-label">
{t("fileSidebar.files", "Files")}
</span>
<button
className="file-sidebar-section-btn file-sidebar-section-btn-external"
onClick={() => openFilesModal()}
title={t(
"fileSidebar.openFileManager",
"Open file manager",
)}
type="button"
>
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
</button>
<button
className="file-sidebar-section-btn file-sidebar-section-btn-add"
onClick={() => nativeFileInputRef.current?.click()}
title={t("fileSidebar.addFiles", "Add files")}
type="button"
>
<AddIcon sx={{ fontSize: "1rem" }} />
</button>
<input
ref={nativeFileInputRef}
type="file"
multiple
accept=".pdf"
style={{ display: "none" }}
onChange={handleNativeFilePick}
/>
</div>
{!stubsLoaded ? (
<div className="file-sidebar-loading">
<Loader size="sm" color="var(--text-muted)" />
</div>
) : filteredFileStubs.length > 0 ? (
<div className="file-sidebar-file-list">
{filteredFileStubs.map((stub) => {
const workbenchFileId = state.files.ids.find(
(id) => (id as string) === (stub.id as string),
);
const isInWorkbench = !!workbenchFileId;
// Both active and viewed-in-viewer are ID-based — never index-based.
const isViewedInViewer = !!(
viewedWorkbenchId &&
viewedWorkbenchId === (stub.id as string)
);
const isActive = isViewedInViewer;
// In-memory thumbnail may be fresher than the IndexedDB stub.
// Encrypted files never get a raster thumbnail — use undefined
// so the sidebar icon is shown instead of a stale canvas thumbnail.
const isEncryptedFile =
stub.processedFile?.isEncrypted === true;
const thumbnailUrl = isEncryptedFile
? undefined
: (workbenchFileId
? state.files.byId[workbenchFileId]?.thumbnailUrl
: undefined) || stub.thumbnailUrl;
return (
<FileItem
key={stub.id}
fileId={stub.id}
name={stub.name}
size={stub.size}
lastModified={stub.lastModified}
isSelected={isInWorkbench}
isActive={isActive}
isViewedInViewer={isViewedInViewer}
thumbnailUrl={thumbnailUrl}
onClick={handleFileClick}
onEyeClick={handleEyeClick}
/>
);
})}
</div>
) : (
!searchActive && (
<div className="file-sidebar-empty">
<p className="file-sidebar-empty-text">
{t("fileSidebar.noFiles", "No files yet")}
</p>
<p className="file-sidebar-empty-hint">
{t("fileSidebar.dropHint", "Open files to get started")}
</p>
</div>
)
)}
</div>
)}
</div>
</div>
{/* Bottom bar: user name + settings */}
<div
className="file-sidebar-bottom-bar"
onClick={onOpenSettings}
role={onOpenSettings ? "button" : undefined}
tabIndex={onOpenSettings ? 0 : undefined}
onKeyDown={
onOpenSettings
? (e) => e.key === "Enter" && onOpenSettings()
: undefined
}
data-testid={onOpenSettings ? "config-button" : undefined}
aria-label={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
: undefined
}
title={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
: undefined
}
style={onOpenSettings ? { cursor: "pointer" } : undefined}
>
<div className="file-sidebar-bottom-avatar" title={displayName}>
{displayName.charAt(0).toUpperCase()}
</div>
{!collapsed && (
<span className="file-sidebar-bottom-name sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<div className="file-sidebar-bottom-settings">
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</div>
)}
</div>
</div>
);
},
);
export default FileSidebar;