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:
bobbert committed 2025-03-15 16:30:04 +00:00
1 parent e7ad53643c
commit 16319f1333
14 files changed
+791 -472

No files matched your search

+42 -13
View File
@@ -5,7 +5,7 @@
height: 100vh; /* Full viewport height */
overflow: hidden; /* Hide overflow at page level */
position: relative;
background-color: #69B5A8;
background-color: #69b5a8;
}
.map-banner {
@@ -59,40 +59,69 @@
/* Map container takes all available space */
.map-container {
flex: 1; /* Take all available space */
overflow: auto; /* Enable scrolling */
position: relative;
width: 100%;
height: 100%;
min-height: calc(100vh - 120px); /* Adjust for header and navbar height */
background-color: #69B5A8; /* Updated background color */
background-color: #69b5a8; /* Background color */
overflow: auto; /* Enable scrolling */
}
.treasure-map {
position: relative; /* Changed from absolute to relative */
width: 100%;
height: 100%;
min-width: 100%;
min-height: 100%;
}
/* SVG container for better scrolling and zooming */
.svg-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
}
/* New class for the background map image */
.map-background {
width: 100%;
height: 100%;
.svg-container svg {
min-width: 100%;
min-height: 100%;
width: auto;
height: auto;
display: block;
transform-origin: center center;
}
/* Map background - display as full-page map */
.map-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: url("/images/map.svg"); /* Updated path to the correct SVG file */
background-size: contain;
width: 100%;
height: 100%;
min-width: 100%;
min-height: 100%;
background-image: url("/images/map.svg");
background-size: contain; /* Change back to contain for proper aspect ratio */
background-position: center;
background-repeat: no-repeat;
z-index: 5; /* Ensure the map is above the background */
z-index: 5;
}
/* Circle elements in the SVG map - attach to a container class */
.svg-container circle {
cursor: pointer;
transition: fill 0.3s ease, transform 0.3s ease;
}
.svg-container circle:hover {
fill: #ffb74d !important;
transform: scale(1.1);
}
/* Remaining styles unchanged */
.map-svg {
width: 100% !important; /* Override Next.js Image default width */
height: auto !important; /* Override Next.js Image default height */
+329 -138
View File
@@ -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"