import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; 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 styles from './FileEditor.module.css'; import { useFileContext } from '../../contexts/FileContext'; import { FileId } from '../../types/file'; interface FileItem { id: FileId; name: string; pageCount: number; thumbnail: string | null; size: number; modifiedAt?: number | string | Date; } interface FileEditorThumbnailProps { file: FileItem; index: number; totalFiles: number; selectedFiles: FileId[]; selectionMode: boolean; onToggleFile: (fileId: FileId) => void; onDeleteFile: (fileId: FileId) => void; onViewFile: (fileId: FileId) => void; onSetStatus: (status: string) => void; onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void; onDownloadFile?: (fileId: FileId) => void; toolMode?: boolean; isSupported?: boolean; } const FileEditorThumbnail = ({ file, index, selectedFiles, onToggleFile, onDeleteFile, onViewFile, onSetStatus, onReorderFiles, onDownloadFile, isSupported = true, }: FileEditorThumbnailProps) => { const { t } = useTranslation(); const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext(); // ---- 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: File) => f.name === file.name && f.size === file.size); }, [activeFiles, file.name, file.size]); const isPinned = actualFile ? isFilePinned(actualFile) : false; const downloadSelectedFile = useCallback(() => { // Prefer parent-provided handler if available if (typeof onDownloadFile === 'function') { onDownloadFile(file.id); return; } // Fallback: attempt to download using the File object if provided const maybeFile = (file as unknown as { file?: File }).file; if (maybeFile instanceof File) { const link = document.createElement('a'); link.href = URL.createObjectURL(maybeFile); link.download = maybeFile.name || file.name || 'download'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(link.href); return; } // If we can't find a way to download, surface a status message onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item'); }, [file, onDownloadFile, onSetStatus, t]); const handleRef = useRef(null); // ---- Selection ---- const isSelected = selectedFiles.includes(file.id); // ---- Meta formatting ---- const prettySize = useMemo(() => { const bytes = file.size ?? 0; if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; }, [file.size]); const extUpper = useMemo(() => { const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ''); return (m?.[1] || '').toUpperCase(); }, [file.name]); const pageLabel = useMemo( () => file.pageCount > 0 ? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}` : '', [file.pageCount] ); const dateLabel = useMemo(() => { const d = file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback if (Number.isNaN(d.getTime())) return ''; return new Intl.DateTimeFormat(undefined, { month: 'short', day: '2-digit', year: 'numeric', }).format(d); }, [file.modifiedAt]); // ---- 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; onToggleFile(file.id); }; return (
{/* Header bar */}
{/* Logo/checkbox area */}
{isSupported ? ( onToggleFile(file.id)} color="var(--checkbox-checked-bg)" /> ) : (
{t('unsupported', 'Unsupported')}
)}
{/* Centered index */}
{index + 1}
{/* Kebab menu */} { e.stopPropagation(); setShowActions((v) => !v); }} >
{/* Actions overlay */} {showActions && (
e.stopPropagation()} >
)} {/* Title + meta line */}
{file.name} {/* e.g., Jan 29, 2025 - PDF file - 3 Pages */} {dateLabel} {extUpper ? ` - ${extUpper} file` : ''} {pageLabel ? ` - ${pageLabel}` : ''}
{/* Preview area */}
{file.thumbnail && ( {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' }} /> )}
{/* Pin indicator (bottom-left) */} {isPinned && ( )} {/* Drag handle (span wrapper so we can attach a ref reliably) */}
); }; export default React.memo(FileEditorThumbnail);