diff --git a/web/app/api/leaderboard/route.ts b/web/app/api/leaderboard/route.ts new file mode 100644 index 0000000..25e0fad --- /dev/null +++ b/web/app/api/leaderboard/route.ts @@ -0,0 +1,219 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { supabase } from '@/lib/supabase'; + +// GET - Fetch leaderboard +export async function GET() { + try { + // Check if Supabase is configured + if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) { + console.error('Supabase environment variables not configured'); + return NextResponse.json( + { error: 'Leaderboard service not configured', leaderboard: [] }, + { status: 503 } + ); + } + + // Fetch top 5 scores, ordered by score descending, then by created_at ascending (earlier scores rank higher if tied) + const { data, error } = await supabase + .from('leaderboards') + .select('username, score, created_at') + .order('score', { ascending: false }) + .order('created_at', { ascending: true }) + .limit(5); + + if (error) { + console.error('Error fetching leaderboard:', error); + return NextResponse.json( + { error: 'Failed to fetch leaderboard', leaderboard: [] }, + { status: 500 } + ); + } + + // Transform data to match expected format + const leaderboard = (data || []).map(entry => ({ + username: entry.username, + score: entry.score, + timestamp: new Date(entry.created_at).getTime(), + })); + + return NextResponse.json({ leaderboard }); + } catch (error) { + console.error('Unexpected error fetching leaderboard:', error); + return NextResponse.json( + { error: 'Internal server error', leaderboard: [] }, + { status: 500 } + ); + } +} + +// POST - Submit a score +export async function POST(request: NextRequest) { + try { + // Check if Supabase is configured + if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) { + console.error('Supabase environment variables not configured'); + return NextResponse.json( + { error: 'Leaderboard service not configured' }, + { status: 503 } + ); + } + + const body = await request.json(); + const { username, score } = body; + + if (!username || typeof username !== 'string' || username.trim().length === 0) { + return NextResponse.json( + { error: 'Username is required' }, + { status: 400 } + ); + } + + if (typeof score !== 'number' || score < 0) { + return NextResponse.json( + { error: 'Valid score is required' }, + { status: 400 } + ); + } + + // Sanitize username (max 20 chars, alphanumeric + spaces/hyphens/underscores) + const sanitizedUsername = username.trim().slice(0, 20).replace(/[^a-zA-Z0-9\s\-_]/g, ''); + + if (sanitizedUsername.length === 0) { + return NextResponse.json( + { error: 'Invalid username format' }, + { status: 400 } + ); + } + + const finalScore = Math.floor(score); + + // Check if user already has a score in the database + const { data: existingScores, error: checkError } = await supabase + .from('leaderboards') + .select('id, username, score, created_at') + .eq('username', sanitizedUsername) + .order('score', { ascending: false }) + .limit(1); + + if (checkError) { + console.error('Error checking existing score:', checkError); + return NextResponse.json( + { error: 'Failed to check existing score' }, + { status: 500 } + ); + } + + let insertedData; + let scoreNotUpdated = false; + let existingHighScore: number | undefined = undefined; + + if (existingScores && existingScores.length > 0) { + // User already has a score - only update if new score is higher + const existingScore = existingScores[0].score; + existingHighScore = existingScore; + + if (finalScore > existingScore) { + // Update existing entry with new higher score + const { data: updatedData, error: updateError } = await supabase + .from('leaderboards') + .update({ score: finalScore }) + .eq('id', existingScores[0].id) + .select('id, username, score, created_at') + .single(); + + if (updateError) { + console.error('Error updating score:', updateError); + return NextResponse.json( + { error: 'Failed to update score' }, + { status: 500 } + ); + } + + insertedData = updatedData; + } else { + // New score is not higher, don't update - return existing entry + insertedData = existingScores[0]; + scoreNotUpdated = true; + // Still need to fetch full leaderboard for response + } + } else { + // No existing score - insert new entry + const { data: newData, error: insertError } = await supabase + .from('leaderboards') + .insert({ + username: sanitizedUsername, + score: finalScore, + }) + .select('id, username, score, created_at') + .single(); + + if (insertError) { + console.error('Error inserting score:', insertError); + return NextResponse.json( + { error: 'Failed to submit score' }, + { status: 500 } + ); + } + + insertedData = newData; + } + + // Fetch full leaderboard to calculate rank + const { data: allScores, error: rankError } = await supabase + .from('leaderboards') + .select('id, username, score, created_at') + .order('score', { ascending: false }) + .order('created_at', { ascending: true }); + + if (rankError) { + console.error('Error fetching rank:', rankError); + // Still return success even if we can't get rank + const { data: leaderboardData } = await supabase + .from('leaderboards') + .select('username, score, created_at') + .order('score', { ascending: false }) + .order('created_at', { ascending: true }) + .limit(5); + + const leaderboard = (leaderboardData || []).map(entry => ({ + username: entry.username, + score: entry.score, + timestamp: new Date(entry.created_at).getTime(), + })); + + return NextResponse.json({ + success: true, + rank: null, + leaderboard, + }); + } + + // Find rank (1-indexed) + const rank = (allScores || []).findIndex( + entry => entry.id === insertedData.id + ) + 1; + + // Get top 5 for leaderboard response + const leaderboard = (allScores || []) + .slice(0, 5) + .map(entry => ({ + username: entry.username, + score: entry.score, + timestamp: new Date(entry.created_at).getTime(), + })); + + return NextResponse.json({ + success: true, + rank: rank > 0 ? rank : (allScores?.length || 0), + leaderboard, + scoreNotUpdated: scoreNotUpdated, // Indicates if score wasn't updated because it's lower + existingScore: existingHighScore, // The existing high score (only set if scoreNotUpdated is true) + }); + } catch (error) { + console.error('Unexpected error submitting score:', error); + return NextResponse.json( + { error: 'Invalid request body' }, + { status: 400 } + ); + } +} diff --git a/web/components/teamGame-components/BeaverGame.tsx b/web/components/teamGame-components/BeaverGame.tsx index 62ca9a2..0f81f79 100644 --- a/web/components/teamGame-components/BeaverGame.tsx +++ b/web/components/teamGame-components/BeaverGame.tsx @@ -5,6 +5,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { teamMembers, TeamMember } from '@/lib/teamGameData'; import Beaver from './Beaver'; import Obstacle from './Obstacle'; +import Leaderboard from './Leaderboard'; +import { ToastContainer } from '@/components/ui/Toast'; import { faInstagram, faLinkedin } from '@fortawesome/free-brands-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -138,6 +140,11 @@ export default function BeaverGame() { const [selectedMember, setSelectedMember] = useState(null); const [hoveredMember, setHoveredMember] = useState(null); + const [showLeaderboard, setShowLeaderboard] = useState(false); + const [toasts, setToasts] = useState>([]); + const [showUsernamePrompt, setShowUsernamePrompt] = useState(false); + const [pendingScore, setPendingScore] = useState(null); + const [username, setUsername] = useState(null); const beaverRef = useRef(null); const laneRef = useRef(null); @@ -150,6 +157,7 @@ export default function BeaverGame() { const scoreAccRef = useRef(0); const speedScaleRef = useRef(1); + const currentScoreRef = useRef(0); const yRef = useRef(0); const vyRef = useRef(0); @@ -199,10 +207,10 @@ export default function BeaverGame() { }, []); useEffect(() => { - const savedHighScore = localStorage.getItem('highScore'); - const savedMetMembers = localStorage.getItem('metMembers'); - if (savedHighScore) setGameState(prev => ({ ...prev, highScore: parseInt(savedHighScore) })); - if (savedMetMembers) setMetMembers(JSON.parse(savedMetMembers)); + const savedUsername = localStorage.getItem('gameUsername'); + if (savedUsername) { + setUsername(savedUsername); + } }, []); useEffect(() => { @@ -223,6 +231,11 @@ export default function BeaverGame() { isPlayingRef.current = gameState.isPlaying; }, [gameState.isPlaying]); + // Keep score ref in sync with state + useEffect(() => { + currentScoreRef.current = gameState.score; + }, [gameState.score]); + const markMet = useCallback((memberId: string) => { setMetMembers(prev => { if (prev.includes(memberId)) return prev; @@ -232,19 +245,86 @@ export default function BeaverGame() { }); }, []); + const addToast = useCallback((message: string, type: 'success' | 'error' | 'info' = 'success') => { + const id = Date.now().toString(); + setToasts(prev => [...prev, { id, message, type }]); + }, []); + + const removeToast = useCallback((id: string) => { + setToasts(prev => prev.filter(toast => toast.id !== id)); + }, []); + + const submitScore = useCallback(async (username: string, score: number) => { + console.log('submitScore called with:', { username, score }); + try { + const response = await fetch('/api/leaderboard', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + score: score, + }), + }); + + if (!response.ok) { + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to submit score'); + } + throw new Error('Failed to submit score'); + } + + const data = await response.json(); + + // Check if score wasn't updated because it's lower than existing + if (data.scoreNotUpdated) { + addToast(`đŸ’Ē Your best score is ${data.existingScore}. Try again to beat it!`, 'info'); + } else { + // Show success toast + if (data.rank && data.rank <= 10) { + addToast(`🎉 New High Score! You're ranked #${data.rank}!`, 'success'); + } else { + addToast('Score submitted successfully!', 'success'); + } + } + + return data; + } catch (error) { + console.error('Error submitting score:', error); + addToast(error instanceof Error ? error.message : 'Failed to submit score', 'error'); + throw error; + } + }, [addToast]); + const endGame = useCallback( - (member: TeamMember) => { + async (member: TeamMember) => { if (!isPlayingRef.current) return; isPlayingRef.current = false; markMet(member.id); + // Check if this is a new high score before updating state + // Use ref to get the most current score value + const currentScore = currentScoreRef.current || gameState.score; + const currentHighScore = gameState.highScore; + const isNewHighScore = currentScore > currentHighScore; + setGameState(prev => { const newHighScore = Math.max(prev.score, prev.highScore); localStorage.setItem('highScore', newHighScore.toString()); return { ...prev, isPlaying: false, isGameOver: true, highScore: newHighScore }; }); + // Show toast if new high score achieved + if (isNewHighScore) { + setTimeout(() => { + addToast(`🏆 New Personal Best: ${currentScore} points!`, 'success'); + }, 500); + } + setHitMember(member); jumpingRef.current = false; @@ -255,8 +335,28 @@ export default function BeaverGame() { if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = null; lastTimeRef.current = null; + + // Check if username exists, if not prompt for it + const savedUsername = localStorage.getItem('gameUsername'); + console.log('Game ended. Score from ref:', currentScoreRef.current, 'Score from state:', gameState.score, 'Using:', currentScore, 'Username:', savedUsername); + if (savedUsername && savedUsername.trim()) { + // Username exists, automatically submit score + try { + console.log('Submitting score:', currentScore, 'for user:', savedUsername.trim()); + await submitScore(savedUsername.trim(), currentScore); + setShowLeaderboard(true); + } catch (error) { + console.error('Auto-submit failed:', error); + setShowLeaderboard(true); + } + } else { + // New user - prompt for username + console.log('New user, prompting for username. Score:', currentScore); + setPendingScore(currentScore); + setShowUsernamePrompt(true); + } }, - [markMet] + [markMet, submitScore, addToast] ); const boxesOverlap = (a: DOMRect, b: DOMRect) => @@ -317,6 +417,7 @@ export default function BeaverGame() { setGameState(prev => { const nextScore = prev.score + steps; + currentScoreRef.current = nextScore; // Keep ref in sync const nextSpeed = Math.min(18, 6 + Math.floor(nextScore / 140)); speedScaleRef.current = 1 + (nextSpeed - 6) * 0.06; return { ...prev, score: nextScore, speed: nextSpeed }; @@ -425,11 +526,16 @@ export default function BeaverGame() { useEffect(() => { const onEsc = (e: KeyboardEvent) => { - if (e.key === 'Escape') setSelectedMember(null); + if (e.key === 'Escape') { + // Don't close username prompt - username is required + if (showUsernamePrompt) return; + setSelectedMember(null); + setShowLeaderboard(false); + } }; window.addEventListener('keydown', onEsc); return () => window.removeEventListener('keydown', onEsc); - }, []); + }, [showUsernamePrompt]); const startGame = useCallback(() => { if (!laneWidth) return; @@ -442,6 +548,7 @@ export default function BeaverGame() { setSelectedMember(null); isPlayingRef.current = true; + currentScoreRef.current = 0; // Reset score ref setGameState(prev => ({ ...prev, @@ -662,9 +769,17 @@ export default function BeaverGame() { )} -
- HI {displayHigh} - {displayScore} +
+
+ HI {displayHigh} + {displayScore} +
+
@@ -775,6 +890,121 @@ export default function BeaverGame() { /> + + {/* Username Prompt Modal */} + {showUsernamePrompt && ( +
{ + // Don't close on backdrop click - username is required + e.stopPropagation(); + }} + > +
e.stopPropagation()} + > +

Enter Your Username

+

+ Your score: {pendingScore} +

+

+ Enter a username to submit your score to the leaderboard. +

+ { + if (e.key === 'Enter') { + const input = e.currentTarget; + const username = input.value.trim(); + if (username.length === 0) return; + + // Sanitize username + const sanitized = username.slice(0, 20).replace(/[^a-zA-Z0-9\s\-_]/g, ''); + if (sanitized.length === 0) { + addToast('Invalid username format', 'error'); + return; + } + + // Save username + localStorage.setItem('gameUsername', sanitized); + setUsername(sanitized); + + // Submit score + try { + await submitScore(sanitized, pendingScore || 0); + setShowUsernamePrompt(false); + setPendingScore(null); + setShowLeaderboard(true); + } catch (error) { + console.error('Failed to submit score:', error); + // Still close prompt and show leaderboard + setShowUsernamePrompt(false); + setPendingScore(null); + setShowLeaderboard(true); + } + } + }} + autoFocus + /> +
+ +
+

+ This username will be saved for future games +

+
+
+ )} + + setShowLeaderboard(false)} + username={username} + /> + + ); } diff --git a/web/components/teamGame-components/Leaderboard.tsx b/web/components/teamGame-components/Leaderboard.tsx new file mode 100644 index 0000000..93b12ff --- /dev/null +++ b/web/components/teamGame-components/Leaderboard.tsx @@ -0,0 +1,130 @@ +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; + +import { supabase } from '@/lib/supabase'; + +interface LeaderboardEntry { + username: string; + score: number; + timestamp: number; +} + +interface LeaderboardProps { + isOpen: boolean; + onClose: () => void; + username: string | null; +} + +export default function Leaderboard({ isOpen, onClose, username }: LeaderboardProps) { + const [leaderboard, setLeaderboard] = useState([]); + const [loading, setLoading] = useState(false); + + const fetchLeaderboard = useCallback(async () => { + setLoading(true); + try { + const response = await fetch('/api/leaderboard'); + + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + const text = await response.text(); + console.error('Non-JSON response:', text); + setLeaderboard([]); + return; + } + + const data = await response.json(); + setLeaderboard(data.leaderboard || []); + } catch (err) { + console.error('Failed to fetch leaderboard:', err); + setLeaderboard([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (isOpen) { + fetchLeaderboard(); + + const channel = supabase + .channel('leaderboard-changes') + .on( + 'postgres_changes', + { event: 'INSERT', schema: 'public', table: 'leaderboards' }, + (payload) => { + console.log('Leaderboard change received!', payload); + fetchLeaderboard(); + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + } + }, [isOpen, fetchLeaderboard]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+

Leaderboard

+ +
+ + {loading ? ( +
Loading leaderboard...
+ ) : leaderboard.length === 0 ? ( +
No scores yet. Be the first!
+ ) : ( +
+
+
#
+
Username
+
Score
+
+ {leaderboard.map((entry, index) => { + const isCurrentUser = username && entry.username.toLowerCase() === username.toLowerCase(); + return ( +
+
+ {index === 0 ? 'đŸĨ‡' : index === 1 ? 'đŸĨˆ' : index === 2 ? 'đŸĨ‰' : index + 1} +
+
{entry.username}
+
{entry.score.toLocaleString()}
+
+ ); + })} +
+ )} + +
+ Press Esc or click outside to close. +
+
+
+ ); +} diff --git a/web/components/ui/Toast.tsx b/web/components/ui/Toast.tsx new file mode 100644 index 0000000..723d83a --- /dev/null +++ b/web/components/ui/Toast.tsx @@ -0,0 +1,77 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; + +interface ToastProps { + message: string; + type?: 'success' | 'error' | 'info'; + duration?: number; + onClose: () => void; +} + +export default function Toast({ message, type = 'success', duration = 3000, onClose }: ToastProps) { + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => { + setIsVisible(false); + setTimeout(onClose, 300); // Wait for fade out animation + }, duration); + + return () => clearTimeout(timer); + }, [duration, onClose]); + + const bgColor = { + success: 'bg-green-500', + error: 'bg-red-500', + info: 'bg-blue-500', + }[type]; + + const icon = { + success: '🎉', + error: '❌', + info: 'â„šī¸', + }[type]; + + if (!isVisible) return null; + + return ( +
+ {icon} +

{message}

+ +
+ ); +} + +interface ToastContainerProps { + toasts: Array<{ id: string; message: string; type?: 'success' | 'error' | 'info' }>; + onRemove: (id: string) => void; +} + +export function ToastContainer({ toasts, onRemove }: ToastContainerProps) { + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => ( + onRemove(toast.id)} + /> + ))} +
+ ); +} diff --git a/web/lib/supabase.ts b/web/lib/supabase.ts new file mode 100644 index 0000000..3992d95 --- /dev/null +++ b/web/lib/supabase.ts @@ -0,0 +1,10 @@ +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ''; +const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; + +if (!supabaseUrl || !supabaseAnonKey) { + console.warn('Supabase environment variables are not set. Leaderboard functionality will not work.'); +} + +export const supabase = createClient(supabaseUrl, supabaseAnonKey); diff --git a/web/package-lock.json b/web/package-lock.json index 14d797a..768c124 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,6 +14,7 @@ "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.1.1", "@radix-ui/react-accordion": "^1.2.12", + "@supabase/supabase-js": "^2.93.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.554.0", @@ -1518,6 +1519,86 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.93.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.93.2.tgz", + "integrity": "sha512-uifI5vkhvHCQjn+LUPL5QlsuDMP4oVBD5SiliREgYuTJvCkbPLOcAPGrw88q7VUX9S0J0QuJn+37hrcTITisuw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.93.2", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.93.2.tgz", + "integrity": "sha512-reSp7yj4KmvAFfmN+N7vYsHXOIZQh9cmRBh+VrZlm7qgIIUdYmzKuD85TvFnWApqcdI2pPnuZGKWE/2B4GXT1A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.93.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.93.2.tgz", + "integrity": "sha512-W2AWDsYwRT217II5yD3jWaX3fJjB7DwyNi2KNi4sphdUI3DKY4fP2XYVDGfeb1clEFL18gw+GBhyQb3BcpNWkw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.93.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.93.2.tgz", + "integrity": "sha512-YpAmJn7DLbMeYfQilcf3f0DKoY8O8TRbTF2oRpWFzHXTlEA+YWms8fBqM13Mf7RE72ouSNKDYyf5K2pWRSHvFw==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.93.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.93.2.tgz", + "integrity": "sha512-abRSVClfIQn+SqpdqL7S7b3VeyS8270/o0gqmGFtiidb7Lu0COsIV6Mor/mK9xE99KYWzyd37vwYYwv/jaANhw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.93.2", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.93.2.tgz", + "integrity": "sha512-G3bZZi6rPwXcPtyHLXQTeHKa5ADZ2UW/+hv8YhwZFwngz4TlPnR4+TeO37EwU5+d/reD02qXozOZgz+QHv1Jtg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.93.2", + "@supabase/functions-js": "2.93.2", + "@supabase/postgrest-js": "2.93.2", + "@supabase/realtime-js": "2.93.2", + "@supabase/storage-js": "2.93.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1834,12 +1915,17 @@ "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", @@ -1862,6 +1948,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.48.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", @@ -3449,6 +3544,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4157,6 +4253,15 @@ "node": ">= 0.4" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6617,7 +6722,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -6836,6 +6940,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web/package.json b/web/package.json index 790a5d0..7ab81eb 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.1.1", "@radix-ui/react-accordion": "^1.2.12", + "@supabase/supabase-js": "^2.93.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.554.0", diff --git a/web/supabase/create_leaderboards_table.sql b/web/supabase/create_leaderboards_table.sql new file mode 100644 index 0000000..12f2afa --- /dev/null +++ b/web/supabase/create_leaderboards_table.sql @@ -0,0 +1,55 @@ +-- ============================================ +-- Leaderboards Table Creation Script +-- ============================================ +-- Copy and paste this entire script into Supabase SQL Editor +-- ============================================ + +-- Create the leaderboards table +CREATE TABLE IF NOT EXISTS leaderboards ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + username TEXT NOT NULL, + score INTEGER NOT NULL CHECK (score >= 0), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL +); + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_leaderboards_score_desc + ON leaderboards(score DESC, created_at ASC); + +CREATE INDEX IF NOT EXISTS idx_leaderboards_created_at + ON leaderboards(created_at ASC); + +-- Enable Row Level Security +ALTER TABLE leaderboards ENABLE ROW LEVEL SECURITY; + +-- Policy: Allow anyone to read the leaderboard +CREATE POLICY "Allow public read access to leaderboards" + ON leaderboards + FOR SELECT + USING (true); + +-- Policy: Allow anyone to submit scores +CREATE POLICY "Allow public insert access to leaderboards" + ON leaderboards + FOR INSERT + WITH CHECK (true); + +-- Policy: Allow anyone to update scores (for updating existing user scores) +CREATE POLICY "Allow public update access to leaderboards" + ON leaderboards + FOR UPDATE + USING (true) + WITH CHECK (true); + +-- ============================================ +-- Optional: If you want to allow users to delete their own scores +-- Uncomment the following policy: +-- ============================================ +-- CREATE POLICY "Allow users to delete their own scores" +-- ON leaderboards +-- FOR DELETE +-- USING (true); + +-- ============================================ +-- Done! Your leaderboards table is ready. +-- ============================================ diff --git a/web/supabase/migrations/001_create_leaderboards_table.sql b/web/supabase/migrations/001_create_leaderboards_table.sql new file mode 100644 index 0000000..c11eb29 --- /dev/null +++ b/web/supabase/migrations/001_create_leaderboards_table.sql @@ -0,0 +1,41 @@ +-- Create leaderboards table +CREATE TABLE IF NOT EXISTS leaderboards ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + username TEXT NOT NULL, + score INTEGER NOT NULL CHECK (score >= 0), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL +); + +-- Create index on score for faster leaderboard queries +CREATE INDEX IF NOT EXISTS idx_leaderboards_score_desc ON leaderboards(score DESC, created_at ASC); + +-- Create index on created_at for sorting +CREATE INDEX IF NOT EXISTS idx_leaderboards_created_at ON leaderboards(created_at ASC); + +-- Enable Row Level Security (RLS) +ALTER TABLE leaderboards ENABLE ROW LEVEL SECURITY; + +-- Create policy to allow anyone to read leaderboard (public read access) +CREATE POLICY "Allow public read access to leaderboards" + ON leaderboards + FOR SELECT + USING (true); + +-- Create policy to allow anyone to insert scores (public write access) +CREATE POLICY "Allow public insert access to leaderboards" + ON leaderboards + FOR INSERT + WITH CHECK (true); + +-- Optional: Create policy to allow users to delete their own scores (if needed) +-- Uncomment if you want users to be able to delete their own entries +-- CREATE POLICY "Allow users to delete their own scores" +-- ON leaderboards +-- FOR DELETE +-- USING (true); + +-- Add comment to table +COMMENT ON TABLE leaderboards IS 'Stores game scores for the leaderboard'; +COMMENT ON COLUMN leaderboards.username IS 'Player username (max 20 characters, sanitized)'; +COMMENT ON COLUMN leaderboards.score IS 'Player score (non-negative integer)'; +COMMENT ON COLUMN leaderboards.created_at IS 'Timestamp when the score was submitted';