feat: mobile-responsive mail plugin UI
This commit is contained in:
@@ -26,9 +26,9 @@ function ToolbarSearch({ item }: { item: ToolbarItem }) {
|
||||
type="text"
|
||||
placeholder={item.searchPlaceholder || 'Suchen...'}
|
||||
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
|
||||
placeholder:text-secondary-400"
|
||||
placeholder:text-secondary-400 flex-shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -38,9 +38,9 @@ function ToolbarSelect({ item }: { item: ToolbarItem }) {
|
||||
<select
|
||||
value={item.selectValue || ''}
|
||||
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
|
||||
bg-white cursor-pointer"
|
||||
bg-white cursor-pointer flex-shrink-0 max-w-[120px] sm:max-w-none"
|
||||
>
|
||||
{item.selectOptions?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
@@ -66,7 +66,7 @@ export function PluginToolbar() {
|
||||
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
{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');
|
||||
|
||||
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">
|
||||
{/* 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')}
|
||||
</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
|
||||
label={t('mail.cc')}
|
||||
value={cc}
|
||||
@@ -278,7 +278,7 @@ export function ComposeModal({
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
|
||||
@@ -91,7 +91,7 @@ export function MailDetail({
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="mail-detail">
|
||||
{/* 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={
|
||||
<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" />
|
||||
@@ -135,9 +135,9 @@ export function MailDetail({
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
<div className="px-4 py-3 border-b border-secondary-200" data-testid="mail-detail-headers">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 flex-1">{mail.subject || t('mail.noSubject')}</h2>
|
||||
<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 flex-col sm:flex-row sm:items-start sm:justify-between gap-2 sm:gap-4">
|
||||
<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 && (
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
{mail.labels.map((label) => (
|
||||
@@ -148,30 +148,30 @@ export function MailDetail({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-secondary-600">
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium text-secondary-500">{t('mail.from')}:</span>
|
||||
<span>{mail.from_name ? `${mail.from_name} <${mail.from_address}>` : mail.from_address}</span>
|
||||
<div className="mt-2 space-y-1 text-xs md:text-sm text-secondary-600">
|
||||
<div className="flex flex-col sm:flex-row sm:gap-2">
|
||||
<span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.from')}:</span>
|
||||
<span className="break-words">{mail.from_name ? `${mail.from_name} <${mail.from_address}>` : mail.from_address}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium text-secondary-500">{t('mail.to')}:</span>
|
||||
<span>{mail.to_addresses.join(', ')}</span>
|
||||
<div className="flex flex-col sm:flex-row sm:gap-2">
|
||||
<span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.to')}:</span>
|
||||
<span className="break-words">{mail.to_addresses.join(', ')}</span>
|
||||
</div>
|
||||
{mail.cc_addresses.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium text-secondary-500">{t('mail.cc')}:</span>
|
||||
<span>{mail.cc_addresses.join(', ')}</span>
|
||||
<div className="flex flex-col sm:flex-row sm:gap-2">
|
||||
<span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.cc')}:</span>
|
||||
<span className="break-words">{mail.cc_addresses.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium text-secondary-500">{t('mail.date')}:</span>
|
||||
<div className="flex flex-col sm:flex-row sm:gap-2">
|
||||
<span className="font-medium text-secondary-500 flex-shrink-0">{t('mail.date')}:</span>
|
||||
<span>{formatFullDate(mail.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-secondary-800"
|
||||
@@ -190,9 +190,9 @@ export function MailDetail({
|
||||
|
||||
{/* Attachments */}
|
||||
{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>
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-1 md:space-y-2">
|
||||
{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">
|
||||
<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
|
||||
onClick={() => onSelect(folder.id)}
|
||||
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',
|
||||
isSelected
|
||||
? 'bg-primary-50 text-primary-700 font-medium'
|
||||
@@ -203,7 +203,7 @@ function AccountNode({
|
||||
<div role="treeitem" aria-label={account.email}>
|
||||
<button
|
||||
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}`}
|
||||
>
|
||||
<ChevronIcon expanded={expanded} />
|
||||
|
||||
@@ -73,7 +73,7 @@ export function MailList({
|
||||
<button
|
||||
onClick={() => onSelectMail(mail)}
|
||||
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',
|
||||
mail.id === selectedMailId && 'bg-primary-50',
|
||||
!mail.is_seen && 'font-semibold',
|
||||
@@ -102,7 +102,7 @@ export function MailList({
|
||||
</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')}>
|
||||
<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 && (
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ModalProps {
|
||||
closeOnBackdrop?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
fullScreenMobile?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
@@ -28,6 +29,7 @@ export function Modal({
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
showCloseButton = true,
|
||||
fullScreenMobile = false,
|
||||
}: ModalProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
@@ -60,7 +62,10 @@ export function Modal({
|
||||
|
||||
return (
|
||||
<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"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? 'modal-title' : undefined}
|
||||
@@ -76,12 +81,14 @@ export function Modal({
|
||||
className={clsx(
|
||||
'relative bg-white rounded-lg shadow-xl w-full',
|
||||
'motion-safe:animate-scale',
|
||||
fullScreenMobile && 'h-full md:h-auto md:max-h-[85vh]',
|
||||
fullScreenMobile ? 'rounded-none md:rounded-lg' : '',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<div className="px-6 py-4 border-b border-secondary-200">
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-secondary-900">
|
||||
<div className="px-4 py-3 md:px-6 md:py-4 border-b border-secondary-200">
|
||||
<h2 id="modal-title" className="text-base md:text-lg font-semibold text-secondary-900">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -89,7 +96,7 @@ export function Modal({
|
||||
{showCloseButton && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,7 @@ export function MailPage() {
|
||||
const [forwardMailState, setForwardMailState] = useState<Mail | null>(null);
|
||||
const [downloadingAttachmentId, setDownloadingAttachmentId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
|
||||
|
||||
// Load accounts
|
||||
const loadAccounts = useCallback(async () => {
|
||||
@@ -168,6 +169,7 @@ export function MailPage() {
|
||||
setMailsPage(1);
|
||||
setSearchQuery('');
|
||||
setSelectedMail(null);
|
||||
setActiveView('list');
|
||||
},
|
||||
[folders],
|
||||
);
|
||||
@@ -175,6 +177,7 @@ export function MailPage() {
|
||||
// Handle mail selection
|
||||
const handleSelectMail = useCallback(
|
||||
async (mail: Mail) => {
|
||||
setActiveView('detail');
|
||||
setLoadingMail(true);
|
||||
try {
|
||||
const detail = await getMail(mail.id);
|
||||
@@ -435,8 +438,8 @@ export function MailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Three-pane layout with resizable panels */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Desktop: three-pane layout with resizable panels */}
|
||||
<div className="hidden md:flex flex-1 overflow-hidden">
|
||||
{/* Folder tree — resizable */}
|
||||
<ResizablePanel
|
||||
initialWidth={224}
|
||||
@@ -495,6 +498,82 @@ export function MailPage() {
|
||||
</ResizablePanel>
|
||||
</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
|
||||
open={composeOpen}
|
||||
mode={composeMode}
|
||||
|
||||
Reference in New Issue
Block a user