2026-07-01 20:43:49 +02:00
|
|
|
/**
|
|
|
|
|
* Mail list component with pagination.
|
|
|
|
|
* Shows list of mails in selected folder with seen/flagged indicators.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import React from 'react';
|
|
|
|
|
import clsx from 'clsx';
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
import type { Mail } from '@/api/mail';
|
2026-07-16 09:40:59 +02:00
|
|
|
import { decodeMimeHeader } from '@/api/mail';
|
2026-07-01 20:43:49 +02:00
|
|
|
import { EmptyState } from '@/components/ui/EmptyState';
|
|
|
|
|
import { Pagination } from '@/components/ui/Pagination';
|
|
|
|
|
|
|
|
|
|
export interface MailListProps {
|
|
|
|
|
mails: Mail[];
|
|
|
|
|
selectedMailId: string | null;
|
|
|
|
|
onSelectMail: (mail: Mail) => void;
|
|
|
|
|
loading: boolean;
|
|
|
|
|
currentPage: number;
|
|
|
|
|
total: number;
|
|
|
|
|
pageSize: number;
|
|
|
|
|
onPageChange: (page: number) => void;
|
2026-07-15 19:26:37 +02:00
|
|
|
selectedMailIds: Set<string>;
|
|
|
|
|
onToggleSelect: (mailId: string) => void;
|
|
|
|
|
onSelectAll: () => void;
|
2026-07-15 20:17:27 +02:00
|
|
|
sortBy?: 'date' | 'from' | 'subject';
|
|
|
|
|
sortOrder?: 'asc' | 'desc';
|
|
|
|
|
onSortChange?: (sortBy: 'date' | 'from' | 'subject', sortOrder: 'asc' | 'desc') => void;
|
2026-07-01 20:43:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDate(dateStr: string): string {
|
|
|
|
|
const date = new Date(dateStr);
|
|
|
|
|
const now = new Date();
|
|
|
|
|
if (date.toDateString() === now.toDateString()) {
|
|
|
|
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
|
|
|
}
|
|
|
|
|
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function MailList({
|
|
|
|
|
mails,
|
|
|
|
|
selectedMailId,
|
|
|
|
|
onSelectMail,
|
|
|
|
|
loading,
|
|
|
|
|
currentPage,
|
|
|
|
|
total,
|
|
|
|
|
pageSize,
|
|
|
|
|
onPageChange,
|
2026-07-15 19:26:37 +02:00
|
|
|
selectedMailIds,
|
|
|
|
|
onToggleSelect,
|
|
|
|
|
onSelectAll,
|
2026-07-15 20:17:27 +02:00
|
|
|
sortBy = 'date',
|
|
|
|
|
sortOrder = 'desc',
|
|
|
|
|
onSortChange,
|
2026-07-01 20:43:49 +02:00
|
|
|
}: MailListProps) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const totalPages = Math.ceil(total / pageSize);
|
2026-07-15 19:26:37 +02:00
|
|
|
const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id));
|
2026-07-01 20:43:49 +02:00
|
|
|
|
2026-07-15 20:17:27 +02:00
|
|
|
const handleSortFieldChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
|
|
|
const newSortBy = e.target.value as 'date' | 'from' | 'subject';
|
|
|
|
|
onSortChange?.(newSortBy, sortOrder);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSortOrderToggle = () => {
|
|
|
|
|
const newOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
|
|
|
|
onSortChange?.(sortBy, newOrder);
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-01 20:43:49 +02:00
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center justify-center py-12" data-testid="mail-list-loading">
|
|
|
|
|
<svg className="animate-spin h-5 w-5 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-sm text-secondary-500">{t('common.loading')}</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mails.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<EmptyState
|
|
|
|
|
title={t('mail.noMails')}
|
|
|
|
|
description={t('mail.noMailsDesc')}
|
|
|
|
|
data-testid="mail-list-empty"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-16 09:40:59 +02:00
|
|
|
<div className="flex flex-col h-full" data-testid="mail-list" role="listbox" aria-label={t('mail.selectMail')}>
|
2026-07-15 19:26:37 +02:00
|
|
|
{/* Select-all header */}
|
2026-07-16 09:40:59 +02:00
|
|
|
<div className="flex items-center gap-2 px-3 py-2 md:px-4 md:py-2 border-b border-secondary-200 bg-secondary-50 flex-shrink-0">
|
2026-07-15 19:26:37 +02:00
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={allSelected}
|
|
|
|
|
onChange={onSelectAll}
|
|
|
|
|
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
|
|
|
|
aria-label={t('mail.selectAll')}
|
|
|
|
|
data-testid="mail-select-all"
|
|
|
|
|
/>
|
|
|
|
|
{selectedMailIds.size > 0 && (
|
|
|
|
|
<span className="text-xs text-secondary-600">
|
|
|
|
|
{t('mail.selectedCount', { count: selectedMailIds.size })}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-07-15 20:17:27 +02:00
|
|
|
{/* Sort header */}
|
|
|
|
|
{onSortChange && (
|
2026-07-16 09:40:59 +02:00
|
|
|
<div className="flex items-center gap-2 px-3 py-1.5 md:px-4 border-b border-secondary-200 bg-secondary-50 flex-shrink-0" data-testid="mail-sort-bar">
|
2026-07-15 20:17:27 +02:00
|
|
|
<label htmlFor="mail-sort-by" className="text-xs text-secondary-600">
|
|
|
|
|
{t('mail.sortBy')}:
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
id="mail-sort-by"
|
|
|
|
|
value={sortBy}
|
|
|
|
|
onChange={handleSortFieldChange}
|
|
|
|
|
className="text-xs rounded border border-secondary-300 bg-white px-1.5 py-0.5 text-secondary-700 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
|
|
|
aria-label={t('mail.sortBy')}
|
|
|
|
|
data-testid="mail-sort-by"
|
|
|
|
|
>
|
|
|
|
|
<option value="date">{t('mail.sortDate')}</option>
|
|
|
|
|
<option value="from">{t('mail.sortFrom')}</option>
|
|
|
|
|
<option value="subject">{t('mail.sortSubject')}</option>
|
|
|
|
|
</select>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSortOrderToggle}
|
|
|
|
|
className="inline-flex items-center gap-1 text-xs rounded border border-secondary-300 bg-white px-1.5 py-0.5 text-secondary-700 hover:bg-secondary-100 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
|
|
|
aria-label={sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}
|
|
|
|
|
title={sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}
|
|
|
|
|
data-testid="mail-sort-order"
|
|
|
|
|
>
|
|
|
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
|
|
|
{sortOrder === 'asc'
|
|
|
|
|
? <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
|
|
|
|
: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />}
|
|
|
|
|
</svg>
|
|
|
|
|
<span>{sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-07-16 09:40:59 +02:00
|
|
|
{/* Mail list — scrolls, pagination stays fixed */}
|
|
|
|
|
<ul className="divide-y divide-secondary-100 flex-1 overflow-y-auto" role="list">
|
2026-07-01 20:43:49 +02:00
|
|
|
{mails.map((mail) => (
|
2026-07-15 19:26:37 +02:00
|
|
|
<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',
|
2026-07-01 20:43:49 +02:00
|
|
|
)}
|
2026-07-15 19:26:37 +02:00
|
|
|
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 && (
|
|
|
|
|
<svg className="w-4 h-4 text-warning-500 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24" aria-label={t('mail.flagged')}>
|
|
|
|
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
|
|
|
|
{mail.has_attachments && (
|
|
|
|
|
<svg className="w-4 h-4 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 13l-3.586 3.586a2 2 0 01-2.828 0L5 12.828a2 2 0 010-2.828l5.657-5.657a2 2 0 012.828 0L17 6.343M14.828 8.172a2 2 0 00-2.828 0l-3.586 3.586a2 2 0 000 2.828l1.414 1.414a2 2 0 002.828 0" />
|
|
|
|
|
</svg>
|
2026-07-01 20:43:49 +02:00
|
|
|
)}
|
2026-07-15 19:26:37 +02:00
|
|
|
<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')}>
|
2026-07-16 09:40:59 +02:00
|
|
|
{decodeMimeHeader(mail.from_name) || mail.from_address}
|
2026-07-15 19:26:37 +02:00
|
|
|
</span>
|
|
|
|
|
<span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p className={clsx('text-xs md:text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
|
2026-07-16 09:40:59 +02:00
|
|
|
{decodeMimeHeader(mail.subject) || t('mail.noSubject')}
|
2026-07-15 19:26:37 +02:00
|
|
|
</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>
|
2026-07-01 20:43:49 +02:00
|
|
|
</div>
|
2026-07-15 19:26:37 +02:00
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-07-01 20:43:49 +02:00
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
{totalPages > 1 && (
|
2026-07-16 09:40:59 +02:00
|
|
|
<div className="flex-shrink-0">
|
|
|
|
|
<Pagination
|
|
|
|
|
currentPage={currentPage}
|
|
|
|
|
totalPages={totalPages}
|
|
|
|
|
total={total}
|
|
|
|
|
pageSize={pageSize}
|
|
|
|
|
onPageChange={onPageChange}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-07-01 20:43:49 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|