Phase 2: Code-Splitting + Virtual Scrolling (Tasks 2.1-2.7)
This commit is contained in:
@@ -3,9 +3,10 @@
|
||||
* Replaces FileGrid. Supports: list, table, icons-sm, icons-md, icons-lg.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { DmsFile } from '@/api/dms';
|
||||
import { Archive, ChevronDown, ChevronUp, Eye, FileText, Image, Loader2, Share2, Trash2 } from 'lucide-react';
|
||||
|
||||
@@ -184,6 +185,17 @@ export function FileExplorer({
|
||||
return sorted;
|
||||
}, [files, sortBy, sortOrder]);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const shouldVirtualize = sortedFiles.length >= 50;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: shouldVirtualize ? sortedFiles.length : 0,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 44,
|
||||
overscan: 10,
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12" data-testid="file-explorer-loading">
|
||||
@@ -204,8 +216,41 @@ export function FileExplorer({
|
||||
|
||||
// ─── List View ───
|
||||
if (viewMode === 'list') {
|
||||
const renderFileRow = (file: DmsFile) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full" data-testid="file-explorer-list">
|
||||
<div className="overflow-y-auto h-full" data-testid="file-explorer-list" ref={scrollRef}>
|
||||
{/* 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} />
|
||||
@@ -214,48 +259,79 @@ export function FileExplorer({
|
||||
<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}>
|
||||
{shouldVirtualize ? (
|
||||
<ul className="divide-y divide-secondary-50" style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const file = sortedFiles[virtualRow.index];
|
||||
return (
|
||||
<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}`}
|
||||
key={file.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<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} />
|
||||
{renderFileRow(file)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary-50">
|
||||
{sortedFiles.map((file) => renderFileRow(file))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Table View ───
|
||||
if (viewMode === 'table') {
|
||||
const renderFileRow = (file: DmsFile) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full" data-testid="file-explorer-table">
|
||||
<div className="overflow-auto h-full" data-testid="file-explorer-table" ref={scrollRef} style={shouldVirtualize ? { maxHeight: '70vh', overflowY: 'auto' } : undefined}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white border-b border-secondary-200 z-10">
|
||||
<tr>
|
||||
@@ -283,45 +359,30 @@ export function FileExplorer({
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
{shouldVirtualize ? (
|
||||
<>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow, idx) => {
|
||||
const file = sortedFiles[virtualRow.index];
|
||||
return (
|
||||
<React.Fragment key={file.id}>
|
||||
{idx === 0 && virtualRow.start > 0 && (
|
||||
<tr style={{ height: virtualRow.start }}>
|
||||
<td colSpan={6} style={{ padding: 0, border: 'none' }} />
|
||||
</tr>
|
||||
)}
|
||||
{renderFileRow(file)}
|
||||
{idx === rowVirtualizer.getVirtualItems().length - 1 && (
|
||||
<tr style={{ height: rowVirtualizer.getTotalSize() - virtualRow.end }}>
|
||||
<td colSpan={6} style={{ padding: 0, border: 'none' }} />
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
sortedFiles.map((file) => renderFileRow(file))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* Displays files with icons, names, sizes, and action buttons.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { DmsFile } from '@/api/dms';
|
||||
import { Eye, FileText, Share2, Trash2 } from 'lucide-react';
|
||||
|
||||
@@ -33,11 +34,22 @@ export function FileGrid({
|
||||
loading = false,
|
||||
}: FileGridProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sortedFiles = useMemo(() => {
|
||||
return [...files].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [files]);
|
||||
|
||||
const shouldVirtualize = sortedFiles.length >= 50;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: shouldVirtualize ? sortedFiles.length : 0,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 140,
|
||||
overscan: 8,
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid gap-3 items-start content-start" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }} data-testid="file-grid-loading">
|
||||
@@ -62,67 +74,148 @@ export function FileGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 items-start content-start" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }} data-testid="file-grid">
|
||||
{sortedFiles.map((file) => {
|
||||
const icon = getFileIcon(file.mime_type);
|
||||
const isSelected = selectedFileIds.has(file.id);
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className={clsx(
|
||||
'bg-white rounded-lg border p-3 motion-safe:transition-all cursor-pointer overflow-hidden flex flex-col',
|
||||
isSelected ? 'border-primary-500 ring-2 ring-primary-200' : 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm'
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
data-testid={`file-card-${file.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-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')}
|
||||
/>
|
||||
<icon.icon className={clsx('w-10 h-10', icon.color)} aria-hidden="true" strokeWidth={1.5} />
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="grid gap-3 items-start content-start overflow-y-auto h-full"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}
|
||||
data-testid="file-grid"
|
||||
>
|
||||
{shouldVirtualize ? (
|
||||
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative', gridColumn: '1 / -1' }}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const file = sortedFiles[virtualRow.index];
|
||||
const icon = getFileIcon(file.mime_type);
|
||||
const isSelected = selectedFileIds.has(file.id);
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-white rounded-lg border p-3 motion-safe:transition-all cursor-pointer overflow-hidden flex flex-col',
|
||||
isSelected ? 'border-primary-500 ring-2 ring-primary-200' : 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm'
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
data-testid={`file-card-${file.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-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')}
|
||||
/>
|
||||
<icon.icon className={clsx('w-10 h-10', icon.color)} aria-hidden="true" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-secondary-900 truncate" title={file.name}>{file.name}</p>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-secondary-500">
|
||||
<span>{formatFileSize(file.size_bytes ?? file.size ?? 0)}</span>
|
||||
{file.mime_type && <span className="truncate">{file.mime_type.split('/')[1]?.toUpperCase()}</span>}
|
||||
</div>
|
||||
<div className="mt-3 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
sortedFiles.map((file) => {
|
||||
const icon = getFileIcon(file.mime_type);
|
||||
const isSelected = selectedFileIds.has(file.id);
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className={clsx(
|
||||
'bg-white rounded-lg border p-3 motion-safe:transition-all cursor-pointer overflow-hidden flex flex-col',
|
||||
isSelected ? 'border-primary-500 ring-2 ring-primary-200' : 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm'
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
data-testid={`file-card-${file.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-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')}
|
||||
/>
|
||||
<icon.icon className={clsx('w-10 h-10', icon.color)} aria-hidden="true" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-secondary-900 truncate" title={file.name}>{file.name}</p>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-secondary-500">
|
||||
<span>{formatFileSize(file.size_bytes ?? file.size ?? 0)}</span>
|
||||
{file.mime_type && <span className="truncate">{file.mime_type.split('/')[1]?.toUpperCase()}</span>}
|
||||
</div>
|
||||
<div className="mt-3 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>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-secondary-900 truncate" title={file.name}>{file.name}</p>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-secondary-500">
|
||||
<span>{formatFileSize(file.size_bytes ?? file.size ?? 0)}</span>
|
||||
{file.mime_type && <span className="truncate">{file.mime_type.split('/')[1]?.toUpperCase()}</span>}
|
||||
</div>
|
||||
<div className="mt-3 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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user