feat: add keyboard shortcuts button and show/hide toolbar labels option

- Implemented a keyboard shortcuts button in the navigation rail.
- Added a setting to toggle the visibility of toolbar labels in appearance settings.
- Updated the email context menu to streamline actions for archiving, deleting, and marking emails as read/unread.
- Enhanced the email viewer with improved spam handling and read state toggling.
- Updated localization files to include new strings for keyboard shortcuts and toolbar labels.
This commit is contained in:
Linus Rath
2026-03-16 16:19:51 +01:00
parent f2f3e1d85c
commit 3cd123778e
7 changed files with 329 additions and 205 deletions
+1
View File
@@ -941,6 +941,7 @@ export default function Home() {
quota={quota} quota={quota}
isPushConnected={isPushConnected} isPushConnected={isPushConnected}
onLogout={handleLogout} onLogout={handleLogout}
onShowShortcuts={() => setShowShortcutsModal(true)}
/> />
</div> </div>
)} )}
+49 -51
View File
@@ -213,28 +213,24 @@ export function EmailContextMenu({
</> </>
)} )}
{/* Mark as read/unread */} {/* Archive */}
<ContextMenuItem <ContextMenuItem
icon={isUnread ? MailOpen : Mail} icon={Archive}
label={isUnread ? t("mark_read") : t("mark_unread")} label={t("archive")}
onClick={() => onClick={() => handleAction(onArchive!)}
handleAction(() => disabled={!onArchive}
showBatchActions
? onBatchMarkAsRead?.(isUnread)
: onMarkAsRead?.(isUnread)
)
}
/> />
{/* Star/Unstar - only for single email */} {/* Delete */}
{!showBatchActions && ( <ContextMenuItem
<ContextMenuItem icon={Trash2}
icon={Star} label={t("delete")}
label={isStarred ? t("unstar") : t("star")} onClick={() =>
onClick={() => handleAction(onToggleStar!)} handleAction(showBatchActions ? onBatchDelete! : onDelete!)
disabled={!onToggleStar} }
/> disabled={showBatchActions ? !onBatchDelete : !onDelete}
)} destructive
/>
<ContextMenuSeparator /> <ContextMenuSeparator />
@@ -280,32 +276,15 @@ export function EmailContextMenu({
</ContextMenuSubMenu> </ContextMenuSubMenu>
)} )}
{/* Archive */} {/* Star/Unstar - only for single email */}
<ContextMenuItem {!showBatchActions && (
icon={Archive} <ContextMenuItem
label={t("archive")} icon={Star}
onClick={() => handleAction(onArchive!)} label={isStarred ? t("unstar") : t("star")}
disabled={!onArchive} onClick={() => handleAction(onToggleStar!)}
/> disabled={!onToggleStar}
/>
<ContextMenuSeparator /> )}
{/* Spam - contextual based on folder */}
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
onClick={() =>
handleAction(
showBatchActions
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
)
}
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
destructive={!isInJunkFolder}
/>
<ContextMenuSeparator />
{/* Set tag submenu - only for single email */} {/* Set tag submenu - only for single email */}
{!showBatchActions && ( {!showBatchActions && (
@@ -342,15 +321,34 @@ export function EmailContextMenu({
<ContextMenuSeparator /> <ContextMenuSeparator />
{/* Delete */} {/* Spam - contextual based on folder */}
<ContextMenuItem <ContextMenuItem
icon={Trash2} icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={t("delete")} label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
onClick={() => onClick={() =>
handleAction(showBatchActions ? onBatchDelete! : onDelete!) handleAction(
showBatchActions
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
)
}
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
destructive={!isInJunkFolder}
/>
<ContextMenuSeparator />
{/* Mark as read/unread */}
<ContextMenuItem
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
onClick={() =>
handleAction(() =>
showBatchActions
? onBatchMarkAsRead?.(isUnread)
: onMarkAsRead?.(isUnread)
)
} }
disabled={showBatchActions ? !onBatchDelete : !onDelete}
destructive
/> />
</ContextMenu> </ContextMenu>
); );
+250 -152
View File
@@ -23,6 +23,7 @@ import {
ChevronRight, ChevronRight,
Download, Download,
Mail, Mail,
MailOpen,
Clock, Clock,
Loader2, Loader2,
Printer, Printer,
@@ -487,6 +488,7 @@ export function EmailViewer({
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
// Detect if current mailbox is Junk folder // Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk'; const isInJunkFolder = currentMailboxRole === 'junk';
@@ -1205,6 +1207,7 @@ export function EmailViewer({
const sender = email.from?.[0]; const sender = email.from?.[0];
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isUnread = !email.keywords?.$seen;
const isImportant = email.keywords?.["$important"]; const isImportant = email.keywords?.["$important"];
return ( return (
@@ -1240,19 +1243,6 @@ export function EmailViewer({
<Archive className="w-5 h-5" /> <Archive className="w-5 h-5" />
{t('archive')} {t('archive')}
</button> </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 */} {/* Move to folder */}
{moveTree.length > 0 && onMoveToMailbox && ( {moveTree.length > 0 && onMoveToMailbox && (
<> <>
@@ -1324,6 +1314,28 @@ export function EmailViewer({
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
</> </>
)} )}
{/* Spam */}
{(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>
)}
{/* Toggle read state */}
<button
onClick={() => { onMarkAsRead?.(email.id, isUnread); 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"
>
{isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />}
{isUnread ? t('mark_read') : t('mark_unread')}
</button>
<button <button
onClick={() => { handlePrint(); setMoreMenuOpen(false); }} 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" className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
@@ -1390,7 +1402,7 @@ export function EmailViewer({
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> {showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reply')}</span>}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -1400,7 +1412,7 @@ export function EmailViewer({
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> {showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reply_all')}</span>}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -1410,18 +1422,18 @@ export function EmailViewer({
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> {showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
</Button> </Button>
</div> </div>
{/* Right: Organize actions */} {/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print */}
<div className="flex items-center gap-0 sm: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, overflows to More menu */} {/* Archive */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -1432,8 +1444,9 @@ export function EmailViewer({
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> {showToolbarLabels && <span className="hidden sm:inline text-sm">{t('archive')}</span>}
</Button> </Button>
{/* Delete */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -1442,40 +1455,21 @@ export function EmailViewer({
title={t('tooltips.delete')} title={t('tooltips.delete')}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
<span className="text-[10px] leading-tight sm:text-sm">{t('delete')}</span> {showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('delete')}</span>}
</Button> </Button>
{/* Spam - hidden on mobile, overflows to More menu */} {/* Move to folder — promoted to Button, same design as archive/delete */}
{(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"
)}
title={isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
>
{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" />
)}
</Button>
)}
{/* Move to folder - hidden on mobile, overflows to More menu */}
{moveTree.length > 0 && onMoveToMailbox && ( {moveTree.length > 0 && onMoveToMailbox && (
<div ref={moveMenuRef} data-overflow-item data-overflow-priority="3" className="relative hidden sm:block"> <div ref={moveMenuRef} data-overflow-item data-overflow-priority="2" className="relative hidden sm:block">
<button <Button
variant="ghost"
size="sm"
onClick={() => { setMoveMenuOpen(!moveMenuOpen); setMoreMenuOpen(false); setTagMenuOpen(false); }} onClick={() => { setMoveMenuOpen(!moveMenuOpen); setMoreMenuOpen(false); setTagMenuOpen(false); }}
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2" className="h-8 gap-1.5"
title={t('move_to')} title={t('move_to')}
> >
<FolderInput className="w-4 h-4 text-muted-foreground" /> <FolderInput className="w-4 h-4" />
<span className="text-xs text-muted-foreground">{t('move_to')}</span> {showToolbarLabels && <span className="text-sm">{t('move_to')}</span>}
</button> </Button>
{moveMenuOpen && ( {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"> <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">
{(() => { {(() => {
@@ -1514,11 +1508,12 @@ export function EmailViewer({
)} )}
</div> </div>
)} )}
{/* Star/Flag toggle */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onToggleStar} onClick={onToggleStar}
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" 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-1.5 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(
@@ -1528,8 +1523,8 @@ export function EmailViewer({
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span> <span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
</Button> </Button>
{/* Tag Picker + Divider - hidden on mobile, overflows to More menu */} {/* Tag Picker hidden on mobile, overflows to More menu */}
<div data-overflow-item data-overflow-priority="4" className="hidden sm:flex items-center"> <div data-overflow-item data-overflow-priority="3" className="hidden sm:flex items-center">
<div className="w-px h-5 bg-border mx-0.5" /> <div className="w-px h-5 bg-border mx-0.5" />
<div ref={tagMenuRef} className="relative"> <div ref={tagMenuRef} className="relative">
<button <button
@@ -1546,12 +1541,12 @@ export function EmailViewer({
return dotClass ? ( return dotClass ? (
<> <>
<span className={cn("w-3 h-3 rounded-full", dotClass)} /> <span className={cn("w-3 h-3 rounded-full", dotClass)} />
<span className="text-xs font-medium text-foreground">{kw!.label}</span> {showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>}
</> </>
) : ( ) : (
<> <>
<Tag className="w-4 h-4 text-muted-foreground" /> <Tag className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{t('tag')}</span> {showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</> </>
); );
})()} })()}
@@ -1589,21 +1584,69 @@ export function EmailViewer({
</div> </div>
</div> </div>
{/* Print - hidden on mobile, overflows to 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="4"
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"
)}
title={isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
>
{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" />
)}
</Button>
)}
{/* Toggle read state — hidden on mobile, overflows to More menu */}
<Button
variant="ghost"
size="sm"
onClick={() => onMarkAsRead?.(email.id, isUnread)}
data-overflow-item
data-overflow-priority="5"
className="hidden sm:inline-flex h-8 gap-1.5"
title={isUnread ? t('mark_read') : t('mark_unread')}
>
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
</Button>
{/* Print — hidden on mobile, overflows to More menu */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handlePrint} onClick={handlePrint}
data-overflow-item data-overflow-item
data-overflow-priority="5" data-overflow-priority="6"
className="hidden sm:inline-flex 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> {showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
</Button> </Button>
{/* More menu - click-based */} {/* View source — hidden on mobile, overflows to More menu */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowSourceModal(true)}
data-overflow-item
data-overflow-priority="7"
className="hidden sm:inline-flex h-8 gap-1.5"
title={t('view_source')}
>
<Code className="w-4 h-4" />
</Button>
{/* More menu — click-based */}
<div ref={moreMenuRef} className="relative"> <div ref={moreMenuRef} className="relative">
<Button <Button
variant="ghost" variant="ghost"
@@ -1617,30 +1660,17 @@ export function EmailViewer({
</Button> </Button>
{moreMenuOpen && !isMobile && ( {moreMenuOpen && !isMobile && (
<div className="absolute right-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10"> <div className="absolute right-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10">
{/* Overflow actions - shown when hidden from toolbar or on mobile */} {/* Overflow: archive */}
<button <button
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }} onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
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")} className={cn("w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 7 ? "" : "sm:hidden")}
> >
<Archive className="w-4 h-4" /> <Archive className="w-4 h-4" />
{t('archive')} {t('archive')}
</button> </button>
{(onMarkAsSpam || onUndoSpam) && ( {/* Overflow: move to folder */}
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
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" />
) : (
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
)}
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
</button>
)}
{/* Move to folder submenu */}
{moveTree.length > 0 && onMoveToMailbox && ( {moveTree.length > 0 && onMoveToMailbox && (
<div className={cn(overflowCount >= 3 ? "" : "sm:hidden")}> <div className={cn(overflowCount >= 6 ? "" : "sm:hidden")}>
<div className="h-px bg-border my-1" /> <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> <div className="px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
{(() => { {(() => {
@@ -1677,9 +1707,10 @@ export function EmailViewer({
})()} })()}
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
</div> </div>
)} {/* Tag submenu */} )}
{/* Overflow: tag submenu */}
{colorOptions.length > 0 && ( {colorOptions.length > 0 && (
<div className={cn(overflowCount >= 2 ? "" : "sm:hidden")}> <div className={cn(overflowCount >= 5 ? "" : "sm:hidden")}>
<div className="h-px bg-border my-1" /> <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> <div className="px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => ( {colorOptions.map((option) => (
@@ -1708,16 +1739,40 @@ export function EmailViewer({
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
</div> </div>
)} )}
{/* Overflow: spam */}
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
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" />
) : (
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
)}
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
</button>
)}
{/* Overflow: toggle read */}
<button
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
className={cn("w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 3 ? "" : "sm:hidden")}
>
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
{isUnread ? t('mark_read') : t('mark_unread')}
</button>
{/* Overflow: print */}
<button <button
onClick={() => { handlePrint(); setMoreMenuOpen(false); }} onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
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")} 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 >= 2 ? "" : "sm:hidden")}
> >
<Printer className="w-4 h-4" /> <Printer className="w-4 h-4" />
{t('print')} {t('print')}
</button> </button>
{/* Overflow: view source */}
<button <button
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }} 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" 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")}
> >
<Code className="w-4 h-4" /> <Code className="w-4 h-4" />
{t('view_source')} {t('view_source')}
@@ -1824,7 +1879,7 @@ export function EmailViewer({
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> {showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reply')}</span>}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -1834,7 +1889,7 @@ export function EmailViewer({
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> {showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reply_all')}</span>}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@@ -1844,18 +1899,18 @@ export function EmailViewer({
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> {showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
</Button> </Button>
</div> </div>
{/* Right: Organize actions */} {/* Right: Organize actions — order: archive, delete, move, star, tag, spam, read state, print, view source */}
<div className="flex items-center gap-0 sm: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, overflows to More menu */} {/* Archive */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -1866,40 +1921,32 @@ export function EmailViewer({
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> {showToolbarLabels && <span className="hidden sm:inline text-sm">{t('archive')}</span>}
</Button> </Button>
{/* Spam - hidden on mobile, overflows to More menu */} {/* Delete */}
{(onMarkAsSpam || onUndoSpam) && ( <Button
<Button variant="ghost"
variant="ghost" size="sm"
size="sm" onClick={onDelete}
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam} 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"
data-overflow-item title={t('tooltips.delete')}
data-overflow-priority="2" >
className={cn( <Trash2 className="w-4 h-4" />
"hidden sm:inline-flex h-8 gap-1.5", {showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('delete')}</span>}
isInJunkFolder ? "hover:bg-green-50 dark:hover:bg-green-950/30" : "hover:bg-red-50 dark:hover:bg-red-950/30" </Button>
)} {/* Move to folder — promoted to Button, same design as archive/delete */}
title={isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
>
{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" />
)}
</Button>
)}
{/* Move to folder - hidden on mobile, overflows to More menu */}
{moveTree.length > 0 && onMoveToMailbox && ( {moveTree.length > 0 && onMoveToMailbox && (
<div ref={moveMenuRef} data-overflow-item data-overflow-priority="3" className="relative hidden sm:block"> <div ref={moveMenuRef} data-overflow-item data-overflow-priority="2" className="relative hidden sm:block">
<button <Button
variant="ghost"
size="sm"
onClick={() => { setMoveMenuOpen(!moveMenuOpen); setMoreMenuOpen(false); setTagMenuOpen(false); }} onClick={() => { setMoveMenuOpen(!moveMenuOpen); setMoreMenuOpen(false); setTagMenuOpen(false); }}
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2" className="h-8 gap-1.5"
title={t('move_to')} title={t('move_to')}
> >
<FolderInput className="w-4 h-4 text-muted-foreground" /> <FolderInput className="w-4 h-4" />
<span className="text-xs text-muted-foreground">{t('move_to')}</span> {showToolbarLabels && <span className="text-sm">{t('move_to')}</span>}
</button> </Button>
{moveMenuOpen && ( {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"> <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">
{(() => { {(() => {
@@ -1938,21 +1985,12 @@ export function EmailViewer({
)} )}
</div> </div>
)} )}
<Button {/* Star/Flag toggle */}
variant="ghost"
size="sm"
onClick={onDelete}
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')}
>
<Trash2 className="w-4 h-4" />
<span className="text-[10px] leading-tight sm:text-sm">{t('delete')}</span>
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onToggleStar} onClick={onToggleStar}
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" 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-1.5 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(
@@ -1962,8 +2000,8 @@ export function EmailViewer({
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span> <span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
</Button> </Button>
{/* Tag Picker + Divider - hidden on mobile, overflows to More menu */} {/* Tag Picker hidden on mobile, overflows to More menu */}
<div data-overflow-item data-overflow-priority="4" className="hidden sm:flex items-center"> <div data-overflow-item data-overflow-priority="3" className="hidden sm:flex items-center">
<div className="w-px h-5 bg-border mx-0.5" /> <div className="w-px h-5 bg-border mx-0.5" />
<div ref={tagMenuRef} className="relative"> <div ref={tagMenuRef} className="relative">
<button <button
@@ -1980,12 +2018,12 @@ export function EmailViewer({
return dotClass ? ( return dotClass ? (
<> <>
<span className={cn("w-3 h-3 rounded-full", dotClass)} /> <span className={cn("w-3 h-3 rounded-full", dotClass)} />
<span className="text-xs font-medium text-foreground">{kw!.label}</span> {showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>}
</> </>
) : ( ) : (
<> <>
<Tag className="w-4 h-4 text-muted-foreground" /> <Tag className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{t('tag')}</span> {showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</> </>
); );
})()} })()}
@@ -2023,21 +2061,69 @@ export function EmailViewer({
</div> </div>
</div> </div>
{/* Print - hidden on mobile, overflows to 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="4"
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"
)}
title={isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
>
{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" />
)}
</Button>
)}
{/* Toggle read state — hidden on mobile, overflows to More menu */}
<Button
variant="ghost"
size="sm"
onClick={() => onMarkAsRead?.(email.id, isUnread)}
data-overflow-item
data-overflow-priority="5"
className="hidden sm:inline-flex h-8 gap-1.5"
title={isUnread ? t('mark_read') : t('mark_unread')}
>
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
</Button>
{/* Print — hidden on mobile, overflows to More menu */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handlePrint} onClick={handlePrint}
data-overflow-item data-overflow-item
data-overflow-priority="5" data-overflow-priority="6"
className="hidden sm:inline-flex 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> {showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
</Button> </Button>
{/* More menu - click-based */} {/* View source — hidden on mobile, overflows to More menu */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowSourceModal(true)}
data-overflow-item
data-overflow-priority="7"
className="hidden sm:inline-flex h-8 gap-1.5"
title={t('view_source')}
>
<Code className="w-4 h-4" />
</Button>
{/* More menu — click-based */}
<div ref={moreMenuRef} className="relative"> <div ref={moreMenuRef} className="relative">
<Button <Button
variant="ghost" variant="ghost"
@@ -2051,30 +2137,17 @@ export function EmailViewer({
</Button> </Button>
{moreMenuOpen && !isMobile && ( {moreMenuOpen && !isMobile && (
<div className="absolute right-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10"> <div className="absolute right-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10">
{/* Overflow actions - shown when hidden from toolbar or on mobile */} {/* Overflow: archive */}
<button <button
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }} onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
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")} className={cn("w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 7 ? "" : "sm:hidden")}
> >
<Archive className="w-4 h-4" /> <Archive className="w-4 h-4" />
{t('archive')} {t('archive')}
</button> </button>
{(onMarkAsSpam || onUndoSpam) && ( {/* Overflow: move to folder */}
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
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" />
) : (
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
)}
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
</button>
)}
{/* Move to folder submenu */}
{moveTree.length > 0 && onMoveToMailbox && ( {moveTree.length > 0 && onMoveToMailbox && (
<div className={cn(overflowCount >= 3 ? "" : "sm:hidden")}> <div className={cn(overflowCount >= 6 ? "" : "sm:hidden")}>
<div className="h-px bg-border my-1" /> <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> <div className="px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
{(() => { {(() => {
@@ -2111,9 +2184,10 @@ export function EmailViewer({
})()} })()}
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
</div> </div>
)} {/* Tag submenu */} )}
{/* Overflow: tag submenu */}
{colorOptions.length > 0 && ( {colorOptions.length > 0 && (
<div className={cn(overflowCount >= 2 ? "" : "sm:hidden")}> <div className={cn(overflowCount >= 5 ? "" : "sm:hidden")}>
<div className="h-px bg-border my-1" /> <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> <div className="px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => ( {colorOptions.map((option) => (
@@ -2142,16 +2216,40 @@ export function EmailViewer({
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
</div> </div>
)} )}
{/* Overflow: spam */}
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
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" />
) : (
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
)}
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
</button>
)}
{/* Overflow: toggle read */}
<button
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); }}
className={cn("w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 3 ? "" : "sm:hidden")}
>
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
{isUnread ? t('mark_read') : t('mark_unread')}
</button>
{/* Overflow: print */}
<button <button
onClick={() => { handlePrint(); setMoreMenuOpen(false); }} onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
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")} 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 >= 2 ? "" : "sm:hidden")}
> >
<Printer className="w-4 h-4" /> <Printer className="w-4 h-4" />
{t('print')} {t('print')}
</button> </button>
{/* Overflow: view source */}
<button <button
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }} 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" 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")}
> >
<Code className="w-4 h-4" /> <Code className="w-4 h-4" />
{t('view_source')} {t('view_source')}
+13 -1
View File
@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut } from "lucide-react"; import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard } from "lucide-react";
import { usePathname, Link } from "@/i18n/navigation"; import { usePathname, Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store"; import { useCalendarStore } from "@/stores/calendar-store";
@@ -26,6 +26,7 @@ interface NavigationRailProps {
quota?: { used: number; total: number } | null; quota?: { used: number; total: number } | null;
isPushConnected?: boolean; isPushConnected?: boolean;
onLogout?: () => void; onLogout?: () => void;
onShowShortcuts?: () => void;
} }
function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) { function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) {
@@ -136,6 +137,7 @@ export function NavigationRail({
quota, quota,
isPushConnected, isPushConnected,
onLogout, onLogout,
onShowShortcuts,
}: NavigationRailProps) { }: NavigationRailProps) {
const t = useTranslations("sidebar"); const t = useTranslations("sidebar");
const pathname = usePathname(); const pathname = usePathname();
@@ -263,6 +265,16 @@ export function NavigationRail({
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} /> <StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
)} )}
{onShowShortcuts && (
<button
onClick={onShowShortcuts}
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("keyboard_shortcuts")}
>
<Keyboard className="w-[18px] h-[18px]" />
</button>
)}
{isPushConnected != null && ( {isPushConnected != null && (
<span <span
className="relative group" className="relative group"
+9 -1
View File
@@ -62,7 +62,7 @@ function DensityPreview({ density }: { density: Density }) {
export function AppearanceSettings() { export function AppearanceSettings() {
const t = useTranslations('settings.appearance'); const t = useTranslations('settings.appearance');
const { theme, setTheme } = useThemeStore(); const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, toolbarPosition, updateSetting } = useSettingsStore(); const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
return ( return (
<SettingsSection title={t('title')} description={t('description')}> <SettingsSection title={t('title')} description={t('description')}>
@@ -126,6 +126,14 @@ export function AppearanceSettings() {
/> />
</SettingItem> </SettingItem>
{/* Toolbar Labels */}
<SettingItem label={t('toolbar_labels.label')} description={t('toolbar_labels.description')}>
<ToggleSwitch
checked={showToolbarLabels}
onChange={(checked) => updateSetting('showToolbarLabels', checked)}
/>
</SettingItem>
{/* Animations */} {/* Animations */}
<SettingItem label={t('animations.label')} description={t('animations.description')}> <SettingItem label={t('animations.label')} description={t('animations.description')}>
<ToggleSwitch <ToggleSwitch
+5
View File
@@ -65,6 +65,7 @@
"loading_mailboxes": "Loading mailboxes...", "loading_mailboxes": "Loading mailboxes...",
"push_connected": "Real-time updates active", "push_connected": "Real-time updates active",
"push_disconnected": "Real-time updates inactive", "push_disconnected": "Real-time updates inactive",
"keyboard_shortcuts": "Keyboard Shortcuts",
"theme": { "theme": {
"light": "Light mode", "light": "Light mode",
"dark": "Dark mode", "dark": "Dark mode",
@@ -548,6 +549,10 @@
"description": "Where to show email action buttons (Reply, Archive, Delete, etc.)", "description": "Where to show email action buttons (Reply, Archive, Delete, etc.)",
"top": "Top", "top": "Top",
"below_subject": "Below subject" "below_subject": "Below subject"
},
"toolbar_labels": {
"label": "Show Toolbar Labels",
"description": "Display text labels next to toolbar icons. Disable to save space once you are familiar with the icons."
} }
}, },
"keywords": { "keywords": {
+2
View File
@@ -97,6 +97,7 @@ interface SettingsState {
// Layout // Layout
toolbarPosition: ToolbarPosition; toolbarPosition: ToolbarPosition;
showToolbarLabels: boolean;
// Experimental // Experimental
senderFavicons: boolean; senderFavicons: boolean;
@@ -175,6 +176,7 @@ const DEFAULT_SETTINGS = {
// Layout // Layout
toolbarPosition: 'top' as ToolbarPosition, toolbarPosition: 'top' as ToolbarPosition,
showToolbarLabels: true,
// Experimental // Experimental
senderFavicons: true, senderFavicons: true,