feat: add Pro interface

This commit is contained in:
Linus Rath
2026-05-18 19:17:39 +02:00
parent cf9292262d
commit 98879802ae
19 changed files with 1269 additions and 58 deletions
+8 -1
View File
@@ -21,6 +21,7 @@ interface EmailListItemProps {
email: Email;
selected?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void;
@@ -30,7 +31,7 @@ interface EmailListItemProps {
onMarkAsSpam?: () => void;
}
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
@@ -125,6 +126,12 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
onClick?.();
}
}}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!onDoubleClick) return;
e.preventDefault();
onDoubleClick();
}}
onContextMenu={handleContextMenu}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
+3
View File
@@ -23,6 +23,7 @@ interface EmailListProps {
emails: Email[];
selectedEmailId?: string;
onEmailSelect?: (email: Email) => void;
onEmailDoubleClick?: (email: Email) => void;
className?: string;
isLoading?: boolean;
onOpenConversation?: (thread: ThreadGroup) => void;
@@ -44,6 +45,7 @@ export function EmailList({
emails,
selectedEmailId,
onEmailSelect,
onEmailDoubleClick,
className,
isLoading = false,
onOpenConversation,
@@ -447,6 +449,7 @@ export function EmailList({
expandedEmails={threadEmailsCache.get(thread.threadId)}
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
onEmailSelect={(email) => onEmailSelect?.(email)}
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
onContextMenu={openContextMenu}
onOpenConversation={onOpenConversation}
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
+8
View File
@@ -18,6 +18,7 @@ interface ThreadEmailItemProps {
selected?: boolean;
isLast?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
}
@@ -26,6 +27,7 @@ export function ThreadEmailItem({
selected,
isLast = false,
onClick,
onDoubleClick,
onContextMenu,
}: ThreadEmailItemProps) {
const t = useTranslations('email_viewer');
@@ -96,6 +98,12 @@ export function ThreadEmailItem({
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={handleClick}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!onDoubleClick) return;
e.preventDefault();
onDoubleClick();
}}
onContextMenu={handleContextMenu}
style={{ paddingBlock: 'var(--density-item-py)' }}
>
+18 -1
View File
@@ -25,6 +25,7 @@ interface ThreadListItemProps {
expandedEmails?: Email[];
onToggleExpand: () => void;
onEmailSelect: (email: Email) => void;
onEmailDoubleClick?: (email: Email) => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onOpenConversation?: (thread: ThreadGroup) => void;
onToggleStar?: (email: Email) => void;
@@ -39,6 +40,7 @@ interface SingleEmailItemProps {
email: Email;
selected: boolean;
onClick: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean;
colorTag: string | null;
@@ -51,7 +53,7 @@ interface SingleEmailItemProps {
}
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
const t = useTranslations('email_viewer');
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
@@ -146,6 +148,12 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={handleClick}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!onDoubleClick) return;
e.preventDefault();
onDoubleClick();
}}
onContextMenu={handleContextMenu}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
@@ -351,6 +359,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
expandedEmails,
onToggleExpand,
onEmailSelect,
onEmailDoubleClick,
onContextMenu,
onOpenConversation,
onToggleStar,
@@ -420,6 +429,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
email={latestEmail}
selected={selectedEmailId === latestEmail.id}
onClick={() => onEmailSelect(latestEmail)}
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
onContextMenu={onContextMenu}
showPreview={showPreview}
colorTag={colorTag}
@@ -506,6 +516,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
isThreadPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30"
)}
onClick={handleHeaderClick}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!onEmailDoubleClick) return;
e.preventDefault();
onEmailDoubleClick(latestEmail);
}}
onContextMenu={handleContextMenu}
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
@@ -762,6 +778,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
selected={email.id === selectedEmailId}
isLast={index === emailsToShow.length - 1}
onClick={() => onEmailSelect(email)}
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(email) : undefined}
onContextMenu={onContextMenu}
/>
))
+44 -8
View File
@@ -45,6 +45,19 @@ interface NavigationRailProps {
onInlineApp?: (appId: string, url: string, name: string) => void;
onCloseInlineApp?: () => void;
activeAppId?: string | null;
/**
* If provided, intercepts the rail's built-in route navigation. Return
* `true` to prevent the underlying `<Link>` from navigating — used by the
* Pro interface to open the route as a tab instead. The visual rail is
* unchanged.
*/
onNavigate?: (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => boolean | void;
/**
* When `onNavigate` is in use, this controls which nav item the rail
* highlights as active (since the URL alone no longer reflects the
* active app).
*/
activeItemId?: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null;
}
function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) {
@@ -160,6 +173,8 @@ export function NavigationRail({
onInlineApp,
onCloseInlineApp,
activeAppId,
onNavigate,
activeItemId,
}: NavigationRailProps) {
const t = useTranslations("sidebar");
const pathname = usePathname();
@@ -259,18 +274,39 @@ export function NavigationRail({
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled },
];
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
// When the host (e.g. the Pro shell) takes over navigation via `onNavigate`,
// it tells us which item is active; otherwise we infer it from the URL.
const isSettingsActive = onNavigate
? activeItemId === 'settings'
: !activeAppId && pathname.startsWith("/settings");
const visibleItems = navItems.filter((item) => !item.hidden);
const getIsActive = (href: string) => {
const getIsActive = (href: string, itemId: string) => {
if (activeAppId) return false;
if (onNavigate) {
return activeItemId === itemId;
}
if (href === "/") {
return pathname === "/" || pathname === "";
}
return pathname.startsWith(href);
};
const handleNavClick = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') =>
(e: React.MouseEvent) => {
if (onNavigate) {
const intercepted = onNavigate(itemId);
if (intercepted !== false) {
e.preventDefault();
}
return;
}
if (activeAppId) {
onCloseInlineApp?.();
}
};
if (orientation === "horizontal") {
return (
<nav
@@ -279,13 +315,13 @@ export function NavigationRail({
aria-label={t("nav_label")}
>
{visibleItems.map((item) => {
const isActive = getIsActive(item.href);
const isActive = getIsActive(item.href, item.id);
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
@@ -375,7 +411,7 @@ export function NavigationRail({
{/* Settings */}
<Link
href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
onClick={handleNavClick('settings')}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
@@ -429,13 +465,13 @@ export function NavigationRail({
aria-label={t("nav_label")}
>
{visibleItems.map((item) => {
const isActive = getIsActive(item.href);
const isActive = getIsActive(item.href, item.id);
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
data-tour={`nav-${item.id}`}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
@@ -555,7 +591,7 @@ export function NavigationRail({
<Link
href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
onClick={handleNavClick('settings')}
data-tour="nav-settings"
className={cn(
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer";
import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { useProTabStore, type ProComposeTabData } from "@/stores/pro-tab-store";
import { debug } from "@/lib/debug";
interface ProComposeTabBodyProps {
tabId: string;
data: ProComposeTabData;
}
/**
* Renders a standalone `<EmailComposer />` inside its own Pro tab. Sending,
* draft autosave, and discard all flow through the shared `email-store`, so
* the result is identical to composing inline in the mail page — the
* composer is just hosted in its own tab instead of in the right pane.
*/
export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const t = useTranslations();
const client = useAuthStore((s) => s.client);
const sendEmail = useEmailStore((s) => s.sendEmail);
const fetchEmails = useEmailStore((s) => s.fetchEmails);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
const closeTab = useProTabStore((s) => s.closeTab);
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
// Keep stable references for the callbacks below so the composer's
// `key={sessionId}` doesn't churn.
const tabIdRef = useRef(tabId);
tabIdRef.current = tabId;
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
if (!client) return;
try {
await sendEmail(
client,
sendData.to,
sendData.subject,
sendData.body,
sendData.cc,
sendData.bcc,
sendData.identityId,
sendData.fromEmail,
sendData.draftId,
sendData.fromName,
sendData.htmlBody,
sendData.attachments,
sendData.inReplyTo,
sendData.references,
sendData.envelopeMailFrom,
);
// Mark the original message as $answered / $forwarded so the standard
// viewer and list reflect the action (same behaviour as inline compose).
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
try {
await client.setKeyword(data.sourceEmailId, '$answered');
} catch (e) {
debug.error('Failed to set $answered keyword:', e);
}
} else if (data.sourceEmailId && data.mode === 'forward') {
try {
await client.setKeyword(data.sourceEmailId, '$forwarded');
} catch (e) {
debug.error('Failed to set $forwarded keyword:', e);
}
}
// Refresh the currently-active mail list so the new sent message /
// updated keyword status shows up.
await fetchEmails(client, selectedMailbox);
closeTab(tabIdRef.current);
} catch (error) {
console.error('Failed to send email:', error);
toast.error(t('notifications.error_sending'));
}
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t]);
const handleClose = useCallback(() => {
closeTab(tabIdRef.current);
}, [closeTab]);
const handleDiscardDraft = useCallback(async (draftId: string) => {
if (!client) return;
try {
await client.deleteEmail(draftId);
} catch (error) {
console.error('Failed to discard draft:', error);
}
}, [client]);
const handleSaveState = useCallback((state: ComposerDraftData) => {
updateComposeDraft(tabIdRef.current, state);
// Keep the tab title in sync with the working subject.
const subject = state.subject?.trim() || t('email_composer.new_message');
updateTabTitle(tabIdRef.current, subject);
}, [updateComposeDraft, updateTabTitle, t]);
// On first mount, ensure the tab title matches whatever subject we were
// initialised with (replies start with "Re: …", forwards with "Fwd: …").
useEffect(() => {
const initialSubject = data.initialData?.subject?.trim()
?? data.replyTo?.subject
?? '';
const title = initialSubject || t('email_composer.new_message');
updateTabTitle(tabIdRef.current, title);
// Run once on mount only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="flex h-full w-full flex-col bg-background">
<ErrorBoundary fallback={ComposerErrorFallback}>
<EmailComposer
key={data.sessionId}
mode={data.initialData?.mode ?? data.mode}
replyTo={data.replyTo}
initialDraftText={data.initialDraftText}
initialData={data.initialData}
onSend={handleSend}
onClose={handleClose}
onDiscardDraft={handleDiscardDraft}
onSaveState={handleSaveState}
className="flex-1"
/>
</ErrorBoundary>
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { EmailViewer } from "@/components/email/email-viewer";
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
import type { Email } from "@/lib/jmap/types";
interface ProEmailTabBodyProps {
tabId: string;
data: ProEmailTabData;
}
function buildReplyContext(email: Email): ProReplyContext {
const textPartId = email.textBody?.[0]?.partId ?? '';
const htmlPartId = email.htmlBody?.[0]?.partId ?? '';
return {
from: email.from,
replyToAddresses: email.replyTo,
to: email.to,
cc: email.cc,
bcc: email.bcc,
subject: email.subject,
body: email.bodyValues?.[textPartId]?.value || email.preview || '',
htmlBody: email.bodyValues?.[htmlPartId]?.value || undefined,
receivedAt: email.receivedAt,
accountId: email.accountId,
attachments: email.attachments,
messageId: email.messageId,
inReplyTo: email.inReplyTo,
references: email.references,
};
}
/**
* Renders a single email in its own Pro tab. Fetches the email content on
* mount via `email-store.fetchEmailContent` so the tab is self-sufficient —
* it doesn't depend on what the Mail tab has selected.
*/
export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
const t = useTranslations();
const tNotifications = useTranslations('notifications');
const client = useAuthStore((s) => s.client);
const fetchEmailContent = useEmailStore((s) => s.fetchEmailContent);
const deleteEmail = useEmailStore((s) => s.deleteEmail);
const markAsRead = useEmailStore((s) => s.markAsRead);
const toggleStar = useEmailStore((s) => s.toggleStar);
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
const mailboxes = useEmailStore((s) => s.mailboxes);
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
const closeTab = useProTabStore((s) => s.closeTab);
const openComposeTab = useProTabStore((s) => s.openComposeTab);
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
const [email, setEmail] = useState<Email | null>(null);
const [isLoading, setIsLoading] = useState(true);
const composerSessionIdRef = useRef(0);
useEffect(() => {
let cancelled = false;
if (!client) return;
setIsLoading(true);
fetchEmailContent(client, data.emailId)
.then((loaded) => {
if (cancelled) return;
setEmail(loaded);
if (loaded?.subject) {
updateTabTitle(tabId, loaded.subject);
}
})
.catch((err) => {
console.error('Failed to fetch email for Pro tab:', err);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [client, data.emailId, fetchEmailContent, tabId, updateTabTitle]);
const currentMailboxRole = useMemo(() => {
if (!email) return undefined;
const mb = email.mailboxIds
? Object.keys(email.mailboxIds).map((id) => mailboxes.find((m) => m.id === id)).find(Boolean)
: undefined;
return mb?.role;
}, [email, mailboxes]);
const handleReply = useCallback((draftText?: string) => {
if (!email) return;
composerSessionIdRef.current += 1;
openComposeTab({
sessionId: composerSessionIdRef.current,
mode: 'reply',
replyTo: buildReplyContext(email),
sourceEmailId: email.id,
initialDraftText: draftText,
title: `Re: ${email.subject || t('email_composer.new_message')}`,
});
}, [email, openComposeTab, t]);
const handleReplyAll = useCallback(() => {
if (!email) return;
composerSessionIdRef.current += 1;
openComposeTab({
sessionId: composerSessionIdRef.current,
mode: 'replyAll',
replyTo: buildReplyContext(email),
sourceEmailId: email.id,
title: `Re: ${email.subject || t('email_composer.new_message')}`,
});
}, [email, openComposeTab, t]);
const handleForward = useCallback(() => {
if (!email) return;
composerSessionIdRef.current += 1;
openComposeTab({
sessionId: composerSessionIdRef.current,
mode: 'forward',
replyTo: buildReplyContext(email),
sourceEmailId: email.id,
title: `Fwd: ${email.subject || t('email_composer.new_message')}`,
});
}, [email, openComposeTab, t]);
const handleDelete = useCallback(async () => {
if (!client || !email) return;
try {
await deleteEmail(client, email.id);
closeTab(tabId);
} catch (err) {
console.error('Delete failed:', err);
toast.error(tNotifications('error_deleting'));
}
}, [client, email, deleteEmail, closeTab, tabId, tNotifications]);
const handleArchive = useCallback(async () => {
if (!client || !email) return;
const archiveMb = mailboxes.find((m) => m.role === 'archive');
if (!archiveMb) return;
try {
await moveToMailbox(client, email.id, archiveMb.id);
toast.success(tNotifications('email_archived'));
closeTab(tabId);
} catch (err) {
console.error('Archive failed:', err);
}
}, [client, email, mailboxes, moveToMailbox, closeTab, tabId, tNotifications]);
const handleToggleStar = useCallback(async () => {
if (!client || !email) return;
try {
await toggleStar(client, email.id);
// Reflect locally — the viewer re-reads from email-store's selectedEmail
// shape only for the mail tab; here we update our local copy too.
setEmail((prev) => prev ? {
...prev,
keywords: {
...prev.keywords,
$flagged: !prev.keywords?.$flagged,
},
} : prev);
} catch (err) {
console.error('Toggle star failed:', err);
}
}, [client, email, toggleStar]);
const handleMarkAsRead = useCallback(async (emailId: string, read: boolean) => {
if (!client) return;
try {
await markAsRead(client, emailId, read);
setEmail((prev) => prev && prev.id === emailId ? {
...prev,
keywords: { ...prev.keywords, $seen: read },
} : prev);
} catch (err) {
console.error('Mark as read failed:', err);
}
}, [client, markAsRead]);
const handleSetColorTag = useCallback((emailId: string, color: string | null) => {
if (!email || email.id !== emailId) return;
// Drop existing color keywords, optionally add the new one. Matches the
// mail page's local optimistic update.
const keywords = { ...(email.keywords ?? {}) };
for (const kw of settingsKeywords) {
delete keywords[`$label:${kw.id}`];
}
if (color) {
const def = settingsKeywords.find((k) => k.color === color);
if (def) keywords[`$label:${def.id}`] = true;
}
setEmailKeywordsLocal(emailId, keywords);
setEmail({ ...email, keywords });
}, [email, settingsKeywords, setEmailKeywordsLocal]);
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
if (!client || !email) return;
try {
await moveToMailbox(client, email.id, mailboxId);
closeTab(tabId);
} catch (err) {
console.error('Move failed:', err);
}
}, [client, email, moveToMailbox, closeTab, tabId]);
const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string) => {
if (!client) return;
try {
await client.downloadBlob(blobId, name, type);
} catch (err) {
console.error('Download failed:', err);
}
}, [client]);
const handleQuickReply = useCallback(async (body: string) => {
handleReply(body);
}, [handleReply]);
return (
<div className="flex h-full w-full flex-col bg-background">
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
email={email}
isLoading={isLoading}
onReply={handleReply}
onReplyAll={handleReplyAll}
onForward={handleForward}
onDelete={handleDelete}
onArchive={handleArchive}
onToggleStar={handleToggleStar}
onMarkAsRead={handleMarkAsRead}
onSetColorTag={handleSetColorTag}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
onMoveToMailbox={handleMoveToMailbox}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split('@')[0]}
currentMailboxRole={currentMailboxRole}
mailboxes={mailboxes}
className="flex-1"
/>
</ErrorBoundary>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useTranslations } from "next-intl";
import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, X, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ProTab, ProTabKind } from "@/stores/pro-tab-store";
interface ProTabBarProps {
tabs: ProTab[];
activeTabId: string;
onActivate: (id: string) => void;
onClose: (id: string) => void;
className?: string;
}
const TAB_ICONS: Record<ProTabKind, LucideIcon> = {
mail: Mail,
calendar: Calendar,
contacts: BookUser,
files: HardDrive,
settings: Settings,
compose: PenSquare,
email: MailOpen,
};
export function ProTabBar({ tabs, activeTabId, onActivate, onClose, className }: ProTabBarProps) {
const tSidebar = useTranslations("sidebar");
return (
<div
className={cn(
"flex items-stretch h-9 bg-secondary px-1 overflow-x-auto scroll-hidden flex-shrink-0",
className
)}
style={{ borderBottom: '1px solid rgba(128, 128, 128, 0.3)' }}
role="tablist"
>
{tabs.map((tab) => {
const Icon = TAB_ICONS[tab.kind];
const isActive = tab.id === activeTabId;
const label = tab.title ?? tSidebar(tab.labelKey);
return (
<div
key={tab.id}
role="tab"
aria-selected={isActive}
onClick={() => onActivate(tab.id)}
onMouseDown={(e) => {
// Middle-click closes the tab (when closeable) — matches browser-tab behavior.
if (e.button === 1 && tab.closeable) {
e.preventDefault();
onClose(tab.id);
}
}}
className={cn(
// Equal-width tabs that grow up to 200px when there's room and
// shrink down to ~64px when the bar would overflow — matches
// browser/Thunderbird tab behaviour.
"group relative flex items-center gap-1.5 px-3 h-9 text-sm cursor-pointer select-none transition-colors",
"min-w-0 flex-1 basis-0 max-w-[200px] [min-width:80px]",
"border-r border-border first:border-l",
isActive
? "bg-background text-foreground font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
style={
isActive
? { borderRightColor: 'rgba(128, 128, 128, 0.3)', borderLeftColor: 'rgba(128, 128, 128, 0.3)' }
: undefined
}
>
<Icon className={cn("w-4 h-4 flex-shrink-0", isActive && "text-primary")} />
<span className="truncate flex-1 min-w-0" title={label}>{label}</span>
{tab.closeable && (
<button
onClick={(e) => {
e.stopPropagation();
onClose(tab.id);
}}
className={cn(
"ml-1 flex items-center justify-center w-4 h-4 rounded-sm transition-colors flex-shrink-0",
"text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground",
!isActive && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
)}
aria-label={tSidebar("close") /* falls back gracefully if missing */}
tabIndex={isActive ? 0 : -1}
>
<X className="w-3 h-3" />
</button>
)}
{isActive && (
<span
className="absolute left-0 right-0 -bottom-px h-px bg-background"
aria-hidden="true"
/>
)}
</div>
);
})}
</div>
);
}
+21 -1
View File
@@ -1,11 +1,13 @@
"use client";
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
import { useMediaQuery } from '@/hooks/use-media-query';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
@@ -115,9 +117,10 @@ function MailLayoutPreview({
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, updateSetting } = useSettingsStore();
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const isDesktop = useMediaQuery('(min-width: 1024px)');
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -188,6 +191,23 @@ export function LayoutSettings() {
/>
</SettingItem>
)}
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
<div className="flex items-center gap-3">
{proInterface && isDesktop && (
<Link
href="/pro"
className="text-sm font-medium text-primary hover:underline"
>
{t('pro_interface.open_label')}
</Link>
)}
<ToggleSwitch
checked={proInterface}
onChange={(v) => updateSetting('proInterface', v)}
/>
</div>
</SettingItem>
</SettingsSection>
);
}