feat: mail phase 1 - HTML iframe display, bulk read/unread, IMAP flag sync
- Fix FlagUpdatePayload field names to match backend schema (is_seen/is_flagged) - Replace dangerouslySetInnerHTML with sandboxed iframe (srcDoc, sandbox="") - Add bulk mark read/unread with checkbox selection in MailList - Add floating bulk action bar in Mail.tsx - Add imap_sync_mail_flags() to push Seen/Flagged flags to IMAP server - Call IMAP flag sync in PATCH /flags endpoint - Add i18n keys for bulk actions (de/en)
This commit is contained in:
@@ -19,6 +19,9 @@ export interface MailListProps {
|
||||
total: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
selectedMailIds: Set<string>;
|
||||
onToggleSelect: (mailId: string) => void;
|
||||
onSelectAll: () => void;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
@@ -39,9 +42,13 @@ export function MailList({
|
||||
total,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
selectedMailIds,
|
||||
onToggleSelect,
|
||||
onSelectAll,
|
||||
}: MailListProps) {
|
||||
const { t } = useTranslation();
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -67,56 +74,84 @@ export function MailList({
|
||||
|
||||
return (
|
||||
<div data-testid="mail-list" role="listbox" aria-label={t('mail.selectMail')}>
|
||||
{/* Select-all header */}
|
||||
<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">
|
||||
<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>
|
||||
<ul className="divide-y divide-secondary-100" role="list">
|
||||
{mails.map((mail) => (
|
||||
<li key={mail.id} role="listitem">
|
||||
<button
|
||||
onClick={() => onSelectMail(mail)}
|
||||
className={clsx(
|
||||
'w-full text-left px-3 py-2 md:px-4 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 && (
|
||||
<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>
|
||||
)}
|
||||
<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')}>
|
||||
{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-xs md:text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
|
||||
{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>
|
||||
<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>
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
<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')}>
|
||||
{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-xs md:text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}>
|
||||
{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>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user