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

-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/fill-database.sql" dialect="SQLite" />
<file url="file://$PROJECT_DIR$/init-schema.sql" dialect="SQLite" />
</component>
</project>
+16 -1
View File
@@ -2,6 +2,11 @@
import { useState, useEffect, useRef } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import {
setHuntTimer,
clearHuntTimer,
formatElapsedTime,
} from "@/utils/timerService";
export default function AdminDashboard() {
const { data: session, status } = useSession();
@@ -38,6 +43,9 @@ export default function AdminDashboard() {
const currentTime = Date.now();
const elapsed = Math.floor((currentTime - startTime) / 1000);
setElapsedTime(elapsed);
// Update global timer data for all clients
setHuntTimer(data.startTime, data.endTime);
}
// If there's an end time, set it
@@ -182,6 +190,9 @@ export default function AdminDashboard() {
setTimerEndTime(null);
}
// Update global timer data for all clients
setHuntTimer(data.startTime, data.endTime);
setError("");
} else {
setError(data.error || "Failed to start timer");
@@ -210,7 +221,11 @@ export default function AdminDashboard() {
if (data.success) {
setTimerActive(false);
setTimerEndTime(null);
setError("");
setElapsedTime(0);
formatTimeDisplay(0);
// Clear timer data for all clients
clearHuntTimer();
} else {
setError(data.error || "Failed to stop timer");
}
+3
View File
@@ -0,0 +1,3 @@
export default function ClueLayout({ children }) {
return <div className="clue-layout">{children}</div>;
}
+149 -134
View File
@@ -1,96 +1,90 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useHuntStatusCheck } from "@/hooks/useHuntStatusCheck";
import Link from "next/link";
import { use } from "react"; // Add this import for React.use()
import { useSession } from "next-auth/react";
import { formatElapsedTime, getHuntTimer } from "@/utils/timerService";
export default function CluePage({ params }) {
const { data: session, status } = useSession();
// Unwrap the params Promise using React.use()
const unwrappedParams = use(params);
const { clueId } = unwrappedParams;
const [clue, setClue] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [foundNow, setFoundNow] = useState(false);
const [elapsedTime, setElapsedTime] = useState("00:00:00");
const router = useRouter();
// Add this line to use the hunt status hook
const huntActive = useHuntStatusCheck();
const { status } = useSession();
const { clueId } = params;
useEffect(() => {
async function fetchClueDetails() {
if (status === "unauthenticated") {
router.push("/login");
return;
}
if (status === "loading") {
return;
}
// Fetch clue data
async function fetchClue() {
try {
// Try to check hunt status first with robust error handling
try {
const statusResponse = await fetch("/api/hunt-status", {
credentials: "include",
headers: { Accept: "application/json" },
});
// Read as text first
const statusText = await statusResponse.text();
try {
const statusData = JSON.parse(statusText);
if (!statusData.active && statusData.authenticated) {
router.push("/");
return;
}
} catch (parseError) {
console.error("Failed to parse hunt status response:", parseError);
// Continue anyway to try fetching clue details
}
} catch (statusErr) {
console.error("Error checking hunt status:", statusErr);
// Continue anyway to try fetching clue details
}
// Now fetch clue details with robust error handling
setLoading(true);
const response = await fetch(`/api/clues/${clueId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache, no-store, must-revalidate",
},
credentials: "include",
headers: { Accept: "application/json" },
});
// Read as text first
const responseText = await response.text();
// Try to parse as JSON
let data;
try {
data = JSON.parse(responseText);
} catch (parseError) {
console.error("Failed to parse clue details response:", parseError);
console.log("Response received:", responseText.slice(0, 200));
throw new Error("Invalid response from server. Please try again.");
if (!response.ok) {
if (response.status === 404) {
throw new Error("Clue not found");
} else {
const data = await response.json();
throw new Error(data.error || "Failed to fetch clue");
}
}
// Check for error in the response
if (data.error) {
throw new Error(data.error);
}
setClue(data.clue);
const data = await response.json();
setClue(data);
setFoundNow(data.foundNow);
setLoading(false);
} catch (err) {
console.error("Error fetching clue details:", err);
console.error("Error fetching clue:", err);
setError(err.message);
} finally {
setLoading(false);
}
}
if (clueId) {
fetchClueDetails();
fetchClue();
}, [clueId, router, status]);
// Update timer display
useEffect(() => {
// Set up timer display updates
const timerData = getHuntTimer();
if (!timerData || !timerData.isActive) {
return;
}
}, [clueId, router]);
const timer = setInterval(() => {
setElapsedTime(formatElapsedTime(timerData.startTime));
}, 1000);
// Initial set
setElapsedTime(formatElapsedTime(timerData.startTime));
return () => clearInterval(timer);
}, []);
if (loading) {
return (
<div className="clue-container">
<div className="loading">Loading clue details...</div>
<div className="loading">
<div className="loading-spinner"></div>
<p>Loading clue...</p>
</div>
</div>
);
}
@@ -130,6 +124,15 @@ export default function CluePage({ params }) {
return (
<div className="clue-container">
<div className="clue-scroll">
{foundNow && (
<div className="new-discovery">
<div className="discovery-icon">🎉</div>
<div className="discovery-text">New Clue Discovered!</div>
</div>
)}
<div className="timer-display">{elapsedTime}</div>
<h1 className="clue-title">{clue.title}</h1>
<div className="clue-content">
@@ -165,59 +168,13 @@ export default function CluePage({ params }) {
}
.clue-scroll {
background-color: #f5e6ca;
border: 2px solid #9c6b30;
background-color: #f8f3e3;
border-radius: 10px;
padding: 30px;
max-width: 800px;
width: 100%;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
max-width: 600px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
position: relative;
}
.clue-scroll::before,
.clue-scroll::after {
content: "";
position: absolute;
height: 30px;
width: 100%;
left: 0;
}
.clue-scroll::before {
top: 0;
background: linear-gradient(
180deg,
rgba(156, 107, 48, 0.2) 0%,
rgba(156, 107, 48, 0) 100%
);
border-radius: 8px 8px 0 0;
}
.clue-scroll::after {
bottom: 0;
background: linear-gradient(
0deg,
rgba(156, 107, 48, 0.2) 0%,
rgba(156, 107, 48, 0) 100%
);
border-radius: 0 0 8px 8px;
}
.clue-title {
color: #5d4037;
font-size: 2rem;
text-align: center;
margin-bottom: 20px;
font-family: "Pirates", cursive, serif;
border-bottom: 2px dashed #9c6b30;
padding-bottom: 15px;
}
.clue-content {
padding: 20px;
background-color: #f9f3e3;
border-radius: 8px;
margin-bottom: 20px;
border: 1px dashed #9c6b30;
}
@@ -253,51 +210,109 @@ export default function CluePage({ params }) {
padding: 10px 20px;
border: none;
border-radius: 5px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
font-weight: bold;
transition: all 0.2s;
}
.map-button {
background-color: #9c6b30;
background-color: #68543c;
color: white;
}
.home-button {
background-color: #5d4037;
background-color: #a98467;
color: white;
}
.map-button:hover,
.home-button:hover {
transform: translateY(-3px);
}
.map-button:hover {
background-color: #7d562a;
}
.home-button:hover {
background-color: #4a332e;
transform: scale(1.05);
}
.loading,
.error,
.not-found {
padding: 50px;
text-align: center;
border: 2px dashed #9c6b30;
border-radius: 8px;
margin: 30px 0;
color: #5d4037;
background: #f5e6ca;
font-size: 1.2rem;
padding: 30px;
}
.loading-spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #9c6b30;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.error {
border-color: #d32f2f;
color: #d32f2f;
}
.clue-title {
color: #68543c;
font-size: 2rem;
text-align: center;
margin-bottom: 20px;
border-bottom: 2px solid #9c6b30;
padding-bottom: 10px;
}
.timer-display {
position: absolute;
top: 15px;
right: 15px;
background-color: rgba(0, 0, 0, 0.1);
padding: 5px 10px;
border-radius: 5px;
font-family: monospace;
font-weight: bold;
font-size: 1rem;
}
.new-discovery {
background-color: #feefb3;
border: 1px solid #9f6000;
color: #9f6000;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
display: flex;
align-items: center;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
.discovery-icon {
font-size: 1.5rem;
margin-right: 10px;
}
.discovery-text {
font-weight: bold;
}
`}</style>
</div>
);
+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"
+79 -52
View File
@@ -1,70 +1,97 @@
import { queryOne, run } from "@/lib/db";
import { cookies } from "next/headers";
import { verifyToken } from "@/app/api/auth/[...nextauth]/route";
import { queryOne } from "@/lib/db";
import { getToken } from "next-auth/jwt";
export const dynamic = "force-dynamic"; // Disable caching
// GET endpoint to retrieve a specific clue
export async function GET(request, { params }) {
const { clueId } = params;
export async function GET(request, context) {
try {
// Check if the hunt is active first
const huntActiveSetting = await queryOne(
"SELECT value FROM settings WHERE key = 'hunt_active'"
);
const huntActive = huntActiveSetting && huntActiveSetting.value === "true";
const token = await getToken({
req: request,
secret:
process.env.NEXTAUTH_SECRET ||
"your-fallback-secret-should-be-at-least-32-chars",
});
// Only verify authentication when hunt is not active
if (!huntActive) {
const cookieStore = await cookies();
const sessionToken =
cookieStore.get("next-auth.session-token")?.value ||
cookieStore.get("__Secure-next-auth.session-token")?.value;
if (!sessionToken) {
return Response.json(
{ error: "Unauthorized", clue: null },
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
const userData = verifyToken(sessionToken);
if (!userData) {
return Response.json(
{ error: "Invalid session", clue: null },
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
// Admin can see clues even if hunt is not active
if (userData.role !== "admin") {
return Response.json(
{ error: "Hunt is not active", clue: null },
{ headers: { "Content-Type": "application/json" } }
);
}
if (!token) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// Fetch the specific clue
const clue = await queryOne("SELECT * FROM clues WHERE id = ?", [clueId]);
if (!clue) {
// Check if user has a team
if (!token.teamId) {
return Response.json(
{ error: "Clue not found", clue: null },
{ status: 404, headers: { "Content-Type": "application/json" } }
{ error: "You must be part of a team to view clues" },
{ status: 403 }
);
}
const { clueId } = context.params;
// Validate clueId is a number
if (isNaN(parseInt(clueId))) {
return Response.json({ error: "Invalid clue ID" }, { status: 400 });
}
// Get the specific clue
const clue = await queryOne(
"SELECT id, title, description, location, x_coord, y_coord FROM clues WHERE id = ?",
[clueId]
);
if (!clue) {
return Response.json({ error: "Clue not found" }, { status: 404 });
}
// Check if this team has found this clue before
const found = await queryOne(
"SELECT * FROM team_clues WHERE team_id = ? AND clue_id = ?",
[token.teamId, clueId]
);
// If not found yet, mark it as found now
if (!found) {
try {
// First ensure the team_clues table exists
await queryOne(`
CREATE TABLE IF NOT EXISTS team_clues (
team_id INTEGER NOT NULL,
clue_id INTEGER NOT NULL,
found_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (team_id, clue_id),
FOREIGN KEY (team_id) REFERENCES teams(id),
FOREIGN KEY (clue_id) REFERENCES clues(id)
)
`);
// Record that the team found this clue
await queryOne(
"INSERT INTO team_clues (team_id, clue_id) VALUES (?, ?)",
[token.teamId, clueId]
);
} catch (err) {
console.error("Error marking clue as found:", err);
// Continue even if there's an error recording the find
}
}
return Response.json(
{ clue },
{ headers: { "Content-Type": "application/json" } }
{
...clue,
found: !!found,
foundNow: !found,
},
{
headers: {
"Cache-Control": "no-store, no-cache, must-revalidate",
Pragma: "no-cache",
Expires: "0",
},
}
);
} catch (error) {
console.error("Error fetching clue details:", error);
console.error("Error fetching clue:", error);
return Response.json(
{ error: "Failed to fetch clue details: " + error.message, clue: null },
{ status: 500, headers: { "Content-Type": "application/json" } }
{ error: "Failed to fetch clue: " + error.message },
{ status: 500 }
);
}
}
+43 -107
View File
@@ -1,127 +1,63 @@
import { query, queryOne, run } from "@/lib/db";
import { cookies } from "next/headers";
import { verifyToken } from "@/app/api/auth/[...nextauth]/route";
import { query, queryOne } from "@/lib/db";
import { getToken } from "next-auth/jwt";
export const dynamic = "force-dynamic"; // Disable caching
// Helper function to ensure the clues table exists
async function ensureCluesTableExists() {
try {
await run(`
CREATE TABLE IF NOT EXISTS clues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL,
location TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
return true;
} catch (error) {
console.error("Error ensuring clues table exists:", error);
return false;
}
}
// Helper function to extract numeric value from clue title
function extractClueNumber(title) {
// Look for patterns like "Clue 1", "Clue 10", etc.
const match = title.match(/Clue\s+(\d+)/i);
// If we find a match, return the number, otherwise return Infinity (to sort to the end)
return match ? parseInt(match[1], 10) : Infinity;
}
// GET endpoint to retrieve all clues
export async function GET(request) {
try {
// First check if the hunt is active
const huntActiveSetting = await queryOne(
"SELECT value FROM settings WHERE key = 'hunt_active'"
);
const huntActive = huntActiveSetting && huntActiveSetting.value === "true";
const token = await getToken({
req: request,
secret:
process.env.NEXTAUTH_SECRET ||
"your-fallback-secret-should-be-at-least-32-chars",
});
// If hunt is active, allow access without authentication
if (!huntActive) {
// Only verify authentication when hunt is not active
const cookieStore = await cookies();
const sessionToken =
cookieStore.get("next-auth.session-token")?.value ||
cookieStore.get("__Secure-next-auth.session-token")?.value;
if (!sessionToken) {
return Response.json(
{ error: "Unauthorized", clues: [] },
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
const userData = verifyToken(sessionToken);
if (!userData) {
return Response.json(
{ error: "Invalid session", clues: [] },
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
// Admin can see clues even if hunt is not active
if (userData.role !== "admin") {
return Response.json(
{ error: "Hunt is not active", clues: [] },
{ headers: { "Content-Type": "application/json" } }
);
}
if (!token) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// Ensure the clues table exists
await ensureCluesTableExists();
// Fetch all clues without sorting in SQL
const clues = await query("SELECT * FROM clues");
// If no clues are found, insert sample clues (for testing purposes)
if (!clues || clues.length === 0) {
// Insert sample clues
await run(
"INSERT INTO clues (title, description, location) VALUES (?, ?, ?)",
["Clue 1", "This is the first clue description", "Location 1"]
);
await run(
"INSERT INTO clues (title, description, location) VALUES (?, ?, ?)",
["Clue 2", "This is the second clue description", "Location 2"]
);
await run(
"INSERT INTO clues (title, description, location) VALUES (?, ?, ?)",
["Clue 3", "This is the final clue description", "Location 3"]
);
// Fetch the inserted clues
const sampleClues = await query("SELECT * FROM clues");
// Sort clues based on numbering in title
sampleClues.sort(
(a, b) => extractClueNumber(a.title) - extractClueNumber(b.title)
);
// Check if user has a team
if (!token.teamId) {
return Response.json(
{ clues: sampleClues },
{ headers: { "Content-Type": "application/json" } }
{ error: "You must be part of a team to view clues" },
{ status: 403 }
);
}
// Sort clues based on numbering in title
clues.sort(
(a, b) => extractClueNumber(a.title) - extractClueNumber(b.title)
// Get all clues
const clues = await query(`
SELECT id, title, description, location, x_coord, y_coord
FROM clues
ORDER BY id ASC
`);
// For each clue, check if the team has found it
const cluesWithStatus = await Promise.all(
clues.map(async (clue) => {
const found = await queryOne(
"SELECT 1 FROM team_clues WHERE team_id = ? AND clue_id = ?",
[token.teamId, clue.id]
);
return {
...clue,
found: !!found,
};
})
);
return Response.json(
{ clues },
{ headers: { "Content-Type": "application/json" } }
);
return Response.json(cluesWithStatus, {
headers: {
"Cache-Control": "no-store, no-cache, must-revalidate",
Pragma: "no-cache",
Expires: "0",
},
});
} catch (error) {
console.error("Error fetching clues:", error);
return Response.json(
{ error: "Failed to fetch clues: " + error.message, clues: [] },
{ status: 500, headers: { "Content-Type": "application/json" } }
{ error: "Failed to fetch clues: " + error.message },
{ status: 500 }
);
}
}
+15 -2
View File
@@ -62,6 +62,7 @@ export async function GET(request) {
let isActive = false;
let startTime = null;
let endTime = null;
let source = "none";
// Check all possible active indicators in priority order
if (timerStatus && Number(timerStatus.active) === 1) {
@@ -69,27 +70,39 @@ export async function GET(request) {
isActive = true;
startTime = timerStatus.start_time;
endTime = timerStatus.end_time;
source = "timer";
} else if (huntStatus && huntStatus.is_active) {
console.log("Hunt active from hunt_status table");
isActive = true;
startTime = huntStatus.start_time;
endTime = huntStatus.end_time;
source = "hunt_status";
} else if (huntActiveSetting && huntActiveSetting.value === "true") {
console.log("Hunt active from settings table");
isActive = true;
// If we have no start time but hunt is active, use current time
if (!startTime) {
startTime = new Date().toISOString();
}
source = "settings";
} else if (oldHuntRecord && oldHuntRecord.active) {
console.log("Hunt active from hunts table");
isActive = true;
startTime = oldHuntRecord.start_time;
endTime = oldHuntRecord.end_time;
source = "hunts";
}
console.log("Hunt status check:", { isActive, startTime, endTime });
console.log("Hunt status check:", { isActive, startTime, endTime, source });
// Set extremely aggressive cache control headers
// Always include all timing data for consistency
return new Response(
JSON.stringify({
active: isActive,
startTime: startTime || null,
endTime: endTime || null,
source, // For debugging
timestamp: new Date().toISOString(), // For cache-busting
}),
{
status: 200,
+18 -9
View File
@@ -90,10 +90,12 @@ async function ensureSettingsTableExists() {
}
// Helper function to update hunt active status - ensure it updates all tables
async function updateHuntActiveStatus(isActive) {
async function updateHuntActiveStatus(isActive, startTime = null) {
try {
await ensureSettingsTableExists();
const now = startTime || new Date().toISOString();
// Update settings table - this is used by many endpoints to check status
await run("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", [
"hunt_active",
@@ -102,7 +104,6 @@ async function updateHuntActiveStatus(isActive) {
// Update hunt_status table for consistency
try {
const now = new Date().toISOString();
await run(`
CREATE TABLE IF NOT EXISTS hunt_status (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -121,11 +122,15 @@ async function updateHuntActiveStatus(isActive) {
[isActive ? 1 : 0, isActive ? now : null]
);
} else {
// Update existing record
await run(
"UPDATE hunt_status SET is_active = ?, start_time = ? WHERE id = 1",
[isActive ? 1 : 0, isActive ? now : null]
);
// Update existing record - but preserve start_time when stopping
if (isActive) {
await run(
"UPDATE hunt_status SET is_active = ?, start_time = ? WHERE id = 1",
[1, now]
);
} else {
await run("UPDATE hunt_status SET is_active = ? WHERE id = 1", [0]);
}
}
} catch (err) {
console.error("Error updating hunt_status table:", err);
@@ -289,7 +294,7 @@ export async function POST(request) {
);
// Update hunt active status to true
await updateHuntActiveStatus(true);
await updateHuntActiveStatus(true, startTime);
return Response.json({
success: true,
@@ -306,10 +311,13 @@ export async function POST(request) {
}
} else if (action === "stop") {
try {
// Get current time for the end timestamp
const endTime = new Date().toISOString();
// Update the most recent timer to inactive
await run(
"UPDATE hunt_timer SET active = 0, end_time = ? WHERE active = 1",
[new Date().toISOString()]
[endTime]
);
// Update hunt active status to false
@@ -318,6 +326,7 @@ export async function POST(request) {
return Response.json({
success: true,
message: "Timer stopped successfully",
endTime: endTime,
});
} catch (dbError) {
console.error("Database error stopping timer:", dbError);
+14 -2
View File
@@ -1,5 +1,5 @@
import { getToken } from "next-auth/jwt";
import { queryOne } from "@/lib/db";
import { queryOne, run } from "@/lib/db";
export async function GET(request, context) {
try {
@@ -14,7 +14,8 @@ export async function GET(request, context) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { teamId } = context.params;
// Properly await context.params
const { teamId } = await context.params;
// Validate that user belongs to this team
if (!token.teamId || parseInt(token.teamId, 10) !== parseInt(teamId, 10)) {
@@ -24,6 +25,17 @@ export async function GET(request, context) {
);
}
// First ensure the team_hunt_sessions table exists
await run(`
CREATE TABLE IF NOT EXISTS team_hunt_sessions (
team_id INTEGER PRIMARY KEY,
start_time TEXT NOT NULL,
initiated_by INTEGER,
FOREIGN KEY (team_id) REFERENCES teams(id),
FOREIGN KEY (initiated_by) REFERENCES users(id)
)
`);
// Get the team's hunt session from the database
const session = await queryOne(
"SELECT team_id, start_time FROM team_hunt_sessions WHERE team_id = ?",
+9 -7
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import styles from "./waiting.module.css";
import { setHuntTimer, clearHuntTimer } from "@/utils/timerService";
export default function WaitingPage() {
const router = useRouter();
@@ -52,11 +53,8 @@ export default function WaitingPage() {
if (teamHuntResponse.ok) {
const teamHuntData = await teamHuntResponse.json();
if (teamHuntData.startTime) {
// Store the start time for the map page
localStorage.setItem(
"teamHuntStartTime",
teamHuntData.startTime
);
// Store the start time in the centralized timer service
setHuntTimer(teamHuntData.startTime);
console.log("Team hunt is active! Redirecting to map...");
redirectAttempted = true;
@@ -92,6 +90,10 @@ export default function WaitingPage() {
// Hunt was active before but now it's not - it has ended
console.log("Hunt has ended! Showing leaderboard...");
setHuntEnded(true);
// Clear any timer data
clearHuntTimer();
// Don't redirect immediately to allow user to read the message
}
@@ -99,9 +101,9 @@ export default function WaitingPage() {
setLastHuntData(data);
if (data.active) {
// Store the global start time
// Store the global start time in the centralized timer service
if (data.startTime) {
localStorage.setItem("teamHuntStartTime", data.startTime);
setHuntTimer(data.startTime, data.endTime);
}
console.log("Hunt is active! Redirecting immediately...");
BIN
View File
Binary file not shown.
+74
View File
@@ -0,0 +1,74 @@
/**
* Centralized timer service to ensure consistent timing across the application
*/
// Store the hunt timer information in localStorage for persistence
export const setHuntTimer = (startTime, endTime = null, isActive = true) => {
const timerData = {
startTime,
endTime,
isActive,
lastUpdated: new Date().toISOString(),
};
localStorage.setItem("huntTimerData", JSON.stringify(timerData));
};
// Clear the hunt timer completely
export const clearHuntTimer = () => {
localStorage.removeItem("huntTimerData");
localStorage.removeItem("teamHuntStartTime"); // Remove legacy timer as well
};
// Get the current hunt timer data
export const getHuntTimer = () => {
try {
const timerData = localStorage.getItem("huntTimerData");
if (!timerData) {
// Try to migrate from old format if exists
const legacyStartTime = localStorage.getItem("teamHuntStartTime");
if (legacyStartTime) {
return {
startTime: legacyStartTime,
endTime: null,
isActive: true,
lastUpdated: new Date().toISOString(),
};
}
return null;
}
return JSON.parse(timerData);
} catch (error) {
console.error("Error parsing hunt timer data:", error);
return null;
}
};
// Calculate and format elapsed time from a given start time
export const formatElapsedTime = (startTimeStr) => {
if (!startTimeStr) return "00:00:00";
const startTime = new Date(startTimeStr).getTime();
const now = Date.now();
const elapsed = Math.floor((now - startTime) / 1000);
// Handle negative time (shouldn't happen in normal usage)
if (elapsed < 0) return "00:00:00";
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");
return `${hours}:${minutes}:${seconds}`;
};
// Hook function to use in components to get formatted elapsed time
export const useElapsedTime = (startTime, isActive = true) => {
if (!startTime || !isActive) return "00:00:00";
return formatElapsedTime(startTime);
};