feat: implement email keyword management with color tagging
- Introduced a new settings section for managing email keywords (labels/tags) with associated colors. - Updated email context menu, viewer, and list item components to support the new keyword system. - Refactored color tag handling to accommodate both new $label: and legacy $color: prefixes. - Added utility functions for retrieving email color tags based on keywords. - Enhanced localization files to include translations for the new keyword management features. - Updated the settings store to manage keyword definitions, including add, update, delete, and reorder functionalities. - Added tests for the new keyword handling logic.
This commit is contained in:
@@ -532,22 +532,22 @@ export default function Home() {
|
|||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Remove any existing color tags
|
// Remove any existing label/color tags
|
||||||
const email = emails.find(e => e.id === emailId);
|
const email = emails.find(e => e.id === emailId);
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
|
|
||||||
const keywords = { ...email.keywords };
|
const keywords = { ...email.keywords };
|
||||||
|
|
||||||
// Remove old color tags - set to false for JMAP to remove them
|
// Remove old label and legacy color tags - set to false for JMAP to remove them
|
||||||
Object.keys(keywords).forEach(key => {
|
Object.keys(keywords).forEach(key => {
|
||||||
if (key.startsWith("$color:")) {
|
if (key.startsWith("$label:") || key.startsWith("$color:")) {
|
||||||
keywords[key] = false;
|
keywords[key] = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add new color tag if specified
|
// Add new label tag if specified (using new $label: prefix)
|
||||||
if (color) {
|
if (color) {
|
||||||
keywords[`$color:${color}`] = true;
|
keywords[`$label:${color}`] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update email keywords via JMAP
|
// Update email keywords via JMAP
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import { FilterSettings } from '@/components/settings/filter-settings';
|
|||||||
import { TemplateSettings } from '@/components/settings/template-settings';
|
import { TemplateSettings } from '@/components/settings/template-settings';
|
||||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||||
import { FolderSettings } from '@/components/settings/folder-settings';
|
import { FolderSettings } from '@/components/settings/folder-settings';
|
||||||
|
import { KeywordSettings } from '@/components/settings/keyword-settings';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'advanced';
|
type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'advanced';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -53,6 +54,7 @@ export default function SettingsPage() {
|
|||||||
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters') }] : []),
|
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters') }] : []),
|
||||||
{ id: 'templates', label: t('tabs.templates') },
|
{ id: 'templates', label: t('tabs.templates') },
|
||||||
{ id: 'folders', label: t('tabs.folders') },
|
{ id: 'folders', label: t('tabs.folders') },
|
||||||
|
{ id: 'keywords', label: t('tabs.keywords') },
|
||||||
{ id: 'advanced', label: t('tabs.advanced') },
|
{ id: 'advanced', label: t('tabs.advanced') },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ export default function SettingsPage() {
|
|||||||
{activeTab === 'filters' && <FilterSettings />}
|
{activeTab === 'filters' && <FilterSettings />}
|
||||||
{activeTab === 'templates' && <TemplateSettings />}
|
{activeTab === 'templates' && <TemplateSettings />}
|
||||||
{activeTab === 'folders' && <FolderSettings />}
|
{activeTab === 'folders' && <FolderSettings />}
|
||||||
|
{activeTab === 'keywords' && <KeywordSettings />}
|
||||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Archive,
|
Archive,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
Palette,
|
Tag,
|
||||||
X,
|
X,
|
||||||
Inbox,
|
Inbox,
|
||||||
Send,
|
Send,
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -84,12 +85,14 @@ const getMailboxIcon = (role?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get current color from email keywords
|
// Get current label/color from email keywords (supports both $label: and legacy $color:)
|
||||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
||||||
if (!keywords) return null;
|
if (!keywords) return null;
|
||||||
for (const key of Object.keys(keywords)) {
|
for (const key of Object.keys(keywords)) {
|
||||||
if (key.startsWith("$color:") && keywords[key] === true) {
|
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||||
return key.replace("$color:", "");
|
return key.startsWith("$label:")
|
||||||
|
? key.slice("$label:".length)
|
||||||
|
: key.slice("$color:".length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -125,22 +128,19 @@ export function EmailContextMenu({
|
|||||||
}: EmailContextMenuProps) {
|
}: EmailContextMenuProps) {
|
||||||
const t = useTranslations("context_menu");
|
const t = useTranslations("context_menu");
|
||||||
const tColor = useTranslations("email_viewer.color_tag");
|
const tColor = useTranslations("email_viewer.color_tag");
|
||||||
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const currentColor = getCurrentColor(email.keywords);
|
const currentColor = getCurrentColor(email.keywords);
|
||||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
|
|
||||||
// Color options for email tags (using translations)
|
// Build color options from keyword definitions in settings
|
||||||
const colorOptions = [
|
const colorOptions = emailKeywords.map((kw) => ({
|
||||||
{ name: tColor("red"), value: "red", color: "bg-red-500" },
|
name: kw.label,
|
||||||
{ name: tColor("orange"), value: "orange", color: "bg-orange-500" },
|
value: kw.id,
|
||||||
{ name: tColor("yellow"), value: "yellow", color: "bg-yellow-500" },
|
color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500",
|
||||||
{ name: tColor("green"), value: "green", color: "bg-green-500" },
|
}));
|
||||||
{ name: tColor("blue"), value: "blue", color: "bg-blue-500" },
|
|
||||||
{ name: tColor("purple"), value: "purple", color: "bg-purple-500" },
|
|
||||||
{ name: tColor("pink"), value: "pink", color: "bg-pink-500" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Filter mailboxes for move-to submenu (exclude current, drafts, virtual nodes)
|
// Filter mailboxes for move-to submenu (exclude current, drafts, virtual nodes)
|
||||||
const moveTargets = mailboxes.filter(
|
const moveTargets = mailboxes.filter(
|
||||||
@@ -272,7 +272,7 @@ export function EmailContextMenu({
|
|||||||
|
|
||||||
{/* Set color submenu - only for single email */}
|
{/* Set color submenu - only for single email */}
|
||||||
{!showBatchActions && (
|
{!showBatchActions && (
|
||||||
<ContextMenuSubMenu icon={Palette} label={t("color_tag")}>
|
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||||
<div
|
<div
|
||||||
className="px-3 py-2 flex flex-wrap gap-2"
|
className="px-3 py-2 flex flex-wrap gap-2"
|
||||||
role="group"
|
role="group"
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
|
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||||
|
import { getEmailColorTag } from "@/lib/thread-utils";
|
||||||
|
|
||||||
interface EmailListItemProps {
|
interface EmailListItemProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -19,39 +20,22 @@ interface EmailListItemProps {
|
|||||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color tag mapping - using lighter backgrounds for better readability
|
|
||||||
const colorTags = {
|
|
||||||
red: "bg-red-50 dark:bg-red-950/30",
|
|
||||||
orange: "bg-orange-50 dark:bg-orange-950/30",
|
|
||||||
yellow: "bg-yellow-50 dark:bg-yellow-950/30",
|
|
||||||
green: "bg-green-50 dark:bg-green-950/30",
|
|
||||||
blue: "bg-blue-50 dark:bg-blue-950/30",
|
|
||||||
purple: "bg-purple-50 dark:bg-purple-950/30",
|
|
||||||
pink: "bg-pink-50 dark:bg-pink-950/30",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const getEmailColor = (keywords: Record<string, boolean> | undefined) => {
|
|
||||||
if (!keywords) return null;
|
|
||||||
for (const key of Object.keys(keywords)) {
|
|
||||||
if (key.startsWith("$color:") && keywords[key] === true) {
|
|
||||||
const color = key.replace("$color:", "");
|
|
||||||
return colorTags[color as keyof typeof colorTags] || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
|
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox } = useEmailStore();
|
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox } = useEmailStore();
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const { identities } = useAuthStore();
|
const { identities } = useAuthStore();
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const isImportant = email.keywords?.["$important"];
|
const isImportant = email.keywords?.["$important"];
|
||||||
const sender = email.from?.[0];
|
const sender = email.from?.[0];
|
||||||
const colorTag = getEmailColor(email.keywords);
|
|
||||||
|
// Resolve color tag using keyword definitions from settings
|
||||||
|
const colorTagId = getEmailColorTag(email.keywords);
|
||||||
|
const keywordDef = colorTagId ? emailKeywords.find(k => k.id === colorTagId) : null;
|
||||||
|
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||||
|
|
||||||
// Drag and drop functionality
|
// Drag and drop functionality
|
||||||
const { dragHandlers, isDragging } = useEmailDrag({
|
const { dragHandlers, isDragging } = useEmailDrag({
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import {
|
|||||||
Keyboard,
|
Keyboard,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useSettingsStore } 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 { useDeviceDetection } from "@/hooks/use-media-query";
|
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -113,8 +113,10 @@ const getFileIcon = (name?: string, type?: string) => {
|
|||||||
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
|
||||||
if (!keywords) return null;
|
if (!keywords) return null;
|
||||||
for (const key of Object.keys(keywords)) {
|
for (const key of Object.keys(keywords)) {
|
||||||
if (key.startsWith("$color:") && keywords[key] === true) {
|
if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) {
|
||||||
return key.replace("$color:", "");
|
return key.startsWith("$label:")
|
||||||
|
? key.slice("$label:".length)
|
||||||
|
: key.slice("$color:".length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -184,20 +186,17 @@ export function EmailViewer({
|
|||||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||||
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
|
||||||
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
|
||||||
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
|
||||||
// Detect if current mailbox is Junk folder
|
// Detect if current mailbox is Junk folder
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
|
|
||||||
// Color options for email tags (using translations)
|
// Color options for email tags (from user-defined keyword settings)
|
||||||
const colorOptions = [
|
const colorOptions = emailKeywords.map((kw) => ({
|
||||||
{ name: t("color_tag.red"), value: "red", color: "bg-red-500" },
|
name: kw.label,
|
||||||
{ name: t("color_tag.orange"), value: "orange", color: "bg-orange-500" },
|
value: kw.id,
|
||||||
{ name: t("color_tag.yellow"), value: "yellow", color: "bg-yellow-500" },
|
color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500',
|
||||||
{ name: t("color_tag.green"), value: "green", color: "bg-green-500" },
|
}));
|
||||||
{ name: t("color_tag.blue"), value: "blue", color: "bg-blue-500" },
|
|
||||||
{ name: t("color_tag.purple"), value: "purple", color: "bg-purple-500" },
|
|
||||||
{ name: t("color_tag.pink"), value: "pink", color: "bg-pink-500" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Tablet list visibility
|
// Tablet list visibility
|
||||||
const { isTablet } = useDeviceDetection();
|
const { isTablet } = useDeviceDetection();
|
||||||
@@ -804,17 +803,13 @@ export function EmailViewer({
|
|||||||
className="h-8 w-8 rounded hover:bg-muted flex items-center justify-center"
|
className="h-8 w-8 rounded hover:bg-muted flex items-center justify-center"
|
||||||
title={t('set_color')}
|
title={t('set_color')}
|
||||||
>
|
>
|
||||||
<Circle className={cn(
|
{(() => {
|
||||||
"w-4 h-4",
|
const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null;
|
||||||
currentColor === 'red' && "fill-red-500 text-red-500",
|
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
||||||
currentColor === 'orange' && "fill-orange-500 text-orange-500",
|
return dotClass
|
||||||
currentColor === 'yellow' && "fill-yellow-500 text-yellow-500",
|
? <div className={cn("w-4 h-4 rounded-full", dotClass)} />
|
||||||
currentColor === 'green' && "fill-green-500 text-green-500",
|
: <Circle className="w-4 h-4 text-gray-400" />;
|
||||||
currentColor === 'blue' && "fill-blue-500 text-blue-500",
|
})()}
|
||||||
currentColor === 'purple' && "fill-purple-500 text-purple-500",
|
|
||||||
currentColor === 'pink' && "fill-pink-500 text-pink-500",
|
|
||||||
!currentColor && "text-gray-400"
|
|
||||||
)} />
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Colors appear on hover */}
|
{/* Colors appear on hover */}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare } from "lucide-react";
|
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare } from "lucide-react";
|
||||||
import { useSettingsStore } 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 } from "@/lib/thread-utils";
|
import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
|
||||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
import { ThreadEmailItem } from "./thread-email-item";
|
import { ThreadEmailItem } from "./thread-email-item";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -26,16 +26,6 @@ interface ThreadListItemProps {
|
|||||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorTags = {
|
|
||||||
red: "bg-red-50 dark:bg-red-950/30",
|
|
||||||
orange: "bg-orange-50 dark:bg-orange-950/30",
|
|
||||||
yellow: "bg-yellow-50 dark:bg-yellow-950/30",
|
|
||||||
green: "bg-green-50 dark:bg-green-950/30",
|
|
||||||
blue: "bg-blue-50 dark:bg-blue-950/30",
|
|
||||||
purple: "bg-purple-50 dark:bg-purple-950/30",
|
|
||||||
pink: "bg-pink-50 dark:bg-pink-950/30",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
interface SingleEmailItemProps {
|
interface SingleEmailItemProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@@ -51,8 +41,17 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const sender = email.from?.[0];
|
const sender = email.from?.[0];
|
||||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails } = useEmailStore();
|
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails } = useEmailStore();
|
||||||
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
|
|
||||||
|
// Resolve color from keyword definitions if not passed directly
|
||||||
|
const resolvedColorTag = (() => {
|
||||||
|
if (colorTag) return colorTag;
|
||||||
|
const tagId = getEmailColorTag(email.keywords);
|
||||||
|
const kw = tagId ? emailKeywords.find(k => k.id === tagId) : null;
|
||||||
|
return kw ? KEYWORD_PALETTE[kw.color]?.bg ?? null : null;
|
||||||
|
})();
|
||||||
|
|
||||||
const { dragHandlers, isDragging } = useEmailDrag({
|
const { dragHandlers, isDragging } = useEmailDrag({
|
||||||
email,
|
email,
|
||||||
sourceMailboxId: selectedMailbox,
|
sourceMailboxId: selectedMailbox,
|
||||||
@@ -80,15 +79,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{...dragHandlers}
|
{...dragHandlers}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
||||||
colorTag ? colorTag : (
|
resolvedColorTag ? resolvedColorTag : (
|
||||||
selected
|
selected
|
||||||
? "bg-accent"
|
? "bg-accent"
|
||||||
: "bg-background"
|
: "bg-background"
|
||||||
),
|
),
|
||||||
selected && !colorTag && "shadow-sm",
|
selected && !resolvedColorTag && "shadow-sm",
|
||||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
!resolvedColorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||||
isUnread && !colorTag && "bg-accent/30",
|
isUnread && !resolvedColorTag && "bg-accent/30",
|
||||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
|
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
|
||||||
)}
|
)}
|
||||||
@@ -192,7 +191,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
});
|
});
|
||||||
|
|
||||||
const threadColor = getThreadColorTag(thread.emails);
|
const threadColor = getThreadColorTag(thread.emails);
|
||||||
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
|
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
|
||||||
|
const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null;
|
||||||
|
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
|
||||||
|
|
||||||
const isSelected = selectedEmailId === latestEmail.id ||
|
const isSelected = selectedEmailId === latestEmail.id ||
|
||||||
thread.emails.some(e => e.id === selectedEmailId);
|
thread.emails.some(e => e.id === selectedEmailId);
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
|
||||||
|
import { SettingsSection } from "./settings-section";
|
||||||
|
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
|
||||||
|
|
||||||
|
function KeywordColorPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (color: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{PALETTE_KEYS.map((colorKey) => (
|
||||||
|
<button
|
||||||
|
key={colorKey}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(colorKey)}
|
||||||
|
className={cn(
|
||||||
|
"w-6 h-6 rounded-full transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
KEYWORD_PALETTE[colorKey].dot,
|
||||||
|
value === colorKey && "ring-2 ring-offset-2 ring-offset-background ring-foreground"
|
||||||
|
)}
|
||||||
|
aria-label={colorKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeywordRow({
|
||||||
|
keyword,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
keyword: KeywordDefinition;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("settings.keywords");
|
||||||
|
const palette = KEYWORD_PALETTE[keyword.color];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 py-2.5 px-3 rounded-md border border-border bg-background group">
|
||||||
|
<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")} />
|
||||||
|
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">{"$label:" + keyword.id}</span>
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title={t("edit")}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
title={t("delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeywordEditForm({
|
||||||
|
initial,
|
||||||
|
existingIds,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
initial?: KeywordDefinition;
|
||||||
|
existingIds: string[];
|
||||||
|
onSave: (keyword: KeywordDefinition) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("settings.keywords");
|
||||||
|
const [label, setLabel] = useState(initial?.label || "");
|
||||||
|
const [color, setColor] = useState(initial?.color || "blue");
|
||||||
|
const isEditing = !!initial;
|
||||||
|
|
||||||
|
const normalizedId = isEditing
|
||||||
|
? initial.id
|
||||||
|
: label
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
|
||||||
|
const isDuplicate = !isEditing && normalizedId.length > 0 && existingIds.includes(normalizedId);
|
||||||
|
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!isValid) return;
|
||||||
|
onSave({ id: normalizedId, label: label.trim(), color });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
|
{t("label_field")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder={t("label_placeholder")}
|
||||||
|
autoFocus
|
||||||
|
maxLength={30}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||||
|
/>
|
||||||
|
{isDuplicate && (
|
||||||
|
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
|
||||||
|
{t("color_field")}
|
||||||
|
</label>
|
||||||
|
<KeywordColorPicker value={color} onChange={setColor} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isValid}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
{isEditing ? t("save") : t("add")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeywordSettings() {
|
||||||
|
const t = useTranslations("settings.keywords");
|
||||||
|
const { emailKeywords, addKeyword, updateKeyword, removeKeyword, reorderKeywords } =
|
||||||
|
useSettingsStore();
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const existingIds = emailKeywords.map((k) => k.id);
|
||||||
|
|
||||||
|
const handleAdd = (keyword: KeywordDefinition) => {
|
||||||
|
addKeyword(keyword);
|
||||||
|
setIsAdding(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (keyword: KeywordDefinition) => {
|
||||||
|
updateKeyword(keyword.id, { label: keyword.label, color: keyword.color });
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
removeKeyword(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetDefaults = () => {
|
||||||
|
reorderKeywords(DEFAULT_KEYWORDS);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection title={t("title")} description={t("description")}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{emailKeywords.map((keyword) =>
|
||||||
|
editingId === keyword.id ? (
|
||||||
|
<KeywordEditForm
|
||||||
|
key={keyword.id}
|
||||||
|
initial={keyword}
|
||||||
|
existingIds={existingIds.filter((id) => id !== keyword.id)}
|
||||||
|
onSave={handleEdit}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<KeywordRow
|
||||||
|
key={keyword.id}
|
||||||
|
keyword={keyword}
|
||||||
|
onEdit={() => {
|
||||||
|
setEditingId(keyword.id);
|
||||||
|
setIsAdding(false);
|
||||||
|
}}
|
||||||
|
onDelete={() => handleDelete(keyword.id)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdding ? (
|
||||||
|
<KeywordEditForm
|
||||||
|
existingIds={existingIds}
|
||||||
|
onSave={handleAdd}
|
||||||
|
onCancel={() => setIsAdding(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsAdding(true);
|
||||||
|
setEditingId(null);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-dashed border-border hover:border-primary hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
{t("add_keyword")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetDefaults}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
{t("reset_defaults")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -210,7 +210,11 @@ describe('mergeThreadEmails', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getEmailColorTag', () => {
|
describe('getEmailColorTag', () => {
|
||||||
it('returns color from $color: keyword', () => {
|
it('returns label from $label: keyword', () => {
|
||||||
|
expect(getEmailColorTag({ '$label:red': true, $seen: true })).toBe('red');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns label from legacy $color: keyword', () => {
|
||||||
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
|
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -227,7 +231,7 @@ describe('getThreadColorTag', () => {
|
|||||||
it('returns first color found across thread emails', () => {
|
it('returns first color found across thread emails', () => {
|
||||||
const emails = [
|
const emails = [
|
||||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||||
makeEmail({ id: 'e2', keywords: { '$color:blue': true } }),
|
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
|
||||||
];
|
];
|
||||||
expect(getThreadColorTag(emails)).toBe('blue');
|
expect(getThreadColorTag(emails)).toBe('blue');
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-3
@@ -136,15 +136,23 @@ export function mergeThreadEmails(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Active prefix for new keyword tags written to JMAP */
|
||||||
|
export const KEYWORD_PREFIX = "$label:";
|
||||||
|
/** Legacy prefix still recognised when reading */
|
||||||
|
export const KEYWORD_PREFIX_LEGACY = "$color:";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets color tag from email keywords (if any).
|
* Gets label/color tag from email keywords (if any).
|
||||||
|
* Reads both the current $label: prefix and the legacy $color: prefix.
|
||||||
*/
|
*/
|
||||||
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
|
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
|
||||||
if (!keywords) return null;
|
if (!keywords) return null;
|
||||||
|
|
||||||
for (const key of Object.keys(keywords)) {
|
for (const key of Object.keys(keywords)) {
|
||||||
if (key.startsWith("$color:") && keywords[key] === true) {
|
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
|
||||||
return key.replace("$color:", "");
|
return key.startsWith(KEYWORD_PREFIX)
|
||||||
|
? key.slice(KEYWORD_PREFIX.length)
|
||||||
|
: key.slice(KEYWORD_PREFIX_LEGACY.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -447,7 +447,8 @@
|
|||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"filters": "Filter",
|
"filters": "Filter",
|
||||||
"templates": "Vorlagen",
|
"templates": "Vorlagen",
|
||||||
"folders": "Ordner"
|
"folders": "Ordner",
|
||||||
|
"keywords": "Schlüsselwörter"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "Allgemein",
|
"general": "Allgemein",
|
||||||
@@ -487,6 +488,23 @@
|
|||||||
"description": "Weiche Übergänge und Effekte anzeigen"
|
"description": "Weiche Übergänge und Effekte anzeigen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"keywords": {
|
||||||
|
"title": "E-Mail-Schlüsselwörter",
|
||||||
|
"description": "Definieren Sie Schlüsselwörter (Labels/Tags) zum Organisieren Ihrer E-Mails mit Farben.",
|
||||||
|
"add_keyword": "Schlüsselwort hinzufügen",
|
||||||
|
"reset_defaults": "Auf Standard zurücksetzen",
|
||||||
|
"label_field": "Anzeigename",
|
||||||
|
"label_placeholder": "z.B. Arbeit, Privat, Dringend",
|
||||||
|
"id_field": "Schlüsselwort-ID",
|
||||||
|
"id_placeholder": "z.B. arbeit, privat",
|
||||||
|
"color_field": "Farbe",
|
||||||
|
"id_exists": "Diese Schlüsselwort-ID existiert bereits",
|
||||||
|
"edit": "Schlüsselwort bearbeiten",
|
||||||
|
"delete": "Schlüsselwort löschen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"cancel": "Abbrechen"
|
||||||
|
},
|
||||||
"language_region": {
|
"language_region": {
|
||||||
"title": "Sprache & Region",
|
"title": "Sprache & Region",
|
||||||
"description": "Konfigurieren Sie Sprach- und Regionaleinstellungen",
|
"description": "Konfigurieren Sie Sprach- und Regionaleinstellungen",
|
||||||
|
|||||||
+19
-1
@@ -459,7 +459,8 @@
|
|||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"filters": "Filters",
|
"filters": "Filters",
|
||||||
"templates": "Templates",
|
"templates": "Templates",
|
||||||
"folders": "Folders"
|
"folders": "Folders",
|
||||||
|
"keywords": "Keywords"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
@@ -499,6 +500,23 @@
|
|||||||
"description": "Show smooth transitions and effects"
|
"description": "Show smooth transitions and effects"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"keywords": {
|
||||||
|
"title": "Email Keywords",
|
||||||
|
"description": "Define keywords (labels/tags) to organize your emails with colors. These are stored as JMAP keywords on the server.",
|
||||||
|
"add_keyword": "Add Keyword",
|
||||||
|
"reset_defaults": "Reset to Defaults",
|
||||||
|
"label_field": "Display Name",
|
||||||
|
"label_placeholder": "e.g. Work, Personal, Urgent",
|
||||||
|
"id_field": "Keyword ID",
|
||||||
|
"id_placeholder": "e.g. work, personal",
|
||||||
|
"color_field": "Color",
|
||||||
|
"id_exists": "This keyword ID already exists",
|
||||||
|
"edit": "Edit keyword",
|
||||||
|
"delete": "Delete keyword",
|
||||||
|
"save": "Save",
|
||||||
|
"add": "Add",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
"language_region": {
|
"language_region": {
|
||||||
"title": "Language & Region",
|
"title": "Language & Region",
|
||||||
"description": "Configure language and regional preferences",
|
"description": "Configure language and regional preferences",
|
||||||
|
|||||||
+19
-1
@@ -447,7 +447,8 @@
|
|||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"filters": "Filtros",
|
"filters": "Filtros",
|
||||||
"templates": "Plantillas",
|
"templates": "Plantillas",
|
||||||
"folders": "Carpetas"
|
"folders": "Carpetas",
|
||||||
|
"keywords": "Palabras clave"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
@@ -487,6 +488,23 @@
|
|||||||
"description": "Mostrar transiciones y efectos suaves"
|
"description": "Mostrar transiciones y efectos suaves"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"keywords": {
|
||||||
|
"title": "Palabras clave de correo",
|
||||||
|
"description": "Define palabras clave (etiquetas) para organizar tus correos con colores.",
|
||||||
|
"add_keyword": "Añadir palabra clave",
|
||||||
|
"reset_defaults": "Restablecer valores predeterminados",
|
||||||
|
"label_field": "Nombre para mostrar",
|
||||||
|
"label_placeholder": "ej. Trabajo, Personal, Urgente",
|
||||||
|
"id_field": "ID de palabra clave",
|
||||||
|
"id_placeholder": "ej. trabajo, personal",
|
||||||
|
"color_field": "Color",
|
||||||
|
"id_exists": "Esta ID de palabra clave ya existe",
|
||||||
|
"edit": "Editar palabra clave",
|
||||||
|
"delete": "Eliminar palabra clave",
|
||||||
|
"save": "Guardar",
|
||||||
|
"add": "Añadir",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
"language_region": {
|
"language_region": {
|
||||||
"title": "Idioma y Región",
|
"title": "Idioma y Región",
|
||||||
"description": "Configure las preferencias de idioma y región",
|
"description": "Configure las preferencias de idioma y región",
|
||||||
|
|||||||
+19
-1
@@ -447,7 +447,8 @@
|
|||||||
"calendar": "Calendrier",
|
"calendar": "Calendrier",
|
||||||
"filters": "Filtres",
|
"filters": "Filtres",
|
||||||
"templates": "Modèles",
|
"templates": "Modèles",
|
||||||
"folders": "Dossiers"
|
"folders": "Dossiers",
|
||||||
|
"keywords": "Mots-clés"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "Général",
|
"general": "Général",
|
||||||
@@ -487,6 +488,23 @@
|
|||||||
"description": "Afficher les transitions et effets fluides"
|
"description": "Afficher les transitions et effets fluides"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"keywords": {
|
||||||
|
"title": "Mots-clés des e-mails",
|
||||||
|
"description": "Définissez des mots-clés (étiquettes) pour organiser vos e-mails avec des couleurs.",
|
||||||
|
"add_keyword": "Ajouter un mot-clé",
|
||||||
|
"reset_defaults": "Réinitialiser par défaut",
|
||||||
|
"label_field": "Nom d'affichage",
|
||||||
|
"label_placeholder": "ex. Travail, Personnel, Urgent",
|
||||||
|
"id_field": "ID du mot-clé",
|
||||||
|
"id_placeholder": "ex. travail, personnel",
|
||||||
|
"color_field": "Couleur",
|
||||||
|
"id_exists": "Cet ID de mot-clé existe déjà",
|
||||||
|
"edit": "Modifier le mot-clé",
|
||||||
|
"delete": "Supprimer le mot-clé",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"cancel": "Annuler"
|
||||||
|
},
|
||||||
"language_region": {
|
"language_region": {
|
||||||
"title": "Langue et région",
|
"title": "Langue et région",
|
||||||
"description": "Configurez vos préférences linguistiques et régionales",
|
"description": "Configurez vos préférences linguistiques et régionales",
|
||||||
|
|||||||
+19
-1
@@ -447,7 +447,8 @@
|
|||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"filters": "Filtri",
|
"filters": "Filtri",
|
||||||
"templates": "Modelli",
|
"templates": "Modelli",
|
||||||
"folders": "Cartelle"
|
"folders": "Cartelle",
|
||||||
|
"keywords": "Parole chiave"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "Generale",
|
"general": "Generale",
|
||||||
@@ -487,6 +488,23 @@
|
|||||||
"description": "Mostra transizioni ed effetti fluidi"
|
"description": "Mostra transizioni ed effetti fluidi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"keywords": {
|
||||||
|
"title": "Parole chiave e-mail",
|
||||||
|
"description": "Definisci parole chiave (etichette) per organizzare le tue e-mail con colori.",
|
||||||
|
"add_keyword": "Aggiungi parola chiave",
|
||||||
|
"reset_defaults": "Ripristina predefiniti",
|
||||||
|
"label_field": "Nome visualizzato",
|
||||||
|
"label_placeholder": "es. Lavoro, Personale, Urgente",
|
||||||
|
"id_field": "ID parola chiave",
|
||||||
|
"id_placeholder": "es. lavoro, personale",
|
||||||
|
"color_field": "Colore",
|
||||||
|
"id_exists": "Questo ID parola chiave esiste già",
|
||||||
|
"edit": "Modifica parola chiave",
|
||||||
|
"delete": "Elimina parola chiave",
|
||||||
|
"save": "Salva",
|
||||||
|
"add": "Aggiungi",
|
||||||
|
"cancel": "Annulla"
|
||||||
|
},
|
||||||
"language_region": {
|
"language_region": {
|
||||||
"title": "Lingua e regione",
|
"title": "Lingua e regione",
|
||||||
"description": "Configura le preferenze di lingua e regionali",
|
"description": "Configura le preferenze di lingua e regionali",
|
||||||
|
|||||||
+19
-1
@@ -447,7 +447,8 @@
|
|||||||
"calendar": "カレンダー",
|
"calendar": "カレンダー",
|
||||||
"filters": "フィルター",
|
"filters": "フィルター",
|
||||||
"templates": "テンプレート",
|
"templates": "テンプレート",
|
||||||
"folders": "フォルダー"
|
"folders": "フォルダー",
|
||||||
|
"keywords": "キーワード"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "一般",
|
"general": "一般",
|
||||||
@@ -487,6 +488,23 @@
|
|||||||
"description": "スムーズな遷移とエフェクトを表示"
|
"description": "スムーズな遷移とエフェクトを表示"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"keywords": {
|
||||||
|
"title": "メールキーワード",
|
||||||
|
"description": "色でメールを整理するためのキーワード(ラベル/タグ)を定義します。",
|
||||||
|
"add_keyword": "キーワードを追加",
|
||||||
|
"reset_defaults": "デフォルトに戻す",
|
||||||
|
"label_field": "表示名",
|
||||||
|
"label_placeholder": "例:仕事、個人、緊急",
|
||||||
|
"id_field": "キーワードID",
|
||||||
|
"id_placeholder": "例:work、personal",
|
||||||
|
"color_field": "色",
|
||||||
|
"id_exists": "このキーワードIDは既に存在します",
|
||||||
|
"edit": "キーワードを編集",
|
||||||
|
"delete": "キーワードを削除",
|
||||||
|
"save": "保存",
|
||||||
|
"add": "追加",
|
||||||
|
"cancel": "キャンセル"
|
||||||
|
},
|
||||||
"language_region": {
|
"language_region": {
|
||||||
"title": "言語と地域",
|
"title": "言語と地域",
|
||||||
"description": "言語と地域の設定を構成",
|
"description": "言語と地域の設定を構成",
|
||||||
|
|||||||
+19
-1
@@ -447,7 +447,8 @@
|
|||||||
"calendar": "Agenda",
|
"calendar": "Agenda",
|
||||||
"filters": "Filters",
|
"filters": "Filters",
|
||||||
"templates": "Sjablonen",
|
"templates": "Sjablonen",
|
||||||
"folders": "Mappen"
|
"folders": "Mappen",
|
||||||
|
"keywords": "Sleutelwoorden"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "Algemeen",
|
"general": "Algemeen",
|
||||||
@@ -487,6 +488,23 @@
|
|||||||
"description": "Vloeiende overgangen en effecten tonen"
|
"description": "Vloeiende overgangen en effecten tonen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"keywords": {
|
||||||
|
"title": "E-mail trefwoorden",
|
||||||
|
"description": "Definieer trefwoorden (labels/tags) om uw e-mails met kleuren te organiseren.",
|
||||||
|
"add_keyword": "Trefwoord toevoegen",
|
||||||
|
"reset_defaults": "Standaardwaarden herstellen",
|
||||||
|
"label_field": "Weergavenaam",
|
||||||
|
"label_placeholder": "bijv. Werk, Persoonlijk, Urgent",
|
||||||
|
"id_field": "Trefwoord-ID",
|
||||||
|
"id_placeholder": "bijv. werk, persoonlijk",
|
||||||
|
"color_field": "Kleur",
|
||||||
|
"id_exists": "Dit trefwoord-ID bestaat al",
|
||||||
|
"edit": "Trefwoord bewerken",
|
||||||
|
"delete": "Trefwoord verwijderen",
|
||||||
|
"save": "Opslaan",
|
||||||
|
"add": "Toevoegen",
|
||||||
|
"cancel": "Annuleren"
|
||||||
|
},
|
||||||
"language_region": {
|
"language_region": {
|
||||||
"title": "Taal & Regio",
|
"title": "Taal & Regio",
|
||||||
"description": "Configureer taal- en regiovoorkeuren",
|
"description": "Configureer taal- en regiovoorkeuren",
|
||||||
|
|||||||
+19
-1
@@ -447,7 +447,8 @@
|
|||||||
"calendar": "Calendário",
|
"calendar": "Calendário",
|
||||||
"filters": "Filtros",
|
"filters": "Filtros",
|
||||||
"templates": "Modelos",
|
"templates": "Modelos",
|
||||||
"folders": "Pastas"
|
"folders": "Pastas",
|
||||||
|
"keywords": "Palavras-chave"
|
||||||
},
|
},
|
||||||
"tab_groups": {
|
"tab_groups": {
|
||||||
"general": "Geral",
|
"general": "Geral",
|
||||||
@@ -487,6 +488,23 @@
|
|||||||
"description": "Mostrar transições e efeitos suaves"
|
"description": "Mostrar transições e efeitos suaves"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"keywords": {
|
||||||
|
"title": "Palavras-chave de e-mail",
|
||||||
|
"description": "Defina palavras-chave (rótulos/tags) para organizar seus e-mails com cores.",
|
||||||
|
"add_keyword": "Adicionar palavra-chave",
|
||||||
|
"reset_defaults": "Restaurar padrões",
|
||||||
|
"label_field": "Nome de exibição",
|
||||||
|
"label_placeholder": "ex. Trabalho, Pessoal, Urgente",
|
||||||
|
"id_field": "ID da palavra-chave",
|
||||||
|
"id_placeholder": "ex. trabalho, pessoal",
|
||||||
|
"color_field": "Cor",
|
||||||
|
"id_exists": "Este ID de palavra-chave já existe",
|
||||||
|
"edit": "Editar palavra-chave",
|
||||||
|
"delete": "Excluir palavra-chave",
|
||||||
|
"save": "Salvar",
|
||||||
|
"add": "Adicionar",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
"language_region": {
|
"language_region": {
|
||||||
"title": "Idioma e Região",
|
"title": "Idioma e Região",
|
||||||
"description": "Configure preferências de idioma e região",
|
"description": "Configure preferências de idioma e região",
|
||||||
|
|||||||
@@ -27,6 +27,39 @@ export type TimeFormat = '12h' | '24h';
|
|||||||
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
|
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
|
||||||
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
|
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
|
||||||
|
|
||||||
|
export interface KeywordDefinition {
|
||||||
|
id: string; // Used as JMAP keyword suffix: $label:<id>
|
||||||
|
label: string; // Display name
|
||||||
|
color: string; // Key from KEYWORD_PALETTE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available color palette for keywords
|
||||||
|
export const KEYWORD_PALETTE: Record<string, { dot: string; bg: string }> = {
|
||||||
|
red: { dot: 'bg-red-500', bg: 'bg-red-50 dark:bg-red-950/30' },
|
||||||
|
orange: { dot: 'bg-orange-500', bg: 'bg-orange-50 dark:bg-orange-950/30' },
|
||||||
|
yellow: { dot: 'bg-yellow-500', bg: 'bg-yellow-50 dark:bg-yellow-950/30' },
|
||||||
|
green: { dot: 'bg-green-500', bg: 'bg-green-50 dark:bg-green-950/30' },
|
||||||
|
blue: { dot: 'bg-blue-500', bg: 'bg-blue-50 dark:bg-blue-950/30' },
|
||||||
|
purple: { dot: 'bg-purple-500', bg: 'bg-purple-50 dark:bg-purple-950/30' },
|
||||||
|
pink: { dot: 'bg-pink-500', bg: 'bg-pink-50 dark:bg-pink-950/30' },
|
||||||
|
teal: { dot: 'bg-teal-500', bg: 'bg-teal-50 dark:bg-teal-950/30' },
|
||||||
|
cyan: { dot: 'bg-cyan-500', bg: 'bg-cyan-50 dark:bg-cyan-950/30' },
|
||||||
|
indigo: { dot: 'bg-indigo-500', bg: 'bg-indigo-50 dark:bg-indigo-950/30' },
|
||||||
|
amber: { dot: 'bg-amber-500', bg: 'bg-amber-50 dark:bg-amber-950/30' },
|
||||||
|
lime: { dot: 'bg-lime-500', bg: 'bg-lime-50 dark:bg-lime-950/30' },
|
||||||
|
gray: { dot: 'bg-gray-500', bg: 'bg-gray-50 dark:bg-gray-950/30' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DEFAULT_KEYWORDS: KeywordDefinition[] = [
|
||||||
|
{ id: 'red', label: 'Red', color: 'red' },
|
||||||
|
{ id: 'orange', label: 'Orange', color: 'orange' },
|
||||||
|
{ id: 'yellow', label: 'Yellow', color: 'yellow' },
|
||||||
|
{ id: 'green', label: 'Green', color: 'green' },
|
||||||
|
{ id: 'blue', label: 'Blue', color: 'blue' },
|
||||||
|
{ id: 'purple', label: 'Purple', color: 'purple' },
|
||||||
|
{ id: 'pink', label: 'Pink', color: 'pink' },
|
||||||
|
];
|
||||||
|
|
||||||
interface SettingsState {
|
interface SettingsState {
|
||||||
// Appearance
|
// Appearance
|
||||||
fontSize: FontSize;
|
fontSize: FontSize;
|
||||||
@@ -64,6 +97,9 @@ interface SettingsState {
|
|||||||
// Folders
|
// Folders
|
||||||
folderIcons: Record<string, string>; // mailboxId -> icon name
|
folderIcons: Record<string, string>; // mailboxId -> icon name
|
||||||
|
|
||||||
|
// Keywords (labels/tags)
|
||||||
|
emailKeywords: KeywordDefinition[];
|
||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
debugMode: boolean;
|
debugMode: boolean;
|
||||||
settingsSyncDisabled: boolean;
|
settingsSyncDisabled: boolean;
|
||||||
@@ -86,6 +122,13 @@ interface SettingsState {
|
|||||||
removeTrustedSender: (email: string) => void;
|
removeTrustedSender: (email: string) => void;
|
||||||
isSenderTrusted: (email: string) => boolean;
|
isSenderTrusted: (email: string) => boolean;
|
||||||
|
|
||||||
|
// Keywords
|
||||||
|
addKeyword: (keyword: KeywordDefinition) => void;
|
||||||
|
updateKeyword: (id: string, updates: Partial<Omit<KeywordDefinition, 'id'>>) => void;
|
||||||
|
removeKeyword: (id: string) => void;
|
||||||
|
reorderKeywords: (keywords: KeywordDefinition[]) => void;
|
||||||
|
getKeywordById: (id: string) => KeywordDefinition | undefined;
|
||||||
|
|
||||||
// Settings sync
|
// Settings sync
|
||||||
enableSync: (username: string, serverUrl: string) => void;
|
enableSync: (username: string, serverUrl: string) => void;
|
||||||
disableSync: () => void;
|
disableSync: () => void;
|
||||||
@@ -129,6 +172,9 @@ const DEFAULT_SETTINGS = {
|
|||||||
// Folders
|
// Folders
|
||||||
folderIcons: {} as Record<string, string>,
|
folderIcons: {} as Record<string, string>,
|
||||||
|
|
||||||
|
// Keywords
|
||||||
|
emailKeywords: DEFAULT_KEYWORDS,
|
||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
settingsSyncDisabled: false,
|
settingsSyncDisabled: false,
|
||||||
@@ -188,6 +234,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
calendarNotificationSound: state.calendarNotificationSound,
|
calendarNotificationSound: state.calendarNotificationSound,
|
||||||
senderFavicons: state.senderFavicons,
|
senderFavicons: state.senderFavicons,
|
||||||
folderIcons: state.folderIcons,
|
folderIcons: state.folderIcons,
|
||||||
|
emailKeywords: state.emailKeywords,
|
||||||
debugMode: state.debugMode,
|
debugMode: state.debugMode,
|
||||||
settingsSyncDisabled: state.settingsSyncDisabled,
|
settingsSyncDisabled: state.settingsSyncDisabled,
|
||||||
// Cross-store settings
|
// Cross-store settings
|
||||||
@@ -264,6 +311,33 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
return get().trustedSenders.includes(normalizedEmail);
|
return get().trustedSenders.includes(normalizedEmail);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Keyword methods
|
||||||
|
addKeyword: (keyword: KeywordDefinition) => {
|
||||||
|
const current = get().emailKeywords;
|
||||||
|
if (current.some(k => k.id === keyword.id)) return;
|
||||||
|
set({ emailKeywords: [...current, keyword] });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateKeyword: (id: string, updates: Partial<Omit<KeywordDefinition, 'id'>>) => {
|
||||||
|
set({
|
||||||
|
emailKeywords: get().emailKeywords.map(k =>
|
||||||
|
k.id === id ? { ...k, ...updates } : k
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeKeyword: (id: string) => {
|
||||||
|
set({ emailKeywords: get().emailKeywords.filter(k => k.id !== id) });
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderKeywords: (keywords: KeywordDefinition[]) => {
|
||||||
|
set({ emailKeywords: keywords });
|
||||||
|
},
|
||||||
|
|
||||||
|
getKeywordById: (id: string) => {
|
||||||
|
return get().emailKeywords.find(k => k.id === id);
|
||||||
|
},
|
||||||
|
|
||||||
// Settings sync methods
|
// Settings sync methods
|
||||||
enableSync: (username: string, serverUrl: string) => {
|
enableSync: (username: string, serverUrl: string) => {
|
||||||
syncUsername = username;
|
syncUsername = username;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default {
|
|||||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./stores/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
Reference in New Issue
Block a user