feat: confetti, winner popup, bracket view
This commit is contained in:
@@ -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>
|
||||
|
||||
76
connect4-ui/components/Celebration.tsx
Normal file
76
connect4-ui/components/Celebration.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user