From ffeb53474003dbdb2845d8b1d4ed86c59c0bbe9c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:40:09 +0100 Subject: [PATCH] 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. --- app/[locale]/page.tsx | 10 +- app/[locale]/settings/page.tsx | 5 +- components/email/email-context-menu.tsx | 30 +-- components/email/email-list-item.tsx | 32 +-- components/email/email-viewer.tsx | 43 ++-- components/email/thread-list-item.tsx | 37 ++-- components/settings/keyword-settings.tsx | 244 +++++++++++++++++++++++ lib/__tests__/thread-utils.test.ts | 8 +- lib/thread-utils.ts | 14 +- locales/de/common.json | 20 +- locales/en/common.json | 20 +- locales/es/common.json | 20 +- locales/fr/common.json | 20 +- locales/it/common.json | 20 +- locales/ja/common.json | 20 +- locales/nl/common.json | 20 +- locales/pt/common.json | 20 +- stores/settings-store.ts | 74 +++++++ tailwind.config.ts | 1 + 19 files changed, 558 insertions(+), 100 deletions(-) create mode 100644 components/settings/keyword-settings.tsx diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index c2656b50..60062c96 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -532,22 +532,22 @@ export default function Home() { if (!client) return; try { - // Remove any existing color tags + // Remove any existing label/color tags const email = emails.find(e => e.id === emailId); if (!email) return; 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 => { - if (key.startsWith("$color:")) { + if (key.startsWith("$label:") || key.startsWith("$color:")) { keywords[key] = false; } }); - // Add new color tag if specified + // Add new label tag if specified (using new $label: prefix) if (color) { - keywords[`$color:${color}`] = true; + keywords[`$label:${color}`] = true; } // Update email keywords via JMAP diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 8a866a87..da573edc 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -15,11 +15,12 @@ import { FilterSettings } from '@/components/settings/filter-settings'; import { TemplateSettings } from '@/components/settings/template-settings'; import { AdvancedSettings } from '@/components/settings/advanced-settings'; import { FolderSettings } from '@/components/settings/folder-settings'; +import { KeywordSettings } from '@/components/settings/keyword-settings'; import { useAuthStore } from '@/stores/auth-store'; import { useIsDesktop } from '@/hooks/use-media-query'; 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() { const router = useRouter(); @@ -53,6 +54,7 @@ export default function SettingsPage() { ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters') }] : []), { id: 'templates', label: t('tabs.templates') }, { id: 'folders', label: t('tabs.folders') }, + { id: 'keywords', label: t('tabs.keywords') }, { id: 'advanced', label: t('tabs.advanced') }, ]; @@ -76,6 +78,7 @@ export default function SettingsPage() { {activeTab === 'filters' && } {activeTab === 'templates' && } {activeTab === 'folders' && } + {activeTab === 'keywords' && } {activeTab === 'advanced' && } ); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 7742e087..77dc07a4 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -19,7 +19,7 @@ import { Trash2, Archive, FolderInput, - Palette, + Tag, X, Inbox, Send, @@ -29,6 +29,7 @@ import { ShieldCheck, } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; interface Position { 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 | undefined) => { if (!keywords) return null; for (const key of Object.keys(keywords)) { - if (key.startsWith("$color:") && keywords[key] === true) { - return key.replace("$color:", ""); + if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { + return key.startsWith("$label:") + ? key.slice("$label:".length) + : key.slice("$color:".length); } } return null; @@ -125,22 +128,19 @@ export function EmailContextMenu({ }: EmailContextMenuProps) { const t = useTranslations("context_menu"); const tColor = useTranslations("email_viewer.color_tag"); + const emailKeywords = useSettingsStore((state) => state.emailKeywords); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const currentColor = getCurrentColor(email.keywords); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; - // Color options for email tags (using translations) - const colorOptions = [ - { name: tColor("red"), value: "red", color: "bg-red-500" }, - { name: tColor("orange"), value: "orange", color: "bg-orange-500" }, - { name: tColor("yellow"), value: "yellow", color: "bg-yellow-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" }, - ]; + // Build color options from keyword definitions in settings + const colorOptions = emailKeywords.map((kw) => ({ + name: kw.label, + value: kw.id, + color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500", + })); // Filter mailboxes for move-to submenu (exclude current, drafts, virtual nodes) const moveTargets = mailboxes.filter( @@ -272,7 +272,7 @@ export function EmailContextMenu({ {/* Set color submenu - only for single email */} {!showBatchActions && ( - +
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 | 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) { const t = useTranslations('email_viewer'); const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox } = useEmailStore(); const showPreview = useSettingsStore((state) => state.showPreview); + const emailKeywords = useSettingsStore((state) => state.emailKeywords); const { identities } = useAuthStore(); const isChecked = selectedEmailIds.has(email.id); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isImportant = email.keywords?.["$important"]; 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 const { dragHandlers, isDragging } = useEmailDrag({ diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 2e409274..e7c2cf89 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -50,7 +50,7 @@ import { Keyboard, } from "lucide-react"; 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 { useDeviceDetection } from "@/hooks/use-media-query"; import { useAuthStore } from "@/stores/auth-store"; @@ -113,8 +113,10 @@ const getFileIcon = (name?: string, type?: string) => { const getCurrentColor = (keywords: Record | undefined) => { if (!keywords) return null; for (const key of Object.keys(keywords)) { - if (key.startsWith("$color:") && keywords[key] === true) { - return key.replace("$color:", ""); + if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { + return key.startsWith("$label:") + ? key.slice("$label:".length) + : key.slice("$color:".length); } } return null; @@ -184,20 +186,17 @@ export function EmailViewer({ const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); + const emailKeywords = useSettingsStore((state) => state.emailKeywords); // Detect if current mailbox is Junk folder const isInJunkFolder = currentMailboxRole === 'junk'; - // Color options for email tags (using translations) - const colorOptions = [ - { name: t("color_tag.red"), value: "red", color: "bg-red-500" }, - { name: t("color_tag.orange"), value: "orange", color: "bg-orange-500" }, - { name: t("color_tag.yellow"), value: "yellow", color: "bg-yellow-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" }, - ]; + // Color options for email tags (from user-defined keyword settings) + const colorOptions = emailKeywords.map((kw) => ({ + name: kw.label, + value: kw.id, + color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500', + })); // Tablet list visibility const { isTablet } = useDeviceDetection(); @@ -804,17 +803,13 @@ export function EmailViewer({ className="h-8 w-8 rounded hover:bg-muted flex items-center justify-center" title={t('set_color')} > - + {(() => { + const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null; + const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; + return dotClass + ?
+ : ; + })()} {/* Colors appear on hover */} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index d5ee48e7..a504f118 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -6,10 +6,10 @@ import { Email, ThreadGroup } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; 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 { 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 { ThreadEmailItem } from "./thread-email-item"; import { useTranslations } from "next-intl"; @@ -26,16 +26,6 @@ interface ThreadListItemProps { 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 { email: Email; selected: boolean; @@ -51,8 +41,17 @@ const SingleEmailItem = React.forwardRef( const isStarred = email.keywords?.$flagged; const sender = email.from?.[0]; const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails } = useEmailStore(); + const emailKeywords = useSettingsStore((state) => state.emailKeywords); 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({ email, sourceMailboxId: selectedMailbox, @@ -80,15 +79,15 @@ const SingleEmailItem = React.forwardRef( {...dragHandlers} className={cn( "relative group cursor-pointer transition-all duration-200 border-b border-border", - colorTag ? colorTag : ( + resolvedColorTag ? resolvedColorTag : ( selected ? "bg-accent" : "bg-background" ), - selected && !colorTag && "shadow-sm", - !colorTag && !selected && "hover:bg-muted hover:shadow-sm", - colorTag && "hover:brightness-95 dark:hover:brightness-110", - isUnread && !colorTag && "bg-accent/30", + selected && !resolvedColorTag && "shadow-sm", + !resolvedColorTag && !selected && "hover:bg-muted hover:shadow-sm", + resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110", + isUnread && !resolvedColorTag && "bg-accent/30", isChecked && "ring-2 ring-primary/20 bg-accent/40", isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30" )} @@ -192,7 +191,9 @@ export const ThreadListItem = React.forwardRef 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 || thread.emails.some(e => e.id === selectedEmailId); diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx new file mode 100644 index 00000000..e9b27827 --- /dev/null +++ b/components/settings/keyword-settings.tsx @@ -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 ( +
+ {PALETTE_KEYS.map((colorKey) => ( +
+ ); +} + +function KeywordRow({ + keyword, + onEdit, + onDelete, +}: { + keyword: KeywordDefinition; + onEdit: () => void; + onDelete: () => void; +}) { + const t = useTranslations("settings.keywords"); + const palette = KEYWORD_PALETTE[keyword.color]; + + return ( +
+ +
+ {keyword.label} + {"$label:" + keyword.id} +
+ + +
+
+ ); +} + +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 ( +
+
+ + 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 && ( +

{t("id_exists")}

+ )} +
+
+ + +
+
+ + +
+
+ ); +} + +export function KeywordSettings() { + const t = useTranslations("settings.keywords"); + const { emailKeywords, addKeyword, updateKeyword, removeKeyword, reorderKeywords } = + useSettingsStore(); + const [editingId, setEditingId] = useState(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 ( + +
+ {emailKeywords.map((keyword) => + editingId === keyword.id ? ( + id !== keyword.id)} + onSave={handleEdit} + onCancel={() => setEditingId(null)} + /> + ) : ( + { + setEditingId(keyword.id); + setIsAdding(false); + }} + onDelete={() => handleDelete(keyword.id)} + /> + ) + )} + + {isAdding ? ( + setIsAdding(false)} + /> + ) : ( +
+ + +
+ )} +
+
+ ); +} diff --git a/lib/__tests__/thread-utils.test.ts b/lib/__tests__/thread-utils.test.ts index f27931c5..2c9c7dfd 100644 --- a/lib/__tests__/thread-utils.test.ts +++ b/lib/__tests__/thread-utils.test.ts @@ -210,7 +210,11 @@ describe('mergeThreadEmails', () => { }); 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'); }); @@ -227,7 +231,7 @@ describe('getThreadColorTag', () => { it('returns first color found across thread emails', () => { const emails = [ 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'); }); diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts index ebcde2e8..18681278 100644 --- a/lib/thread-utils.ts +++ b/lib/thread-utils.ts @@ -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 | undefined): string | null { if (!keywords) return null; for (const key of Object.keys(keywords)) { - if (key.startsWith("$color:") && keywords[key] === true) { - return key.replace("$color:", ""); + if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) { + return key.startsWith(KEYWORD_PREFIX) + ? key.slice(KEYWORD_PREFIX.length) + : key.slice(KEYWORD_PREFIX_LEGACY.length); } } diff --git a/locales/de/common.json b/locales/de/common.json index f043e9c2..d1b4748f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -447,7 +447,8 @@ "calendar": "Kalender", "filters": "Filter", "templates": "Vorlagen", - "folders": "Ordner" + "folders": "Ordner", + "keywords": "Schlüsselwörter" }, "tab_groups": { "general": "Allgemein", @@ -487,6 +488,23 @@ "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": { "title": "Sprache & Region", "description": "Konfigurieren Sie Sprach- und Regionaleinstellungen", diff --git a/locales/en/common.json b/locales/en/common.json index a346d9a8..d7c7b7d7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -459,7 +459,8 @@ "calendar": "Calendar", "filters": "Filters", "templates": "Templates", - "folders": "Folders" + "folders": "Folders", + "keywords": "Keywords" }, "tab_groups": { "general": "General", @@ -499,6 +500,23 @@ "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": { "title": "Language & Region", "description": "Configure language and regional preferences", diff --git a/locales/es/common.json b/locales/es/common.json index ec7368f2..15dbe8b7 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -447,7 +447,8 @@ "calendar": "Calendario", "filters": "Filtros", "templates": "Plantillas", - "folders": "Carpetas" + "folders": "Carpetas", + "keywords": "Palabras clave" }, "tab_groups": { "general": "General", @@ -487,6 +488,23 @@ "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": { "title": "Idioma y Región", "description": "Configure las preferencias de idioma y región", diff --git a/locales/fr/common.json b/locales/fr/common.json index 0c0d02b5..9f9f5400 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -447,7 +447,8 @@ "calendar": "Calendrier", "filters": "Filtres", "templates": "Modèles", - "folders": "Dossiers" + "folders": "Dossiers", + "keywords": "Mots-clés" }, "tab_groups": { "general": "Général", @@ -487,6 +488,23 @@ "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": { "title": "Langue et région", "description": "Configurez vos préférences linguistiques et régionales", diff --git a/locales/it/common.json b/locales/it/common.json index a22ca085..c263c165 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -447,7 +447,8 @@ "calendar": "Calendario", "filters": "Filtri", "templates": "Modelli", - "folders": "Cartelle" + "folders": "Cartelle", + "keywords": "Parole chiave" }, "tab_groups": { "general": "Generale", @@ -487,6 +488,23 @@ "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": { "title": "Lingua e regione", "description": "Configura le preferenze di lingua e regionali", diff --git a/locales/ja/common.json b/locales/ja/common.json index 1e0cab76..cefe324e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -447,7 +447,8 @@ "calendar": "カレンダー", "filters": "フィルター", "templates": "テンプレート", - "folders": "フォルダー" + "folders": "フォルダー", + "keywords": "キーワード" }, "tab_groups": { "general": "一般", @@ -487,6 +488,23 @@ "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": { "title": "言語と地域", "description": "言語と地域の設定を構成", diff --git a/locales/nl/common.json b/locales/nl/common.json index c9b0fc1f..d40ec70d 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -447,7 +447,8 @@ "calendar": "Agenda", "filters": "Filters", "templates": "Sjablonen", - "folders": "Mappen" + "folders": "Mappen", + "keywords": "Sleutelwoorden" }, "tab_groups": { "general": "Algemeen", @@ -487,6 +488,23 @@ "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": { "title": "Taal & Regio", "description": "Configureer taal- en regiovoorkeuren", diff --git a/locales/pt/common.json b/locales/pt/common.json index 9431fd59..b4086fe3 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -447,7 +447,8 @@ "calendar": "Calendário", "filters": "Filtros", "templates": "Modelos", - "folders": "Pastas" + "folders": "Pastas", + "keywords": "Palavras-chave" }, "tab_groups": { "general": "Geral", @@ -487,6 +488,23 @@ "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": { "title": "Idioma e Região", "description": "Configure preferências de idioma e região", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 01a91668..24dcaba4 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -27,6 +27,39 @@ export type TimeFormat = '12h' | '24h'; export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday export type ExternalContentPolicy = 'ask' | 'block' | 'allow'; +export interface KeywordDefinition { + id: string; // Used as JMAP keyword suffix: $label: + label: string; // Display name + color: string; // Key from KEYWORD_PALETTE +} + +// Available color palette for keywords +export const KEYWORD_PALETTE: Record = { + 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 { // Appearance fontSize: FontSize; @@ -64,6 +97,9 @@ interface SettingsState { // Folders folderIcons: Record; // mailboxId -> icon name + // Keywords (labels/tags) + emailKeywords: KeywordDefinition[]; + // Advanced debugMode: boolean; settingsSyncDisabled: boolean; @@ -86,6 +122,13 @@ interface SettingsState { removeTrustedSender: (email: string) => void; isSenderTrusted: (email: string) => boolean; + // Keywords + addKeyword: (keyword: KeywordDefinition) => void; + updateKeyword: (id: string, updates: Partial>) => void; + removeKeyword: (id: string) => void; + reorderKeywords: (keywords: KeywordDefinition[]) => void; + getKeywordById: (id: string) => KeywordDefinition | undefined; + // Settings sync enableSync: (username: string, serverUrl: string) => void; disableSync: () => void; @@ -129,6 +172,9 @@ const DEFAULT_SETTINGS = { // Folders folderIcons: {} as Record, + // Keywords + emailKeywords: DEFAULT_KEYWORDS, + // Advanced debugMode: false, settingsSyncDisabled: false, @@ -188,6 +234,7 @@ export const useSettingsStore = create()( calendarNotificationSound: state.calendarNotificationSound, senderFavicons: state.senderFavicons, folderIcons: state.folderIcons, + emailKeywords: state.emailKeywords, debugMode: state.debugMode, settingsSyncDisabled: state.settingsSyncDisabled, // Cross-store settings @@ -264,6 +311,33 @@ export const useSettingsStore = create()( 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>) => { + 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 enableSync: (username: string, serverUrl: string) => { syncUsername = username; diff --git a/tailwind.config.ts b/tailwind.config.ts index d4e90b64..36f2f4e7 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -6,6 +6,7 @@ export default { "./pages/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}", + "./stores/**/*.{js,ts,jsx,tsx}", ], theme: { extend: {