diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css b/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css index 1c63636984..9430fba2be 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css @@ -11,7 +11,9 @@ --file-text-height: 58px; --file-text-bottom: 12px; --thumb-text-gap: 6px; - padding-bottom: calc(var(--file-text-bottom) + var(--file-text-height) + var(--thumb-text-gap)); + padding-bottom: calc( + var(--file-text-bottom) + var(--file-text-height) + var(--thumb-text-gap) + ); user-select: none; cursor: pointer; } @@ -156,8 +158,10 @@ pointer-events: none; } -.card:hover [data-hover-action-menu-mode="cssHover"][data-force-visible="false"], -.card:focus-within [data-hover-action-menu-mode="cssHover"][data-force-visible="false"] { +.card:hover + [data-hover-action-menu-mode="cssHover"][data-force-visible="false"], +.card:focus-within + [data-hover-action-menu-mode="cssHover"][data-force-visible="false"] { opacity: 1; pointer-events: auto; } diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index eb5a0c6340..51d6f0d45e 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -86,7 +86,10 @@ const FileEditorThumbnail = ({ const { state, selectors } = useFileState(); const isMobile = useIsMobile(); - const actualFile = useMemo(() => activeFiles.find((f) => f.fileId === file.id), [activeFiles, file.id]); + const actualFile = useMemo( + () => activeFiles.find((f) => f.fileId === file.id), + [activeFiles, file.id], + ); const isPinned = actualFile ? isFilePinned(actualFile) : false; const isZipFile = zipFileService.isZipFileStub(file); @@ -212,7 +215,10 @@ const FileEditorThumbnail = ({ [file.id, file.name, onReorderFiles], ); - const handleCloseWithConfirmation = useCallback(() => setShowCloseModal(true), []); + const handleCloseWithConfirmation = useCallback( + () => setShowCloseModal(true), + [], + ); const handleCancelClose = useCallback(() => setShowCloseModal(false), []); const handleConfirmClose = useCallback(() => { @@ -295,10 +301,20 @@ const FileEditorThumbnail = ({ if (actualFile) { if (isPinned) { unpinFile(actualFile); - alert({ alertType: "neutral", title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 }); + alert({ + alertType: "neutral", + title: `Unpinned ${file.name}`, + expandable: false, + durationMs: 3000, + }); } else { pinFile(actualFile); - alert({ alertType: "success", title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 }); + alert({ + alertType: "success", + title: `Pinned ${file.name}`, + expandable: false, + durationMs: 3000, + }); } } }, @@ -412,7 +428,9 @@ const FileEditorThumbnail = ({ onViewFile(file.id); }; - const metaLine = [dateLabel, extUpper ? `${extUpper} file` : "", pageLabel].filter(Boolean).join(" - "); + const metaLine = [dateLabel, extUpper ? `${extUpper} file` : "", pageLabel] + .filter(Boolean) + .join(" - "); return (
- {t("error._value", "Error")} + + {t("error._value", "Error")} +
)} @@ -474,7 +494,12 @@ const FileEditorThumbnail = ({ /> ) : file.type?.startsWith("application/pdf") ? ( - + Loading thumbnail... @@ -484,17 +509,26 @@ const FileEditorThumbnail = ({ {/* Badges — visible on hover via CSS */}
- v{file.versionNumber} + + v{file.versionNumber} + {isPinned && ( )} {isSharedFile && !isOwnedOrLocal && ( - {t("fileManager.sharedWithYou", "Shared")} + + {t("fileManager.sharedWithYou", "Shared")} + )} {isEncrypted && ( - + {/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */} - +
{/* File name + meta */} diff --git a/frontend/src/core/components/fileManager/CompactFileDetails.tsx b/frontend/src/core/components/fileManager/CompactFileDetails.tsx index 7e8a6ec0eb..09ca9a10b1 100644 --- a/frontend/src/core/components/fileManager/CompactFileDetails.tsx +++ b/frontend/src/core/components/fileManager/CompactFileDetails.tsx @@ -95,7 +95,9 @@ const CompactFileDetails: React.FC = ({ {/* File info */} - {currentFile ? currentFile.name : "No file loaded"} + + {currentFile ? currentFile.name : "No file loaded"} + {currentFile ? getFileSize(currentFile) : ""} @@ -152,7 +154,10 @@ const CompactFileDetails: React.FC = ({ disabled={!hasSelection && !canCloseAll} fullWidth style={{ - backgroundColor: hasSelection || canCloseAll ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)", + backgroundColor: + hasSelection || canCloseAll + ? "var(--btn-open-file)" + : "var(--mantine-color-gray-4)", color: "white", }} > diff --git a/frontend/src/core/components/fileManager/FileDetails.tsx b/frontend/src/core/components/fileManager/FileDetails.tsx index eb9ef3db9a..4621559ae8 100644 --- a/frontend/src/core/components/fileManager/FileDetails.tsx +++ b/frontend/src/core/components/fileManager/FileDetails.tsx @@ -12,7 +12,8 @@ interface FileDetailsProps { } const FileDetails: React.FC = ({ compact = false }) => { - const { selectedFiles, onOpenFiles, modalHeight, activeFileIds } = useFileManagerContext(); + const { selectedFiles, onOpenFiles, modalHeight, activeFileIds } = + useFileManagerContext(); const { t } = useTranslation(); const [currentFileIndex, setCurrentFileIndex] = useState(0); const [isAnimating, setIsAnimating] = useState(false); @@ -111,7 +112,10 @@ const FileDetails: React.FC = ({ compact = false }) => { disabled={!hasSelection && !canCloseAll} fullWidth style={{ - backgroundColor: hasSelection || canCloseAll ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)", + backgroundColor: + hasSelection || canCloseAll + ? "var(--btn-open-file)" + : "var(--mantine-color-gray-4)", color: "white", }} > diff --git a/frontend/src/core/components/fileManager/FileListItem.tsx b/frontend/src/core/components/fileManager/FileListItem.tsx index 4beee925f8..47273c7d69 100644 --- a/frontend/src/core/components/fileManager/FileListItem.tsx +++ b/frontend/src/core/components/fileManager/FileListItem.tsx @@ -188,7 +188,10 @@ const FileListItem: React.FC = ({ p="sm" style={{ cursor: isHistoryFile ? "default" : "pointer", - backgroundColor: isSelected || shouldShowHovered ? "var(--mantine-color-gray-1)" : "var(--bg-file-list)", + backgroundColor: + isSelected || shouldShowHovered + ? "var(--mantine-color-gray-1)" + : "var(--bg-file-list)", opacity: isSupported ? 1 : 0.5, transition: "background-color 0.15s ease", userSelect: "none", diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index 500b5116e2..0385a7536c 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -169,8 +169,13 @@ export default function Workbench() { } > {/* Workbench Bar - replaces TopControls and includes RightRail action buttons */} - {!customWorkbenchViews.find((v) => v.workbenchId === currentView)?.hideTopControls && ( - 0} /> + {!customWorkbenchViews.find((v) => v.workbenchId === currentView) + ?.hideTopControls && ( + 0} + /> )} {/* Dismiss All Errors Button */} diff --git a/frontend/src/core/components/pageEditor/PageSelectByNumberButton.tsx b/frontend/src/core/components/pageEditor/PageSelectByNumberButton.tsx index 7b3650bc44..0e29a02709 100644 --- a/frontend/src/core/components/pageEditor/PageSelectByNumberButton.tsx +++ b/frontend/src/core/components/pageEditor/PageSelectByNumberButton.tsx @@ -25,7 +25,13 @@ export default function PageSelectByNumberButton({ updatePagesFromCSV, }: PageSelectByNumberButtonProps) { return ( - +
diff --git a/frontend/src/core/components/shared/FilePickerModal.tsx b/frontend/src/core/components/shared/FilePickerModal.tsx index 97d92b479d..16f35f9fb1 100644 --- a/frontend/src/core/components/shared/FilePickerModal.tsx +++ b/frontend/src/core/components/shared/FilePickerModal.tsx @@ -245,7 +245,8 @@ const FilePickerModal = ({ {/* Selection summary */} {selectedFileIds.length > 0 && ( - {selectedFileIds.length} {t("fileManager.filesSelected", "files selected")} + {selectedFileIds.length}{" "} + {t("fileManager.filesSelected", "files selected")} )} diff --git a/frontend/src/core/components/shared/FileSelectorPicker.module.css b/frontend/src/core/components/shared/FileSelectorPicker.module.css index 078e506869..7d9b0251db 100644 --- a/frontend/src/core/components/shared/FileSelectorPicker.module.css +++ b/frontend/src/core/components/shared/FileSelectorPicker.module.css @@ -28,7 +28,8 @@ .triggerOpen { border-color: var(--mantine-color-blue-5); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--mantine-color-blue-5) 20%, transparent); + box-shadow: 0 0 0 2px + color-mix(in srgb, var(--mantine-color-blue-5) 20%, transparent); } /* ── Popover dropdown ────────────────────────────── */ @@ -67,7 +68,11 @@ max-height: 26px; border: 1px solid var(--border-default); border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--mantine-color-default-hover) 85%, transparent); + background: color-mix( + in srgb, + var(--mantine-color-default-hover) 85%, + transparent + ); padding: 1px; gap: 1px; box-sizing: border-box; diff --git a/frontend/src/core/components/shared/FileSelectorPicker.tsx b/frontend/src/core/components/shared/FileSelectorPicker.tsx index 98a4019354..cafd4fa7d7 100644 --- a/frontend/src/core/components/shared/FileSelectorPicker.tsx +++ b/frontend/src/core/components/shared/FileSelectorPicker.tsx @@ -1,9 +1,20 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { createPortal } from "react-dom"; -import { Box, Popover, ScrollArea, Text, Loader, FileButton } from "@mantine/core"; +import { + Box, + Popover, + ScrollArea, + Text, + Loader, + FileButton, +} from "@mantine/core"; import AddIcon from "@mui/icons-material/Add"; import { useTranslation } from "react-i18next"; -import { createStirlingFile, createFileId, createNewStirlingFileStub } from "@app/types/fileContext"; +import { + createStirlingFile, + createFileId, + createNewStirlingFileStub, +} from "@app/types/fileContext"; import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; import type { FileId } from "@app/types/file"; import { useAllFiles } from "@app/contexts/FileContext"; @@ -12,7 +23,10 @@ import { useFileContext } from "@app/contexts/file/fileHooks"; import { useFileManager } from "@app/hooks/useFileManager"; import { fileStorage } from "@app/services/fileStorage"; import apiClient from "@app/services/apiClient"; -import { parseContentDispositionFilename, extractLatestFilesFromBundle } from "@app/services/shareBundleUtils"; +import { + parseContentDispositionFilename, + extractLatestFilesFromBundle, +} from "@app/services/shareBundleUtils"; import { truncateCenter } from "@app/utils/textUtils"; import { generateThumbnailForFile } from "@app/utils/thumbnailUtils"; import styles from "@app/components/shared/FileSelectorPicker.module.css"; @@ -22,7 +36,11 @@ const LS_TAB = "filePicker.tab"; const LS_SORT = "filePicker.sort"; const LS_SORT_DIR = "filePicker.sortDir"; -function lsGet(key: string, fallback: T[number], valid: T): T[number] { +function lsGet( + key: string, + fallback: T[number], + valid: T, +): T[number] { try { const v = localStorage.getItem(key); const allowed = valid as readonly string[]; @@ -50,7 +68,11 @@ function formatBytes(bytes: number): string { function formatDate(ms: number | undefined): string { if (!ms) return ""; - return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); + return new Date(ms).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); } function buildMeta(stub: StirlingFileStub): string { @@ -81,18 +103,32 @@ export interface FileSelectorPickerProps { onSelect: (result: FileSelectorResult) => void; } -export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = false, onSelect }: FileSelectorPickerProps) { +export function FileSelectorPicker({ + placeholder, + excludeIds = [], + disabled = false, + onSelect, +}: FileSelectorPickerProps) { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); - const [activeTab, setActiveTab] = useState<"workbench" | "saved">(() => lsGet(LS_TAB, "saved", ["workbench", "saved"])); - const [sortBy, setSortBy] = useState<"date" | "name">(() => lsGet(LS_SORT, "date", ["date", "name"])); - const [sortDir, setSortDir] = useState<"asc" | "desc">(() => lsGet(LS_SORT_DIR, "desc", ["asc", "desc"])); + const [activeTab, setActiveTab] = useState<"workbench" | "saved">(() => + lsGet(LS_TAB, "saved", ["workbench", "saved"]), + ); + const [sortBy, setSortBy] = useState<"date" | "name">(() => + lsGet(LS_SORT, "date", ["date", "name"]), + ); + const [sortDir, setSortDir] = useState<"asc" | "desc">(() => + lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]), + ); const [savedStubs, setSavedStubs] = useState([]); const [savedLoading, setSavedLoading] = useState(false); const [loadingId, setLoadingId] = useState(null); const [uploadBusy, setUploadBusy] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const [hoveredStub, setHoveredStub] = useState<{ rect: DOMRect; stub: StirlingFileStub } | null>(null); + const [hoveredStub, setHoveredStub] = useState<{ + rect: DOMRect; + stub: StirlingFileStub; + } | null>(null); const [hoveredThumbnail, setHoveredThumbnail] = useState(null); const thumbCancelRef = useRef(false); @@ -120,7 +156,10 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa const thumbnail = await generateThumbnailForFile(file); if (thumbCancelRef.current || !thumbnail) return; setHoveredThumbnail(thumbnail); - void indexedDB.updateThumbnail(hoveredStub.stub.id as FileId, thumbnail); + void indexedDB.updateThumbnail( + hoveredStub.stub.id as FileId, + thumbnail, + ); } catch { // non-critical } @@ -172,19 +211,35 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa .finally(() => setSavedLoading(false)); }, [activeTab, isOpen, loadRecentFiles]); - const workbenchIdSet = useMemo(() => new Set(workbenchStubs.map((s) => s.id)), [workbenchStubs]); + const workbenchIdSet = useMemo( + () => new Set(workbenchStubs.map((s) => s.id)), + [workbenchStubs], + ); const displayStubs = useMemo(() => { const base = activeTab === "workbench" ? workbenchStubs : savedStubs; const q = searchQuery.trim().toLowerCase(); - const filtered = base.filter((s) => !excludeIds.includes(s.id) && (!q || s.name.toLowerCase().includes(q))); + const filtered = base.filter( + (s) => + !excludeIds.includes(s.id) && (!q || s.name.toLowerCase().includes(q)), + ); const dir = sortDir === "asc" ? 1 : -1; return [...filtered].sort((a, b) => sortBy === "name" ? dir * a.name.localeCompare(b.name) - : dir * ((a.lastModified || a.createdAt || 0) - (b.lastModified || b.createdAt || 0)), + : dir * + ((a.lastModified || a.createdAt || 0) - + (b.lastModified || b.createdAt || 0)), ); - }, [activeTab, workbenchStubs, savedStubs, excludeIds, sortBy, sortDir, searchQuery]); + }, [ + activeTab, + workbenchStubs, + savedStubs, + excludeIds, + sortBy, + sortDir, + searchQuery, + ]); const loadAndSelect = useCallback( async (stub: StirlingFileStub) => { @@ -195,7 +250,8 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa const sf = selectors.getFile(stub.id as FileId); if (sf) { // Prefer the workbench stub (has thumbnail) over the saved stub (may not) - const workbenchStub = selectors.getStirlingFileStub(stub.id as FileId) ?? stub; + const workbenchStub = + selectors.getStirlingFileStub(stub.id as FileId) ?? stub; onSelect({ stub: workbenchStub, stirlingFile: sf }); setIsOpen(false); } @@ -208,33 +264,53 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa let stirlingFile: StirlingFile | null = null; if (stub.remoteShareToken) { - const res = await apiClient.get(`/api/v1/storage/share-links/${stub.remoteShareToken}`, { - responseType: "blob", - suppressErrorToast: true, - skipAuthRedirect: true, - } as any); - const ct = res.headers?.["content-type"] || res.headers?.["Content-Type"] || ""; - const disp = res.headers?.["content-disposition"] || res.headers?.["Content-Disposition"] || ""; + const res = await apiClient.get( + `/api/v1/storage/share-links/${stub.remoteShareToken}`, + { + responseType: "blob", + suppressErrorToast: true, + skipAuthRedirect: true, + } as any, + ); + const ct = + res.headers?.["content-type"] || + res.headers?.["Content-Type"] || + ""; + const disp = + res.headers?.["content-disposition"] || + res.headers?.["Content-Disposition"] || + ""; const files = await extractLatestFilesFromBundle( res.data as Blob, parseContentDispositionFilename(disp) || "shared-file", ct, ); - if (files[0]) stirlingFile = createStirlingFile(files[0], createFileId()); + if (files[0]) + stirlingFile = createStirlingFile(files[0], createFileId()); } else if (stub.remoteStorageId) { - const res = await apiClient.get(`/api/v1/storage/files/${stub.remoteStorageId}/download`, { - responseType: "blob", - suppressErrorToast: true, - skipAuthRedirect: true, - } as any); - const ct = res.headers?.["content-type"] || res.headers?.["Content-Type"] || ""; - const disp = res.headers?.["content-disposition"] || res.headers?.["Content-Disposition"] || ""; + const res = await apiClient.get( + `/api/v1/storage/files/${stub.remoteStorageId}/download`, + { + responseType: "blob", + suppressErrorToast: true, + skipAuthRedirect: true, + } as any, + ); + const ct = + res.headers?.["content-type"] || + res.headers?.["Content-Type"] || + ""; + const disp = + res.headers?.["content-disposition"] || + res.headers?.["Content-Disposition"] || + ""; const files = await extractLatestFilesFromBundle( res.data as Blob, parseContentDispositionFilename(disp) || stub.name, ct, ); - if (files[0]) stirlingFile = createStirlingFile(files[0], stub.id as FileId); + if (files[0]) + stirlingFile = createStirlingFile(files[0], stub.id as FileId); } else { // Local IndexedDB file const localFile = await fileStorage.getStirlingFile(stub.id); @@ -250,7 +326,10 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa if (thumbnail) { resolvedStub = { ...stub, thumbnailUrl: thumbnail }; // Persist so subsequent opens don't regenerate - void fileStorage.updateThumbnail(stirlingFile.fileId as FileId, thumbnail); + void fileStorage.updateThumbnail( + stirlingFile.fileId as FileId, + thumbnail, + ); } } catch { // Non-fatal — thumbnail simply won't show @@ -299,7 +378,11 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa [disabled, loadRecentFiles, onSelect, uploadBusy], ); - const triggerClass = [styles.trigger, disabled ? styles.triggerDisabled : "", isOpen ? styles.triggerOpen : ""] + const triggerClass = [ + styles.trigger, + disabled ? styles.triggerDisabled : "", + isOpen ? styles.triggerOpen : "", + ] .filter(Boolean) .join(" "); @@ -327,15 +410,31 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa role="button" tabIndex={disabled ? -1 : 0} onKeyDown={(e) => { - if (!disabled && (e.key === "Enter" || e.key === " ")) setIsOpen((o) => !o); + if (!disabled && (e.key === "Enter" || e.key === " ")) + setIsOpen((o) => !o); }} aria-expanded={isOpen} aria-haspopup="listbox" > - + {placeholder || t("fileSelectorPicker.placeholder", "Select file")} - + @@ -345,12 +444,17 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
- + {(fbProps) => ( )} @@ -383,7 +505,10 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
@@ -429,7 +565,9 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
) : displayStubs.length === 0 ? (
- {t("fileSelectorPicker.empty", "No files available")} + + {t("fileSelectorPicker.empty", "No files available")} +
) : ( displayStubs.map((stub) => { @@ -442,7 +580,12 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa className={styles.fileItem} onClick={() => void loadAndSelect(stub)} disabled={!!loadingId} - onMouseEnter={(e) => setHoveredStub({ rect: e.currentTarget.getBoundingClientRect(), stub })} + onMouseEnter={(e) => + setHoveredStub({ + rect: e.currentTarget.getBoundingClientRect(), + stub, + }) + } onMouseLeave={() => setHoveredStub(null)} >
@@ -468,7 +611,11 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa left: hoveredStub.rect.left - 170, }} > - +
, document.body, )} diff --git a/frontend/src/core/components/shared/FileSidebar.tsx b/frontend/src/core/components/shared/FileSidebar.tsx index e261ce8d8a..31be214b76 100644 --- a/frontend/src/core/components/shared/FileSidebar.tsx +++ b/frontend/src/core/components/shared/FileSidebar.tsx @@ -1,11 +1,21 @@ -import React, { useState, useCallback, useRef, useEffect, forwardRef } from "react"; +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 { + 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"; @@ -32,425 +42,518 @@ export interface FileSidebarProps { onOpenSettings?: () => void; } -const FileSidebar = forwardRef(function FileSidebar( - { collapsed = false, onToggleCollapse, onOpenSettings }, - ref, -) { - const { t } = useTranslation(); - const [searchActive, setSearchActive] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const searchInputRef = useRef(null); - const nativeFileInputRef = useRef(null); - // State (not ref) so setting it triggers a re-render — avoids racing addFiles state updates. - const [pendingViewFileId, setPendingViewFileId] = useState(null); +const FileSidebar = forwardRef( + function FileSidebar( + { collapsed = false, onToggleCollapse, onOpenSettings }, + ref, + ) { + const { t } = useTranslation(); + const [searchActive, setSearchActive] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const searchInputRef = useRef(null); + const nativeFileInputRef = useRef(null); + // State (not ref) so setting it triggers a re-render — avoids racing addFiles state updates. + const [pendingViewFileId, setPendingViewFileId] = useState( + 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 } = useNavigationState(); - const { requestNavigation } = useNavigationGuard(); - const { activeFileId, setActiveFileId } = useViewer(); - const { addFiles } = useFileHandler(); - const indexedDB = useIndexedDB(); - const [displayName, setDisplayName] = useState("Guest"); + 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 } = useNavigationState(); + const { requestNavigation } = useNavigationGuard(); + const { activeFileId, setActiveFileId } = useViewer(); + const { addFiles } = useFileHandler(); + const indexedDB = useIndexedDB(); + const [displayName, setDisplayName] = useState("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([]); - 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 => !!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); - navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor"); - } - }, [isGoogleDriveEnabled, openGoogleDrivePicker, addFiles, navActions]); - - // 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 (workbenchCount === 0) { - navActions.setWorkbench("viewer"); - } else { - navActions.setWorkbench("fileEditor"); - } - } - }, - [allFileStubs, state.files.ids, fileActions, navActions, currentWorkbench, activeFileId, requestNavigation], - ); - - // 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(); + useEffect(() => { + if (!config?.enableLogin) return; + accountService + .getAccountData() + .then((data) => { + if (data?.username) setDisplayName(data.username); + }) + .catch(() => { + /* not logged in or security disabled */ }); - } else { - await performSwitch(); - } - }, - [ - allFileStubs, - viewedWorkbenchId, - state.files.ids, - fileActions, - navActions, - currentWorkbench, - setPendingViewFileId, - requestNavigation, - ], - ); + }, [config?.enableLogin]); - const handleNativeFilePick = useCallback( - async (e: React.ChangeEvent) => { - const files = Array.from(e.target.files ?? []); + // Leaf files = user-visible files (excludes intermediate tool outputs) + const [allFileStubs, setAllFileStubs] = useState([]); + 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 => + !!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); navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor"); } - e.target.value = ""; - }, - [addFiles, navActions], - ); + }, [isGoogleDriveEnabled, openGoogleDrivePicker, addFiles, navActions]); - const shouldHideGoogleDrive = !isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive; + // Toggle file in/out of workbench + const handleFileClick = useCallback( + async (fileId: FileId) => { + const stub = allFileStubs.find((s) => s.id === fileId); + if (!stub) return; - const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH; + const workbenchFileId = state.files.ids.find( + (id) => (id as string) === (stub.id as string), + ); - return ( -
-
- {/* Header: hamburger + branding */} -
{ - if (searchActive) { - handleSearchClose(); - } else { - onToggleCollapse?.(); - } - }} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === "Enter" && onToggleCollapse?.()} - aria-label={collapsed ? t("fileSidebar.expand", "Expand sidebar") : t("fileSidebar.collapse", "Collapse sidebar")} - > - {searchActive && !collapsed ? ( - - ) : ( - - )} - {!collapsed && Stirling PDF} -
+ 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; - {/* Search row */} - { -
e.key === "Enter" && handleSearchClick() : undefined} - > - - {!collapsed && - (searchActive ? ( - setSearchQuery(e.target.value)} - placeholder={t("fileSidebar.searchPlaceholder", "Search files...")} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - {t("fileSidebar.search", "Search")} - ))} -
+ if (workbenchCount > 0 && currentWorkbench === "viewer") { + navActions.setWorkbench("fileEditor"); + } + + await fileActions.addStirlingFileStubs([stub]); + + if (workbenchCount === 0) { + navActions.setWorkbench("viewer"); + } else { + navActions.setWorkbench("fileEditor"); + } + } + }, + [ + allFileStubs, + state.files.ids, + fileActions, + navActions, + currentWorkbench, + activeFileId, + requestNavigation, + ], + ); + + // 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; } - {/* Scrollable content */} -
- {/* Open from Computer + Google Drive */} - { - <> -
{ - if (collapsed && onToggleCollapse) onToggleCollapse(); - openFilesModal(); - }} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === "Enter" && openFilesModal()} - > - - {!collapsed && ( - - {t("fileSidebar.openFromComputer", "Open from computer")} - - )} -
+ // 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), + ); - {!shouldHideGoogleDrive && ( + 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) => { + const files = Array.from(e.target.files ?? []); + if (files.length > 0) { + await addFiles(files); + navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor"); + } + e.target.value = ""; + }, + [addFiles, navActions], + ); + + const shouldHideGoogleDrive = + !isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive; + + const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH; + + return ( +
+
+ {/* Header: hamburger + branding */} +
{ + if (searchActive) { + handleSearchClose(); + } else { + onToggleCollapse?.(); + } + }} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === "Enter" && onToggleCollapse?.()} + aria-label={ + collapsed + ? t("fileSidebar.expand", "Expand sidebar") + : t("fileSidebar.collapse", "Collapse sidebar") + } + > + {searchActive && !collapsed ? ( + + ) : ( + + )} + {!collapsed && ( + + Stirling PDF + + )} +
+ + {/* Search row */} + { +
e.key === "Enter" && handleSearchClick() + : undefined + } + > + + {!collapsed && + (searchActive ? ( + setSearchQuery(e.target.value)} + placeholder={t( + "fileSidebar.searchPlaceholder", + "Search files...", + )} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {t("fileSidebar.search", "Search")} + + ))} +
+ } + + {/* Scrollable content */} +
+ {/* Open from Computer + Google Drive */} + { + <>
{ + if (collapsed && onToggleCollapse) onToggleCollapse(); + openFilesModal(); + }} 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") - } + tabIndex={0} + onKeyDown={(e) => e.key === "Enter" && openFilesModal()} > -
- - {isGoogleDriveEnabled && } -
+ {!collapsed && ( - {t("fileSidebar.googleDrive", "Google Drive")} + {t("fileSidebar.openFromComputer", "Open from computer")} )}
- )} - - } - {/* Files section - always visible when expanded */} - {!collapsed && ( -
-
- {t("fileSidebar.files", "Files")} - - - -
- - {!stubsLoaded ? ( -
- -
- ) : filteredFileStubs.length > 0 ? ( -
- {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. - const thumbnailUrl = - (workbenchFileId ? state.files.byId[workbenchFileId]?.thumbnailUrl : undefined) || stub.thumbnailUrl; - return ( - +
+ - ); - })} -
- ) : ( - !searchActive && ( -
-

{t("fileSidebar.noFiles", "No files yet")}

-

{t("fileSidebar.dropHint", "Open files to get started")}

+ {isGoogleDriveEnabled && ( + + )} +
+ {!collapsed && ( + + {t("fileSidebar.googleDrive", "Google Drive")} + + )}
- ) - )} + )} + + } + + {/* Files section - always visible when expanded */} + {!collapsed && ( +
+
+ + {t("fileSidebar.files", "Files")} + + + + +
+ + {!stubsLoaded ? ( +
+ +
+ ) : filteredFileStubs.length > 0 ? ( +
+ {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. + const thumbnailUrl = + (workbenchFileId + ? state.files.byId[workbenchFileId]?.thumbnailUrl + : undefined) || stub.thumbnailUrl; + return ( + + ); + })} +
+ ) : ( + !searchActive && ( +
+

+ {t("fileSidebar.noFiles", "No files yet")} +

+

+ {t("fileSidebar.dropHint", "Open files to get started")} +

+
+ ) + )} +
+ )} +
+
+ + {/* Bottom bar: user name + settings */} +
e.key === "Enter" && onOpenSettings() + : undefined + } + aria-label={ + onOpenSettings + ? t("fileSidebar.openSettings", "Open settings") + : undefined + } + title={ + onOpenSettings + ? t("fileSidebar.openSettings", "Open settings") + : undefined + } + style={onOpenSettings ? { cursor: "pointer" } : undefined} + > +
+ {displayName.charAt(0).toUpperCase()} +
+ {!collapsed && ( + + {displayName} + + )} + {onOpenSettings && !collapsed && ( +
+
)}
- - {/* Bottom bar: user name + settings */} -
e.key === "Enter" && onOpenSettings() : undefined} - aria-label={onOpenSettings ? t("fileSidebar.openSettings", "Open settings") : undefined} - title={onOpenSettings ? t("fileSidebar.openSettings", "Open settings") : undefined} - style={onOpenSettings ? { cursor: "pointer" } : undefined} - > -
- {displayName.charAt(0).toUpperCase()} -
- {!collapsed && {displayName}} - {onOpenSettings && !collapsed && ( -
- -
- )} -
-
- ); -}); + ); + }, +); export default FileSidebar; diff --git a/frontend/src/core/components/shared/FileSidebarFileItem.css b/frontend/src/core/components/shared/FileSidebarFileItem.css index bdc1b2e98e..8de6de4881 100644 --- a/frontend/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/src/core/components/shared/FileSidebarFileItem.css @@ -158,7 +158,10 @@ z-index: 2; /* solid base + green tint overlay so content behind doesn't bleed through */ background-color: var(--bg-toolbar); - background-image: linear-gradient(rgba(34, 197, 94, 0.1), rgba(34, 197, 94, 0.1)); + background-image: linear-gradient( + rgba(34, 197, 94, 0.1), + rgba(34, 197, 94, 0.1) + ); } .file-sidebar-file-item.viewed .file-sidebar-file-name { @@ -172,7 +175,10 @@ /* viewed takes precedence over selected */ .file-sidebar-file-item.viewed.selected { background-color: var(--bg-toolbar); - background-image: linear-gradient(rgba(34, 197, 94, 0.1), rgba(34, 197, 94, 0.1)); + background-image: linear-gradient( + rgba(34, 197, 94, 0.1), + rgba(34, 197, 94, 0.1) + ); } /* Always show eye for the currently viewed file */ diff --git a/frontend/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/src/core/components/shared/FileSidebarFileItem.tsx index e44a5a32a3..ec1e55cae7 100644 --- a/frontend/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/src/core/components/shared/FileSidebarFileItem.tsx @@ -10,7 +10,11 @@ import "@app/components/shared/FileSidebarFileItem.css"; const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB /** Generate + persist a thumbnail for a sidebar file that doesn't have one yet. */ -function useLazyThumbnail(fileId: FileId, size: number, thumbnailUrl?: string): string | undefined { +function useLazyThumbnail( + fileId: FileId, + size: number, + thumbnailUrl?: string, +): string | undefined { const [thumb, setThumb] = useState(thumbnailUrl); const attempted = useRef(false); const indexedDB = useIndexedDB(); @@ -21,7 +25,8 @@ function useLazyThumbnail(fileId: FileId, size: number, thumbnailUrl?: string): }, [thumbnailUrl]); useEffect(() => { - if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT) return; + if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT) + return; attempted.current = true; let cancelled = false; @@ -57,10 +62,20 @@ export function formatFileDate(lastModifiedTs: number): string { const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); - const fileDay = new Date(lastModified.getFullYear(), lastModified.getMonth(), lastModified.getDate()); + const fileDay = new Date( + lastModified.getFullYear(), + lastModified.getMonth(), + lastModified.getDate(), + ); if (fileDay.getTime() === today.getTime()) { - return lastModified.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true }).toLowerCase(); + return lastModified + .toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }) + .toLowerCase(); } if (fileDay.getTime() === yesterday.getTime()) { return "Yesterday"; @@ -70,10 +85,19 @@ export function formatFileDate(lastModifiedTs: number): string { if (fileDay >= weekAgo) { return lastModified.toLocaleDateString("en-US", { weekday: "long" }); } - return lastModified.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + return lastModified.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); } -function FilePdfIcon({ className, style }: { className?: string; style?: React.CSSProperties }) { +function FilePdfIcon({ + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +}) { return ( @@ -216,7 +257,11 @@ export function FileItem({ )}
- {name} + + {name} + {dateLabel} {dateLabel && typeLabel ? " · " : ""} @@ -233,16 +278,29 @@ export function FileItem({ type="button" aria-label={isViewedInViewer ? "Close viewer" : "Open in viewer"} > - - + +
{thumbPos && resolvedThumbnail && createPortal( -
- +
+
, document.body, )} diff --git a/frontend/src/core/components/shared/TextInput.tsx b/frontend/src/core/components/shared/TextInput.tsx index 9dceab73c1..e134710529 100644 --- a/frontend/src/core/components/shared/TextInput.tsx +++ b/frontend/src/core/components/shared/TextInput.tsx @@ -108,7 +108,12 @@ export const TextInput = forwardRef( {...props} /> {shouldShowClearButton && ( - )} diff --git a/frontend/src/core/components/shared/ViewerInlineControls.tsx b/frontend/src/core/components/shared/ViewerInlineControls.tsx index c886db8225..96a9d2f06c 100644 --- a/frontend/src/core/components/shared/ViewerInlineControls.tsx +++ b/frontend/src/core/components/shared/ViewerInlineControls.tsx @@ -73,7 +73,9 @@ export function ViewerInlineControls() { - {Math.round(zoomPercent)}% + + {Math.round(zoomPercent)}% +
); } diff --git a/frontend/src/core/components/shared/WorkbenchBar.tsx b/frontend/src/core/components/shared/WorkbenchBar.tsx index 33245276bd..c2d988c02a 100644 --- a/frontend/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/src/core/components/shared/WorkbenchBar.tsx @@ -2,7 +2,11 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { ActionIcon } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useRightRail } from "@app/contexts/RightRailContext"; -import { useFileState, useFileSelection, useFileActions } from "@app/contexts/FileContext"; +import { + useFileState, + useFileSelection, + useFileActions, +} from "@app/contexts/FileContext"; import { isStirlingFile } from "@app/types/fileContext"; import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; @@ -12,7 +16,11 @@ import { WorkbenchType } from "@app/types/workbench"; import { Tooltip } from "@app/components/shared/Tooltip"; import LocalIcon from "@app/components/shared/LocalIcon"; import { downloadFile } from "@app/services/downloadService"; -import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from "@app/types/rightRail"; +import { + RightRailButtonConfig, + RightRailRenderContext, + RightRailSection, +} from "@app/types/rightRail"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; import FolderIcon from "@mui/icons-material/Folder"; import CloseIcon from "@mui/icons-material/Close"; @@ -33,7 +41,10 @@ interface WorkbenchBarProps { hasFiles: boolean; } -function renderWithTooltip(node: React.ReactNode, tooltip: React.ReactNode | undefined) { +function renderWithTooltip( + node: React.ReactNode, + tooltip: React.ReactNode | undefined, +) { if (!tooltip) return node; return ( { if (currentView === "pageEditor") return pageEditorTotalPages; @@ -77,7 +95,9 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: const sectionsWithButtons = useMemo(() => { return SECTION_ORDER.map((section) => { - const sectionButtons = buttons.filter((btn) => (btn.section ?? "top") === section && (btn.visible ?? true)); + const sectionButtons = buttons.filter( + (btn) => (btn.section ?? "top") === section && (btn.visible ?? true), + ); return { section, buttons: sectionButtons }; }).filter((entry) => entry.buttons.length > 0); }, [buttons]); @@ -87,9 +107,12 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: if (currentView === "viewer") { const buffer = await viewerContext?.exportActions?.saveAsCopy?.(); if (!buffer) return; - const fileToExport = selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0]; + const fileToExport = + selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0]; if (!fileToExport) return; - const stub = isStirlingFile(fileToExport) ? selectors.getStirlingFileStub(fileToExport.fileId) : undefined; + const stub = isStirlingFile(fileToExport) + ? selectors.getStirlingFileStub(fileToExport.fileId) + : undefined; try { const result = await downloadFile({ data: new Blob([buffer], { type: "application/pdf" }), @@ -113,9 +136,12 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: return; } - const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles; + const filesToExport = + selectedFiles.length > 0 ? selectedFiles : activeFiles; for (const file of filesToExport) { - const stub = isStirlingFile(file) ? selectors.getStirlingFileStub(file.fileId) : undefined; + const stub = isStirlingFile(file) + ? selectors.getStirlingFileStub(file.fileId) + : undefined; try { const result = await downloadFile({ data: file, @@ -130,11 +156,23 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: }); } } catch (error) { - console.error("[WorkbenchBar] Failed to export file:", file.name, error); + console.error( + "[WorkbenchBar] Failed to export file:", + file.name, + error, + ); } } }, - [currentView, selectedFiles, activeFiles, pageEditorFunctions, viewerContext, selectors, fileActions], + [ + currentView, + selectedFiles, + activeFiles, + pageEditorFunctions, + viewerContext, + selectors, + fileActions, + ], ); const handlePrint = useCallback(() => { @@ -146,11 +184,17 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: await fileActions.clearAllFiles(); } else if (currentView === "viewer") { const file = - (activeFileId ? activeFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId) : null) ?? activeFiles[0]; + (activeFileId + ? activeFiles.find( + (f) => isStirlingFile(f) && f.fileId === activeFileId, + ) + : null) ?? activeFiles[0]; const countBeforeRemove = activeFiles.length; if (file && isStirlingFile(file)) { // Pick the next file to show before removing, so the sidebar stays in sync. - const remaining = activeFiles.filter((f) => isStirlingFile(f) && f.fileId !== file.fileId); + const remaining = activeFiles.filter( + (f) => isStirlingFile(f) && f.fileId !== file.fileId, + ); const nextFile = remaining.find(isStirlingFile) ?? null; await fileActions.removeFiles([file.fileId], false); if (countBeforeRemove <= 1) { @@ -164,10 +208,19 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: } else if (currentView === "pageEditor") { pageEditorFunctions?.closePdf?.(); } - }, [currentView, fileActions, activeFiles, activeFileId, setActiveFileId, pageEditorFunctions, setCurrentView]); + }, [ + currentView, + fileActions, + activeFiles, + activeFileId, + setActiveFileId, + pageEditorFunctions, + setCurrentView, + ]); const downloadTooltip = useMemo(() => { - if (currentView === "pageEditor") return t("rightRail.exportAll", "Export PDF"); + if (currentView === "pageEditor") + return t("rightRail.exportAll", "Export PDF"); if (currentView === "viewer") return terminology.download; if (selectedCount > 0) return terminology.downloadSelected; return terminology.downloadAll; @@ -176,7 +229,9 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: const renderButton = useCallback( (btn: RightRailButtonConfig) => { const action = actions[btn.id]; - const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen); + const disabled = Boolean( + btn.disabled || allButtonsDisabled || disableForFullscreen, + ); const isActive = Boolean(btn.active); const triggerAction = () => { @@ -197,7 +252,9 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: if (!btn.icon) return null; - const ariaLabel = btn.ariaLabel || (typeof btn.tooltip === "string" ? (btn.tooltip as string) : undefined); + const ariaLabel = + btn.ariaLabel || + (typeof btn.tooltip === "string" ? (btn.tooltip as string) : undefined); const buttonNode = ( }, - { value: "fileEditor", label: t("workbenchBar.activeFiles", "Active Files"), icon: }, + { + value: "viewer", + label: t("workbenchBar.viewer", "Viewer"), + icon: , + }, + { + value: "fileEditor", + label: t("workbenchBar.activeFiles", "Active Files"), + icon: , + }, ]; const barRef = useRef(null); @@ -231,14 +296,19 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: const measure = () => { const viewsEl = bar.querySelector(".workbench-bar-views"); - const globalsEl = bar.querySelector(".workbench-bar-globals"); + const globalsEl = bar.querySelector( + ".workbench-bar-globals", + ); const centerEl = bar.querySelector(".workbench-bar-center"); const viewsWidth = viewsEl?.offsetWidth ?? 0; const globalsWidth = globalsEl?.offsetWidth ?? 0; - const centerChildren = centerEl ? (Array.from(centerEl.children) as HTMLElement[]) : []; + const centerChildren = centerEl + ? (Array.from(centerEl.children) as HTMLElement[]) + : []; const centerWidth = - centerChildren.reduce((sum, el) => sum + el.offsetWidth, 0) + Math.max(0, centerChildren.length - 1) * 2; // gap: 2px + centerChildren.reduce((sum, el) => sum + el.offsetWidth, 0) + + Math.max(0, centerChildren.length - 1) * 2; // gap: 2px const needed = viewsWidth + centerWidth + globalsWidth + 24; // 24px bar padding bar.dataset.wrapped = String(needed > bar.clientWidth); @@ -251,7 +321,12 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: }, []); return ( -
+
{/* Left: View switcher */}
{hasFiles && @@ -272,20 +347,22 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: {/* Tool buttons — second row, only rendered when buttons exist */} {sectionsWithButtons.length > 0 && (
- {sectionsWithButtons.map(({ section, buttons: sectionButtons }, idx) => ( - - {idx > 0 &&
} - {sectionButtons.map((btn) => { - const content = renderButton(btn); - if (!content) return null; - return ( -
- {content} -
- ); - })} - - ))} + {sectionsWithButtons.map( + ({ section, buttons: sectionButtons }, idx) => ( + + {idx > 0 &&
} + {sectionButtons.map((btn) => { + const content = renderButton(btn); + if (!content) return null; + return ( +
+ {content} +
+ ); + })} + + ), + )}
)} @@ -299,7 +376,9 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: radius="md" className="workbench-bar-action-icon" onClick={handlePrint} - disabled={totalItems === 0 || allButtonsDisabled || disableForFullscreen} + disabled={ + totalItems === 0 || allButtonsDisabled || disableForFullscreen + } aria-label={t("rightRail.print", "Print PDF")} > @@ -314,14 +393,20 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: radius="md" className="workbench-bar-action-icon" onClick={handleClose} - disabled={totalItems === 0 || allButtonsDisabled || disableForFullscreen} + disabled={ + totalItems === 0 || allButtonsDisabled || disableForFullscreen + } aria-label={ - currentView === "fileEditor" ? t("rightRail.closeAll", "Close All") : t("rightRail.closePdf", "Close PDF") + currentView === "fileEditor" + ? t("rightRail.closeAll", "Close All") + : t("rightRail.closePdf", "Close PDF") } > , - currentView === "fileEditor" ? t("rightRail.closeAll", "Close All") : t("rightRail.closePdf", "Close PDF"), + currentView === "fileEditor" + ? t("rightRail.closeAll", "Close All") + : t("rightRail.closePdf", "Close PDF"), )} {/* Download */} @@ -331,9 +416,15 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: radius="md" className="workbench-bar-action-icon" onClick={() => handleExportAll()} - disabled={disableForFullscreen || totalItems === 0 || allButtonsDisabled} + disabled={ + disableForFullscreen || totalItems === 0 || allButtonsDisabled + } > - + , downloadTooltip, )} @@ -346,9 +437,15 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: radius="md" className="workbench-bar-action-icon" onClick={() => handleExportAll(true)} - disabled={disableForFullscreen || totalItems === 0 || allButtonsDisabled} + disabled={ + disableForFullscreen || totalItems === 0 || allButtonsDisabled + } > - + , t("rightRail.saveAs", "Save As"), )} diff --git a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx index 19938d170f..6a7c93fe63 100644 --- a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx @@ -361,13 +361,22 @@ const GeneralSection: React.FC = ({ {/* Appearance */} -
+
{t("settings.general.theme", "Theme")} - {t("settings.general.themeDescription", "Switch between light and dark mode")} + {t( + "settings.general.themeDescription", + "Switch between light and dark mode", + )}
= ({ if ((themeMode === "dark") !== (val === "dark")) toggleTheme(); }} data={[ - { label: t("settings.general.themeLight", "Light"), value: "light" }, - { label: t("settings.general.themeDark", "Dark"), value: "dark" }, + { + label: t("settings.general.themeLight", "Light"), + value: "light", + }, + { + label: t("settings.general.themeDark", "Dark"), + value: "dark", + }, ]} />
-
+
{t("settings.general.language", "Language")} - {t("settings.general.languageDescription", "Choose the display language")} + {t( + "settings.general.languageDescription", + "Choose the display language", + )}
diff --git a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx index 6b7053043b..380d75ee08 100644 --- a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx +++ b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx @@ -207,7 +207,11 @@ export default function ViewerAnnotationControls({ onClick={handleRedactionToggle} disabled={disabled || currentView !== "viewer"} > - + @@ -238,7 +242,11 @@ export default function ViewerAnnotationControls({ aria-pressed={annotationsHidden} > diff --git a/frontend/src/core/components/shared/signing/CreateSessionPanel.tsx b/frontend/src/core/components/shared/signing/CreateSessionPanel.tsx index 2194d872a4..dfbb1c0f74 100644 --- a/frontend/src/core/components/shared/signing/CreateSessionPanel.tsx +++ b/frontend/src/core/components/shared/signing/CreateSessionPanel.tsx @@ -42,9 +42,12 @@ const CreateSessionPanel = ({ ) : ( <>
-
{t("quickAccess.selectedFile", "File")}
+
+ {t("quickAccess.selectedFile", "File")} +
- {selectedFiles[0]?.name || t("quickAccess.noFile", "No file loaded")} + {selectedFiles[0]?.name || + t("quickAccess.noFile", "No file loaded")}
diff --git a/frontend/src/core/components/tools/ToolPanelViewerBar.tsx b/frontend/src/core/components/tools/ToolPanelViewerBar.tsx index 7978095e14..7a4394585b 100644 --- a/frontend/src/core/components/tools/ToolPanelViewerBar.tsx +++ b/frontend/src/core/components/tools/ToolPanelViewerBar.tsx @@ -3,7 +3,10 @@ import { ActionIcon } from "@mantine/core"; import { useRightRail } from "@app/contexts/RightRailContext"; import { useNavigationState } from "@app/contexts/NavigationContext"; import { Tooltip } from "@app/components/shared/Tooltip"; -import type { RightRailButtonConfig, RightRailRenderContext } from "@app/types/rightRail"; +import type { + RightRailButtonConfig, + RightRailRenderContext, +} from "@app/types/rightRail"; /** * Mini toolbar rendered at the top of the ToolPanel when in viewer mode. @@ -41,7 +44,9 @@ export function ToolPanelViewerBar() { if (!btn.icon) return null; - const ariaLabel = btn.ariaLabel || (typeof btn.tooltip === "string" ? btn.tooltip : undefined); + const ariaLabel = + btn.ariaLabel || + (typeof btn.tooltip === "string" ? btn.tooltip : undefined); const buttonNode = ( +
{buttonNode}
); @@ -75,7 +86,10 @@ export function ToolPanelViewerBar() { const content = renderButton(btn); if (!content) return null; return ( -
+
{content}
); diff --git a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx index 9155bde6b9..d3a13a6fdc 100644 --- a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx +++ b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx @@ -111,7 +111,12 @@ const CompareDocumentPane = ({
- + {title ? truncateCenter(title, COMPARE_PANE_TITLE_MAX_LEN) : title} diff --git a/frontend/src/core/components/tools/compare/compareView.css b/frontend/src/core/components/tools/compare/compareView.css index a16b85799e..120f22415f 100644 --- a/frontend/src/core/components/tools/compare/compareView.css +++ b/frontend/src/core/components/tools/compare/compareView.css @@ -564,8 +564,13 @@ justify-content: center; gap: 0.35rem; border-radius: var(--radius-md); - border: 1px solid color-mix(in srgb, var(--mantine-color-blue-4) 28%, var(--border-default)); - background: color-mix(in srgb, var(--mantine-color-blue-0) 92%, var(--mantine-color-body)); + border: 1px solid + color-mix(in srgb, var(--mantine-color-blue-4) 28%, var(--border-default)); + background: color-mix( + in srgb, + var(--mantine-color-blue-0) 92%, + var(--mantine-color-body) + ); color: var(--mantine-color-blue-filled); cursor: pointer; font: inherit; @@ -575,8 +580,16 @@ } .compare-step-selection__swap:hover:not(:disabled) { - background: color-mix(in srgb, var(--mantine-color-blue-1) 75%, var(--mantine-color-body)); - border-color: color-mix(in srgb, var(--mantine-color-blue-5) 35%, var(--border-default)); + background: color-mix( + in srgb, + var(--mantine-color-blue-1) 75%, + var(--mantine-color-body) + ); + border-color: color-mix( + in srgb, + var(--mantine-color-blue-5) 35%, + var(--border-default) + ); } .compare-step-selection__swap:disabled { @@ -598,18 +611,31 @@ writing-mode: horizontal-tb; } -[data-mantine-color-scheme=”dark”] .compare-step-selection__swap { - background: color-mix(in srgb, var(--mantine-color-blue-9) 22%, var(--mantine-color-dark-6)); - border-color: color-mix(in srgb, var(--mantine-color-blue-4) 40%, var(--border-default)); +[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap { + background: color-mix( + in srgb, + var(--mantine-color-blue-9) 22%, + var(--mantine-color-dark-6) + ); + border-color: color-mix( + in srgb, + var(--mantine-color-blue-4) 40%, + var(--border-default) + ); color: var(--mantine-color-blue-3); } -[data-mantine-color-scheme=”dark”] .compare-step-selection__swap:hover:not(:disabled) { - background: color-mix(in srgb, var(--mantine-color-blue-9) 32%, var(--mantine-color-dark-6)); +[data-mantine-color-scheme="”dark”"] + .compare-step-selection__swap:hover:not(:disabled) { + background: color-mix( + in srgb, + var(--mantine-color-blue-9) 32%, + var(--mantine-color-dark-6) + ); } -[data-mantine-color-scheme=”dark”] .compare-step-selection__swap-icon, -[data-mantine-color-scheme=”dark”] .compare-step-selection__swap-label { +[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap-icon, +[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap-label { color: var(--mantine-color-blue-3); } diff --git a/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx index 48f4772d1c..08a0d0ed91 100644 --- a/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx @@ -165,7 +165,11 @@ const FileStatusIndicator = ({ > ✓{" "} {selectedFiles.length === 1 ? ( - {t("fileSelected", "{{filename}}", { filename: selectedFiles[0]?.name })} + + {t("fileSelected", "{{filename}}", { + filename: selectedFiles[0]?.name, + })} + ) : ( t("filesSelected", "{{count}} files", { count: selectedFiles.length }) )} diff --git a/frontend/src/core/components/tools/shared/OperationButton.tsx b/frontend/src/core/components/tools/shared/OperationButton.tsx index 0a516f3259..3993953eab 100644 --- a/frontend/src/core/components/tools/shared/OperationButton.tsx +++ b/frontend/src/core/components/tools/shared/OperationButton.tsx @@ -58,7 +58,10 @@ const OperationButton = ({ "tool.endpointUnavailable", "This tool is unavailable on your server.", ), - filesLoading: t("tool.filesLoading", "Files are still loading, please wait."), + filesLoading: t( + "tool.filesLoading", + "Files are still loading, please wait.", + ), noFiles: t("tool.noFiles", "Add a file to get started."), invalidParams: t("tool.invalidParams", "Fill in the required settings."), viewerMode: t( diff --git a/frontend/src/core/components/tools/shared/ScopedOperationButton.tsx b/frontend/src/core/components/tools/shared/ScopedOperationButton.tsx index 6283059f5a..1761c91e4b 100644 --- a/frontend/src/core/components/tools/shared/ScopedOperationButton.tsx +++ b/frontend/src/core/components/tools/shared/ScopedOperationButton.tsx @@ -34,7 +34,9 @@ export function ScopedOperationButton({ // Disable until all files are hydrated — running early would silently skip unloaded files. const isFilesHydrating = fileIds.length > allFiles.length; const effectiveDisabledReason = - isFilesHydrating && props.disabledReason !== "endpointUnavailable" ? "filesLoading" : props.disabledReason; + isFilesHydrating && props.disabledReason !== "endpointUnavailable" + ? "filesLoading" + : props.disabledReason; const isViewerMode = workbench === "viewer"; const isFileEditorMode = workbench === "fileEditor"; @@ -62,7 +64,9 @@ export function ScopedOperationButton({ const pendingCount = fileIds.length - allFiles.length; const isBulkLoading = pendingCount > 1; - const loadProgress = isBulkLoading ? (allFiles.length / fileIds.length) * 100 : 0; + const loadProgress = isBulkLoading + ? (allFiles.length / fileIds.length) * 100 + : 0; const showSelectFilesHint = !disableScopeHints && isFileEditorMode && @@ -79,7 +83,12 @@ export function ScopedOperationButton({ loading={!isBulkLoading} variant={props.variant ?? "filled"} color={props.color ?? "blue"} - style={{ position: "relative", overflow: "hidden", minHeight: "2.5rem", pointerEvents: "none" }} + style={{ + position: "relative", + overflow: "hidden", + minHeight: "2.5rem", + pointerEvents: "none", + }} > {isBulkLoading && ( <> @@ -94,17 +103,25 @@ export function ScopedOperationButton({ }} /> - {t("tool.filesLoadingProgress", "{{loaded}} / {{total}} files loading...", { - loaded: allFiles.length, - total: fileIds.length, - })} + {t( + "tool.filesLoadingProgress", + "{{loaded}} / {{total}} files loading...", + { + loaded: allFiles.length, + total: fileIds.length, + }, + )} )} ) : ( - + )} {viewerFileName && ( diff --git a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx index 7ca1c5ad32..d4021d794a 100644 --- a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx @@ -201,7 +201,9 @@ const ToolButton: React.FC = ({ handleUnlessSpecialClick(e, () => handleClick(id)); }; - const selectedStyles = isSelected ? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" } : {}; + const selectedStyles = isSelected + ? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" } + : {}; const buttonElement = navProps ? ( // For internal tools with URLs, render Button as an anchor for proper link behavior @@ -295,7 +297,12 @@ const ToolButton: React.FC = ({ return (
{star} - + {buttonElement}
diff --git a/frontend/src/core/components/tools/toolPicker/ToolSearch.tsx b/frontend/src/core/components/tools/toolPicker/ToolSearch.tsx index 3ba38bbe50..ae136c4333 100644 --- a/frontend/src/core/components/tools/toolPicker/ToolSearch.tsx +++ b/frontend/src/core/components/tools/toolPicker/ToolSearch.tsx @@ -94,8 +94,15 @@ const ToolSearch = ({ ref={searchRef} value={value} onChange={handleSearchChange} - placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")} - icon={iconOverride ?? (hideIcon ? undefined : )} + placeholder={ + placeholder || t("toolPicker.searchPlaceholder", "Search tools...") + } + icon={ + iconOverride ?? + (hideIcon ? undefined : ( + + )) + } iconClickable={!!iconOverride} autoComplete="off" onFocus={onFocus} diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 817c8075d0..ebb094d66a 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -333,14 +333,19 @@ const EmbedPdfViewerContent = ({ if (previewFile) { return previewFile; } else if (activeFiles.length > 0) { - const byId = activeFileId ? activeFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId) : null; + const byId = activeFileId + ? activeFiles.find( + (f) => isStirlingFile(f) && f.fileId === activeFileId, + ) + : null; return byId || activeFiles[0]; } return null; }, [previewFile, activeFiles, activeFileId]); // Stable id — avoids blob URL churn when FileContext recreates file objects each render. - const currentFileStableId = currentFile && isStirlingFile(currentFile) ? currentFile.fileId : null; + const currentFileStableId = + currentFile && isStirlingFile(currentFile) ? currentFile.fileId : null; const fileWithUrl = useFileWithUrl(currentFile, currentFileStableId); // Determine the effective file to display @@ -765,7 +770,14 @@ const EmbedPdfViewerContent = ({ formApplyInProgressRef.current = false; } }, - [currentFile, activeFiles, actions, selectors, activeFileIds.length, rotationState.rotation], + [ + currentFile, + activeFiles, + actions, + selectors, + activeFileIds.length, + rotationState.rotation, + ], ); useEffect(() => { @@ -823,7 +835,14 @@ const EmbedPdfViewerContent = ({ layerApplyInProgressRef.current = false; } }, - [currentFile, activeFiles, actions, selectors, activeFileIds.length, rotationState.rotation], + [ + currentFile, + activeFiles, + actions, + selectors, + activeFileIds.length, + rotationState.rotation, + ], ); // Discard pending redactions but save already-applied ones @@ -1097,7 +1116,11 @@ const EmbedPdfViewerContent = ({ // the effect re-fires before the async fetch completes. } - if (currentFile && !isCurrentFileEncrypted && (fileChanged || providerChanged)) { + if ( + currentFile && + !isCurrentFileEncrypted && + (fileChanged || providerChanged) + ) { console.log("[FormFill] Fetching form fields for:", currentFileId); fetchFormFields(currentFile, currentFileId ?? undefined); } @@ -1159,7 +1182,12 @@ const EmbedPdfViewerContent = ({
- {t("encryptedPdfUnlock.viewerLocked", "This PDF is password-protected")} + + {t( + "encryptedPdfUnlock.viewerLocked", + "This PDF is password-protected", + )} +
diff --git a/frontend/src/core/services/fileStorage.ts b/frontend/src/core/services/fileStorage.ts index f030f6e5ab..1676811f51 100644 --- a/frontend/src/core/services/fileStorage.ts +++ b/frontend/src/core/services/fileStorage.ts @@ -340,7 +340,12 @@ class FileStorageService { if (cursor) { const record = cursor.value as StoredStirlingFileRecord; // Only include leaf files (default to true if undefined) - if (record && record.name && typeof record.size === "number" && record.isLeaf !== false) { + if ( + record && + record.name && + typeof record.size === "number" && + record.isLeaf !== false + ) { const fresh = this.isThumbnailFresh(record); if (record.thumbnail) { if (fresh) tobump.push(record.id); @@ -408,7 +413,8 @@ class FileStorageService { const store = transaction.objectStore(this.storeName); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); - transaction.onabort = () => reject(transaction.error ?? new Error("Transaction aborted")); + transaction.onabort = () => + reject(transaction.error ?? new Error("Transaction aborted")); ids.forEach((id) => store.delete(id)); }); } diff --git a/frontend/src/core/tools/Compare.tsx b/frontend/src/core/tools/Compare.tsx index 28d25d7a2b..50ff935ddd 100644 --- a/frontend/src/core/tools/Compare.tsx +++ b/frontend/src/core/tools/Compare.tsx @@ -2,7 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import CompareRoundedIcon from "@mui/icons-material/CompareRounded"; import CloseIcon from "@mui/icons-material/Close"; -import { Box, Group, Stack, Text, Button, Modal, ActionIcon } from "@mantine/core"; +import { + Box, + Group, + Stack, + Text, + Button, + Modal, + ActionIcon, +} from "@mantine/core"; import SwapVertRoundedIcon from "@mui/icons-material/SwapVertRounded"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool"; @@ -24,7 +32,10 @@ import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbn import type { CompareWorkbenchData } from "@app/types/compare"; import { getDefaultWorkbench } from "@app/types/workbench"; import { truncateCenter } from "@app/utils/textUtils"; -import { FileSelectorPicker, FileSelectorResult } from "@app/components/shared/FileSelectorPicker"; +import { + FileSelectorPicker, + FileSelectorResult, +} from "@app/components/shared/FileSelectorPicker"; import "@app/components/tools/compare/compareView.css"; const CUSTOM_VIEW_ID = "compareWorkbenchView"; @@ -40,10 +51,16 @@ const Compare = (props: BaseToolProps) => { clearCustomWorkbenchViewData, } = useToolWorkflow(); - const base = useBaseTool("compare", useCompareParameters, useCompareOperation, props, { - minFiles: 0, - ignoreViewerScope: true, - }); + const base = useBaseTool( + "compare", + useCompareParameters, + useCompareOperation, + props, + { + minFiles: 0, + ignoreViewerScope: true, + }, + ); const operation = base.operation as CompareOperationHook; const params = base.params.parameters; @@ -141,7 +158,12 @@ const Compare = (props: BaseToolProps) => { ); const prepareWorkbenchForRun = useCallback( - (baseId: FileId | null, compId: FileId | null, baseFile: StirlingFile | null, comparisonFile: StirlingFile | null) => { + ( + baseId: FileId | null, + compId: FileId | null, + baseFile: StirlingFile | null, + comparisonFile: StirlingFile | null, + ) => { if (!baseId || !compId) return; updateWorkbenchData({ @@ -184,8 +206,10 @@ const Compare = (props: BaseToolProps) => { result, baseFileId, comparisonFileId, - baseLocalFile: baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null, - comparisonLocalFile: compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null, + baseLocalFile: + baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null, + comparisonLocalFile: + compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null, isLoading: false, }); lastProcessedAtRef.current = processedAt; @@ -198,8 +222,10 @@ const Compare = (props: BaseToolProps) => { result: null, baseFileId, comparisonFileId, - baseLocalFile: baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null, - comparisonLocalFile: compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null, + baseLocalFile: + baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null, + comparisonLocalFile: + compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null, isLoading: true, }); return; @@ -219,15 +245,33 @@ const Compare = (props: BaseToolProps) => { if (!baseSlot || !compSlot) return; const baseId = baseSlot.stirlingFile.fileId; const compId = compSlot.stirlingFile.fileId; - const selected: StirlingFile[] = [baseSlot.stirlingFile, compSlot.stirlingFile]; + const selected: StirlingFile[] = [ + baseSlot.stirlingFile, + compSlot.stirlingFile, + ]; - prepareWorkbenchForRun(baseId, compId, baseSlot.stirlingFile, compSlot.stirlingFile); + prepareWorkbenchForRun( + baseId, + compId, + baseSlot.stirlingFile, + compSlot.stirlingFile, + ); requestAnimationFrame(() => { navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID); }); - await operation.executeOperation({ ...params, baseFileId: baseId, comparisonFileId: compId }, selected); - }, [baseSlot, compSlot, navigationActions, operation, params, prepareWorkbenchForRun]); + await operation.executeOperation( + { ...params, baseFileId: baseId, comparisonFileId: compId }, + selected, + ); + }, [ + baseSlot, + compSlot, + navigationActions, + operation, + params, + prepareWorkbenchForRun, + ]); const performSwap = useCallback(() => { if (!baseSlot || !compSlot) return; @@ -238,14 +282,32 @@ const Compare = (props: BaseToolProps) => { if (operation.result) { const baseId = newBase.stirlingFile.fileId; const compId = newComp.stirlingFile.fileId; - const selected: StirlingFile[] = [newBase.stirlingFile, newComp.stirlingFile]; - prepareWorkbenchForRun(baseId, compId, newBase.stirlingFile, newComp.stirlingFile); + const selected: StirlingFile[] = [ + newBase.stirlingFile, + newComp.stirlingFile, + ]; + prepareWorkbenchForRun( + baseId, + compId, + newBase.stirlingFile, + newComp.stirlingFile, + ); requestAnimationFrame(() => { navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID); }); - void operation.executeOperation({ ...params, baseFileId: baseId, comparisonFileId: compId }, selected); + void operation.executeOperation( + { ...params, baseFileId: baseId, comparisonFileId: compId }, + selected, + ); } - }, [baseSlot, compSlot, navigationActions, operation, params, prepareWorkbenchForRun]); + }, [ + baseSlot, + compSlot, + navigationActions, + operation, + params, + prepareWorkbenchForRun, + ]); const handleSwap = useCallback(() => { if (!baseSlot || !compSlot) return; @@ -270,7 +332,11 @@ const Compare = (props: BaseToolProps) => { if (stub) { const dateMs = (stub.lastModified || stub.createdAt) ?? null; const dateText = dateMs - ? new Date(dateMs).toLocaleDateString(undefined, { month: "short", day: "2-digit", year: "numeric" }) + ? new Date(dateMs).toLocaleDateString(undefined, { + month: "short", + day: "2-digit", + year: "numeric", + }) : ""; const pageCount = stub.processedFile?.totalPages || null; @@ -300,14 +366,25 @@ const Compare = (props: BaseToolProps) => { - + {truncateCenter(stub.name || "", 50)} {pageCount && dateText && ( - + {pageCount} {t("compare.pages", "pages")}
{dateText} @@ -327,11 +404,17 @@ const Compare = (props: BaseToolProps) => { isDisabled ? t("compare.edited.selectBaseFirst", "Select original PDF first") : t( - role === "base" ? "compare.original.placeholder" : "compare.edited.placeholder", - role === "base" ? "Select the original PDF" : "Select the edited PDF", + role === "base" + ? "compare.original.placeholder" + : "compare.edited.placeholder", + role === "base" + ? "Select the original PDF" + : "Select the edited PDF", ) } - excludeIds={otherSlot ? [otherSlot.stirlingFile.fileId as string] : []} + excludeIds={ + otherSlot ? [otherSlot.stirlingFile.fileId as string] : [] + } disabled={isDisabled} onSelect={(result: FileSelectorResult) => { if (role === "base") setBaseSlot(result); @@ -376,7 +459,9 @@ const Compare = (props: BaseToolProps) => { styles={{ root: { textDecoration: "underline" } }} style={{ background: !hasAnySelected ? "transparent" : undefined, - color: !hasAnySelected ? "var(--spdf-clear-disabled-text)" : undefined, + color: !hasAnySelected + ? "var(--spdf-clear-disabled-text)" + : undefined, }} > {t("compare.clearSelected", "Clear selected")} @@ -404,8 +489,13 @@ const Compare = (props: BaseToolProps) => { disabled={base.operation.isLoading} aria-label={t("compare.swap.label", "Swap")} > - - {t("compare.swap.label", "Swap")} + + + {t("compare.swap.label", "Swap")} + )}
@@ -452,7 +542,12 @@ const Compare = (props: BaseToolProps) => { size="sm" > - {t("compare.clear.confirmBody", "This will clear the current file selections.")} + + {t( + "compare.clear.confirmBody", + "This will clear the current file selections.", + )} +