Phase 2: Code-Splitting + Virtual Scrolling (Tasks 2.1-2.7)

This commit is contained in:
Agent Zero
2026-07-23 12:00:34 +02:00
parent 0c14b06b67
commit a8331fbc2b
10 changed files with 1165 additions and 387 deletions
+196 -107
View File
@@ -1,6 +1,7 @@
import React from 'react';
import React, { useRef } from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Badge } from '@/components/ui/Badge';
import { Pagination } from '@/components/ui/Pagination';
import { EmptyState } from '@/components/ui/EmptyState';
@@ -75,6 +76,18 @@ export function ContactList({
onSortChange,
}: ContactListProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-skip virtualization for small datasets
const shouldVirtualize = contacts.length >= 50;
const rowVirtualizer = useVirtualizer({
count: shouldVirtualize ? contacts.length : 0,
getScrollElement: () => scrollRef.current,
estimateSize: () => viewMode === 'cards' ? 120 : 56,
overscan: 8,
enabled: shouldVirtualize,
});
if (loading) {
return (
@@ -105,43 +118,67 @@ export function ContactList({
// ── List View ──
if (viewMode === 'list') {
const renderContactItem = (contact: UnifiedContact) => (
<li key={contact.id}>
<button
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', contact.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => onSelectContact(contact)}
className={clsx(
'flex items-center gap-3 w-full px-3 py-2.5 text-left min-h-touch transition-colors',
selectedContactId === contact.id
? 'bg-primary-50'
: 'hover:bg-secondary-50',
)}
aria-current={selectedContactId === contact.id ? 'true' : undefined}
>
<div className="w-9 h-9 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold text-sm flex-shrink-0">
{getInitials(contact)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
<TypeBadge type={contact.type} />
</div>
<div className="text-xs text-secondary-500 truncate">
{getEmail(contact)} · {getCity(contact)}
</div>
</div>
</button>
</li>
);
return (
<div className="flex flex-col h-full" data-testid="contact-list-view">
<div className="flex-1 overflow-y-auto">
<ul className="divide-y divide-secondary-100" role="list">
{contacts.map((contact) => (
<li key={contact.id}>
<button
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', contact.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => onSelectContact(contact)}
className={clsx(
'flex items-center gap-3 w-full px-3 py-2.5 text-left min-h-touch transition-colors',
selectedContactId === contact.id
? 'bg-primary-50'
: 'hover:bg-secondary-50',
)}
aria-current={selectedContactId === contact.id ? 'true' : undefined}
>
<div className="w-9 h-9 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold text-sm flex-shrink-0">
{getInitials(contact)}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{shouldVirtualize ? (
<ul className="divide-y divide-secondary-100" role="list" style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const contact = contacts[virtualRow.index];
return (
<div
key={contact.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderContactItem(contact)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
<TypeBadge type={contact.type} />
</div>
<div className="text-xs text-secondary-500 truncate">
{getEmail(contact)} · {getCity(contact)}
</div>
</div>
</button>
</li>
))}
</ul>
);
})}
</ul>
) : (
<ul className="divide-y divide-secondary-100" role="list">
{contacts.map((contact) => renderContactItem(contact))}
</ul>
)}
</div>
<Pagination
currentPage={currentPage}
@@ -160,11 +197,45 @@ export function ContactList({
if (sortBy !== field) return null;
return sortOrder === 'asc' ? ' ▲' : ' ▼';
};
const renderContactRow = (contact: UnifiedContact) => (
<tr
key={contact.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', contact.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => onSelectContact(contact)}
className={clsx(
'cursor-pointer transition-colors',
selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50',
)}
aria-current={selectedContactId === contact.id ? 'true' : undefined}
>
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" className="rounded border-secondary-300" aria-label={getDisplayName(contact)} />
</td>
<td className="px-3 py-2"><TypeBadge type={contact.type} /></td>
<td className="px-3 py-2 font-medium text-secondary-900">{getDisplayName(contact)}</td>
<td className="px-3 py-2 text-secondary-600">{getEmail(contact)}</td>
<td className="px-3 py-2 text-secondary-600">{getPhone(contact)}</td>
<td className="px-3 py-2 text-secondary-600">{getCity(contact)}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{getTags(contact).slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary">{tag}</Badge>
))}
</div>
</td>
</tr>
);
return (
<div className="flex flex-col h-full" data-testid="contact-table-view">
<div className="flex-1 overflow-auto">
<div ref={scrollRef} className="flex-1 overflow-auto" style={shouldVirtualize ? { maxHeight: '70vh' } : undefined}>
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white border-b border-secondary-200">
<thead className="sticky top-0 bg-white border-b border-secondary-200 z-10">
<tr>
<th className="px-3 py-2 text-left font-medium text-secondary-600 w-10">
<input type="checkbox" className="rounded border-secondary-300" aria-label={t('common.all')} />
@@ -202,38 +273,30 @@ export function ContactList({
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{contacts.map((contact) => (
<tr
key={contact.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', contact.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => onSelectContact(contact)}
className={clsx(
'cursor-pointer transition-colors',
selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50',
)}
aria-current={selectedContactId === contact.id ? 'true' : undefined}
>
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" className="rounded border-secondary-300" aria-label={getDisplayName(contact)} />
</td>
<td className="px-3 py-2"><TypeBadge type={contact.type} /></td>
<td className="px-3 py-2 font-medium text-secondary-900">{getDisplayName(contact)}</td>
<td className="px-3 py-2 text-secondary-600">{getEmail(contact)}</td>
<td className="px-3 py-2 text-secondary-600">{getPhone(contact)}</td>
<td className="px-3 py-2 text-secondary-600">{getCity(contact)}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{getTags(contact).slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary">{tag}</Badge>
))}
</div>
</td>
</tr>
))}
{shouldVirtualize ? (
<>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const contact = contacts[virtualRow.index];
return (
<React.Fragment key={contact.id}>
{virtualRow.index === 0 && (
<tr style={{ height: virtualRow.start }}>
<td colSpan={7} style={{ padding: 0, border: 'none' }} />
</tr>
)}
{renderContactRow(contact)}
{virtualRow.index === rowVirtualizer.getVirtualItems().length - 1 && (
<tr style={{ height: rowVirtualizer.getTotalSize() - virtualRow.end }}>
<td colSpan={7} style={{ padding: 0, border: 'none' }} />
</tr>
)}
</React.Fragment>
);
})}
</>
) : (
contacts.map((contact) => renderContactRow(contact))
)}
</tbody>
</table>
</div>
@@ -249,48 +312,74 @@ export function ContactList({
}
// ── Cards View ──
const renderContactCard = (contact: UnifiedContact) => (
<div
key={contact.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', contact.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => onSelectContact(contact)}
className={clsx(
'p-3 rounded-lg border cursor-pointer transition-colors min-h-touch',
selectedContactId === contact.id
? 'border-primary-300 bg-primary-50'
: 'border-secondary-200 bg-white hover:bg-secondary-50',
)}
role="button"
tabIndex={0}
aria-current={selectedContactId === contact.id ? 'true' : undefined}
>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold flex-shrink-0">
{getInitials(contact)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
</div>
<TypeBadge type={contact.type} />
</div>
</div>
<div className="text-xs text-secondary-500 space-y-0.5">
<div className="truncate">{getEmail(contact)}</div>
<div className="truncate">{getPhone(contact)}</div>
<div className="truncate">{getCity(contact)}</div>
</div>
</div>
);
return (
<div className="flex flex-col h-full" data-testid="contact-cards-view">
<div className="flex-1 overflow-y-auto p-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{contacts.map((contact) => (
<div
key={contact.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', contact.id);
e.dataTransfer.effectAllowed = 'move';
}}
onClick={() => onSelectContact(contact)}
className={clsx(
'p-3 rounded-lg border cursor-pointer transition-colors min-h-touch',
selectedContactId === contact.id
? 'border-primary-300 bg-primary-50'
: 'border-secondary-200 bg-white hover:bg-secondary-50',
)}
role="button"
tabIndex={0}
aria-current={selectedContactId === contact.id ? 'true' : undefined}
>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold flex-shrink-0">
{getInitials(contact)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3">
{shouldVirtualize ? (
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3" style={{ position: 'absolute', top: 0, left: 0, right: 0 }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const contact = contacts[virtualRow.index];
return (
<div
key={contact.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderContactCard(contact)}
</div>
<TypeBadge type={contact.type} />
</div>
</div>
<div className="text-xs text-secondary-500 space-y-0.5">
<div className="truncate">{getEmail(contact)}</div>
<div className="truncate">{getPhone(contact)}</div>
<div className="truncate">{getCity(contact)}</div>
</div>
);
})}
</div>
))}
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{contacts.map((contact) => renderContactCard(contact))}
</div>
)}
</div>
<Pagination
currentPage={currentPage}
+134 -73
View File
@@ -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>
+153 -60
View File
@@ -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>
);
}
+121 -70
View File
@@ -1,11 +1,12 @@
/**
* Mail list component with pagination.
* Mail list component with pagination and virtual scrolling.
* Shows list of mails in selected folder with seen/flagged indicators.
*/
import React from 'react';
import React, { useRef } from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { Mail } from '@/api/mail';
import { decodeMimeHeader } from '@/api/mail';
import { EmptyState } from '@/components/ui/EmptyState';
@@ -53,6 +54,18 @@ export function MailList({
const { t } = useTranslation();
const totalPages = Math.ceil(total / pageSize);
const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id));
const scrollRef = useRef<HTMLUListElement>(null);
// Auto-skip virtualization for small datasets
const shouldVirtualize = mails.length >= 50;
const rowVirtualizer = useVirtualizer({
count: shouldVirtualize ? mails.length : 0,
getScrollElement: () => scrollRef.current,
estimateSize: () => 80, // approx mail item height
overscan: 8,
enabled: shouldVirtualize,
});
const handleSortFieldChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newSortBy = e.target.value as 'date' | 'from' | 'subject';
@@ -83,6 +96,75 @@ export function MailList({
);
}
const renderMailItem = (mail: Mail) => (
<li key={mail.id} role="listitem" className={clsx(selectedMailIds.has(mail.id) && 'bg-primary-50')}>
<div className="flex items-start gap-2">
<div className="flex items-center pt-3 pl-3 md:pl-4">
<input
type="checkbox"
checked={selectedMailIds.has(mail.id)}
onChange={() => onToggleSelect(mail.id)}
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={t('mail.selectMail')}
data-testid={`mail-checkbox-${mail.id}`}
/>
</div>
<button
onClick={() => onSelectMail(mail)}
className={clsx(
'w-full text-left px-1 py-2 md:px-2 md:py-3 hover:bg-secondary-50 min-h-touch motion-safe:transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
mail.id === selectedMailId && 'bg-primary-50',
!mail.is_seen && 'font-semibold',
)}
aria-selected={mail.id === selectedMailId}
data-testid={`mail-item-${mail.id}`}
>
<div className="flex items-center gap-2">
{!mail.is_seen && (
<span className="w-2 h-2 rounded-full bg-primary-500 flex-shrink-0" aria-label={t('mail.unread')} />
)}
{mail.is_flagged && (
<span className="flex-shrink-0" aria-label={t('mail.flagged')}>
{mail.flag_type === 'star' && <span className="text-sm"></span>}
{mail.flag_type === 'flag' && <span className="text-sm">🚩</span>}
{mail.flag_type === 'bookmark' && <span className="text-sm">🔖</span>}
{mail.flag_type === 'important' && <span className="text-sm"></span>}
{mail.flag_type === 'question' && <span className="text-sm"></span>}
{(!mail.flag_type || mail.flag_type === '') && (
<Star className="w-4 h-4 text-warning-500" fill="currentColor" />
)}
</span>
)}
{mail.has_attachments && (
<Paperclip className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className={clsx('text-sm truncate', !mail.is_seen ? 'text-secondary-900 font-semibold' : 'text-secondary-700')}>
{decodeMimeHeader(mail.from_name) || mail.from_address}
</span>
<span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span>
</div>
<p className={clsx('text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
{decodeMimeHeader(mail.subject) || t('mail.noSubject')}
</p>
{mail.labels && mail.labels.length > 0 && (
<div className="flex gap-1 mt-1">
{mail.labels.map((label) => (
<span key={label.id} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs text-white" style={{ backgroundColor: label.color }}>
{label.name}
</span>
))}
</div>
)}
</div>
</div>
</button>
</div>
</li>
);
return (
<div className="flex flex-col h-full" data-testid="mail-list" role="listbox" aria-label={t('mail.selectMail')}>
{/* Select-all + Sort header in one row */}
@@ -133,76 +215,45 @@ export function MailList({
)}
</div>
{/* Mail list — scrolls, pagination stays fixed */}
<ul className="divide-y divide-secondary-100 flex-1 overflow-y-auto" role="list">
{mails.map((mail) => (
<li key={mail.id} role="listitem" className={clsx(selectedMailIds.has(mail.id) && 'bg-primary-50')}>
<div className="flex items-start gap-2">
<div className="flex items-center pt-3 pl-3 md:pl-4">
<input
type="checkbox"
checked={selectedMailIds.has(mail.id)}
onChange={() => onToggleSelect(mail.id)}
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={t('mail.selectMail')}
data-testid={`mail-checkbox-${mail.id}`}
/>
</div>
<button
onClick={() => onSelectMail(mail)}
className={clsx(
'w-full text-left px-1 py-2 md:px-2 md:py-3 hover:bg-secondary-50 min-h-touch motion-safe:transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
mail.id === selectedMailId && 'bg-primary-50',
!mail.is_seen && 'font-semibold',
)}
aria-selected={mail.id === selectedMailId}
data-testid={`mail-item-${mail.id}`}
>
<div className="flex items-center gap-2">
{!mail.is_seen && (
<span className="w-2 h-2 rounded-full bg-primary-500 flex-shrink-0" aria-label={t('mail.unread')} />
)}
{mail.is_flagged && (
<span className="flex-shrink-0" aria-label={t('mail.flagged')}>
{mail.flag_type === 'star' && <span className="text-sm"></span>}
{mail.flag_type === 'flag' && <span className="text-sm">🚩</span>}
{mail.flag_type === 'bookmark' && <span className="text-sm">🔖</span>}
{mail.flag_type === 'important' && <span className="text-sm"></span>}
{mail.flag_type === 'question' && <span className="text-sm"></span>}
{(!mail.flag_type || mail.flag_type === '') && (
<Star className="w-4 h-4 text-warning-500" fill="currentColor" />
)}
</span>
)}
{mail.has_attachments && (
<Paperclip className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className={clsx('text-sm truncate', !mail.is_seen ? 'text-secondary-900 font-semibold' : 'text-secondary-700')}>
{decodeMimeHeader(mail.from_name) || mail.from_address}
</span>
<span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span>
</div>
<p className={clsx('text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
{decodeMimeHeader(mail.subject) || t('mail.noSubject')}
</p>
{mail.labels && mail.labels.length > 0 && (
<div className="flex gap-1 mt-1">
{mail.labels.map((label) => (
<span key={label.id} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs text-white" style={{ backgroundColor: label.color }}>
{label.name}
</span>
))}
</div>
)}
</div>
{shouldVirtualize ? (
<ul
ref={scrollRef}
className="divide-y divide-secondary-100 flex-1 overflow-y-auto"
role="list"
style={{ contain: 'strict' }}
>
<li
key="__virtual-spacer"
style={{
height: rowVirtualizer.getTotalSize(),
position: 'relative',
width: '100%',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const mail = mails[virtualRow.index];
return (
<div
key={mail.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderMailItem(mail)}
</div>
</button>
</div>
);
})}
</li>
))}
</ul>
</ul>
) : (
<ul className="divide-y divide-secondary-100 flex-1 overflow-y-auto" role="list">
{mails.map((mail) => renderMailItem(mail))}
</ul>
)}
{totalPages > 1 && (
<div className="flex-shrink-0">
<Pagination
+74 -24
View File
@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useRef, useCallback } from 'react';
import {
useReactTable,
getCoreRowModel,
@@ -9,6 +9,7 @@ import {
SortingState,
ColumnFiltersState,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import { Pagination } from '@/components/ui/Pagination';
import { useTranslation } from 'react-i18next';
@@ -30,6 +31,7 @@ export interface DataGridProps<T> {
globalFilter?: string;
onGlobalFilterChange?: (value: string) => void;
testId?: string;
virtualized?: boolean;
}
export function DataGrid<T extends Record<string, any>>({
@@ -44,9 +46,11 @@ export function DataGrid<T extends Record<string, any>>({
loading = false,
enableSorting = true,
testId,
virtualized = true,
}: DataGridProps<T>) {
const { t } = useTranslation();
const [sorting, setSorting] = useState<SortingState>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const table = useReactTable({
data,
@@ -60,11 +64,59 @@ export function DataGrid<T extends Record<string, any>>({
const totalPages = Math.ceil(total / pageSize);
// Auto-skip virtualization for small datasets
const shouldVirtualize = virtualized && data.length >= 50;
const rows = table.getRowModel().rows;
const rowVirtualizer = useVirtualizer({
count: shouldVirtualize ? rows.length : 0,
getScrollElement: () => scrollRef.current,
estimateSize: () => 53, // approx row height
overscan: 10,
enabled: shouldVirtualize,
});
const virtualRows = shouldVirtualize ? rowVirtualizer.getVirtualItems() : [];
const totalHeight = shouldVirtualize ? rowVirtualizer.getTotalSize() : 0;
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom = virtualRows.length > 0 ? totalHeight - virtualRows[virtualRows.length - 1].end : 0;
const renderRow = useCallback(
(row: typeof rows[number]) => (
<tr
key={row.id}
className={clsx(
'hover:bg-secondary-50 motion-safe:transition-colors',
onRowClick && 'cursor-pointer'
)}
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={onRowClick ? (e) => {
if (e.key === 'Enter') onRowClick(row.original);
} : undefined}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 text-sm text-secondary-900">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
),
[onRowClick]
);
return (
<div className="bg-white rounded-lg shadow-sm border border-secondary-200 overflow-hidden" data-testid={testId}>
<div className="overflow-x-auto" role="region" aria-label="Data grid">
<div
ref={scrollRef}
className="overflow-x-auto"
role="region"
aria-label="Data grid"
style={shouldVirtualize ? { maxHeight: '70vh', overflowY: 'auto' } : undefined}
>
<table className="min-w-full divide-y divide-secondary-200">
<thead className="bg-secondary-50">
<thead className="bg-secondary-50 sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
@@ -114,33 +166,31 @@ export function DataGrid<T extends Record<string, any>>({
</span>
</td>
</tr>
) : table.getRowModel().rows.length === 0 ? (
) : rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8 text-center text-secondary-500">
{emptyMessage || t('table.empty')}
</td>
</tr>
) : shouldVirtualize ? (
<>
{paddingTop > 0 && (
<tr key="__padding-top" style={{ height: paddingTop }}>
<td colSpan={columns.length} style={{ padding: 0, border: 'none' }} />
</tr>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return renderRow(row);
})}
{paddingBottom > 0 && (
<tr key="__padding-bottom" style={{ height: paddingBottom }}>
<td colSpan={columns.length} style={{ padding: 0, border: 'none' }} />
</tr>
)}
</>
) : (
table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className={clsx(
'hover:bg-secondary-50 motion-safe:transition-colors',
onRowClick && 'cursor-pointer'
)}
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={onRowClick ? (e) => {
if (e.key === 'Enter') onRowClick(row.original);
} : undefined}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-6 py-4 text-sm text-secondary-900">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))
rows.map((row) => renderRow(row))
)}
</tbody>
</table>