feat: add move-to mailbox functionality in email viewer
This commit is contained in:
@@ -1407,6 +1407,13 @@ export default function Home() {
|
|||||||
currentUserEmail={client?.["username"]}
|
currentUserEmail={client?.["username"]}
|
||||||
currentUserName={client?.["username"]?.split("@")[0]}
|
currentUserName={client?.["username"]?.split("@")[0]}
|
||||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||||
|
mailboxes={mailboxes}
|
||||||
|
selectedMailbox={selectedMailbox}
|
||||||
|
onMoveToMailbox={async (mailboxId) => {
|
||||||
|
if (client && selectedEmail) {
|
||||||
|
await moveToMailbox(client, selectedEmail.id, mailboxId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={isMobile ? "flex-1" : undefined}
|
className={isMobile ? "flex-1" : undefined}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useMemo, useRef } 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, Mailbox } from "@/lib/jmap/types";
|
||||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { formatFileSize, cn } from "@/lib/utils";
|
import { formatFileSize, cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||||
import {
|
import {
|
||||||
Reply,
|
Reply,
|
||||||
@@ -53,6 +53,9 @@ import {
|
|||||||
StickyNote,
|
StickyNote,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
Send,
|
Send,
|
||||||
|
FolderInput,
|
||||||
|
Inbox,
|
||||||
|
Folder,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
@@ -84,11 +87,14 @@ interface EmailViewerProps {
|
|||||||
onQuickReply?: (body: string) => Promise<void>;
|
onQuickReply?: (body: string) => Promise<void>;
|
||||||
onMarkAsSpam?: () => void;
|
onMarkAsSpam?: () => void;
|
||||||
onUndoSpam?: () => void;
|
onUndoSpam?: () => void;
|
||||||
|
onMoveToMailbox?: (mailboxId: string) => void;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onShowShortcuts?: () => void;
|
onShowShortcuts?: () => void;
|
||||||
currentUserEmail?: string;
|
currentUserEmail?: string;
|
||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
currentMailboxRole?: string;
|
currentMailboxRole?: string;
|
||||||
|
mailboxes?: Mailbox[];
|
||||||
|
selectedMailbox?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +339,7 @@ function ContactSidebarPanel({
|
|||||||
<div key={i} className="text-sm">
|
<div key={i} className="text-sm">
|
||||||
{o.name}
|
{o.name}
|
||||||
{o.units && o.units.length > 0 && (
|
{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>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -402,11 +408,14 @@ export function EmailViewer({
|
|||||||
onQuickReply,
|
onQuickReply,
|
||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
|
onMoveToMailbox,
|
||||||
onBack,
|
onBack,
|
||||||
onShowShortcuts,
|
onShowShortcuts,
|
||||||
currentUserEmail,
|
currentUserEmail,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
currentMailboxRole,
|
currentMailboxRole,
|
||||||
|
mailboxes = [],
|
||||||
|
selectedMailbox = "",
|
||||||
className,
|
className,
|
||||||
}: EmailViewerProps) {
|
}: EmailViewerProps) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
@@ -443,13 +452,54 @@ export function EmailViewer({
|
|||||||
const [showSourceModal, setShowSourceModal] = useState(false);
|
const [showSourceModal, setShowSourceModal] = useState(false);
|
||||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||||
const [tagMenuOpen, setTagMenuOpen] = useState(false);
|
const [tagMenuOpen, setTagMenuOpen] = useState(false);
|
||||||
|
const [moveMenuOpen, setMoveMenuOpen] = useState(false);
|
||||||
const moreMenuRef = useRef<HTMLDivElement>(null);
|
const moreMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const tagMenuRef = useRef<HTMLDivElement>(null);
|
const tagMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const moveMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const currentColor = getCurrentColor(email?.keywords);
|
const currentColor = getCurrentColor(email?.keywords);
|
||||||
|
|
||||||
|
// Build mailbox tree for move-to dropdown
|
||||||
|
const moveTargetIds = useMemo(() => new Set(
|
||||||
|
mailboxes
|
||||||
|
.filter(
|
||||||
|
(m) =>
|
||||||
|
m.id !== selectedMailbox &&
|
||||||
|
m.role !== "drafts" &&
|
||||||
|
!m.id.startsWith("shared-") &&
|
||||||
|
m.myRights?.mayAddItems
|
||||||
|
)
|
||||||
|
.map((m) => m.id)
|
||||||
|
), [mailboxes, selectedMailbox]);
|
||||||
|
|
||||||
|
const moveTree = useMemo(() => {
|
||||||
|
const tree = buildMailboxTree(mailboxes);
|
||||||
|
const filterTree = (nodes: MailboxNode[]): MailboxNode[] => {
|
||||||
|
return nodes.reduce<MailboxNode[]>((acc, node) => {
|
||||||
|
const filteredChildren = filterTree(node.children);
|
||||||
|
if (moveTargetIds.has(node.id) || filteredChildren.length > 0) {
|
||||||
|
acc.push({ ...node, children: filteredChildren });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
|
return filterTree(tree);
|
||||||
|
}, [mailboxes, moveTargetIds]);
|
||||||
|
|
||||||
|
// Get mailbox icon based on role
|
||||||
|
const getMoveMailboxIcon = (role?: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case "inbox": return Inbox;
|
||||||
|
case "sent": return Send;
|
||||||
|
case "drafts": return File;
|
||||||
|
case "trash": return Trash2;
|
||||||
|
case "archive": return Archive;
|
||||||
|
default: return Folder;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Close dropdown menus on click outside
|
// Close dropdown menus on click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!moreMenuOpen && !tagMenuOpen) return;
|
if (!moreMenuOpen && !tagMenuOpen && !moveMenuOpen) return;
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
if (moreMenuOpen && moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) {
|
if (moreMenuOpen && moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) {
|
||||||
setMoreMenuOpen(false);
|
setMoreMenuOpen(false);
|
||||||
@@ -457,15 +507,19 @@ export function EmailViewer({
|
|||||||
if (tagMenuOpen && tagMenuRef.current && !tagMenuRef.current.contains(e.target as Node)) {
|
if (tagMenuOpen && tagMenuRef.current && !tagMenuRef.current.contains(e.target as Node)) {
|
||||||
setTagMenuOpen(false);
|
setTagMenuOpen(false);
|
||||||
}
|
}
|
||||||
|
if (moveMenuOpen && moveMenuRef.current && !moveMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setMoveMenuOpen(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, [moreMenuOpen, tagMenuOpen]);
|
}, [moreMenuOpen, tagMenuOpen, moveMenuOpen]);
|
||||||
|
|
||||||
// Close dropdowns when email changes
|
// Close dropdowns when email changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMoreMenuOpen(false);
|
setMoreMenuOpen(false);
|
||||||
setTagMenuOpen(false);
|
setTagMenuOpen(false);
|
||||||
|
setMoveMenuOpen(false);
|
||||||
}, [email?.id]);
|
}, [email?.id]);
|
||||||
|
|
||||||
// Contact sidebar state
|
// Contact sidebar state
|
||||||
@@ -1120,6 +1174,55 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/* Move to folder - hidden on mobile, available in More menu */}
|
||||||
|
{moveTree.length > 0 && onMoveToMailbox && (
|
||||||
|
<div ref={moveMenuRef} 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"
|
||||||
|
title={t('move_to')}
|
||||||
|
>
|
||||||
|
<FolderInput className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('move_to')}</span>
|
||||||
|
</button>
|
||||||
|
{moveMenuOpen && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-lg shadow-lg border border-border z-10">
|
||||||
|
{(() => {
|
||||||
|
const renderNodes = (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); setMoveMenuOpen(false); }}
|
||||||
|
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
|
||||||
|
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground"
|
||||||
|
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{node.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.children.length > 0 && renderNodes(node.children, depth + 1)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return renderNodes(moveTree);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1139,7 +1242,7 @@ export function EmailViewer({
|
|||||||
{/* Tag Picker - click-based, hidden on mobile (available in More menu) */}
|
{/* Tag Picker - click-based, hidden on mobile (available in More menu) */}
|
||||||
<div ref={tagMenuRef} className="relative hidden sm:block">
|
<div ref={tagMenuRef} className="relative hidden sm:block">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); }}
|
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(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"
|
||||||
@@ -1213,7 +1316,7 @@ export function EmailViewer({
|
|||||||
size="sm"
|
size="sm"
|
||||||
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"
|
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); }}
|
onClick={() => { setMoreMenuOpen(!moreMenuOpen); setTagMenuOpen(false); setMoveMenuOpen(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>
|
<span className="text-[10px] leading-tight sm:hidden">{t('more_actions')}</span>
|
||||||
@@ -1241,7 +1344,46 @@ export function EmailViewer({
|
|||||||
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Tag submenu on mobile */}
|
{/* Move to folder submenu on mobile */}
|
||||||
|
{moveTree.length > 0 && onMoveToMailbox && (
|
||||||
|
<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('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-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2"
|
||||||
|
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 text-sm flex items-center gap-2 text-muted-foreground"
|
||||||
|
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 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" />
|
||||||
|
</div>
|
||||||
|
)} {/* Tag submenu on mobile */}
|
||||||
{colorOptions.length > 0 && (
|
{colorOptions.length > 0 && (
|
||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
@@ -1449,6 +1591,55 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/* Move to folder - hidden on mobile, available in More menu */}
|
||||||
|
{moveTree.length > 0 && onMoveToMailbox && (
|
||||||
|
<div ref={moveMenuRef} 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"
|
||||||
|
title={t('move_to')}
|
||||||
|
>
|
||||||
|
<FolderInput className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('move_to')}</span>
|
||||||
|
</button>
|
||||||
|
{moveMenuOpen && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-lg shadow-lg border border-border z-10">
|
||||||
|
{(() => {
|
||||||
|
const renderNodes = (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); setMoveMenuOpen(false); }}
|
||||||
|
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
|
||||||
|
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground"
|
||||||
|
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{node.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.children.length > 0 && renderNodes(node.children, depth + 1)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return renderNodes(moveTree);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1478,7 +1669,7 @@ export function EmailViewer({
|
|||||||
{/* Tag Picker - click-based, hidden on mobile (available in More menu) */}
|
{/* Tag Picker - click-based, hidden on mobile (available in More menu) */}
|
||||||
<div ref={tagMenuRef} className="relative hidden sm:block">
|
<div ref={tagMenuRef} className="relative hidden sm:block">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); }}
|
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(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"
|
||||||
@@ -1552,7 +1743,7 @@ export function EmailViewer({
|
|||||||
size="sm"
|
size="sm"
|
||||||
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"
|
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); }}
|
onClick={() => { setMoreMenuOpen(!moreMenuOpen); setTagMenuOpen(false); setMoveMenuOpen(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>
|
<span className="text-[10px] leading-tight sm:hidden">{t('more_actions')}</span>
|
||||||
@@ -1580,7 +1771,46 @@ export function EmailViewer({
|
|||||||
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Tag submenu on mobile */}
|
{/* Move to folder submenu on mobile */}
|
||||||
|
{moveTree.length > 0 && onMoveToMailbox && (
|
||||||
|
<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('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-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2"
|
||||||
|
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 text-sm flex items-center gap-2 text-muted-foreground"
|
||||||
|
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 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" />
|
||||||
|
</div>
|
||||||
|
)} {/* Tag submenu on mobile */}
|
||||||
{colorOptions.length > 0 && (
|
{colorOptions.length > 0 && (
|
||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
@@ -2216,12 +2446,12 @@ export function EmailViewer({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{email.to && email.to.length > 0 && (
|
{email.to && email.to.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span>→ {t('recipient_to_prefix')}</span>
|
<span>→ {t('recipient_to_prefix')}</span>
|
||||||
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
|
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"set_color": "Label setzen",
|
"set_color": "Label setzen",
|
||||||
"tag": "Label",
|
"tag": "Label",
|
||||||
"more_actions": "Weitere Aktionen",
|
"more_actions": "Weitere Aktionen",
|
||||||
|
"move_to": "Verschieben nach...",
|
||||||
"remove_color": "Label entfernen",
|
"remove_color": "Label entfernen",
|
||||||
"more_count": "+{count} weitere",
|
"more_count": "+{count} weitere",
|
||||||
"characters_count": "{count} Zeichen",
|
"characters_count": "{count} Zeichen",
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"set_color": "Set tag",
|
"set_color": "Set tag",
|
||||||
"tag": "Tag",
|
"tag": "Tag",
|
||||||
"more_actions": "More actions",
|
"more_actions": "More actions",
|
||||||
|
"move_to": "Move to...",
|
||||||
"remove_color": "Remove tag",
|
"remove_color": "Remove tag",
|
||||||
"more_count": "+{count} more",
|
"more_count": "+{count} more",
|
||||||
"characters_count": "{count} characters",
|
"characters_count": "{count} characters",
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"set_color": "Establecer etiqueta",
|
"set_color": "Establecer etiqueta",
|
||||||
"tag": "Etiqueta",
|
"tag": "Etiqueta",
|
||||||
"more_actions": "Más acciones",
|
"more_actions": "Más acciones",
|
||||||
|
"move_to": "Mover a...",
|
||||||
"remove_color": "Eliminar etiqueta",
|
"remove_color": "Eliminar etiqueta",
|
||||||
"more_count": "+{count} más",
|
"more_count": "+{count} más",
|
||||||
"characters_count": "{count} caracteres",
|
"characters_count": "{count} caracteres",
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"set_color": "Définir l'étiquette",
|
"set_color": "Définir l'étiquette",
|
||||||
"tag": "Étiquette",
|
"tag": "Étiquette",
|
||||||
"more_actions": "Plus d'actions",
|
"more_actions": "Plus d'actions",
|
||||||
|
"move_to": "Déplacer vers...",
|
||||||
"remove_color": "Retirer l'étiquette",
|
"remove_color": "Retirer l'étiquette",
|
||||||
"more_count": "+{count} de plus",
|
"more_count": "+{count} de plus",
|
||||||
"characters_count": "{count} caractères",
|
"characters_count": "{count} caractères",
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"set_color": "Imposta etichetta",
|
"set_color": "Imposta etichetta",
|
||||||
"tag": "Etichetta",
|
"tag": "Etichetta",
|
||||||
"more_actions": "Altre azioni",
|
"more_actions": "Altre azioni",
|
||||||
|
"move_to": "Sposta in...",
|
||||||
"remove_color": "Rimuovi etichetta",
|
"remove_color": "Rimuovi etichetta",
|
||||||
"more_count": "+{count} altri",
|
"more_count": "+{count} altri",
|
||||||
"characters_count": "{count} caratteri",
|
"characters_count": "{count} caratteri",
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"set_color": "ラベルを設定",
|
"set_color": "ラベルを設定",
|
||||||
"tag": "ラベル",
|
"tag": "ラベル",
|
||||||
"more_actions": "その他の操作",
|
"more_actions": "その他の操作",
|
||||||
|
"move_to": "移動...",
|
||||||
"remove_color": "ラベルを削除",
|
"remove_color": "ラベルを削除",
|
||||||
"more_count": "他{count}件",
|
"more_count": "他{count}件",
|
||||||
"characters_count": "{count}文字",
|
"characters_count": "{count}文字",
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"set_color": "Label instellen",
|
"set_color": "Label instellen",
|
||||||
"tag": "Label",
|
"tag": "Label",
|
||||||
"more_actions": "Meer acties",
|
"more_actions": "Meer acties",
|
||||||
|
"move_to": "Verplaatsen naar...",
|
||||||
"remove_color": "Label verwijderen",
|
"remove_color": "Label verwijderen",
|
||||||
"more_count": "+{count} meer",
|
"more_count": "+{count} meer",
|
||||||
"characters_count": "{count} tekens",
|
"characters_count": "{count} tekens",
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
"set_color": "Definir etiqueta",
|
"set_color": "Definir etiqueta",
|
||||||
"tag": "Etiqueta",
|
"tag": "Etiqueta",
|
||||||
"more_actions": "Mais ações",
|
"more_actions": "Mais ações",
|
||||||
|
"move_to": "Mover para...",
|
||||||
"remove_color": "Remover etiqueta",
|
"remove_color": "Remover etiqueta",
|
||||||
"more_count": "+{count} mais",
|
"more_count": "+{count} mais",
|
||||||
"characters_count": "{count} caracteres",
|
"characters_count": "{count} caracteres",
|
||||||
|
|||||||
Reference in New Issue
Block a user