diff --git a/src/app/(frontend)/flappy/page.tsx b/src/app/(frontend)/flappy/page.tsx index d52bc99..56248d3 100644 --- a/src/app/(frontend)/flappy/page.tsx +++ b/src/app/(frontend)/flappy/page.tsx @@ -4,7 +4,7 @@ import { getPayload } from "payload"; import { headers as nextHeaders } from "next/headers"; import { redirect } from "next/navigation"; import { Bird } from "lucide-react"; -import { FlappyBird } from "@/components/frontend/flappy/FlappyBird"; +import { FlappyArena } from "@/components/frontend/flappy/FlappyArena"; export const metadata: Metadata = { title: "Flappy Bird — Polaris Task Force", @@ -56,7 +56,7 @@ export default async function FlappyPage() {

- + ); } diff --git a/src/components/frontend/flappy/FlappyArena.tsx b/src/components/frontend/flappy/FlappyArena.tsx new file mode 100644 index 0000000..bd720fa --- /dev/null +++ b/src/components/frontend/flappy/FlappyArena.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useState } from "react"; +import type { FlappyStats } from "@/app/(frontend)/flappy/actions"; +import { FlappyBird } from "./FlappyBird"; +import { FlappyLeaderboard } from "./FlappyLeaderboard"; + +export function FlappyArena({ + initialStats, + currentUserId, +}: { + initialStats: FlappyStats; + currentUserId: number | string; +}) { + const [refreshKey, setRefreshKey] = useState(0); + + return ( +
+ setRefreshKey((k) => k + 1)} /> + +
+ ); +} diff --git a/src/components/frontend/flappy/FlappyBird.tsx b/src/components/frontend/flappy/FlappyBird.tsx index b77530c..c387f22 100644 --- a/src/components/frontend/flappy/FlappyBird.tsx +++ b/src/components/frontend/flappy/FlappyBird.tsx @@ -142,7 +142,13 @@ function birdHitsPipe(birdX: number, birdY: number, pipe: Pipe): boolean { return topBlocked || bottomBlocked; } -export function FlappyBird({ initialStats: serverStats }: { initialStats: FlappyStats }) { +export function FlappyBird({ + initialStats: serverStats, + onGameRecorded, +}: { + initialStats: FlappyStats; + onGameRecorded?: () => void; +}) { const canvasRef = useRef(null); const wrapperRef = useRef(null); const phaseRef = useRef("ready"); @@ -206,6 +212,7 @@ export function FlappyBird({ initialStats: serverStats }: { initialStats: Flappy if (res.success && res.data) { statsRef.current = res.data; setStats(res.data); + onGameRecorded?.(); if (res.data.xpEarned > 0) { playXp(); } @@ -223,7 +230,7 @@ export function FlappyBird({ initialStats: serverStats }: { initialStats: Flappy }); } }, - [], + [onGameRecorded], ); const handleGameOverRef = useRef(handleGameOver); diff --git a/src/components/frontend/flappy/FlappyLeaderboard.tsx b/src/components/frontend/flappy/FlappyLeaderboard.tsx new file mode 100644 index 0000000..23e99b6 --- /dev/null +++ b/src/components/frontend/flappy/FlappyLeaderboard.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Trophy } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ApiUser { + id: number; + username?: string | null; + displayName?: string | null; + payloadDisplayName?: string | null; +} + +interface ApiProfile { + id: number; + profileTitle?: string | null; + user?: ApiUser | number | null; + progression?: { + minigames?: { + flappyBird?: { + gamesPlayed?: number | null; + totalPasses?: number | null; + bestScore?: number | null; + xpEarned?: number | null; + } | null; + } | null; + } | null; +} + +interface LeaderboardRow { + id: number; + name: string; + gamesPlayed: number; + totalPasses: number; + bestScore: number; + xpEarned: number; + isCurrentUser: boolean; +} + +interface FlappyLeaderboardProps { + refreshKey?: number; + currentUserId?: number | string; +} + +function rankClass(index: number) { + if (index === 0) return "font-bold text-amber-400"; + if (index === 1) return "font-semibold text-slate-300"; + if (index === 2) return "font-semibold text-amber-700"; + return "text-muted-foreground"; +} + +export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeaderboardProps) { + const [rows, setRows] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + setFailed(false); + + const params = new URLSearchParams({ + "where[progression.minigames.flappyBird.bestScore][greater_than]": "0", + sort: "-progression.minigames.flappyBird.bestScore", + limit: "10", + depth: "1", + }); + + fetch(`/api/profiles?${params.toString()}`) + .then((res) => { + if (!res.ok) throw new Error("leaderboard fetch failed"); + return res.json() as Promise<{ docs?: ApiProfile[] }>; + }) + .then((data) => { + if (cancelled) return; + setRows( + (data.docs ?? []).map((doc) => { + const flappy = doc.progression?.minigames?.flappyBird ?? {}; + const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null; + const name = + user?.displayName || doc.profileTitle || user?.username || `Pilot #${doc.id}`; + return { + id: doc.id, + name, + gamesPlayed: flappy.gamesPlayed ?? 0, + totalPasses: flappy.totalPasses ?? 0, + bestScore: flappy.bestScore ?? 0, + xpEarned: flappy.xpEarned ?? 0, + isCurrentUser: user ? String(user.id) === String(currentUserId) : false, + }; + }), + ); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + + return () => { + cancelled = true; + }; + }, [refreshKey, currentUserId]); + + return ( +
+
+ +

Flappy Leaderboard

+ Top 10 · best run +
+ + {failed ? ( +

Could not load the leaderboard.

+ ) : rows === null ? ( +
+
+
+
+
+ ) : rows.length === 0 ? ( +

+ No pilots on the board yet — be the first to pass a pipe! +

+ ) : ( + + + + + + + + + + + + + {rows.map((row, i) => ( + + + + + + + + + ))} + +
#PilotGamesPipesBestXP
+ {i + 1} + + + {row.name} + {row.isCurrentUser && ( + (you) + )} + + + {row.gamesPlayed} + + {row.totalPasses} + {row.bestScore} + {row.xpEarned} +
+ )} +
+ ); +}