import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { Text, ActionIcon, CheckboxIndicator, Tooltip } from '@mantine/core'; import { alert } from '../toast'; import { useTranslation } from 'react-i18next'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; import CloseIcon from '@mui/icons-material/Close'; import UnarchiveIcon from '@mui/icons-material/Unarchive'; import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { StirlingFileStub } from '../../types/fileContext'; import { zipFileService } from '../../services/zipFileService'; import styles from './FileEditor.module.css'; import { useFileContext } from '../../contexts/FileContext'; import { useFileState } from '../../contexts/file/fileHooks'; import { FileId } from '../../types/file'; import { formatFileSize } from '../../utils/fileUtils'; import ToolChain from '../shared/ToolChain'; 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, selectedFileIds: FileId[]) => void; onDownloadFile: (fileId: FileId) => void; onUnzipFile?: (fileId: FileId) => void; toolMode?: boolean; isSupported?: boolean; } const FileEditorThumbnail = ({ file, index, selectedFiles, onToggleFile, onCloseFile, onViewFile, _onSetStatus, onReorderFiles, onDownloadFile, onUnzipFile, isSupported = true, }: FileEditorThumbnailProps) => { const { t } = useTranslation(); const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext(); const { state } = useFileState(); const hasError = state.ui.errorFileIds.includes(file.id); // ---- Drag state ---- const [isDragging, setIsDragging] = useState(false); const dragElementRef = useRef(null); const [actionsWidth, setActionsWidth] = useState(undefined); const [showActions, setShowActions] = useState(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 isPinned = actualFile ? isFilePinned(actualFile) : false; // Check if this is a ZIP file const isZipFile = zipFileService.isZipFileStub(file); const pageCount = file.processedFile?.totalPages || 0; const handleRef = useRef(null); // ---- Selection ---- const isSelected = selectedFiles.includes(file.id); // ---- Meta formatting ---- const prettySize = useMemo(() => { return formatFileSize(file.size); }, [file.size]); const extUpper = useMemo(() => { const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ''); return (m?.[1] || '').toUpperCase(); }, [file.name]); const pageLabel = useMemo( () => pageCount > 0 ? `${pageCount} ${pageCount === 1 ? 'Page' : 'Pages'}` : '', [pageCount] ); const dateLabel = useMemo(() => { const d = new Date(file.lastModified); if (Number.isNaN(d.getTime())) return ''; return new Intl.DateTimeFormat(undefined, { month: 'short', day: '2-digit', year: 'numeric', }).format(d); }, [file.lastModified]); // ---- Drag & drop wiring ---- 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 FileId; const selectedFileIds = sourceData.selectedFiles as FileId[]; onReorderFiles(sourceFileId, file.id, selectedFileIds); } } }); return () => { dragCleanup(); dropCleanup(); }; }, [file.id, file.name, selectedFiles, onReorderFiles]); // Update dropdown width on resize useEffect(() => { const update = () => { if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth); }; update(); window.addEventListener('resize', update); return () => window.removeEventListener('resize', update); }, []); // Close the actions dropdown when hovering outside this file card (and its dropdown) useEffect(() => { if (!showActions) return; const isInsideCard = (target: EventTarget | null) => { const container = dragElementRef.current; if (!container) return false; return target instanceof Node && container.contains(target); }; const handleMouseMove = (e: MouseEvent) => { if (!isInsideCard(e.target)) { setShowActions(false); } }; const handleTouchStart = (e: TouchEvent) => { // On touch devices, close if the touch target is outside the card if (!isInsideCard(e.target)) { setShowActions(false); } }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('touchstart', handleTouchStart, { passive: true }); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('touchstart', handleTouchStart); }; }, [showActions]); // ---- 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); } catch (_e) { void _e; } } onToggleFile(file.id); }; const handleCardDoubleClick = () => { if (!isSupported) return; onViewFile(file.id); }; // ---- Style helpers ---- const getHeaderClassName = () => { if (hasError) return styles.headerError; if (!isSupported) return styles.headerUnsupported; return isSelected ? styles.headerSelected : styles.headerResting; }; return (
{/* Header bar */}
{/* Logo/checkbox area */}
{hasError ? (
{t('error._value', 'Error')}
) : isSupported ? ( onToggleFile(file.id)} color="var(--checkbox-checked-bg)" /> ) : (
{t('unsupported', 'Unsupported')}
)}
{/* Centered index */}
{index + 1}
{/* Action buttons group */}
{/* Pin/Unpin icon */} { 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 }); } } }} > {isPinned ? : } {/* Download icon */} { e.stopPropagation(); onDownloadFile(file.id); alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); }} > {/* Kebab menu */} { e.stopPropagation(); setShowActions((v) => !v); }} >
{/* Actions overlay */} {showActions && (
e.stopPropagation()} > {isZipFile && onUnzipFile && ( )}
)} {/* Title + meta line */}
{file.name} {/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */} {`v${file.versionNumber} - `} {dateLabel} {extUpper ? ` - ${extUpper} file` : ''} {pageLabel ? ` - ${pageLabel}` : ''}
{/* Preview area */}
{file.thumbnailUrl && ( {file.name} { 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' }} /> )}
{/* Drag handle (span wrapper so we can attach a ref reliably) */} {/* Tool chain display at bottom */} {file.toolHistory && (
)}
); }; export default React.memo(FileEditorThumbnail);