feat: add mobile bottom action bar with reply and email navigation
- Move Reply, Reply All, Forward from toolbar to fixed bottom bar on mobile - Add Next/Previous email navigation buttons to the bottom bar - Style bottom bar to match existing NavigationRail horizontal pattern - Add onNavigateNext/onNavigatePrev props to EmailViewer - Wire up email list index-based navigation in page.tsx - Add padding to email content area to prevent bottom bar overlap
This commit is contained in:
@@ -808,6 +808,17 @@ export default function Home() {
|
||||
setActiveView("list");
|
||||
};
|
||||
|
||||
// Navigate to next/previous email in the list
|
||||
const selectedEmailIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
|
||||
|
||||
const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < emails.length - 1
|
||||
? () => handleEmailSelect(emails[selectedEmailIndex + 1])
|
||||
: undefined;
|
||||
|
||||
const handleNavigatePrev = selectedEmailIndex > 0
|
||||
? () => handleEmailSelect(emails[selectedEmailIndex - 1])
|
||||
: undefined;
|
||||
|
||||
// Handle opening conversation view on mobile
|
||||
const handleOpenConversation = async (thread: ThreadGroup) => {
|
||||
if (!client) return;
|
||||
@@ -1403,6 +1414,8 @@ export default function Home() {
|
||||
setTabletListVisible(true);
|
||||
selectEmail(null);
|
||||
}}
|
||||
onNavigateNext={handleNavigateNext}
|
||||
onNavigatePrev={handleNavigatePrev}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
currentUserEmail={client?.["username"]}
|
||||
currentUserName={client?.["username"]?.split("@")[0]}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Mail,
|
||||
Clock,
|
||||
@@ -89,6 +90,8 @@ interface EmailViewerProps {
|
||||
onUndoSpam?: () => void;
|
||||
onMoveToMailbox?: (mailboxId: string) => void;
|
||||
onBack?: () => void;
|
||||
onNavigateNext?: () => void;
|
||||
onNavigatePrev?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
currentUserEmail?: string;
|
||||
currentUserName?: string;
|
||||
@@ -339,7 +342,7 @@ function ContactSidebarPanel({
|
||||
<div key={i} className="text-sm">
|
||||
{o.name}
|
||||
{o.units && o.units.length > 0 && (
|
||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
||||
<span className="text-muted-foreground"> — {o.units.map(u => u.name).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -410,6 +413,8 @@ export function EmailViewer({
|
||||
onUndoSpam,
|
||||
onMoveToMailbox,
|
||||
onBack,
|
||||
onNavigateNext,
|
||||
onNavigatePrev,
|
||||
onShowShortcuts,
|
||||
currentUserEmail,
|
||||
currentUserName,
|
||||
@@ -438,7 +443,7 @@ export function EmailViewer({
|
||||
}));
|
||||
|
||||
// Tablet list visibility
|
||||
const { isTablet } = useDeviceDetection();
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client } = useAuthStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
@@ -456,6 +461,8 @@ export function EmailViewer({
|
||||
const moreMenuRef = useRef<HTMLDivElement>(null);
|
||||
const tagMenuRef = useRef<HTMLDivElement>(null);
|
||||
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const [overflowCount, setOverflowCount] = useState(0);
|
||||
const currentColor = getCurrentColor(email?.keywords);
|
||||
|
||||
// Build mailbox tree for move-to dropdown
|
||||
@@ -522,6 +529,41 @@ export function EmailViewer({
|
||||
setMoveMenuOpen(false);
|
||||
}, [email?.id]);
|
||||
|
||||
// Dynamically detect which toolbar items overflow and should move to the More menu
|
||||
useEffect(() => {
|
||||
const el = toolbarRef.current;
|
||||
if (!el) return;
|
||||
const calculate = () => {
|
||||
const items = Array.from(el.querySelectorAll<HTMLElement>('[data-overflow-item]'));
|
||||
if (items.length === 0) return;
|
||||
// Sort descending by priority so highest number (least important) is first
|
||||
items.sort((a, b) =>
|
||||
Number(b.dataset.overflowPriority || 0) - Number(a.dataset.overflowPriority || 0)
|
||||
);
|
||||
// Show all items to measure their natural widths
|
||||
items.forEach(item => { item.style.display = ''; });
|
||||
const containerWidth = el.clientWidth;
|
||||
const leftGroup = el.firstElementChild as HTMLElement;
|
||||
const rightGroup = el.lastElementChild as HTMLElement;
|
||||
const mainGap = parseFloat(getComputedStyle(el).gap) || 0;
|
||||
// Iteratively hide items until content fits
|
||||
let count = 0;
|
||||
const isOverflowing = () =>
|
||||
leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap > containerWidth + 1;
|
||||
for (const item of items) {
|
||||
if (!isOverflowing()) break;
|
||||
// Skip items already hidden by CSS (e.g., on mobile)
|
||||
if (item.offsetWidth === 0) continue;
|
||||
item.style.display = 'none';
|
||||
count++;
|
||||
}
|
||||
setOverflowCount(prev => prev === count ? prev : count);
|
||||
};
|
||||
const observer = new ResizeObserver(calculate);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [toolbarPosition]);
|
||||
|
||||
// Contact sidebar state
|
||||
const [contactSidebarEmail, setContactSidebarEmail] = useState<string | null>(null);
|
||||
const contacts = useContactStore((s) => s.contacts);
|
||||
@@ -1063,6 +1105,144 @@ export function EmailViewer({
|
||||
key={email.id}
|
||||
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
|
||||
>
|
||||
{/* Mobile More menu sidebar overlay */}
|
||||
{isMobile && moreMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
|
||||
onClick={() => setMoreMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isMobile && (
|
||||
<div className={cn(
|
||||
"fixed inset-y-0 right-0 w-72 bg-background border-l border-border z-[70] sm:hidden",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
"flex flex-col",
|
||||
moreMenuOpen ? "translate-x-0" : "translate-x-full"
|
||||
)}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => setMoreMenuOpen(false)} className="h-9 w-9">
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<button
|
||||
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||
>
|
||||
<Archive className="w-5 h-5" />
|
||||
{t('archive')}
|
||||
</button>
|
||||
{(onMarkAsSpam || onUndoSpam) && (
|
||||
<button
|
||||
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||
>
|
||||
{isInJunkFolder ? (
|
||||
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||
</button>
|
||||
)}
|
||||
{/* Move to folder */}
|
||||
{moveTree.length > 0 && onMoveToMailbox && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
|
||||
{(() => {
|
||||
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
|
||||
return nodes.map((node) => {
|
||||
const Icon = getMoveMailboxIcon(node.role);
|
||||
const isTarget = moveTargetIds.has(node.id);
|
||||
return (
|
||||
<div key={node.id}>
|
||||
{isTarget ? (
|
||||
<button
|
||||
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); }}
|
||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
|
||||
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
|
||||
style={{ paddingLeft: `${1 + depth * 1}rem` }}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{node.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
return renderMobileNodes(moveTree);
|
||||
})()}
|
||||
<div className="h-px bg-border my-1" />
|
||||
</>
|
||||
)}
|
||||
{/* Tags */}
|
||||
{colorOptions.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
||||
{colorOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
||||
className={cn(
|
||||
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
|
||||
currentColor === option.value && "bg-accent font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
|
||||
<span className="truncate">{option.name}</span>
|
||||
{currentColor === option.value && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
|
||||
</button>
|
||||
))}
|
||||
{currentColor && (
|
||||
<button
|
||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
|
||||
>
|
||||
<X className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t('remove_color')}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||
>
|
||||
<Printer className="w-5 h-5" />
|
||||
{t('print')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||
>
|
||||
<Code className="w-5 h-5" />
|
||||
{t('view_source')}
|
||||
</button>
|
||||
{onShowShortcuts && (
|
||||
<button
|
||||
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
|
||||
>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
{t('keyboard_shortcuts')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Main email content */}
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
||||
{/* Loading overlay when fetching new email */}
|
||||
@@ -1081,7 +1261,7 @@ export function EmailViewer({
|
||||
"max-lg:sticky max-lg:top-0 max-lg:z-10"
|
||||
)}>
|
||||
<div className="px-2 sm:px-4 lg:px-6 py-1 sm:py-2">
|
||||
<div className="flex items-center justify-between gap-0.5 sm:gap-2">
|
||||
<div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2">
|
||||
{/* Left: Back + Reply actions */}
|
||||
<div className="flex items-center gap-0 sm:gap-1">
|
||||
{isTablet && !tabletListVisible && onBack && (
|
||||
@@ -1099,31 +1279,31 @@ export function EmailViewer({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onReply?.()}
|
||||
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('tooltips.reply')}
|
||||
>
|
||||
<Reply className="w-4 h-4" />
|
||||
<span className="text-[10px] leading-tight sm:text-sm">{t('reply')}</span>
|
||||
<span className="hidden sm:inline text-sm">{t('reply')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onReplyAll}
|
||||
className="flex-col items-center gap-0.5 h-auto py-1.5 px-1.5 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0 sm:px-3"
|
||||
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0 sm:px-3"
|
||||
title={t('tooltips.reply_all')}
|
||||
>
|
||||
<ReplyAll className="w-4 h-4" />
|
||||
<span className="text-[10px] leading-tight sm:text-sm">{t('reply_all')}</span>
|
||||
<span className="hidden sm:inline text-sm">{t('reply_all')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onForward}
|
||||
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('tooltips.forward')}
|
||||
>
|
||||
<Forward className="w-4 h-4" />
|
||||
<span className="text-[10px] leading-tight sm:text-sm">{t('forward')}</span>
|
||||
<span className="hidden sm:inline text-sm">{t('forward')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1134,11 +1314,13 @@ export function EmailViewer({
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{/* Archive - hidden on mobile, available in More menu */}
|
||||
{/* Archive - hidden on mobile, overflows to More menu */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onArchive}
|
||||
data-overflow-item
|
||||
data-overflow-priority="1"
|
||||
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||
title={t('tooltips.archive')}
|
||||
>
|
||||
@@ -1155,12 +1337,14 @@ export function EmailViewer({
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span className="text-[10px] leading-tight sm:text-sm">{t('delete')}</span>
|
||||
</Button>
|
||||
{/* Spam - hidden on mobile, available in More menu */}
|
||||
{/* Spam - hidden on mobile, overflows to More menu */}
|
||||
{(onMarkAsSpam || onUndoSpam) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
|
||||
data-overflow-item
|
||||
data-overflow-priority="2"
|
||||
className={cn(
|
||||
"hidden sm:inline-flex h-8 gap-1.5",
|
||||
isInJunkFolder ? "hover:bg-green-50 dark:hover:bg-green-950/30" : "hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
@@ -1174,9 +1358,9 @@ export function EmailViewer({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{/* Move to folder - hidden on mobile, available in More menu */}
|
||||
{/* Move to folder - hidden on mobile, overflows to More menu */}
|
||||
{moveTree.length > 0 && onMoveToMailbox && (
|
||||
<div ref={moveMenuRef} className="relative hidden sm:block">
|
||||
<div ref={moveMenuRef} data-overflow-item data-overflow-priority="3" className="relative hidden sm:block">
|
||||
<button
|
||||
onClick={() => { setMoveMenuOpen(!moveMenuOpen); setMoreMenuOpen(false); setTagMenuOpen(false); }}
|
||||
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
|
||||
@@ -1237,10 +1421,10 @@ export function EmailViewer({
|
||||
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-5 bg-border mx-0.5 hidden sm:block" />
|
||||
|
||||
{/* Tag Picker - click-based, hidden on mobile (available in More menu) */}
|
||||
<div ref={tagMenuRef} className="relative hidden sm:block">
|
||||
{/* Tag Picker + Divider - hidden on mobile, overflows to More menu */}
|
||||
<div data-overflow-item data-overflow-priority="4" className="hidden sm:flex items-center">
|
||||
<div className="w-px h-5 bg-border mx-0.5" />
|
||||
<div ref={tagMenuRef} className="relative">
|
||||
<button
|
||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||
className={cn(
|
||||
@@ -1296,12 +1480,15 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Print - hidden on mobile, available in More menu */}
|
||||
{/* Print - hidden on mobile, overflows to More menu */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handlePrint}
|
||||
data-overflow-item
|
||||
data-overflow-priority="5"
|
||||
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||
title={t('print')}
|
||||
>
|
||||
@@ -1321,12 +1508,12 @@ export function EmailViewer({
|
||||
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-[10px] leading-tight sm:hidden">{t('more_actions')}</span>
|
||||
</Button>
|
||||
{moreMenuOpen && (
|
||||
{moreMenuOpen && !isMobile && (
|
||||
<div className="absolute right-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
{/* Mobile-only actions */}
|
||||
{/* Overflow actions - shown when hidden from toolbar or on mobile */}
|
||||
<button
|
||||
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2 sm:hidden"
|
||||
className={cn("w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 5 ? "" : "sm:hidden")}
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
{t('archive')}
|
||||
@@ -1334,7 +1521,7 @@ export function EmailViewer({
|
||||
{(onMarkAsSpam || onUndoSpam) && (
|
||||
<button
|
||||
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2 sm:hidden"
|
||||
className={cn("w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 4 ? "" : "sm:hidden")}
|
||||
>
|
||||
{isInJunkFolder ? (
|
||||
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
@@ -1344,9 +1531,9 @@ export function EmailViewer({
|
||||
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||
</button>
|
||||
)}
|
||||
{/* Move to folder submenu on mobile */}
|
||||
{/* Move to folder submenu */}
|
||||
{moveTree.length > 0 && onMoveToMailbox && (
|
||||
<div className="sm:hidden">
|
||||
<div className={cn(overflowCount >= 3 ? "" : "sm:hidden")}>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
|
||||
{(() => {
|
||||
@@ -1383,9 +1570,9 @@ export function EmailViewer({
|
||||
})()}
|
||||
<div className="h-px bg-border my-1" />
|
||||
</div>
|
||||
)} {/* Tag submenu on mobile */}
|
||||
)} {/* Tag submenu */}
|
||||
{colorOptions.length > 0 && (
|
||||
<div className="sm:hidden">
|
||||
<div className={cn(overflowCount >= 2 ? "" : "sm:hidden")}>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
||||
{colorOptions.map((option) => (
|
||||
@@ -1416,7 +1603,7 @@ export function EmailViewer({
|
||||
)}
|
||||
<button
|
||||
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-3 py-2.5 sm:py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
|
||||
className={cn("w-full px-3 py-2.5 sm:py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 1 ? "" : "sm:hidden")}
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
{t('print')}
|
||||
@@ -1519,38 +1706,38 @@ export function EmailViewer({
|
||||
{toolbarPosition === 'below-subject' && (
|
||||
<div className="bg-background border-b border-border">
|
||||
<div className="px-2 sm:px-4 lg:px-6 py-1 sm:py-1.5">
|
||||
<div className="flex items-center justify-between gap-0.5 sm:gap-2">
|
||||
<div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2">
|
||||
{/* Left: Reply actions */}
|
||||
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onReply?.()}
|
||||
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('tooltips.reply')}
|
||||
>
|
||||
<Reply className="w-4 h-4" />
|
||||
<span className="text-[10px] leading-tight sm:text-sm">{t('reply')}</span>
|
||||
<span className="hidden sm:inline text-sm">{t('reply')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onReplyAll}
|
||||
className="flex-col items-center gap-0.5 h-auto py-1.5 px-1.5 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0 sm:px-3"
|
||||
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0 sm:px-3"
|
||||
title={t('tooltips.reply_all')}
|
||||
>
|
||||
<ReplyAll className="w-4 h-4" />
|
||||
<span className="text-[10px] leading-tight sm:text-sm">{t('reply_all')}</span>
|
||||
<span className="hidden sm:inline text-sm">{t('reply_all')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onForward}
|
||||
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('tooltips.forward')}
|
||||
>
|
||||
<Forward className="w-4 h-4" />
|
||||
<span className="text-[10px] leading-tight sm:text-sm">{t('forward')}</span>
|
||||
<span className="hidden sm:inline text-sm">{t('forward')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1561,23 +1748,27 @@ export function EmailViewer({
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{/* Archive - hidden on mobile, available in More menu */}
|
||||
{/* Archive - hidden on mobile, overflows to More menu */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onArchive}
|
||||
data-overflow-item
|
||||
data-overflow-priority="1"
|
||||
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||
title={t('tooltips.archive')}
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
<span className="hidden sm:inline text-sm">{t('archive')}</span>
|
||||
</Button>
|
||||
{/* Spam - hidden on mobile, available in More menu */}
|
||||
{/* Spam - hidden on mobile, overflows to More menu */}
|
||||
{(onMarkAsSpam || onUndoSpam) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
|
||||
data-overflow-item
|
||||
data-overflow-priority="2"
|
||||
className={cn(
|
||||
"hidden sm:inline-flex h-8 gap-1.5",
|
||||
isInJunkFolder ? "hover:bg-green-50 dark:hover:bg-green-950/30" : "hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
@@ -1591,9 +1782,9 @@ export function EmailViewer({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{/* Move to folder - hidden on mobile, available in More menu */}
|
||||
{/* Move to folder - hidden on mobile, overflows to More menu */}
|
||||
{moveTree.length > 0 && onMoveToMailbox && (
|
||||
<div ref={moveMenuRef} className="relative hidden sm:block">
|
||||
<div ref={moveMenuRef} data-overflow-item data-overflow-priority="3" className="relative hidden sm:block">
|
||||
<button
|
||||
onClick={() => { setMoveMenuOpen(!moveMenuOpen); setMoreMenuOpen(false); setTagMenuOpen(false); }}
|
||||
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
|
||||
@@ -1664,10 +1855,10 @@ export function EmailViewer({
|
||||
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-5 bg-border mx-0.5 hidden sm:block" />
|
||||
|
||||
{/* Tag Picker - click-based, hidden on mobile (available in More menu) */}
|
||||
<div ref={tagMenuRef} className="relative hidden sm:block">
|
||||
{/* Tag Picker + Divider - hidden on mobile, overflows to More menu */}
|
||||
<div data-overflow-item data-overflow-priority="4" className="hidden sm:flex items-center">
|
||||
<div className="w-px h-5 bg-border mx-0.5" />
|
||||
<div ref={tagMenuRef} className="relative">
|
||||
<button
|
||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||
className={cn(
|
||||
@@ -1723,12 +1914,15 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Print - hidden on mobile, available in More menu */}
|
||||
{/* Print - hidden on mobile, overflows to More menu */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handlePrint}
|
||||
data-overflow-item
|
||||
data-overflow-priority="5"
|
||||
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||
title={t('print')}
|
||||
>
|
||||
@@ -1748,12 +1942,12 @@ export function EmailViewer({
|
||||
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-[10px] leading-tight sm:hidden">{t('more_actions')}</span>
|
||||
</Button>
|
||||
{moreMenuOpen && (
|
||||
{moreMenuOpen && !isMobile && (
|
||||
<div className="absolute right-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10">
|
||||
{/* Mobile-only actions */}
|
||||
{/* Overflow actions - shown when hidden from toolbar or on mobile */}
|
||||
<button
|
||||
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2 sm:hidden"
|
||||
className={cn("w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 5 ? "" : "sm:hidden")}
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
{t('archive')}
|
||||
@@ -1761,7 +1955,7 @@ export function EmailViewer({
|
||||
{(onMarkAsSpam || onUndoSpam) && (
|
||||
<button
|
||||
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2 sm:hidden"
|
||||
className={cn("w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 4 ? "" : "sm:hidden")}
|
||||
>
|
||||
{isInJunkFolder ? (
|
||||
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
@@ -1771,9 +1965,9 @@ export function EmailViewer({
|
||||
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||
</button>
|
||||
)}
|
||||
{/* Move to folder submenu on mobile */}
|
||||
{/* Move to folder submenu */}
|
||||
{moveTree.length > 0 && onMoveToMailbox && (
|
||||
<div className="sm:hidden">
|
||||
<div className={cn(overflowCount >= 3 ? "" : "sm:hidden")}>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
|
||||
{(() => {
|
||||
@@ -1810,9 +2004,9 @@ export function EmailViewer({
|
||||
})()}
|
||||
<div className="h-px bg-border my-1" />
|
||||
</div>
|
||||
)} {/* Tag submenu on mobile */}
|
||||
)} {/* Tag submenu */}
|
||||
{colorOptions.length > 0 && (
|
||||
<div className="sm:hidden">
|
||||
<div className={cn(overflowCount >= 2 ? "" : "sm:hidden")}>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
|
||||
{colorOptions.map((option) => (
|
||||
@@ -1843,7 +2037,7 @@ export function EmailViewer({
|
||||
)}
|
||||
<button
|
||||
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
|
||||
className="w-full px-3 py-2.5 sm:py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
|
||||
className={cn("w-full px-3 py-2.5 sm:py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 1 ? "" : "sm:hidden")}
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
{t('print')}
|
||||
@@ -2403,7 +2597,7 @@ export function EmailViewer({
|
||||
)}
|
||||
|
||||
{/* Email Content Area */}
|
||||
<div className="flex-1 overflow-auto bg-muted/30">
|
||||
<div className={cn("flex-1 overflow-auto bg-muted/30", isMobile && "pb-16")}>
|
||||
{/* Mobile/Tablet Sender Info - scrolls with content */}
|
||||
<div className="lg:hidden bg-background border-b border-border px-4" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||
@@ -2446,12 +2640,12 @@ export function EmailViewer({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>·</span>
|
||||
<span>·</span>
|
||||
</>
|
||||
)}
|
||||
{email.to && email.to.length > 0 && (
|
||||
<>
|
||||
<span>→ {t('recipient_to_prefix')}</span>
|
||||
<span>→ {t('recipient_to_prefix')}</span>
|
||||
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
|
||||
</>
|
||||
)}
|
||||
@@ -2693,6 +2887,62 @@ export function EmailViewer({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom action bar */}
|
||||
{isMobile && (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden">
|
||||
<div className="flex items-center justify-around">
|
||||
<button
|
||||
onClick={onNavigatePrev}
|
||||
disabled={!onNavigatePrev}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150",
|
||||
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||
)}
|
||||
aria-label={t('tooltips.previous')}
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('previous')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReply?.()}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
aria-label={t('tooltips.reply')}
|
||||
>
|
||||
<Reply className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('reply')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onReplyAll}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
aria-label={t('tooltips.reply_all')}
|
||||
>
|
||||
<ReplyAll className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('reply_all')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onForward}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
|
||||
aria-label={t('tooltips.forward')}
|
||||
>
|
||||
<Forward className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('forward')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateNext}
|
||||
disabled={!onNavigateNext}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150",
|
||||
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
|
||||
)}
|
||||
aria-label={t('tooltips.next')}
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t('next')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Contact Detail Sidebar - desktop only */}
|
||||
{contactSidebarEmail && !isMobileDevice && (
|
||||
<ContactSidebarPanel
|
||||
|
||||
@@ -175,6 +175,8 @@
|
||||
"set_color": "Set tag",
|
||||
"tag": "Tag",
|
||||
"more_actions": "More actions",
|
||||
"previous": "Prev",
|
||||
"next": "Next",
|
||||
"move_to": "Move to...",
|
||||
"remove_color": "Remove tag",
|
||||
"more_count": "+{count} more",
|
||||
@@ -251,7 +253,9 @@
|
||||
"delete": "Delete (# or Del)",
|
||||
"star": "Star (s)",
|
||||
"unstar": "Unstar (s)",
|
||||
"compose": "Compose (c)"
|
||||
"compose": "Compose (c)",
|
||||
"previous": "Previous email",
|
||||
"next": "Next email"
|
||||
},
|
||||
"spam": {
|
||||
"button_title": "Report spam",
|
||||
|
||||
Reference in New Issue
Block a user