mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
UI redesign staging (#6149)
Co-authored-by: Reece Browne <[email protected]> Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
Reece Browne
James Brunton
parent
beb99e273b
commit
c731d5fd5d
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user