feat: implement dropdown menus for actions in EmailViewer component
This commit is contained in:
+294
-129
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ContactCard } from "@/lib/jmap/types";
|
import { Email, ContactCard } from "@/lib/jmap/types";
|
||||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||||
@@ -441,8 +441,33 @@ export function EmailViewer({
|
|||||||
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
|
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
|
||||||
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
|
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
|
||||||
const [showSourceModal, setShowSourceModal] = useState(false);
|
const [showSourceModal, setShowSourceModal] = useState(false);
|
||||||
|
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||||
|
const [tagMenuOpen, setTagMenuOpen] = useState(false);
|
||||||
|
const moreMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tagMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const currentColor = getCurrentColor(email?.keywords);
|
const currentColor = getCurrentColor(email?.keywords);
|
||||||
|
|
||||||
|
// Close dropdown menus on click outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!moreMenuOpen && !tagMenuOpen) return;
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (moreMenuOpen && moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setMoreMenuOpen(false);
|
||||||
|
}
|
||||||
|
if (tagMenuOpen && tagMenuRef.current && !tagMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setTagMenuOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [moreMenuOpen, tagMenuOpen]);
|
||||||
|
|
||||||
|
// Close dropdowns when email changes
|
||||||
|
useEffect(() => {
|
||||||
|
setMoreMenuOpen(false);
|
||||||
|
setTagMenuOpen(false);
|
||||||
|
}, [email?.id]);
|
||||||
|
|
||||||
// Contact sidebar state
|
// Contact sidebar state
|
||||||
const [contactSidebarEmail, setContactSidebarEmail] = useState<string | null>(null);
|
const [contactSidebarEmail, setContactSidebarEmail] = useState<string | null>(null);
|
||||||
const contacts = useContactStore((s) => s.contacts);
|
const contacts = useContactStore((s) => s.contacts);
|
||||||
@@ -1001,10 +1026,10 @@ export function EmailViewer({
|
|||||||
"bg-background border-b border-border",
|
"bg-background border-b border-border",
|
||||||
"max-lg:sticky max-lg:top-0 max-lg:z-10"
|
"max-lg:sticky max-lg:top-0 max-lg:z-10"
|
||||||
)}>
|
)}>
|
||||||
<div className="px-4 lg:px-6 py-2">
|
<div className="px-2 sm:px-4 lg:px-6 py-1 sm:py-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-0.5 sm:gap-2">
|
||||||
{/* Left: Back + Reply actions */}
|
{/* Left: Back + Reply actions */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0 sm:gap-1">
|
||||||
{isTablet && !tabletListVisible && onBack && (
|
{isTablet && !tabletListVisible && onBack && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1020,46 +1045,47 @@ export function EmailViewer({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onReply?.()}
|
onClick={() => onReply?.()}
|
||||||
className="h-8 gap-1.5"
|
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"
|
||||||
title={t('tooltips.reply')}
|
title={t('tooltips.reply')}
|
||||||
>
|
>
|
||||||
<Reply className="w-4 h-4" />
|
<Reply className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline text-sm">{t('reply')}</span>
|
<span className="text-[10px] leading-tight sm:text-sm">{t('reply')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onReplyAll}
|
onClick={onReplyAll}
|
||||||
className="h-8 gap-1.5"
|
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"
|
||||||
title={t('tooltips.reply_all')}
|
title={t('tooltips.reply_all')}
|
||||||
>
|
>
|
||||||
<ReplyAll className="w-4 h-4" />
|
<ReplyAll className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline text-sm">{t('reply_all')}</span>
|
<span className="text-[10px] leading-tight sm:text-sm">{t('reply_all')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onForward}
|
onClick={onForward}
|
||||||
className="h-8 gap-1.5"
|
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"
|
||||||
title={t('tooltips.forward')}
|
title={t('tooltips.forward')}
|
||||||
>
|
>
|
||||||
<Forward className="w-4 h-4" />
|
<Forward className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline text-sm">{t('forward')}</span>
|
<span className="text-[10px] leading-tight sm:text-sm">{t('forward')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Organize actions */}
|
{/* Right: Organize actions */}
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Archive - hidden on mobile, available in More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onArchive}
|
onClick={onArchive}
|
||||||
className="h-8 gap-1.5"
|
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||||
title={t('tooltips.archive')}
|
title={t('tooltips.archive')}
|
||||||
>
|
>
|
||||||
<Archive className="w-4 h-4" />
|
<Archive className="w-4 h-4" />
|
||||||
@@ -1069,19 +1095,20 @@ export function EmailViewer({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
className="h-8 gap-1.5"
|
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"
|
||||||
title={t('tooltips.delete')}
|
title={t('tooltips.delete')}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline text-sm">{t('delete')}</span>
|
<span className="text-[10px] leading-tight sm:text-sm">{t('delete')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Spam - hidden on mobile, available in More menu */}
|
||||||
{(onMarkAsSpam || onUndoSpam) && (
|
{(onMarkAsSpam || onUndoSpam) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
|
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 gap-1.5",
|
"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"
|
isInJunkFolder ? "hover:bg-green-50 dark:hover:bg-green-950/30" : "hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||||
)}
|
)}
|
||||||
title={isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
title={isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||||
@@ -1095,22 +1122,24 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
onClick={onToggleStar}
|
onClick={onToggleStar}
|
||||||
className="h-8 w-8"
|
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:w-auto sm:gap-0 sm:py-0 sm:px-2"
|
||||||
title={isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
title={isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
||||||
>
|
>
|
||||||
<Star className={cn(
|
<Star className={cn(
|
||||||
"w-4 h-4 transition-colors",
|
"w-4 h-4 transition-colors",
|
||||||
isStarred ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground"
|
isStarred ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground"
|
||||||
)} />
|
)} />
|
||||||
|
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-border mx-0.5" />
|
<div className="w-px h-5 bg-border mx-0.5 hidden sm:block" />
|
||||||
|
|
||||||
{/* Tag Picker */}
|
{/* Tag Picker - click-based, hidden on mobile (available in More menu) */}
|
||||||
<div className="relative group hidden sm:block">
|
<div ref={tagMenuRef} className="relative hidden sm:block">
|
||||||
<button
|
<button
|
||||||
|
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
||||||
currentColor && "bg-muted/50"
|
currentColor && "bg-muted/50"
|
||||||
@@ -1133,75 +1162,141 @@ export function EmailViewer({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-10">
|
{tagMenuOpen && (
|
||||||
{colorOptions.map((option) => (
|
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
|
||||||
<button
|
{colorOptions.map((option) => (
|
||||||
key={option.value}
|
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); }}
|
|
||||||
className={cn(
|
|
||||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
|
||||||
<span className="truncate">{option.name}</span>
|
|
||||||
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{currentColor && (
|
|
||||||
<>
|
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); }}
|
key={option.value}
|
||||||
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||||
|
currentColor === option.value && "bg-accent font-medium"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3 flex-shrink-0" />
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
<span>{t('remove_color')}</span>
|
<span className="truncate">{option.name}</span>
|
||||||
|
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||||
</button>
|
</button>
|
||||||
</>
|
))}
|
||||||
)}
|
{currentColor && (
|
||||||
</div>
|
<>
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => { if (email) onSetColorTag?.(email.id, null); setTagMenuOpen(false); }}
|
||||||
|
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span>{t('remove_color')}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Print - hidden on mobile, available in More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
className="h-8 gap-1.5"
|
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||||
title={t('print')}
|
title={t('print')}
|
||||||
>
|
>
|
||||||
<Printer className="w-4 h-4" />
|
<Printer className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline text-sm">{t('print')}</span>
|
<span className="hidden sm:inline text-sm">{t('print')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* More menu */}
|
{/* More menu - click-based */}
|
||||||
<div className="relative group">
|
<div ref={moreMenuRef} className="relative">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
className="h-8 w-8"
|
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:w-8 sm:gap-0 sm:py-0 sm:px-0"
|
||||||
title={t('more_actions')}
|
title={t('more_actions')}
|
||||||
|
onClick={() => { setMoreMenuOpen(!moreMenuOpen); setTagMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-[10px] leading-tight sm:hidden">{t('more_actions')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="absolute right-0 top-full mt-1 w-44 bg-background rounded-md shadow-lg border border-border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-10">
|
{moreMenuOpen && (
|
||||||
<button
|
<div className="absolute right-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10">
|
||||||
onClick={() => setShowSourceModal(true)}
|
{/* Mobile-only actions */}
|
||||||
className="w-full px-3 py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Code className="w-4 h-4" />
|
|
||||||
{t('view_source')}
|
|
||||||
</button>
|
|
||||||
{onShowShortcuts && (
|
|
||||||
<button
|
<button
|
||||||
onClick={onShowShortcuts}
|
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
|
||||||
className="w-full px-3 py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
|
className="w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2 sm:hidden"
|
||||||
>
|
>
|
||||||
<Keyboard className="w-4 h-4" />
|
<Archive className="w-4 h-4" />
|
||||||
{t('keyboard_shortcuts')}
|
{t('archive')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
{(onMarkAsSpam || onUndoSpam) && (
|
||||||
</div>
|
<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"
|
||||||
|
>
|
||||||
|
{isInJunkFolder ? (
|
||||||
|
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||||
|
)}
|
||||||
|
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Tag submenu on mobile */}
|
||||||
|
{colorOptions.length > 0 && (
|
||||||
|
<div className="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) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||||
|
currentColor === option.value && "bg-accent font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
|
<span className="truncate">{option.name}</span>
|
||||||
|
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{currentColor && (
|
||||||
|
<button
|
||||||
|
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span>{t('remove_color')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Printer className="w-4 h-4" />
|
||||||
|
{t('print')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowSourceModal(true); 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"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
{t('view_source')}
|
||||||
|
</button>
|
||||||
|
{onShowShortcuts && (
|
||||||
|
<button
|
||||||
|
onClick={() => { onShowShortcuts(); 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"
|
||||||
|
>
|
||||||
|
<Keyboard className="w-4 h-4" />
|
||||||
|
{t('keyboard_shortcuts')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1281,66 +1376,68 @@ export function EmailViewer({
|
|||||||
{/* === TOOLBAR (below-subject position) === */}
|
{/* === TOOLBAR (below-subject position) === */}
|
||||||
{toolbarPosition === 'below-subject' && (
|
{toolbarPosition === 'below-subject' && (
|
||||||
<div className="bg-background border-b border-border">
|
<div className="bg-background border-b border-border">
|
||||||
<div className="px-4 lg:px-6 py-1.5">
|
<div className="px-2 sm:px-4 lg:px-6 py-1 sm:py-1.5">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-0.5 sm:gap-2">
|
||||||
{/* Left: Reply actions */}
|
{/* Left: Reply actions */}
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onReply?.()}
|
onClick={() => onReply?.()}
|
||||||
className="h-8 gap-1.5"
|
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"
|
||||||
title={t('tooltips.reply')}
|
title={t('tooltips.reply')}
|
||||||
>
|
>
|
||||||
<Reply className="w-4 h-4" />
|
<Reply className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline text-sm">{t('reply')}</span>
|
<span className="text-[10px] leading-tight sm:text-sm">{t('reply')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onReplyAll}
|
onClick={onReplyAll}
|
||||||
className="h-8 gap-1.5"
|
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"
|
||||||
title={t('tooltips.reply_all')}
|
title={t('tooltips.reply_all')}
|
||||||
>
|
>
|
||||||
<ReplyAll className="w-4 h-4" />
|
<ReplyAll className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline text-sm">{t('reply_all')}</span>
|
<span className="text-[10px] leading-tight sm:text-sm">{t('reply_all')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onForward}
|
onClick={onForward}
|
||||||
className="h-8 gap-1.5"
|
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"
|
||||||
title={t('tooltips.forward')}
|
title={t('tooltips.forward')}
|
||||||
>
|
>
|
||||||
<Forward className="w-4 h-4" />
|
<Forward className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline text-sm">{t('forward')}</span>
|
<span className="text-[10px] leading-tight sm:text-sm">{t('forward')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Organize actions */}
|
{/* Right: Organize actions */}
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0 sm:gap-0.5">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Archive - hidden on mobile, available in More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onArchive}
|
onClick={onArchive}
|
||||||
className="h-8 gap-1.5"
|
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||||
title={t('tooltips.archive')}
|
title={t('tooltips.archive')}
|
||||||
>
|
>
|
||||||
<Archive className="w-4 h-4" />
|
<Archive className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline text-sm">{t('archive')}</span>
|
<span className="hidden sm:inline text-sm">{t('archive')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Spam - hidden on mobile, available in More menu */}
|
||||||
{(onMarkAsSpam || onUndoSpam) && (
|
{(onMarkAsSpam || onUndoSpam) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
|
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 gap-1.5",
|
"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"
|
isInJunkFolder ? "hover:bg-green-50 dark:hover:bg-green-950/30" : "hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||||
)}
|
)}
|
||||||
title={isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
title={isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||||
@@ -1356,30 +1453,32 @@ export function EmailViewer({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
className="h-8 gap-1.5"
|
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"
|
||||||
title={t('tooltips.delete')}
|
title={t('tooltips.delete')}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline text-sm">{t('delete')}</span>
|
<span className="text-[10px] leading-tight sm:text-sm">{t('delete')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
onClick={onToggleStar}
|
onClick={onToggleStar}
|
||||||
className="h-8 w-8"
|
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:w-auto sm:gap-0 sm:py-0 sm:px-2"
|
||||||
title={isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
title={isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
||||||
>
|
>
|
||||||
<Star className={cn(
|
<Star className={cn(
|
||||||
"w-4 h-4 transition-colors",
|
"w-4 h-4 transition-colors",
|
||||||
isStarred ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground"
|
isStarred ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground"
|
||||||
)} />
|
)} />
|
||||||
|
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-border mx-0.5" />
|
<div className="w-px h-5 bg-border mx-0.5 hidden sm:block" />
|
||||||
|
|
||||||
{/* Tag Picker */}
|
{/* Tag Picker - click-based, hidden on mobile (available in More menu) */}
|
||||||
<div className="relative group hidden sm:block">
|
<div ref={tagMenuRef} className="relative hidden sm:block">
|
||||||
<button
|
<button
|
||||||
|
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
"h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2",
|
||||||
currentColor && "bg-muted/50"
|
currentColor && "bg-muted/50"
|
||||||
@@ -1402,75 +1501,141 @@ export function EmailViewer({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-10">
|
{tagMenuOpen && (
|
||||||
{colorOptions.map((option) => (
|
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
|
||||||
<button
|
{colorOptions.map((option) => (
|
||||||
key={option.value}
|
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); }}
|
|
||||||
className={cn(
|
|
||||||
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
|
||||||
currentColor === option.value && "bg-accent font-medium"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
|
||||||
<span className="truncate">{option.name}</span>
|
|
||||||
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{currentColor && (
|
|
||||||
<>
|
|
||||||
<div className="h-px bg-border my-1" />
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (email) onSetColorTag?.(email.id, null); }}
|
key={option.value}
|
||||||
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||||
|
currentColor === option.value && "bg-accent font-medium"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3 flex-shrink-0" />
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
<span>{t('remove_color')}</span>
|
<span className="truncate">{option.name}</span>
|
||||||
|
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||||
</button>
|
</button>
|
||||||
</>
|
))}
|
||||||
)}
|
{currentColor && (
|
||||||
</div>
|
<>
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => { if (email) onSetColorTag?.(email.id, null); setTagMenuOpen(false); }}
|
||||||
|
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span>{t('remove_color')}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Print - hidden on mobile, available in More menu */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
className="h-8 gap-1.5"
|
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||||
title={t('print')}
|
title={t('print')}
|
||||||
>
|
>
|
||||||
<Printer className="w-4 h-4" />
|
<Printer className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline text-sm">{t('print')}</span>
|
<span className="hidden sm:inline text-sm">{t('print')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* More Actions */}
|
{/* More menu - click-based */}
|
||||||
<div className="relative group">
|
<div ref={moreMenuRef} className="relative">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
className="h-8 w-8"
|
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:w-8 sm:gap-0 sm:py-0 sm:px-0"
|
||||||
title={t('more_actions')}
|
title={t('more_actions')}
|
||||||
|
onClick={() => { setMoreMenuOpen(!moreMenuOpen); setTagMenuOpen(false); }}
|
||||||
>
|
>
|
||||||
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-[10px] leading-tight sm:hidden">{t('more_actions')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="absolute right-0 top-full mt-1 w-44 bg-background rounded-md shadow-lg border border-border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-10">
|
{moreMenuOpen && (
|
||||||
<button
|
<div className="absolute right-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10">
|
||||||
onClick={() => setShowSourceModal(true)}
|
{/* Mobile-only actions */}
|
||||||
className="w-full px-3 py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Code className="w-4 h-4" />
|
|
||||||
{t('view_source')}
|
|
||||||
</button>
|
|
||||||
{onShowShortcuts && (
|
|
||||||
<button
|
<button
|
||||||
onClick={onShowShortcuts}
|
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
|
||||||
className="w-full px-3 py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
|
className="w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2 sm:hidden"
|
||||||
>
|
>
|
||||||
<Keyboard className="w-4 h-4" />
|
<Archive className="w-4 h-4" />
|
||||||
{t('keyboard_shortcuts')}
|
{t('archive')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
{(onMarkAsSpam || onUndoSpam) && (
|
||||||
</div>
|
<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"
|
||||||
|
>
|
||||||
|
{isInJunkFolder ? (
|
||||||
|
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||||
|
)}
|
||||||
|
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Tag submenu on mobile */}
|
||||||
|
{colorOptions.length > 0 && (
|
||||||
|
<div className="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) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2",
|
||||||
|
currentColor === option.value && "bg-accent font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
|
||||||
|
<span className="truncate">{option.name}</span>
|
||||||
|
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{currentColor && (
|
||||||
|
<button
|
||||||
|
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
|
||||||
|
className="w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span>{t('remove_color')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Printer className="w-4 h-4" />
|
||||||
|
{t('print')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowSourceModal(true); 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"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
{t('view_source')}
|
||||||
|
</button>
|
||||||
|
{onShowShortcuts && (
|
||||||
|
<button
|
||||||
|
onClick={() => { onShowShortcuts(); 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"
|
||||||
|
>
|
||||||
|
<Keyboard className="w-4 h-4" />
|
||||||
|
{t('keyboard_shortcuts')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user