Merge branch 'dev'
This commit is contained in:
@@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store";
|
||||
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -84,6 +85,7 @@ interface EmailComposerProps {
|
||||
body?: string;
|
||||
htmlBody?: string;
|
||||
receivedAt?: string;
|
||||
accountId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,12 +256,28 @@ export function EmailComposer({
|
||||
|
||||
if (matchedIdentityId) {
|
||||
setSelectedIdentityId(matchedIdentityId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: match identity by the account's email when replying from unified view
|
||||
if (replyTo?.accountId) {
|
||||
const account = useAccountStore.getState().getAccountById(replyTo.accountId);
|
||||
if (account?.email) {
|
||||
const accountEmail = account.email.trim().toLowerCase();
|
||||
const accountIdentity = identities.find(
|
||||
(identity) => identity.email.trim().toLowerCase() === accountEmail
|
||||
);
|
||||
if (accountIdentity) {
|
||||
setSelectedIdentityId(accountIdentity.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
autoSelectReplyIdentity,
|
||||
identities,
|
||||
initialData?.selectedIdentityId,
|
||||
mode,
|
||||
replyTo?.accountId,
|
||||
replyTo?.bcc,
|
||||
replyTo?.cc,
|
||||
replyTo?.to,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -176,7 +176,7 @@ export function EmailList({
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await batchDelete(client);
|
||||
await batchDelete(client, isInTrash);
|
||||
} finally {
|
||||
setTimeout(() => setIsProcessing(false), 500);
|
||||
}
|
||||
|
||||
@@ -3074,13 +3074,13 @@ export function EmailViewer({
|
||||
<>
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.slice(0, 3).map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId);
|
||||
return kw ? <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot)} /> : null;
|
||||
const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' };
|
||||
return <span key={tagId} className={cn("w-3 h-3 rounded-full", KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500')} />;
|
||||
})}
|
||||
</span>
|
||||
{showToolbarLabels && currentColors.length === 1 && (
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{emailKeywords.find(k => k.id === currentColors[0])?.label}
|
||||
{emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -3678,11 +3678,11 @@ export function EmailViewer({
|
||||
{currentColors.length > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
{currentColors.map((tagId) => {
|
||||
const kw = emailKeywords.find(k => k.id === tagId);
|
||||
const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null;
|
||||
return dotClass ? (
|
||||
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw!.label} />
|
||||
) : 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 (
|
||||
<span key={tagId} className={cn("w-2.5 h-2.5 rounded-full flex-shrink-0", dotClass)} title={kw.label} />
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu
|
||||
import { useSettingsStore, KEYWORD_PALETTE } 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 { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
@@ -63,13 +64,16 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
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;
|
||||
@@ -184,6 +188,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{isUnifiedView && email.accountId && accountColor && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: accountColor }}
|
||||
title={email.accountLabel}
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
@@ -228,6 +239,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{isUnifiedView && email.accountId && accountColor && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: accountColor }}
|
||||
title={email.accountLabel}
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
@@ -345,7 +363,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : '';
|
||||
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
@@ -375,7 +395,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const emailKeywordDefs = useSettingsStore((state) => 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 ||
|
||||
@@ -548,6 +568,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: threadAccountColor }}
|
||||
title={latestEmail.accountLabel}
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
@@ -602,6 +629,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{isUnifiedView && latestEmail.accountId && threadAccountColor && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: threadAccountColor }}
|
||||
title={latestEmail.accountLabel}
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
hasUnread
|
||||
|
||||
@@ -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" && (
|
||||
<Input
|
||||
<select
|
||||
value={action.value || ""}
|
||||
onChange={(e) => 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")}
|
||||
>
|
||||
<option value="">{t("label_placeholder")}</option>
|
||||
{emailKeywords.map((kw) => (
|
||||
<option key={kw.id} value={kw.id}>{kw.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
+570
-359
File diff suppressed because it is too large
Load Diff
@@ -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<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
|
||||
'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 (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
@@ -161,6 +163,27 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Colorful Sidebar Icons */}
|
||||
<SettingItem label={t('colorful_sidebar_icons.label')} description={t('colorful_sidebar_icons.description')}>
|
||||
<ToggleSwitch
|
||||
checked={colorfulSidebarIcons}
|
||||
onChange={(checked) => updateSetting('colorfulSidebarIcons', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Unified Mailbox */}
|
||||
{accounts.length > 1 && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
description={t('unified_mailbox.description')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableUnifiedMailbox}
|
||||
onChange={(v) => updateSetting('enableUnifiedMailbox', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Animations */}
|
||||
{!isSettingHidden('animationsEnabled') && (
|
||||
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
|
||||
|
||||
@@ -23,8 +23,13 @@ import {
|
||||
Filter,
|
||||
RotateCcw,
|
||||
PalmtreeIcon,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
|
||||
function isReadonlyRule(r: FilterRule): boolean {
|
||||
return r.origin === "external" || r.origin === "opaque";
|
||||
}
|
||||
|
||||
function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||
const t = useTranslations("settings.filters");
|
||||
|
||||
@@ -429,90 +434,137 @@ export function FilterSettings() {
|
||||
|
||||
{!isOpaque && rules.length > 0 && (
|
||||
<div className="space-y-1" role="list" aria-label={t("rule_list")}>
|
||||
{rules.map((rule, index) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
role="listitem"
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
|
||||
dragOverIndex === index
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted/50"
|
||||
} ${!rule.enabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
{rules.map((rule, index) => {
|
||||
const readonly = isReadonlyRule(rule);
|
||||
|
||||
if (readonly) {
|
||||
const label = rule.originLabel || t("origin_external");
|
||||
const tooltip = t("managed_by_tooltip", { source: label });
|
||||
const hasStructuredSummary =
|
||||
rule.origin === "external" &&
|
||||
rule.conditions.length > 0 &&
|
||||
rule.actions.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={rule.id}
|
||||
role="listitem"
|
||||
className="flex items-start gap-3 p-3 rounded-md border border-border"
|
||||
title={tooltip}
|
||||
>
|
||||
<div className="pt-0.5 text-muted-foreground" aria-label={tooltip}>
|
||||
<Lock className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{rule.name}
|
||||
</p>
|
||||
<span className="inline-flex items-baseline px-1.5 py-px rounded-sm bg-muted/60 text-muted-foreground text-[10px]">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{hasStructuredSummary ? (
|
||||
expandedFilterView ? (
|
||||
<VisualRuleSummary rule={rule} />
|
||||
) : (
|
||||
<RuleSummary rule={rule} />
|
||||
)
|
||||
) : rule.rawBlock ? (
|
||||
<pre className="mt-1.5 text-xs font-mono whitespace-pre-wrap break-all text-muted-foreground bg-muted rounded p-2 max-h-32 overflow-y-auto">
|
||||
{rule.rawBlock.trim()}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
|
||||
aria-label={t("drag_to_reorder")}
|
||||
key={rule.id}
|
||||
role="listitem"
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
|
||||
dragOverIndex === index
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted/50"
|
||||
} ${!rule.enabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
|
||||
aria-label={t("drag_to_reorder")}
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="pt-0.5">
|
||||
<ToggleSwitch
|
||||
checked={rule.enabled}
|
||||
onChange={() => handleToggle(rule.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-0.5">
|
||||
<ToggleSwitch
|
||||
checked={rule.enabled}
|
||||
onChange={() => handleToggle(rule.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => {
|
||||
setEditingRule(rule);
|
||||
setShowRuleModal(true);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => {
|
||||
setEditingRule(rule);
|
||||
setShowRuleModal(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{rule.name}
|
||||
</p>
|
||||
{expandedFilterView ? (
|
||||
<VisualRuleSummary rule={rule} />
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setEditingRule(rule);
|
||||
setShowRuleModal(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{rule.name}
|
||||
</p>
|
||||
{expandedFilterView ? (
|
||||
<VisualRuleSummary rule={rule} />
|
||||
) : (
|
||||
<RuleSummary rule={rule} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId === rule.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(rule.id)}
|
||||
>
|
||||
{t("confirm_delete")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<RuleSummary rule={rule} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteConfirmId(rule.id)}
|
||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
aria-label={t("delete_rule")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId === rule.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(rule.id)}
|
||||
>
|
||||
{t("confirm_delete")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteConfirmId(rule.id)}
|
||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
aria-label={t("delete_rule")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
@@ -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<GameState>("idle");
|
||||
const [enemies, setEnemies] = useState<Enemy[]>([]);
|
||||
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<number>(0);
|
||||
const lastTimeRef = useRef<number>(0);
|
||||
const spawnTimerRef = useRef<number>(0);
|
||||
const gameStateRef = useRef<GameState>("idle");
|
||||
const elapsedRef = useRef(0);
|
||||
const destroyedRef = useRef(new Set<number>());
|
||||
const clickedRef = useRef(new Set<number>());
|
||||
const enemiesRef = useRef<Enemy[]>([]);
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="relative rounded-xl border border-border bg-card shadow-2xl overflow-hidden select-none"
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative rounded-lg border border-border bg-card shadow-xl overflow-hidden select-none"
|
||||
style={{ width: GAME_WIDTH, maxWidth: "95vw" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" style={{ color: "rgb(219, 45, 84)" }} />
|
||||
<span className="text-sm font-semibold text-foreground">Spam Siege</span>
|
||||
<Shield className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-medium text-foreground">Spam Siege</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* HUD */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border text-xs">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground">Score: <span className="font-semibold text-foreground">{score}</span></span>
|
||||
<span className="text-muted-foreground">Time: <span className="font-semibold text-foreground">{timeLeft}s</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Shield
|
||||
key={i}
|
||||
className="w-3.5 h-3.5 transition-colors"
|
||||
style={{ color: i < shieldHealth ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
|
||||
fill={i < shieldHealth ? "rgb(219, 45, 84)" : "none"}
|
||||
strokeWidth={i < shieldHealth ? 0 : 1.5}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/40 border-b border-border text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>
|
||||
Score <span className="font-medium text-foreground tabular-nums">{score}</span>
|
||||
</span>
|
||||
<span>
|
||||
Time <span className="font-medium text-foreground tabular-nums">{timeLeft}s</span>
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
Misses{" "}
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium tabular-nums",
|
||||
misses >= MAX_MISSES - 1 ? "text-destructive" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
{misses}/{MAX_MISSES}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Game area */}
|
||||
<div
|
||||
className="relative bg-background overflow-hidden"
|
||||
style={{ height: GAME_HEIGHT }}
|
||||
>
|
||||
{/* Grid lines for depth */}
|
||||
<div className="absolute inset-0 opacity-[0.03]" style={{
|
||||
backgroundImage: "linear-gradient(to bottom, currentColor 1px, transparent 1px), linear-gradient(to right, currentColor 1px, transparent 1px)",
|
||||
backgroundSize: "40px 40px",
|
||||
}} />
|
||||
|
||||
{/* Fortress wall */}
|
||||
<div className="absolute left-0 right-0 bottom-0 flex flex-col items-center" style={{ height: GAME_HEIGHT - FORTRESS_Y }}>
|
||||
<div className="relative w-full">
|
||||
{/* Shield centered above the line */}
|
||||
<div className="absolute -top-5 left-1/2 -translate-x-1/2 z-10">
|
||||
<Shield
|
||||
className="w-7 h-7 drop-shadow-sm"
|
||||
style={{ color: shieldHealth > 0 ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }}
|
||||
fill={shieldHealth > 0 ? "rgba(219, 45, 84, 0.2)" : "none"}
|
||||
/>
|
||||
</div>
|
||||
{/* Solid line */}
|
||||
<div
|
||||
className="h-[2px] w-full"
|
||||
style={{ backgroundColor: shieldHealth > 0 ? "rgba(219, 45, 84, 0.35)" : "rgba(100, 100, 100, 0.3)" }}
|
||||
/>
|
||||
</div>
|
||||
{/* Subtle gradient fill below */}
|
||||
<div
|
||||
className="flex-1 w-full"
|
||||
style={{
|
||||
background: shieldHealth > 0
|
||||
? "linear-gradient(to bottom, rgba(219, 45, 84, 0.06), transparent)"
|
||||
: "linear-gradient(to bottom, rgba(100, 100, 100, 0.04), transparent)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-center gap-2 px-4"
|
||||
style={{ top: INBOX_Y }}
|
||||
>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Inbox
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
{/* 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 (
|
||||
<div
|
||||
<button
|
||||
key={e.id}
|
||||
className="absolute flex items-center justify-center w-8 h-8 rounded-md transition-transform"
|
||||
style={{
|
||||
left: e.x,
|
||||
top: e.y,
|
||||
backgroundColor: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
}}
|
||||
onMouseEnter={() => handleHover(e)}
|
||||
>
|
||||
{e.type === "phishing" ? (
|
||||
<AlertTriangle className="w-4 h-4" style={{ color: style.color }} />
|
||||
) : e.type === "legit" ? (
|
||||
<MailCheck className="w-4 h-4" style={{ color: style.color }} />
|
||||
) : (
|
||||
<Mail className="w-4 h-4" style={{ color: style.color }} />
|
||||
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
|
||||
)}
|
||||
</div>
|
||||
style={{ left: e.x, top: e.y }}
|
||||
onMouseEnter={(ev) => handleClick(ev, e)}
|
||||
onClick={(ev) => handleClick(ev, e)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Destroy effects */}
|
||||
{destroyEffects.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="absolute pointer-events-none animate-ping"
|
||||
style={{ left: e.x + 4, top: e.y + 4 }}
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-foreground/50" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Deliver effects (legit mail arrived) */}
|
||||
{deliverEffects.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="absolute pointer-events-none animate-ping"
|
||||
style={{ left: e.x + 4, top: e.y - 8 }}
|
||||
>
|
||||
<Inbox className="w-5 h-5" style={{ color: "rgb(34, 197, 94)" }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Hit effects on fortress */}
|
||||
{hitEffects.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="absolute pointer-events-none"
|
||||
style={{ left: e.x, top: e.y - 10 }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full animate-ping" style={{ backgroundColor: e.color }} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Idle overlay */}
|
||||
{gameState === "idle" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
|
||||
<Shield className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} fill="rgba(219, 45, 84, 0.1)" />
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-foreground">Spam Siege</p>
|
||||
<p className="text-xs text-muted-foreground mt-1.5 max-w-[280px] leading-relaxed">
|
||||
Hover over threats to block them. Let legitimate mail through. Survive {GAME_DURATION} seconds.
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
|
||||
<Shield className="w-10 h-10 text-primary" />
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-base font-medium text-foreground">Spam Siege</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Click spam and phishing before they hit your inbox. Don't block legitimate
|
||||
mail. Three misses and it's over.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4 mt-3 text-[11px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Mail className="w-3 h-3" style={{ color: "rgb(219, 45, 84)" }} /> Spam
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" style={{ color: "rgb(234, 179, 8)" }} /> Phishing
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MailCheck className="w-3 h-3" style={{ color: "rgb(34, 197, 94)" }} /> Legit
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={startGame} className="mt-1 text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
|
||||
<Shield className="w-3.5 h-3.5 mr-1.5" />
|
||||
Defend
|
||||
<div className="flex items-center gap-4 text-[11px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Mail className="w-3 h-3 text-destructive" />
|
||||
Spam
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3 h-3 text-warning" />
|
||||
Phishing
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MailCheck className="w-3 h-3 text-success" />
|
||||
Legit
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" onClick={startGame}>
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Won overlay */}
|
||||
{gameState === "won" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
|
||||
<Trophy className="w-14 h-14" style={{ color: "rgb(219, 45, 84)" }} />
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-foreground">Fortress Secured</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Score: <span className="font-semibold text-foreground">{score}</span>
|
||||
{gameState === "over" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/95 px-8 text-center">
|
||||
<Shield
|
||||
className={cn(
|
||||
"w-10 h-10",
|
||||
survived ? "text-success" : "text-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{survived ? "Inbox held" : "Inbox overrun"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Final score{" "}
|
||||
<span className="font-medium text-foreground tabular-nums">{score}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
|
||||
<Button size="sm" onClick={startGame}>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||
Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lost overlay */}
|
||||
{gameState === "lost" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/80">
|
||||
<Shield className="w-14 h-14 text-muted-foreground/40" />
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-foreground">Fortress Breached</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Score: <span className="font-semibold text-foreground">{score}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onClick={startGame} className="text-white" style={{ backgroundColor: "rgb(219, 45, 84)" }}>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { type SVGProps, type ReactElement } from "react";
|
||||
|
||||
type FlagProps = SVGProps<SVGSVGElement>;
|
||||
|
||||
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 (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="60" height="30" fill="#012169" />
|
||||
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6" />
|
||||
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" strokeWidth="2" />
|
||||
<path d="M30,0 V30 M0,15 H60" stroke="#fff" strokeWidth="10" />
|
||||
<path d="M30,0 V30 M0,15 H60" stroke="#C8102E" strokeWidth="6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** France – Blue, White, Red vertical */
|
||||
export function FlagFR(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="1" height="2" fill="#002395" />
|
||||
<rect x="1" width="1" height="2" fill="#fff" />
|
||||
<rect x="2" width="1" height="2" fill="#ED2939" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Japan – White with red circle */
|
||||
export function FlagJP(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="3" height="2" fill="#fff" />
|
||||
<circle cx="1.5" cy="1" r="0.6" fill="#BC002D" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** South Korea – Simplified */
|
||||
export function FlagKR(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="3" height="2" fill="#fff" />
|
||||
<circle cx="1.5" cy="1" r="0.55" fill="#CD2E3A" />
|
||||
<path d="M1.5,1 a0.275,0.275 0 0,1 0,0.55 a0.275,0.275 0 0,0 0,-0.55" fill="#0047A0" />
|
||||
<path d="M1.5,1 a0.275,0.275 0 0,0 0,-0.55 a0.275,0.275 0 0,1 0,0.55" fill="#0047A0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Spain – Red, Yellow, Red horizontal */
|
||||
export function FlagES(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="3" height="0.5" fill="#AA151B" />
|
||||
<rect y="0.5" width="3" height="1" fill="#F1BF00" />
|
||||
<rect y="1.5" width="3" height="0.5" fill="#AA151B" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Italy – Green, White, Red vertical */
|
||||
export function FlagIT(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="1" height="2" fill="#009246" />
|
||||
<rect x="1" width="1" height="2" fill="#fff" />
|
||||
<rect x="2" width="1" height="2" fill="#CE2B37" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Germany – Black, Red, Gold horizontal */
|
||||
export function FlagDE(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 3" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="5" height="1" fill="#000" />
|
||||
<rect y="1" width="5" height="1" fill="#DD0000" />
|
||||
<rect y="2" width="5" height="1" fill="#FFCC00" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Latvia – Maroon, White, Maroon horizontal */
|
||||
export function FlagLV(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="20" height="4" fill="#9E3039" />
|
||||
<rect y="4" width="20" height="2" fill="#fff" />
|
||||
<rect y="6" width="20" height="4" fill="#9E3039" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Netherlands – Red, White, Blue horizontal */
|
||||
export function FlagNL(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 6" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="9" height="2" fill="#AE1C28" />
|
||||
<rect y="2" width="9" height="2" fill="#fff" />
|
||||
<rect y="4" width="9" height="2" fill="#21468B" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Poland – White, Red horizontal */
|
||||
export function FlagPL(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 5" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="8" height="2.5" fill="#fff" />
|
||||
<rect y="2.5" width="8" height="2.5" fill="#DC143C" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Brazil – Green, yellow diamond (simplified) */
|
||||
export function FlagBR(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 14" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="20" height="14" fill="#009B3A" />
|
||||
<polygon points="10,1.5 18.5,7 10,12.5 1.5,7" fill="#FEDF00" />
|
||||
<circle cx="10" cy="7" r="3" fill="#002776" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Russia – White, Blue, Red horizontal */
|
||||
export function FlagRU(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 6" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="9" height="2" fill="#fff" />
|
||||
<rect y="2" width="9" height="2" fill="#0039A6" />
|
||||
<rect y="4" width="9" height="2" fill="#D52B1E" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ukraine – Blue, Yellow horizontal */
|
||||
export function FlagUA(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="3" height="1" fill="#005BBB" />
|
||||
<rect y="1" width="3" height="1" fill="#FFD500" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** China – Red with yellow stars (simplified) */
|
||||
export function FlagCN(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="30" height="20" fill="#DE2910" />
|
||||
<g fill="#FFDE00">
|
||||
<polygon points="5,2 6,5 3.2,3.2 6.8,3.2 4,5" />
|
||||
<polygon points="10,1 10.6,2.7 9,1.8 11,1.8 9.4,2.7" />
|
||||
<polygon points="12,3 12.6,4.7 11,3.8 13,3.8 11.4,4.7" />
|
||||
<polygon points="12,6 12.6,7.7 11,6.8 13,6.8 11.4,7.7" />
|
||||
<polygon points="10,8 10.6,9.7 9,8.8 11,8.8 9.4,9.7" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map locale codes to flag components */
|
||||
export const flagComponents: Record<string, (props: FlagProps) => 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,
|
||||
};
|
||||
@@ -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 <Flag />;
|
||||
}
|
||||
|
||||
export function LanguageSwitcher({ className }: { className?: string }) {
|
||||
const currentLocale = useLocale();
|
||||
const setLocale = useLocaleStore((state) => state.setLocale);
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(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 (
|
||||
<div className={className}>
|
||||
<Select
|
||||
value={currentLocale}
|
||||
onChange={setLocale}
|
||||
options={languages}
|
||||
/>
|
||||
<div ref={containerRef} className={cn("relative", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground hover:border-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer w-full"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<FlagIcon locale={current.value} />
|
||||
<span className="flex-1 text-left">{current.label}</span>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform duration-150", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-activedescendant={`lang-${currentLocale}`}
|
||||
className="absolute z-50 mt-1 w-full max-h-60 overflow-auto rounded-md border border-border bg-background shadow-lg py-1"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<li
|
||||
key={lang.value}
|
||||
id={`lang-${lang.value}`}
|
||||
role="option"
|
||||
aria-selected={lang.value === currentLocale}
|
||||
onClick={() => {
|
||||
setLocale(lang.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer transition-colors duration-100",
|
||||
lang.value === currentLocale
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<FlagIcon locale={lang.value} />
|
||||
<span>{lang.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user