/** * FileExplorer - middle column file browser with multiple view modes. * Replaces FileGrid. Supports: list, table, icons-sm, icons-md, icons-lg. * Files are draggable for drag-and-drop into folders (SourceTree). * Multi-selection: Shift+Click (range), Ctrl/Cmd+Click (toggle), * rubber-band drag, mobile long-press. */ import React, { useMemo, useState, useRef, useCallback } from 'react'; import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; import type { DmsFile } from '@/api/dms'; export function getFileIcon(mimeType: string): { path: string; color: string } { if (mimeType === 'application/pdf') { return { path: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-danger-500' }; } if (mimeType.startsWith('image/')) { return { path: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z', color: 'text-accent-500' }; } if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) { return { path: 'M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-success-500' }; } if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) { return { path: 'M7 8h10M7 16h10M7 12h6m-7 8h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z', color: 'text-warning-500' }; } if (mimeType.startsWith('text/') || mimeType.includes('document') || mimeType.includes('word')) { return { path: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-primary-500' }; } if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive')) { return { path: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4', color: 'text-secondary-500' }; } return { path: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z', color: 'text-secondary-400' }; } export function formatFileSize(bytes: number): string { if (!bytes || bytes < 0) return '0 B'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } function getFileSize(file: DmsFile): number { return file.size_bytes ?? file.size ?? 0; } function formatDate(dateStr: string | null | undefined): string { if (!dateStr) return ''; try { const d = new Date(dateStr); return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch { return dateStr; } } export type ViewMode = 'list' | 'table' | 'icons-sm' | 'icons-md' | 'icons-lg'; export type SortBy = 'name' | 'size' | 'date' | 'type'; export type SortOrder = 'asc' | 'desc'; export interface FileExplorerProps { files: DmsFile[]; selectedFileIds: Set; selectedFile: DmsFile | null; viewMode: ViewMode; onToggleSelect: (fileId: string) => void; onFileClick: (file: DmsFile) => void; onFileDoubleClick: (file: DmsFile) => void; onFileShare: (file: DmsFile) => void; onFileDelete: (file: DmsFile) => void; onFilePreview: (file: DmsFile) => void; loading?: boolean; sortBy: SortBy; sortOrder: SortOrder; onSortChange: (by: string, order: string) => void; onFileDragStart?: (fileIds: string[]) => void; onRangeSelect?: (fileIds: string[]) => void; } function SortHeader({ label, field, sortBy, sortOrder, onSortChange, className, }: { label: string; field: SortBy; sortBy: SortBy; sortOrder: SortOrder; onSortChange: (by: string, order: string) => void; className?: string; }) { const isActive = sortBy === field; return ( ); } function ActionButtons({ file, onFilePreview, onFileShare, onFileDelete, t, }: { file: DmsFile; onFilePreview: (file: DmsFile) => void; onFileShare: (file: DmsFile) => void; onFileDelete: (file: DmsFile) => void; t: (key: string) => string; }) { return (
); } export function FileExplorer({ files, selectedFileIds, selectedFile, viewMode, onToggleSelect, onFileClick, onFileDoubleClick, onFileShare, onFileDelete, onFilePreview, loading = false, sortBy, sortOrder, onSortChange, onFileDragStart, onRangeSelect, }: FileExplorerProps) { const { t } = useTranslation(); const [draggingId, setDraggingId] = useState(null); // Multi-selection state const lastClickedIndex = useRef(null); // Rubber-band state const containerRef = useRef(null); const rubberBandStartRef = useRef<{ x: number; y: number } | null>(null); const [rubberBand, setRubberBand] = useState<{ startX: number; startY: number; endX: number; endY: number } | null>(null); const [rubberBandSelected, setRubberBandSelected] = useState>(new Set()); const fileElementRefs = useRef>(new Map()); // Long-press state const longPressTimer = useRef | null>(null); const longPressTriggered = useRef(false); const sortedFiles = useMemo(() => { const sorted = [...files]; sorted.sort((a, b) => { let cmp = 0; if (sortBy === 'name') { cmp = a.name.localeCompare(b.name); } else if (sortBy === 'size') { cmp = getFileSize(a) - getFileSize(b); } else if (sortBy === 'date') { cmp = (a.updated_at || a.created_at || '').localeCompare(b.updated_at || b.created_at || ''); } else if (sortBy === 'type') { cmp = a.mime_type.localeCompare(b.mime_type); } return sortOrder === 'asc' ? cmp : -cmp; }); return sorted; }, [files, sortBy, sortOrder]); // ─── Drag handler ─── function handleDragStart(e: React.DragEvent, file: DmsFile) { let ids: string[]; if (selectedFileIds.has(file.id) && selectedFileIds.size > 1) { ids = Array.from(selectedFileIds); } else { ids = [file.id]; } e.dataTransfer.setData('application/json', JSON.stringify({ type: 'files', ids })); e.dataTransfer.effectAllowed = 'move'; setDraggingId(file.id); onFileDragStart?.(ids); } function handleDragEnd() { setDraggingId(null); } // ─── Click handler with modifier detection ─── const handleRowClick = useCallback((e: React.MouseEvent, file: DmsFile, index: number) => { if (longPressTriggered.current) { longPressTriggered.current = false; e.preventDefault(); return; } if (e.shiftKey && lastClickedIndex.current !== null && onRangeSelect) { const start = Math.min(lastClickedIndex.current, index); const end = Math.max(lastClickedIndex.current, index); const rangeIds = sortedFiles.slice(start, end + 1).map(f => f.id); onRangeSelect(rangeIds); } else if (e.ctrlKey || e.metaKey) { onToggleSelect(file.id); lastClickedIndex.current = index; } else { onFileClick(file); lastClickedIndex.current = index; } }, [sortedFiles, onRangeSelect, onToggleSelect, onFileClick]); // ─── Long-press handlers for touch / mobile ─── const handleTouchStart = useCallback((file: DmsFile) => { longPressTriggered.current = false; longPressTimer.current = setTimeout(() => { longPressTriggered.current = true; onToggleSelect(file.id); if (navigator.vibrate) navigator.vibrate(50); }, 500); }, [onToggleSelect]); const handleTouchEnd = useCallback(() => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } }, []); const handleTouchMove = useCallback(() => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } }, []); // ─── Rubber-band handlers ─── const handleMouseDown = useCallback((e: React.MouseEvent) => { const target = e.target as HTMLElement; if (target.closest('[data-file-id]')) return; if (target.closest('button') || target.closest('input') || target.tagName === 'TH') return; if (e.button !== 0) return; const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const x = e.clientX - rect.left + container.scrollLeft; const y = e.clientY - rect.top + container.scrollTop; rubberBandStartRef.current = { x, y }; setRubberBand({ startX: x, startY: y, endX: x, endY: y }); setRubberBandSelected(new Set()); }, []); const handleMouseMove = useCallback((e: React.MouseEvent) => { if (!rubberBandStartRef.current) return; const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const x = e.clientX - rect.left + container.scrollLeft; const y = e.clientY - rect.top + container.scrollTop; const rb = { startX: rubberBandStartRef.current.x, startY: rubberBandStartRef.current.y, endX: x, endY: y, }; setRubberBand(rb); const rbLeft = Math.min(rb.startX, rb.endX); const rbTop = Math.min(rb.startY, rb.endY); const rbRight = Math.max(rb.startX, rb.endX); const rbBottom = Math.max(rb.startY, rb.endY); const newSelected = new Set(); fileElementRefs.current.forEach((el, fileId) => { const elRect = el.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const elLeft = elRect.left - containerRect.left + container.scrollLeft; const elTop = elRect.top - containerRect.top + container.scrollTop; const elRight = elLeft + elRect.width; const elBottom = elTop + elRect.height; if (elLeft < rbRight && elRight > rbLeft && elTop < rbBottom && elBottom > rbTop) { newSelected.add(fileId); } }); setRubberBandSelected(newSelected); }, []); const handleMouseUp = useCallback(() => { if (!rubberBandStartRef.current) return; const hasDragged = rubberBand !== null && (Math.abs(rubberBand.endX - rubberBand.startX) > 3 || Math.abs(rubberBand.endY - rubberBand.startY) > 3); if (hasDragged && rubberBandSelected.size > 0) { onRangeSelect?.(Array.from(rubberBandSelected)); } else if (!hasDragged) { onRangeSelect?.([]); } rubberBandStartRef.current = null; setRubberBand(null); setRubberBandSelected(new Set()); }, [rubberBand, rubberBandSelected, onRangeSelect]); const handleMouseLeave = useCallback(() => { if (rubberBandStartRef.current) { rubberBandStartRef.current = null; setRubberBand(null); setRubberBandSelected(new Set()); } }, []); // Helper: check if file is selected (considering rubber-band) const isFileSelected = (fileId: string) => { if (rubberBand !== null) { return rubberBandSelected.has(fileId); } return selectedFileIds.has(fileId); }; // Rubber-band rectangle element const rubberBandRect = rubberBand && (
); if (loading) { return (
{t('dms.loading')}
); } if (files.length === 0) { return (

{t('dms.noFiles')}

); } // ─── List View ─── if (viewMode === 'list') { return (
| |
    {sortedFiles.map((file, index) => { const icon = getFileIcon(file.mime_type); const isSelected = isFileSelected(file.id); const isActive = selectedFile?.id === file.id; const isDragging = draggingId === file.id; return (
  • { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }} data-file-id={file.id} className={clsx( 'flex items-center gap-2 px-3 py-2 cursor-pointer motion-safe:transition-colors', isActive ? 'bg-primary-50' : isSelected ? 'bg-primary-50' : 'hover:bg-secondary-50', isDragging && 'opacity-50', )} onClick={(e) => handleRowClick(e, file, index)} onDoubleClick={() => onFileDoubleClick(file)} onTouchStart={() => handleTouchStart(file)} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchMove} draggable onDragStart={(e) => handleDragStart(e, file)} onDragEnd={handleDragEnd} data-testid={`file-row-${file.id}`} > onToggleSelect(file.id)} onClick={(e) => e.stopPropagation()} className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500 flex-shrink-0" aria-label={t('dms.bulkSelect')} /> {file.name} {formatFileSize(getFileSize(file))} {formatDate(file.updated_at || file.created_at)}
  • ); })}
{rubberBandRect}
); } // ─── Table View ─── if (viewMode === 'table') { return (
{sortedFiles.map((file, index) => { const icon = getFileIcon(file.mime_type); const isSelected = isFileSelected(file.id); const isActive = selectedFile?.id === file.id; const isDragging = draggingId === file.id; return ( { if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }} data-file-id={file.id} className={clsx( 'cursor-pointer motion-safe:transition-colors', isActive ? 'bg-primary-50' : isSelected ? 'bg-primary-50' : 'hover:bg-secondary-50', isDragging && 'opacity-50', )} onClick={(e) => handleRowClick(e, file, index)} onDoubleClick={() => onFileDoubleClick(file)} onTouchStart={() => handleTouchStart(file)} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchMove} draggable onDragStart={(e) => handleDragStart(e, file)} onDragEnd={handleDragEnd} data-testid={`file-row-${file.id}`} > ); })}
{}} /> {t('common.actions')}
e.stopPropagation()}> onToggleSelect(file.id)} className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500" aria-label={t('dms.bulkSelect')} />
{file.name}
{formatFileSize(getFileSize(file))} {file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')} {formatDate(file.updated_at || file.created_at)} e.stopPropagation()}>
{rubberBandRect}
); } // ─── Icon Views ─── const gridMinWidth = viewMode === 'icons-sm' ? '80px' : viewMode === 'icons-md' ? '120px' : '180px'; const gridClass = `grid gap-3 p-3 overflow-y-auto h-full items-start content-start relative`; const gridStyle = { gridTemplateColumns: `repeat(auto-fill, minmax(${gridMinWidth}, 1fr))` }; return (
{sortedFiles.map((file, index) => { const icon = getFileIcon(file.mime_type); const isSelected = isFileSelected(file.id); const isActive = selectedFile?.id === file.id; const isDragging = draggingId === file.id; // icons-sm: compact, no border, no action buttons if (viewMode === 'icons-sm') { return (
{ if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }} data-file-id={file.id} className={clsx( 'rounded-lg p-1.5 cursor-pointer flex flex-col items-center text-center overflow-hidden', isActive ? 'bg-primary-50' : isSelected ? 'bg-primary-50 ring-1 ring-primary-300' : 'hover:bg-secondary-50', isDragging && 'opacity-50', )} onClick={(e) => handleRowClick(e, file, index)} onDoubleClick={() => onFileDoubleClick(file)} onTouchStart={() => handleTouchStart(file)} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchMove} draggable onDragStart={(e) => handleDragStart(e, file)} onDragEnd={handleDragEnd} data-testid={`file-card-${file.id}`} >

{file.name}

); } // icons-md: medium cards with border, reduced padding if (viewMode === 'icons-md') { return (
{ if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }} data-file-id={file.id} className={clsx( 'bg-white rounded-lg border p-2 cursor-pointer flex flex-col items-center text-center overflow-hidden', isActive ? 'border-primary-500 ring-1 ring-primary-200' : isSelected ? 'border-primary-400' : 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm', isDragging && 'opacity-50', )} onClick={(e) => handleRowClick(e, file, index)} onDoubleClick={() => onFileDoubleClick(file)} onTouchStart={() => handleTouchStart(file)} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchMove} draggable onDragStart={(e) => handleDragStart(e, file)} onDragEnd={handleDragEnd} data-testid={`file-card-${file.id}`} >
onToggleSelect(file.id)} onClick={(e) => e.stopPropagation()} className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500" aria-label={t('dms.bulkSelect')} />

{file.name}

{formatFileSize(getFileSize(file))}
); } // icons-lg: large cards with image preview return (
{ if (el) fileElementRefs.current.set(file.id, el); else fileElementRefs.current.delete(file.id); }} data-file-id={file.id} className={clsx( 'bg-white rounded-lg border p-3 cursor-pointer flex flex-col items-center text-center overflow-hidden', isActive ? 'border-primary-500 ring-2 ring-primary-200' : isSelected ? 'border-primary-400' : 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm', isDragging && 'opacity-50', )} onClick={(e) => handleRowClick(e, file, index)} onDoubleClick={() => onFileDoubleClick(file)} onTouchStart={() => handleTouchStart(file)} onTouchEnd={handleTouchEnd} onTouchMove={handleTouchMove} draggable onDragStart={(e) => handleDragStart(e, file)} onDragEnd={handleDragEnd} data-testid={`file-card-${file.id}`} >
onToggleSelect(file.id)} onClick={(e) => e.stopPropagation()} className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500" aria-label={t('dms.bulkSelect')} />
{file.mime_type.startsWith('image/') ? (
{file.name} { (e.target as HTMLImageElement).style.display = 'none'; }} />
) : ( )}

{file.name}

{formatFileSize(getFileSize(file))}
); })} {rubberBandRect}
); }