Feat: enable keyword reordering #174 and multi-tag support per email #173

This commit is contained in:
Linus Rath
2026-04-10 17:31:13 +02:00
parent 3e85e07363
commit 4f54f768e8
8 changed files with 255 additions and 153 deletions
+15 -9
View File
@@ -830,16 +830,22 @@ export default function Home() {
const keywords = { ...email.keywords }; const keywords = { ...email.keywords };
// Remove old label and legacy color tags - set to false for JMAP to remove them if (color === null) {
Object.keys(keywords).forEach(key => { // Remove all label/color tags
if (key.startsWith("$label:") || key.startsWith("$color:")) { Object.keys(keywords).forEach(key => {
keywords[key] = false; if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
}
});
} else {
const jmapKey = `$label:${color}`;
if (keywords[jmapKey] === true) {
// Toggle off if already active
keywords[jmapKey] = false;
} else {
// Add the tag without disturbing others
keywords[jmapKey] = true;
} }
});
// Add new label tag if specified (using new $label: prefix)
if (color) {
keywords[`$label:${color}`] = true;
} }
// Update email keywords via JMAP // Update email keywords via JMAP
+30 -26
View File
@@ -89,17 +89,18 @@ const getMailboxIcon = (role?: string) => {
} }
}; };
// Get current label/color from email keywords (supports both $label: and legacy $color:) // Get all active label/color tag IDs from email keywords
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => { const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return null; if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:") tags.push(
? key.slice("$label:".length) key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
: key.slice("$color:".length); );
} }
} }
return null; return tags;
}; };
export function EmailContextMenu({ export function EmailContextMenu({
@@ -137,7 +138,7 @@ export function EmailContextMenu({
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const isDraft = email.keywords?.['$draft'] === true; const isDraft = email.keywords?.['$draft'] === true;
const currentColor = getCurrentColor(email.keywords); const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1; const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk'; const isInJunkFolder = currentMailboxRole === 'junk';
@@ -306,24 +307,27 @@ export function EmailContextMenu({
{/* Set tag submenu - only for single email */} {/* Set tag submenu - only for single email */}
{!showBatchActions && ( {!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}> <ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
role="menuitem" <button
onClick={() => handleAction(() => onSetColorTag?.(option.value))} key={option.value}
className={cn( role="menuitem"
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer", onClick={() => handleAction(() => onSetColorTag?.(option.value))}
currentColor === option.value && "bg-accent font-medium" className={cn(
)} "w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
> isActive && "bg-accent font-medium"
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} /> )}
<span className="flex-1">{option.name}</span> >
{currentColor === option.value && ( <span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" /> <span className="flex-1">{option.name}</span>
)} {isActive && (
</button> <Check className="w-3.5 h-3.5 flex-shrink-0 text-foreground" />
))} )}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<> <>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem <ContextMenuItem
+15 -11
View File
@@ -15,7 +15,7 @@ import { useLongPress } from "@/hooks/use-long-press";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { EmailIdentityBadge } from "./email-identity-badge"; import { EmailIdentityBadge } from "./email-identity-badge";
import { EmailHoverActions } from "./email-hover-actions"; import { EmailHoverActions } from "./email-hover-actions";
import { getEmailColorTag } from "@/lib/thread-utils"; import { getEmailColorTags } from "@/lib/thread-utils";
interface EmailListItemProps { interface EmailListItemProps {
email: Email; email: Email;
@@ -51,9 +51,11 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tag using keyword definitions from settings // Resolve color tags using keyword definitions from settings
const colorTagId = getEmailColorTag(email.keywords); const colorTagIds = getEmailColorTags(email.keywords);
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null; const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
// Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality // Drag and drop functionality
@@ -199,7 +201,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</> </>
)} )}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />} {email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />} {keywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn( <span className={cn(
'text-xs tabular-nums', 'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground' isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -249,15 +253,15 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && ( {keywordDefs.map((kd) => (
<span className={cn( <span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full", "inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted" KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}> )}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} /> <span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{keywordDef.label} {kd.label}
</span> </span>
)} ))}
<span className={cn( <span className={cn(
"text-xs tabular-nums", "text-xs tabular-nums",
isUnread isUnread
+96 -76
View File
@@ -193,16 +193,17 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st
return 'Attachment'; return 'Attachment';
}; };
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => { const getCurrentColors = (keywords: Record<string, boolean> | undefined): string[] => {
if (!keywords) return null; if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
return key.startsWith("$label:") tags.push(
? key.slice("$label:".length) key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length)
: key.slice("$color:".length); );
} }
} }
return null; return tags;
}; };
// Helper function to format recipients with contextual display // Helper function to format recipients with contextual display
@@ -933,7 +934,8 @@ export function EmailViewer({
const moveMenuRef = useRef<HTMLDivElement>(null); const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null); const toolbarRef = useRef<HTMLDivElement>(null);
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set()); const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentColor = getCurrentColor(email?.keywords); const currentColors = getCurrentColors(email?.keywords);
const currentColor = currentColors[0] ?? null;
// S/MIME state // S/MIME state
const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null); const [smimeStatus, setSmimeStatus] = useState<SmimeStatus | null>(null);
@@ -3055,43 +3057,51 @@ export function EmailViewer({
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(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" currentColors.length > 0 && "bg-muted/50"
)} )}
title={t('set_color')} title={t('set_color')}
> >
{(() => { {currentColors.length > 0 ? (
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null; <>
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; <span className="flex items-center gap-0.5">
return dotClass ? ( {currentColors.slice(0, 3).map((tagId) => {
<> const kw = emailKeywords.find(k => k.id === tagId);
<span className={cn("w-3 h-3 rounded-full", dotClass)} /> return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
{showToolbarLabels && <span className="text-xs font-medium text-foreground">{kw!.label}</span>} })}
</> </span>
) : ( {showToolbarLabels && currentColors.length === 1 && (
<> <span className="text-xs font-medium text-foreground">
<Tag className="w-4 h-4 text-muted-foreground" /> {emailKeywords.find(k => k.id === currentColors[0])?.label}
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>} </span>
</> )}
); </>
})()} ) : (
<>
<Tag className="w-4 h-4 text-muted-foreground" />
{showToolbarLabels && <span className="text-xs text-muted-foreground">{t('tag')}</span>}
</>
)}
</button> </button>
{tagMenuOpen && ( {tagMenuOpen && (
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10"> <div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }} <button
className={cn( key={option.value}
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
currentColor === option.value && "bg-accent font-medium" className={cn(
)} "w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
> isActive && "bg-accent font-medium"
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} /> )}
<span className="truncate">{option.name}</span> >
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />} <span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
</button> <span className="truncate">{option.name}</span>
))} {isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<> <>
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<button <button
@@ -3299,21 +3309,24 @@ export function EmailViewer({
</button> </button>
{moreMenuSub === 'tag' && ( {moreMenuSub === 'tag' && (
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10"> <div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }} <button
className={cn( key={option.value}
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2", onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
currentColor === option.value && "bg-accent font-medium" className={cn(
)} "w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
> isActive && "bg-accent font-medium"
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} /> )}
<span className="truncate">{option.name}</span> >
{currentColor === option.value && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />} <span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
</button> <span className="truncate">{option.name}</span>
))} {isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<> <>
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<button <button
@@ -3489,21 +3502,24 @@ export function EmailViewer({
<> <>
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div> <div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => ( {colorOptions.map((option) => {
<button const isActive = currentColors.includes(option.value);
key={option.value} return (
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }} <button
className={cn( key={option.value}
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3", onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
currentColor === option.value && "bg-accent font-medium" className={cn(
)} "w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
> isActive && "bg-accent font-medium"
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} /> )}
<span className="truncate">{option.name}</span> >
{currentColor === option.value && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />} <span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
</button> <span className="truncate">{option.name}</span>
))} {isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
{currentColor && ( </button>
);
})}
{currentColors.length > 0 && (
<button <button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }} onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground" className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
@@ -3649,14 +3665,18 @@ export function EmailViewer({
)} /> )} />
</button> </button>
)} )}
{/* Color tag dot */} {/* Color tag dots */}
{currentColor && (() => { {currentColors.length > 0 && (
const kw = emailKeywords.find(k => k.id === currentColor); <span className="flex items-center gap-0.5">
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; {currentColors.map((tagId) => {
return dotClass ? ( const kw = emailKeywords.find(k => k.id === tagId);
<span className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} /> const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
) : null; return dotClass ? (
})()} <span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
) : null;
})}
</span>
)}
{isImportant && ( {isImportant && (
<span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center"> <span className="px-1.5 lg:px-2 py-0.5 bg-warning/15 text-warning rounded-full text-xs font-medium whitespace-nowrap flex-shrink-0 self-center">
{t('important')} {t('important')}
+17 -12
View File
@@ -9,7 +9,7 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils"; import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils";
import { useEmailDrag } from "@/hooks/use-email-drag"; import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press"; import { useLongPress } from "@/hooks/use-long-press";
import { ThreadEmailItem } from "./thread-email-item"; import { ThreadEmailItem } from "./thread-email-item";
@@ -67,9 +67,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isFocusedMailLayout = mailLayout === 'focus'; const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color and keyword definition from keyword definitions if not passed directly // Resolve color tags using keyword definitions
const tagId = getEmailColorTag(email.keywords); const tagIds = getEmailColorTags(email.keywords);
const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null; const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords;
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = (() => { const resolvedColorTag = (() => {
if (colorTag) return colorTag; if (colorTag) return colorTag;
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null; return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
@@ -212,7 +213,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</> </>
)} )}
{email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />} {email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />} {resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn( <span className={cn(
'text-xs tabular-nums', 'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground' isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
@@ -255,15 +258,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDef && ( {resolvedKeywordDefs.map((kd) => (
<span className={cn( <span key={kd.id} className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full", "inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted" KEYWORD_PALETTE[kd.color]?.bg || "bg-muted"
)}> )}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} /> <span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[kd.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label} {kd.label}
</span> </span>
)} ))}
<span className={cn( <span className={cn(
"text-xs tabular-nums", "text-xs tabular-nums",
isUnread isUnread
@@ -584,7 +587,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</> </>
)} )}
{hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />} {hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
{keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />} {keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)}
<span className={cn( <span className={cn(
'text-xs tabular-nums', 'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground' hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
+62 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import React, { useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store"; import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
@@ -41,16 +41,39 @@ function KeywordRow({
keyword, keyword,
onEdit, onEdit,
onDelete, onDelete,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
isDragOver,
isDragging,
}: { }: {
keyword: KeywordDefinition; keyword: KeywordDefinition;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onDragStart: () => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: () => void;
onDragEnd: () => void;
isDragOver: boolean;
isDragging: boolean;
}) { }) {
const t = useTranslations("settings.keywords"); const t = useTranslations("settings.keywords");
const palette = KEYWORD_PALETTE[keyword.color]; const palette = KEYWORD_PALETTE[keyword.color];
return ( return (
<div className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group"> <div
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded-md border bg-background group transition-opacity",
isDragging ? "opacity-40" : "opacity-100",
isDragOver ? "border-primary" : "border-border"
)}
>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" /> <GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} /> <div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span> <span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
@@ -166,9 +189,39 @@ export function KeywordSettings() {
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [isMigrating, setIsMigrating] = useState(false); const [isMigrating, setIsMigrating] = useState(false);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const existingIds = emailKeywords.map((k) => k.id); const existingIds = emailKeywords.map((k) => k.id);
const handleDragStart = (index: number) => {
setDragIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (index !== dragOverIndex) setDragOverIndex(index);
};
const handleDrop = (index: number) => {
if (dragIndex === null || dragIndex === index) {
setDragIndex(null);
setDragOverIndex(null);
return;
}
const reordered = [...emailKeywords];
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(index, 0, moved);
reorderKeywords(reordered);
setDragIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = () => {
setDragIndex(null);
setDragOverIndex(null);
};
const handleAdd = (keyword: KeywordDefinition) => { const handleAdd = (keyword: KeywordDefinition) => {
addKeyword(keyword); addKeyword(keyword);
setIsAdding(false); setIsAdding(false);
@@ -220,7 +273,7 @@ export function KeywordSettings() {
{t("migrating")} {t("migrating")}
</div> </div>
)} )}
{emailKeywords.map((keyword) => {emailKeywords.map((keyword, index) =>
editingId === keyword.id ? ( editingId === keyword.id ? (
<KeywordEditForm <KeywordEditForm
key={keyword.id} key={keyword.id}
@@ -238,6 +291,12 @@ export function KeywordSettings() {
setIsAdding(false); setIsAdding(false);
}} }}
onDelete={() => handleDelete(keyword.id)} onDelete={() => handleDelete(keyword.id)}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
onDragEnd={handleDragEnd}
isDragOver={dragOverIndex === index && dragIndex !== index}
isDragging={dragIndex === index}
/> />
) )
)} )}
+1 -8
View File
@@ -78,14 +78,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
const email = currentEmails.find(em => em.id === emailId); const email = currentEmails.find(em => em.id === emailId);
const keywords = { ...(email?.keywords || {}) }; const keywords = { ...(email?.keywords || {}) };
// Remove old label/color keywords // Add the tag without removing existing ones
Object.keys(keywords).forEach(key => {
if (key.startsWith("$label:") || key.startsWith("$color:")) {
keywords[key] = false;
}
});
// Add the new tag
keywords[`$label:${tagId}`] = true; keywords[`$label:${tagId}`] = true;
await client.updateEmailKeywords(emailId, keywords); await client.updateEmailKeywords(emailId, keywords);
+19 -8
View File
@@ -152,21 +152,32 @@ export const KEYWORD_PREFIX = "$label:";
export const KEYWORD_PREFIX_LEGACY = "$color:"; export const KEYWORD_PREFIX_LEGACY = "$color:";
/** /**
* Gets label/color tag from email keywords (if any). * Gets all active label/color tag IDs from email keywords.
* Reads both the current $label: prefix and the legacy $color: prefix. * Reads both the current $label: prefix and the legacy $color: prefix.
*/ */
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null { export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] {
if (!keywords) return null; if (!keywords) return [];
const tags: string[] = [];
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) { if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
return key.startsWith(KEYWORD_PREFIX) tags.push(
? key.slice(KEYWORD_PREFIX.length) key.startsWith(KEYWORD_PREFIX)
: key.slice(KEYWORD_PREFIX_LEGACY.length); ? key.slice(KEYWORD_PREFIX.length)
: key.slice(KEYWORD_PREFIX_LEGACY.length)
);
} }
} }
return tags;
}
return null; /**
* Gets label/color tag from email keywords (if any).
* Reads both the current $label: prefix and the legacy $color: prefix.
* @deprecated Use getEmailColorTags for multi-tag support.
*/
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
const tags = getEmailColorTags(keywords);
return tags.length > 0 ? tags[0] : null;
} }
/** /**