2025-08-21 17:30:26 +01:00
|
|
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
|
|
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
|
2025-08-01 16:08:04 +01:00
|
|
|
import { useTranslation } from 'react-i18next';
|
2025-07-16 17:53:50 +01:00
|
|
|
import CloseIcon from '@mui/icons-material/Close';
|
2025-08-15 14:43:30 +01:00
|
|
|
import PushPinIcon from '@mui/icons-material/PushPin';
|
|
|
|
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
2025-06-20 17:51:24 +01:00
|
|
|
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
2025-08-21 17:30:26 +01:00
|
|
|
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
2025-06-20 17:51:24 +01:00
|
|
|
import styles from './PageEditor.module.css';
|
2025-08-15 14:43:30 +01:00
|
|
|
import { useFileContext } from '../../contexts/FileContext';
|
2025-06-20 17:51:24 +01:00
|
|
|
|
|
|
|
interface FileItem {
|
|
|
|
id: string;
|
|
|
|
name: string;
|
|
|
|
pageCount: number;
|
|
|
|
thumbnail: string;
|
|
|
|
size: number;
|
|
|
|
splitBefore?: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface FileThumbnailProps {
|
|
|
|
file: FileItem;
|
|
|
|
index: number;
|
|
|
|
totalFiles: number;
|
|
|
|
selectedFiles: string[];
|
|
|
|
selectionMode: boolean;
|
|
|
|
onToggleFile: (fileId: string) => void;
|
|
|
|
onDeleteFile: (fileId: string) => void;
|
|
|
|
onViewFile: (fileId: string) => void;
|
|
|
|
onSetStatus: (status: string) => void;
|
2025-08-21 17:30:26 +01:00
|
|
|
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void;
|
2025-07-16 17:53:50 +01:00
|
|
|
toolMode?: boolean;
|
2025-08-01 16:08:04 +01:00
|
|
|
isSupported?: boolean;
|
2025-06-20 17:51:24 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
const FileThumbnail = ({
|
|
|
|
file,
|
|
|
|
index,
|
|
|
|
totalFiles,
|
|
|
|
selectedFiles,
|
|
|
|
selectionMode,
|
|
|
|
onToggleFile,
|
|
|
|
onDeleteFile,
|
|
|
|
onViewFile,
|
|
|
|
onSetStatus,
|
2025-08-21 17:30:26 +01:00
|
|
|
onReorderFiles,
|
2025-07-16 17:53:50 +01:00
|
|
|
toolMode = false,
|
2025-08-01 16:08:04 +01:00
|
|
|
isSupported = true,
|
2025-06-20 17:51:24 +01:00
|
|
|
}: FileThumbnailProps) => {
|
2025-08-01 16:08:04 +01:00
|
|
|
const { t } = useTranslation();
|
2025-08-15 14:43:30 +01:00
|
|
|
const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
2025-08-21 17:30:26 +01:00
|
|
|
|
|
|
|
// Drag and drop state
|
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
2025-08-15 14:43:30 +01:00
|
|
|
|
|
|
|
// Find the actual File object that corresponds to this FileItem
|
|
|
|
const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size);
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-06-20 17:51:24 +01:00
|
|
|
const formatFileSize = (bytes: number) => {
|
|
|
|
if (bytes === 0) return '0 B';
|
|
|
|
const k = 1024;
|
|
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
|
|
};
|
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
// Setup drag and drop using @atlaskit/pragmatic-drag-and-drop
|
|
|
|
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
|
|
|
if (!element) return;
|
|
|
|
|
|
|
|
dragElementRef.current = element;
|
|
|
|
|
|
|
|
const dragCleanup = draggable({
|
|
|
|
element,
|
|
|
|
getInitialData: () => ({
|
|
|
|
type: 'file',
|
|
|
|
fileId: file.id,
|
|
|
|
fileName: file.name,
|
|
|
|
selectedFiles: [file.id] // Always drag only this file, ignore selection state
|
|
|
|
}),
|
|
|
|
onDragStart: () => {
|
|
|
|
setIsDragging(true);
|
|
|
|
},
|
|
|
|
onDrop: () => {
|
|
|
|
setIsDragging(false);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
const dropCleanup = dropTargetForElements({
|
|
|
|
element,
|
|
|
|
getData: () => ({
|
|
|
|
type: 'file',
|
|
|
|
fileId: file.id
|
|
|
|
}),
|
|
|
|
canDrop: ({ source }) => {
|
|
|
|
const sourceData = source.data;
|
|
|
|
return sourceData.type === 'file' && sourceData.fileId !== file.id;
|
|
|
|
},
|
|
|
|
onDrop: ({ source }) => {
|
|
|
|
const sourceData = source.data;
|
|
|
|
if (sourceData.type === 'file' && onReorderFiles) {
|
|
|
|
const sourceFileId = sourceData.fileId as string;
|
|
|
|
const selectedFileIds = sourceData.selectedFiles as string[];
|
|
|
|
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
dragCleanup();
|
|
|
|
dropCleanup();
|
|
|
|
};
|
|
|
|
}, [file.id, file.name, selectionMode, selectedFiles, onReorderFiles]);
|
|
|
|
|
2025-06-20 17:51:24 +01:00
|
|
|
return (
|
|
|
|
<div
|
2025-08-21 17:30:26 +01:00
|
|
|
ref={fileElementRef}
|
2025-06-20 17:51:24 +01:00
|
|
|
data-file-id={file.id}
|
2025-08-01 16:08:04 +01:00
|
|
|
data-testid="file-thumbnail"
|
2025-06-20 17:51:24 +01:00
|
|
|
className={`
|
|
|
|
${styles.pageContainer}
|
|
|
|
!rounded-lg
|
|
|
|
cursor-grab
|
|
|
|
select-none
|
|
|
|
w-[20rem]
|
|
|
|
h-[24rem]
|
|
|
|
flex flex-col items-center justify-center
|
|
|
|
flex-shrink-0
|
|
|
|
shadow-sm
|
|
|
|
hover:shadow-md
|
|
|
|
transition-all
|
|
|
|
relative
|
|
|
|
${selectionMode
|
|
|
|
? 'bg-white hover:bg-gray-50'
|
|
|
|
: 'bg-white hover:bg-gray-50'}
|
2025-08-21 17:30:26 +01:00
|
|
|
${isDragging ? 'opacity-50 scale-95' : ''}
|
2025-06-20 17:51:24 +01:00
|
|
|
`}
|
|
|
|
style={{
|
2025-08-21 17:30:26 +01:00
|
|
|
opacity: isSupported ? (isDragging ? 0.5 : 1) : 0.5,
|
2025-08-01 16:08:04 +01:00
|
|
|
filter: isSupported ? 'none' : 'grayscale(50%)'
|
2025-06-20 17:51:24 +01:00
|
|
|
}}
|
|
|
|
>
|
|
|
|
{selectionMode && (
|
|
|
|
<div
|
|
|
|
className={styles.checkboxContainer}
|
2025-08-01 16:08:04 +01:00
|
|
|
data-testid="file-thumbnail-checkbox"
|
2025-06-20 17:51:24 +01:00
|
|
|
style={{
|
|
|
|
position: 'absolute',
|
|
|
|
top: 8,
|
|
|
|
right: 8,
|
|
|
|
zIndex: 4,
|
|
|
|
backgroundColor: 'white',
|
|
|
|
borderRadius: '4px',
|
|
|
|
padding: '2px',
|
|
|
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
|
|
|
pointerEvents: 'auto'
|
|
|
|
}}
|
|
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
|
|
onDragStart={(e) => {
|
|
|
|
e.preventDefault();
|
|
|
|
e.stopPropagation();
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<Checkbox
|
|
|
|
checked={selectedFiles.includes(file.id)}
|
|
|
|
onChange={(event) => {
|
|
|
|
event.stopPropagation();
|
2025-08-01 16:08:04 +01:00
|
|
|
if (isSupported) {
|
|
|
|
onToggleFile(file.id);
|
|
|
|
}
|
2025-06-20 17:51:24 +01:00
|
|
|
}}
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
2025-08-01 16:08:04 +01:00
|
|
|
disabled={!isSupported}
|
2025-06-20 17:51:24 +01:00
|
|
|
size="sm"
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
|
|
|
|
{/* File content area */}
|
|
|
|
<div className="file-container w-[90%] h-[80%] relative">
|
|
|
|
{/* Stacked file effect - multiple shadows to simulate pages */}
|
|
|
|
<div
|
|
|
|
style={{
|
|
|
|
width: '100%',
|
|
|
|
height: '100%',
|
|
|
|
backgroundColor: 'var(--mantine-color-gray-1)',
|
|
|
|
borderRadius: 6,
|
|
|
|
border: '1px solid var(--mantine-color-gray-3)',
|
|
|
|
padding: 4,
|
|
|
|
display: 'flex',
|
|
|
|
alignItems: 'center',
|
|
|
|
justifyContent: 'center',
|
|
|
|
position: 'relative',
|
|
|
|
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<img
|
|
|
|
src={file.thumbnail}
|
|
|
|
alt={file.name}
|
2025-08-21 17:30:26 +01:00
|
|
|
draggable={false}
|
|
|
|
onError={(e) => {
|
|
|
|
// Hide broken image if blob URL was revoked
|
|
|
|
const img = e.target as HTMLImageElement;
|
|
|
|
img.style.display = 'none';
|
|
|
|
}}
|
2025-06-20 17:51:24 +01:00
|
|
|
style={{
|
|
|
|
maxWidth: '100%',
|
|
|
|
maxHeight: '100%',
|
|
|
|
objectFit: 'contain',
|
|
|
|
borderRadius: 2,
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
|
2025-08-21 17:30:26 +01:00
|
|
|
{/* Page count badge - only show for PDFs */}
|
|
|
|
{file.pageCount > 0 && (
|
|
|
|
<Badge
|
|
|
|
size="sm"
|
|
|
|
variant="filled"
|
|
|
|
color="blue"
|
|
|
|
style={{
|
|
|
|
position: 'absolute',
|
|
|
|
top: 8,
|
|
|
|
left: 8,
|
|
|
|
zIndex: 3,
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'}
|
|
|
|
</Badge>
|
|
|
|
)}
|
2025-06-20 17:51:24 +01:00
|
|
|
|
2025-08-01 16:08:04 +01:00
|
|
|
{/* Unsupported badge */}
|
|
|
|
{!isSupported && (
|
|
|
|
<Badge
|
|
|
|
size="sm"
|
|
|
|
variant="filled"
|
|
|
|
color="orange"
|
|
|
|
style={{
|
|
|
|
position: 'absolute',
|
|
|
|
top: 8,
|
|
|
|
right: selectionMode ? 48 : 8, // Avoid overlap with checkbox
|
|
|
|
zIndex: 3,
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{t("fileManager.unsupported", "Unsupported")}
|
|
|
|
</Badge>
|
|
|
|
)}
|
|
|
|
|
2025-06-20 17:51:24 +01:00
|
|
|
{/* File name overlay */}
|
|
|
|
<Text
|
|
|
|
className={styles.pageNumber}
|
|
|
|
size="xs"
|
|
|
|
fw={500}
|
|
|
|
c="white"
|
|
|
|
style={{
|
|
|
|
position: 'absolute',
|
|
|
|
bottom: 5,
|
|
|
|
left: 5,
|
|
|
|
right: 5,
|
|
|
|
background: 'rgba(0, 0, 0, 0.8)',
|
|
|
|
padding: '4px 6px',
|
|
|
|
borderRadius: 4,
|
|
|
|
zIndex: 2,
|
|
|
|
opacity: 0,
|
|
|
|
transition: 'opacity 0.2s ease-in-out',
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
overflow: 'hidden',
|
|
|
|
whiteSpace: 'nowrap'
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{file.name}
|
|
|
|
</Text>
|
|
|
|
|
|
|
|
{/* Hover controls */}
|
|
|
|
<div
|
|
|
|
className={styles.pageHoverControls}
|
|
|
|
style={{
|
|
|
|
position: 'absolute',
|
|
|
|
top: '50%',
|
|
|
|
left: '50%',
|
|
|
|
transform: 'translate(-50%, -50%)',
|
|
|
|
background: 'rgba(0, 0, 0, 0.8)',
|
|
|
|
padding: '8px 12px',
|
|
|
|
borderRadius: 20,
|
|
|
|
opacity: 0,
|
|
|
|
transition: 'opacity 0.2s ease-in-out',
|
|
|
|
zIndex: 3,
|
|
|
|
display: 'flex',
|
|
|
|
gap: '8px',
|
|
|
|
alignItems: 'center',
|
|
|
|
whiteSpace: 'nowrap'
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
|
2025-08-15 14:43:30 +01:00
|
|
|
{actualFile && (
|
|
|
|
<Tooltip label={isFilePinned(actualFile) ? "Unpin File" : "Pin File"}>
|
|
|
|
<ActionIcon
|
|
|
|
size="md"
|
|
|
|
variant="subtle"
|
|
|
|
c={isFilePinned(actualFile) ? "yellow" : "white"}
|
|
|
|
onClick={(e) => {
|
|
|
|
e.stopPropagation();
|
|
|
|
if (isFilePinned(actualFile)) {
|
|
|
|
unpinFile(actualFile);
|
|
|
|
onSetStatus(`Unpinned ${file.name}`);
|
|
|
|
} else {
|
|
|
|
pinFile(actualFile);
|
|
|
|
onSetStatus(`Pinned ${file.name}`);
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{isFilePinned(actualFile) ? (
|
|
|
|
<PushPinIcon style={{ fontSize: 20 }} />
|
|
|
|
) : (
|
|
|
|
<PushPinOutlinedIcon style={{ fontSize: 20 }} />
|
|
|
|
)}
|
|
|
|
</ActionIcon>
|
|
|
|
</Tooltip>
|
|
|
|
)}
|
|
|
|
|
2025-07-16 17:53:50 +01:00
|
|
|
<Tooltip label="Close File">
|
2025-06-20 17:51:24 +01:00
|
|
|
<ActionIcon
|
|
|
|
size="md"
|
|
|
|
variant="subtle"
|
2025-07-16 17:53:50 +01:00
|
|
|
c="orange"
|
2025-06-20 17:51:24 +01:00
|
|
|
onClick={(e) => {
|
|
|
|
e.stopPropagation();
|
|
|
|
onDeleteFile(file.id);
|
2025-07-16 17:53:50 +01:00
|
|
|
onSetStatus(`Closed ${file.name}`);
|
2025-06-20 17:51:24 +01:00
|
|
|
}}
|
|
|
|
>
|
2025-07-16 17:53:50 +01:00
|
|
|
<CloseIcon style={{ fontSize: 20 }} />
|
2025-06-20 17:51:24 +01:00
|
|
|
</ActionIcon>
|
|
|
|
</Tooltip>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<DragIndicatorIcon
|
|
|
|
style={{
|
|
|
|
position: 'absolute',
|
|
|
|
bottom: 4,
|
|
|
|
right: 4,
|
|
|
|
color: 'rgba(0,0,0,0.3)',
|
|
|
|
fontSize: 16,
|
|
|
|
zIndex: 1
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
{/* File info */}
|
|
|
|
<div className="w-full px-4 py-2 text-center">
|
|
|
|
<Text size="sm" fw={500} truncate>
|
|
|
|
{file.name}
|
|
|
|
</Text>
|
|
|
|
<Text size="xs" c="dimmed">
|
|
|
|
{formatFileSize(file.size)}
|
|
|
|
</Text>
|
|
|
|
</div>
|
2025-07-16 17:53:50 +01:00
|
|
|
|
2025-06-20 17:51:24 +01:00
|
|
|
</div>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
export default FileThumbnail;
|