diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 00000000..77e9744d --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,5 @@ +{ + "setup-worktree": [ + "npm install" + ] +} diff --git a/client/package-lock.json b/client/package-lock.json index 36b7a2e0..6fef50ec 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -3679,13 +3679,13 @@ } }, "node_modules/framer-motion": { - "version": "12.23.24", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", - "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "version": "12.29.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.29.0.tgz", + "integrity": "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg==", "license": "MIT", "dependencies": { - "motion-dom": "^12.23.23", - "motion-utils": "^12.23.6", + "motion-dom": "^12.29.0", + "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { @@ -5002,18 +5002,18 @@ } }, "node_modules/motion-dom": { - "version": "12.23.23", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", - "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "version": "12.29.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.29.0.tgz", + "integrity": "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA==", "license": "MIT", "dependencies": { - "motion-utils": "^12.23.6" + "motion-utils": "^12.27.2" } }, "node_modules/motion-utils": { - "version": "12.23.6", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", - "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "version": "12.27.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.27.2.tgz", + "integrity": "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q==", "license": "MIT" }, "node_modules/ms": { diff --git a/client/public/explosions/samj_cartoon_explosion.gif b/client/public/explosions/samj_cartoon_explosion.gif new file mode 100644 index 00000000..534ce70f Binary files /dev/null and b/client/public/explosions/samj_cartoon_explosion.gif differ diff --git a/client/public/sfx/xplsion_0.mp3 b/client/public/sfx/xplsion_0.mp3 new file mode 100644 index 00000000..850bfd37 Binary files /dev/null and b/client/public/sfx/xplsion_0.mp3 differ diff --git a/client/public/sfx/xplsion_1.mp3 b/client/public/sfx/xplsion_1.mp3 new file mode 100644 index 00000000..40fce2a9 Binary files /dev/null and b/client/public/sfx/xplsion_1.mp3 differ diff --git a/client/public/sfx/xplsion_2.mp3 b/client/public/sfx/xplsion_2.mp3 new file mode 100644 index 00000000..1fc754d5 Binary files /dev/null and b/client/public/sfx/xplsion_2.mp3 differ diff --git a/client/public/sfx/xplsion_3.mp3 b/client/public/sfx/xplsion_3.mp3 new file mode 100644 index 00000000..689ca122 Binary files /dev/null and b/client/public/sfx/xplsion_3.mp3 differ diff --git a/client/src/components/main/Navbar.tsx b/client/src/components/main/Navbar.tsx index 5f87e431..d0d7946a 100644 --- a/client/src/components/main/Navbar.tsx +++ b/client/src/components/main/Navbar.tsx @@ -5,8 +5,11 @@ import Image from "next/image"; import Link from "next/link"; import { useState } from "react"; +import { useExplosionContext } from "@/contexts/ExplosionContext"; + export default function Navbar() { const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const { triggerExplosionAt } = useExplosionContext(); const navItems = [ { href: "/", label: "Home" }, @@ -16,11 +19,19 @@ export default function Navbar() { { href: "/artwork", label: "Art Showcase" }, ]; + const handleNavClick = (e: React.MouseEvent) => { + triggerExplosionAt(e.clientX, e.clientY); + }; + return ( <>
- + logo {item.label} @@ -67,7 +79,10 @@ export default function Navbar() { setIsDropdownOpen(false)} + onClick={(e) => { + handleNavClick(e); + setIsDropdownOpen(false); + }} className="block whitespace-nowrap px-4 py-3 text-lg transition-colors duration-150 hover:bg-accent" > {item.label} diff --git a/client/src/components/ui/Crater.tsx b/client/src/components/ui/Crater.tsx new file mode 100644 index 00000000..48d457fc --- /dev/null +++ b/client/src/components/ui/Crater.tsx @@ -0,0 +1,172 @@ +import React, { useMemo } from "react"; + +interface CraterProps { + size?: number; + intensity?: number; +} + +/** + * Generates an irregular crater polygon + */ +function generateCraterShape(baseRadius: number, points: number = 10): string { + const coords: string[] = []; + + for (let i = 0; i < points; i++) { + const angle = (i / points) * Math.PI * 2; + const radius = baseRadius * (0.75 + Math.random() * 0.3); + const x = 50 + Math.cos(angle) * radius; + const y = 50 + Math.sin(angle) * radius; + coords.push(`${x.toFixed(1)},${y.toFixed(1)}`); + } + + return coords.join(" "); +} + +/** + * Generates a crack as a polygon shape (actual gap in ground) + * Returns points for a wedge-shaped fissure + */ +function generateCrackFissure( + angle: number, + startRadius: number, + length: number, +): string { + // Crack is a tapered wedge shape - wide at crater, sharp point at end + const widthAtStart = 7 + Math.random() * 5; // Bit skinnier (7-12) + const widthAtEnd = 0.5 + Math.random() * 1; // Sharp point at tip (0.5-1.5) + + // Calculate perpendicular angle for width + const perpAngle = angle + Math.PI / 2; + + // Points along the crack with some jaggedness + const segments = 3; + const points: Array<{ x: number; y: number }> = []; + const pointsBack: Array<{ x: number; y: number }> = []; + + let currentAngle = angle; + + for (let i = 0; i <= segments; i++) { + const t = i / segments; + // Start from inside crater and extend outward + const radius = startRadius + length * t; + const width = widthAtStart + (widthAtEnd - widthAtStart) * t; + + // Add jaggedness to angle + if (i > 0 && i < segments) { + currentAngle += (Math.random() - 0.5) * 0.3; + } + + const centerX = 50 + Math.cos(currentAngle) * radius; + const centerY = 50 + Math.sin(currentAngle) * radius; + + // Offset perpendicular for width + const offsetX = (Math.cos(perpAngle) * width) / 2; + const offsetY = (Math.sin(perpAngle) * width) / 2; + + // Add random jaggedness to edges - less at the tip for sharp point + const jagAmount = 4 * (1 - t * 0.8); // More jagged at start, smooth at tip + const jag1 = (Math.random() - 0.5) * jagAmount; + const jag2 = (Math.random() - 0.5) * jagAmount; + + points.push({ + x: centerX + offsetX + jag1, + y: centerY + offsetY + jag1, + }); + pointsBack.unshift({ + x: centerX - offsetX + jag2, + y: centerY - offsetY + jag2, + }); + } + + // Combine into polygon + const allPoints = [...points, ...pointsBack]; + return allPoints.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" "); +} + +/** + * Crater with fissure-style cracks (actual gaps, not lines) + */ +export const Crater = React.memo(function Crater({ + size = 100, + intensity = 1, +}: CraterProps) { + const uniqueId = useMemo(() => Math.random().toString(36).substr(2, 9), []); + + // Crater shapes + const outerCrater = useMemo(() => generateCraterShape(22, 12), []); + const innerCrater = useMemo(() => generateCraterShape(14, 10), []); + const deepCrater = useMemo(() => generateCraterShape(8, 8), []); + + // Generate 2-4 crack fissures (reduced for performance) + const fissures = useMemo(() => { + const count = 2 + Math.floor(Math.random() * 3); + const result: Array<{ points: string }> = []; + + for (let i = 0; i < count; i++) { + const baseAngle = (i / count) * Math.PI * 2; + const angle = baseAngle + (Math.random() - 0.5) * 0.8; + const length = 20 + Math.random() * 18; + + result.push({ + points: generateCrackFissure(angle, 14, length), + }); + } + + return result; + }, []); + + // Colors + const voidColor = `rgba(0, 0, 0, ${intensity})`; + const deepColor = `rgba(8, 5, 2, ${0.95 * intensity})`; + const craterColor = `rgba(20, 15, 8, ${0.9 * intensity})`; + const rimColor = `rgba(45, 35, 25, ${0.7 * intensity})`; + + return ( + + + + + + + + + + + {/* Crack fissures - simplified to single polygon each */} + {fissures.map((fissure, i) => ( + + ))} + + {/* Outer crater rim */} + + + {/* Main crater */} + + + {/* Inner crater layer */} + + + {/* Deepest void */} + + + {/* Crater rim edge */} + + + ); +}); diff --git a/client/src/components/ui/DebrisBurst.tsx b/client/src/components/ui/DebrisBurst.tsx new file mode 100644 index 00000000..0210e880 --- /dev/null +++ b/client/src/components/ui/DebrisBurst.tsx @@ -0,0 +1,167 @@ +import React, { useCallback, useEffect, useMemo, useRef } from "react"; + +type Debris = { + id: number; + x: number; + y: number; + vx: number; + vy: number; + rot: number; + vr: number; + size: number; + life: number; + maxLife: number; +}; + +type Props = { + x: number; + y: number; + count?: number; + power?: number; + spreadDeg?: number; + gravity?: number; + groundY?: number; + bounce?: number; + onDone?: () => void; +}; + +// Use React.memo to prevent unnecessary re-renders +export const DebrisBurst = React.memo(function DebrisBurst({ + x, + y, + count = 8, // Reduced default count + power = 450, + spreadDeg = 360, + gravity = 1200, + groundY, + bounce = 0.3, + onDone, +}: Props) { + const containerRef = useRef(null); + const debrisRef = useRef([]); + const rafRef = useRef(null); + const lastT = useRef(0); + + // Generate initial debris - memoized + const initial = useMemo(() => { + const arr: Debris[] = []; + const spread = (spreadDeg * Math.PI) / 180; + + for (let i = 0; i < count; i++) { + const angle = + spreadDeg === 360 + ? Math.random() * Math.PI * 2 + : (Math.random() - 0.5) * spread; + + const speed = power * (0.5 + Math.random() * 0.5); + const vx = Math.cos(angle) * speed; + const vy = + -Math.abs(Math.sin(angle) * speed) * (0.7 + Math.random() * 0.3); + const size = 6 + Math.random() * 10; + const maxLife = 500 + Math.random() * 400; // Shorter lifetime + + arr.push({ + id: i, + x: 0, + y: 0, + vx, + vy, + rot: Math.random() * 360, + vr: (Math.random() - 0.5) * 600, + size, + life: maxLife, + maxLife, + }); + } + return arr; + }, [count, power, spreadDeg]); + + // Animation step - using refs to avoid re-renders + const step = useCallback( + (t: number) => { + const dt = Math.min(0.04, (t - lastT.current) / 1000); + lastT.current = t; + + const container = containerRef.current; + if (!container) return; + + const children = container.children; + let anyAlive = false; + + for (let i = 0; i < debrisRef.current.length; i++) { + const d = debrisRef.current[i]; + if (d.life <= 0) continue; + + d.life -= dt * 1000; + if (d.life <= 0) { + (children[i] as HTMLElement).style.display = "none"; + continue; + } + + anyAlive = true; + + // Physics + d.vx *= 1 - 0.2 * dt; + d.vy += gravity * dt; + d.x += d.vx * dt; + d.y += d.vy * dt; + d.rot += d.vr * dt; + + // Ground bounce + if (groundY !== undefined && y + d.y > groundY) { + d.y = groundY - y; + d.vy = -d.vy * bounce; + d.vx *= 0.7; + } + + // Update DOM directly (no React re-render) + const el = children[i] as HTMLElement; + const alpha = Math.max(0, d.life / d.maxLife); + el.style.transform = `translate3d(${d.x}px, ${d.y}px, 0) rotate(${d.rot}deg)`; + el.style.opacity = String(alpha); + } + + if (anyAlive) { + rafRef.current = requestAnimationFrame(step); + } else { + onDone?.(); + } + }, + [gravity, groundY, bounce, y, onDone], + ); + + // Set up animation + useEffect(() => { + debrisRef.current = initial.map((d) => ({ ...d })); + lastT.current = performance.now(); + rafRef.current = requestAnimationFrame(step); + + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + }; + }, [initial, step]); + + return ( +
+ {initial.map((d) => ( + + ))} +
+ ); +}); diff --git a/client/src/components/ui/Explosion.tsx b/client/src/components/ui/Explosion.tsx new file mode 100644 index 00000000..78679785 --- /dev/null +++ b/client/src/components/ui/Explosion.tsx @@ -0,0 +1,95 @@ +import Image from "next/image"; +import React, { useEffect, useRef, useState } from "react"; + +import { ExplosionPosition } from "../../hooks/useExplosions"; +import { Crater } from "./Crater"; +import { DebrisBurst } from "./DebrisBurst"; +import { Smoke } from "./Smoke"; + +interface ExplosionProps { + explosion: ExplosionPosition; +} + +/** + * Renders a single explosion at a specific position. + * Position is defined as a percentage of the parent container. + */ +export const Explosion = React.memo(function Explosion({ + explosion, +}: ExplosionProps) { + const containerRef = useRef(null); + const [debrisPosition, setDebrisPosition] = useState<{ + x: number; + y: number; + } | null>(null); + + // Convert percentage position to pixel coordinates for DebrisBurst + useEffect(() => { + if (!containerRef.current) return; + const container = containerRef.current.closest( + '[class*="relative"]', + ) as HTMLElement; + if (!container) { + // Fallback: use window if no relative container found + const x = (explosion.x / 100) * window.innerWidth; + const y = (explosion.y / 100) * window.innerHeight; + setDebrisPosition({ x, y }); + return; + } + + const rect = container.getBoundingClientRect(); + const x = rect.left + (explosion.x / 100) * rect.width; + const y = rect.top + (explosion.y / 100) * rect.height; + setDebrisPosition({ x, y }); + }, [explosion.x, explosion.y]); + + return ( +
+ {/* SVG Crater with depth shading */} +
+ +
+ {/* Physics-based debris burst */} + {debrisPosition && ( + + )} + {/* The actual explosion GIF */} +
+ Explosion +
+ {/* Rising smoke effect */} + +
+ ); +}); diff --git a/client/src/components/ui/Smoke.tsx b/client/src/components/ui/Smoke.tsx new file mode 100644 index 00000000..adc6241e --- /dev/null +++ b/client/src/components/ui/Smoke.tsx @@ -0,0 +1,179 @@ +import { motion } from "framer-motion"; +import React, { useMemo } from "react"; + +interface SmokeProps { + x: number; // percentage position + y: number; // percentage position + duration?: number; // how long smoke lasts (ms) +} + +/** + * Optimized SVG smoke effect + * Uses shared filters and reduced complexity for better performance + */ +export const Smoke = React.memo(function Smoke({ + x, + y, + duration = 2000, +}: SmokeProps) { + const uniqueId = useMemo(() => Math.random().toString(36).substr(2, 9), []); + + // Generate fewer smoke puffs (5 instead of 8) + const smokePuffs = useMemo(() => { + const puffs = []; + const count = 5; + + for (let i = 0; i < count; i++) { + const angle = (i / count) * Math.PI * 2 + (Math.random() - 0.5) * 0.3; + const distance = 45 + Math.random() * 35; + + puffs.push({ + id: i, + endX: Math.cos(angle) * distance, + endY: Math.sin(angle) * distance - 15, + delay: i * 0.03, + }); + } + return puffs; + }, []); + + return ( +
+ {/* Shared SVG defs - filters defined once */} + + + {/* Single optimized turbulence filter */} + + + + + + + {/* Shared gradient */} + + + + + + + + + {/* Central expanding smoke - uses CSS blur for performance */} + + + {/* Smoke puffs - share single filter */} + {smokePuffs.map((puff) => ( + + + + + + ))} + + {/* Single rising wisp - CSS blur only */} + +
+ ); +}); diff --git a/client/src/contexts/ExplosionContext.tsx b/client/src/contexts/ExplosionContext.tsx new file mode 100644 index 00000000..f2f6ca1d --- /dev/null +++ b/client/src/contexts/ExplosionContext.tsx @@ -0,0 +1,114 @@ +"use client"; + +import React, { + createContext, + useCallback, + useContext, + useRef, + useState, +} from "react"; + +import { DebrisBurst } from "@/components/ui/DebrisBurst"; +import { Explosion } from "@/components/ui/Explosion"; +import { ExplosionPosition, useExplosions } from "@/hooks/useExplosions"; + +// Max concurrent debris bursts to prevent lag +const MAX_DEBRIS = 5; + +type ClickDebris = { + id: number; + x: number; + y: number; +}; + +type ExplosionContextType = { + triggerExplosionAt: (clientX: number, clientY: number) => void; +}; + +const ExplosionContext = createContext(null); + +export function useExplosionContext() { + const context = useContext(ExplosionContext); + if (!context) { + throw new Error( + "useExplosionContext must be used within ExplosionProvider", + ); + } + return context; +} + +export function ExplosionProvider({ children }: { children: React.ReactNode }) { + const { explosions, triggerExplosions } = useExplosions(); + const [clickDebris, setClickDebris] = useState([]); + const lastClickTime = useRef(0); + const debrisTimeouts = useRef>>(new Set()); + + const triggerExplosionAt = useCallback( + (clientX: number, clientY: number) => { + // Throttle - 100ms minimum between explosions + const now = Date.now(); + if (now - lastClickTime.current < 100) return; + lastClickTime.current = now; + + // Convert to percentage of viewport + const x = (clientX / window.innerWidth) * 100; + const y = (clientY / window.innerHeight) * 100; + + // Trigger explosion + triggerExplosions({ + count: 1, + minDelay: 0, + maxDelay: 0, + duration: 1500, + playSound: true, + position: { x, y }, + }); + + // Add debris burst + const debrisId = now; + setClickDebris((prev) => { + const updated = [...prev, { id: debrisId, x: clientX, y: clientY }]; + return updated.slice(-MAX_DEBRIS); + }); + + // Cleanup after animation + const timeout = setTimeout(() => { + setClickDebris((prev) => prev.filter((d) => d.id !== debrisId)); + debrisTimeouts.current.delete(timeout); + }, 1500); + debrisTimeouts.current.add(timeout); + }, + [triggerExplosions], + ); + + return ( + + {children} + {/* Render explosions in a fixed overlay */} +
+ {explosions.map((explosion: ExplosionPosition) => ( + + ))} + {clickDebris.map((debris) => ( + + ))} +
+
+ ); +} diff --git a/client/src/hooks/useExplosions.ts b/client/src/hooks/useExplosions.ts new file mode 100644 index 00000000..ff582169 --- /dev/null +++ b/client/src/hooks/useExplosions.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * Plays a random explosion sound effect. + */ +function playExplosionSound(): void { + const soundIndex = Math.floor(Math.random() * 4); // 0-3 for xplsion_0 to xplsion_3 + const audio = new Audio(`/sfx/xplsion_${soundIndex}.mp3`); + audio.volume = 0.1; // Set volume to 10% to avoid being too loud + audio.play().catch(() => { + // Silently handle autoplay restrictions + }); +} + +/** + * Position of a single explosion within a container. + * Coordinates are percentages (0-100) relative to container size. + */ +export type ExplosionPosition = { + id: string; + x: number; // Percentage (0-100) + y: number; // Percentage (0-100) +}; + +/** + * Configuration for explosion spawning behavior. + */ +export type ExplosionConfig = { + count?: number; // Number of explosions to spawn (default: 1) + minDelay?: number; // Minimum delay between explosions in ms (default: 0) + maxDelay?: number; // Maximum delay between explosions in ms (default: 100) + duration?: number; // How long explosions stay visible in ms (default: 1000) + playSound?: boolean; // Whether to play sound effects (default: true) + position?: { x: number; y: number }; // Specific position (percentage 0-100), if not provided uses random +}; + +/** + * Custom hook to manage explosion spawning. + * Provides state and functions to trigger explosions. + */ +export function useExplosions() { + const [explosions, setExplosions] = useState([]); + const timeoutsRef = useRef>>(new Set()); + + // Cleanup all timeouts on unmount to prevent memory leaks + useEffect(() => { + const timeouts = timeoutsRef.current; + return () => { + timeouts.forEach((t) => clearTimeout(t)); + timeouts.clear(); + }; + }, []); + + const triggerExplosions = useCallback( + (config: ExplosionConfig = {}, withMargin?: boolean) => { + const { + count = 1, + minDelay = 0, + maxDelay = 100, + duration = 3000, + playSound = true, + position, // Optional fixed position + } = config; + + // Generate explosion positions + const now = Date.now(); + + for (let i = 0; i < count; i++) { + let x: number; + let y: number; + + if (position) { + // Use specific position provided + x = position.x; + y = position.y; + } else if (withMargin) { + // Random position with 10% margin from edges + const margin = 10; + x = margin + Math.random() * (100 - margin * 2); + y = margin + Math.random() * (100 - margin * 2); + } else { + // Random position across full area + x = Math.random() * 100; + y = Math.random() * 100; + } + + const delay = minDelay + Math.random() * (maxDelay - minDelay); + + const spawnTimeout = setTimeout(() => { + const explosionId = `${now}-${i}-${Math.random()}`; + const explosion: ExplosionPosition = { + id: explosionId, + x, + y, + }; + + setExplosions((prev) => [...prev, explosion]); + + // Play sound effect + if (playSound) { + playExplosionSound(); + } + + // Clean up after duration + const cleanupTimeout = setTimeout(() => { + setExplosions((prev) => + prev.filter((exp) => exp.id !== explosionId), + ); + timeoutsRef.current.delete(cleanupTimeout); + }, duration); + timeoutsRef.current.add(cleanupTimeout); + + timeoutsRef.current.delete(spawnTimeout); + }, delay); + timeoutsRef.current.add(spawnTimeout); + } + }, + [], + ); + + return { + explosions, + triggerExplosions, + }; +} diff --git a/client/src/pages/_app.tsx b/client/src/pages/_app.tsx index ca3770d2..aa09d857 100644 --- a/client/src/pages/_app.tsx +++ b/client/src/pages/_app.tsx @@ -7,6 +7,7 @@ import { Fira_Code, Inter as FontSans, Jersey_10 } from "next/font/google"; import Footer from "@/components/main/Footer"; import Navbar from "@/components/main/Navbar"; +import { ExplosionProvider } from "@/contexts/ExplosionContext"; const fontSans = FontSans({ subsets: ["latin"], @@ -32,13 +33,17 @@ export default function App({ Component, pageProps }: AppProps) { return ( -
font.variable).join(" ")} - > - - -
-
+ +
font.variable).join(" ") + } + > + + +
+
+
); } diff --git a/client/src/pages/about.tsx b/client/src/pages/about.tsx index c64544aa..3760147d 100644 --- a/client/src/pages/about.tsx +++ b/client/src/pages/about.tsx @@ -86,14 +86,14 @@ export default function AboutPage() { : "Failed to load Committee Members."; return ( - <> +
{about} -
+

{errorMessage}

-
- +
+ ); } else { for (let i = 0; i < 8; i++) { @@ -106,7 +106,7 @@ export default function AboutPage() { } return ( -
+
{about} {/* Portraits Section - DARK - Full Width */}
diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index b361a48b..b201ce31 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -1,17 +1,40 @@ +import { motion } from "framer-motion"; import Image from "next/image"; import Link from "next/link"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; import EventCarousel from "@/components/ui/eventCarousel"; import { EventHighlightCard, eventHighlightCardType, } from "@/components/ui/eventHighlightCard"; import LandingGames from "@/components/ui/landingGames"; +import { useExplosionContext } from "@/contexts/ExplosionContext"; import { UiEvent, useEvents } from "@/hooks/useEvents"; -import { Button } from "../components/ui/button"; - export default function Landing() { + const [isShaking, setIsShaking] = useState(false); + const { triggerExplosionAt } = useExplosionContext(); + + const handleBombClick = (e: React.MouseEvent) => { + // Trigger multiple explosions across the page + for (let i = 0; i < 10; i++) { + setTimeout(() => { + // Random position with 10% margin from edges + const x = window.innerWidth * (0.1 + Math.random() * 0.8); + const y = window.innerHeight * (0.1 + Math.random() * 0.8); + triggerExplosionAt(x, y); + }, i * 50); // Stagger by 50ms + } + + // Trigger screen shake + setIsShaking(true); + setTimeout(() => setIsShaking(false), 400); + + // Prevent event bubbling + e.stopPropagation(); + }; const { data, isPending, isError, isFetching } = useEvents({ type: "upcoming", pageSize: 100, @@ -69,7 +92,18 @@ export default function Landing() { ]; return ( -
+
@@ -102,8 +136,9 @@ export default function Landing() { src="/bomb.png" width={96} height={156} - alt="placeholder" - className="absolute bottom-0 left-0 h-auto w-[20%] -translate-x-1/4 -translate-y-4 [image-rendering:pixelated]" + alt="Bomb - click to explode!" + className="absolute bottom-0 left-0 h-auto w-[20%] -translate-x-1/4 -translate-y-4 cursor-pointer transition-transform [image-rendering:pixelated] hover:scale-110" + onClick={handleBombClick} />
@@ -193,6 +228,6 @@ export default function Landing() {
- + ); } diff --git a/client/src/pages/members/[id].tsx b/client/src/pages/members/[id].tsx index f3bf50fb..7b17438f 100644 --- a/client/src/pages/members/[id].tsx +++ b/client/src/pages/members/[id].tsx @@ -1,9 +1,8 @@ import { useRouter } from "next/router"; +import { MemberProfile } from "@/components/main/MemberProfile"; import { useMember } from "@/hooks/useMember"; -import { MemberProfile } from "../../components/main/MemberProfile"; - // hook assumes correct input, page sanitises to correct type function normaliseId(id: string | string[] | number | undefined) { if (typeof id === "number" && Number.isFinite(id)) { diff --git a/client/src/styles/globals.css b/client/src/styles/globals.css index e6445070..0e3434ea 100644 --- a/client/src/styles/globals.css +++ b/client/src/styles/globals.css @@ -58,3 +58,65 @@ @apply bg-background text-foreground; } } + +@keyframes crater-fade { + 0% { + opacity: 1; + } + 70% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +@keyframes crater-expand { + 0% { + transform: translate(-50%, -50%) scale(0.15); + opacity: 0; + } + 20% { + opacity: 1; + } + 100% { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } +} + +@keyframes crater-punch { + 0% { + transform: scale(0); + opacity: 0; + } + 50% { + transform: scale(1.1); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +@keyframes fissure-open { + 0% { + transform: scale(0); + opacity: 0; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.debris-chunk { + position: absolute; + left: 0; + top: 0; + transform-origin: center; + border-radius: 2px; + background: linear-gradient(135deg, #888 0%, #444 100%); + contain: layout style; +}