mirror of
https://github.com/mudabbir-ahmad/peworkshop.git
synced 2026-10-07 19:50:20 +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
Generated
-7
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function ClueLayout({ children }) {
|
||||
return <div className="clue-layout">{children}</div>;
|
||||
}
|
||||
+149
-134
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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...");
|
||||
|
||||
Binary file not shown.
@@ -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);
|
||||
};
|
||||
Reference in new issue
Block a user