frontend validatoin
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -454,7 +472,9 @@ const FileEditorThumbnail = ({
|
||||
{/* Error overlay */}
|
||||
{hasError && (
|
||||
<div className={styles.errorOverlay}>
|
||||
<span className={styles.errorPill}>{t("error._value", "Error")}</span>
|
||||
<span className={styles.errorPill}>
|
||||
{t("error._value", "Error")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -474,7 +494,12 @@ const FileEditorThumbnail = ({
|
||||
/>
|
||||
</PrivateContent>
|
||||
) : file.type?.startsWith("application/pdf") ? (
|
||||
<Stack align="center" justify="center" gap="xs" style={{ height: "100%" }}>
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="xs"
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Loader size="sm" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading thumbnail...
|
||||
@@ -484,17 +509,26 @@ const FileEditorThumbnail = ({
|
||||
|
||||
{/* Badges — visible on hover via CSS */}
|
||||
<div className={styles.thumbBadges}>
|
||||
<span className={styles.versionBadgeThumb}>v{file.versionNumber}</span>
|
||||
<span className={styles.versionBadgeThumb}>
|
||||
v{file.versionNumber}
|
||||
</span>
|
||||
{isPinned && (
|
||||
<span className={styles.pinnedBadge}>
|
||||
<PushPinIcon style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
)}
|
||||
{isSharedFile && !isOwnedOrLocal && (
|
||||
<span className={styles.ownershipBadge}>{t("fileManager.sharedWithYou", "Shared")}</span>
|
||||
<span className={styles.ownershipBadge}>
|
||||
{t("fileManager.sharedWithYou", "Shared")}
|
||||
</span>
|
||||
)}
|
||||
{isEncrypted && (
|
||||
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"encryptedPdfUnlock.unlockPrompt",
|
||||
"Unlock PDF to continue",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="filled"
|
||||
@@ -521,7 +555,12 @@ const FileEditorThumbnail = ({
|
||||
</div>
|
||||
|
||||
{/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */}
|
||||
<HoverActionMenu show={isMobile} actions={hoverActions} position="outside" visibility="cssHover" />
|
||||
<HoverActionMenu
|
||||
show={isMobile}
|
||||
actions={hoverActions}
|
||||
position="outside"
|
||||
visibility="cssHover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File name + meta */}
|
||||
|
||||
@@ -95,7 +95,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
{/* File info */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
<PrivateContent>{currentFile ? currentFile.name : "No file loaded"}</PrivateContent>
|
||||
<PrivateContent>
|
||||
{currentFile ? currentFile.name : "No file loaded"}
|
||||
</PrivateContent>
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile ? getFileSize(currentFile) : ""}
|
||||
@@ -152,7 +154,10 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -12,7 +12,8 @@ interface FileDetailsProps {
|
||||
}
|
||||
|
||||
const FileDetails: React.FC<FileDetailsProps> = ({ 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<FileDetailsProps> = ({ 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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -188,7 +188,10 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
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",
|
||||
|
||||
@@ -169,8 +169,13 @@ export default function Workbench() {
|
||||
}
|
||||
>
|
||||
{/* Workbench Bar - replaces TopControls and includes RightRail action buttons */}
|
||||
{!customWorkbenchViews.find((v) => v.workbenchId === currentView)?.hideTopControls && (
|
||||
<WorkbenchBar currentView={currentView} setCurrentView={setCurrentView} hasFiles={activeFiles.length > 0} />
|
||||
{!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
||||
?.hideTopControls && (
|
||||
<WorkbenchBar
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
hasFiles={activeFiles.length > 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
|
||||
@@ -25,7 +25,13 @@ export default function PageSelectByNumberButton({
|
||||
updatePagesFromCSV,
|
||||
}: PageSelectByNumberButtonProps) {
|
||||
return (
|
||||
<Tooltip content={label} position="bottom" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip
|
||||
content={label}
|
||||
position="bottom"
|
||||
offset={12}
|
||||
arrow
|
||||
portalTarget={document.body}
|
||||
>
|
||||
<div className={`right-rail-fade enter`}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
|
||||
@@ -245,7 +245,8 @@ const FilePickerModal = ({
|
||||
{/* Selection summary */}
|
||||
{selectedFileIds.length > 0 && (
|
||||
<Text size="sm" c="blue" ta="center">
|
||||
{selectedFileIds.length} {t("fileManager.filesSelected", "files selected")}
|
||||
{selectedFileIds.length}{" "}
|
||||
{t("fileManager.filesSelected", "files selected")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<const T extends readonly string[]>(key: string, fallback: T[number], valid: T): T[number] {
|
||||
function lsGet<const T extends readonly string[]>(
|
||||
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<StirlingFileStub[]>([]);
|
||||
const [savedLoading, setSavedLoading] = useState(false);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(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<string | null>(null);
|
||||
const thumbCancelRef = useRef<boolean>(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"
|
||||
>
|
||||
<Text size="sm" c="dimmed" style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{placeholder || t("fileSelectorPicker.placeholder", "Select file")}
|
||||
</Text>
|
||||
<AddIcon style={{ fontSize: 18, color: "var(--mantine-color-dimmed)", flexShrink: 0 }} />
|
||||
<AddIcon
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Popover.Target>
|
||||
|
||||
@@ -345,12 +444,17 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
|
||||
<div
|
||||
className={styles.slimTabBar}
|
||||
role="group"
|
||||
aria-label={t("fileSelectorPicker.tabListLabel", "Saved files, workbench, upload")}
|
||||
aria-label={t(
|
||||
"fileSelectorPicker.tabListLabel",
|
||||
"Saved files, workbench, upload",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeTab === "saved"}
|
||||
className={activeTab === "saved" ? styles.slimTabActive : styles.slimTab}
|
||||
className={
|
||||
activeTab === "saved" ? styles.slimTabActive : styles.slimTab
|
||||
}
|
||||
onClick={() => handleTabChange("saved")}
|
||||
>
|
||||
{t("fileSelectorPicker.tabs.saved", "Saved files")}
|
||||
@@ -358,22 +462,40 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeTab === "workbench"}
|
||||
className={activeTab === "workbench" ? styles.slimTabActive : styles.slimTab}
|
||||
className={
|
||||
activeTab === "workbench"
|
||||
? styles.slimTabActive
|
||||
: styles.slimTab
|
||||
}
|
||||
onClick={() => handleTabChange("workbench")}
|
||||
>
|
||||
{t("fileSelectorPicker.tabs.workbench", "Workbench")}
|
||||
</button>
|
||||
<FileButton onChange={handleUpload} accept=".pdf,application/pdf" disabled={disabled || uploadBusy}>
|
||||
<FileButton
|
||||
onChange={handleUpload}
|
||||
accept=".pdf,application/pdf"
|
||||
disabled={disabled || uploadBusy}
|
||||
>
|
||||
{(fbProps) => (
|
||||
<button
|
||||
{...fbProps}
|
||||
type="button"
|
||||
className={styles.slimTabUpload}
|
||||
aria-label={t("fileSelectorPicker.upload", "Upload from computer")}
|
||||
title={t("fileSelectorPicker.upload", "Upload from computer")}
|
||||
aria-label={t(
|
||||
"fileSelectorPicker.upload",
|
||||
"Upload from computer",
|
||||
)}
|
||||
title={t(
|
||||
"fileSelectorPicker.upload",
|
||||
"Upload from computer",
|
||||
)}
|
||||
disabled={disabled || uploadBusy}
|
||||
>
|
||||
{uploadBusy ? <Loader size={11} /> : t("fileSelectorPicker.tabs.upload", "Upload")}
|
||||
{uploadBusy ? (
|
||||
<Loader size={11} />
|
||||
) : (
|
||||
t("fileSelectorPicker.tabs.upload", "Upload")
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</FileButton>
|
||||
@@ -383,7 +505,10 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
|
||||
<div className={styles.sortGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.sortBtn, sortBy === "date" ? styles.sortBtnActive : ""].join(" ")}
|
||||
className={[
|
||||
styles.sortBtn,
|
||||
sortBy === "date" ? styles.sortBtnActive : "",
|
||||
].join(" ")}
|
||||
onClick={() => handleSortChange("date")}
|
||||
title={
|
||||
sortDir === "desc"
|
||||
@@ -392,11 +517,18 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
|
||||
}
|
||||
>
|
||||
{t("fileSelectorPicker.sort.dateLabel", "Latest")}
|
||||
{sortBy === "date" && <span className={styles.sortArrow}>{sortDir === "desc" ? " ↓" : " ↑"}</span>}
|
||||
{sortBy === "date" && (
|
||||
<span className={styles.sortArrow}>
|
||||
{sortDir === "desc" ? " ↓" : " ↑"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.sortBtn, sortBy === "name" ? styles.sortBtnActive : ""].join(" ")}
|
||||
className={[
|
||||
styles.sortBtn,
|
||||
sortBy === "name" ? styles.sortBtnActive : "",
|
||||
].join(" ")}
|
||||
onClick={() => handleSortChange("name")}
|
||||
title={
|
||||
sortDir === "asc"
|
||||
@@ -405,7 +537,11 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
|
||||
}
|
||||
>
|
||||
{t("fileSelectorPicker.sort.nameLabel", "A–Z")}
|
||||
{sortBy === "name" && <span className={styles.sortArrow}>{sortDir === "asc" ? " ↑" : " ↓"}</span>}
|
||||
{sortBy === "name" && (
|
||||
<span className={styles.sortArrow}>
|
||||
{sortDir === "asc" ? " ↑" : " ↓"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,7 +565,9 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
|
||||
</div>
|
||||
) : displayStubs.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<Text size="sm">{t("fileSelectorPicker.empty", "No files available")}</Text>
|
||||
<Text size="sm">
|
||||
{t("fileSelectorPicker.empty", "No files available")}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
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)}
|
||||
>
|
||||
<div className={styles.fileItemContent}>
|
||||
@@ -468,7 +611,11 @@ export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = fa
|
||||
left: hoveredStub.rect.left - 170,
|
||||
}}
|
||||
>
|
||||
<img src={hoveredThumbnail} alt="" className="file-sidebar-thumb-img" />
|
||||
<img
|
||||
src={hoveredThumbnail}
|
||||
alt=""
|
||||
className="file-sidebar-thumb-img"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -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<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 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 } = useNavigationState();
|
||||
const { requestNavigation } = useNavigationGuard();
|
||||
const { activeFileId, setActiveFileId } = useViewer();
|
||||
const { addFiles } = useFileHandler();
|
||||
const indexedDB = useIndexedDB();
|
||||
const [displayName, setDisplayName] = useState<string>("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<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);
|
||||
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<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
// 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);
|
||||
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 (
|
||||
<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={() => {
|
||||
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 ? (
|
||||
<CloseIcon className="file-sidebar-menu-icon" />
|
||||
) : (
|
||||
<MenuIcon className="file-sidebar-menu-icon" />
|
||||
)}
|
||||
{!collapsed && <span className="file-sidebar-brand-text sidebar-content-fade">Stirling PDF</span>}
|
||||
</div>
|
||||
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 */}
|
||||
{
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
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 */}
|
||||
<div className="file-sidebar-scroll">
|
||||
{/* Open from Computer + Google Drive */}
|
||||
{
|
||||
<>
|
||||
<div
|
||||
className="file-sidebar-action-row"
|
||||
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>
|
||||
// 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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<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={() => {
|
||||
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 ? (
|
||||
<CloseIcon className="file-sidebar-menu-icon" />
|
||||
) : (
|
||||
<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
|
||||
}
|
||||
>
|
||||
<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-cloud-row${!isGoogleDriveEnabled ? " disabled" : ""}`}
|
||||
onClick={handleGoogleDriveClick}
|
||||
className="file-sidebar-action-row"
|
||||
onClick={() => {
|
||||
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()}
|
||||
>
|
||||
<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>
|
||||
<FolderOpenIcon className="file-sidebar-action-icon" />
|
||||
{!collapsed && (
|
||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||
{t("fileSidebar.googleDrive", "Google Drive")}
|
||||
{t("fileSidebar.openFromComputer", "Open from computer")}
|
||||
</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.
|
||||
const thumbnailUrl =
|
||||
(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}
|
||||
{!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)" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
{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.
|
||||
const thumbnailUrl =
|
||||
(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
|
||||
}
|
||||
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>
|
||||
|
||||
{/* 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}
|
||||
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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<string | undefined>(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 (
|
||||
<svg
|
||||
className={className}
|
||||
@@ -97,7 +121,13 @@ function FilePdfIcon({ className, style }: { className?: string; style?: React.C
|
||||
);
|
||||
}
|
||||
|
||||
function FileGenericIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
||||
function FileGenericIcon({
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
@@ -117,7 +147,13 @@ function FileGenericIcon({ className, style }: { className?: string; style?: Rea
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
||||
function CheckIcon({
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
@@ -179,7 +215,12 @@ export function FileItem({
|
||||
|
||||
// Reactive: tooltip appears as soon as both hover rect and thumbnail are ready
|
||||
const thumbPos =
|
||||
hoverRect && resolvedThumbnail ? { top: hoverRect.top + hoverRect.height / 2, left: hoverRect.right + 10 } : null;
|
||||
hoverRect && resolvedThumbnail
|
||||
? {
|
||||
top: hoverRect.top + hoverRect.height / 2,
|
||||
left: hoverRect.right + 10,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -216,7 +257,11 @@ export function FileItem({
|
||||
)}
|
||||
</div>
|
||||
<div className="file-sidebar-file-info">
|
||||
<span className={`file-sidebar-file-name${isSelected ? " selected" : ""}`}>{name}</span>
|
||||
<span
|
||||
className={`file-sidebar-file-name${isSelected ? " selected" : ""}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span className="file-sidebar-file-meta">
|
||||
{dateLabel}
|
||||
{dateLabel && typeLabel ? " · " : ""}
|
||||
@@ -233,16 +278,29 @@ export function FileItem({
|
||||
type="button"
|
||||
aria-label={isViewedInViewer ? "Close viewer" : "Open in viewer"}
|
||||
>
|
||||
<VisibilityOutlinedIcon className="file-sidebar-eye-open" sx={{ fontSize: "1.1rem" }} />
|
||||
<VisibilityOffOutlinedIcon className="file-sidebar-eye-closed" sx={{ fontSize: "1.1rem" }} />
|
||||
<VisibilityOutlinedIcon
|
||||
className="file-sidebar-eye-open"
|
||||
sx={{ fontSize: "1.1rem" }}
|
||||
/>
|
||||
<VisibilityOffOutlinedIcon
|
||||
className="file-sidebar-eye-closed"
|
||||
sx={{ fontSize: "1.1rem" }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{thumbPos &&
|
||||
resolvedThumbnail &&
|
||||
createPortal(
|
||||
<div className="file-sidebar-thumb-tooltip" style={{ top: thumbPos.top, left: thumbPos.left }}>
|
||||
<img src={resolvedThumbnail} alt="" className="file-sidebar-thumb-img" />
|
||||
<div
|
||||
className="file-sidebar-thumb-tooltip"
|
||||
style={{ top: thumbPos.top, left: thumbPos.left }}
|
||||
>
|
||||
<img
|
||||
src={resolvedThumbnail}
|
||||
alt=""
|
||||
className="file-sidebar-thumb-img"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -108,7 +108,12 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
{...props}
|
||||
/>
|
||||
{shouldShowClearButton && (
|
||||
<button type="button" className={styles.clearButton} onClick={handleClear} aria-label="Clear input">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearButton}
|
||||
onClick={handleClear}
|
||||
aria-label="Clear input"
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -73,7 +73,9 @@ export function ViewerInlineControls() {
|
||||
<ZoomInIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
|
||||
<span className="viewer-inline-controls__zoom-pct">{Math.round(zoomPercent)}%</span>
|
||||
<span className="viewer-inline-controls__zoom-pct">
|
||||
{Math.round(zoomPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Tooltip
|
||||
@@ -48,11 +59,17 @@ function renderWithTooltip(node: React.ReactNode, tooltip: React.ReactNode | und
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }: WorkbenchBarProps) {
|
||||
export default function WorkbenchBar({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
hasFiles,
|
||||
}: WorkbenchBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
||||
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
|
||||
const disableForFullscreen = toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
|
||||
const { pageEditorFunctions, toolPanelMode, leftPanelView } =
|
||||
useToolWorkflow();
|
||||
const disableForFullscreen =
|
||||
toolPanelMode === "fullscreen" && leftPanelView === "toolPicker";
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
@@ -63,7 +80,8 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
|
||||
const activeFiles = selectors.getFiles();
|
||||
const { activeFileId, setActiveFileId } = useViewer();
|
||||
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
|
||||
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
|
||||
const pageEditorSelectedCount =
|
||||
pageEditorFunctions?.selectedPageIds?.length ?? 0;
|
||||
|
||||
const totalItems = useMemo(() => {
|
||||
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 = (
|
||||
<ActionIcon
|
||||
variant={isActive ? "filled" : "subtle"}
|
||||
@@ -219,8 +276,16 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
|
||||
|
||||
// View options
|
||||
const viewOptions: ViewOption[] = [
|
||||
{ value: "viewer", label: t("workbenchBar.viewer", "Viewer"), icon: <InsertDriveFileIcon fontSize="small" /> },
|
||||
{ value: "fileEditor", label: t("workbenchBar.activeFiles", "Active Files"), icon: <FolderIcon fontSize="small" /> },
|
||||
{
|
||||
value: "viewer",
|
||||
label: t("workbenchBar.viewer", "Viewer"),
|
||||
icon: <InsertDriveFileIcon fontSize="small" />,
|
||||
},
|
||||
{
|
||||
value: "fileEditor",
|
||||
label: t("workbenchBar.activeFiles", "Active Files"),
|
||||
icon: <FolderIcon fontSize="small" />,
|
||||
},
|
||||
];
|
||||
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
@@ -231,14 +296,19 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
|
||||
|
||||
const measure = () => {
|
||||
const viewsEl = bar.querySelector<HTMLElement>(".workbench-bar-views");
|
||||
const globalsEl = bar.querySelector<HTMLElement>(".workbench-bar-globals");
|
||||
const globalsEl = bar.querySelector<HTMLElement>(
|
||||
".workbench-bar-globals",
|
||||
);
|
||||
const centerEl = bar.querySelector<HTMLElement>(".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 (
|
||||
<div ref={barRef} className="workbench-bar" data-wrapped="true" data-tour="workbench-bar">
|
||||
<div
|
||||
ref={barRef}
|
||||
className="workbench-bar"
|
||||
data-wrapped="true"
|
||||
data-tour="workbench-bar"
|
||||
>
|
||||
{/* Left: View switcher */}
|
||||
<div className="workbench-bar-views">
|
||||
{hasFiles &&
|
||||
@@ -272,20 +347,22 @@ export default function WorkbenchBar({ currentView, setCurrentView, hasFiles }:
|
||||
{/* Tool buttons — second row, only rendered when buttons exist */}
|
||||
{sectionsWithButtons.length > 0 && (
|
||||
<div className="workbench-bar-center">
|
||||
{sectionsWithButtons.map(({ section, buttons: sectionButtons }, idx) => (
|
||||
<React.Fragment key={section}>
|
||||
{idx > 0 && <div className="workbench-bar-divider" />}
|
||||
{sectionButtons.map((btn) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div key={btn.id} className="workbench-bar-action-wrapper">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{sectionsWithButtons.map(
|
||||
({ section, buttons: sectionButtons }, idx) => (
|
||||
<React.Fragment key={section}>
|
||||
{idx > 0 && <div className="workbench-bar-divider" />}
|
||||
{sectionButtons.map((btn) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div key={btn.id} className="workbench-bar-action-wrapper">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
<PrintIcon sx={{ fontSize: "1rem" }} />
|
||||
@@ -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")
|
||||
}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>,
|
||||
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
|
||||
}
|
||||
>
|
||||
<LocalIcon icon={icons.downloadIconName} width="1rem" height="1rem" />
|
||||
<LocalIcon
|
||||
icon={icons.downloadIconName}
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>,
|
||||
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
|
||||
}
|
||||
>
|
||||
<LocalIcon icon={icons.saveAsIconName} width="1rem" height="1rem" />
|
||||
<LocalIcon
|
||||
icon={icons.saveAsIconName}
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>,
|
||||
t("rightRail.saveAs", "Save As"),
|
||||
)}
|
||||
|
||||
@@ -361,13 +361,22 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
{/* Appearance */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.theme", "Theme")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t("settings.general.themeDescription", "Switch between light and dark mode")}
|
||||
{t(
|
||||
"settings.general.themeDescription",
|
||||
"Switch between light and dark mode",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
@@ -376,18 +385,33 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t("settings.general.language", "Language")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t("settings.general.languageDescription", "Choose the display language")}
|
||||
{t(
|
||||
"settings.general.languageDescription",
|
||||
"Choose the display language",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<LanguageSelector position="bottom-end" offset={6} />
|
||||
|
||||
@@ -207,7 +207,11 @@ export default function ViewerAnnotationControls({
|
||||
onClick={handleRedactionToggle}
|
||||
disabled={disabled || currentView !== "viewer"}
|
||||
>
|
||||
<LocalIcon icon="scan-delete-rounded" width="1.25rem" height="1.25rem" />
|
||||
<LocalIcon
|
||||
icon="scan-delete-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
@@ -238,7 +242,11 @@ export default function ViewerAnnotationControls({
|
||||
aria-pressed={annotationsHidden}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "preview-off-rounded"}
|
||||
icon={
|
||||
viewerContext?.isAnnotationsVisible
|
||||
? "visibility"
|
||||
: "preview-off-rounded"
|
||||
}
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
|
||||
@@ -42,9 +42,12 @@ const CreateSessionPanel = ({
|
||||
) : (
|
||||
<>
|
||||
<div className="quick-access-popout__section">
|
||||
<div className="quick-access-popout__label">{t("quickAccess.selectedFile", "File")}</div>
|
||||
<div className="quick-access-popout__label">
|
||||
{t("quickAccess.selectedFile", "File")}
|
||||
</div>
|
||||
<div className="quick-access-popout__row-title">
|
||||
{selectedFiles[0]?.name || t("quickAccess.noFile", "No file loaded")}
|
||||
{selectedFiles[0]?.name ||
|
||||
t("quickAccess.noFile", "No file loaded")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
<ActionIcon
|
||||
variant={isActive ? "filled" : "subtle"}
|
||||
@@ -59,7 +64,13 @@ export function ToolPanelViewerBar() {
|
||||
|
||||
if (!btn.tooltip) return buttonNode;
|
||||
return (
|
||||
<Tooltip content={btn.tooltip} position="bottom" offset={6} arrow portalTarget={document.body}>
|
||||
<Tooltip
|
||||
content={btn.tooltip}
|
||||
position="bottom"
|
||||
offset={6}
|
||||
arrow
|
||||
portalTarget={document.body}
|
||||
>
|
||||
<div style={{ display: "inline-flex" }}>{buttonNode}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -75,7 +86,10 @@ export function ToolPanelViewerBar() {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div key={btn.id} style={{ display: "inline-flex", alignItems: "center" }}>
|
||||
<div
|
||||
key={btn.id}
|
||||
style={{ display: "inline-flex", alignItems: "center" }}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -111,7 +111,12 @@ const CompareDocumentPane = ({
|
||||
<div className="compare-pane">
|
||||
<div className="compare-header">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text fw={600} size="lg" style={{ minWidth: 0, flex: 1 }} title={title || undefined}>
|
||||
<Text
|
||||
fw={600}
|
||||
size="lg"
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
title={title || undefined}
|
||||
>
|
||||
{title ? truncateCenter(title, COMPARE_PANE_TITLE_MAX_LEN) : title}
|
||||
</Text>
|
||||
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,11 @@ const FileStatusIndicator = ({
|
||||
>
|
||||
✓{" "}
|
||||
{selectedFiles.length === 1 ? (
|
||||
<PrivateContent>{t("fileSelected", "{{filename}}", { filename: selectedFiles[0]?.name })}</PrivateContent>
|
||||
<PrivateContent>
|
||||
{t("fileSelected", "{{filename}}", {
|
||||
filename: selectedFiles[0]?.name,
|
||||
})}
|
||||
</PrivateContent>
|
||||
) : (
|
||||
t("filesSelected", "{{count}} files", { count: selectedFiles.length })
|
||||
)}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
}}
|
||||
/>
|
||||
<Text size="sm" style={{ position: "relative" }}>
|
||||
{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,
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<OperationButton {...props} submitText={scopedText} disabledReason={effectiveDisabledReason} />
|
||||
<OperationButton
|
||||
{...props}
|
||||
submitText={scopedText}
|
||||
disabledReason={effectiveDisabledReason}
|
||||
/>
|
||||
)}
|
||||
{viewerFileName && (
|
||||
<Text size="xs" c="dimmed" ta="center" mx="md" mt={2}>
|
||||
|
||||
@@ -201,7 +201,9 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
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<ToolButtonProps> = ({
|
||||
return (
|
||||
<div className="tool-button-container">
|
||||
{star}
|
||||
<Tooltip content={tooltipContent} position="left" arrow={true} delay={500}>
|
||||
<Tooltip
|
||||
content={tooltipContent}
|
||||
position="left"
|
||||
arrow={true}
|
||||
delay={500}
|
||||
>
|
||||
{buttonElement}
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -94,8 +94,15 @@ const ToolSearch = ({
|
||||
ref={searchRef}
|
||||
value={value}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
|
||||
icon={iconOverride ?? (hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.25rem" height="1.25rem" />)}
|
||||
placeholder={
|
||||
placeholder || t("toolPicker.searchPlaceholder", "Search tools...")
|
||||
}
|
||||
icon={
|
||||
iconOverride ??
|
||||
(hideIcon ? undefined : (
|
||||
<LocalIcon icon="search-rounded" width="1.25rem" height="1.25rem" />
|
||||
))
|
||||
}
|
||||
iconClickable={!!iconOverride}
|
||||
autoComplete="off"
|
||||
onFocus={onFocus}
|
||||
|
||||
@@ -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 = ({
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Stack align="center" gap="md">
|
||||
<LockIcon style={{ fontSize: 48, opacity: 0.5 }} />
|
||||
<Text fw={500}>{t("encryptedPdfUnlock.viewerLocked", "This PDF is password-protected")}</Text>
|
||||
<Text fw={500}>
|
||||
{t(
|
||||
"encryptedPdfUnlock.viewerLocked",
|
||||
"This PDF is password-protected",
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={() => {
|
||||
@@ -1266,7 +1294,11 @@ const EmbedPdfViewerContent = ({
|
||||
)}
|
||||
|
||||
{/* Thumbnail Sidebar */}
|
||||
<ThumbnailSidebar visible={isThumbnailSidebarVisible} onToggle={toggleThumbnailSidebar} activeFileId={activeFileId} />
|
||||
<ThumbnailSidebar
|
||||
visible={isThumbnailSidebarVisible}
|
||||
onToggle={toggleThumbnailSidebar}
|
||||
activeFileId={activeFileId}
|
||||
/>
|
||||
<BookmarkSidebar
|
||||
visible={isBookmarkSidebarVisible}
|
||||
thumbnailVisible={isThumbnailSidebarVisible}
|
||||
|
||||
@@ -164,7 +164,8 @@ export function LocalEmbedPDF({
|
||||
|
||||
// Stable key — avoids recreating the blob URL (and crashing ViewportPlugin) when
|
||||
// FileContext produces new File object references for the same file content.
|
||||
const fileStableKey = fileId ?? (file ? `${(file as File).name}-${file.size}` : null);
|
||||
const fileStableKey =
|
||||
fileId ?? (file ? `${(file as File).name}-${file.size}` : null);
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ActionIcon, Button, Paper, Group, NumberInput, Slider } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Paper,
|
||||
Group,
|
||||
NumberInput,
|
||||
Slider,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
@@ -325,7 +332,14 @@ export function PdfViewerToolbar({
|
||||
>
|
||||
<ZoomInIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<span style={{ minWidth: "2.5rem", textAlign: "center", fontSize: 12, color: "var(--text-muted)" }}>
|
||||
<span
|
||||
style={{
|
||||
minWidth: "2.5rem",
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{displayZoomPercent}%
|
||||
</span>
|
||||
</Group>
|
||||
|
||||
@@ -9,7 +9,11 @@ interface ThumbnailSidebarProps {
|
||||
activeFileId?: string | null;
|
||||
}
|
||||
|
||||
export function ThumbnailSidebar({ visible, onToggle: _onToggle, activeFileId }: ThumbnailSidebarProps) {
|
||||
export function ThumbnailSidebar({
|
||||
visible,
|
||||
onToggle: _onToggle,
|
||||
activeFileId,
|
||||
}: ThumbnailSidebarProps) {
|
||||
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
|
||||
const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({});
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ const Viewer = (props: ViewerProps) => {
|
||||
// Determine the active file — previewFile takes priority, then look up by stable ID
|
||||
const activeFile = useMemo(() => {
|
||||
if (props.previewFile) return props.previewFile;
|
||||
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] ?? null;
|
||||
}, [props.previewFile, activeFiles, activeFileId]);
|
||||
|
||||
|
||||
@@ -201,9 +201,17 @@ export function useViewerRightRailButtons(
|
||||
},
|
||||
{
|
||||
id: "viewer-pan-mode",
|
||||
icon: <LocalIcon icon="pan-tool-rounded" width="1.25rem" height="1.25rem" />,
|
||||
tooltip: !isPanning && pendingCount > 0 && redactionActiveType !== null ? applyRedactionsLabel : panLabel,
|
||||
ariaLabel: !isPanning && pendingCount > 0 && redactionActiveType !== null ? applyRedactionsLabel : panLabel,
|
||||
icon: (
|
||||
<LocalIcon icon="pan-tool-rounded" width="1.25rem" height="1.25rem" />
|
||||
),
|
||||
tooltip:
|
||||
!isPanning && pendingCount > 0 && redactionActiveType !== null
|
||||
? applyRedactionsLabel
|
||||
: panLabel,
|
||||
ariaLabel:
|
||||
!isPanning && pendingCount > 0 && redactionActiveType !== null
|
||||
? applyRedactionsLabel
|
||||
: panLabel,
|
||||
section: "top" as const,
|
||||
order: 20,
|
||||
active: isPanning,
|
||||
@@ -248,7 +256,9 @@ export function useViewerRightRailButtons(
|
||||
},
|
||||
{
|
||||
id: "viewer-rotate-right",
|
||||
icon: <LocalIcon icon="rotate-right" width="1.25rem" height="1.25rem" />,
|
||||
icon: (
|
||||
<LocalIcon icon="rotate-right" width="1.25rem" height="1.25rem" />
|
||||
),
|
||||
tooltip: rotateRightLabel,
|
||||
ariaLabel: rotateRightLabel,
|
||||
section: "top" as const,
|
||||
@@ -271,7 +281,13 @@ export function useViewerRightRailButtons(
|
||||
},
|
||||
{
|
||||
id: "viewer-toggle-bookmarks",
|
||||
icon: <LocalIcon icon="bookmark-add-rounded" width="1.25rem" height="1.25rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="bookmark-add-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
),
|
||||
tooltip: bookmarkLabel,
|
||||
ariaLabel: bookmarkLabel,
|
||||
section: "top" as const,
|
||||
@@ -283,7 +299,13 @@ export function useViewerRightRailButtons(
|
||||
},
|
||||
{
|
||||
id: "viewer-toggle-attachments",
|
||||
icon: <LocalIcon icon="attachment-rounded" width="1.25rem" height="1.25rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="attachment-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
),
|
||||
tooltip: attachmentLabel,
|
||||
ariaLabel: attachmentLabel,
|
||||
section: "top" as const,
|
||||
@@ -351,7 +373,11 @@ export function useViewerRightRailButtons(
|
||||
variant={isReadingAloud ? "filled" : "subtle"}
|
||||
radius="md"
|
||||
className="workbench-bar-action-icon"
|
||||
disabled={disabled || typeof window === "undefined" || !window.speechSynthesis}
|
||||
disabled={
|
||||
disabled ||
|
||||
typeof window === "undefined" ||
|
||||
!window.speechSynthesis
|
||||
}
|
||||
aria-label={readAloudLabel}
|
||||
onClick={handleReadAloud}
|
||||
color={isReadingAloud ? "blue" : undefined}
|
||||
|
||||
@@ -506,7 +506,9 @@ function FileContextInner({
|
||||
}
|
||||
|
||||
if (failedIds.length > 0) {
|
||||
const failedNames = failedIds.map((id) => stateRef.current.files.byId[id]?.name ?? id);
|
||||
const failedNames = failedIds.map(
|
||||
(id) => stateRef.current.files.byId[id]?.name ?? id,
|
||||
);
|
||||
setUnlockError(
|
||||
t("encryptedPdfUnlock.unlockAllPartialFail", {
|
||||
defaultValue: "Wrong password for: {{names}}",
|
||||
|
||||
@@ -117,9 +117,15 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
activeFileIds,
|
||||
maxSelectable = null,
|
||||
}) => {
|
||||
const [activeSource, setActiveSource] = useState<"recent" | "local" | "drive">("recent");
|
||||
const [storageFilter, setStorageFilter] = useState<"all" | "local" | "sharedWithMe" | "sharedByMe">("all");
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>(() => activeFileIds);
|
||||
const [activeSource, setActiveSource] = useState<
|
||||
"recent" | "local" | "drive"
|
||||
>("recent");
|
||||
const [storageFilter, setStorageFilter] = useState<
|
||||
"all" | "local" | "sharedWithMe" | "sharedByMe"
|
||||
>("all");
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>(
|
||||
() => activeFileIds,
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||
const [expandedFileIds, setExpandedFileIds] = useState<Set<FileId>>(
|
||||
@@ -670,13 +676,23 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
|
||||
// Add newly checked files (not already active)
|
||||
const newlySelected = selectedFiles.filter((f) => !activeFileIds.includes(f.id));
|
||||
const newlySelected = selectedFiles.filter(
|
||||
(f) => !activeFileIds.includes(f.id),
|
||||
);
|
||||
if (newlySelected.length > 0) {
|
||||
onRecentFilesSelected(newlySelected);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [selectedFiles, selectedFilesSet, activeFileIds, filteredFiles, removeFiles, onRecentFilesSelected, onClose]);
|
||||
}, [
|
||||
selectedFiles,
|
||||
selectedFilesSet,
|
||||
activeFileIds,
|
||||
filteredFiles,
|
||||
removeFiles,
|
||||
onRecentFilesSelected,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchTerm(value);
|
||||
@@ -710,8 +726,12 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
setLastClickedIndex(null);
|
||||
} else {
|
||||
// Select all filtered files, capped at maxSelectable
|
||||
const allIds = filteredFiles.map((file) => file.id).filter(Boolean) as FileId[];
|
||||
setSelectedFileIds(maxSelectable != null ? allIds.slice(0, maxSelectable) : allIds);
|
||||
const allIds = filteredFiles
|
||||
.map((file) => file.id)
|
||||
.filter(Boolean) as FileId[];
|
||||
setSelectedFileIds(
|
||||
maxSelectable != null ? allIds.slice(0, maxSelectable) : allIds,
|
||||
);
|
||||
setLastClickedIndex(null);
|
||||
}
|
||||
}, [filteredFiles, selectedFileIds, maxSelectable]);
|
||||
@@ -738,7 +758,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
if (localIds.length > 0) {
|
||||
try {
|
||||
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
|
||||
const safeIds = getSafeFilesToDelete(localIds, allStoredStubs) as FileId[];
|
||||
const safeIds = getSafeFilesToDelete(
|
||||
localIds,
|
||||
allStoredStubs,
|
||||
) as FileId[];
|
||||
const safeIdSet = new Set(safeIds);
|
||||
|
||||
// Clear UI state in one pass.
|
||||
@@ -753,7 +776,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
safeIds.forEach((id) => next.delete(id));
|
||||
for (const [mainId, histFiles] of next.entries()) {
|
||||
const filtered = histFiles.filter((h) => !safeIdSet.has(h.id));
|
||||
if (filtered.length !== histFiles.length) next.set(mainId, filtered);
|
||||
if (filtered.length !== histFiles.length)
|
||||
next.set(mainId, filtered);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -777,7 +801,15 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
await refreshRecentFiles();
|
||||
}
|
||||
}
|
||||
}, [selectedFileIds, filteredFiles, performFileDelete, getSafeFilesToDelete, removeFiles, refreshRecentFiles, onBulkRemove]);
|
||||
}, [
|
||||
selectedFileIds,
|
||||
filteredFiles,
|
||||
performFileDelete,
|
||||
getSafeFilesToDelete,
|
||||
removeFiles,
|
||||
refreshRecentFiles,
|
||||
onBulkRemove,
|
||||
]);
|
||||
|
||||
const handleDownloadSelected = useCallback(async () => {
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
@@ -268,7 +268,15 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
}
|
||||
closeFilesModal();
|
||||
},
|
||||
[addFiles, closeFilesModal, insertAfterPage, customHandler, actions, fileCtx, navActions],
|
||||
[
|
||||
addFiles,
|
||||
closeFilesModal,
|
||||
insertAfterPage,
|
||||
customHandler,
|
||||
actions,
|
||||
fileCtx,
|
||||
navActions,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRecentFileSelect = useCallback(
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
* Integrates with FileContext to provide transparent file persistence
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { FileId } from "@app/types/file";
|
||||
import {
|
||||
|
||||
@@ -246,7 +246,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
// Dep on state.files.ids so the effect re-runs on every add/remove.
|
||||
useEffect(() => {
|
||||
if (!activeFileId) return;
|
||||
const stillInWorkbench = state.files.ids.some((id) => (id as string) === activeFileId);
|
||||
const stillInWorkbench = state.files.ids.some(
|
||||
(id) => (id as string) === activeFileId,
|
||||
);
|
||||
if (!stillInWorkbench) setActiveFileId(null);
|
||||
}, [activeFileId, state.files.ids]);
|
||||
|
||||
|
||||
@@ -484,7 +484,11 @@ export async function addFiles(
|
||||
// Persist the thumbnail to IndexedDB so it's available in future sessions.
|
||||
// The file was stored before hydration ran, so it had no thumbnail yet.
|
||||
// Skip blob URLs — they're session-only and won't be valid after reload.
|
||||
if (primaryThumbnail && enablePersistence && !primaryThumbnail.startsWith("blob:")) {
|
||||
if (
|
||||
primaryThumbnail &&
|
||||
enablePersistence &&
|
||||
!primaryThumbnail.startsWith("blob:")
|
||||
) {
|
||||
try {
|
||||
await fileStorage.updateThumbnail(fileId, primaryThumbnail);
|
||||
} catch {
|
||||
@@ -715,7 +719,11 @@ export async function undoConsumeFiles(
|
||||
if (indexedDB) {
|
||||
outputFileIds.forEach((fileId) => {
|
||||
indexedDB.deleteFile(fileId).catch((error) => {
|
||||
console.error("📄 undoConsumeFiles: Failed to delete output file from IDB:", fileId, error);
|
||||
console.error(
|
||||
"📄 undoConsumeFiles: Failed to delete output file from IDB:",
|
||||
fileId,
|
||||
error,
|
||||
);
|
||||
// Bump revision so the sidebar re-reads IDB and orphaned files reappear.
|
||||
indexedDB.bumpRevision?.();
|
||||
});
|
||||
@@ -726,7 +734,10 @@ export async function undoConsumeFiles(
|
||||
await Promise.all(
|
||||
inputStirlingFileStubs.map((stub) =>
|
||||
fileStorage.markFileAsLeaf(stub.id).catch((error) => {
|
||||
console.warn(`📄 undoConsumeFiles: Failed to restore isLeaf for ${stub.id}:`, error);
|
||||
console.warn(
|
||||
`📄 undoConsumeFiles: Failed to restore isLeaf for ${stub.id}:`,
|
||||
error,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -179,7 +179,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
versionStatus: "alpha",
|
||||
},
|
||||
multiTool: {
|
||||
icon: <LocalIcon icon="dashboard-customize-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="dashboard-customize-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.multiTool.title", "Multi-Tool"),
|
||||
component: null,
|
||||
workbench: "pageEditor",
|
||||
@@ -196,7 +202,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
automationSettings: null,
|
||||
},
|
||||
merge: {
|
||||
icon: <LocalIcon icon="library-add-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="library-add-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.merge.title", "Merge"),
|
||||
component: Merge,
|
||||
description: t(
|
||||
@@ -213,7 +225,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
},
|
||||
// Signing
|
||||
certSign: {
|
||||
icon: <LocalIcon icon="workspace-premium-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="workspace-premium-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.certSign.title", "Certificate Sign"),
|
||||
component: CertSign,
|
||||
description: t(
|
||||
@@ -229,7 +247,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
automationSettings: CertSignAutomationSettings,
|
||||
},
|
||||
timestampPdf: {
|
||||
icon: <LocalIcon icon="schedule-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="schedule-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.timestampPdf.title", "Timestamp PDF"),
|
||||
component: TimestampPdf,
|
||||
description: t(
|
||||
@@ -285,7 +309,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
supportsAutomate: false,
|
||||
},
|
||||
addImage: {
|
||||
icon: <LocalIcon icon="image-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="image-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.addImage.title", "Add Image"),
|
||||
component: AddImage,
|
||||
description: t("home.addImage.desc", "Add images anywhere in your PDF"),
|
||||
@@ -298,7 +328,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
supportsAutomate: false,
|
||||
},
|
||||
annotate: {
|
||||
icon: <LocalIcon icon="edit-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="edit-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.annotate.title", "Annotate"),
|
||||
component: Annotate,
|
||||
description: t(
|
||||
@@ -358,7 +394,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "watermark"),
|
||||
},
|
||||
addStamp: {
|
||||
icon: <LocalIcon icon="approval-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="approval-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.addStamp.title", "Add Stamp to PDF"),
|
||||
component: AddStamp,
|
||||
description: t(
|
||||
@@ -396,7 +438,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "sanitize"),
|
||||
},
|
||||
flatten: {
|
||||
icon: <LocalIcon icon="layers-clear-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="layers-clear-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.flatten.title", "Flatten"),
|
||||
component: Flatten,
|
||||
description: t(
|
||||
@@ -472,7 +520,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "changePermissions"),
|
||||
},
|
||||
getPdfInfo: {
|
||||
icon: <LocalIcon icon="fact-check-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="fact-check-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.getPdfInfo.title", "Get ALL Info on PDF"),
|
||||
component: GetPdfInfo,
|
||||
description: t(
|
||||
@@ -488,7 +542,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
maxFiles: 1,
|
||||
},
|
||||
validateSignature: {
|
||||
icon: <LocalIcon icon="verified-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="verified-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.validateSignature.title", "Validate PDF Signature"),
|
||||
component: ValidateSignature,
|
||||
description: t(
|
||||
@@ -506,7 +566,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
// Document Review
|
||||
|
||||
read: {
|
||||
icon: <LocalIcon icon="article-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="article-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.read.title", "Read"),
|
||||
component: null,
|
||||
workbench: "viewer",
|
||||
@@ -522,7 +588,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
automationSettings: null,
|
||||
},
|
||||
changeMetadata: {
|
||||
icon: <LocalIcon icon="assignment-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="assignment-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.changeMetadata.title", "Change Metadata"),
|
||||
component: ChangeMetadata,
|
||||
description: t(
|
||||
@@ -660,7 +732,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "addPageNumbers"),
|
||||
},
|
||||
pageLayout: {
|
||||
icon: <LocalIcon icon="dashboard-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="dashboard-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.pageLayout.title", "Multi-Page Layout"),
|
||||
component: PageLayout,
|
||||
description: t(
|
||||
@@ -675,7 +753,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "pageLayout"),
|
||||
},
|
||||
bookletImposition: {
|
||||
icon: <LocalIcon icon="menu-book-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="menu-book-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.bookletImposition.title", "Booklet Imposition"),
|
||||
component: BookletImposition,
|
||||
operationConfig: bookletImpositionOperationConfig,
|
||||
@@ -750,7 +834,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
endpoints: ["rearrange-pages"],
|
||||
},
|
||||
extractImages: {
|
||||
icon: <LocalIcon icon="photo-library-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="photo-library-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.extractImages.title", "Extract Images"),
|
||||
component: ExtractImages,
|
||||
description: t(
|
||||
@@ -791,7 +881,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
automationSettings: RemovePagesSettings,
|
||||
},
|
||||
removeBlanks: {
|
||||
icon: <LocalIcon icon="scan-delete-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="scan-delete-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.removeBlanks.title", "Remove Blank Pages"),
|
||||
component: RemoveBlanks,
|
||||
description: t(
|
||||
@@ -937,7 +1033,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
// Advanced Formatting
|
||||
|
||||
adjustContrast: {
|
||||
icon: <LocalIcon icon="palette-outline" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon icon="palette-outline" width="1.5rem" height="1.5rem" />
|
||||
),
|
||||
name: t("home.adjustContrast.title", "Adjust Colors/Contrast"),
|
||||
component: AdjustContrast,
|
||||
description: t(
|
||||
@@ -975,8 +1073,17 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
automationSettings: null,
|
||||
},
|
||||
scannerImageSplit: {
|
||||
icon: <LocalIcon icon="scanner-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.scannerImageSplit.title", "Detect & Split Scanned Photos"),
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="scanner-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t(
|
||||
"home.scannerImageSplit.title",
|
||||
"Detect & Split Scanned Photos",
|
||||
),
|
||||
component: ScannerImageSplit,
|
||||
description: t(
|
||||
"home.scannerImageSplit.desc",
|
||||
@@ -991,7 +1098,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "ScannerImageSplit"),
|
||||
},
|
||||
overlayPdfs: {
|
||||
icon: <LocalIcon icon="layers-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="layers-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.overlay-pdfs.title", "Overlay PDFs"),
|
||||
component: OverlayPdfs,
|
||||
description: t(
|
||||
@@ -1028,7 +1141,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "replaceColor"),
|
||||
},
|
||||
scannerEffect: {
|
||||
icon: <LocalIcon icon="scanner-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="scanner-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.scannerEffect.title", "Scanner Effect"),
|
||||
component: null,
|
||||
description: t(
|
||||
@@ -1252,7 +1371,13 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, "ocr"),
|
||||
},
|
||||
redact: {
|
||||
icon: <LocalIcon icon="visibility-off-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="visibility-off-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
name: t("home.redact.title", "Redact"),
|
||||
component: Redact,
|
||||
description: t(
|
||||
|
||||
@@ -15,7 +15,13 @@ export enum ToolType {
|
||||
* Reason the execute button is disabled. Resolved to a translated tooltip by OperationButton.
|
||||
* null means the button is enabled.
|
||||
*/
|
||||
export type ExecuteDisabledReason = "endpointUnavailable" | "filesLoading" | "noFiles" | "invalidParams" | "viewerMode" | null;
|
||||
export type ExecuteDisabledReason =
|
||||
| "endpointUnavailable"
|
||||
| "filesLoading"
|
||||
| "noFiles"
|
||||
| "invalidParams"
|
||||
| "viewerMode"
|
||||
| null;
|
||||
|
||||
/**
|
||||
* Result from custom processor with optional metadata about input consumption.
|
||||
|
||||
@@ -5,7 +5,9 @@ export function useRightRailTooltipSide(
|
||||
sidebarRefs?: SidebarRefs,
|
||||
defaultOffset: number = 16,
|
||||
): { position: "left" | "right" | "bottom"; offset: number } {
|
||||
const [position, setPosition] = useState<"left" | "right" | "bottom">("bottom");
|
||||
const [position, setPosition] = useState<"left" | "right" | "bottom">(
|
||||
"bottom",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const computePosition = () => {
|
||||
|
||||
@@ -376,7 +376,10 @@ export default function HomePage() {
|
||||
<Workbench />
|
||||
{!hideToolPanel && <ToolPanel />}
|
||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||
<AppConfigModal opened={configModalOpen} onClose={() => setConfigModalOpen(false)} />
|
||||
<AppConfigModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
</ActionIcon>
|
||||
<Group align="flex-start" wrap="nowrap" gap="md">
|
||||
<Box style={{ alignSelf: "center" }}>
|
||||
<DocumentThumbnail file={stub} thumbnail={stub.thumbnailUrl || null} />
|
||||
<DocumentThumbnail
|
||||
file={stub}
|
||||
thumbnail={stub.thumbnailUrl || null}
|
||||
/>
|
||||
</Box>
|
||||
<Stack style={{ minWidth: 0, overflow: "hidden", flex: 1 }}>
|
||||
<Text fw={600} title={stub.name}>
|
||||
{truncateCenter(stub.name || "", 50)}
|
||||
</Text>
|
||||
{pageCount && dateText && (
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{pageCount} {t("compare.pages", "pages")}
|
||||
<br />
|
||||
{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")}
|
||||
>
|
||||
<SwapVertRoundedIcon className="compare-step-selection__swap-icon" fontSize="inherit" />
|
||||
<span className="compare-step-selection__swap-label">{t("compare.swap.label", "Swap")}</span>
|
||||
<SwapVertRoundedIcon
|
||||
className="compare-step-selection__swap-icon"
|
||||
fontSize="inherit"
|
||||
/>
|
||||
<span className="compare-step-selection__swap-label">
|
||||
{t("compare.swap.label", "Swap")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -452,7 +542,12 @@ const Compare = (props: BaseToolProps) => {
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>{t("compare.clear.confirmBody", "This will clear the current file selections.")}</Text>
|
||||
<Text>
|
||||
{t(
|
||||
"compare.clear.confirmBody",
|
||||
"This will clear the current file selections.",
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
@@ -482,7 +577,8 @@ const Compare = (props: BaseToolProps) => {
|
||||
onClick: handleExecuteCompare,
|
||||
disabled: !canExecute,
|
||||
// Explicitly null so the noFiles gate is bypassed — Compare manages its own slot state
|
||||
disabledReason: base.endpointEnabled === false ? "endpointUnavailable" : null,
|
||||
disabledReason:
|
||||
base.endpointEnabled === false ? "endpointUnavailable" : null,
|
||||
testId: "compare-execute",
|
||||
disableScopeHints: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user