mirror of
https://github.com/mudabbir-ahmad/peworkshop.git
synced 2026-10-08 03:50:21 +00:00
fixed the map, made it so the map is scrollable and synced the timing between the admin page and map page. They now share the same elapsed time. need to fix map css for altering the svg.
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
e7ad53643c
commit
16319f1333
14 files changed
+791
-472
No files matched your search
+329
-138
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import styles from "./map.module.css";
|
||||
import {
|
||||
getHuntTimer,
|
||||
setHuntTimer,
|
||||
clearHuntTimer,
|
||||
formatElapsedTime,
|
||||
} from "@/utils/timerService";
|
||||
|
||||
export default function MapPage() {
|
||||
const { data: session, status } = useSession();
|
||||
@@ -12,14 +18,34 @@ export default function MapPage() {
|
||||
const [error, setError] = useState(null);
|
||||
const [teamData, setTeamData] = useState(null);
|
||||
const [elapsedTime, setElapsedTime] = useState("00:00:00");
|
||||
const [mapSvg, setMapSvg] = useState(null);
|
||||
const [clues, setClues] = useState([]);
|
||||
const mapContainerRef = useRef(null);
|
||||
const router = useRouter();
|
||||
const isMounted = useRef(true);
|
||||
|
||||
// Add local state for hunt active status
|
||||
const [huntActive, setHuntActive] = useState(null);
|
||||
|
||||
// Use useCallback for functions called in useEffect to avoid dependency issues
|
||||
const softRedirect = useCallback(
|
||||
(url) => {
|
||||
if (isMounted.current) {
|
||||
router.push(url);
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// Hard redirect function (for when a full page reload is needed)
|
||||
const hardRedirect = useCallback((url) => {
|
||||
if (typeof window !== "undefined" && isMounted.current) {
|
||||
window.location.href = url;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Add function to fetch team hunt start time
|
||||
const fetchTeamHuntStartTime = async () => {
|
||||
const fetchTeamHuntStartTime = useCallback(async () => {
|
||||
try {
|
||||
// First check localStorage for cached start time
|
||||
const cachedStartTime = localStorage.getItem("teamHuntStartTime");
|
||||
@@ -59,124 +85,12 @@ export default function MapPage() {
|
||||
console.error("Failed to fetch team hunt start time:", error);
|
||||
return new Date().toISOString();
|
||||
}
|
||||
};
|
||||
}, [session?.user?.teamId]);
|
||||
|
||||
// Initial hunt status check
|
||||
useEffect(() => {
|
||||
// Check if hunt is active
|
||||
checkHuntAndTimer();
|
||||
// IMPORTANT: Move fetchTeamData up here, before it's referenced in useEffect dependencies
|
||||
const fetchTeamData = useCallback(async () => {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
// Set up periodic checks
|
||||
const interval = setInterval(checkHuntAndTimer, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Handle authentication and hunt status
|
||||
useEffect(() => {
|
||||
// Only redirect if explicitly not authenticated
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
// If hunt is not active, redirect to waiting page
|
||||
if (huntActive === false) {
|
||||
console.log("Hunt is not active, redirecting to waiting page");
|
||||
router.push("/waiting");
|
||||
return;
|
||||
}
|
||||
|
||||
// Only fetch team data if authenticated and hunt is active or status unknown
|
||||
if (status === "authenticated" && huntActive !== false) {
|
||||
fetchTeamData();
|
||||
}
|
||||
}, [status, huntActive, router]);
|
||||
|
||||
// Function to check hunt status and timer
|
||||
async function checkHuntAndTimer() {
|
||||
try {
|
||||
const response = await fetch("/api/hunt-status", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log("Hunt status check from map:", data);
|
||||
|
||||
// If hunt is not active, check if it has ended
|
||||
if (!data.active) {
|
||||
console.log("Hunt not active, checking if it ended...");
|
||||
|
||||
// Check if we had an active hunt before (using localStorage)
|
||||
const wasActive = localStorage.getItem("teamHuntStartTime");
|
||||
|
||||
if (wasActive) {
|
||||
// Hunt was active before but not now - it ended
|
||||
console.log("Hunt has ended, redirecting to leaderboard");
|
||||
localStorage.removeItem("teamHuntStartTime"); // Clean up
|
||||
window.location.href = "/leaderboard?huntEnded=true";
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise just show the waiting page
|
||||
setHuntActive(false);
|
||||
router.push("/waiting");
|
||||
return;
|
||||
}
|
||||
|
||||
// If timer endTime exists and has passed, redirect to leaderboard
|
||||
if (data.endTime && new Date(data.endTime) <= new Date()) {
|
||||
console.log("Timer expired, redirecting to leaderboard");
|
||||
// Clean up the storage
|
||||
localStorage.removeItem("teamHuntStartTime");
|
||||
router.push("/leaderboard?huntEnded=true");
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, hunt is active and timer is running
|
||||
setHuntActive(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check hunt status:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Use team hunt start time for elapsed time calculation
|
||||
useEffect(() => {
|
||||
if (!teamData?.team) return;
|
||||
|
||||
let startTimePromise = fetchTeamHuntStartTime();
|
||||
startTimePromise.then((startTimeStr) => {
|
||||
// Parse the ISO string to milliseconds
|
||||
const startTime = new Date(startTimeStr).getTime();
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const elapsed = Math.floor((now - startTime) / 1000);
|
||||
|
||||
const hours = Math.floor(elapsed / 3600)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const minutes = Math.floor((elapsed % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const seconds = Math.floor(elapsed % 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
|
||||
setElapsedTime(`${hours}:${minutes}:${seconds}`);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
}, [teamData, session]);
|
||||
|
||||
const fetchTeamData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -207,7 +121,11 @@ export default function MapPage() {
|
||||
setError("You need to join a team before viewing the map");
|
||||
setLoading(false);
|
||||
// Give user time to read the error message before redirect
|
||||
setTimeout(() => router.push("/team-selection"), 2000);
|
||||
setTimeout(() => {
|
||||
if (isMounted.current) {
|
||||
softRedirect("/team-selection");
|
||||
}
|
||||
}, 2000);
|
||||
return;
|
||||
} else {
|
||||
throw new Error(errorData.error || "Failed to fetch team data");
|
||||
@@ -215,18 +133,279 @@ export default function MapPage() {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTeamData(data);
|
||||
setLoading(false);
|
||||
if (isMounted.current) {
|
||||
setTeamData(data);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching team data:", err);
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
if (isMounted.current) {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [softRedirect]);
|
||||
|
||||
// Function to fetch clues
|
||||
const fetchClues = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/clues", {
|
||||
headers: { "Cache-Control": "no-cache, no-store, must-revalidate" },
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const cluesData = await response.json();
|
||||
setClues(cluesData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch clues:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Function to load and enhance the SVG map
|
||||
const loadAndEnhanceMap = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/images/map.svg");
|
||||
|
||||
if (!response.ok) {
|
||||
setMapError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const svgText = await response.text();
|
||||
setMapSvg(svgText);
|
||||
} catch (error) {
|
||||
console.error("Failed to load map SVG:", error);
|
||||
setMapError(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Function to enhance SVG with clickable areas
|
||||
const enhanceSvgMap = useCallback(() => {
|
||||
if (!mapContainerRef.current || !clues.length) return;
|
||||
|
||||
// Find all circles in the SVG
|
||||
const circleSvgElements =
|
||||
mapContainerRef.current.querySelectorAll("circle");
|
||||
|
||||
// Convert NodeList to Array for easier manipulation
|
||||
Array.from(circleSvgElements).forEach((circle, index) => {
|
||||
// Only process if we have a clue for this circle
|
||||
if (clues[index]) {
|
||||
// Make circle interactive
|
||||
circle.style.cursor = "pointer";
|
||||
circle.style.fill = "#ff9800";
|
||||
circle.style.stroke = "#d87f00";
|
||||
circle.style.strokeWidth = "2";
|
||||
|
||||
// Add click handler to navigate to the clue page
|
||||
circle.onclick = () => {
|
||||
router.push(`/clue/${clues[index].id}`);
|
||||
};
|
||||
|
||||
// Add hover effects
|
||||
circle.onmouseenter = () => {
|
||||
circle.style.fill = "#ffb74d";
|
||||
circle.style.transform = "scale(1.1)";
|
||||
circle.style.transition = "all 0.3s ease";
|
||||
};
|
||||
|
||||
circle.onmouseleave = () => {
|
||||
circle.style.fill = "#ff9800";
|
||||
circle.style.transform = "scale(1)";
|
||||
};
|
||||
|
||||
// Optional: Add label or tooltip
|
||||
const label = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"title"
|
||||
);
|
||||
label.textContent = `Clue #${index + 1}: ${
|
||||
clues[index].title || "Mystery clue"
|
||||
}`;
|
||||
circle.appendChild(label);
|
||||
}
|
||||
});
|
||||
}, [clues, router]);
|
||||
|
||||
// Clean up function for unmounting
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initial hunt status check
|
||||
useEffect(() => {
|
||||
// Check if hunt is active
|
||||
checkHuntAndTimer();
|
||||
|
||||
// Set up periodic checks
|
||||
const interval = setInterval(checkHuntAndTimer, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Handle authentication and hunt status
|
||||
useEffect(() => {
|
||||
// Only redirect if explicitly not authenticated
|
||||
if (status === "unauthenticated") {
|
||||
softRedirect("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
// If hunt is not active, redirect to waiting page
|
||||
if (huntActive === false) {
|
||||
console.log("Hunt is not active, redirecting to waiting page");
|
||||
softRedirect("/waiting");
|
||||
return;
|
||||
}
|
||||
|
||||
// Only fetch team data if authenticated and hunt is active or status unknown
|
||||
if (status === "authenticated" && huntActive !== false) {
|
||||
fetchTeamData();
|
||||
loadAndEnhanceMap();
|
||||
fetchClues();
|
||||
}
|
||||
}, [
|
||||
status,
|
||||
huntActive,
|
||||
softRedirect,
|
||||
fetchTeamData,
|
||||
loadAndEnhanceMap,
|
||||
fetchClues,
|
||||
]);
|
||||
|
||||
// Add effect to enhance SVG once it's loaded and clues are fetched
|
||||
useEffect(() => {
|
||||
if (mapSvg && clues.length > 0) {
|
||||
// Let the DOM update first with the SVG content
|
||||
setTimeout(() => {
|
||||
enhanceSvgMap();
|
||||
}, 100);
|
||||
}
|
||||
}, [mapSvg, clues, enhanceSvgMap]);
|
||||
|
||||
// Function to check hunt status and timer
|
||||
async function checkHuntAndTimer() {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/hunt-status", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log("Hunt status check from map:", data);
|
||||
|
||||
// If hunt is not active, check if it has ended
|
||||
if (!data.active) {
|
||||
console.log("Hunt not active, checking if it ended...");
|
||||
|
||||
// Check if we had an active hunt before
|
||||
const timerData = getHuntTimer();
|
||||
|
||||
if (timerData && timerData.isActive) {
|
||||
// Hunt was active before but not now - it has ended
|
||||
console.log("Hunt has ended, redirecting to leaderboard");
|
||||
clearHuntTimer(); // Clean up all timer data
|
||||
|
||||
// Use the appropriate redirect method
|
||||
if (isMounted.current) {
|
||||
hardRedirect("/leaderboard?huntEnded=true");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise just show the waiting page
|
||||
if (isMounted.current) {
|
||||
setHuntActive(false);
|
||||
softRedirect("/waiting");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If timer endTime exists and has passed, redirect to leaderboard
|
||||
if (data.endTime && new Date(data.endTime) <= new Date()) {
|
||||
console.log("Timer expired, redirecting to leaderboard");
|
||||
clearHuntTimer(); // Clean up all timer data
|
||||
|
||||
if (isMounted.current) {
|
||||
softRedirect("/leaderboard?huntEnded=true");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, hunt is active and timer is running
|
||||
if (isMounted.current) {
|
||||
setHuntActive(true);
|
||||
|
||||
// Update hunt timer data with the server's values
|
||||
if (data.startTime) {
|
||||
setHuntTimer(data.startTime, data.endTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check hunt status:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Use team hunt start time for elapsed time calculation
|
||||
useEffect(() => {
|
||||
if (!teamData?.team) return;
|
||||
|
||||
// Get hunt timer data from the centralized service
|
||||
let timerData = getHuntTimer();
|
||||
|
||||
// If no timer data yet, fetch it
|
||||
if (!timerData) {
|
||||
fetchTeamHuntStartTime().then((startTimeStr) => {
|
||||
setHuntTimer(startTimeStr);
|
||||
timerData = getHuntTimer();
|
||||
startTimer(timerData);
|
||||
});
|
||||
} else {
|
||||
startTimer(timerData);
|
||||
}
|
||||
|
||||
function startTimer(timerData) {
|
||||
const timer = setInterval(() => {
|
||||
if (!isMounted.current) {
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh timer data on each interval to catch any updates
|
||||
const currentTimerData = getHuntTimer();
|
||||
if (!currentTimerData || !currentTimerData.isActive) {
|
||||
setElapsedTime("00:00:00");
|
||||
return;
|
||||
}
|
||||
|
||||
setElapsedTime(formatElapsedTime(currentTimerData.startTime));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, [teamData, fetchTeamHuntStartTime]);
|
||||
|
||||
// Check hunt status every second to ensure immediate updates
|
||||
useEffect(() => {
|
||||
const checkHuntStatus = async () => {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
try {
|
||||
const timestamp = Date.now(); // Add cache-busting timestamp
|
||||
const response = await fetch(`/api/hunt-status?t=${timestamp}`, {
|
||||
@@ -250,19 +429,25 @@ export default function MapPage() {
|
||||
// If hunt is not active, force redirect to waiting page
|
||||
if (!data.active) {
|
||||
console.log("Hunt not active, redirecting to waiting page");
|
||||
window.location.href = "/waiting";
|
||||
if (isMounted.current) {
|
||||
softRedirect("/waiting");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If timer endTime exists and has passed, redirect to leaderboard
|
||||
if (data.endTime && new Date(data.endTime) <= new Date()) {
|
||||
console.log("Timer expired, redirecting to leaderboard");
|
||||
window.location.href = "/leaderboard?huntEnded=true";
|
||||
if (isMounted.current) {
|
||||
softRedirect("/leaderboard?huntEnded=true");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, hunt is active and timer is running
|
||||
setHuntActive(true);
|
||||
if (isMounted.current) {
|
||||
setHuntActive(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check hunt status:", err);
|
||||
}
|
||||
@@ -272,7 +457,7 @@ export default function MapPage() {
|
||||
checkHuntStatus();
|
||||
const intervalId = setInterval(checkHuntStatus, 1000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
}, [softRedirect]);
|
||||
|
||||
if (status === "loading" || loading) {
|
||||
return (
|
||||
@@ -289,7 +474,7 @@ export default function MapPage() {
|
||||
<div className={styles["map-error"]}>
|
||||
<h2>Hunt Not Active</h2>
|
||||
<p>The treasure hunt has not started yet.</p>
|
||||
<button onClick={() => router.push("/waiting")}>
|
||||
<button onClick={() => softRedirect("/waiting")}>
|
||||
Go to Waiting Room
|
||||
</button>
|
||||
</div>
|
||||
@@ -311,7 +496,7 @@ export default function MapPage() {
|
||||
<div className={styles["map-error"]}>
|
||||
<h2>Error Loading Map</h2>
|
||||
<p>We couldn't load the treasure map. Please try again.</p>
|
||||
<button onClick={fetchTeamData}>Retry</button>
|
||||
<button onClick={loadAndEnhanceMap}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -329,19 +514,25 @@ export default function MapPage() {
|
||||
<div className={styles["team-code"]}>{teamData?.team?.code || ""}</div>
|
||||
</div>
|
||||
|
||||
{/* Main map content area - showing map as background */}
|
||||
{/* Main map content area - map fills the entire container */}
|
||||
<div className={styles["map-container"]} ref={mapContainerRef}>
|
||||
<div className={styles["treasure-map"]}>
|
||||
{/* Using background-image for the map SVG */}
|
||||
<div
|
||||
className={styles["map-background"]}
|
||||
role="img"
|
||||
aria-label="Treasure Map"
|
||||
></div>
|
||||
{mapSvg ? (
|
||||
<div
|
||||
className={styles["svg-container"]}
|
||||
dangerouslySetInnerHTML={{ __html: mapSvg }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={styles["map-background"]}
|
||||
role="img"
|
||||
aria-label="Treasure Map"
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation bar - Map as first button */}
|
||||
{/* Navigation bar at the bottom */}
|
||||
<nav className={styles["nav-bar"]}>
|
||||
<Link
|
||||
href="/map"
|
||||
|
||||
Reference in new issue
Block a user