749 lines
31 KiB
TypeScript
749 lines
31 KiB
TypeScript
/**
|
|
* 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<string>;
|
|
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 (
|
|
<button
|
|
type="button"
|
|
onClick={() => onSortChange(field, isActive && sortOrder === 'asc' ? 'desc' : 'asc')}
|
|
className={clsx(
|
|
'inline-flex items-center gap-1 text-xs font-medium hover:text-secondary-700',
|
|
isActive ? 'text-primary-600' : 'text-secondary-500',
|
|
className,
|
|
)}
|
|
>
|
|
{label}
|
|
{isActive && (
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'asc' ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7'} />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onFilePreview(file); }}
|
|
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50"
|
|
aria-label={t('dms.preview')}
|
|
title={t('dms.preview')}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onFileShare(file); }}
|
|
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50"
|
|
aria-label={t('dms.share')}
|
|
title={t('dms.share')}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.7 10.7l6.6-3.4M8.7 13.3l6.6 3.4M18 12a3 3 0 11-6 0 3 3 0 016 0zM9 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onFileDelete(file); }}
|
|
className="p-1.5 rounded text-secondary-400 hover:text-danger-600 hover:bg-danger-50"
|
|
aria-label={t('dms.delete')}
|
|
title={t('dms.delete')}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<string | null>(null);
|
|
|
|
// Multi-selection state
|
|
const lastClickedIndex = useRef<number | null>(null);
|
|
|
|
// Rubber-band state
|
|
const containerRef = useRef<HTMLDivElement>(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<Set<string>>(new Set());
|
|
const fileElementRefs = useRef<Map<string, HTMLElement>>(new Map());
|
|
|
|
// Long-press state
|
|
const longPressTimer = useRef<ReturnType<typeof setTimeout> | 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<string>();
|
|
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 && (
|
|
<div
|
|
className="absolute border border-primary-400 bg-primary-100/30 pointer-events-none z-50 rounded-sm"
|
|
style={{
|
|
left: Math.min(rubberBand.startX, rubberBand.endX),
|
|
top: Math.min(rubberBand.startY, rubberBand.endY),
|
|
width: Math.abs(rubberBand.endX - rubberBand.startX),
|
|
height: Math.abs(rubberBand.endY - rubberBand.startY),
|
|
}}
|
|
/>
|
|
);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12" data-testid="file-explorer-loading">
|
|
<svg className="animate-spin h-6 w-6 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
<span className="ml-2 text-secondary-500 text-sm">{t('dms.loading')}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (files.length === 0) {
|
|
return (
|
|
<div className="text-center py-12" data-testid="file-explorer-empty">
|
|
<svg className="mx-auto h-12 w-12 text-secondary-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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" />
|
|
</svg>
|
|
<p className="mt-2 text-sm text-secondary-500">{t('dms.noFiles')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── List View ───
|
|
if (viewMode === 'list') {
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={clsx('overflow-y-auto h-full relative', rubberBand && 'select-none')}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseLeave}
|
|
data-testid="file-explorer-list"
|
|
>
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-100 sticky top-0 bg-white z-10">
|
|
<SortHeader label={t('dms.sortName')} field="name" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
|
<span className="text-secondary-300">|</span>
|
|
<SortHeader label={t('dms.sortSize')} field="size" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
|
<span className="text-secondary-300">|</span>
|
|
<SortHeader label={t('dms.sortDate')} field="date" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
|
</div>
|
|
<ul className="divide-y divide-secondary-50">
|
|
{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 (
|
|
<li key={file.id}>
|
|
<div
|
|
ref={(el) => { 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}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={() => 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')}
|
|
/>
|
|
<svg className={clsx('w-5 h-5 flex-shrink-0', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
|
</svg>
|
|
<span className="text-sm font-medium text-secondary-900 truncate flex-1" title={file.name}>{file.name}</span>
|
|
<span className="text-xs text-secondary-500 flex-shrink-0">{formatFileSize(getFileSize(file))}</span>
|
|
<span className="text-xs text-secondary-400 flex-shrink-0 hidden sm:inline">{formatDate(file.updated_at || file.created_at)}</span>
|
|
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
{rubberBandRect}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Table View ───
|
|
if (viewMode === 'table') {
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={clsx('overflow-auto h-full relative', rubberBand && 'select-none')}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseLeave}
|
|
data-testid="file-explorer-table"
|
|
>
|
|
<table className="w-full text-sm">
|
|
<thead className="sticky top-0 bg-white border-b border-secondary-200 z-10">
|
|
<tr>
|
|
<th className="px-3 py-2 w-8">
|
|
<input
|
|
type="checkbox"
|
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
|
aria-label={t('dms.bulkSelect')}
|
|
onChange={() => {}}
|
|
/>
|
|
</th>
|
|
<th className="px-3 py-2 text-left">
|
|
<SortHeader label={t('dms.sortName')} field="name" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
|
</th>
|
|
<th className="px-3 py-2 text-left">
|
|
<SortHeader label={t('dms.fileSize')} field="size" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
|
</th>
|
|
<th className="px-3 py-2 text-left">
|
|
<SortHeader label={t('dms.fileType')} field="type" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
|
</th>
|
|
<th className="px-3 py-2 text-left">
|
|
<SortHeader label={t('dms.fileModified')} field="date" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
|
|
</th>
|
|
<th className="px-3 py-2 text-right">{t('common.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-secondary-50">
|
|
{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 (
|
|
<tr
|
|
key={file.id}
|
|
ref={(el) => { 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}`}
|
|
>
|
|
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={() => onToggleSelect(file.id)}
|
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
|
aria-label={t('dms.bulkSelect')}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<svg className={clsx('w-5 h-5 flex-shrink-0', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
|
</svg>
|
|
<span className="font-medium text-secondary-900 truncate" title={file.name}>{file.name}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2 text-secondary-600">{formatFileSize(getFileSize(file))}</td>
|
|
<td className="px-3 py-2 text-secondary-600">{file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')}</td>
|
|
<td className="px-3 py-2 text-secondary-600">{formatDate(file.updated_at || file.created_at)}</td>
|
|
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
|
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
{rubberBandRect}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div
|
|
ref={containerRef}
|
|
className={clsx(gridClass, rubberBand && 'select-none')}
|
|
style={gridStyle}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseLeave}
|
|
data-testid={`file-explorer-${viewMode}`}
|
|
>
|
|
{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 (
|
|
<div
|
|
key={file.id}
|
|
ref={(el) => { 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}`}
|
|
>
|
|
<svg className={clsx('w-8 h-8 mb-1', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
|
</svg>
|
|
<p className="text-[10px] font-medium text-secondary-900 truncate w-full" title={file.name}>{file.name}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// icons-md: medium cards with border, reduced padding
|
|
if (viewMode === 'icons-md') {
|
|
return (
|
|
<div
|
|
key={file.id}
|
|
ref={(el) => { 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}`}
|
|
>
|
|
<div className="flex items-center justify-between w-full mb-1.5">
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={() => onToggleSelect(file.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
|
aria-label={t('dms.bulkSelect')}
|
|
/>
|
|
</div>
|
|
<svg className={clsx('w-8 h-8 mb-1.5', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
|
</svg>
|
|
<p className="text-xs font-medium text-secondary-900 truncate w-full" title={file.name}>{file.name}</p>
|
|
<div className="mt-1 flex items-center gap-1.5 text-xs text-secondary-500">
|
|
<span>{formatFileSize(getFileSize(file))}</span>
|
|
</div>
|
|
<div className="mt-1.5">
|
|
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// icons-lg: large cards with image preview
|
|
return (
|
|
<div
|
|
key={file.id}
|
|
ref={(el) => { 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}`}
|
|
>
|
|
<div className="flex items-center justify-between w-full mb-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={() => onToggleSelect(file.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
|
aria-label={t('dms.bulkSelect')}
|
|
/>
|
|
</div>
|
|
{file.mime_type.startsWith('image/') ? (
|
|
<div className="w-16 h-16 bg-secondary-100 rounded-lg flex items-center justify-center mb-2 overflow-hidden">
|
|
<img
|
|
src={`/api/v1/dms/files/${file.id}/preview`}
|
|
alt={file.name}
|
|
className="w-full h-full object-cover"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).style.display = 'none';
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<svg className={clsx('w-16 h-16 mb-2', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
|
|
</svg>
|
|
)}
|
|
<p className="text-xs font-medium text-secondary-900 truncate w-full" title={file.name}>{file.name}</p>
|
|
<div className="mt-1 flex items-center gap-2 text-xs text-secondary-500">
|
|
<span>{formatFileSize(getFileSize(file))}</span>
|
|
</div>
|
|
<div className="mt-2">
|
|
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{rubberBandRect}
|
|
</div>
|
|
);
|
|
}
|