From 108406a885405d4ad67e27132207148cc816c927 Mon Sep 17 00:00:00 2001 From: Mathy Vanvoorden Date: Wed, 29 Jul 2026 13:55:31 +0200 Subject: [PATCH] feat: improve visualization of tags Previously tags where very much focused on color coding email and less about adding additional information. They were also visualized in different ways in different locations. This commit gets rid of all "Color-coding" references, aligns visualization of the tags across the whole project and tries to improve user experience of using tags in general. A search box is shown in the tagging control so the user can quickly search for a tag if they have a huge (more than 10) amount of tags. --- app/(main)/[locale]/page.tsx | 20 +- .../email/__tests__/tag-picker.test.tsx | 101 ++++++++ .../email/__tests__/thread-list-item.test.tsx | 61 +++++ components/email/email-context-menu.tsx | 67 ++--- components/email/email-hover-actions.tsx | 6 +- components/email/email-list.tsx | 13 +- components/email/email-viewer.tsx | 208 ++++----------- components/email/tag-badge.tsx | 78 ++++++ components/email/tag-option-label.tsx | 29 --- components/email/tag-picker.tsx | 138 ++++++++++ components/email/thread-email-item.tsx | 12 + components/email/thread-list-item.tsx | 238 ++++++++++-------- components/layout/sidebar.tsx | 29 +-- components/pro/pro-email-tab-body.tsx | 9 +- components/settings/keyword-settings.tsx | 51 ++-- hooks/__tests__/use-keyword-format.test.tsx | 87 +++++++ hooks/use-keyword-format.ts | 46 +++- hooks/use-tag-display.ts | 70 ++++++ lib/__tests__/thread-utils.test.ts | 66 +++-- lib/thread-utils.ts | 35 ++- locales/ar/common.json | 18 +- locales/ca/common.json | 18 +- locales/cs/common.json | 18 +- locales/da/common.json | 18 +- locales/de/common.json | 18 +- locales/en/common.json | 22 +- locales/es/common.json | 18 +- locales/fa/common.json | 18 +- locales/fr/common.json | 18 +- locales/he/common.json | 18 +- locales/hu/common.json | 18 +- locales/it/common.json | 18 +- locales/ja/common.json | 18 +- locales/ko/common.json | 18 +- locales/lv/common.json | 18 +- locales/nl/common.json | 22 +- locales/pl/common.json | 18 +- locales/pt/common.json | 18 +- locales/ro/common.json | 18 +- locales/ru/common.json | 18 +- locales/sk/common.json | 18 +- locales/tr/common.json | 18 +- locales/uk/common.json | 18 +- locales/zh/common.json | 18 +- .../__tests__/settings-store-keywords.test.ts | 37 ++- stores/settings-store.ts | 93 +++++-- 46 files changed, 1102 insertions(+), 832 deletions(-) create mode 100644 components/email/__tests__/tag-picker.test.tsx create mode 100644 components/email/tag-badge.tsx delete mode 100644 components/email/tag-option-label.tsx create mode 100644 components/email/tag-picker.tsx create mode 100644 hooks/__tests__/use-keyword-format.test.tsx create mode 100644 hooks/use-tag-display.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 98d71d31..cd844fc7 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1833,7 +1833,7 @@ export default function Home() { keywords['$pinned'] = true; } - // Same unified-view routing as color tags: write to the email's own + // Same unified-view routing as tags: write to the email's own // account via the login it is reachable through. (#281) const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined; const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined; @@ -1857,25 +1857,25 @@ export default function Home() { } }; - const handleSetColorTag = async (emailId: string, color: string | null) => { + const handleSetTag = async (emailId: string, tagId: string | null) => { if (!client) return; try { - // Remove any existing label/color tags + // Remove any existing tag keywords const email = emails.find(e => e.id === emailId); if (!email) return; const keywords = { ...email.keywords }; - if (color === null) { - // Remove all label/color tags + if (tagId === null) { + // Remove all tag keywords Object.keys(keywords).forEach(key => { if (key.startsWith("$label:") || key.startsWith("$color:")) { keywords[key] = false; } }); } else { - const jmapKey = `$label:${color}`; + const jmapKey = `$label:${tagId}`; if (keywords[jmapKey]) { // Toggle off if already active keywords[jmapKey] = false; @@ -1907,7 +1907,7 @@ export default function Home() { // Refresh tag counts fetchTagCounts(client); } catch (error) { - console.error("Failed to set color tag:", error); + console.error("Failed to set tag:", error); } }; @@ -3309,8 +3309,8 @@ export default function Home() { onArchive={async (email) => { await handleArchive(email); }} - onSetColorTag={(emailId, color) => { - handleSetColorTag(emailId, color); + onSetTag={(emailId, color) => { + handleSetTag(emailId, color); }} onMoveToMailbox={async (emailId, mailboxId) => { if (client) { @@ -3534,7 +3534,7 @@ export default function Home() { }} onArchive={() => handleArchive()} onToggleStar={handleToggleStar} - onSetColorTag={handleSetColorTag} + onSetTag={handleSetTag} onMarkAsSpam={() => handleMarkAsSpam()} onUndoSpam={() => handleUndoSpam()} onMarkAsRead={async (emailId, read) => { diff --git a/components/email/__tests__/tag-picker.test.tsx b/components/email/__tests__/tag-picker.test.tsx new file mode 100644 index 00000000..e7c7e664 --- /dev/null +++ b/components/email/__tests__/tag-picker.test.tsx @@ -0,0 +1,101 @@ +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TagPicker } from '../tag-picker'; +import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store'; + +const TAGS: KeywordDefinition[] = [ + { id: 'work', label: 'Work', color: 'blue' }, + { id: 'work/clients', label: 'Clients', color: 'green' }, + { id: 'work/clients/acme', label: 'Acme', color: 'red' }, + { id: 'personal', label: 'Personal', color: 'purple' }, +]; + +/** Ten tags is the point at which the filter box appears. */ +const MANY_TAGS: KeywordDefinition[] = Array.from({ length: 12 }, (_, i) => ({ + id: `tag-${i}`, + label: i === 0 ? 'Invoices' : `Tag ${i}`, + color: 'blue', +})); + +describe('TagPicker', () => { + beforeEach(() => { + useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true }); + }); + + it('names a nested tag by its own label, not the whole path', () => { + render( {}} />); + + // The tree conveys the hierarchy, so a child needs only its own name. + expect(screen.getByText('Clients')).toBeInTheDocument(); + expect(screen.getByText('Acme')).toBeInTheDocument(); + expect(screen.queryByText('Work/Clients')).not.toBeInTheDocument(); + }); + + it('indents each level below its parent', () => { + const { container } = render( {}} />); + const acme = screen.getByText('Acme'); + + // Two levels down: two nested indent wrappers between it and the list. + const indents = acme.closest('.ps-4')?.parentElement?.closest('.ps-4'); + expect(indents).not.toBeNull(); + expect(container.querySelectorAll('.ps-4').length).toBe(2); + }); + + it('marks the applied tags and reports toggles by id', () => { + const onToggle = vi.fn(); + render(); + + const row = screen.getByText('Clients').closest('button')!; + expect(row).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByText('Work').closest('button')).toHaveAttribute('aria-checked', 'false'); + + fireEvent.click(row); + expect(onToggle).toHaveBeenCalledWith('work/clients'); + }); + + it('offers the clear-all row only while something is applied', () => { + const { rerender } = render( {}} onClearAll={() => {}} />); + expect(screen.queryByText('remove_tag')).not.toBeInTheDocument(); + + rerender( {}} onClearAll={() => {}} />); + expect(screen.getByText('remove_tag')).toBeInTheDocument(); + }); + + it('hides the filter box until the list is long enough to need one', () => { + render( {}} />); + expect(screen.queryByLabelText('tag_filter_placeholder')).not.toBeInTheDocument(); + + useSettingsStore.setState({ emailKeywords: MANY_TAGS }); + render( {}} />); + expect(screen.getAllByLabelText('tag_filter_placeholder').length).toBeGreaterThan(0); + }); + + it('flattens to matches while filtering, and says so when there are none', () => { + useSettingsStore.setState({ emailKeywords: MANY_TAGS }); + const { container } = render( {}} />); + + fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'invo' } }); + expect(within(container).getByText('Invoices')).toBeInTheDocument(); + expect(within(container).queryByText('Tag 5')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'zzz' } }); + expect(within(container).getByText('tag_no_matches')).toBeInTheDocument(); + }); + + it('matches the full path, so a child is reachable by its parent name', () => { + useSettingsStore.setState({ emailKeywords: [...TAGS, ...MANY_TAGS] }); + const { container } = render( {}} />); + + fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'work/cli' } }); + // Filtered rows are flat, so they carry the whole path. + expect(within(container).getByText('Work/Clients')).toBeInTheDocument(); + }); + + it('lists tags flat when nesting is off', () => { + useSettingsStore.setState({ nestedTags: false }); + const { container } = render( {}} />); + + expect(container.querySelectorAll('.ps-4').length).toBe(0); + expect(screen.getByText('Clients')).toBeInTheDocument(); + }); +}); diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index 9b4ac331..1f7bd498 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -118,6 +118,67 @@ describe('ThreadListItem tag badge', () => { }); }); +describe('ThreadListItem multi-message thread', () => { + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + }); + useEmailStore.setState({ + selectedEmailIds: new Set(), + selectedMailbox: 'inbox', + }); + }); + + function renderThread(emails: Email[], expanded = false) { + const [thread] = groupEmailsByThread(emails); + return render( + {}} + onEmailSelect={() => {}} + />, + ); + } + + it('carries the tags of every message, not just the first', () => { + // A collapsed row stands in for the whole thread, so a tag applied only to + // a later message still has to surface. + renderThread([ + makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }), + ]); + + expect(screen.getByText('Red')).toBeInTheDocument(); + expect(screen.getByText('Blue')).toBeInTheDocument(); + }); + + it('names a tag shared by several messages once', () => { + renderThread([ + makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:red': true } }), + ]); + + expect(screen.getAllByText('Red')).toHaveLength(1); + }); + + it('shows each message its own tags once the thread is expanded', () => { + renderThread( + [ + makeEmail({ id: 'e1', threadId: 't1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', threadId: 't1', keywords: { '$label:blue': true } }), + ], + true, + ); + + // Once on the header and once on the message that carries it. + expect(screen.getAllByText('Red').length).toBeGreaterThan(1); + }); +}); + describe('ThreadListItem row content', () => { beforeEach(() => { useSettingsStore.setState({ diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index bccd8a72..5c26667d 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -23,8 +23,6 @@ import { Archive, FolderInput, Tag, - X, - Check, Inbox, Send, File, @@ -36,11 +34,9 @@ import { XCircle, Paperclip, } from "lucide-react"; -import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; +import { buildMailboxTree, MailboxNode } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; -import { useKeywordFormat } from "@/hooks/use-keyword-format"; -import { TagOptionLabel } from "./tag-option-label"; -import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { TagPicker } from "./tag-picker"; interface Position { x: number; @@ -68,7 +64,7 @@ interface EmailContextMenuProps { onTogglePinned?: () => void; onDelete?: () => void; onArchive?: () => void; - onSetColorTag?: (color: string | null) => void; + onSetTag?: (tagId: string | null) => void; onMoveToMailbox?: (mailboxId: string) => void; onMarkAsSpam?: () => void; onUndoSpam?: () => void; @@ -103,8 +99,8 @@ const getMailboxIcon = (role?: string) => { } }; -// Get all active label/color tag IDs from email keywords -const getCurrentColors = (keywords: Record | undefined): string[] => { +/** Every tag id set on a message, reading the current prefix and the legacy one. */ +const getCurrentTagIds = (keywords: Record | undefined): string[] => { if (!keywords) return []; const tags: string[] = []; for (const key of Object.keys(keywords)) { @@ -137,7 +133,7 @@ export function EmailContextMenu({ onTogglePinned, onDelete, onArchive, - onSetColorTag, + onSetTag, onMoveToMailbox, onMarkAsSpam, onUndoSpam, @@ -154,15 +150,12 @@ export function EmailContextMenu({ }: EmailContextMenuProps) { const t = useTranslations("context_menu"); const tSidebar = useTranslations("sidebar"); - const _tColor = useTranslations("email_viewer.color_tag"); const tEmailViewer = useTranslations("email_viewer"); - const emailKeywords = useSettingsStore((state) => state.emailKeywords); - const { tagNameCandidates } = useKeywordFormat(); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isPinned = email.keywords?.['$pinned'] === true; const isDraft = email.keywords?.['$draft'] === true; - const currentColors = getCurrentColors(email.keywords); + const currentTagIds = getCurrentTagIds(email.keywords); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; // Marking your own outgoing mail as spam makes no sense - hide the action @@ -171,13 +164,6 @@ export function EmailContextMenu({ const isScheduled = email.isScheduled === true; const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending'; - // Build color options from keyword definitions in settings - const colorOptions = emailKeywords.map((kw) => ({ - candidates: tagNameCandidates(kw.id), - value: kw.id, - color: KEYWORD_PALETTE[kw.color]?.dot || "bg-gray-500", - })); - // Build mailbox tree for move-to submenu with proper hierarchy const moveTargetIds = new Set( mailboxes @@ -383,39 +369,14 @@ export function EmailContextMenu({ {/* Set tag submenu - only for single email */} {!showBatchActions && ( - -
- {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} + +
+ handleAction(() => onSetTag?.(tagId))} + onClearAll={() => handleAction(() => onSetTag?.(null))} + />
- {currentColors.length > 0 && ( - <> - - handleAction(() => onSetColorTag?.(null))} - /> - - )}
)} diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index a2691da7..2435d1c1 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -15,7 +15,7 @@ interface EmailHoverActionsProps { onMarkAsRead?: (read: boolean) => void; onDelete?: () => void; onArchive?: () => void; - onSetColorTag?: (color: string | null) => void; + onSetTag?: (tagId: string | null) => void; onMarkAsSpam?: () => void; // When the email lives in a junk folder (incl. the aggregate "All Junk" view) // the spam quick-action flips to "not spam". @@ -76,7 +76,7 @@ export function EmailHoverActions({ onMarkAsRead, onDelete, onArchive, - onSetColorTag, + onSetTag, onMarkAsSpam, isInJunk = false, onUndoSpam, @@ -112,7 +112,7 @@ export function EmailHoverActions({ onArchive?.(); break; case "tag": - onSetColorTag?.(null); + onSetTag?.(null); break; case "spam": if (isInJunk) onUndoSpam?.(); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 6dfe4c33..4333f72d 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -17,6 +17,7 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useTranslations } from "next-intl"; import { useVirtualizer } from "@tanstack/react-virtual"; +import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { SearchChips } from "@/components/search/search-chips"; import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils"; @@ -39,7 +40,7 @@ interface EmailListProps { onTogglePinned?: (email: Email) => void; onDelete?: (email: Email) => void; onArchive?: (email: Email) => void; - onSetColorTag?: (emailId: string, color: string | null) => void; + onSetTag?: (emailId: string, tagId: string | null) => void; onMoveToMailbox?: (emailId: string, mailboxId: string) => void; onMarkAsSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void; @@ -70,7 +71,7 @@ export function EmailList({ onTogglePinned, onDelete, onArchive, - onSetColorTag, + onSetTag, onMarkAsSpam, onUndoSpam, onMoveToMailbox, @@ -136,6 +137,8 @@ export function EmailList({ const [isProcessing, setIsProcessing] = useState(false); const parentRef = useRef(null); + // One tag treatment for the whole list, measured from the scroll container. + const tagDisplay = useMeasuredTagDisplay(parentRef); const density = useSettingsStore((state) => state.density); const showPreview = useSettingsStore((state) => state.showPreview); const mailLayout = useSettingsStore((state) => state.mailLayout); @@ -332,6 +335,7 @@ export function EmailList({ }, [density, isFocusedMailLayout, showPreview]); return ( +
{/* Batch Actions Toolbar */}
onMarkAsRead(email, read) : undefined} onDelete={onDelete ? (email) => onDelete(email) : undefined} onArchive={onArchive ? (email) => onArchive(email) : undefined} - onSetColorTag={onSetColorTag} + onSetTag={onSetTag} onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined} onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined} /> @@ -591,7 +595,7 @@ export function EmailList({ onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined} onDelete={() => onDelete?.(contextMenu.data!)} onArchive={() => onArchive?.(contextMenu.data!)} - onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)} + onSetTag={(color) => onSetTag?.(contextMenu.data!.id, color)} onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)} onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)} onUndoSpam={() => onUndoSpam?.(contextMenu.data!)} @@ -645,5 +649,6 @@ export function EmailList({
+ ); } diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 7dca9126..8883e444 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -12,7 +12,9 @@ import { withBasePath } from "@/lib/browser-navigation"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; -import { TagOptionLabel } from "./tag-option-label"; +import { TagBadge } from "./tag-badge"; +import { TagPicker } from "./tag-picker"; +import { useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; @@ -76,7 +78,7 @@ import { import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; -import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { toast } from "@/stores/toast-store"; @@ -117,7 +119,7 @@ interface EmailViewerProps { onArchive?: () => void; onToggleStar?: () => void; onMarkAsRead?: (emailId: string, read: boolean) => void; - onSetColorTag?: (emailId: string, color: string | null) => void; + onSetTag?: (emailId: string, tagId: string | null) => void; onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean) => void; onQuickReply?: (body: string) => Promise; onMarkAsSpam?: () => void; @@ -202,7 +204,7 @@ const getAttachmentDisplayName = (name: string | null | undefined, mimeType?: st return 'Attachment'; }; -const getCurrentColors = (keywords: Record | undefined): string[] => { +const getCurrentTagIds = (keywords: Record | undefined): string[] => { if (!keywords) return []; const tags: string[] = []; for (const key of Object.keys(keywords)) { @@ -630,7 +632,7 @@ export function EmailViewer({ onArchive, onToggleStar, onMarkAsRead, - onSetColorTag, + onSetTag, onDownloadAttachment, onQuickReply, onMarkAsSpam, @@ -669,7 +671,7 @@ export function EmailViewer({ const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender); const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook); const emailKeywords = useSettingsStore((state) => state.emailKeywords); - const { tagName, tagNameCandidates } = useKeywordFormat(); + const { sortTagIds, tagColor } = useKeywordFormat(); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const mailLayout = useSettingsStore((state) => state.mailLayout); @@ -712,12 +714,6 @@ export function EmailViewer({ const isScheduled = email?.isScheduled === true; const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending'; - // Color options for email tags (from user-defined keyword settings) - const colorOptions = emailKeywords.map((kw) => ({ - candidates: tagNameCandidates(kw.id), - value: kw.id, - color: KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500', - })); // Tablet list visibility const { isTablet, isMobile } = useDeviceDetection(); @@ -822,8 +818,13 @@ export function EmailViewer({ const moveMenuRef = useRef(null); const toolbarRef = useRef(null); const [hiddenPriorities, setHiddenPriorities] = useState>(new Set()); - const currentColors = getCurrentColors(email?.keywords); - const currentColor = currentColors[0] ?? null; + const currentTagIds = getCurrentTagIds(email?.keywords); + const sortedTagIds = sortTagIds(currentTagIds); + // The header spans the reading pane, so it measures its own width rather than + // inheriting the message list's answer. + const headerTagsRef = useRef(null); + const { variant: headerTagVariant } = useMeasuredTagDisplay(headerTagsRef); + const currentColor = currentTagIds[0] ?? null; // Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic // onRenderEmailBody hook. Verification/decryption status UI is provided by the @@ -1021,7 +1022,7 @@ export function EmailViewer({ showToolbarLabels, isLoading, moveTree.length, - colorOptions.length, + emailKeywords.length, currentColor, isInJunkFolder, isTablet, @@ -3000,65 +3001,19 @@ export function EmailViewer({
{tagMenuOpen && ( -
- {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} - {currentColors.length > 0 && ( - <> -
- - - )} +
+ { if (email) onSetTag?.(email.id, tagId); setTagMenuOpen(false); }} + onClearAll={() => { if (email) onSetTag?.(email.id, null); setTagMenuOpen(false); }} + />
)}
@@ -3250,7 +3205,7 @@ export function EmailViewer({
)} {/* Overflow: tag - submenu */} - {colorOptions.length > 0 && ( + {emailKeywords.length > 0 && (
setMoreMenuSub('tag')} onMouseLeave={() => setMoreMenuSub(null)} @@ -3264,36 +3219,12 @@ export function EmailViewer({ {moreMenuSub === 'tag' && ( -
- {colorOptions.map((option) => { - const isActive = currentColors.includes(option.value); - return ( - - ); - })} - {currentColors.length > 0 && ( - <> -
- - - )} +
+ { if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }} + onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }} + />
)}
@@ -3437,19 +3368,21 @@ export function EmailViewer({ {isStarred ? t('tooltips.unstar') : t('tooltips.star')} {/* Tag (opens sub-view) */} - {colorOptions.length > 0 && ( + {emailKeywords.length > 0 && ( - ); - })} - {currentColors.length > 0 && ( - - )} - + {moreMenuSub === 'tag' && ( + { if (email) onSetTag?.(email.id, tagId); setMoreMenuOpen(false); setMoreMenuSub(null); }} + onClearAll={() => { if (email) onSetTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }} + /> )}
@@ -3631,28 +3542,19 @@ export function EmailViewer({ )} /> )} - {/* Color tag dots */} - {currentColors.length > 0 && ( - - {currentColors.map((tagId) => { - const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' }; - const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500'; - return ( - - ); - })} - - )} {isImportant && ( {t('important')} )}
+ {sortedTagIds.length > 0 && ( +
+ {sortedTagIds.map((tagId) => ( + + ))} +
+ )}
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
diff --git a/components/email/tag-badge.tsx b/components/email/tag-badge.tsx new file mode 100644 index 00000000..9c61f6d8 --- /dev/null +++ b/components/email/tag-badge.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { useShortenedText } from "@/hooks/use-shortened-text"; + +/** + * How much room the surface has for a tag. + * - `badge` names the tag; `dot` only identifies it by colour. + */ +export type TagBadgeVariant = "badge" | "dot"; + +/** + * The lozenge shape, shared so anything standing next to a tag lines up with + * it rather than approximating its padding and text size. + */ +export const TAG_LOZENGE_CLASS = + "inline-flex min-w-0 shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium"; + +/** + * The row a group of tags sits in. Using it for neighbouring lozenges too keeps + * the spacing between them the same as the spacing within them - a wider gap on + * one side is what makes a neighbour look indented. + */ +export const TAG_GROUP_CLASS = "flex shrink-0 items-center gap-1"; + +/** + * A tag, drawn the one way tags are drawn. + * + * The lozenge carries the colour in its border and text rather than pairing a + * swatch with plain text: the name is the tag, and the colour is how you pick + * it out of a row at a glance. That also matches every other coloured pill in + * the app, all of which set a text colour alongside the background. + * + * A deep name shortens to fit its own box (`Work/../Acme`) before the browser + * clips it, so the outermost and innermost levels survive. + */ +export function TagBadge({ + tagId, + variant, + className, +}: { + tagId: string; + variant: TagBadgeVariant; + className?: string; +}) { + const { tagName, tagNameCandidates, tagColor } = useKeywordFormat(); + const [labelRef, shortenedName] = useShortenedText(tagNameCandidates(tagId)); + const color = tagColor(tagId); + const name = tagName(tagId); + + if (variant === "dot") { + return ( + + ); + } + + return ( + + {shortenedName} + + ); +} diff --git a/components/email/tag-option-label.tsx b/components/email/tag-option-label.tsx deleted file mode 100644 index e396bdad..00000000 --- a/components/email/tag-option-label.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { useShortenedText } from "@/hooks/use-shortened-text"; - -/** - * A tag name inside one of the tag pickers, shortened to what that picker has - * room for. - * - * The pickers differ in width - a narrow popover, a context submenu, a - * full-width mobile sheet - so each row measures itself instead of sharing one - * cap. `candidates` runs longest first (see `keywordRenderings`); the full name - * stays reachable through the tooltip. - */ -export function TagOptionLabel({ - candidates, - className, -}: { - candidates: string[]; - className?: string; -}) { - const [labelRef, shortenedLabel] = useShortenedText(candidates); - - return ( - - {shortenedLabel} - - ); -} diff --git a/components/email/tag-picker.tsx b/components/email/tag-picker.tsx new file mode 100644 index 00000000..e2df5e1d --- /dev/null +++ b/components/email/tag-picker.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Check, Search, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useSettingsStore } from "@/stores/settings-store"; +import { buildKeywordTree, type KeywordNode } from "@/lib/keyword-nesting"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; + +/** Below this many tags a filter box costs more room than it saves. */ +const SEARCH_THRESHOLD = 10; + +/** + * The list of tags to apply to a message. + * + * Shared by all four places one appears - the toolbar popover, the overflow + * flyout, the mobile sheet and the context menu - because they had drifted into + * four different dot sizes, check alignments and separators, and only one of + * them capped its height. + * + * Nested tags are drawn as a tree rather than repeating the parent's name on + * every child. Filtering flattens it: with a query the hierarchy is noise, and + * the full path is what gets matched. + */ +export function TagPicker({ + selectedIds, + onToggle, + onClearAll, + touch = false, +}: { + selectedIds: string[]; + onToggle: (tagId: string) => void; + onClearAll?: () => void; + /** Larger hit areas for the mobile sheet. */ + touch?: boolean; +}) { + const t = useTranslations("email_viewer"); + const keywords = useSettingsStore((state) => state.emailKeywords); + const nestedTags = useSettingsStore((state) => state.nestedTags); + const { tagName, tagColor } = useKeywordFormat(); + const [query, setQuery] = useState(""); + + const trimmedQuery = query.trim().toLowerCase(); + const showSearch = keywords.length >= SEARCH_THRESHOLD; + + const matches = useMemo( + () => + trimmedQuery + ? keywords.filter((keyword) => tagName(keyword.id).toLowerCase().includes(trimmedQuery)) + : [], + // `tagName` is rebuilt whenever the definitions or the nesting setting change. + [keywords, trimmedQuery, tagName], + ); + + const tree = useMemo( + () => (nestedTags ? buildKeywordTree(keywords) : keywords.map((k) => ({ ...k, children: [], depth: 0 }))), + [keywords, nestedTags], + ); + + const rowClass = cn( + "w-full text-start flex items-center gap-2 hover:bg-muted cursor-pointer", + touch ? "px-4 py-2.5 min-h-[44px] text-sm gap-3" : "px-3 py-1.5 text-sm", + ); + const dotClass = touch ? "w-3.5 h-3.5" : "w-3 h-3"; + const checkClass = touch ? "w-4 h-4" : "w-3.5 h-3.5"; + + const renderRow = (id: string, label: string) => { + const isActive = selectedIds.includes(id); + return ( + + ); + }; + + const renderBranch = (nodes: KeywordNode[]) => + nodes.map((node) => ( +
+ {renderRow(node.id, node.depth === 0 ? tagName(node.id) : node.label)} + {node.children.length > 0 &&
{renderBranch(node.children)}
} +
+ )); + + return ( + <> + {showSearch && ( +
+ + setQuery(event.target.value)} + placeholder={t("tag_filter_placeholder")} + aria-label={t("tag_filter_placeholder")} + className="w-full ps-8 pe-2 py-1 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ )} + +
+ {trimmedQuery ? ( + matches.length > 0 ? ( + matches.map((keyword) => renderRow(keyword.id, tagName(keyword.id))) + ) : ( +

{t("tag_no_matches")}

+ ) + ) : ( + renderBranch(tree) + )} +
+ + {onClearAll && selectedIds.length > 0 && ( + <> +
+ + + )} + + ); +} diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index bd35f4c5..a2859e66 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -12,6 +12,10 @@ import { useLongPress } from "@/hooks/use-long-press"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; +import { getEmailTagIds } from "@/lib/thread-utils"; +import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { useTagDisplay } from "@/hooks/use-tag-display"; +import { TagBadge } from "./tag-badge"; interface ThreadEmailItemProps { email: Email; @@ -35,6 +39,11 @@ export function ThreadEmailItem({ const isStarred = email.keywords?.$flagged; const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; + const { sortTagIds } = useKeywordFormat(); + const { variant: tagVariant } = useTagDisplay(); + // A message inside an expanded thread carries its own tags; the collapsed + // header pools them, so without this they disappear on the way in. + const tagIds = sortTagIds(getEmailTagIds(email.keywords)); const sender = email.from?.[0]; const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const density = useSettingsStore((state) => state.density); @@ -178,6 +187,9 @@ export function ThreadEmailItem({ {email.hasAttachment && ( )} + {tagIds.map((id) => ( + + ))}
{/* Preview snippet */} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 6f4812da..70a49f05 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -6,12 +6,14 @@ import { Email, ThreadGroup } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { SelectableAvatar } from "@/components/email/selectable-avatar"; import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react"; -import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; import { useAccountStore } from "@/stores/account-store"; -import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils"; +import { getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; +import { useTagDisplay } from "@/hooks/use-tag-display"; +import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { ThreadEmailItem } from "./thread-email-item"; @@ -35,6 +37,28 @@ function SourceFolderTag({ name }: { name: string }) { ); } +/** + * How many messages a collapsed thread stands for. + * + * Built from the tag lozenge so it lines up with the tags it sits next to: the + * same shape, and the same group spacing. + */ +function ThreadCountPill({ count, hasUnread, title }: { count: number; hasUnread: boolean; title: string }) { + return ( + + + {count} + + ); +} + interface ThreadListItemProps { thread: ThreadGroup; isExpanded: boolean; @@ -51,7 +75,7 @@ interface ThreadListItemProps { onMarkAsRead?: (email: Email, read: boolean) => void; onDelete?: (email: Email) => void; onArchive?: (email: Email) => void; - onSetColorTag?: (emailId: string, color: string | null) => void; + onSetTag?: (emailId: string, tagId: string | null) => void; onMarkAsSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void; } @@ -63,18 +87,18 @@ interface SingleEmailItemProps { onDoubleClick?: () => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; showPreview: boolean; - colorTag: string | null; + rowTint: string | null; onToggleStar?: () => void; onMarkAsRead?: (read: boolean) => void; onDelete?: () => void; onArchive?: () => void; - onSetColorTag?: (color: string | null) => void; + onSetTag?: (tagId: string | null) => void; onMarkAsSpam?: () => void; onUndoSpam?: () => void; } const SingleEmailItem = React.forwardRef( - function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) { + function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) { const t = useTranslations('email_viewer'); const tBatch = useTranslations('email_list.batch_actions'); const isUnread = !email.keywords?.$seen; @@ -90,9 +114,9 @@ const SingleEmailItem = React.forwardRef( ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; - const emailKeywords = useSettingsStore((state) => state.emailKeywords); - const { tagName } = useKeywordFormat(); - const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); + const { sortTagIds, tagColor } = useKeywordFormat(); + const { variant: tagVariant, placement: tagPlacement } = useTagDisplay(); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); const timeFormat = useSettingsStore((state) => state.timeFormat); @@ -112,14 +136,8 @@ const SingleEmailItem = React.forwardRef( ? formatDateTime(email.scheduledSendAt, timeFormat) : null; - // Resolve color tags using keyword definitions; unknown tags fall back to gray - const tagIds = getEmailColorTags(email.keywords); - const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); - const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null; - const resolvedColorTag = !tintListRowsByTag ? null : (() => { - if (colorTag) return colorTag; - return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null; - })(); + const tagIds = sortTagIds(getEmailTagIds(email.keywords)); + const resolvedRowTint = !tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null)); const { dragHandlers, isDragging } = useEmailDrag({ email, @@ -174,16 +192,16 @@ const SingleEmailItem = React.forwardRef( data-unread={isUnread ? 'true' : 'false'} className={cn( "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", - resolvedColorTag ? resolvedColorTag : ( + resolvedRowTint ? resolvedRowTint : ( selected ? "bg-accent" : "bg-background" ), - selected && !resolvedColorTag && "shadow-sm", - !resolvedColorTag && !selected && !isChecked && "hover:bg-muted hover:shadow-sm", - !resolvedColorTag && (selected || isChecked) && "hover:bg-accent hover:shadow-sm", - resolvedColorTag && "hover:brightness-95 dark:hover:brightness-110", - isUnread && !resolvedColorTag && "bg-accent/30", + selected && !resolvedRowTint && "shadow-sm", + !resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm", + !resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm", + resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110", + isUnread && !resolvedRowTint && "bg-accent/30", isChecked && "ring-2 ring-primary/20 bg-accent/40", isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30", isPressed && "bg-muted scale-[0.98] ring-2 ring-primary/30" @@ -260,6 +278,13 @@ const SingleEmailItem = React.forwardRef( {sender?.name || sender?.email || 'Unknown'}
+ {tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} ( )} {email.hasAttachment && } - {resolvedKeywordDefs.map((kd) => ( - - ))} {showSourceFolder && } {scheduledSendLabel ? ( ( )}> {sender?.name || sender?.email || "Unknown"} + {tagPlacement === 'sender' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )}
{isPinned && ( @@ -353,15 +378,6 @@ const SingleEmailItem = React.forwardRef(
- {resolvedKeywordDefs.map((kd) => ( - - - {kd.label} - - ))} {showSourceFolder && } {scheduledSendLabel ? ( (
-
- {email.subject || "(no subject)"} +
+ {tagPlacement === 'subject' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} + + {email.subject || "(no subject)"} +
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( @@ -412,12 +437,12 @@ const SingleEmailItem = React.forwardRef( {!email.isScheduled && ( state.emailKeywords); - const { tagName } = useKeywordFormat(); - const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); - const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null; - const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; + const { sortTagIds, tagColor } = useKeywordFormat(); + const { variant: tagVariant, placement: tagPlacement } = useTagDisplay(); + const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); + // A collapsed row speaks for every message under it, so it carries their tags too. + const tagIds = sortTagIds(getThreadTagIds(thread.emails)); + const rowTint = (tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null; const isSelected = selectedEmailId === latestEmail.id || thread.emails.some(e => e.id === selectedEmailId); @@ -525,12 +550,12 @@ export const ThreadListItem = React.forwardRef onEmailDoubleClick(latestEmail) : undefined} onContextMenu={onContextMenu} showPreview={showPreview} - colorTag={colorTag} + rowTint={rowTint} onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onArchive={onArchive ? () => onArchive(latestEmail) : undefined} - onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} + onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} /> @@ -604,16 +629,16 @@ export const ThreadListItem = React.forwardRef )} {displayNames.join(', ')} - - - {emailCount} -
+ + + {tagIds.map((id) => ( + + ))} + )} {hasAttachment && } - {keywordDef && ( - - )} {showSourceFolder && } {scheduledSendLabel ? ( {displayNames.join(", ")} - - - {emailCount} + + + {tagPlacement === 'sender' && tagIds.map((id) => ( + + ))}
{hasPinned && ( @@ -833,15 +853,6 @@ export const ThreadListItem = React.forwardRef
- {keywordDef && ( - - - {keywordDef.label} - - )} {showSourceFolder && } {scheduledSendLabel ? (
-
- {latestEmail.subject || "(no subject)"} +
+ {tagPlacement === 'subject' && tagIds.length > 0 && ( + + {tagIds.map((id) => ( + + ))} + + )} + + {latestEmail.subject || "(no subject)"} +
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( @@ -892,12 +912,12 @@ export const ThreadListItem = React.forwardRef onToggleStar(latestEmail) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined} onArchive={onArchive ? () => onArchive(latestEmail) : undefined} - onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} + onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} isInJunk={currentMailboxRole === 'junk'} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 31070e1d..f0c936a9 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -61,7 +61,7 @@ import { useTagDrop } from "@/hooks/use-tag-drop"; import { useUIStore } from "@/stores/ui-store"; import { useAuthStore } from "@/stores/auth-store"; import { useVacationStore } from "@/stores/vacation-store"; -import { useSettingsStore, KEYWORD_PALETTE, getKeywordVisibility } from "@/stores/settings-store"; +import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store"; import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import { debug } from "@/lib/debug"; @@ -557,22 +557,6 @@ function MailboxTreeItem({ ); } -const TAG_ICON_COLOR: Record = { - red: "text-red-600/75 dark:text-red-400/75", - orange: "text-orange-600/75 dark:text-orange-400/75", - yellow: "text-yellow-600/75 dark:text-yellow-400/75", - green: "text-green-600/75 dark:text-green-400/75", - blue: "text-blue-600/75 dark:text-blue-400/75", - purple: "text-purple-600/75 dark:text-purple-400/75", - pink: "text-pink-600/75 dark:text-pink-400/75", - teal: "text-teal-600/75 dark:text-teal-400/75", - cyan: "text-cyan-600/75 dark:text-cyan-400/75", - indigo: "text-indigo-600/75 dark:text-indigo-400/75", - amber: "text-amber-600/75 dark:text-amber-400/75", - lime: "text-lime-600/75 dark:text-lime-400/75", - gray: "text-gray-500", -}; - function ShowAllTagsRow({ hiddenCount, showAll, @@ -617,8 +601,8 @@ function TagItem({ colorful: boolean; }) { const t = useTranslations('notifications'); - const { tagNameCandidates } = useKeywordFormat(); - const palette = KEYWORD_PALETTE[node.color]; + const { tagNameCandidates, tagColor } = useKeywordFormat(); + const palette = tagColor(node.id); const hasChildren = node.children.length > 0; const isExpanded = expandedTags.has(node.id); const isSelected = selectedKeyword === node.id; @@ -644,12 +628,9 @@ function TagItem({ }); const tagIcon = colorful ? ( - + ) : ( - + ); return ( diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 625a6982..2a412f7e 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -236,7 +236,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { } }, [client, markAsRead]); - const handleSetColorTag = useCallback((emailId: string, color: string | null) => { + const handleSetTag = useCallback((emailId: string, tagId: string | null) => { if (!email || email.id !== emailId) return; // Drop existing color keywords, optionally add the new one. Matches the // mail page's local optimistic update. @@ -244,9 +244,8 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { for (const kw of settingsKeywords) { delete keywords[`$label:${kw.id}`]; } - if (color) { - const def = settingsKeywords.find((k) => k.color === color); - if (def) keywords[`$label:${def.id}`] = true; + if (tagId) { + keywords[`$label:${tagId}`] = true; } setEmailKeywordsLocal(emailId, keywords); setEmail({ ...email, keywords }); @@ -333,7 +332,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) { onArchive={handleArchive} onToggleStar={handleToggleStar} onMarkAsRead={handleMarkAsRead} - onSetColorTag={handleSetColorTag} + onSetTag={handleSetTag} onDownloadAttachment={handleDownloadAttachment} onQuickReply={handleQuickReply} onEditDraft={handleEditDraft} diff --git a/components/settings/keyword-settings.tsx b/components/settings/keyword-settings.tsx index 4547663e..51beee54 100644 --- a/components/settings/keyword-settings.tsx +++ b/components/settings/keyword-settings.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { useSettingsStore, KEYWORD_PALETTE, + KEYWORD_PALETTE_ROWS, getKeywordVisibility, type KeywordDefinition, type KeywordVisibility, @@ -25,11 +26,11 @@ import { type KeywordNode, MAX_KEYWORD_ID_LENGTH, } from "@/lib/keyword-nesting"; -import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format"; +import { formatKeyword, keywordRenderings } from "@/lib/keyword-format"; import { useShortenedText } from "@/hooks/use-shortened-text"; +import { TagBadge } from "@/components/email/tag-badge"; -const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE); - +/** Lighter, base and darker shade of each hue, one row per shade. */ function KeywordColorPicker({ value, onChange, @@ -38,19 +39,23 @@ function KeywordColorPicker({ onChange: (color: string) => void; }) { return ( -
- {PALETTE_KEYS.map((colorKey) => ( -
))}
); @@ -84,10 +89,7 @@ function KeywordRow({ isDragging: boolean; }) { const t = useTranslations("settings.keywords"); - const palette = KEYWORD_PALETTE[keyword.color]; const hasChildren = hasChildKeywords(keyword.id, keywords); - const nameCandidates = keywordRenderings(formatKeywordLabels(keyword.id, keywords, nestedTags)); - const [nameRef, shortenedName] = useShortenedText(nameCandidates); // Measured with the prefix attached, since that is what occupies the column. const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id]) .map((rendering) => KEYWORD_PREFIX + rendering); @@ -112,14 +114,9 @@ function KeywordRow({ )} > -
- - {shortenedName} - +
+ +
{ + beforeEach(() => { + useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true }); + }); + + describe('tagColor', () => { + it('resolves a tag to its palette entry, including the new shades', () => { + const { result } = renderHook(() => useKeywordFormat()); + + expect(result.current.tagColor('work')).toBe(KEYWORD_PALETTE.blue); + expect(result.current.tagColor('archive')).toBe(KEYWORD_PALETTE['red-dark']); + }); + + it('falls back to grey for a keyword this client has no definition for', () => { + // Set on the message by another client, or its tag was deleted here. + const { result } = renderHook(() => useKeywordFormat()); + + expect(result.current.tagColor('never-heard-of-it')).toBe(KEYWORD_PALETTE.gray); + }); + + it('falls back to grey for a colour that is not in the palette', () => { + useSettingsStore.setState({ emailKeywords: [{ id: 'odd', label: 'Odd', color: 'chartreuse' }] }); + const { result } = renderHook(() => useKeywordFormat()); + + expect(result.current.tagColor('odd')).toBe(KEYWORD_PALETTE.gray); + }); + }); + + describe('sortTagIds', () => { + it('follows the order the user arranged in settings', () => { + // Settings order is work, work/clients, archive - drag-reorderable, and + // deliberately not alphabetical. + const { result } = renderHook(() => useKeywordFormat()); + + expect(result.current.sortTagIds(['archive', 'work/clients', 'work'])).toEqual([ + 'work', + 'work/clients', + 'archive', + ]); + }); + + it('is stable however the keywords happen to arrive', () => { + const { result } = renderHook(() => useKeywordFormat()); + const expected = ['work', 'work/clients', 'archive']; + + expect(result.current.sortTagIds(['work', 'archive', 'work/clients'])).toEqual(expected); + expect(result.current.sortTagIds(['archive', 'work', 'work/clients'])).toEqual(expected); + }); + + it('follows a reordering of the settings list', () => { + useSettingsStore.setState({ emailKeywords: [TAGS[2], TAGS[0], TAGS[1]] }); + const { result } = renderHook(() => useKeywordFormat()); + + expect(result.current.sortTagIds(['work', 'archive'])).toEqual(['archive', 'work']); + }); + + it('puts a tag with no local definition last, ordered by name', () => { + const { result } = renderHook(() => useKeywordFormat()); + + expect(result.current.sortTagIds(['zz-unknown', 'work', 'aa-unknown'])).toEqual([ + 'work', + 'aa-unknown', + 'zz-unknown', + ]); + }); + + it("leaves the caller's array alone", () => { + const { result } = renderHook(() => useKeywordFormat()); + const input = ['archive', 'work']; + + result.current.sortTagIds(input); + + expect(input).toEqual(['archive', 'work']); + }); + }); +}); diff --git a/hooks/use-keyword-format.ts b/hooks/use-keyword-format.ts index f0f0b862..91c05bba 100644 --- a/hooks/use-keyword-format.ts +++ b/hooks/use-keyword-format.ts @@ -1,18 +1,22 @@ "use client"; import { useMemo } from "react"; -import { useSettingsStore } from "@/stores/settings-store"; +import { + useSettingsStore, + KEYWORD_PALETTE, + FALLBACK_KEYWORD_COLOR, + type KeywordColor, +} from "@/stores/settings-store"; import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format"; /** - * Names tags for the screen, bound to the user's tag settings. + * Names and colours tags for the screen, bound to the user's tag settings. * * Resolving the definitions and the nesting setting here rather than at every * call site means no caller can forget the setting and render a nested name to - * someone who never asked for nesting. Subscribing to it also keeps names in - * step the moment it is toggled: reading it straight from the store inside the - * formatter would leave every list showing stale names until something else - * happened to re-render them. + * someone who never asked for nesting. Subscribing to them also keeps tags in + * step the moment either changes: reading the store inside the formatter would + * leave every list stale until something else happened to re-render it. */ export function useKeywordFormat() { const keywords = useSettingsStore((state) => state.emailKeywords); @@ -22,8 +26,38 @@ export function useKeywordFormat() { () => ({ /** The tag's display name. */ tagName: (id: string) => formatKeyword(id, keywords, nested), + /** Its progressively shorter forms, longest first, for `useShortenedText`. */ tagNameCandidates: (id: string) => keywordRenderings(formatKeywordLabels(id, keywords, nested)), + + /** + * The tag's colour. Falls back to grey for a keyword this client has no + * definition for - one created on another device, or whose tag was + * deleted - so such a tag still shows rather than silently vanishing. + */ + tagColor: (id: string): KeywordColor => { + const color = keywords.find((keyword) => keyword.id === id)?.color; + return (color ? KEYWORD_PALETTE[color] : undefined) ?? KEYWORD_PALETTE[FALLBACK_KEYWORD_COLOR]; + }, + + /** + * Tag ids in the order the user arranged them in settings. + * + * The keywords on a message arrive as an unordered JMAP map, so without + * this the same two tags can swap places between rows. A tag with no + * local definition has no place in that order, so it sorts last, by name. + */ + sortTagIds: (ids: string[]): string[] => { + const rank = (id: string) => { + const index = keywords.findIndex((keyword) => keyword.id === id); + return index === -1 ? keywords.length : index; + }; + return [...ids].sort( + (a, b) => + rank(a) - rank(b) || + formatKeyword(a, keywords, nested).localeCompare(formatKeyword(b, keywords, nested)), + ); + }, }), [keywords, nested], ); diff --git a/hooks/use-tag-display.ts b/hooks/use-tag-display.ts new file mode 100644 index 00000000..0afdb186 --- /dev/null +++ b/hooks/use-tag-display.ts @@ -0,0 +1,70 @@ +"use client"; + +import { createContext, useContext, useEffect, useMemo, useState, type RefObject } from "react"; +import type { TagBadgeVariant } from "@/components/email/tag-badge"; + +/** + * Below this, a named tag beside the subject would leave the subject nothing to + * occupy, so tags move up to the sender line instead. The split list runs + * 240-600px wide and defaults to 384, so it reads that way until widened, while + * the full-width focus and bottom-pane layouts keep tags with the subject. + */ +const TAG_BESIDE_SUBJECT_MIN_WIDTH = 560; + +/** + * Below this there is no room to name a tag anywhere on the row, and colour + * alone has to carry it. Well under the split list's default, because the + * sender line still has room for a name long after the subject line does not. + */ +const TAG_NAME_MIN_WIDTH = 320; + +export interface TagDisplay { + /** Whether a tag is named or shown as colour alone. */ + variant: TagBadgeVariant; + /** Which line of a multi-line row the tags belong on. */ + placement: "subject" | "sender"; +} + +const NAMED_BESIDE_SUBJECT: TagDisplay = { variant: "badge", placement: "subject" }; + +/** + * How message rows should draw their tags. + * + * One value for the whole list, never per row: rows are all the same width, so + * measuring each would burn a `ResizeObserver` per virtualised row and, worse, + * let neighbours disagree - one naming its tags while the next showed dots. + */ +export const TagDisplayContext = createContext(NAMED_BESIDE_SUBJECT); + +export function useTagDisplay(): TagDisplay { + return useContext(TagDisplayContext); +} + +/** + * Watches a container and reports what its rows have room for. Falls back to + * naming tags beside the subject where measurement is unavailable - server + * rendering, and jsdom under test - since that is the most informative form. + */ +export function useMeasuredTagDisplay(ref: RefObject): TagDisplay { + const [width, setWidth] = useState(null); + + useEffect(() => { + const element = ref.current; + if (!element || typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver((entries) => { + const measured = entries[0]?.contentRect.width; + if (measured !== undefined) setWidth(measured); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [ref]); + + return useMemo(() => { + if (width === null) return NAMED_BESIDE_SUBJECT; + return { + variant: width >= TAG_NAME_MIN_WIDTH ? "badge" : "dot", + placement: width >= TAG_BESIDE_SUBJECT_MIN_WIDTH ? "subject" : "sender", + }; + }, [width]); +} diff --git a/lib/__tests__/thread-utils.test.ts b/lib/__tests__/thread-utils.test.ts index 62cf41a2..5de19307 100644 --- a/lib/__tests__/thread-utils.test.ts +++ b/lib/__tests__/thread-utils.test.ts @@ -4,8 +4,9 @@ import { sortThreadGroups, getThreadParticipants, mergeThreadEmails, - getEmailColorTag, - getThreadColorTag, + getEmailTagId, + getThreadTagId, + getThreadTagIds, } from '../thread-utils'; import type { Email, ThreadGroup } from '../jmap/types'; @@ -245,47 +246,47 @@ describe('mergeThreadEmails', () => { }); }); -describe('getEmailColorTag', () => { +describe('getEmailTagId', () => { it('returns label from $label: keyword', () => { - expect(getEmailColorTag({ '$label:red': true, $seen: true })).toBe('red'); + expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red'); }); it('returns label from legacy $color: keyword', () => { - expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red'); + expect(getEmailTagId({ '$color:red': true, $seen: true })).toBe('red'); }); it('returns null when no color keyword', () => { - expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull(); + expect(getEmailTagId({ $seen: true, $flagged: true })).toBeNull(); }); it('returns null for undefined keywords', () => { - expect(getEmailColorTag(undefined)).toBeNull(); + expect(getEmailTagId(undefined)).toBeNull(); }); it('ignores keywords set to false', () => { - expect(getEmailColorTag({ '$label:red': false } as unknown as Record)).toBeNull(); + expect(getEmailTagId({ '$label:red': false } as unknown as Record)).toBeNull(); }); it('prefers $label: over $color: when both exist', () => { - expect(getEmailColorTag({ '$label:blue': true, '$color:red': true })).toBe('blue'); + expect(getEmailTagId({ '$label:blue': true, '$color:red': true })).toBe('blue'); }); it('handles custom keyword ids', () => { - expect(getEmailColorTag({ '$label:my-custom-tag': true })).toBe('my-custom-tag'); + expect(getEmailTagId({ '$label:my-custom-tag': true })).toBe('my-custom-tag'); }); it('returns null for empty keywords object', () => { - expect(getEmailColorTag({})).toBeNull(); + expect(getEmailTagId({})).toBeNull(); }); }); -describe('getThreadColorTag', () => { +describe('getThreadTagId', () => { it('returns first color found across thread emails', () => { const emails = [ makeEmail({ id: 'e1', keywords: { $seen: true } }), makeEmail({ id: 'e2', keywords: { '$label:blue': true } }), ]; - expect(getThreadColorTag(emails)).toBe('blue'); + expect(getThreadTagId(emails)).toBe('blue'); }); it('returns null when no emails have color tags', () => { @@ -293,7 +294,7 @@ describe('getThreadColorTag', () => { makeEmail({ id: 'e1', keywords: { $seen: true } }), makeEmail({ id: 'e2', keywords: { $flagged: true } }), ]; - expect(getThreadColorTag(emails)).toBeNull(); + expect(getThreadTagId(emails)).toBeNull(); }); it('returns first tag from earliest tagged email', () => { @@ -301,7 +302,7 @@ describe('getThreadColorTag', () => { makeEmail({ id: 'e1', keywords: { '$label:red': true } }), makeEmail({ id: 'e2', keywords: { '$label:blue': true } }), ]; - expect(getThreadColorTag(emails)).toBe('red'); + expect(getThreadTagId(emails)).toBe('red'); }); it('returns legacy tag from thread emails', () => { @@ -309,10 +310,41 @@ describe('getThreadColorTag', () => { makeEmail({ id: 'e1', keywords: { $seen: true } }), makeEmail({ id: 'e2', keywords: { '$color:green': true } }), ]; - expect(getThreadColorTag(emails)).toBe('green'); + expect(getThreadTagId(emails)).toBe('green'); }); it('returns null for empty email array', () => { - expect(getThreadColorTag([])).toBeNull(); + expect(getThreadTagId([])).toBeNull(); + }); +}); + +describe('getThreadTagIds', () => { + it('gathers the tags of every message in the thread', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', keywords: { '$label:blue': true, '$label:green': true } }), + ]; + expect(getThreadTagIds(emails).sort()).toEqual(['blue', 'green', 'red']); + }); + + it('reports a tag shared by several messages once', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { '$label:red': true } }), + makeEmail({ id: 'e2', keywords: { '$label:red': true } }), + ]; + expect(getThreadTagIds(emails)).toEqual(['red']); + }); + + it('reads the legacy prefix alongside the current one', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { '$color:green': true } }), + makeEmail({ id: 'e2', keywords: { '$label:red': true } }), + ]; + expect(getThreadTagIds(emails).sort()).toEqual(['green', 'red']); + }); + + it('is empty for an untagged or empty thread', () => { + expect(getThreadTagIds([makeEmail({ id: 'e1', keywords: { $seen: true } })])).toEqual([]); + expect(getThreadTagIds([])).toEqual([]); }); }); diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts index 41037492..4a152568 100644 --- a/lib/thread-utils.ts +++ b/lib/thread-utils.ts @@ -168,10 +168,10 @@ export const KEYWORD_PREFIX = "$label:"; export const KEYWORD_PREFIX_LEGACY = "$color:"; /** - * Gets all active label/color tag IDs from email keywords. + * Gets every tag id set on a message. * Reads both the current $label: prefix and the legacy $color: prefix. */ -export function getEmailColorTags(keywords: Record | undefined): string[] { +export function getEmailTagIds(keywords: Record | undefined): string[] { if (!keywords) return []; const tags: string[] = []; for (const key of Object.keys(keywords)) { @@ -187,22 +187,39 @@ export function getEmailColorTags(keywords: Record | undefined) } /** - * Gets label/color tag from email keywords (if any). + * Gets the first tag id set on a message, if any. * Reads both the current $label: prefix and the legacy $color: prefix. - * @deprecated Use getEmailColorTags for multi-tag support. + * @deprecated Use getEmailTagIds for multi-tag support. */ -export function getEmailColorTag(keywords: Record | undefined): string | null { - const tags = getEmailColorTags(keywords); +export function getEmailTagId(keywords: Record | undefined): string | null { + const tags = getEmailTagIds(keywords); return tags.length > 0 ? tags[0] : null; } /** - * Checks if a thread has any color tag (returns first found). + * The first tag id found anywhere in a thread, if any. */ -export function getThreadColorTag(emails: Email[]): string | null { +export function getThreadTagId(emails: Email[]): string | null { for (const email of emails) { - const color = getEmailColorTag(email.keywords); + const color = getEmailTagId(email.keywords); if (color) return color; } return null; } + +/** + * Every tag anywhere in a thread, deduplicated. + * + * A collapsed thread row stands in for all its messages, so it has to account + * for all their tags - showing only the first message's would hide the rest + * with nothing to indicate they exist. + */ +export function getThreadTagIds(emails: Email[]): string[] { + const tags = new Set(); + for (const email of emails) { + for (const tag of getEmailTagIds(email.keywords)) { + tags.add(tag); + } + } + return [...tags]; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 17ac17c6..23d15d86 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -325,13 +325,13 @@ "view_contact": "عرض جهة الاتصال", "message_details": "تفاصيل الرسالة", "more_reply_options": "خيارات رد إضافية", - "set_color": "تعيين وسم", + "set_tag": "تعيين وسم", "tag": "وسم", "more_actions": "المزيد من الإجراءات", "previous": "السابق", "next": "التالي", "move_to": "نقل إلى...", - "remove_color": "إزالة الوسم", + "remove_tag": "إزالة الوسم", "more_count": "+{count} أخرى", "characters_count": "{count} حرفًا", "quick_reply_placeholder": "اكتب ردًا سريعًا...", @@ -425,17 +425,6 @@ "message_id": "معرّف الرسالة", "list_info": "معلومات القائمة" }, - "color_tag": { - "title": "وسم لوني", - "red": "أحمر", - "orange": "برتقالي", - "yellow": "أصفر", - "green": "أخضر", - "blue": "أزرق", - "purple": "بنفسجي", - "pink": "وردي", - "none": "بلا" - }, "tooltips": { "reply": "رد (r)", "reply_all": "الرد على الجميع (a)", @@ -2033,8 +2022,7 @@ "delete": "حذف", "mark_as_spam": "الإبلاغ عن بريد مزعج", "not_spam": "ليس مزعجًا", - "color_tag": "وسم", - "remove_color": "إزالة الوسم", + "tag": "وسم", "items_selected": "{count} رسالة محددة", "edit_draft": "تعديل المسودة", "cancel_scheduled_send": "إلغاء الإرسال", diff --git a/locales/ca/common.json b/locales/ca/common.json index 3845877a..97919417 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -325,13 +325,13 @@ "view_contact": "Mostra el contacte", "message_details": "Detalls del missatge", "more_reply_options": "Més opcions de resposta", - "set_color": "Estableix l'etiqueta", + "set_tag": "Estableix l'etiqueta", "tag": "Etiqueta", "more_actions": "Més accions", "previous": "Anterior", "next": "Següent", "move_to": "Mou a...", - "remove_color": "Elimina l'etiqueta", + "remove_tag": "Elimina l'etiqueta", "more_count": "+{count} més", "characters_count": "{count} caràcters", "quick_reply_placeholder": "Escriviu una resposta ràpida...", @@ -425,17 +425,6 @@ "message_id": "ID del missatge", "list_info": "Informació de la llista" }, - "color_tag": { - "title": "Etiqueta de color", - "red": "Vermell", - "orange": "Taronja", - "yellow": "Groc", - "green": "Verd", - "blue": "Blau", - "purple": "Lila", - "pink": "Rosa", - "none": "Cap" - }, "tooltips": { "reply": "Respon (r)", "reply_all": "Respon a tots (a)", @@ -2001,8 +1990,7 @@ "delete": "Suprimeix", "mark_as_spam": "Denuncia com a brossa", "not_spam": "No és brossa", - "color_tag": "Etiqueta", - "remove_color": "Elimina l'etiqueta", + "tag": "Etiqueta", "items_selected": "{count} correus seleccionats", "edit_draft": "Edita l'esborrany", "cancel_scheduled_send": "Cancel·la l'enviament", diff --git a/locales/cs/common.json b/locales/cs/common.json index 8f76d8ac..a8886132 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -325,13 +325,13 @@ "view_contact": "Zobrazit kontakt", "message_details": "Podrobnosti zprávy", "more_reply_options": "Další možnosti odpovědi", - "set_color": "Nastavit štítek", + "set_tag": "Nastavit štítek", "tag": "Štítek", "more_actions": "Další akce", "previous": "Předchozí", "next": "Další", "move_to": "Přesunout do...", - "remove_color": "Odebrat štítek", + "remove_tag": "Odebrat štítek", "more_count": "+{count} dalších", "characters_count": "{count} znaků", "quick_reply_placeholder": "Napsat rychlou odpověď...", @@ -400,17 +400,6 @@ "message_id": "ID zprávy", "list_info": "Informace o konferenci" }, - "color_tag": { - "title": "Barevný štítek", - "red": "Červený", - "orange": "Oranžový", - "yellow": "Žlutý", - "green": "Zelený", - "blue": "Modrý", - "purple": "Fialový", - "pink": "Růžový", - "none": "Žádný" - }, "tooltips": { "reply": "Odpovědět (r)", "reply_all": "Odpovědět všem (a)", @@ -2033,8 +2022,7 @@ "delete": "Odstranit", "mark_as_spam": "Nahlásit spam", "not_spam": "Není spam", - "color_tag": "Štítek", - "remove_color": "Odebrat štítek", + "tag": "Štítek", "items_selected": "{count} vybraných zpráv", "edit_draft": "Upravit koncept", "cancel_scheduled_send": "Zrušit odeslání", diff --git a/locales/da/common.json b/locales/da/common.json index 8fa78adb..44aa3d90 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -325,13 +325,13 @@ "view_contact": "Vis kontakt", "message_details": "Beskeddetaljer", "more_reply_options": "Flere svar-muligheder", - "set_color": "Sæt tag", + "set_tag": "Sæt tag", "tag": "Tag", "more_actions": "Flere handlinger", "previous": "Forrige", "next": "Næste", "move_to": "Flyt til...", - "remove_color": "Fjern tag", + "remove_tag": "Fjern tag", "more_count": "+{count} mere", "characters_count": "{count} tegn", "quick_reply_placeholder": "Skriv et hurtigt svar...", @@ -425,17 +425,6 @@ "message_id": "Besked-ID", "list_info": "Listeinformation" }, - "color_tag": { - "title": "Farvetag", - "red": "Rød", - "orange": "Orange", - "yellow": "Gul", - "green": "Grøn", - "blue": "Blå", - "purple": "Lilla", - "pink": "Pink", - "none": "Ingen" - }, "tooltips": { "reply": "Svar (r)", "reply_all": "Svar alle (a)", @@ -2033,8 +2022,7 @@ "delete": "Slet", "mark_as_spam": "Rapportér spam", "not_spam": "Ikke spam", - "color_tag": "Tag", - "remove_color": "Fjern tag", + "tag": "Tag", "items_selected": "{count} e-mails valgt", "edit_draft": "Redigér kladde", "cancel_scheduled_send": "Annuller afsendelse", diff --git a/locales/de/common.json b/locales/de/common.json index 1d4ea7ad..20067cbc 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -325,11 +325,11 @@ "view_contact": "Kontakt anzeigen", "message_details": "Nachrichtendetails", "more_reply_options": "Weitere Antwortoptionen", - "set_color": "Label setzen", + "set_tag": "Label setzen", "tag": "Label", "more_actions": "Weitere Aktionen", "move_to": "Verschieben nach...", - "remove_color": "Label entfernen", + "remove_tag": "Label entfernen", "more_count": "+{count} weitere", "characters_count": "{count} Zeichen", "quick_reply_placeholder": "Eine kurze Antwort schreiben...", @@ -398,17 +398,6 @@ "message_id": "Nachrichten-ID", "list_info": "Listeninformationen" }, - "color_tag": { - "title": "Farb-Tag", - "red": "Rot", - "orange": "Orange", - "yellow": "Gelb", - "green": "Grün", - "blue": "Blau", - "purple": "Violett", - "pink": "Rosa", - "none": "Keine" - }, "tooltips": { "reply": "Antworten", "reply_all": "Allen antworten (a)", @@ -2033,8 +2022,7 @@ "delete": "Löschen", "mark_as_spam": "Spam melden", "not_spam": "Kein Spam", - "color_tag": "Label", - "remove_color": "Label entfernen", + "tag": "Label", "items_selected": "{count} E-Mails ausgewählt", "edit_draft": "Entwurf bearbeiten", "cancel_scheduled_send": "Senden abbrechen", diff --git a/locales/en/common.json b/locales/en/common.json index 5555f2c6..96e19eb7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -327,13 +327,15 @@ "view_contact": "View contact", "message_details": "Message Details", "more_reply_options": "More reply options", - "set_color": "Set tag", + "set_tag": "Set tag", "tag": "Tag", "more_actions": "More actions", "previous": "Prev", "next": "Next", "move_to": "Move to...", - "remove_color": "Remove tag", + "remove_tag": "Remove tag", + "tag_filter_placeholder": "Filter tags", + "tag_no_matches": "No matching tags", "more_count": "+{count} more", "characters_count": "{count} characters", "quick_reply_placeholder": "Write a quick reply...", @@ -427,17 +429,6 @@ "message_id": "Message ID", "list_info": "List Information" }, - "color_tag": { - "title": "Color Tag", - "red": "Red", - "orange": "Orange", - "yellow": "Yellow", - "green": "Green", - "blue": "Blue", - "purple": "Purple", - "pink": "Pink", - "none": "None" - }, "tooltips": { "reply": "Reply (r)", "reply_all": "Reply All (a)", @@ -1019,7 +1010,7 @@ }, "keywords": { "title": "Email Tags", - "description": "Define tags to organize your emails with colors. These are stored as JMAP keywords on the server.", + "description": "Define tags to organize your emails. These are stored as JMAP keywords on the server.", "add_keyword": "Add Tag", "label_field": "Display Name", "label_placeholder": "e.g. Work, Personal, Urgent", @@ -2050,8 +2041,7 @@ "delete": "Delete", "mark_as_spam": "Report spam", "not_spam": "Not spam", - "color_tag": "Tag", - "remove_color": "Remove tag", + "tag": "Tag", "items_selected": "{count} emails selected", "edit_draft": "Edit Draft", "cancel_scheduled_send": "Cancel send", diff --git a/locales/es/common.json b/locales/es/common.json index 3f76eabe..4c2a3da9 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -325,11 +325,11 @@ "view_contact": "Ver contacto", "message_details": "Detalles del Mensaje", "more_reply_options": "Más opciones de respuesta", - "set_color": "Establecer etiqueta", + "set_tag": "Establecer etiqueta", "tag": "Etiqueta", "more_actions": "Más acciones", "move_to": "Mover a...", - "remove_color": "Eliminar etiqueta", + "remove_tag": "Eliminar etiqueta", "more_count": "+{count} más", "characters_count": "{count} caracteres", "quick_reply_placeholder": "Escriba una respuesta rápida...", @@ -398,17 +398,6 @@ "message_id": "ID del Mensaje", "list_info": "Información de Lista" }, - "color_tag": { - "title": "Etiqueta de Color", - "red": "Rojo", - "orange": "Naranja", - "yellow": "Amarillo", - "green": "Verde", - "blue": "Azul", - "purple": "Morado", - "pink": "Rosa", - "none": "Ninguno" - }, "tooltips": { "reply": "Responder", "reply_all": "Responder a todos (a)", @@ -2033,8 +2022,7 @@ "delete": "Eliminar", "mark_as_spam": "Reportar spam", "not_spam": "No es spam", - "color_tag": "Etiqueta", - "remove_color": "Eliminar etiqueta", + "tag": "Etiqueta", "items_selected": "{count} correos seleccionados", "edit_draft": "Editar borrador", "cancel_scheduled_send": "Cancelar envío", diff --git a/locales/fa/common.json b/locales/fa/common.json index 31fcb160..4599c53b 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -325,13 +325,13 @@ "view_contact": "مشاهده مخاطب", "message_details": "جزئیات پیام", "more_reply_options": "گزینه‌های بیشتر پاسخ", - "set_color": "تنظیم برچسب", + "set_tag": "تنظیم برچسب", "tag": "برچسب", "more_actions": "عملیات بیشتر", "previous": "قبلی", "next": "بعدی", "move_to": "انتقال به...", - "remove_color": "حذف برچسب", + "remove_tag": "حذف برچسب", "more_count": "+{count} بیشتر", "characters_count": "{count} کاراکتر", "quick_reply_placeholder": "پاسخ سریع بنویسید...", @@ -425,17 +425,6 @@ "message_id": "شناسه پیام", "list_info": "اطلاعات لیست" }, - "color_tag": { - "title": "برچسب رنگی", - "red": "قرمز", - "orange": "نارنجی", - "yellow": "زرد", - "green": "سبز", - "blue": "آبی", - "purple": "بنفش", - "pink": "صورتی", - "none": "هیچکدام" - }, "tooltips": { "reply": "پاسخ (r)", "reply_all": "پاسخ به همه (a)", @@ -2033,8 +2022,7 @@ "delete": "حذف", "mark_as_spam": "گزارش هرزنامه", "not_spam": "هرزنامه نیست", - "color_tag": "برچسب", - "remove_color": "حذف برچسب", + "tag": "برچسب", "items_selected": "{count} ایمیل انتخاب شده", "edit_draft": "ویرایش پیش‌نویس", "cancel_scheduled_send": "لغو ارسال", diff --git a/locales/fr/common.json b/locales/fr/common.json index 7a7145af..cf60e2df 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -325,11 +325,11 @@ "view_contact": "Voir le contact", "message_details": "Détails du message", "more_reply_options": "Plus d'options de réponse", - "set_color": "Définir l'étiquette", + "set_tag": "Définir l'étiquette", "tag": "Étiquette", "more_actions": "Plus d'actions", "move_to": "Déplacer vers...", - "remove_color": "Retirer l'étiquette", + "remove_tag": "Retirer l'étiquette", "more_count": "+{count} de plus", "characters_count": "{count} caractères", "quick_reply_placeholder": "Écrivez une réponse rapide...", @@ -398,17 +398,6 @@ "message_id": "ID du message", "list_info": "Information de liste" }, - "color_tag": { - "title": "Étiquette de couleur", - "red": "Rouge", - "orange": "Orange", - "yellow": "Jaune", - "green": "Vert", - "blue": "Bleu", - "purple": "Violet", - "pink": "Rose", - "none": "Aucune" - }, "tooltips": { "reply": "Répondre", "reply_all": "Répondre à tous (a)", @@ -2033,8 +2022,7 @@ "delete": "Supprimer", "mark_as_spam": "Signaler comme spam", "not_spam": "Pas un spam", - "color_tag": "Étiquette", - "remove_color": "Supprimer l'étiquette", + "tag": "Étiquette", "items_selected": "{count} emails sélectionnés", "edit_draft": "Modifier le brouillon", "cancel_scheduled_send": "Annuler l’envoi", diff --git a/locales/he/common.json b/locales/he/common.json index 52d48ed9..918ccec8 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -272,13 +272,13 @@ "view_contact": "הצג איש קשר", "message_details": "פרטי הודעה", "more_reply_options": "אפשרויות תשובה נוספות", - "set_color": "הגדר תג", + "set_tag": "הגדר תג", "tag": "תג", "more_actions": "עוד פעולות", "previous": "הקודם", "next": "הבא", "move_to": "העבר ל...", - "remove_color": "הסר תג", + "remove_tag": "הסר תג", "more_count": "+{count}נוספים", "characters_count": "{count} תווים", "quick_reply_placeholder": "תשובה מהירה", @@ -347,17 +347,6 @@ "message_id": "מזהה הודעה", "list_info": "רשימת מידע" }, - "color_tag": { - "title": "תג צבע", - "red": "אדום", - "orange": "כתום", - "yellow": "צהוב", - "green": "ירוק", - "blue": "כחול", - "purple": "סגול", - "pink": "ורוד", - "none": "אין" - }, "tooltips": { "reply": "תשובה (ר)", "reply_all": "השב לכולם (א)", @@ -1999,8 +1988,7 @@ "delete": "לִמְחוֹק", "mark_as_spam": "דווח על ספאם", "not_spam": "לא ספאם", - "color_tag": "תווית", - "remove_color": "הסר תווית", + "tag": "תווית", "items_selected": "נבחרו הודעות דוא\"ל מסוג{count}", "edit_draft": "ערוך טיוטה", "cancel_scheduled_send": "ביטול שליחה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 2a1a8361..790f4e8a 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -325,13 +325,13 @@ "view_contact": "Névjegy megtekintése", "message_details": "Üzenet részletei", "more_reply_options": "További válasz opciók", - "set_color": "Címke beállítása", + "set_tag": "Címke beállítása", "tag": "Címke", "more_actions": "További műveletek", "previous": "Előző", "next": "Következő", "move_to": "Áthelyezés ide...", - "remove_color": "Címke eltávolítása", + "remove_tag": "Címke eltávolítása", "more_count": "+{count} további", "characters_count": "{count} karakter", "quick_reply_placeholder": "Gyors válasz írása...", @@ -425,17 +425,6 @@ "message_id": "Üzenet azonosító", "list_info": "Lista információk" }, - "color_tag": { - "title": "Színes címke", - "red": "Piros", - "orange": "Narancs", - "yellow": "Sárga", - "green": "Zöld", - "blue": "Kék", - "purple": "Lila", - "pink": "Rózsaszín", - "none": "Nincs" - }, "tooltips": { "reply": "Válasz (r)", "reply_all": "Válasz mindenkinek (a)", @@ -2033,8 +2022,7 @@ "delete": "Törlés", "mark_as_spam": "Spam jelentése", "not_spam": "Nem spam", - "color_tag": "Címke", - "remove_color": "Címke eltávolítása", + "tag": "Címke", "items_selected": "{count} e-mail kijelölve", "edit_draft": "Piszkozat szerkesztése", "cancel_scheduled_send": "Küldés megszakítása", diff --git a/locales/it/common.json b/locales/it/common.json index 1eff3cac..d43d911b 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -325,11 +325,11 @@ "view_contact": "Visualizza contatto", "message_details": "Dettagli del messaggio", "more_reply_options": "Più opzioni di risposta", - "set_color": "Imposta etichetta", + "set_tag": "Imposta etichetta", "tag": "Etichetta", "more_actions": "Altre azioni", "move_to": "Sposta in...", - "remove_color": "Rimuovi etichetta", + "remove_tag": "Rimuovi etichetta", "more_count": "+{count} altri", "characters_count": "{count} caratteri", "quick_reply_placeholder": "Scrivi una risposta veloce...", @@ -398,17 +398,6 @@ "message_id": "ID messaggio", "list_info": "Informazioni lista" }, - "color_tag": { - "title": "Etichetta colore", - "red": "Rosso", - "orange": "Arancione", - "yellow": "Giallo", - "green": "Verde", - "blue": "Blu", - "purple": "Viola", - "pink": "Rosa", - "none": "Nessuno" - }, "tooltips": { "reply": "Rispondi", "reply_all": "Rispondi a tutti (a)", @@ -2033,8 +2022,7 @@ "delete": "Elimina", "mark_as_spam": "Segnala come spam", "not_spam": "Non spam", - "color_tag": "Etichetta", - "remove_color": "Rimuovi etichetta", + "tag": "Etichetta", "items_selected": "{count} messaggi selezionati", "edit_draft": "Modifica bozza", "cancel_scheduled_send": "Annulla invio", diff --git a/locales/ja/common.json b/locales/ja/common.json index df35f292..2502dfa7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -325,11 +325,11 @@ "view_contact": "連絡先を表示", "message_details": "メッセージの詳細", "more_reply_options": "その他の返信オプション", - "set_color": "ラベルを設定", + "set_tag": "ラベルを設定", "tag": "ラベル", "more_actions": "その他の操作", "move_to": "移動...", - "remove_color": "ラベルを削除", + "remove_tag": "ラベルを削除", "more_count": "他{count}件", "characters_count": "{count}文字", "quick_reply_placeholder": "クイック返信を入力...", @@ -398,17 +398,6 @@ "message_id": "メッセージID", "list_info": "リスト情報" }, - "color_tag": { - "title": "カラータグ", - "red": "赤", - "orange": "オレンジ", - "yellow": "黄色", - "green": "緑", - "blue": "青", - "purple": "紫", - "pink": "ピンク", - "none": "なし" - }, "tooltips": { "reply": "返信", "reply_all": "全員に返信 (a)", @@ -2033,8 +2022,7 @@ "delete": "削除", "mark_as_spam": "迷惑メールを報告", "not_spam": "迷惑メールでない", - "color_tag": "ラベル", - "remove_color": "ラベルを削除", + "tag": "ラベル", "items_selected": "{count}件のメールを選択", "edit_draft": "下書きを編集", "cancel_scheduled_send": "送信をキャンセル", diff --git a/locales/ko/common.json b/locales/ko/common.json index 6f9547ba..bfb65416 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -325,13 +325,13 @@ "view_contact": "연락처 보기", "message_details": "메시지 상세 정보", "more_reply_options": "답장 옵션 더보기", - "set_color": "태그 설정", + "set_tag": "태그 설정", "tag": "태그", "more_actions": "작업 더보기", "previous": "이전", "next": "다음", "move_to": "이동...", - "remove_color": "태그 제거", + "remove_tag": "태그 제거", "more_count": "+{count}개 더보기", "characters_count": "{count}자", "quick_reply_placeholder": "간단하게 답장을 작성해 보세요...", @@ -400,17 +400,6 @@ "message_id": "메시지 ID", "list_info": "목록 정보" }, - "color_tag": { - "title": "색상 태그", - "red": "빨간색", - "orange": "주황색", - "yellow": "노란색", - "green": "초록색", - "blue": "파란색", - "purple": "보라색", - "pink": "분홍색", - "none": "없음" - }, "tooltips": { "reply": "답장 (r)", "reply_all": "전체 답장 (a)", @@ -2033,8 +2022,7 @@ "delete": "삭제", "mark_as_spam": "스팸 신고", "not_spam": "정상 메일", - "color_tag": "태그", - "remove_color": "태그 제거", + "tag": "태그", "items_selected": "{count}개의 메일 선택됨", "edit_draft": "임시보관 메일 수정", "cancel_scheduled_send": "보내기 취소", diff --git a/locales/lv/common.json b/locales/lv/common.json index 9f980f28..c742af88 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -325,13 +325,13 @@ "view_contact": "Skatīt kontaktu", "message_details": "Informācija par ziņojumu", "more_reply_options": "Papildu atbildēšanas iespējas", - "set_color": "Iestatīt tagu", + "set_tag": "Iestatīt tagu", "tag": "Tags", "more_actions": "Citas darbības", "previous": "Iepr.", "next": "Nāk.", "move_to": "Pārvietot uz...", - "remove_color": "Noņemt tagu", + "remove_tag": "Noņemt tagu", "more_count": "+vairāk {count}", "characters_count": "{count} rakstzīmes", "quick_reply_placeholder": "Rakstīt ātru atbildi...", @@ -400,17 +400,6 @@ "message_id": "Ziņojuma ID", "list_info": "Informācija par adresātu sarakstu" }, - "color_tag": { - "title": "Krāsu tags", - "red": "Sarkans", - "orange": "Oranžs", - "yellow": "Dzeltens", - "green": "Zaļš", - "blue": "Zils", - "purple": "Violets", - "pink": "Rozā", - "none": "Nav" - }, "tooltips": { "reply": "Atbildēt (r)", "reply_all": "Atbildēt visiem (a)", @@ -2033,8 +2022,7 @@ "delete": "Dzēst", "mark_as_spam": "Atzīmēt kā mēstuli", "not_spam": "Nav mēstule", - "color_tag": "Tags", - "remove_color": "Noņemt tagu", + "tag": "Tags", "items_selected": "{count} vēstules atlasītas", "edit_draft": "Rediģēt melnrakstu", "cancel_scheduled_send": "Atcelt sūtīšanu", diff --git a/locales/nl/common.json b/locales/nl/common.json index 05966a6f..8048229b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -327,11 +327,13 @@ "view_contact": "Contact bekijken", "message_details": "Berichtdetails", "more_reply_options": "Meer antwoordopties", - "set_color": "Label instellen", + "set_tag": "Label instellen", "tag": "Label", "more_actions": "Meer acties", "move_to": "Verplaatsen naar...", - "remove_color": "Label verwijderen", + "remove_tag": "Label verwijderen", + "tag_filter_placeholder": "Labels filteren", + "tag_no_matches": "Geen overeenkomende labels", "more_count": "+{count} meer", "characters_count": "{count} tekens", "quick_reply_placeholder": "Schrijf een snel antwoord...", @@ -400,17 +402,6 @@ "message_id": "Bericht-ID", "list_info": "Lijstinformatie" }, - "color_tag": { - "title": "Kleurtag", - "red": "Rood", - "orange": "Oranje", - "yellow": "Geel", - "green": "Groen", - "blue": "Blauw", - "purple": "Paars", - "pink": "Roze", - "none": "Geen" - }, "tooltips": { "reply": "Beantwoorden", "reply_all": "Allen beantwoorden (a)", @@ -1016,7 +1007,7 @@ }, "keywords": { "title": "E-maillabels", - "description": "Definieer labels om uw e-mails met kleuren te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.", + "description": "Definieer labels om uw e-mails te organiseren. Deze worden opgeslagen als JMAP-trefwoorden op de server.", "add_keyword": "Label toevoegen", "label_field": "Weergavenaam", "label_placeholder": "bijv. Werk, Persoonlijk, Urgent", @@ -2050,8 +2041,7 @@ "delete": "Verwijderen", "mark_as_spam": "Spam melden", "not_spam": "Geen spam", - "color_tag": "Label", - "remove_color": "Label verwijderen", + "tag": "Label", "items_selected": "{count} e-mails geselecteerd", "edit_draft": "Concept bewerken", "cancel_scheduled_send": "Verzenden annuleren", diff --git a/locales/pl/common.json b/locales/pl/common.json index 3cd54d10..9378c706 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -325,13 +325,13 @@ "view_contact": "Pokaż kontakt", "message_details": "Szczegóły wiadomości", "more_reply_options": "Więcej opcji odpowiedzi", - "set_color": "Ustaw etykietę", + "set_tag": "Ustaw etykietę", "tag": "Etykieta", "more_actions": "Więcej działań", "previous": "Poprz.", "next": "Nast.", "move_to": "Przenieś do...", - "remove_color": "Usuń etykietę", + "remove_tag": "Usuń etykietę", "more_count": "+{count} więcej", "characters_count": "{count} znaków", "quick_reply_placeholder": "Napisz szybką odpowiedź...", @@ -400,17 +400,6 @@ "message_id": "ID wiadomości", "list_info": "Informacje o liście" }, - "color_tag": { - "title": "Kolorowa etykieta", - "red": "Czerwony", - "orange": "Pomarańczowy", - "yellow": "Żółty", - "green": "Zielony", - "blue": "Niebieski", - "purple": "Fioletowy", - "pink": "Różowy", - "none": "Brak" - }, "tooltips": { "reply": "Odpowiedz (r)", "reply_all": "Odpowiedz wszystkim (a)", @@ -2033,8 +2022,7 @@ "delete": "Usuń", "mark_as_spam": "Zgłoś spam", "not_spam": "To nie spam", - "color_tag": "Etykieta", - "remove_color": "Usuń etykietę", + "tag": "Etykieta", "items_selected": "{count} zaznaczonych wiadomości", "edit_draft": "Edytuj szkic", "cancel_scheduled_send": "Anuluj wysyłkę", diff --git a/locales/pt/common.json b/locales/pt/common.json index 42b5e2de..6bbd2b2e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -325,11 +325,11 @@ "view_contact": "Ver contato", "message_details": "Detalhes da Mensagem", "more_reply_options": "Mais opções de resposta", - "set_color": "Definir etiqueta", + "set_tag": "Definir etiqueta", "tag": "Etiqueta", "more_actions": "Mais ações", "move_to": "Mover para...", - "remove_color": "Remover etiqueta", + "remove_tag": "Remover etiqueta", "more_count": "+{count} mais", "characters_count": "{count} caracteres", "quick_reply_placeholder": "Escreva uma resposta rápida...", @@ -398,17 +398,6 @@ "message_id": "ID da Mensagem", "list_info": "Informações da Lista" }, - "color_tag": { - "title": "Etiqueta de Cor", - "red": "Vermelho", - "orange": "Laranja", - "yellow": "Amarelo", - "green": "Verde", - "blue": "Azul", - "purple": "Roxo", - "pink": "Rosa", - "none": "Nenhuma" - }, "tooltips": { "reply": "Responder", "reply_all": "Responder a todos (a)", @@ -2033,8 +2022,7 @@ "delete": "Excluir", "mark_as_spam": "Reportar spam", "not_spam": "Não é spam", - "color_tag": "Etiqueta", - "remove_color": "Remover etiqueta", + "tag": "Etiqueta", "items_selected": "{count} e-mails selecionados", "edit_draft": "Editar rascunho", "cancel_scheduled_send": "Cancelar envio", diff --git a/locales/ro/common.json b/locales/ro/common.json index 02f011a4..12968735 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -325,13 +325,13 @@ "view_contact": "Vizualizare contact", "message_details": "Detalii mesaj", "more_reply_options": "Mai multe opțiuni de răspuns", - "set_color": "Setați eticheta", + "set_tag": "Setați eticheta", "tag": "Etichetă", "more_actions": "Alte acțiuni", "previous": "Anterior", "next": "Următorul", "move_to": "Mergi la...", - "remove_color": "Eliminați eticheta", + "remove_tag": "Eliminați eticheta", "more_count": "+{count} mai multe", "characters_count": "{count} caractere", "quick_reply_placeholder": "Scrie un răspuns rapid...", @@ -425,17 +425,6 @@ "message_id": "IDul mesajelor", "list_info": "Informații despre listă" }, - "color_tag": { - "title": "Etichetă de culoare", - "red": "Roșu", - "orange": "Portocaliu", - "yellow": "Galben", - "green": "Verde", - "blue": "Albastru", - "purple": "Violet", - "pink": "Roz", - "none": "Niciunul" - }, "tooltips": { "reply": "Răspunde (r)", "reply_all": "Răspunde tuturor (a)", @@ -2033,8 +2022,7 @@ "delete": "Șterge", "mark_as_spam": "Raportează spamul", "not_spam": "Nu este spam", - "color_tag": "Etichetă", - "remove_color": "Eliminați eticheta", + "tag": "Etichetă", "items_selected": "{count} e-mailuri selectate", "edit_draft": "Editează schița", "cancel_scheduled_send": "Anulează trimiterea", diff --git a/locales/ru/common.json b/locales/ru/common.json index 878c9af6..9302e3a6 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -325,13 +325,13 @@ "view_contact": "Просмотреть контакт", "message_details": "Детали сообщения", "more_reply_options": "Дополнительные параметры ответа", - "set_color": "Установить тег", + "set_tag": "Установить тег", "tag": "Тег", "more_actions": "Другие действия", "previous": "Пред.", "next": "След.", "move_to": "Переместить в...", - "remove_color": "Удалить тег", + "remove_tag": "Удалить тег", "more_count": "+{count} ещё", "characters_count": "{count} символов", "quick_reply_placeholder": "Написать быстрый ответ...", @@ -400,17 +400,6 @@ "message_id": "Идентификатор сообщения", "list_info": "Информация о рассылке" }, - "color_tag": { - "title": "Цветной тег", - "red": "Красный", - "orange": "Оранжевый", - "yellow": "Жёлтый", - "green": "Зелёный", - "blue": "Синий", - "purple": "Фиолетовый", - "pink": "Розовый", - "none": "Нет" - }, "tooltips": { "reply": "Ответить (r)", "reply_all": "Ответить всем (a)", @@ -2033,8 +2022,7 @@ "delete": "Удалить", "mark_as_spam": "Отметить как спам", "not_spam": "Не спам", - "color_tag": "Тег", - "remove_color": "Удалить тег", + "tag": "Тег", "items_selected": "{count} писем выбрано", "edit_draft": "Редактировать черновик", "cancel_scheduled_send": "Отменить отправку", diff --git a/locales/sk/common.json b/locales/sk/common.json index 3d10b558..0d2fba68 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -325,13 +325,13 @@ "view_contact": "Zobraziť kontakt", "message_details": "Podrobnosti správy", "more_reply_options": "Viac možností odpovede", - "set_color": "Nastaviť štítok", + "set_tag": "Nastaviť štítok", "tag": "Štítok", "more_actions": "Viac akcií", "previous": "Predchádzajúci", "next": "Ďalší", "move_to": "Presunúť do...", - "remove_color": "Odstrániť štítok", + "remove_tag": "Odstrániť štítok", "more_count": "+{count} ďalších", "characters_count": "{count} znakov", "quick_reply_placeholder": "Napísať rýchlu odpoveď...", @@ -425,17 +425,6 @@ "message_id": "ID správy", "list_info": "Informácie o zozname" }, - "color_tag": { - "title": "Farebný štítok", - "red": "Červený", - "orange": "Oranžový", - "yellow": "Žltý", - "green": "Zelený", - "blue": "Modrý", - "purple": "Fialový", - "pink": "RŪžový", - "none": "Žiadny" - }, "tooltips": { "reply": "Odpovedať (r)", "reply_all": "Odpovedať všetkým (a)", @@ -2033,8 +2022,7 @@ "delete": "Odstrániť", "mark_as_spam": "Nahlásiť spam", "not_spam": "Nie je spam", - "color_tag": "Štítok", - "remove_color": "Odstrániť štítok", + "tag": "Štítok", "items_selected": "{count} vybraných e-mailov", "edit_draft": "Upraviť koncept", "cancel_scheduled_send": "Zrušiť odoslanie", diff --git a/locales/tr/common.json b/locales/tr/common.json index baa6a5cf..6e6ed5a0 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -325,13 +325,13 @@ "view_contact": "Kişiyi görüntüle", "message_details": "İleti Ayrıntıları", "more_reply_options": "Daha fazla yanıt seçeneği", - "set_color": "Etiket ayarla", + "set_tag": "Etiket ayarla", "tag": "Etiket", "more_actions": "Diğer işlemler", "previous": "Önceki", "next": "Sonraki", "move_to": "Şuraya taşı...", - "remove_color": "Etiketi kaldır", + "remove_tag": "Etiketi kaldır", "more_count": "+{count} daha", "characters_count": "{count} karakter", "quick_reply_placeholder": "Hızlı yanıt yazın...", @@ -400,17 +400,6 @@ "message_id": "İleti Kimliği", "list_info": "Liste Bilgisi" }, - "color_tag": { - "title": "Renk Etiketi", - "red": "Kırmızı", - "orange": "Turuncu", - "yellow": "Sarı", - "green": "Yeşil", - "blue": "Mavi", - "purple": "Mor", - "pink": "Pembe", - "none": "Yok" - }, "tooltips": { "reply": "Yanıtla (r)", "reply_all": "Tümünü Yanıtla (a)", @@ -2033,8 +2022,7 @@ "delete": "Sil", "mark_as_spam": "Spam bildir", "not_spam": "Spam değil", - "color_tag": "Etiket", - "remove_color": "Etiketi kaldır", + "tag": "Etiket", "items_selected": "{count} e-posta seçildi", "edit_draft": "Taslağı Düzenle", "cancel_scheduled_send": "Göndermeyi iptal et", diff --git a/locales/uk/common.json b/locales/uk/common.json index 31a97d4e..6da37126 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -325,13 +325,13 @@ "view_contact": "Переглянути контакт", "message_details": "Деталі повідомлення", "more_reply_options": "Більше варіантів відповіді", - "set_color": "Встановити тег", + "set_tag": "Встановити тег", "tag": "Тег", "more_actions": "Більше дій", "previous": "попередня", "next": "Далі", "move_to": "Перейти до...", - "remove_color": "Видалити тег", + "remove_tag": "Видалити тег", "more_count": "+ ще {count}", "characters_count": "{count} символів", "quick_reply_placeholder": "Напишіть швидку відповідь...", @@ -400,17 +400,6 @@ "message_id": "ID повідомлення", "list_info": "Інформація про список" }, - "color_tag": { - "title": "Кольоровий тег", - "red": "Червоний", - "orange": "Помаранчевий", - "yellow": "Жовтий", - "green": "Зелений", - "blue": "Синій", - "purple": "Фіолетовий", - "pink": "Рожевий", - "none": "Жодного" - }, "tooltips": { "reply": "Відповісти (р)", "reply_all": "Відповісти всім (а)", @@ -2033,8 +2022,7 @@ "delete": "Видалити", "mark_as_spam": "Повідомити про спам", "not_spam": "Не спам", - "color_tag": "Мітка", - "remove_color": "Видалити мітку", + "tag": "Мітка", "items_selected": "Вибрано електронних листів: {count}", "edit_draft": "Редагувати чернетку", "cancel_scheduled_send": "Скасувати надсилання", diff --git a/locales/zh/common.json b/locales/zh/common.json index 6015e0c0..54273d96 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -325,13 +325,13 @@ "view_contact": "查看联系人", "message_details": "邮件详情", "more_reply_options": "更多回复选项", - "set_color": "设置颜色标签", + "set_tag": "设置颜色标签", "tag": "标签", "more_actions": "更多操作", "previous": "上一封", "next": "下一封", "move_to": "移动到…", - "remove_color": "删除标签", + "remove_tag": "删除标签", "more_count": "+{count} 更多", "characters_count": "{count} 个字符", "quick_reply_placeholder": "快速回复...", @@ -400,17 +400,6 @@ "message_id": "消息 ID", "list_info": "邮件列表信息" }, - "color_tag": { - "title": "颜色标签", - "red": "红色", - "orange": "橙色", - "yellow": "黄色", - "green": "绿色", - "blue": "蓝色", - "purple": "紫色", - "pink": "粉色", - "none": "无" - }, "tooltips": { "reply": "回复 (r)", "reply_all": "全部回复 (a)", @@ -2033,8 +2022,7 @@ "delete": "删除", "mark_as_spam": "举报垃圾邮件", "not_spam": "不是垃圾邮件", - "color_tag": "标签", - "remove_color": "删除标签", + "tag": "标签", "items_selected": "已选择 {count} 封邮件", "edit_draft": "编辑草稿", "cancel_scheduled_send": "取消发送", diff --git a/stores/__tests__/settings-store-keywords.test.ts b/stores/__tests__/settings-store-keywords.test.ts index 202e8428..822c8a72 100644 --- a/stores/__tests__/settings-store-keywords.test.ts +++ b/stores/__tests__/settings-store-keywords.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE, getKeywordVisibility } from '../settings-store'; +import { useSettingsStore, DEFAULT_KEYWORDS, KEYWORD_PALETTE, KEYWORD_PALETTE_ROWS, getKeywordVisibility } from '../settings-store'; import type { KeywordDefinition } from '../settings-store'; describe('settings-store keywords', () => { @@ -16,7 +16,7 @@ describe('settings-store keywords', () => { DEFAULT_KEYWORDS.forEach((kw) => { expect(KEYWORD_PALETTE[kw.color]).toBeDefined(); expect(KEYWORD_PALETTE[kw.color].dot).toBeTruthy(); - expect(KEYWORD_PALETTE[kw.color].bg).toBeTruthy(); + expect(KEYWORD_PALETTE[kw.color].fill).toBeTruthy(); }); }); @@ -27,14 +27,39 @@ describe('settings-store keywords', () => { }); describe('KEYWORD_PALETTE', () => { - it('has 13 colors', () => { - expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(13); + it('has a lighter, base and darker shade of every hue', () => { + expect(KEYWORD_PALETTE_ROWS).toHaveLength(3); + KEYWORD_PALETTE_ROWS.forEach((row) => expect(row).toHaveLength(13)); + expect(Object.keys(KEYWORD_PALETTE)).toHaveLength(39); }); - it('each color has dot and bg classes', () => { + it('lays every row out in the same hue order', () => { + const [light, base, dark] = KEYWORD_PALETTE_ROWS; + expect(light).toEqual(base.map((key) => `${key}-light`)); + expect(dark).toEqual(base.map((key) => `${key}-dark`)); + }); + + it('keeps the bare hue name on the base row, so saved tags still resolve', () => { + // A tag stored as `red` predates the lighter and darker rows. + expect(KEYWORD_PALETTE_ROWS[1]).toContain('red'); + expect(KEYWORD_PALETTE.red).toBeDefined(); + }); + + it('spells every class out so Tailwind can find it', () => { + // A composed class name would compile to nothing, so none may be built + // at runtime and each has to carry its own utility prefix. Object.values(KEYWORD_PALETTE).forEach((entry) => { expect(entry.dot).toMatch(/^bg-/); - expect(entry.bg).toMatch(/^bg-/); + expect(entry.fill).toMatch(/^bg-/); + expect(entry.border).toMatch(/^border-/); + expect(entry.text).toMatch(/^text-.* dark:text-/); + expect(entry.rowTint).toMatch(/^bg-.* dark:bg-/); + }); + }); + + it('resolves every row key', () => { + KEYWORD_PALETTE_ROWS.flat().forEach((key) => { + expect(KEYWORD_PALETTE[key]).toBeDefined(); }); }); }); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index b8afec55..e2ae45bf 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -125,23 +125,86 @@ export interface SidebarApp { showOnMobile: boolean; } -// 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' }, +export interface KeywordColor { + /** Solid swatch: the dot form and the settings swatches. */ + dot: string; + /** The same solid colour as `dot`, for glyphs that take a text colour. */ + icon: string; + /** Lozenge background. */ + fill: string; + /** Lozenge border. */ + border: string; + /** Lozenge text. */ + text: string; + /** Full-row wash when `tintListRowsByTag` is on. */ + rowTint: string; +} + +/** + * Tag colours, written out literally. + * + * Tailwind v4 scans this file, but only for classes that appear verbatim - + * a composed `bg-${hue}-500` would compile to nothing. Every shade a tag can + * take therefore has to be spelled out, which is why this map is long. + * + * Three shades per hue: the middle one keeps the bare hue name, so a tag + * saved before the lighter and darker rows existed still resolves. + */ +export const KEYWORD_PALETTE: Record = { + // light + 'red-light': { dot: 'bg-red-300', icon: 'text-red-300', fill: 'bg-red-300/10', border: 'border-red-300/30', text: 'text-red-600 dark:text-red-200', rowTint: 'bg-red-50/60 dark:bg-red-950/20' }, + 'orange-light': { dot: 'bg-orange-300', icon: 'text-orange-300', fill: 'bg-orange-300/10', border: 'border-orange-300/30', text: 'text-orange-600 dark:text-orange-200', rowTint: 'bg-orange-50/60 dark:bg-orange-950/20' }, + 'amber-light': { dot: 'bg-amber-300', icon: 'text-amber-300', fill: 'bg-amber-300/10', border: 'border-amber-300/30', text: 'text-amber-600 dark:text-amber-200', rowTint: 'bg-amber-50/60 dark:bg-amber-950/20' }, + 'yellow-light': { dot: 'bg-yellow-300', icon: 'text-yellow-300', fill: 'bg-yellow-300/10', border: 'border-yellow-300/30', text: 'text-yellow-600 dark:text-yellow-200', rowTint: 'bg-yellow-50/60 dark:bg-yellow-950/20' }, + 'lime-light': { dot: 'bg-lime-300', icon: 'text-lime-300', fill: 'bg-lime-300/10', border: 'border-lime-300/30', text: 'text-lime-600 dark:text-lime-200', rowTint: 'bg-lime-50/60 dark:bg-lime-950/20' }, + 'green-light': { dot: 'bg-green-300', icon: 'text-green-300', fill: 'bg-green-300/10', border: 'border-green-300/30', text: 'text-green-600 dark:text-green-200', rowTint: 'bg-green-50/60 dark:bg-green-950/20' }, + 'teal-light': { dot: 'bg-teal-300', icon: 'text-teal-300', fill: 'bg-teal-300/10', border: 'border-teal-300/30', text: 'text-teal-600 dark:text-teal-200', rowTint: 'bg-teal-50/60 dark:bg-teal-950/20' }, + 'cyan-light': { dot: 'bg-cyan-300', icon: 'text-cyan-300', fill: 'bg-cyan-300/10', border: 'border-cyan-300/30', text: 'text-cyan-600 dark:text-cyan-200', rowTint: 'bg-cyan-50/60 dark:bg-cyan-950/20' }, + 'blue-light': { dot: 'bg-blue-300', icon: 'text-blue-300', fill: 'bg-blue-300/10', border: 'border-blue-300/30', text: 'text-blue-600 dark:text-blue-200', rowTint: 'bg-blue-50/60 dark:bg-blue-950/20' }, + 'indigo-light': { dot: 'bg-indigo-300', icon: 'text-indigo-300', fill: 'bg-indigo-300/10', border: 'border-indigo-300/30', text: 'text-indigo-600 dark:text-indigo-200', rowTint: 'bg-indigo-50/60 dark:bg-indigo-950/20' }, + 'purple-light': { dot: 'bg-purple-300', icon: 'text-purple-300', fill: 'bg-purple-300/10', border: 'border-purple-300/30', text: 'text-purple-600 dark:text-purple-200', rowTint: 'bg-purple-50/60 dark:bg-purple-950/20' }, + 'pink-light': { dot: 'bg-pink-300', icon: 'text-pink-300', fill: 'bg-pink-300/10', border: 'border-pink-300/30', text: 'text-pink-600 dark:text-pink-200', rowTint: 'bg-pink-50/60 dark:bg-pink-950/20' }, + 'gray-light': { dot: 'bg-gray-300', icon: 'text-gray-300', fill: 'bg-gray-300/10', border: 'border-gray-300/30', text: 'text-gray-600 dark:text-gray-200', rowTint: 'bg-gray-50/60 dark:bg-gray-950/20' }, + // base + red: { dot: 'bg-red-500', icon: 'text-red-500', fill: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-700 dark:text-red-300', rowTint: 'bg-red-50 dark:bg-red-950/30' }, + orange: { dot: 'bg-orange-500', icon: 'text-orange-500', fill: 'bg-orange-500/10', border: 'border-orange-500/30', text: 'text-orange-700 dark:text-orange-300', rowTint: 'bg-orange-50 dark:bg-orange-950/30' }, + amber: { dot: 'bg-amber-500', icon: 'text-amber-500', fill: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-700 dark:text-amber-300', rowTint: 'bg-amber-50 dark:bg-amber-950/30' }, + yellow: { dot: 'bg-yellow-500', icon: 'text-yellow-500', fill: 'bg-yellow-500/10', border: 'border-yellow-500/30', text: 'text-yellow-700 dark:text-yellow-300', rowTint: 'bg-yellow-50 dark:bg-yellow-950/30' }, + lime: { dot: 'bg-lime-500', icon: 'text-lime-500', fill: 'bg-lime-500/10', border: 'border-lime-500/30', text: 'text-lime-700 dark:text-lime-300', rowTint: 'bg-lime-50 dark:bg-lime-950/30' }, + green: { dot: 'bg-green-500', icon: 'text-green-500', fill: 'bg-green-500/10', border: 'border-green-500/30', text: 'text-green-700 dark:text-green-300', rowTint: 'bg-green-50 dark:bg-green-950/30' }, + teal: { dot: 'bg-teal-500', icon: 'text-teal-500', fill: 'bg-teal-500/10', border: 'border-teal-500/30', text: 'text-teal-700 dark:text-teal-300', rowTint: 'bg-teal-50 dark:bg-teal-950/30' }, + cyan: { dot: 'bg-cyan-500', icon: 'text-cyan-500', fill: 'bg-cyan-500/10', border: 'border-cyan-500/30', text: 'text-cyan-700 dark:text-cyan-300', rowTint: 'bg-cyan-50 dark:bg-cyan-950/30' }, + blue: { dot: 'bg-blue-500', icon: 'text-blue-500', fill: 'bg-blue-500/10', border: 'border-blue-500/30', text: 'text-blue-700 dark:text-blue-300', rowTint: 'bg-blue-50 dark:bg-blue-950/30' }, + indigo: { dot: 'bg-indigo-500', icon: 'text-indigo-500', fill: 'bg-indigo-500/10', border: 'border-indigo-500/30', text: 'text-indigo-700 dark:text-indigo-300', rowTint: 'bg-indigo-50 dark:bg-indigo-950/30' }, + purple: { dot: 'bg-purple-500', icon: 'text-purple-500', fill: 'bg-purple-500/10', border: 'border-purple-500/30', text: 'text-purple-700 dark:text-purple-300', rowTint: 'bg-purple-50 dark:bg-purple-950/30' }, + pink: { dot: 'bg-pink-500', icon: 'text-pink-500', fill: 'bg-pink-500/10', border: 'border-pink-500/30', text: 'text-pink-700 dark:text-pink-300', rowTint: 'bg-pink-50 dark:bg-pink-950/30' }, + gray: { dot: 'bg-gray-500', icon: 'text-gray-500', fill: 'bg-gray-500/10', border: 'border-gray-500/30', text: 'text-gray-700 dark:text-gray-300', rowTint: 'bg-gray-50 dark:bg-gray-950/30' }, + // dark + 'red-dark': { dot: 'bg-red-700', icon: 'text-red-700', fill: 'bg-red-700/10', border: 'border-red-700/30', text: 'text-red-800 dark:text-red-400', rowTint: 'bg-red-100 dark:bg-red-950/50' }, + 'orange-dark': { dot: 'bg-orange-700', icon: 'text-orange-700', fill: 'bg-orange-700/10', border: 'border-orange-700/30', text: 'text-orange-800 dark:text-orange-400', rowTint: 'bg-orange-100 dark:bg-orange-950/50' }, + 'amber-dark': { dot: 'bg-amber-700', icon: 'text-amber-700', fill: 'bg-amber-700/10', border: 'border-amber-700/30', text: 'text-amber-800 dark:text-amber-400', rowTint: 'bg-amber-100 dark:bg-amber-950/50' }, + 'yellow-dark': { dot: 'bg-yellow-700', icon: 'text-yellow-700', fill: 'bg-yellow-700/10', border: 'border-yellow-700/30', text: 'text-yellow-800 dark:text-yellow-400', rowTint: 'bg-yellow-100 dark:bg-yellow-950/50' }, + 'lime-dark': { dot: 'bg-lime-700', icon: 'text-lime-700', fill: 'bg-lime-700/10', border: 'border-lime-700/30', text: 'text-lime-800 dark:text-lime-400', rowTint: 'bg-lime-100 dark:bg-lime-950/50' }, + 'green-dark': { dot: 'bg-green-700', icon: 'text-green-700', fill: 'bg-green-700/10', border: 'border-green-700/30', text: 'text-green-800 dark:text-green-400', rowTint: 'bg-green-100 dark:bg-green-950/50' }, + 'teal-dark': { dot: 'bg-teal-700', icon: 'text-teal-700', fill: 'bg-teal-700/10', border: 'border-teal-700/30', text: 'text-teal-800 dark:text-teal-400', rowTint: 'bg-teal-100 dark:bg-teal-950/50' }, + 'cyan-dark': { dot: 'bg-cyan-700', icon: 'text-cyan-700', fill: 'bg-cyan-700/10', border: 'border-cyan-700/30', text: 'text-cyan-800 dark:text-cyan-400', rowTint: 'bg-cyan-100 dark:bg-cyan-950/50' }, + 'blue-dark': { dot: 'bg-blue-700', icon: 'text-blue-700', fill: 'bg-blue-700/10', border: 'border-blue-700/30', text: 'text-blue-800 dark:text-blue-400', rowTint: 'bg-blue-100 dark:bg-blue-950/50' }, + 'indigo-dark': { dot: 'bg-indigo-700', icon: 'text-indigo-700', fill: 'bg-indigo-700/10', border: 'border-indigo-700/30', text: 'text-indigo-800 dark:text-indigo-400', rowTint: 'bg-indigo-100 dark:bg-indigo-950/50' }, + 'purple-dark': { dot: 'bg-purple-700', icon: 'text-purple-700', fill: 'bg-purple-700/10', border: 'border-purple-700/30', text: 'text-purple-800 dark:text-purple-400', rowTint: 'bg-purple-100 dark:bg-purple-950/50' }, + 'pink-dark': { dot: 'bg-pink-700', icon: 'text-pink-700', fill: 'bg-pink-700/10', border: 'border-pink-700/30', text: 'text-pink-800 dark:text-pink-400', rowTint: 'bg-pink-100 dark:bg-pink-950/50' }, + 'gray-dark': { dot: 'bg-gray-700', icon: 'text-gray-700', fill: 'bg-gray-700/10', border: 'border-gray-700/30', text: 'text-gray-800 dark:text-gray-400', rowTint: 'bg-gray-100 dark:bg-gray-950/50' }, } as const; +/** Palette laid out as the settings picker shows it: lighter, base, darker. */ +export const KEYWORD_PALETTE_ROWS: string[][] = [ + ['red-light', 'orange-light', 'amber-light', 'yellow-light', 'lime-light', 'green-light', 'teal-light', 'cyan-light', 'blue-light', 'indigo-light', 'purple-light', 'pink-light', 'gray-light'], + ['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'teal', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'gray'], + ['red-dark', 'orange-dark', 'amber-dark', 'yellow-dark', 'lime-dark', 'green-dark', 'teal-dark', 'cyan-dark', 'blue-dark', 'indigo-dark', 'purple-dark', 'pink-dark', 'gray-dark'], +]; + +/** The colour a tag falls back to when its definition is gone. */ +export const FALLBACK_KEYWORD_COLOR = 'gray'; + export const DEFAULT_KEYWORDS: KeywordDefinition[] = [ { id: 'red', label: 'Red', color: 'red' }, { id: 'orange', label: 'Orange', color: 'orange' },