Files
leocrm/frontend/src/components/dms/FileExplorer.tsx
T
2026-07-23 05:05:08 +02:00

417 lines
17 KiB
TypeScript

/**
* FileExplorer - middle column file browser with multiple view modes.
* Replaces FileGrid. Supports: list, table, icons-sm, icons-md, icons-lg.
*/
import React, { useMemo } from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { DmsFile } from '@/api/dms';
import { Archive, ChevronDown, ChevronUp, Eye, FileText, Image, Loader2, Share2, Trash2 } from 'lucide-react';
export function getFileIcon(mimeType: string): { icon: React.ElementType; color: string } {
if (mimeType === 'application/pdf') {
return { icon: FileText, color: 'text-danger-500' };
}
if (mimeType.startsWith('image/')) {
return { icon: Image, color: 'text-accent-500' };
}
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) {
return { icon: FileText, color: 'text-success-500' };
}
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) {
return { icon: FileText, color: 'text-warning-500' };
}
if (mimeType.startsWith('text/') || mimeType.includes('document') || mimeType.includes('word')) {
return { icon: FileText, color: 'text-primary-500' };
}
if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive')) {
return { icon: Archive, color: 'text-secondary-500' };
}
return { icon: FileText, 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`;
}
import { formatDateShort } from '@/utils/date';
function getFileSize(file: DmsFile): number {
return file.size_bytes ?? file.size ?? 0;
}
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '';
return formatDateShort(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;
}
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 && (
sortOrder === 'asc' ? <ChevronUp className="w-3 h-3" aria-hidden="true" strokeWidth={2} /> : <ChevronDown className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
)}
</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 min-h-touch min-w-touch"
aria-label={t('dms.preview')}
title={t('dms.preview')}
>
<Eye className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onFileShare(file); }}
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50 min-h-touch min-w-touch"
aria-label={t('dms.share')}
title={t('dms.share')}
>
<Share2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onFileDelete(file); }}
className="p-1.5 rounded text-secondary-400 hover:text-danger-600 hover:bg-danger-50 min-h-touch min-w-touch"
aria-label={t('dms.delete')}
title={t('dms.delete')}
>
<Trash2 className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
</button>
</div>
);
}
export function FileExplorer({
files,
selectedFileIds,
selectedFile,
viewMode,
onToggleSelect,
onFileClick,
onFileDoubleClick,
onFileShare,
onFileDelete,
onFilePreview,
loading = false,
sortBy,
sortOrder,
onSortChange,
}: FileExplorerProps) {
const { t } = useTranslation();
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]);
if (loading) {
return (
<div className="flex items-center justify-center py-12" data-testid="file-explorer-loading">
<Loader2 className="animate-spin h-6 w-6 text-secondary-400" aria-hidden="true" />
<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">
<FileText className="mx-auto h-12 w-12 text-secondary-300" aria-hidden="true" strokeWidth={1.5} />
<p className="mt-2 text-sm text-secondary-500">{t('dms.noFiles')}</p>
</div>
);
}
// ─── List View ───
if (viewMode === 'list') {
return (
<div className="overflow-y-auto h-full" data-testid="file-explorer-list">
{/* Sort header */}
<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) => {
const icon = getFileIcon(file.mime_type);
const isSelected = selectedFileIds.has(file.id);
const isActive = selectedFile?.id === file.id;
return (
<li key={file.id}>
<div
className={clsx(
'flex items-center gap-2 px-3 py-2 cursor-pointer motion-safe:transition-colors',
isActive ? 'bg-primary-50' : 'hover:bg-secondary-50',
)}
onClick={() => onFileClick(file)}
onDoubleClick={() => onFileDoubleClick(file)}
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')}
/>
<icon.icon className={clsx('w-5 h-5 flex-shrink-0', icon.color)} aria-hidden="true" strokeWidth={1.5} />
<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>
</div>
);
}
// ─── Table View ───
if (viewMode === 'table') {
return (
<div className="overflow-auto h-full" 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) => {
const icon = getFileIcon(file.mime_type);
const isSelected = selectedFileIds.has(file.id);
const isActive = selectedFile?.id === file.id;
return (
<tr
key={file.id}
className={clsx(
'cursor-pointer motion-safe:transition-colors',
isActive ? 'bg-primary-50' : 'hover:bg-secondary-50',
)}
onClick={() => onFileClick(file)}
onDoubleClick={() => onFileDoubleClick(file)}
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">
<icon.icon className={clsx('w-5 h-5 flex-shrink-0', icon.color)} aria-hidden="true" strokeWidth={1.5} />
<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>
</div>
);
}
// ─── Icon Views ───
const minColWidth =
viewMode === 'icons-sm' ? '80px'
: viewMode === 'icons-md' ? '120px'
: '180px';
const iconSize =
viewMode === 'icons-sm' ? 'w-6 h-6'
: viewMode === 'icons-md' ? 'w-8 h-8'
: 'w-16 h-16';
const isSmall = viewMode === 'icons-sm';
return (
<div
className="grid gap-1.5 p-2 overflow-y-auto h-full items-start content-start"
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${minColWidth}, 1fr))` }}
data-testid={`file-explorer-${viewMode}`}
>
{sortedFiles.map((file) => {
const icon = getFileIcon(file.mime_type);
const isSelected = selectedFileIds.has(file.id);
const isActive = selectedFile?.id === file.id;
return (
<div
key={file.id}
className={clsx(
isSmall
? clsx(
'rounded p-1.5 motion-safe:transition-all cursor-pointer flex flex-col items-center text-center overflow-hidden',
isActive ? 'bg-primary-50' : isSelected ? 'bg-primary-50/50' : 'hover:bg-secondary-50',
)
: clsx(
'rounded-lg border p-2 motion-safe:transition-all 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',
),
)}
onClick={() => onFileClick(file)}
onDoubleClick={() => onFileDoubleClick(file)}
data-testid={`file-card-${file.id}`}
>
<div className={clsx('flex items-center w-full', isSmall ? 'mb-0.5 justify-center' : 'mb-1.5 justify-between')}>
<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>
{viewMode === 'icons-lg' && 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>
) : (
<icon.icon className={clsx(iconSize, icon.color, isSmall ? 'mb-0.5' : 'mb-1')} aria-hidden="true" strokeWidth={1.5} />
)}
<p className={clsx('font-medium text-secondary-900 truncate w-full', isSmall ? 'text-[10px]' : 'text-xs')} title={file.name}>{file.name}</p>
{!isSmall && (
<div className="mt-0.5 flex items-center gap-2 text-xs text-secondary-500">
<span>{formatFileSize(getFileSize(file))}</span>
</div>
)}
{!isSmall && (
<div className="mt-1.5">
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
</div>
)}
</div>
);
})}
</div>
);
}