From f22699fe2035e7758b7c8f64cd468d1f86e77465 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:36:13 +0200 Subject: [PATCH] feat: add unified mailbox across accounts and sidebar icons toggle --- app/[locale]/page.tsx | 133 ++- components/email/email-composer.tsx | 18 + components/email/thread-list-item.tsx | 36 +- components/layout/sidebar.tsx | 929 ++++++++++++-------- components/settings/appearance-settings.tsx | 25 +- components/settings/spam-siege-game.tsx | 426 ++++----- components/ui/flag-icons.tsx | 184 ++++ components/ui/language-switcher.tsx | 119 ++- lib/jmap/types.ts | 30 + lib/unified-mailbox.ts | 170 ++++ lib/utils.ts | 36 +- locales/de/common.json | 14 + locales/en/common.json | 19 + locales/es/common.json | 14 + locales/fr/common.json | 14 + locales/it/common.json | 14 + locales/ja/common.json | 14 + locales/ko/common.json | 14 + locales/lv/common.json | 14 + locales/nl/common.json | 14 + locales/pl/common.json | 14 + locales/pt/common.json | 14 + locales/ru/common.json | 14 + locales/uk/common.json | 14 + locales/zh/common.json | 14 + stores/auth-store.ts | 5 + stores/email-store.ts | 212 ++++- stores/settings-store.ts | 14 + 28 files changed, 1889 insertions(+), 649 deletions(-) create mode 100644 components/ui/flag-icons.tsx create mode 100644 lib/unified-mailbox.ts diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 2263d185..34e61cad 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -9,7 +9,9 @@ import { EmailComposer } from "@/components/email/email-composer"; import type { ComposerDraftData } from "@/components/email/email-composer"; import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header"; -import { ThreadGroup, Email } from "@/lib/jmap/types"; +import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types"; +import { useAccountStore } from "@/stores/account-store"; +import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { useEmailStore } from "@/stores/email-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; @@ -155,8 +157,54 @@ export default function Home() { hasMoreEmails, fetchTagCounts, fetchEmailContent, + isUnifiedView, + fetchUnifiedEmails: fetchUnifiedEmailsAction, + refreshUnifiedCounts, + exitUnifiedView, } = useEmailStore(); + const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); + const accounts = useAccountStore((s) => s.accounts); + const connectedAccountsSignature = useMemo( + () => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","), + [accounts], + ); + + const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => { + const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected); + const clients = useAuthStore.getState().getAllConnectedClients(); + const result: UnifiedAccountClient[] = []; + for (const account of connected) { + const accountClient = clients.get(account.id); + if (!accountClient) continue; + result.push({ + accountId: account.id, + accountLabel: account.label || account.email, + client: accountClient, + mailboxes: [], + }); + } + return result; + }, []); + + const populateUnifiedAccountMailboxes = useCallback( + async (list: UnifiedAccountClient[]): Promise => { + const populated = await Promise.all( + list.map(async (entry) => { + try { + const mailboxes = await entry.client.getMailboxes(); + return { ...entry, mailboxes }; + } catch (err) { + debug.error('Failed to load mailboxes for unified account', entry.accountId, err); + return entry; + } + }), + ); + return populated; + }, + [], + ); + // Browser back / forward integration. The restore handler reads the // latest values from a ref so we don't have to recreate the callback on // every render (and so the popstate listener is never stale). @@ -485,13 +533,30 @@ export default function Home() { }; }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]); + // Keep unified mailbox counts in sync when the feature is enabled and more + // than one account is connected. Runs whenever the set of connected accounts + // or the primary account's mailboxes change (a proxy for "something worth + // recounting happened"). + useEffect(() => { + if (!enableUnifiedMailbox || !isAuthenticated || !client) return; + const built = buildUnifiedAccounts(); + if (built.length < 2) return; + populateUnifiedAccountMailboxes(built).then((populated) => { + refreshUnifiedCounts(populated); + }); + }, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]); + // Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive) useEffect(() => { if (!selectedEmail || !client) return; // If the email lacks bodyValues, it was auto-selected from the list and needs full content if (!selectedEmail.bodyValues) { + const perAccountClient = isUnifiedView && selectedEmail.accountId + ? useAuthStore.getState().getClientForAccount(selectedEmail.accountId) + : undefined; + const fetchClient = perAccountClient ?? client; setLoadingEmail(true); - fetchEmailContent(client, selectedEmail.id).finally(() => { + fetchEmailContent(fetchClient, selectedEmail.id).finally(() => { setLoadingEmail(false); }); } @@ -886,6 +951,32 @@ export default function Home() { }; const handleMailboxSelect = async (mailboxId: string) => { + if (isUnifiedMailboxId(mailboxId)) { + const role = UNIFIED_ROLE_BY_ID[mailboxId]; + if (!role) return; + + selectMailbox(mailboxId); + selectEmail(null); + + if (isMobile) { + setSidebarOpen(false); + setActiveView("list"); + } + if (isTablet) { + setTabletListVisible(true); + } + + const built = buildUnifiedAccounts(); + const populated = await populateUnifiedAccountMailboxes(built); + await fetchUnifiedEmailsAction(populated, role); + refreshUnifiedCounts(populated); + return; + } + + if (isUnifiedView) { + exitUnifiedView(); + } + selectMailbox(mailboxId); selectEmail(null); // Clear selected email when switching mailboxes @@ -969,6 +1060,7 @@ export default function Home() { const handleSearch = async (query: string) => { if (!client) return; + if (isUnifiedView) return; setSearchQuery(query); if (!isFilterEmpty(searchFilters)) { await advancedSearch(client); @@ -987,6 +1079,7 @@ export default function Home() { const handleAdvancedSearch = async () => { if (!client) return; + if (isUnifiedView) return; await advancedSearch(client); }; @@ -996,9 +1089,9 @@ export default function Home() { clearTimeout(advancedSearchDebounceRef.current); } advancedSearchDebounceRef.current = setTimeout(() => { - if (client) advancedSearch(client); + if (client && !isUnifiedView) advancedSearch(client); }, 300); - }, [client, advancedSearch]); + }, [client, advancedSearch, isUnifiedView]); useEffect(() => { return () => { @@ -1127,13 +1220,29 @@ export default function Home() { // Fetch the full content try { - // Find selected mailbox to determine accountId (for shared folders) - const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); - // Only pass accountId for shared mailboxes - const accountId = mailbox?.isShared ? mailbox.accountId : undefined; + // In unified view each email carries its own accountId. Use that + // account's client so we fetch from the server that actually owns it. + const listEmail = emails.find(e => e.id === email.id); + const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined; + const perAccountClient = emailAccountId + ? useAuthStore.getState().getClientForAccount(emailAccountId) + : undefined; + const fetchClient = perAccountClient ?? client; - const fullEmail = await client.getEmail(email.id, accountId); + // For shared folders on the primary client, we still need to pass the + // shared account's id. In unified view we use the per-account client + // directly, so no explicit accountId is needed. + const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); + const accountId = perAccountClient + ? undefined + : mailbox?.isShared ? mailbox.accountId : undefined; + + const fullEmail = await fetchClient.getEmail(email.id, accountId); if (fullEmail) { + if (emailAccountId) { + fullEmail.accountId = emailAccountId; + fullEmail.accountLabel = listEmail?.accountLabel; + } selectEmail(fullEmail); // Mark-as-read logic is now handled by useEffect } @@ -1397,6 +1506,8 @@ export default function Home() { className={cn("pl-9 h-9", searchQuery && "pr-8")} data-search-input data-tour="search-input" + disabled={isUnifiedView} + title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined} /> {searchQuery && ( + ) : ( +
+ )} +
+ )} + + + + ); +} + +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 ( -
-