UI redesign staging (#6149)

Co-authored-by: Reece Browne <[email protected]>
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
EthanHealy01
2026-05-15 15:36:50 +01:00
committed by GitHub
co-authored by Reece Browne James Brunton
parent beb99e273b
commit c731d5fd5d
173 changed files with 7290 additions and 4269 deletions
@@ -1,7 +1,6 @@
import React, { useRef, useState } from "react";
import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Wordmark } from "@app/components/shared/Wordmark";
@@ -69,7 +68,7 @@ const AddFileCard = ({
/>
<div
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
className={`${styles.addFileCard} select-none flex flex-col transition-all relative cursor-pointer`}
tabIndex={0}
role="button"
aria-label={t("fileEditor.addFiles", "Add files")}
@@ -81,17 +80,6 @@ const AddFileCard = ({
}
}}
>
{/* Header bar - matches FileEditorThumbnail structure */}
<div className={`${styles.header} ${styles.addFileHeader}`}>
<div className={styles.logoMark}>
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
</div>
<div className={styles.headerIndex}>
{t("fileEditor.addFiles", "Add Files")}
</div>
<div className={styles.kebab} />
</div>
{/* Main content area */}
<div className={styles.addFileContent}>
{/* Stirling PDF Branding */}
@@ -328,42 +328,33 @@
========================= */
.addFileCard {
width: 260px;
max-width: 260px;
height: calc(310px - 0.5rem);
margin: 0.5rem auto 0;
background: var(--file-card-bg);
border: 2px dashed var(--border-default);
border-radius: 0.0625rem;
border: 1.5px solid var(--border-default);
border-radius: 12px;
box-shadow: var(--shadow-md);
cursor: pointer;
transition: all 0.18s ease;
max-width: 100%;
max-height: 100%;
transition:
box-shadow 0.18s ease,
transform 0.2s ease,
border-color 0.18s ease;
overflow: hidden;
margin-left: 0.5rem;
margin-right: 0.5rem;
opacity: 0.7;
}
.addFileCard:hover {
opacity: 1;
border-color: var(--color-blue-500);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
border-color: var(--accent-interactive);
}
.addFileCard:focus {
outline: 2px solid var(--color-blue-500);
.addFileCard:focus-visible {
outline: 2px solid var(--accent-interactive);
outline-offset: 2px;
}
.addFileHeader {
background: var(--bg-subtle);
color: var(--text-secondary);
border-bottom: 1px solid var(--border-default);
}
.addFileCard:hover .addFileHeader {
background: var(--color-blue-500);
color: white;
}
.addFileContent {
flex: 1;
display: flex;
@@ -1,13 +1,12 @@
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
import { useState, useCallback, useMemo, useEffect } from "react";
import { flushSync } from "react-dom";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { Center, Box, LoadingOverlay } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import {
useFileSelection,
useFileState,
useFileManagement,
useFileActions,
useFileContext,
} from "@app/contexts/FileContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
@@ -19,7 +18,6 @@ import FilePickerModal from "@app/components/shared/FilePickerModal";
import { FileId, StirlingFile } from "@app/types/fileContext";
import { alert } from "@app/components/toast";
import { downloadFile } from "@app/services/downloadService";
import { useFileEditorRightRailButtons } from "@app/components/fileEditor/fileEditorRightRailButtons";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
interface FileEditorProps {
@@ -46,17 +44,13 @@ const FileEditor = ({
const { state, selectors } = useFileState();
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
const { actions: fileActions } = useFileActions();
const { actions: fileContextActions } = useFileContext();
const { clearAllFileErrors } = fileContextActions;
const { selectedFileIds, setSelectedFiles } = useFileSelection();
// Extract needed values from state (memoized to prevent infinite loops)
const activeStirlingFileStubs = useMemo(
() => selectors.getStirlingFileStubs(),
[state.files.byId, state.files.ids],
);
const selectedFileIds = state.ui.selectedFileIds;
const totalItems = state.files.ids.length;
const selectedCount = selectedFileIds.length;
// Get navigation actions
const { actions: navActions } = useNavigationActions();
@@ -64,9 +58,6 @@ const FileEditor = ({
// Get viewer context for setting active file index and ID
const { setActiveFileIndex, setActiveFileId } = useViewer();
// Get file selection context
const { setSelectedFiles } = useFileSelection();
const [_status, _setStatus] = useState<string | null>(null);
const [_error, _setError] = useState<string | null>(null);
@@ -93,7 +84,6 @@ const FileEditor = ({
expandable: true,
});
}, []);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Current tool (for enforcing maxFiles limits)
const { selectedTool } = useToolWorkflow();
@@ -104,65 +94,7 @@ const FileEditor = ({
return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
}, [selectedTool?.maxFiles, toolMode]);
// Enable selection mode automatically in tool mode
useEffect(() => {
if (toolMode) {
setSelectionMode(true);
}
}, [toolMode]);
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
// Get selected file IDs from context (defensive programming)
const contextSelectedIds = Array.isArray(selectedFileIds)
? selectedFileIds
: [];
// Create refs for frequently changing values to stabilize callbacks
const contextSelectedIdsRef = useRef<FileId[]>([]);
contextSelectedIdsRef.current = contextSelectedIds;
// Use activeStirlingFileStubs directly - no conversion needed
const localSelectedIds = contextSelectedIds;
const handleSelectAllFiles = useCallback(() => {
// Respect maxAllowed: if limited, select the last N files
const allIds = state.files.ids;
const idsToSelect = Number.isFinite(maxAllowed)
? allIds.slice(-maxAllowed)
: allIds;
setSelectedFiles(idsToSelect);
try {
clearAllFileErrors();
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.warn("Failed to clear file errors on select all:", error);
}
}
}, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]);
const handleDeselectAllFiles = useCallback(() => {
setSelectedFiles([]);
try {
clearAllFileErrors();
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.warn("Failed to clear file errors on deselect:", error);
}
}
}, [setSelectedFiles, clearAllFileErrors]);
const handleCloseSelectedFiles = useCallback(() => {
if (selectedFileIds.length === 0) return;
void removeFiles(selectedFileIds, false);
setSelectedFiles([]);
}, [selectedFileIds, removeFiles, setSelectedFiles]);
useFileEditorRightRailButtons({
totalItems,
selectedCount,
onSelectAll: handleSelectAllFiles,
onDeselectAll: handleDeselectAllFiles,
onCloseSelected: handleCloseSelectedFiles,
});
// Process uploaded files using context
// ZIP extraction is now handled automatically in FileContext based on user preferences
@@ -199,56 +131,6 @@ const FileEditor = ({
[addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles],
);
const toggleFile = useCallback(
(fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeStirlingFileStubs.find((r) => r.id === fileId);
if (!targetRecord) return;
const contextFileId = fileId; // No need to create a new ID
const isSelected = currentSelectedIds.includes(contextFileId);
let newSelection: FileId[];
if (isSelected) {
// Remove file from selection
newSelection = currentSelectedIds.filter((id) => id !== contextFileId);
} else {
// Add file to selection
// Determine max files allowed from the active tool (negative or undefined means unlimited)
const rawMax = selectedTool?.maxFiles;
const maxAllowed =
!toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
if (maxAllowed === 1) {
// Only one file allowed -> replace selection with the new file
newSelection = [contextFileId];
} else {
// If at capacity, drop the oldest selected and append the new one
if (
Number.isFinite(maxAllowed) &&
currentSelectedIds.length >= maxAllowed
) {
newSelection = [...currentSelectedIds.slice(1), contextFileId];
} else {
newSelection = [...currentSelectedIds, contextFileId];
}
}
}
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
},
[
setSelectedFiles,
toolMode,
_setStatus,
activeStirlingFileStubs,
selectedTool?.maxFiles,
],
);
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
useEffect(() => {
if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) {
@@ -495,16 +377,7 @@ const FileEditor = ({
<Box p="md">
{activeStirlingFileStubs.length === 0 ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">
📁
</Text>
<Text c="dimmed">No files loaded</Text>
<Text size="sm" c="dimmed">
Upload PDF files, ZIP archives, or load from storage to get
started
</Text>
</Stack>
<AddFileCard onFileSelect={handleFileUpload} />
</Center>
) : (
<div
@@ -531,12 +404,8 @@ const FileEditor = ({
file={record}
index={index}
totalFiles={activeStirlingFileStubs.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onCloseFile={handleCloseFile}
onViewFile={handleViewFile}
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
@@ -0,0 +1,9 @@
import { StirlingFileStub } from "@app/types/fileContext";
interface FileEditorStatusDotProps {
file: StirlingFileStub;
}
export function FileEditorStatusDot(_props: FileEditorStatusDotProps) {
return null;
}
@@ -0,0 +1,266 @@
.card {
width: 260px;
max-width: 260px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 10px 10px 12px;
position: relative;
--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)
);
user-select: none;
cursor: pointer;
}
/* Inner wrapper — centers [toolChain + thumbnail + hover menu] as a unit */
.thumbInner {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
width: 100%;
max-width: 100%;
}
/* Groups toolchain bar + thumbnail so they center together inside thumbWrap */
.thumbUnit {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-height: 100%;
}
/* ToolChain bar sits directly above the thumbnail card — always rendered for consistent sizing */
.toolChainBar {
flex-shrink: 0;
width: 100%;
height: 22px;
text-align: center;
overflow: hidden;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
line-height: 22px;
margin-bottom: 4px;
}
.card:hover .toolChainBar,
.card:focus-within .toolChainBar {
opacity: 1;
}
.thumbWrap {
width: 100%;
position: relative;
height: 310px;
display: flex;
align-items: center;
justify-content: center;
}
.thumbContainer {
aspect-ratio: var(--thumb-aspect, 8.5 / 11);
width: 100%;
max-width: 100%;
max-height: 100%;
border-radius: 12px;
box-shadow: var(--shadow-md);
overflow: hidden;
position: relative;
background: var(--bg-surface);
transition:
box-shadow 0.15s ease,
transform 0.2s ease;
}
.thumbContainer:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
}
.thumbImage {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 12px;
background: var(--bg-surface);
display: block;
position: relative;
z-index: 1;
}
/* Text block pinned below thumbnail */
.fileText {
position: absolute;
left: 10px;
right: 10px;
bottom: var(--file-text-bottom);
height: var(--file-text-height);
text-align: center;
pointer-events: none;
--file-name-line-height: 18px;
--file-name-lines: 2;
--file-meta-line-height: 14px;
--file-meta-gap: 4px;
}
.fileName {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: var(--file-name-line-height);
height: calc(var(--file-name-lines) * var(--file-name-line-height));
margin: 0;
position: absolute;
left: 0;
right: 0;
bottom: calc(var(--file-meta-line-height) + var(--file-meta-gap));
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: break-word;
word-break: normal;
}
.fileMeta {
font-size: 11px;
color: var(--text-secondary);
line-height: var(--file-meta-line-height);
height: var(--file-meta-line-height);
overflow: hidden;
margin: 0;
position: absolute;
left: 0;
right: 0;
bottom: 0;
white-space: nowrap;
text-overflow: ellipsis;
}
/* CSS-hover driven menu visibility */
.card [data-hover-action-menu-mode="cssHover"][data-force-visible="false"] {
opacity: 0;
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"] {
opacity: 1;
pointer-events: auto;
}
/* Error overlay */
.errorOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(220, 38, 38, 0.12);
border-radius: 12px;
z-index: 2;
pointer-events: none;
}
.errorPill {
background: var(--mantine-color-red-6);
color: white;
border-radius: 12px;
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
}
/* Badges row pinned to top-left of thumbnail - always visible */
.thumbBadges {
position: absolute;
top: 6px;
left: 6px;
display: flex;
gap: 4px;
align-items: center;
z-index: 3;
pointer-events: none;
}
.thumbBadgesRight {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 4px;
align-items: center;
z-index: 3;
pointer-events: auto;
}
.statusDot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.15);
}
.ownershipBadge {
background: var(--mantine-color-blue-6);
color: white;
border-radius: 6px;
padding: 2px 7px;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.versionBadgeThumb {
background: var(--mantine-color-blue-6);
border-radius: 4px;
padding: 1px 5px;
font-size: 10px;
font-weight: 700;
color: white;
line-height: 16px;
white-space: nowrap;
}
.pinnedBadge {
background: var(--mantine-color-yellow-6);
color: white;
border-radius: 4px;
padding: 2px 4px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.dragHandle {
position: absolute;
bottom: 6px;
right: 6px;
cursor: grab;
color: var(--text-secondary);
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
}
.card:hover .dragHandle,
.card:focus-within .dragHandle {
opacity: 0.5;
pointer-events: auto;
}
@@ -1,14 +1,12 @@
import React, { useState, useCallback, useRef, useMemo } from "react";
import {
Text,
ActionIcon,
CheckboxIndicator,
Tooltip,
Modal,
Button,
Group,
Stack,
Loader,
ActionIcon,
Tooltip,
} from "@mantine/core";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { alert } from "@app/components/toast";
@@ -21,7 +19,6 @@ import UnarchiveIcon from "@mui/icons-material/Unarchive";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import LinkIcon from "@mui/icons-material/Link";
import PushPinIcon from "@mui/icons-material/PushPin";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
@@ -31,11 +28,10 @@ import {
import { StirlingFileStub } from "@app/types/fileContext";
import { zipFileService } from "@app/services/zipFileService";
import styles from "@app/components/fileEditor/FileEditor.module.css";
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
import { useFileContext } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/file/fileHooks";
import { FileId } from "@app/types/file";
import { formatFileSize } from "@app/utils/fileUtils";
import ToolChain from "@app/components/shared/ToolChain";
import HoverActionMenu, {
HoverAction,
@@ -45,18 +41,17 @@ import { PrivateContent } from "@app/components/shared/PrivateContent";
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
import ShareFileModal from "@app/components/shared/ShareFileModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import FileEditorFileName from "@app/components/fileEditor/FileEditorFileName";
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
import { truncateCenter } from "@app/utils/textUtils";
import { FileEditorStatusDot } from "@app/components/fileEditor/FileEditorStatusDot";
interface FileEditorThumbnailProps {
file: StirlingFileStub;
index: number;
totalFiles: number;
selectedFiles: FileId[];
selectionMode: boolean;
onToggleFile: (fileId: FileId) => void;
onCloseFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void;
_onSetStatus: (status: string) => void;
onReorderFiles?: (
sourceFileId: FileId,
targetFileId: FileId,
@@ -70,12 +65,8 @@ interface FileEditorThumbnailProps {
const FileEditorThumbnail = ({
file,
index,
selectedFiles,
onToggleFile,
onCloseFile,
onViewFile,
_onSetStatus,
onReorderFiles,
onDownloadFile,
onUnzipFile,
@@ -95,41 +86,41 @@ const FileEditorThumbnail = ({
openEncryptedUnlockPrompt,
} = useFileContext();
const { state, selectors } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const [showHoverMenu, setShowHoverMenu] = useState(false);
const isMobile = useIsMobile();
const [showCloseModal, setShowCloseModal] = useState(false);
const [showUploadModal, setShowUploadModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
const sharedEditNoticeShownRef = useRef(false);
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
return 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;
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
const hasError = state.ui.errorFileIds.includes(file.id);
const pageCount = file.processedFile?.totalPages || 0;
const isEncrypted = Boolean(file.processedFile?.isEncrypted);
const {
isEncrypted,
thumbnail: displayThumbnail,
isGenerating: isThumbGenerating,
} = useFileThumbnail(file);
// Aspect ratio from page dimensions, falling back to letter size
const firstPage = file.processedFile?.pages?.[0];
const firstPageRotation = firstPage?.rotation ?? 0;
const isLandscape = firstPageRotation === 90 || firstPageRotation === 270;
const thumbAspect = (() => {
const w = firstPage?.width;
const h = firstPage?.height;
if (w && h && w > 0 && h > 0) {
// width/height are effective (post-rotation) dims from PDFium, so use
// them directly — no swapping needed.
return `${w} / ${h}`;
}
return isLandscape ? "11 / 8.5" : "8.5 / 11";
})();
const handleRef = useRef<HTMLSpanElement | null>(null);
// ---- Selection ----
const isSelected = selectedFiles.includes(file.id);
// ---- Meta formatting ----
const prettySize = useMemo(() => {
return formatFileSize(file.size);
}, [file.size]);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const extUpper = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
@@ -143,6 +134,7 @@ const FileEditorThumbnail = ({
const isCBZ = extLower === "cbz";
const isCBR = extLower === "cbr";
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled =
uploadEnabled && config?.storageSharingEnabled === true;
@@ -178,7 +170,13 @@ const FileEditorThumbnail = ({
}).format(d);
}, [file.lastModified]);
// ---- Drag & drop wiring ----
const [isDragging, setIsDragging] = useState(false);
const [showCloseModal, setShowCloseModal] = useState(false);
const [showUploadModal, setShowUploadModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
const sharedEditNoticeShownRef = useRef(false);
const fileElementRef = useCallback(
(element: HTMLDivElement | null) => {
if (!element) return;
@@ -191,14 +189,10 @@ const FileEditorThumbnail = ({
type: "file",
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id], // Always drag only this file, ignore selection state
selectedFiles: [file.id],
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
});
const dropCleanup = dropTargetForElements({
@@ -211,10 +205,7 @@ const FileEditorThumbnail = ({
const sourceData = source.data;
return sourceData.type === "file" && sourceData.fileId !== file.id;
},
onDragEnter: () => setIsDragOver(true),
onDragLeave: () => setIsDragOver(false),
onDrop: ({ source }) => {
setIsDragOver(false);
const sourceData = source.data;
if (sourceData.type === "file" && onReorderFiles) {
const sourceFileId = sourceData.fileId as FileId;
@@ -229,13 +220,14 @@ const FileEditorThumbnail = ({
dropCleanup();
};
},
[file.id, file.name, selectedFiles, onReorderFiles],
[file.id, file.name, onReorderFiles],
);
// Handle close with confirmation
const handleCloseWithConfirmation = useCallback(() => {
setShowCloseModal(true);
}, []);
const handleCloseWithConfirmation = useCallback(
() => setShowCloseModal(true),
[],
);
const handleCancelClose = useCallback(() => setShowCloseModal(false), []);
const handleConfirmClose = useCallback(() => {
onCloseFile(file.id);
@@ -278,7 +270,6 @@ const FileEditorThumbnail = ({
return;
}
}
// Then close
onCloseFile(file.id);
alert({
alertType: "success",
@@ -296,11 +287,6 @@ const FileEditorThumbnail = ({
fileActions,
]);
const handleCancelClose = useCallback(() => {
setShowCloseModal(false);
}, []);
// Build hover menu actions
const hoverActions = useMemo<HoverAction[]>(
() => [
{
@@ -312,6 +298,36 @@ const FileEditorThumbnail = ({
onViewFile(file.id);
},
},
{
id: "pin",
icon: <PushPinIcon style={{ fontSize: 20 }} />,
label: isPinned
? t("unpin", "Unpin File (replace after tool run)")
: t("pin", "Pin File (keep active after tool run)"),
dataTour: "file-card-pin",
onClick: (e) => {
e.stopPropagation();
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
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,
});
}
}
},
},
{
id: "download",
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
@@ -321,36 +337,32 @@ const FileEditorThumbnail = ({
onDownloadFile(file.id);
},
},
...(canUpload || canShare
...(canUpload
? [
...(canUpload
? [
{
id: "upload",
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
label: isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowUploadModal(true);
},
},
]
: []),
...(canShare
? [
{
id: "share",
icon: <LinkIcon style={{ fontSize: 20 }} />,
label: t("fileManager.share", "Share"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowShareModal(true);
},
},
]
: []),
{
id: "upload",
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
label: isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowUploadModal(true);
},
},
]
: []),
...(canShare
? [
{
id: "share",
icon: <LinkIcon style={{ fontSize: 20 }} />,
label: t("fileManager.share", "Share"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowShareModal(true);
},
},
]
: []),
{
@@ -389,7 +401,10 @@ const FileEditorThumbnail = ({
isZipFile,
isCBZ,
isCBR,
isPinned,
actualFile,
terminology,
DownloadOutlinedIcon,
onViewFile,
onDownloadFile,
onUnzipFile,
@@ -397,13 +412,13 @@ const FileEditorThumbnail = ({
canUpload,
canShare,
isUploaded,
pinFile,
unpinFile,
],
);
// ---- Card interactions ----
const handleCardClick = () => {
if (!isSupported) return;
// Clear error state if file has an error (click to clear error)
if (hasError) {
try {
fileActions.clearFileError(file.id);
@@ -415,7 +430,6 @@ const FileEditorThumbnail = ({
sharedEditNoticeShownRef.current = true;
setShowSharedEditNotice(true);
}
onToggleFile(file.id);
};
const handleCardDoubleClick = () => {
@@ -423,12 +437,9 @@ const FileEditorThumbnail = ({
onViewFile(file.id);
};
// ---- Style helpers ----
const getHeaderClassName = () => {
if (hasError) return styles.headerError;
if (!isSupported) return styles.headerUnsupported;
return isSelected ? styles.headerSelected : styles.headerResting;
};
const metaLine = [dateLabel, extUpper ? `${extUpper} file` : "", pageLabel]
.filter(Boolean)
.join(" - ");
return (
<div
@@ -436,268 +447,130 @@ const FileEditorThumbnail = ({
data-file-id={file.id}
data-testid="file-thumbnail"
data-tour="file-card-checkbox"
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
style={
{
opacity: isDragging ? 0.4 : 1,
outline: isDragOver
? "3px dashed var(--mantine-color-blue-5, #3b82f6)"
: undefined,
outlineOffset: isDragOver ? "2px" : undefined,
transform: isDragOver ? "scale(1.02)" : undefined,
transition:
"outline 120ms ease, transform 120ms ease, opacity 120ms ease",
// Tag each card with a stable, unique view-transition-name so the
// browser can animate the reorder (see FileEditor.handleReorderFiles,
// which dispatches reorderFiles inside document.startViewTransition).
viewTransitionName: `file-card-${file.id}`,
} as React.CSSProperties
}
className={`${styles.card} select-none`}
style={{ opacity: isDragging ? 0.9 : 1 }}
tabIndex={0}
role="listitem"
aria-selected={isSelected}
onClick={handleCardClick}
onMouseEnter={() => setShowHoverMenu(true)}
onMouseLeave={() => setShowHoverMenu(false)}
onDoubleClick={handleCardDoubleClick}
>
{/* Header bar */}
<div
className={`${styles.header} ${getHeaderClassName()}`}
data-has-error={hasError}
>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{hasError ? (
<div className={styles.errorPill}>
<span>{t("error._value", "Error")}</span>
</div>
) : isSupported ? (
<CheckboxIndicator
checked={isSelected}
onChange={() => onToggleFile(file.id)}
color="var(--checkbox-checked-bg)"
/>
) : (
<div className={styles.unsupportedPill}>
<span>{t("unsupported", "Unsupported")}</span>
</div>
)}
</div>
{/* Centered index */}
<div
className={styles.headerIndex}
aria-label={`Position ${index + 1}`}
>
{index + 1}
</div>
{/* Action buttons group */}
<div className={styles.headerActions}>
{isEncrypted && (
<Tooltip
label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
<div className={styles.thumbInner}>
{/* Thumbnail area */}
<div className={styles.thumbWrap}>
{/* thumbUnit groups toolchain + card so they center together, keeping text tight above the card */}
<div className={styles.thumbUnit}>
{/* Tool chain bar — always rendered for consistent height, content only when history exists */}
<div className={styles.toolChainBar}>
{file.toolHistory && file.toolHistory.length > 0 && (
<ToolChain
toolChain={file.toolHistory}
displayStyle="text"
size="xs"
maxWidth="100%"
color="var(--mantine-color-gray-7)"
/>
)}
</div>
<div
className={styles.thumbContainer}
data-supported={isSupported}
style={{ "--thumb-aspect": thumbAspect } as React.CSSProperties}
>
<ActionIcon
aria-label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
)}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
openEncryptedUnlockPrompt(file.id);
}}
>
<LockOpenIcon fontSize="small" />
</ActionIcon>
</Tooltip>
)}
{/* Pin/Unpin icon */}
<Tooltip
label={
isPinned
? t("unpin", "Unpin File (replace after tool run)")
: t("pin", "Pin File (keep active after tool run)")
}
>
<ActionIcon
aria-label={
isPinned
? t("unpin", "Unpin File (replace after tool run)")
: t("pin", "Pin File (keep active after tool run)")
}
variant="subtle"
className={isPinned ? styles.pinned : styles.headerIconButton}
data-tour="file-card-pin"
onClick={(e) => {
e.stopPropagation();
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
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,
});
}
{/* Error overlay */}
{hasError && (
<div className={styles.errorOverlay}>
<span className={styles.errorPill}>
{t("error._value", "Error")}
</span>
</div>
)}
{/* Thumbnail image or loading state */}
<DocumentThumbnail
file={file}
thumbnail={displayThumbnail || undefined}
isEncrypted={isEncrypted}
isLoading={
!isEncrypted &&
!displayThumbnail &&
(isThumbGenerating ||
file.type?.startsWith("application/pdf") ||
file.type?.startsWith("image/"))
}
}}
>
{isPinned ? (
<PushPinIcon fontSize="small" />
) : (
<PushPinOutlinedIcon fontSize="small" />
)}
</ActionIcon>
</Tooltip>
</div>
</div>
{/* Title + meta line */}
<div
style={{
padding: "0.5rem",
textAlign: "center",
background: "var(--file-card-bg)",
marginTop: "0.5rem",
marginBottom: "0.5rem",
}}
>
<Text
size="lg"
fw={700}
className={styles.title}
title={file.name}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.25rem",
}}
>
<FileEditorFileName file={file} />
</Text>
<Text
size="sm"
c="dimmed"
className={styles.meta}
lineClamp={3}
title={`${extUpper || "FILE"}${prettySize}`}
>
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
{`v${file.versionNumber} - `}
{dateLabel}
{extUpper ? ` - ${extUpper} file` : ""}
{pageLabel ? ` - ${pageLabel}` : ""}
</Text>
</div>
{/* Preview area */}
<div
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
style={
isSupported || hasError
? undefined
: { filter: "grayscale(80%)", opacity: 0.6 }
}
>
<div className={styles.previewPaper}>
{file.thumbnailUrl ? (
<PrivateContent>
<img
src={file.thumbnailUrl}
alt={file.name}
draggable={false}
loading="lazy"
decoding="async"
onError={(e) => {
const img = e.currentTarget;
img.style.display = "none";
img.parentElement?.setAttribute("data-thumb-missing", "true");
}}
style={{
maxWidth: "80%",
maxHeight: "80%",
objectFit: "contain",
borderRadius: 0,
background: "#ffffff",
border: "1px solid var(--border-default)",
display: "block",
marginLeft: "auto",
marginRight: "auto",
alignSelf: "start",
iconSize="6rem"
imgClassName={styles.thumbImage}
onImageError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
</PrivateContent>
) : file.type?.startsWith("application/pdf") ? (
<Stack
align="center"
justify="center"
gap="xs"
style={{ height: "100%" }}
>
<Loader size="sm" />
<Text size="xs" c="dimmed">
Loading thumbnail...
</Text>
</Stack>
) : null}
{/* Badges — top-left: version, pin, ownership, encrypted */}
<div className={styles.thumbBadges}>
<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>
)}
{isEncrypted && (
<Tooltip
label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
)}
>
<ActionIcon
size="xs"
variant="filled"
color="yellow"
onClick={(e) => {
e.stopPropagation();
openEncryptedUnlockPrompt(file.id);
}}
style={{ pointerEvents: "auto" }}
>
<LockOpenIcon style={{ fontSize: 12 }} />
</ActionIcon>
</Tooltip>
)}
</div>
<FileEditorStatusDot file={file} />
</div>
</div>
{/* end thumbUnit */}
{/* Drag handle */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
</span>
</div>
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
</span>
{/* Tool chain display at bottom */}
{file.toolHistory && (
<div
style={{
position: "absolute",
bottom: "4px",
left: "4px",
right: "4px",
padding: "4px 6px",
textAlign: "center",
fontWeight: 600,
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
<ToolChain
toolChain={file.toolHistory}
displayStyle="text"
size="xs"
maxWidth={"100%"}
color="var(--mantine-color-gray-7)"
/>
</div>
)}
{/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */}
<HoverActionMenu
show={isMobile}
actions={hoverActions}
position="outside"
visibility="cssHover"
/>
</div>
{/* Hover Menu */}
<HoverActionMenu
show={showHoverMenu || isMobile}
actions={hoverActions}
position="outside"
/>
{/* File name + meta */}
<div className={styles.fileText}>
<p className={styles.fileName}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
</p>
<p className={styles.fileMeta}>{metaLine}</p>
</div>
{/* Close Confirmation Modal */}
<Modal
@@ -714,7 +587,7 @@ const FileEditorThumbnail = ({
{t("confirmCloseUnsaved", "This file has unsaved changes.")}
</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
<PrivateContent>{file.name}</PrivateContent>
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
@@ -741,7 +614,7 @@ const FileEditorThumbnail = ({
)}
</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
<PrivateContent>{file.name}</PrivateContent>
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
@@ -759,6 +632,8 @@ const FileEditorThumbnail = ({
)}
</Stack>
</Modal>
{/* Shared edit notice modal */}
<Modal
opened={showSharedEditNotice}
onClose={() => setShowSharedEditNotice(false)}
@@ -1,89 +0,0 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
useRightRailButtons,
RightRailButtonWithAction,
} from "@app/hooks/useRightRailButtons";
import LocalIcon from "@app/components/shared/LocalIcon";
interface FileEditorRightRailButtonsParams {
totalItems: number;
selectedCount: number;
onSelectAll: () => void;
onDeselectAll: () => void;
onCloseSelected: () => void;
}
export function useFileEditorRightRailButtons({
totalItems,
selectedCount,
onSelectAll,
onDeselectAll,
onCloseSelected,
}: FileEditorRightRailButtonsParams) {
const { t, i18n } = useTranslation();
const buttons = useMemo<RightRailButtonWithAction[]>(
() => [
{
id: "file-select-all",
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
tooltip: t("rightRail.selectAll", "Select All"),
ariaLabel:
typeof t === "function"
? t("rightRail.selectAll", "Select All")
: "Select All",
section: "top" as const,
order: 10,
disabled: totalItems === 0 || selectedCount === totalItems,
visible: totalItems > 0,
onClick: onSelectAll,
},
{
id: "file-deselect-all",
icon: (
<LocalIcon
icon="crop-square-outline"
width="1.5rem"
height="1.5rem"
/>
),
tooltip: t("rightRail.deselectAll", "Deselect All"),
ariaLabel:
typeof t === "function"
? t("rightRail.deselectAll", "Deselect All")
: "Deselect All",
section: "top" as const,
order: 20,
disabled: selectedCount === 0,
visible: totalItems > 0,
onClick: onDeselectAll,
},
{
id: "file-close-selected",
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
tooltip: t("rightRail.closeSelected", "Close Selected Files"),
ariaLabel:
typeof t === "function"
? t("rightRail.closeSelected", "Close Selected Files")
: "Close Selected Files",
section: "top" as const,
order: 30,
disabled: selectedCount === 0,
visible: totalItems > 0,
onClick: onCloseSelected,
},
],
[
t,
i18n.language,
totalItems,
selectedCount,
onSelectAll,
onDeselectAll,
onCloseSelected,
],
);
useRightRailButtons(buttons);
}