feat: implement version badge and easter egg in settings
This commit is contained in:
@@ -11,12 +11,13 @@ import { useThemeStore } from "@/stores/theme-store";
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useConfig } from "@/hooks/use-config";
|
import { useConfig } from "@/hooks/use-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react";
|
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||||
|
|
||||||
const APP_VERSION = "1.4.11";
|
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||||
|
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
const THEME_OPTIONS = [
|
||||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||||
@@ -24,6 +25,41 @@ const THEME_OPTIONS = [
|
|||||||
{ value: "system" as const, icon: Monitor, label: "System" },
|
{ value: "system" as const, icon: Monitor, label: "System" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function VersionBadge() {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`;
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(versionInfo).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex justify-center">
|
||||||
|
<p className="peer text-center text-xs text-muted-foreground/40 cursor-default">
|
||||||
|
v{APP_VERSION}
|
||||||
|
</p>
|
||||||
|
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
|
||||||
|
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors"
|
||||||
|
aria-label="Copy version info"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
@@ -579,9 +615,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-center text-xs text-muted-foreground/40">
|
<VersionBadge />
|
||||||
v{APP_VERSION}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1075,9 +1109,7 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-center text-xs text-muted-foreground/40">
|
<VersionBadge />
|
||||||
v{APP_VERSION}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
|
import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store';
|
||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
import { SpamSiegeGame } from './spam-siege-game';
|
||||||
|
|
||||||
|
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||||
|
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||||
|
|
||||||
export function AdvancedSettings() {
|
export function AdvancedSettings() {
|
||||||
const t = useTranslations('settings.advanced');
|
const t = useTranslations('settings.advanced');
|
||||||
@@ -18,6 +23,20 @@ export function AdvancedSettings() {
|
|||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||||
|
const [showGame, setShowGame] = useState(false);
|
||||||
|
const logoClickCount = useRef(0);
|
||||||
|
const logoClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const handleLogoClick = () => {
|
||||||
|
logoClickCount.current++;
|
||||||
|
if (logoClickTimer.current) clearTimeout(logoClickTimer.current);
|
||||||
|
if (logoClickCount.current >= 3) {
|
||||||
|
logoClickCount.current = 0;
|
||||||
|
setShowGame(true);
|
||||||
|
} else {
|
||||||
|
logoClickTimer.current = setTimeout(() => { logoClickCount.current = 0; }, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
const settingsJson = exportSettings();
|
const settingsJson = exportSettings();
|
||||||
@@ -65,6 +84,44 @@ export function AdvancedSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
|
||||||
|
{/* About */}
|
||||||
|
<div className="rounded-lg border border-border bg-card p-5 mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<img
|
||||||
|
src="/branding/Bulwark_Logo_Color.svg"
|
||||||
|
alt="Bulwark"
|
||||||
|
className="w-12 h-12 object-contain dark:hidden group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="/branding/Bulwark_Logo_White.svg"
|
||||||
|
alt="Bulwark"
|
||||||
|
className="w-12 h-12 object-contain hidden dark:block group-hover/about:scale-105 group-active/about:scale-95 transition-transform"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{t('about.title')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground group-hover/about:translate-x-0.5 group-active/about:translate-y-px transition-transform">
|
||||||
|
v{APP_VERSION} <span className="text-muted-foreground/60">({GIT_COMMIT})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="https://github.com/stalwartlabs/webmail"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
GitHub <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SettingsSection title={t('title')} description={t('description')}>
|
<SettingsSection title={t('title')} description={t('description')}>
|
||||||
{/* Debug Mode */}
|
{/* Debug Mode */}
|
||||||
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
|
{!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && (
|
||||||
@@ -147,5 +204,6 @@ export function AdvancedSettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { Shield, Mail, X, AlertTriangle, Trophy, RotateCcw, Inbox, MailCheck } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
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 GAME_DURATION = 30;
|
||||||
|
const ENEMY_SPEED_START = 1.2;
|
||||||
|
const ENEMY_SPEED_INCREASE = 0.04;
|
||||||
|
|
||||||
|
interface Enemy {
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
speed: number;
|
||||||
|
type: "spam" | "phishing" | "legit";
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameState = "idle" | "playing" | "won" | "lost";
|
||||||
|
|
||||||
|
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 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>());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
gameStateRef.current = gameState;
|
||||||
|
}, [gameState]);
|
||||||
|
|
||||||
|
const startGame = useCallback(() => {
|
||||||
|
setGameState("playing");
|
||||||
|
setEnemies([]);
|
||||||
|
setScore(0);
|
||||||
|
setTimeLeft(GAME_DURATION);
|
||||||
|
setShieldHealth(3);
|
||||||
|
setHitEffects([]);
|
||||||
|
setDestroyEffects([]);
|
||||||
|
setDeliverEffects([]);
|
||||||
|
nextId.current = 0;
|
||||||
|
spawnTimerRef.current = 0;
|
||||||
|
elapsedRef.current = 0;
|
||||||
|
destroyedRef.current = new Set();
|
||||||
|
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 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 handleHover = useCallback((enemy: Enemy) => {
|
||||||
|
if (destroyedRef.current.has(enemy.id)) return;
|
||||||
|
destroyedRef.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnemies((prev) => prev.filter((e) => e.id !== enemy.id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Game loop
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameState !== "playing") return;
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
if (gameStateRef.current !== "playing") return;
|
||||||
|
|
||||||
|
const dt = now - lastTimeRef.current;
|
||||||
|
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");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn
|
||||||
|
spawnTimerRef.current += dt;
|
||||||
|
const spawnInterval = Math.max(
|
||||||
|
SPAWN_INTERVAL_MIN,
|
||||||
|
SPAWN_INTERVAL_START - (elapsedRef.current / 1000) * 35
|
||||||
|
);
|
||||||
|
if (spawnTimerRef.current >= spawnInterval) {
|
||||||
|
spawnTimerRef.current = 0;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (spamBreached) {
|
||||||
|
setShieldHealth((prev) => {
|
||||||
|
const nh = prev - 1;
|
||||||
|
if (nh <= 0) setGameState("lost");
|
||||||
|
return Math.max(0, nh);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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)" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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"
|
||||||
|
style={{ width: GAME_WIDTH, maxWidth: "95vw" }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Enemies */}
|
||||||
|
{enemies.map((e) => {
|
||||||
|
const style = getEnemyStyle(e.type);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 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.
|
||||||
|
</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
|
||||||
|
</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>
|
||||||
|
</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" />
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1210,6 +1210,9 @@
|
|||||||
"label": "Import Settings",
|
"label": "Import Settings",
|
||||||
"description": "Upload settings from JSON file",
|
"description": "Upload settings from JSON file",
|
||||||
"button": "Import"
|
"button": "Import"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Bulwark Webmail"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar_apps": {
|
"sidebar_apps": {
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
let gitCommitHash = "unknown";
|
||||||
|
try {
|
||||||
|
gitCommitHash = execSync("git rev-parse --short HEAD").toString().trim();
|
||||||
|
} catch {
|
||||||
|
// git not available
|
||||||
|
}
|
||||||
|
|
||||||
|
let appVersion = "0.0.0";
|
||||||
|
try {
|
||||||
|
appVersion = readFileSync(join(import.meta.dirname, "VERSION"), "utf-8").trim();
|
||||||
|
} catch {
|
||||||
|
// VERSION file not found
|
||||||
|
}
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
@@ -7,6 +24,10 @@ const nextConfig: NextConfig = {
|
|||||||
turbopack: {
|
turbopack: {
|
||||||
root: import.meta.dirname,
|
root: import.meta.dirname,
|
||||||
},
|
},
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_GIT_COMMIT: gitCommitHash,
|
||||||
|
NEXT_PUBLIC_APP_VERSION: appVersion,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin();
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|||||||
Reference in New Issue
Block a user