From 24949e183fab5da280a9074d264b554d22b47b6f Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 13:57:20 +0200 Subject: [PATCH 1/5] feat: add i18n API, render hooks, and new intercept hooks to plugin system --- lib/plugin-api.ts | 29 ++++++++++- lib/plugin-hooks.ts | 27 +++++++++- lib/plugin-i18n.ts | 118 +++++++++++++++++++++++++++++++++++++++++++ lib/plugin-loader.ts | 26 +++++++++- lib/plugin-types.ts | 89 ++++++++++++++++++++++++++++++++ 5 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 lib/plugin-i18n.ts diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index 4ca7144e..70306630 100644 --- a/lib/plugin-api.ts +++ b/lib/plugin-api.ts @@ -14,6 +14,7 @@ import type { AdminPageSection, CalendarEventAction, SlotName, + PluginI18n, } from './plugin-types'; import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types'; import { @@ -22,8 +23,9 @@ import { taskHooks, templateHooks, smimeHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, - sidebarAppHooks, avatarHooks, + sidebarAppHooks, avatarHooks, renderHooks, } from './plugin-hooks'; +import { createPluginI18n } from './plugin-i18n'; import { toast as appToast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; @@ -109,6 +111,8 @@ function createPluginLogger(pluginId: string) { export interface PluginAPI { plugin: { id: string; version: string; settings: Record }; + /** Localisation API — register translations and call t() to get strings */ + i18n: PluginI18n; ui: { registerToolbarAction: (action: ToolbarAction) => Disposable; registerEmailBanner: (factory: BannerFactory) => Disposable; @@ -149,6 +153,8 @@ export interface PluginHooksAPI { onEmailClose: (handler: () => void) => Disposable; onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable; onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable; + /** Intercept — receives ComposeOptions, may mutate fields, return false to cancel */ + onBeforeCompose: (handler: (options: import('./plugin-types').ComposeOptions) => boolean | void | Promise) => Disposable; onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable; @@ -157,6 +163,10 @@ export interface PluginHooksAPI { onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable; + /** Emitted after emails are moved to the Archive mailbox */ + onEmailArchive: (handler: (emailIds: string[]) => void) => Disposable; + /** Emitted after emails are moved out of the Archive mailbox */ + onEmailUnarchive: (handler: (emailIds: string[]) => void) => Disposable; onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable; onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable; onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable; @@ -173,6 +183,8 @@ export interface PluginHooksAPI { onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable; onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable; onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable; + /** Intercept — receives MailtoContext, return false to prevent the system mail client */ + onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise) => Disposable; // Calendar onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable; @@ -215,6 +227,8 @@ export interface PluginHooksAPI { onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + /** Intercept — receives { file: FileResourceView, newName: string }, return false to cancel */ + onBeforeFileRename: (handler: (ctx: { file: import('./plugin-types').FileResourceView; newName: string }) => boolean | void | Promise) => Disposable; onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable; onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable; onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable; @@ -316,6 +330,9 @@ export interface PluginHooksAPI { onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable; // Avatar onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable; + // Render — transform hook for email list row badges + // Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[] + onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable; } // --- Permission mapping for hooks ---------------------------- @@ -324,14 +341,17 @@ const HOOK_PERMISSIONS: Record = { // Email onEmailOpen: 'email:read', onEmailClose: 'email:read', onEmailContentRender: 'email:read', onThreadExpand: 'email:read', - onComposerOpen: 'email:read', onDraftAutoSave: 'email:read', + onBeforeCompose: 'email:read', onComposerOpen: 'email:read', + onDraftAutoSave: 'email:read', onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read', onSearch: 'email:read', onSearchResults: 'email:read', onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read', onPushConnectionChange: 'email:read', onQuotaChange: 'email:read', + onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read', onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send', onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write', onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write', + onEmailArchive: 'email:write', onEmailUnarchive: 'email:write', onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write', onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write', onMailboxCreate: 'email:write', onMailboxRename: 'email:write', @@ -358,6 +378,7 @@ const HOOK_PERMISSIONS: Record = { onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write', onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write', onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write', + onBeforeFileRename: 'files:write', onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write', onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write', // Auth @@ -469,6 +490,8 @@ const HOOK_BUSES: Record { requirePermission(plugin, 'ui:toolbar'); diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 01ae6f06..c3df1b10 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -172,6 +172,10 @@ export const emailHooks = { onEmailClose: new HookBus(), onEmailContentRender: new HookBus(), onThreadExpand: new HookBus(), + // Intercept hook — fires before the composer opens. + // Handlers receive ComposeOptions and may mutate fields in place. + // Return false to cancel opening the composer. + onBeforeCompose: new HookBus(), onComposerOpen: new HookBus(), onBeforeEmailSend: new HookBus(), onAfterEmailSend: new HookBus(), @@ -180,6 +184,10 @@ export const emailHooks = { onAfterEmailDelete: new HookBus(), onBeforeEmailMove: new HookBus(), onAfterEmailMove: new HookBus(), + // Fired after one or more emails are archived to the Archive mailbox + onEmailArchive: new HookBus(), + // Fired after one or more emails are moved out of the Archive mailbox + onEmailUnarchive: new HookBus(), onEmailReadStateChange: new HookBus(), onEmailStarToggle: new HookBus(), onEmailSpamToggle: new HookBus(), @@ -196,6 +204,9 @@ export const emailHooks = { onNewEmailReceived: new HookBus(), onPushConnectionChange: new HookBus(), onQuotaChange: new HookBus(), + // Intercept hook — fired when a mailto: link is clicked. + // Return false to prevent the browser from opening the system mail client. + onMailtoIntercept: new HookBus(), }; // §7.2 Calendar Hooks @@ -250,6 +261,10 @@ export const fileHooks = { onDirectoryCreate: new HookBus(), onBeforeFileDelete: new HookBus(), onAfterFileDelete: new HookBus(), + // Intercept hook — fires before a file is renamed. + // Receives { file: FileResourceView, newName: string }. + // Return false to cancel the rename. + onBeforeFileRename: new HookBus(), onFileRename: new HookBus(), onFileMove: new HookBus(), onFileCopy: new HookBus(), @@ -406,6 +421,16 @@ export const avatarHooks = { onAvatarResolve: new HookBus(), }; +// §7.22 Render Hooks +export const renderHooks = { + // Transform hook — runs for each visible email list row. + // Initial value: EmailListBadge[] (always starts as []) + // Second argument: { emailId: string; email: EmailReadView } + // Handlers return a new (or extended) badges array. + // Rendered by the email list row component next to the subject line. + onEmailListItemRender: new HookBus(), +}; + // ─── Aggregate: remove all handlers for a plugin across all buses ─── const allHookGroups = [ @@ -414,7 +439,7 @@ const allHookGroups = [ taskHooks, templateHooks, smimeHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, - avatarHooks, + avatarHooks, renderHooks, ]; export function removeAllPluginHooks(pluginId: string): void { diff --git a/lib/plugin-i18n.ts b/lib/plugin-i18n.ts new file mode 100644 index 00000000..b3ab7faf --- /dev/null +++ b/lib/plugin-i18n.ts @@ -0,0 +1,118 @@ +// Plugin i18n registry — manages per-plugin translation tables +// +// Each plugin gets its own namespace keyed by: +// pluginId → locale → { messageKey → translated string } +// +// Resolution order when calling t(key): +// 1. Exact locale match ("fr-CA") +// 2. Language-prefix match ("fr" from "fr-CA") +// 3. English fallback ("en") +// 4. Raw key (plugin is never broken by missing strings) +// +// Interpolation uses {paramName} placeholders. + +// ─── Registry ──────────────────────────────────────────────── + +/** pluginId → locale → key → translated string */ +const registry = new Map>>(); + +let currentLocale = 'en'; + +// ─── Locale sync (called by plugin-loader) ─────────────────── + +/** Keep the registry in sync with the app locale */ +export function setPluginI18nLocale(locale: string): void { + currentLocale = locale; +} + +export function getPluginI18nLocale(): string { + return currentLocale; +} + +// ─── Cleanup ───────────────────────────────────────────────── + +/** Remove all translations for a plugin (called on deactivation) */ +export function clearPluginI18nTranslations(pluginId: string): void { + registry.delete(pluginId); +} + +// ─── Helpers ───────────────────────────────────────────────── + +function interpolate(template: string, params?: Record): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`)); +} + +function resolve(pluginId: string, key: string): string | undefined { + const byLocale = registry.get(pluginId); + if (!byLocale) return undefined; + + // 1. Exact locale (e.g. "fr-CA") + const exact = byLocale.get(currentLocale)?.[key]; + if (exact !== undefined) return exact; + + // 2. Language prefix (e.g. "fr" from "fr-CA") + const lang = currentLocale.split('-')[0]; + if (lang !== currentLocale) { + const langMatch = byLocale.get(lang)?.[key]; + if (langMatch !== undefined) return langMatch; + } + + // 3. English fallback + return byLocale.get('en')?.[key]; +} + +// ─── Public API factory ────────────────────────────────────── + +/** + * Build the i18n API object exposed as `api.i18n` inside each plugin. + * + * @example + * // In your plugin activate(): + * api.i18n.addTranslations('en', { 'banner.title': 'Hello' }); + * api.i18n.addTranslations('de', { 'banner.title': 'Hallo' }); + * + * // Later, in any React component the plugin renders: + * const title = api.i18n.t('banner.title'); + * const greeting = api.i18n.t('welcome', { name: 'Alice' }); // 'Hello, {name}!' + */ +export function createPluginI18n(pluginId: string) { + return { + /** + * Register translations for one locale. + * Multiple calls for the same locale are merged (last-write-wins on key collision). + * + * @param locale BCP-47 locale tag, e.g. "en", "de", "fr-CA" + * @param strings Key → translated string map. Use {paramName} for interpolation. + */ + addTranslations(locale: string, strings: Record): void { + let byLocale = registry.get(pluginId); + if (!byLocale) { + byLocale = new Map>(); + registry.set(pluginId, byLocale); + } + const existing = byLocale.get(locale) ?? {}; + byLocale.set(locale, { ...existing, ...strings }); + }, + + /** + * Translate a key using the current app locale. + * Falls back through: exact locale → language prefix → 'en' → raw key. + * + * @param key Translation key, e.g. `'banner.title'` + * @param params Optional interpolation values, e.g. `{ count: 3 }` + */ + t(key: string, params?: Record): string { + const template = resolve(pluginId, key); + if (template !== undefined) return interpolate(template, params); + return key; // never throw — just return the key + }, + + /** The current app locale (e.g. "en", "de", "fr") */ + getLocale(): string { + return currentLocale; + }, + }; +} + +export type PluginI18nInstance = ReturnType; diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts index 7eb1719a..80547fc8 100644 --- a/lib/plugin-loader.ts +++ b/lib/plugin-loader.ts @@ -1,15 +1,18 @@ -// Plugin Loader — loads and activates plugins via blob URL dynamic import +// Plugin Loader — loads and activates plugins via blob URL dynamic import import type { InstalledPlugin, Disposable } from './plugin-types'; import { pluginStorage } from './plugin-storage'; import { createPluginAPI, type PluginAPI } from './plugin-api'; import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks'; +import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n'; import React from 'react'; import ReactDOM from 'react-dom'; import * as ReactJSX from 'react/jsx-runtime'; // --- Shared React (window.__PLUGIN_EXTERNALS__) ------------- +let localeSyncInitialised = false; + export function exposePluginExternals(): void { if (typeof window === 'undefined') return; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -18,6 +21,16 @@ export function exposePluginExternals(): void { ReactDOM, ReactJSX, }; + + // Sync plugin i18n with the app locale (runs once per page load) + if (!localeSyncInitialised) { + localeSyncInitialised = true; + // Dynamic import avoids a circular dependency chain at module evaluation time + import('@/stores/locale-store').then(({ useLocaleStore }) => { + setPluginI18nLocale(useLocaleStore.getState().locale); + useLocaleStore.subscribe((state) => setPluginI18nLocale(state.locale)); + }).catch(() => {/* locale sync is best-effort */}); + } } // --- Active plugin tracking ---------------------------------- @@ -78,6 +91,14 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise { // 4. Build sandboxed API const api = createPluginAPI(plugin); + // 4b. Auto-register translations bundled in the manifest (plugin.locales) + // Plugins may still call api.i18n.addTranslations() in activate() to add more. + if (plugin.locales) { + for (const [locale, strings] of Object.entries(plugin.locales)) { + api.i18n.addTranslations(locale, strings); + } + } + // 5. Call activate const disposable = await mod.activate(api); @@ -119,6 +140,9 @@ export function deactivatePlugin(pluginId: string): void { // Remove all hook subscriptions for this plugin removeAllPluginHooks(pluginId); + // Clear cached translations (avoids memory leak on repeated enable/disable cycles) + clearPluginI18nTranslations(pluginId); + // Reset error tracker pluginErrorTracker.reset(pluginId); diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 1936fc6e..f4721f3d 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -34,6 +34,13 @@ export interface PluginManifest { entrypoint: string; minAppVersion?: string; settingsSchema?: Record; + /** + * Bundled translations shipped inside the plugin ZIP. + * Keyed by BCP-47 locale tag ("en", "de", "fr-CA", …). + * The loader auto-registers these before calling activate(), + * so plugins can use api.i18n.t() without calling addTranslations() first. + */ + locales?: Record>; } export interface SettingFieldSchema { @@ -83,6 +90,8 @@ export interface InstalledPlugin { adminApproved?: boolean; settingsSchema?: Record; settings: Record; + /** Bundled translations, carried over from the manifest on install. */ + locales?: Record>; } // ─── UI Slots ──────────────────────────────────────────────── @@ -388,6 +397,86 @@ export interface ComposerContext { originalSubject?: string; } +// ─── New hook context types ────────────────────────────────── + +/** + * Passed to onBeforeCompose handlers. + * Handlers may mutate the object in place to pre-fill fields; returning false cancels the compose. + */ +export interface ComposeOptions { + to: string[]; + cc: string[]; + subject: string; + body: string; + mode: 'new' | 'reply' | 'reply-all' | 'forward'; +} + +/** + * A small visual indicator injected into an email list row via onEmailListItemRender. + */ +export interface EmailListBadge { + /** Stable unique key within the plugin — used as React key */ + key: string; + /** Short label text displayed in the badge */ + label: string; + /** CSS color value for the badge background, e.g. "#e74c3c" or "var(--color-warning)" */ + color?: string; + /** Tooltip / aria-label */ + title?: string; +} + +/** + * Passed to onMailtoIntercept handlers. + * Return false to prevent the browser from opening the system mail client. + */ +export interface MailtoContext { + /** The raw href, e.g. "mailto:alice@example.com?subject=Hello" */ + href: string; + /** Parsed list of recipient addresses */ + to: string[]; + subject?: string; + body?: string; +} + +// ─── Plugin i18n API ───────────────────────────────────────── + +/** + * Localisation API exposed as `api.i18n` inside every plugin. + * + * Plugins ship their own translation tables; the app locale is tracked + * automatically so `t()` always returns the right string without any + * extra setup from the plugin side. + */ +export interface PluginI18n { + /** + * Register translations for one locale. + * Multiple calls for the same locale are merged (last-write-wins per key). + * + * @param locale BCP-47 tag, e.g. "en", "de", "fr-CA" + * @param strings Key → translated string map. Use {paramName} for interpolation. + * + * @example + * api.i18n.addTranslations('en', { 'banner.title': 'Tracking blocked' }); + * api.i18n.addTranslations('de', { 'banner.title': 'Tracking blockiert' }); + */ + addTranslations(locale: string, strings: Record): void; + + /** + * Return the translated string for `key` using the current app locale, + * with optional {param} interpolation. + * + * Falls back: exact locale → language prefix → "en" → raw key. + * + * @example + * api.i18n.t('banner.title') + * api.i18n.t('items_found', { count: 3 }) // 'Found {count} items' → 'Found 3 items' + */ + t(key: string, params?: Record): string; + + /** The current app locale string (e.g. "en", "de", "fr") */ + getLocale(): string; +} + // ─── Permission Reference ──────────────────────────────────── export const ALL_PERMISSIONS = [ From 1b816d3185b15ca1311ee938babee9d7fb676a0c Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 15:37:57 +0200 Subject: [PATCH 2/5] fix: standardize tag naming and fix unknown keyword display #184 #185 --- components/email/email-list-item.tsx | 4 +-- components/email/email-viewer.tsx | 16 ++++++------ components/email/thread-list-item.tsx | 6 ++--- components/filters/filter-rule-modal.tsx | 15 ++++++++--- lib/sieve/__tests__/generator.test.ts | 4 +-- lib/sieve/generator.ts | 2 +- locales/de/common.json | 24 +++++++++--------- locales/en/common.json | 30 +++++++++++----------- locales/es/common.json | 18 ++++++------- locales/fr/common.json | 26 +++++++++---------- locales/it/common.json | 22 ++++++++-------- locales/ja/common.json | 24 +++++++++--------- locales/ko/common.json | 32 ++++++++++++------------ locales/lv/common.json | 32 ++++++++++++------------ locales/nl/common.json | 22 ++++++++-------- locales/pl/common.json | 22 ++++++++-------- locales/pt/common.json | 24 +++++++++--------- locales/ru/common.json | 30 +++++++++++----------- locales/zh/common.json | 26 +++++++++---------- 19 files changed, 193 insertions(+), 186 deletions(-) diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 0db55e12..f12b5a6d 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -51,9 +51,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl const isFocusedMailLayout = mailLayout === 'focus'; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; - // Resolve color tags using keyword definitions from settings + // Resolve color tags using keyword definitions from settings; unknown tags fall back to gray const colorTagIds = getEmailColorTags(email.keywords); - const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords; + const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); // Use first tag for background coloring const keywordDef = keywordDefs[0] ?? null; const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e5000d14..eb8c1fb9 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3070,13 +3070,13 @@ export function EmailViewer({ <> {currentColors.slice(0, 3).map((tagId) => { - const kw = emailKeywords.find(k => k.id === tagId); - return kw ? : null; + const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' }; + return ; })} {showToolbarLabels && currentColors.length === 1 && ( - {emailKeywords.find(k => k.id === currentColors[0])?.label} + {emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]} )} @@ -3674,11 +3674,11 @@ export function EmailViewer({ {currentColors.length > 0 && ( {currentColors.map((tagId) => { - const kw = emailKeywords.find(k => k.id === tagId); - const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; - return dotClass ? ( - - ) : null; + 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 ( + + ); })} )} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 9ee8e705..819d180d 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -67,9 +67,9 @@ const SingleEmailItem = React.forwardRef( const isFocusedMailLayout = mailLayout === 'focus'; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; - // Resolve color tags using keyword definitions + // 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)).filter(Boolean) as typeof emailKeywords; + const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null; const resolvedColorTag = (() => { if (colorTag) return colorTag; @@ -375,7 +375,7 @@ export const ThreadListItem = React.forwardRef state.emailKeywords); - const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null; + const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null; const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; const isSelected = selectedEmailId === latestEmail.id || diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 3dd5f4da..96c7b293 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -17,6 +17,7 @@ import type { } from "@/lib/jmap/sieve-types"; import type { Mailbox } from "@/lib/jmap/types"; import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils"; +import { useSettingsStore } from "@/stores/settings-store"; interface FilterRuleModalProps { rule?: FilterRule; @@ -58,6 +59,7 @@ export function FilterRuleModal({ }: FilterRuleModalProps) { const t = useTranslations("settings.filters"); const isEdit = !!rule; + const emailKeywords = useSettingsStore((state) => state.emailKeywords); const [name, setName] = useState(rule?.name || ""); const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all"); @@ -375,12 +377,17 @@ export function FilterRuleModal({ )} {action.type === "add_label" && ( - updateAction(index, { value: e.target.value })} - placeholder={t("label_placeholder")} - className="flex-1 min-w-[140px]" - /> + className={`${selectClass} flex-1 min-w-[140px]`} + aria-label={t("label_placeholder")} + > + + {emailKeywords.map((kw) => ( + + ))} + )} + ) : ( +
+ )} +
+ )} + + + + ); +} + +function SidebarSectionHeader({ + label, + expanded, + onToggle, + onSettings, + settingsTitle, + isCollapsed, + first, + icon, + sub, +}: { + label: string; + expanded: boolean; + onToggle: () => void; + onSettings?: () => void; + settingsTitle?: string; + isCollapsed: boolean; + first?: boolean; + icon?: ReactNode; + sub?: boolean; +}) { + if (isCollapsed) { + return first ? null :
; + } + + const paddingY = sub ? "pt-2" : first ? "pt-3" : "pt-5"; + const paddingX = sub ? "px-4" : "px-3"; + const textClass = sub + ? "text-xs font-semibold text-muted-foreground truncate" + : "text-sm font-semibold text-foreground truncate"; + + return ( + + ); +} + function MailboxTreeItem({ node, selectedMailbox, @@ -87,6 +364,7 @@ function MailboxTreeItem({ onToggleExpand, isCollapsed, onUnreadFilterClick, + colorful, }: { node: MailboxNode; selectedMailbox: string; @@ -95,14 +373,15 @@ function MailboxTreeItem({ onToggleExpand: (id: string) => void; isCollapsed: boolean; onUnreadFilterClick?: (mailboxId: string) => void; + colorful: boolean; }) { - const t = useTranslations('sidebar'); const tNotifications = useTranslations('notifications'); const hasChildren = node.children.length > 0; const isExpanded = expandedFolders.has(node.id); const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id); - const indentPixels = node.depth * 16; const isVirtualNode = node.id.startsWith('shared-'); + const isSelected = selectedMailbox === node.id; + const roleKey = resolveRoleKey(node.role, node.name); const { isDragging: globalDragging } = useDragDropContext(); const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({ @@ -127,125 +406,58 @@ function MailboxTreeItem({ return ( <> -
- {hasChildren && !isCollapsed && ( - - )} + } + label={node.name} + depth={node.depth} + isSelected={isSelected} + isVirtual={isVirtualNode} + unread={node.unreadEmails} + total={node.totalEmails} + onClick={() => onMailboxSelect?.(node.id)} + hasChildren={hasChildren} + isExpanded={isExpanded} + onExpandToggle={() => onToggleExpand(node.id)} + onUnreadClick={() => onUnreadFilterClick?.(node.id)} + isCollapsed={isCollapsed} + dropHandlers={globalDragging ? (dropHandlers as Record) : undefined} + isValidDropTarget={isValidDropTarget} + isInvalidDropTarget={isInvalidDropTarget} + /> - -
- - {hasChildren && isExpanded && !isCollapsed && ( -
- {node.children.map((child) => ( - - ))} -
- )} + {hasChildren && isExpanded && !isCollapsed && node.children.map((child) => ( + + ))} ); } +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 TagItem({ kw, isSelected, @@ -253,6 +465,7 @@ function TagItem({ onTagSelect, totalCount, unreadCount, + colorful, }: { kw: KeywordDefinition; isSelected: boolean; @@ -260,6 +473,7 @@ function TagItem({ onTagSelect?: (keywordId: string | null) => void; totalCount: number; unreadCount: number; + colorful: boolean; }) { const t = useTranslations('notifications'); const palette = KEYWORD_PALETTE[kw.color]; @@ -278,51 +492,28 @@ function TagItem({ }, }); + const tagIcon = colorful ? ( + + ) : ( + + ); + return ( -
- -
+ onTagSelect?.(isSelected ? null : kw.id)} + isCollapsed={isCollapsed} + dropHandlers={globalDragging ? (dropHandlers as Record) : undefined} + isValidDropTarget={isValidDropTarget} + /> ); } @@ -337,7 +528,6 @@ function DemoBanner() { const handleReset = async () => { setIsResetting(true); - // Navigate to home first so the mail page re-fetches data router.push('/'); await loginDemo(); setIsResetting(false); @@ -417,7 +607,7 @@ export function Sidebar({ selectedKeyword = null, onMailboxSelect, onTagSelect, - onCompose, + onCompose: _onCompose, onSidebarClose, onUnreadFilterClick, className, @@ -438,9 +628,33 @@ export function Sidebar({ return stored !== null ? JSON.parse(stored) : true; } catch { return true; } }); + const [unifiedExpanded, setUnifiedExpanded] = useState(() => { + try { + const stored = localStorage.getItem('sidebarUnifiedExpanded'); + return stored !== null ? JSON.parse(stored) : true; + } catch { return true; } + }); + const [sharedExpanded, setSharedExpanded] = useState(() => { + try { + const stored = localStorage.getItem('sidebarSharedExpanded'); + return stored !== null ? JSON.parse(stored) : false; + } catch { return false; } + }); + const [expandedSharedAccounts, setExpandedSharedAccounts] = useState>(() => { + try { + const stored = localStorage.getItem('sidebarExpandedSharedAccounts'); + return stored !== null ? new Set(JSON.parse(stored) as string[]) : new Set(); + } catch { return new Set(); } + }); const emailKeywords = useSettingsStore(s => s.emailKeywords); const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher); + const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox); + const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons); const tagCounts = useEmailStore(s => s.tagCounts); + const accounts = useAccountStore(s => s.accounts); + const connectedAccounts = accounts.filter(a => a.isConnected); + const showUnified = enableUnifiedMailbox && connectedAccounts.length > 1; + const { unifiedCounts } = useEmailStore(); const t = useTranslations('sidebar'); useEffect(() => { @@ -484,6 +698,20 @@ export function Sidebar({ }; const mailboxTree = buildMailboxTree(mailboxes); + const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-')); + const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-')); + + const getUnifiedIcon = (role: UnifiedMailboxRole) => { + switch (role) { + case 'inbox': return Inbox; + case 'sent': return Send; + case 'drafts': return File; + case 'trash': return Trash2; + case 'archive': return Archive; + case 'junk': return Ban; + default: return Folder; + } + }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -516,6 +744,52 @@ export function Sidebar({ return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedMailbox, isCollapsed, expandedFolders, mailboxTree]); + const toggleUnified = () => { + setUnifiedExpanded((prev: boolean) => { + const next = !prev; + try { localStorage.setItem('sidebarUnifiedExpanded', JSON.stringify(next)); } catch { /* */ } + return next; + }); + }; + const toggleFolders = () => { + setFoldersExpanded((prev: boolean) => { + const next = !prev; + try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ } + return next; + }); + }; + const toggleTags = () => { + setTagsExpanded((prev: boolean) => { + const next = !prev; + try { localStorage.setItem('sidebarTagsExpanded', JSON.stringify(next)); } catch { /* */ } + return next; + }); + }; + const toggleShared = () => { + setSharedExpanded((prev: boolean) => { + const next = !prev; + try { localStorage.setItem('sidebarSharedExpanded', JSON.stringify(next)); } catch { /* */ } + return next; + }); + }; + const toggleSharedAccount = (id: string) => { + setExpandedSharedAccounts((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + try { localStorage.setItem('sidebarExpandedSharedAccounts', JSON.stringify(Array.from(next))); } catch { /* */ } + return next; + }); + }; + + const openFolderSettings = () => { + try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ } + router.push('/settings'); + }; + const openKeywordSettings = () => { + try { localStorage.setItem('settings-active-tab', 'keywords'); } catch { /* */ } + router.push('/settings'); + }; + return (
- {/* Demo Banner */} {!isCollapsed && } - - {/* Vacation Banner */} {!isCollapsed && } {/* Mailbox List */}
-
- {/* Folders Section Header */} -
- {!isCollapsed && ( - - )} - - - - {!isCollapsed && ( - + {showUnified && ( +
+ + {((unifiedExpanded && !isCollapsed) || isCollapsed) && ( + <> + {unifiedCounts.map((count) => { + const unifiedId = UNIFIED_MAILBOX_IDS[count.role]; + const Icon = getUnifiedIcon(count.role); + const isSelected = !selectedKeyword && selectedMailbox === unifiedId; + return ( + } + label={t(`unified_${count.role}`)} + depth={0} + isSelected={isSelected} + unread={count.unreadEmails} + total={count.totalEmails} + onClick={() => onMailboxSelect?.(unifiedId)} + isCollapsed={isCollapsed} + /> + ); + })} + )}
+ )} - {/* Folder Items */} +
+ {((foldersExpanded && !isCollapsed) || isCollapsed) && ( <> {mailboxes.length === 0 ? ( @@ -644,126 +883,98 @@ export function Sidebar({ {!isCollapsed && t("loading_mailboxes")}
) : ( - <> - {mailboxTree.map((node) => ( - - ))} - + ownTree.map((node) => ( + + )) )} )}
- {/* Tags Section */} - {emailKeywords.length > 0 && ( - <> -
- {!isCollapsed && ( - - )} - - - - {!isCollapsed && ( - - )} -
- - {((tagsExpanded && !isCollapsed) || isCollapsed) && ( -
- {emailKeywords.map((kw) => { - const isSelected = selectedKeyword === kw.id; + {sharedAccounts.length > 0 && ( +
+ + {((sharedExpanded && !isCollapsed) || isCollapsed) && ( + <> + {sharedAccounts.map((account) => { + const accountExpanded = expandedSharedAccounts.has(account.id); return ( - +
+ toggleSharedAccount(account.id)} + isCollapsed={isCollapsed} + sub + icon={} + /> + {accountExpanded && !isCollapsed && account.children.map((child) => ( + + ))} +
); })} -
+ )} - +
+ )} + + {emailKeywords.length > 0 && ( +
+ + {((tagsExpanded && !isCollapsed) || isCollapsed) && ( + <> + {emailKeywords.map((kw) => ( + + ))} + + )} +
)} {!isCollapsed && }
- -
); } diff --git a/components/settings/appearance-settings.tsx b/components/settings/appearance-settings.tsx index 02ad83bc..dbdf1308 100644 --- a/components/settings/appearance-settings.tsx +++ b/components/settings/appearance-settings.tsx @@ -10,6 +10,7 @@ import { useTour } from '@/components/tour/tour-provider'; import { Button } from '@/components/ui/button'; import { PlayCircle } from 'lucide-react'; import { usePolicyStore } from '@/stores/policy-store'; +import { useAccountStore } from '@/stores/account-store'; const DENSITY_PREVIEW: Record = { 'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false }, @@ -67,9 +68,10 @@ export function AppearanceSettings() { const t = useTranslations('settings.appearance'); const tTour = useTranslations('tour'); const { theme, setTheme } = useThemeStore(); - const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, updateSetting } = useSettingsStore(); + const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, updateSetting } = useSettingsStore(); const { startTour, resetTourCompletion } = useTour(); const { isSettingLocked, isSettingHidden } = usePolicyStore(); + const accounts = useAccountStore(s => s.accounts); return ( @@ -161,6 +163,27 @@ export function AppearanceSettings() { /> + {/* Colorful Sidebar Icons */} + + updateSetting('colorfulSidebarIcons', checked)} + /> + + + {/* Unified Mailbox */} + {accounts.length > 1 && ( + + updateSetting('enableUnifiedMailbox', v)} + /> + + )} + {/* Animations */} {!isSettingHidden('animationsEnabled') && ( diff --git a/components/settings/spam-siege-game.tsx b/components/settings/spam-siege-game.tsx index 0ee53fdc..9f17ecfb 100644 --- a/components/settings/spam-siege-game.tsx +++ b/components/settings/spam-siege-game.tsx @@ -1,17 +1,19 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; -import { Shield, Mail, X, AlertTriangle, Trophy, RotateCcw, Inbox, MailCheck } from "lucide-react"; +import { Shield, Mail, X, AlertTriangle, MailCheck, RotateCcw } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; const GAME_WIDTH = 400; const GAME_HEIGHT = 520; -const FORTRESS_Y = GAME_HEIGHT - 48; -const SPAWN_INTERVAL_START = 850; -const SPAWN_INTERVAL_MIN = 320; +const INBOX_Y = GAME_HEIGHT - 40; +const SPAWN_INTERVAL_START = 900; +const SPAWN_INTERVAL_MIN = 340; const GAME_DURATION = 30; const ENEMY_SPEED_START = 1.2; const ENEMY_SPEED_INCREASE = 0.04; +const MAX_MISSES = 3; interface Enemy { id: number; @@ -21,81 +23,85 @@ interface Enemy { type: "spam" | "phishing" | "legit"; } -type GameState = "idle" | "playing" | "won" | "lost"; +type GameState = "idle" | "playing" | "over"; export function SpamSiegeGame({ onClose }: { onClose: () => void }) { const [gameState, setGameState] = useState("idle"); const [enemies, setEnemies] = useState([]); const [score, setScore] = useState(0); const [timeLeft, setTimeLeft] = useState(GAME_DURATION); - const [shieldHealth, setShieldHealth] = useState(3); - const [hitEffects, setHitEffects] = useState<{ id: number; x: number; y: number; color: string }[]>([]); - const [destroyEffects, setDestroyEffects] = useState<{ id: number; x: number; y: number }[]>([]); - const [deliverEffects, setDeliverEffects] = useState<{ id: number; x: number; y: number }[]>([]); + const [misses, setMisses] = useState(0); + const [survived, setSurvived] = useState(false); const nextId = useRef(0); const animFrameRef = useRef(0); const lastTimeRef = useRef(0); const spawnTimerRef = useRef(0); const gameStateRef = useRef("idle"); const elapsedRef = useRef(0); - const destroyedRef = useRef(new Set()); + const clickedRef = useRef(new Set()); + const enemiesRef = useRef([]); + const missesRef = useRef(0); + const scoreRef = useRef(0); useEffect(() => { gameStateRef.current = gameState; }, [gameState]); + const endGame = useCallback((didSurvive: boolean) => { + setSurvived(didSurvive); + setGameState("over"); + }, []); + const startGame = useCallback(() => { setGameState("playing"); setEnemies([]); setScore(0); setTimeLeft(GAME_DURATION); - setShieldHealth(3); - setHitEffects([]); - setDestroyEffects([]); - setDeliverEffects([]); + setMisses(0); + setSurvived(false); nextId.current = 0; spawnTimerRef.current = 0; elapsedRef.current = 0; - destroyedRef.current = new Set(); + clickedRef.current = new Set(); + enemiesRef.current = []; + missesRef.current = 0; + scoreRef.current = 0; lastTimeRef.current = performance.now(); }, []); const spawnEnemy = useCallback(() => { const id = nextId.current++; const rand = Math.random(); - const type = rand > 0.7 ? "legit" : rand > 0.45 ? "phishing" : "spam"; + const type = rand > 0.75 ? "legit" : rand > 0.45 ? "phishing" : "spam"; const x = 20 + Math.random() * (GAME_WIDTH - 60); - const elapsed = elapsedRef.current; - const speed = ENEMY_SPEED_START + (elapsed / 1000) * ENEMY_SPEED_INCREASE; - setEnemies((prev) => [...prev, { id, x, y: -30, speed, type }]); + const speed = ENEMY_SPEED_START + (elapsedRef.current / 1000) * ENEMY_SPEED_INCREASE; + enemiesRef.current = [...enemiesRef.current, { id, x, y: -32, speed, type }]; + setEnemies(enemiesRef.current); }, []); - const handleHover = useCallback((enemy: Enemy) => { - if (destroyedRef.current.has(enemy.id)) return; - destroyedRef.current.add(enemy.id); + const handleClick = useCallback( + (ev: React.MouseEvent, enemy: Enemy) => { + ev.stopPropagation(); + if (clickedRef.current.has(enemy.id)) return; + clickedRef.current.add(enemy.id); - if (enemy.type === "legit") { - // Penalty for blocking legit mail - setShieldHealth((prev) => { - const nh = prev - 1; - if (nh <= 0) setGameState("lost"); - return Math.max(0, nh); - }); - setScore((prev) => Math.max(0, prev - 15)); - const effectId = nextId.current++; - setHitEffects((p) => [...p, { id: effectId, x: enemy.x, y: enemy.y, color: "rgba(34, 197, 94, 0.5)" }]); - setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500); - } else { - setScore((prev) => prev + 10); - const effectId = nextId.current++; - setDestroyEffects((prev) => [...prev, { id: effectId, x: enemy.x, y: enemy.y }]); - setTimeout(() => setDestroyEffects((prev) => prev.filter((e) => e.id !== effectId)), 400); - } + enemiesRef.current = enemiesRef.current.filter((e) => e.id !== enemy.id); + setEnemies(enemiesRef.current); - setEnemies((prev) => prev.filter((e) => e.id !== enemy.id)); - }, []); + if (enemy.type === "legit") { + missesRef.current += 1; + setMisses(missesRef.current); + scoreRef.current = Math.max(0, scoreRef.current - 15); + setScore(scoreRef.current); + if (missesRef.current >= MAX_MISSES) endGame(false); + } else { + scoreRef.current += enemy.type === "phishing" ? 15 : 10; + setScore(scoreRef.current); + } + }, + [endGame] + ); - // Game loop useEffect(() => { if (gameState !== "playing") return; @@ -106,15 +112,13 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) { lastTimeRef.current = now; elapsedRef.current += dt; - // Timer const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000); setTimeLeft(Math.max(0, newTimeLeft)); if (newTimeLeft <= 0) { - setGameState("won"); + endGame(true); return; } - // Spawn spawnTimerRef.current += dt; const spawnInterval = Math.max( SPAWN_INTERVAL_MIN, @@ -125,261 +129,187 @@ export function SpamSiegeGame({ onClose }: { onClose: () => void }) { spawnEnemy(); } - // Move enemies - setEnemies((prev) => { - const next: Enemy[] = []; - let spamBreached = false; - for (const e of prev) { - const ny = e.y + e.speed * (dt / 16); - if (ny >= FORTRESS_Y) { - if (e.type === "legit") { - // Legit mail delivered — bonus - setScore((s) => s + 5); - const effectId = nextId.current++; - setDeliverEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y }]); - setTimeout(() => setDeliverEffects((p) => p.filter((d) => d.id !== effectId)), 500); - } else { - spamBreached = true; - const effectId = nextId.current++; - setHitEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y, color: "rgba(219, 45, 84, 0.3)" }]); - setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500); - } - } else { - next.push({ ...e, y: ny }); - } + const nextEnemies: Enemy[] = []; + let missed = 0; + let scoreDelta = 0; + for (const e of enemiesRef.current) { + const ny = e.y + e.speed * (dt / 16); + if (ny >= INBOX_Y) { + if (e.type === "legit") scoreDelta += 5; + else missed++; + } else { + nextEnemies.push({ ...e, y: ny }); } - if (spamBreached) { - setShieldHealth((prev) => { - const nh = prev - 1; - if (nh <= 0) setGameState("lost"); - return Math.max(0, nh); - }); + } + enemiesRef.current = nextEnemies; + setEnemies(nextEnemies); + + if (scoreDelta > 0) { + scoreRef.current += scoreDelta; + setScore(scoreRef.current); + } + if (missed > 0) { + missesRef.current += missed; + setMisses(missesRef.current); + if (missesRef.current >= MAX_MISSES) { + endGame(false); + return; } - return next; - }); + } animFrameRef.current = requestAnimationFrame(tick); }; animFrameRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(animFrameRef.current); - }, [gameState, spawnEnemy]); - - const getEnemyStyle = (type: Enemy["type"]) => { - switch (type) { - case "phishing": - return { bg: "rgba(234, 179, 8, 0.15)", border: "rgba(234, 179, 8, 0.4)", color: "rgb(234, 179, 8)" }; - case "legit": - return { bg: "rgba(34, 197, 94, 0.12)", border: "rgba(34, 197, 94, 0.4)", color: "rgb(34, 197, 94)" }; - default: - return { bg: "rgba(219, 45, 84, 0.1)", border: "rgba(219, 45, 84, 0.3)", color: "rgb(219, 45, 84)" }; - } - }; + }, [gameState, spawnEnemy, endGame]); return ( -
-
+
e.stopPropagation()} > - {/* Header */} -
+
- - Spam Siege + + Spam Siege
-
- {/* HUD */} -
-
- Score: {score} - Time: {timeLeft}s -
-
- {[...Array(3)].map((_, i) => ( - - ))} +
+
+ + Score {score} + + + Time {timeLeft}s +
+ + Misses{" "} + = MAX_MISSES - 1 ? "text-destructive" : "text-foreground" + )} + > + {misses}/{MAX_MISSES} + +
- {/* Game area */}
- {/* Grid lines for depth */} -
- - {/* Fortress wall */} -
-
- {/* Shield centered above the line */} -
- 0 ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }} - fill={shieldHealth > 0 ? "rgba(219, 45, 84, 0.2)" : "none"} - /> -
- {/* Solid line */} -
0 ? "rgba(219, 45, 84, 0.35)" : "rgba(100, 100, 100, 0.3)" }} - /> -
- {/* Subtle gradient fill below */} -
0 - ? "linear-gradient(to bottom, rgba(219, 45, 84, 0.06), transparent)" - : "linear-gradient(to bottom, rgba(100, 100, 100, 0.04), transparent)", - }} - /> +
+
+ + Inbox + +
- {/* Enemies */} {enemies.map((e) => { - const style = getEnemyStyle(e.type); + const variant = + e.type === "phishing" + ? "text-warning border-warning/40 bg-warning/10 hover:bg-warning/20" + : e.type === "legit" + ? "text-success border-success/40 bg-success/10 hover:bg-success/20" + : "text-destructive border-destructive/40 bg-destructive/10 hover:bg-destructive/20"; + const Icon = + e.type === "phishing" ? AlertTriangle : e.type === "legit" ? MailCheck : Mail; return ( -
handleHover(e)} - > - {e.type === "phishing" ? ( - - ) : e.type === "legit" ? ( - - ) : ( - + type="button" + className={cn( + "absolute flex items-center justify-center w-8 h-8 rounded-md border cursor-pointer", + "active:scale-95 transition-transform", + variant )} -
+ style={{ left: e.x, top: e.y }} + onMouseEnter={(ev) => handleClick(ev, e)} + onClick={(ev) => handleClick(ev, e)} + > + + ); })} - {/* Destroy effects */} - {destroyEffects.map((e) => ( -
- -
- ))} - - {/* Deliver effects (legit mail arrived) */} - {deliverEffects.map((e) => ( -
- -
- ))} - - {/* Hit effects on fortress */} - {hitEffects.map((e) => ( -
-
-
- ))} - - {/* Idle overlay */} {gameState === "idle" && ( -
- -
-

Spam Siege

-

- Hover over threats to block them. Let legitimate mail through. Survive {GAME_DURATION} seconds. +

+ +
+

Spam Siege

+

+ Click spam and phishing before they hit your inbox. Don't block legitimate + mail. Three misses and it's over.

-
- - Spam - - - Phishing - - - Legit - -
-
)} - {/* Won overlay */} - {gameState === "won" && ( -
- -
-

Fortress Secured

-

- Score: {score} + {gameState === "over" && ( +

+ +
+

+ {survived ? "Inbox held" : "Inbox overrun"} +

+

+ Final score{" "} + {score}

-
+
-
)} - - {/* Lost overlay */} - {gameState === "lost" && ( -
- -
-

Fortress Breached

-

- Score: {score} -

-
-
- - -
-
- )}
diff --git a/components/ui/flag-icons.tsx b/components/ui/flag-icons.tsx new file mode 100644 index 00000000..236f8bd5 --- /dev/null +++ b/components/ui/flag-icons.tsx @@ -0,0 +1,184 @@ +import { type SVGProps, type ReactElement } from "react"; + +type FlagProps = SVGProps; + +const flagClass = "inline-block rounded-[2px] shrink-0"; +const W = 20; +const H = 15; + +/** Great Britain – Union Jack (simplified) */ +export function FlagGB(props: FlagProps) { + return ( + + + + + + + + ); +} + +/** France – Blue, White, Red vertical */ +export function FlagFR(props: FlagProps) { + return ( + + + + + + ); +} + +/** Japan – White with red circle */ +export function FlagJP(props: FlagProps) { + return ( + + + + + ); +} + +/** South Korea – Simplified */ +export function FlagKR(props: FlagProps) { + return ( + + + + + + + ); +} + +/** Spain – Red, Yellow, Red horizontal */ +export function FlagES(props: FlagProps) { + return ( + + + + + + ); +} + +/** Italy – Green, White, Red vertical */ +export function FlagIT(props: FlagProps) { + return ( + + + + + + ); +} + +/** Germany – Black, Red, Gold horizontal */ +export function FlagDE(props: FlagProps) { + return ( + + + + + + ); +} + +/** Latvia – Maroon, White, Maroon horizontal */ +export function FlagLV(props: FlagProps) { + return ( + + + + + + ); +} + +/** Netherlands – Red, White, Blue horizontal */ +export function FlagNL(props: FlagProps) { + return ( + + + + + + ); +} + +/** Poland – White, Red horizontal */ +export function FlagPL(props: FlagProps) { + return ( + + + + + ); +} + +/** Brazil – Green, yellow diamond (simplified) */ +export function FlagBR(props: FlagProps) { + return ( + + + + + + ); +} + +/** Russia – White, Blue, Red horizontal */ +export function FlagRU(props: FlagProps) { + return ( + + + + + + ); +} + +/** Ukraine – Blue, Yellow horizontal */ +export function FlagUA(props: FlagProps) { + return ( + + + + + ); +} + +/** China – Red with yellow stars (simplified) */ +export function FlagCN(props: FlagProps) { + return ( + + + + + + + + + + + ); +} + +/** Map locale codes to flag components */ +export const flagComponents: Record ReactElement> = { + en: FlagGB, + fr: FlagFR, + ja: FlagJP, + ko: FlagKR, + es: FlagES, + it: FlagIT, + de: FlagDE, + lv: FlagLV, + nl: FlagNL, + pl: FlagPL, + pt: FlagBR, + ru: FlagRU, + uk: FlagUA, + zh: FlagCN, +}; diff --git a/components/ui/language-switcher.tsx b/components/ui/language-switcher.tsx index 1049dddf..9078d6f4 100644 --- a/components/ui/language-switcher.tsx +++ b/components/ui/language-switcher.tsx @@ -1,37 +1,110 @@ "use client"; +import { useState, useRef, useEffect } from "react"; import { useLocale } from 'next-intl'; import { useLocaleStore } from '@/stores/locale-store'; -import { Select } from '@/components/settings/settings-section'; +import { ChevronDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { flagComponents } from './flag-icons'; + +const languages = [ + { value: 'en', label: 'English' }, + { value: 'fr', label: 'Français' }, + { value: 'ja', label: '日本語' }, + { value: 'ko', label: '한국어' }, + { value: 'es', label: 'Español' }, + { value: 'it', label: 'Italiano' }, + { value: 'de', label: 'Deutsch' }, + { value: 'lv', label: 'Latviešu' }, + { value: 'nl', label: 'Nederlands' }, + { value: 'pl', label: 'Polski' }, + { value: 'pt', label: 'Português' }, + { value: 'ru', label: 'Русский' }, + { value: 'uk', label: 'Українська' }, + { value: 'zh', label: '简体中文' }, +]; + +function FlagIcon({ locale }: { locale: string }) { + const Flag = flagComponents[locale]; + if (!Flag) return null; + return ; +} export function LanguageSwitcher({ className }: { className?: string }) { const currentLocale = useLocale(); const setLocale = useLocaleStore((state) => state.setLocale); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const listRef = useRef(null); - const languages = [ - { value: 'en', label: '🇬🇧 English' }, - { value: 'fr', label: '🇫🇷 Français' }, - { value: 'ja', label: '🇯🇵 日本語' }, - { value: 'ko', label: '🇰🇷 한국어' }, - { value: 'es', label: '🇪🇸 Español' }, - { value: 'it', label: '🇮🇹 Italiano' }, - { value: 'de', label: '🇩🇪 Deutsch' }, - { value: 'lv', label: '🇱🇻 Latviešu' }, - { value: 'nl', label: '🇳🇱 Nederlands' }, - { value: 'pl', label: '🇵🇱 Polski' }, - { value: 'pt', label: '🇧🇷 Português' }, - { value: 'ru', label: '🇷🇺 Русский' }, - { value: 'uk', label: '🇺🇦 Українська' }, - { value: 'zh', label: '🇨🇳 简体中文' } - ]; + const current = languages.find((l) => l.value === currentLocale) ?? languages[0]; + + // Close on outside click + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + // Close on Escape + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open]); return ( -
-