feat: mobile-responsive mail plugin UI

This commit is contained in:
Agent Zero
2026-07-15 17:53:56 +02:00
parent 57b6df5357
commit de74429f4e
7 changed files with 127 additions and 38 deletions
@@ -26,9 +26,9 @@ function ToolbarSearch({ item }: { item: ToolbarItem }) {
type="text" type="text"
placeholder={item.searchPlaceholder || 'Suchen...'} placeholder={item.searchPlaceholder || 'Suchen...'}
onChange={(e) => item.onSearch?.(e.target.value)} onChange={(e) => item.onSearch?.(e.target.value)}
className="w-40 px-2 py-1 text-xs border border-secondary-200 rounded className="w-28 sm:w-40 px-2 py-1 text-xs border border-secondary-200 rounded
focus:outline-none focus:border-primary-400 focus:ring-1 focus:ring-primary-300 focus:outline-none focus:border-primary-400 focus:ring-1 focus:ring-primary-300
placeholder:text-secondary-400" placeholder:text-secondary-400 flex-shrink-0"
/> />
); );
} }
@@ -38,9 +38,9 @@ function ToolbarSelect({ item }: { item: ToolbarItem }) {
<select <select
value={item.selectValue || ''} value={item.selectValue || ''}
onChange={(e) => item.onSelect?.(e.target.value)} onChange={(e) => item.onSelect?.(e.target.value)}
className="px-2 py-1 text-xs border border-secondary-200 rounded className="px-1.5 sm:px-2 py-1 text-xs border border-secondary-200 rounded
focus:outline-none focus:border-primary-400 focus:ring-1 focus:ring-primary-300 focus:outline-none focus:border-primary-400 focus:ring-1 focus:ring-primary-300
bg-white cursor-pointer" bg-white cursor-pointer flex-shrink-0 max-w-[120px] sm:max-w-none"
> >
{item.selectOptions?.map((opt) => ( {item.selectOptions?.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option> <option key={opt.value} value={opt.value}>{opt.label}</option>
@@ -66,7 +66,7 @@ export function PluginToolbar() {
return ( return (
<div <div
className="flex items-center gap-1 px-3 py-1 bg-white border-b border-secondary-200 min-h-[36px]" className="flex items-center gap-1 px-2 sm:px-3 py-1 bg-white border-b border-secondary-200 min-h-[36px] overflow-x-auto"
data-testid="plugin-toolbar" data-testid="plugin-toolbar"
> >
{groupNames.map((groupName, gi) => ( {groupNames.map((groupName, gi) => (
@@ -151,7 +151,7 @@ export function ComposeModal({
const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : t('mail.compose'); const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : t('mail.compose');
return ( return (
<Modal open={open} onClose={onClose} title={title} size="xl" closeOnBackdrop={false}> <Modal open={open} onClose={onClose} title={title} size="xl" closeOnBackdrop={false} fullScreenMobile>
<div className="space-y-4" data-testid="compose-modal"> <div className="space-y-4" data-testid="compose-modal">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center gap-1 border-b border-secondary-200 pb-2" data-testid="compose-toolbar"> <div className="flex items-center gap-1 border-b border-secondary-200 pb-2" data-testid="compose-toolbar">
@@ -225,7 +225,7 @@ export function ComposeModal({
{t('mail.showCcBcc')} {t('mail.showCcBcc')}
</button> </button>
) : ( ) : (
<div className="w-full grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="w-full grid grid-cols-1 sm:grid-cols-2 gap-2">
<Input <Input
label={t('mail.cc')} label={t('mail.cc')}
value={cc} value={cc}
@@ -278,7 +278,7 @@ export function ComposeModal({
/> />
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t border-secondary-200"> <div className="flex justify-end gap-2 pt-2 border-t border-secondary-200 sticky bottom-0 bg-white py-3">
<Button variant="secondary" onClick={onClose} type="button"> <Button variant="secondary" onClick={onClose} type="button">
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
+19 -19
View File
@@ -91,7 +91,7 @@ export function MailDetail({
return ( return (
<div className="flex flex-col h-full" data-testid="mail-detail"> <div className="flex flex-col h-full" data-testid="mail-detail">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-secondary-200" data-testid="mail-detail-toolbar"> <div className="flex items-center gap-1 sm:gap-2 px-3 py-2 md:px-4 md:py-3 border-b border-secondary-200 overflow-x-auto" data-testid="mail-detail-toolbar">
<Button variant="secondary" size="sm" onClick={() => onReply(mail)} icon={ <Button variant="secondary" size="sm" onClick={() => onReply(mail)} icon={
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
@@ -135,9 +135,9 @@ export function MailDetail({
</div> </div>
{/* Headers */} {/* Headers */}
<div className="px-4 py-3 border-b border-secondary-200" data-testid="mail-detail-headers"> <div className="px-3 py-2 md:px-4 md:py-3 border-b border-secondary-200" data-testid="mail-detail-headers">
<div className="flex items-start justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 sm:gap-4">
<h2 className="text-lg font-semibold text-secondary-900 flex-1">{mail.subject || t('mail.noSubject')}</h2> <h2 className="text-base md:text-lg font-semibold text-secondary-900 flex-1">{mail.subject || t('mail.noSubject')}</h2>
{mail.labels && mail.labels.length > 0 && ( {mail.labels && mail.labels.length > 0 && (
<div className="flex gap-1 flex-shrink-0"> <div className="flex gap-1 flex-shrink-0">
{mail.labels.map((label) => ( {mail.labels.map((label) => (
@@ -148,30 +148,30 @@ export function MailDetail({
</div> </div>
)} )}
</div> </div>
<div className="mt-2 space-y-1 text-sm text-secondary-600"> <div className="mt-2 space-y-1 text-xs md:text-sm text-secondary-600">
<div className="flex gap-2"> <div className="flex flex-col sm:flex-row sm:gap-2">
<span className="font-medium text-secondary-500">{t('mail.from')}:</span> <span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.from')}:</span>
<span>{mail.from_name ? `${mail.from_name} <${mail.from_address}>` : mail.from_address}</span> <span className="break-words">{mail.from_name ? `${mail.from_name} <${mail.from_address}>` : mail.from_address}</span>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-col sm:flex-row sm:gap-2">
<span className="font-medium text-secondary-500">{t('mail.to')}:</span> <span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.to')}:</span>
<span>{mail.to_addresses.join(', ')}</span> <span className="break-words">{mail.to_addresses.join(', ')}</span>
</div> </div>
{mail.cc_addresses.length > 0 && ( {mail.cc_addresses.length > 0 && (
<div className="flex gap-2"> <div className="flex flex-col sm:flex-row sm:gap-2">
<span className="font-medium text-secondary-500">{t('mail.cc')}:</span> <span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.cc')}:</span>
<span>{mail.cc_addresses.join(', ')}</span> <span className="break-words">{mail.cc_addresses.join(', ')}</span>
</div> </div>
)} )}
<div className="flex gap-2"> <div className="flex flex-col sm:flex-row sm:gap-2">
<span className="font-medium text-secondary-500">{t('mail.date')}:</span> <span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.date')}:</span>
<span>{formatFullDate(mail.date)}</span> <span>{formatFullDate(mail.date)}</span>
</div> </div>
</div> </div>
</div> </div>
{/* Body */} {/* Body */}
<div className="flex-1 overflow-y-auto px-4 py-4" data-testid="mail-detail-body"> <div className="flex-1 overflow-y-auto px-3 py-3 md:px-4 md:py-4" data-testid="mail-detail-body">
{safeHtml && isSafe ? ( {safeHtml && isSafe ? (
<div <div
className="prose prose-sm max-w-none text-secondary-800" className="prose prose-sm max-w-none text-secondary-800"
@@ -190,9 +190,9 @@ export function MailDetail({
{/* Attachments */} {/* Attachments */}
{mail.attachments && mail.attachments.length > 0 && ( {mail.attachments && mail.attachments.length > 0 && (
<div className="px-4 py-3 border-t border-secondary-200" data-testid="mail-detail-attachments"> <div className="px-3 py-2 md:px-4 md:py-3 border-t border-secondary-200" data-testid="mail-detail-attachments">
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('mail.attachments')}</h3> <h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('mail.attachments')}</h3>
<ul className="space-y-2"> <ul className="space-y-1 md:space-y-2">
{mail.attachments.map((att) => ( {mail.attachments.map((att) => (
<li key={att.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-secondary-50 min-h-touch"> <li key={att.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-secondary-50 min-h-touch">
<svg className="w-5 h-5 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg className="w-5 h-5 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
@@ -147,7 +147,7 @@ function FolderNode({
<button <button
onClick={() => onSelect(folder.id)} onClick={() => onSelect(folder.id)}
className={clsx( className={clsx(
'flex-1 flex items-center gap-2 px-2 py-1.5 rounded-md text-sm min-h-touch', 'flex-1 flex items-center gap-2 px-2 py-2 md:py-1.5 rounded-md text-sm min-h-touch',
'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500', 'motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isSelected isSelected
? 'bg-primary-50 text-primary-700 font-medium' ? 'bg-primary-50 text-primary-700 font-medium'
@@ -203,7 +203,7 @@ function AccountNode({
<div role="treeitem" aria-label={account.email}> <div role="treeitem" aria-label={account.email}>
<button <button
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-1 px-1 py-1.5 rounded-md text-sm font-semibold text-secondary-900 hover:bg-secondary-50" className="w-full flex items-center gap-1 px-1 py-2 md:py-1.5 rounded-md text-sm font-semibold text-secondary-900 hover:bg-secondary-50 min-h-touch"
data-testid={`account-${account.id}`} data-testid={`account-${account.id}`}
> >
<ChevronIcon expanded={expanded} /> <ChevronIcon expanded={expanded} />
+2 -2
View File
@@ -73,7 +73,7 @@ export function MailList({
<button <button
onClick={() => onSelectMail(mail)} onClick={() => onSelectMail(mail)}
className={clsx( className={clsx(
'w-full text-left px-4 py-3 hover:bg-secondary-50 min-h-touch motion-safe:transition-colors', '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', 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
mail.id === selectedMailId && 'bg-primary-50', mail.id === selectedMailId && 'bg-primary-50',
!mail.is_seen && 'font-semibold', !mail.is_seen && 'font-semibold',
@@ -102,7 +102,7 @@ export function MailList({
</span> </span>
<span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span> <span className="text-xs text-secondary-400 flex-shrink-0">{formatDate(mail.date)}</span>
</div> </div>
<p className={clsx('text-sm truncate mt-0.5', !mail.is_seen ? 'text-secondary-800' : 'text-secondary-600')}> <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')} {mail.subject || t('mail.noSubject')}
</p> </p>
{mail.labels && mail.labels.length > 0 && ( {mail.labels && mail.labels.length > 0 && (
+15 -5
View File
@@ -10,6 +10,7 @@ export interface ModalProps {
closeOnBackdrop?: boolean; closeOnBackdrop?: boolean;
closeOnEscape?: boolean; closeOnEscape?: boolean;
showCloseButton?: boolean; showCloseButton?: boolean;
fullScreenMobile?: boolean;
} }
const sizeClasses = { const sizeClasses = {
@@ -28,6 +29,7 @@ export function Modal({
closeOnBackdrop = true, closeOnBackdrop = true,
closeOnEscape = true, closeOnEscape = true,
showCloseButton = true, showCloseButton = true,
fullScreenMobile = false,
}: ModalProps) { }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null); const previouslyFocused = useRef<HTMLElement | null>(null);
@@ -60,7 +62,10 @@ export function Modal({
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center p-4" className={clsx(
'fixed inset-0 z-50 flex items-center justify-center',
fullScreenMobile ? 'p-0 md:p-4' : 'p-4'
)}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined} aria-labelledby={title ? 'modal-title' : undefined}
@@ -76,12 +81,14 @@ export function Modal({
className={clsx( className={clsx(
'relative bg-white rounded-lg shadow-xl w-full', 'relative bg-white rounded-lg shadow-xl w-full',
'motion-safe:animate-scale', 'motion-safe:animate-scale',
fullScreenMobile && 'h-full md:h-auto md:max-h-[85vh]',
fullScreenMobile ? 'rounded-none md:rounded-lg' : '',
sizeClasses[size] sizeClasses[size]
)} )}
> >
{title && ( {title && (
<div className="px-6 py-4 border-b border-secondary-200"> <div className="px-4 py-3 md:px-6 md:py-4 border-b border-secondary-200">
<h2 id="modal-title" className="text-lg font-semibold text-secondary-900"> <h2 id="modal-title" className="text-base md:text-lg font-semibold text-secondary-900">
{title} {title}
</h2> </h2>
</div> </div>
@@ -89,7 +96,7 @@ export function Modal({
{showCloseButton && ( {showCloseButton && (
<button <button
onClick={onClose} onClick={onClose}
className="absolute top-4 right-4 text-secondary-400 hover:text-secondary-600 min-h-touch min-w-touch flex items-center justify-center rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500" className="absolute top-3 right-3 md:top-4 md:right-4 text-secondary-400 hover:text-secondary-600 min-h-touch min-w-touch flex items-center justify-center rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
aria-label="Close dialog" aria-label="Close dialog"
> >
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
@@ -97,7 +104,10 @@ export function Modal({
</svg> </svg>
</button> </button>
)} )}
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto"> <div className={clsx(
'px-4 py-4 md:px-6 overflow-y-auto',
fullScreenMobile ? 'max-h-none md:max-h-[70vh] h-full md:h-auto' : 'max-h-[70vh]'
)}>
{children} {children}
</div> </div>
</div> </div>
+81 -2
View File
@@ -69,6 +69,7 @@ export function MailPage() {
const [forwardMailState, setForwardMailState] = useState<Mail | null>(null); const [forwardMailState, setForwardMailState] = useState<Mail | null>(null);
const [downloadingAttachmentId, setDownloadingAttachmentId] = useState<string | null>(null); const [downloadingAttachmentId, setDownloadingAttachmentId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
// Load accounts // Load accounts
const loadAccounts = useCallback(async () => { const loadAccounts = useCallback(async () => {
@@ -168,6 +169,7 @@ export function MailPage() {
setMailsPage(1); setMailsPage(1);
setSearchQuery(''); setSearchQuery('');
setSelectedMail(null); setSelectedMail(null);
setActiveView('list');
}, },
[folders], [folders],
); );
@@ -175,6 +177,7 @@ export function MailPage() {
// Handle mail selection // Handle mail selection
const handleSelectMail = useCallback( const handleSelectMail = useCallback(
async (mail: Mail) => { async (mail: Mail) => {
setActiveView('detail');
setLoadingMail(true); setLoadingMail(true);
try { try {
const detail = await getMail(mail.id); const detail = await getMail(mail.id);
@@ -435,8 +438,8 @@ export function MailPage() {
</div> </div>
)} )}
{/* Three-pane layout with resizable panels */} {/* Desktop: three-pane layout with resizable panels */}
<div className="flex flex-1 overflow-hidden"> <div className="hidden md:flex flex-1 overflow-hidden">
{/* Folder tree — resizable */} {/* Folder tree — resizable */}
<ResizablePanel <ResizablePanel
initialWidth={224} initialWidth={224}
@@ -495,6 +498,82 @@ export function MailPage() {
</ResizablePanel> </ResizablePanel>
</div> </div>
{/* Mobile: single-pane view switching */}
<div className="flex md:hidden flex-1 overflow-hidden flex-col">
{/* View 1: Folder tree */}
{activeView === 'folders' && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-folder-pane">
<div className="p-3">
<MailFolderTree
accounts={accounts}
folders={folders}
selectedFolderId={selectedFolderId}
onSelect={handleSelectFolder}
loading={loadingFolders}
/>
</div>
</div>
)}
{/* View 2: Mail list */}
{activeView === 'list' && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-list-pane">
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
<button
onClick={() => setActiveView('folders')}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label="Zurück zu Ordnern"
data-testid="mobile-back-to-folders"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span className="text-sm font-medium">Ordner</span>
</button>
</div>
<MailList
mails={mails}
selectedMailId={selectedMail?.id || null}
onSelectMail={handleSelectMail}
loading={loadingMails}
currentPage={mailsPage}
total={mailsTotal}
pageSize={PAGE_SIZE}
onPageChange={setMailsPage}
/>
</div>
)}
{/* View 3: Mail detail */}
{activeView === 'detail' && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-detail-pane">
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
<button
onClick={() => setActiveView('list')}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label="Zurück zur Liste"
data-testid="mobile-back-to-list"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span className="text-sm font-medium">Zurück</span>
</button>
</div>
<MailDetail
mail={selectedMail}
loading={loadingMail}
onReply={handleReply}
onForward={handleForward}
onCreateEvent={handleCreateEvent}
onToggleFlag={handleToggleFlag}
onDownloadAttachment={handleDownloadAttachment}
downloadingAttachmentId={downloadingAttachmentId}
/>
</div>
)}
</div>
<ComposeModal <ComposeModal
open={composeOpen} open={composeOpen}
mode={composeMode} mode={composeMode}