mirror of
https://github.com/mudabbir-ahmad/peworkshop.git
synced 2026-10-08 03:50:21 +00:00
fixed user leaderboard. need to add features to admin leaderboard and fix timing issue on admin dashboard.
Grey = cant click Red = current Green = complete blue = skipped? Need to add Clue page Need to fix Leaderboard.
This commit is contained in:
1 parent
a762528494
commit
7d6b73ffb4
6 files changed
+440
-62
No files matched your search
+27
-55
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import {
|
||||
setHuntTimer,
|
||||
clearHuntTimer,
|
||||
getHuntTimer,
|
||||
formatElapsedTime,
|
||||
} from "@/utils/timerService";
|
||||
|
||||
@@ -26,6 +27,7 @@ export default function AdminDashboard() {
|
||||
useEffect(() => {
|
||||
async function loadTimerStatus() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/hunt/timer", {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
@@ -39,18 +41,13 @@ export default function AdminDashboard() {
|
||||
|
||||
// Calculate elapsed time
|
||||
if (data.startTime) {
|
||||
const startTime = new Date(data.startTime).getTime();
|
||||
const currentTime = Date.now();
|
||||
const elapsed = Math.floor((currentTime - startTime) / 1000);
|
||||
setElapsedTime(elapsed);
|
||||
|
||||
// Update global timer data for all clients
|
||||
// Store the start time in the centralized service
|
||||
setHuntTimer(data.startTime, data.endTime);
|
||||
}
|
||||
|
||||
// If there's an end time, set it
|
||||
if (data.endTime) {
|
||||
setTimerEndTime(new Date(data.endTime).getTime());
|
||||
// If there's an end time, set it
|
||||
if (data.endTime) {
|
||||
setTimerEndTime(new Date(data.endTime).getTime());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,62 +75,39 @@ export default function AdminDashboard() {
|
||||
}
|
||||
}, [session, status, router]);
|
||||
|
||||
// Timer update effect
|
||||
// Timer update effect - use the centralized timer service
|
||||
useEffect(() => {
|
||||
if (timerActive) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setElapsedTime((prev) => {
|
||||
const newElapsed = prev + 1;
|
||||
let intervalId;
|
||||
|
||||
// If we have an end time and we've reached it, stop the timer
|
||||
if (timerActive) {
|
||||
// Use the existing timer data from the centralized service
|
||||
intervalId = setInterval(() => {
|
||||
const timerData = getHuntTimer();
|
||||
|
||||
if (timerData && timerData.startTime) {
|
||||
// Format the elapsed time using the centralized service
|
||||
const formattedTime = formatElapsedTime(timerData.startTime);
|
||||
setTimerDisplay(formattedTime);
|
||||
|
||||
// If there's an end time, check if we've reached it
|
||||
if (timerEndTime && Date.now() >= timerEndTime) {
|
||||
stopTimer();
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Update the display
|
||||
formatTimeDisplay(newElapsed);
|
||||
return newElapsed;
|
||||
});
|
||||
} else {
|
||||
setTimerDisplay("--:--:--");
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
setTimerDisplay("--:--:--");
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}, [timerActive, timerEndTime]);
|
||||
|
||||
// Format time for display whenever elapsed time changes
|
||||
useEffect(() => {
|
||||
formatTimeDisplay(elapsedTime);
|
||||
}, [elapsedTime]);
|
||||
|
||||
// Format seconds into HH:MM:SS
|
||||
const formatTimeDisplay = (totalSeconds) => {
|
||||
if (!timerActive) {
|
||||
setTimerDisplay("--:--:--");
|
||||
return;
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const display = [
|
||||
hours.toString().padStart(2, "0"),
|
||||
minutes.toString().padStart(2, "0"),
|
||||
seconds.toString().padStart(2, "0"),
|
||||
].join(":");
|
||||
|
||||
setTimerDisplay(display);
|
||||
};
|
||||
|
||||
const startTimer = async (preset = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -182,7 +156,6 @@ export default function AdminDashboard() {
|
||||
|
||||
if (data.success) {
|
||||
setTimerActive(true);
|
||||
setElapsedTime(0);
|
||||
|
||||
if (endTime) {
|
||||
setTimerEndTime(new Date(endTime).getTime());
|
||||
@@ -221,8 +194,7 @@ export default function AdminDashboard() {
|
||||
if (data.success) {
|
||||
setTimerActive(false);
|
||||
setTimerEndTime(null);
|
||||
setElapsedTime(0);
|
||||
formatTimeDisplay(0);
|
||||
setTimerDisplay("--:--:--");
|
||||
|
||||
// Clear timer data for all clients
|
||||
clearHuntTimer();
|
||||
|
||||
@@ -1,15 +1,70 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// ...other imports
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const [teams, setTeams] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const searchParams = useSearchParams();
|
||||
const huntJustEnded = searchParams.get("huntEnded") === "true";
|
||||
const router = useRouter();
|
||||
const { status } = useSession();
|
||||
|
||||
// ...existing fetch logic
|
||||
// Function to fetch leaderboard data
|
||||
const fetchLeaderboard = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch from the public leaderboard endpoint
|
||||
const response = await fetch("/api/leaderboard", {
|
||||
headers: {
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch leaderboard data");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTeams(data);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
console.error("Error fetching leaderboard:", err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch data on mount and set up refresh interval
|
||||
useEffect(() => {
|
||||
// Only proceed if authenticated
|
||||
if (status === "authenticated") {
|
||||
fetchLeaderboard();
|
||||
|
||||
// Refresh data every 30 seconds
|
||||
const interval = setInterval(fetchLeaderboard, 30000);
|
||||
return () => clearInterval(interval);
|
||||
} else if (status === "unauthenticated") {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="leaderboard-page loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Loading leaderboard...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="leaderboard-page">
|
||||
@@ -29,11 +84,248 @@ export default function LeaderboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rest of the leaderboard content */}
|
||||
{/* ...existing code */}
|
||||
{error ? (
|
||||
<div className="error-message">{error}</div>
|
||||
) : teams.length > 0 ? (
|
||||
<div
|
||||
className={`leaderboard-container ${
|
||||
huntJustEnded ? "final-results" : ""
|
||||
}`}
|
||||
>
|
||||
<table className="leaderboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Team</th>
|
||||
{!huntJustEnded && (
|
||||
<>
|
||||
<th>Progress</th>
|
||||
<th>Last Activity</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{teams.map((team, index) => (
|
||||
<tr
|
||||
key={team.id}
|
||||
className={index < 3 ? `rank-${index + 1}` : ""}
|
||||
>
|
||||
<td className="rank-cell">
|
||||
{index < 3 ? (
|
||||
<div className="medal">
|
||||
{index === 0 && "🥇"}
|
||||
{index === 1 && "🥈"}
|
||||
{index === 2 && "🥉"}
|
||||
</div>
|
||||
) : (
|
||||
`${index + 1}`
|
||||
)}
|
||||
</td>
|
||||
<td className="team-name-cell">{team.name}</td>
|
||||
{!huntJustEnded && (
|
||||
<>
|
||||
<td className="progress-cell">
|
||||
<div className="progress-container">
|
||||
<div
|
||||
className="progress-bar"
|
||||
style={{ width: `${team.progress}%` }}
|
||||
></div>
|
||||
<span className="progress-text">
|
||||
{team.cluesFound} / {team.totalClues}
|
||||
{team.progress > 0 && ` (${team.progress}%)`}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="activity-cell">
|
||||
{team.lastActivity
|
||||
? new Date(team.lastActivity).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "Never"}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="no-data">No teams on the leaderboard yet</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Bar */}
|
||||
<div className="nav-bar">
|
||||
<Link href="/map" className="nav-item">
|
||||
<div className="nav-icon">🗺️</div>
|
||||
<div className="nav-label">Map</div>
|
||||
</Link>
|
||||
<Link href="/leaderboard" className="nav-item active">
|
||||
<div className="nav-icon">📊</div>
|
||||
<div className="nav-label">Stats</div>
|
||||
</Link>
|
||||
<Link href="/team-selection" className="nav-item">
|
||||
<div className="nav-icon">👥</div>
|
||||
<div className="nav-label">Team</div>
|
||||
</Link>
|
||||
<Link href="/profile" className="nav-item">
|
||||
<div className="nav-icon">👤</div>
|
||||
<div className="nav-label">Profile</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
/* ...existing styles */
|
||||
.leaderboard-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background-color: #f4f4f4;
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
background-color: #68543c;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.back-button,
|
||||
.placeholder {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.leaderboard-container {
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.final-results {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.leaderboard-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.leaderboard-table th,
|
||||
.leaderboard-table td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.leaderboard-table th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.rank-cell {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.medal {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.rank-1 {
|
||||
background-color: rgba(255, 215, 0, 0.1);
|
||||
}
|
||||
|
||||
.rank-2 {
|
||||
background-color: rgba(192, 192, 192, 0.1);
|
||||
}
|
||||
|
||||
.rank-3 {
|
||||
background-color: rgba(205, 127, 50, 0.1);
|
||||
}
|
||||
|
||||
.team-name-cell {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background-color: #4caf50;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
font-size: 0.8rem;
|
||||
color: #333;
|
||||
text-shadow: 0 0 2px rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.no-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 50px 0;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #68543c;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: red;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hunt-ended-alert {
|
||||
background-color: #f8d7da;
|
||||
@@ -49,6 +341,42 @@ export default function LeaderboardPage() {
|
||||
font-size: 36px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Navigation Bar Styles */
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
background-color: white;
|
||||
border-top: 1px solid #ddd;
|
||||
padding: 10px 0;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: #666;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: #68543c;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -384,7 +384,7 @@ export default function MapPage() {
|
||||
clearHuntTimer(); // Clean up all timer data
|
||||
|
||||
if (isMounted.current) {
|
||||
softRedirect("/leaderboard?huntEnded=true");
|
||||
hardRedirect("/leaderboard?huntEnded=true");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { query, queryOne } from "@/lib/db";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
|
||||
export const dynamic = "force-dynamic"; // Disable caching
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
// Verify authentication - users need to be logged in to see leaderboard
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
secret:
|
||||
process.env.NEXTAUTH_SECRET ||
|
||||
"your-fallback-secret-should-be-at-least-32-chars",
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get the total number of clues for calculating progress
|
||||
const clueCountResult = await queryOne(
|
||||
"SELECT COUNT(*) as total FROM clues"
|
||||
);
|
||||
const totalClues = clueCountResult?.total || 0;
|
||||
|
||||
// Get all teams with progress info and member count
|
||||
// Only include teams that have at least one member
|
||||
const teams = await query(`
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.code,
|
||||
COUNT(DISTINCT tc.clue_id) as cluesFound,
|
||||
MAX(tc.found_at) as lastActivity,
|
||||
COUNT(DISTINCT u.id) as memberCount
|
||||
FROM
|
||||
teams t
|
||||
LEFT JOIN
|
||||
users u ON t.id = u.team_id
|
||||
LEFT JOIN
|
||||
team_clues tc ON t.id = tc.team_id
|
||||
GROUP BY
|
||||
t.id
|
||||
HAVING
|
||||
memberCount > 0
|
||||
ORDER BY
|
||||
cluesFound DESC,
|
||||
lastActivity ASC
|
||||
`);
|
||||
|
||||
// Add progress percentage and total clues to each team
|
||||
const teamsWithProgress = teams.map((team) => ({
|
||||
...team,
|
||||
totalClues,
|
||||
progress:
|
||||
totalClues > 0 ? Math.round((team.cluesFound / totalClues) * 100) : 0,
|
||||
}));
|
||||
|
||||
return Response.json(teamsWithProgress, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||
Pragma: "no-cache",
|
||||
Expires: "0",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Leaderboard error:", error);
|
||||
return Response.json(
|
||||
{ error: "Failed to fetch leaderboard data" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -94,7 +94,12 @@ export default function WaitingPage() {
|
||||
// Clear any timer data
|
||||
clearHuntTimer();
|
||||
|
||||
// Don't redirect immediately to allow user to read the message
|
||||
// Redirect to leaderboard after a brief delay
|
||||
setTimeout(() => {
|
||||
window.location.href = "/leaderboard?huntEnded=true";
|
||||
}, 2000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the current hunt data for next comparison
|
||||
|
||||
Binary file not shown.
Reference in new issue
Block a user