This commit is contained in:
+15
-9
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user