feat: confetti, winner popup, bracket view

This commit is contained in:
2026-03-30 16:53:13 -04:00
Unverified
parent 77724ba260
commit e4fa58f327
13 changed files with 640 additions and 217 deletions

View File

@@ -37,11 +37,12 @@ export default function Board({
: "border-gray-700 bg-gray-900 text-gray-400"
}`}
>
<div className="w-3.5 h-3.5 rounded-full bg-red-500 shrink-0" />
<span className="font-medium">{player1}</span>
{currentTurnColor === 1 && (
<span className="text-xs text-red-400 animate-pulse"> Turn</span>
{currentTurnColor === 1 ? (
<div className="w-3.5 h-3.5 rounded-full bg-red-500 shrink-0 animate-pulse" />
) : (
<div className="w-3.5 h-3.5 rounded-full bg-red-500 shrink-0" />
)}
<span className="font-medium">{player1}</span>
</div>
<span className="text-gray-600 font-bold">vs</span>
<div
@@ -51,13 +52,12 @@ export default function Board({
: "border-gray-700 bg-gray-900 text-gray-400"
}`}
>
<div className="w-3.5 h-3.5 rounded-full bg-yellow-400 shrink-0" />
<span className="font-medium">{player2}</span>
{currentTurnColor === 2 && (
<span className="text-xs text-yellow-400 animate-pulse">
Turn
</span>
{currentTurnColor === 2 ? (
<div className="w-3.5 h-3.5 rounded-full bg-yellow-400 shrink-0 animate-pulse" />
) : (
<div className="w-3.5 h-3.5 rounded-full bg-yellow-400 shrink-0" />
)}
<span className="font-medium">{player2}</span>
</div>
</div>
)}
@@ -128,9 +128,9 @@ export default function Board({
{Array.from({ length: 7 }, (_, col) => (
<div
key={col}
className="w-12 p-1 text-center text-xs text-blue-400/70 font-mono"
className="w-14 p-1 text-center text-xs text-blue-400/70 font-mono"
>
{col}
{col + 1}
</div>
))}
</div>

View File

@@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
import Confetti from "react-confetti";
import { useConnection } from "@/lib/connection";
export default function Celebration() {
const { subscribe } = useConnection();
const [winner, setWinner] = useState<string | null>(null);
const [viewport, setViewport] = useState({ width: 0, height: 0 });
useEffect(() => {
const updateViewport = () => {
setViewport({
width: window.innerWidth,
height: window.innerHeight,
});
};
updateViewport();
window.addEventListener("resize", updateViewport);
return () => window.removeEventListener("resize", updateViewport);
}, []);
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const unsubscribe = subscribe((msg) => {
if (msg.type !== "TOURNAMENT_WINNER") return;
setWinner(msg.username || "Unknown player");
});
return () => {
unsubscribe();
};
}, [subscribe]);
if (!winner) return null;
return (
<>
<Confetti
width={viewport.width}
height={viewport.height}
recycle={false}
numberOfPieces={600}
gravity={0.2}
className="pointer-events-none fixed! inset-0! z-90!"
/>
<div className="pointer-events-none fixed inset-0 z-100 flex items-center justify-center px-4">
<div className="pointer-events-auto w-full max-w-xl rounded-3xl border border-amber-300/40 bg-linear-to-br from-amber-300 via-yellow-200 to-orange-300 p-1px shadow-2xl shadow-amber-950/60">
<div className="rounded-[calc(1.5rem-1px)] bg-slate-950/95 px-8 py-10 text-center backdrop-blur">
<div className="text-5xl">🏆</div>
<p className="mt-4 text-sm font-semibold uppercase tracking-[0.35em] text-amber-200/80">
Tournament Winner
</p>
<h2 className="mt-3 text-4xl font-black tracking-tight text-white sm:text-5xl">
{winner}
</h2>
<p className="mt-4 text-base text-amber-100/85">
Dominated the bracket and closed out the tournament.
</p>
<button
type="button"
onClick={() => setWinner(null)}
className="mt-7 inline-flex items-center justify-center rounded-full border border-amber-200/30 bg-amber-300/15 px-5 py-2 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/25"
>
Close
</button>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,26 +1,17 @@
"use client";
import Link from "next/link";
import { FormEvent, useState } from "react";
import { SubmitEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useConnection } from "@/lib/connection";
import { cmd, DEFAULT_WS_URL } from "@/lib/protocol";
import { cmd } from "@/lib/protocol";
export default function Nav() {
const pathname = usePathname();
const router = useRouter();
const { status, role, username, send, becomePlayer } = useConnection();
const { status, role, username, send, becomePlayer, disconnect } = useConnection();
const [showPlayerModal, setShowPlayerModal] = useState(false);
const [nextUsername, setNextUsername] = useState(username);
const statusLabel =
status === "connected"
? `Connected ${role === "player" ? `as ${username}` : "as observer"}`
: status === "reconnecting"
? "Reconnecting..."
: status === "connecting"
? "Connecting..."
: "Not connected";
const isConnectionPage = pathname === "/";
const disableRoleSwitch =
@@ -31,7 +22,7 @@ export default function Nav() {
router.push("/spectate");
};
const handleBecomePlayer = (event: FormEvent<HTMLFormElement>) => {
const handleBecomePlayer = (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = nextUsername.trim();
if (!trimmed) return;
@@ -50,31 +41,35 @@ export default function Nav() {
className="text-lg font-bold text-white flex items-center gap-2"
>
<span className="text-2xl">🔴</span>
<span>Connect4</span>
<span className="text-gray-400 text-sm font-normal">Moderator</span>
<span>Connect4 Observer</span>
</Link>
<div className="ml-auto flex items-center gap-2">
{!isConnectionPage && (
<button
onClick={
role === "player"
? handleBecomeObserver
: () => {
setNextUsername(username);
setShowPlayerModal(true);
}
}
disabled={disableRoleSwitch}
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white"
>
{role === "player" ? "Become Observer" : "Become Player"}
</button>
)}
<>
<button
onClick={
role === "player"
? handleBecomeObserver
: () => {
setNextUsername(username);
setShowPlayerModal(true);
}
}
disabled={disableRoleSwitch}
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white"
>
{role === "player" ? "Become Observer" : "Become Player"}
</button>
<div className="text-xs text-gray-400 bg-gray-800 px-3 py-1 rounded-full">
{statusLabel}
</div>
<button
onClick={disconnect}
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-gray-700 hover:bg-red-600 text-white"
>
Disconnect
</button>
</>
)}
</div>
</div>
</nav>