diff --git a/app/(main)/admin/page.js b/app/(main)/admin/page.js index 3fdf192..6a85bdb 100644 --- a/app/(main)/admin/page.js +++ b/app/(main)/admin/page.js @@ -122,7 +122,7 @@ export default function AdminDashboard() { const timerData = getHuntTimer(); if (timerData && timerData.startTime) { setTimerDisplay(formatElapsedTime(timerData.startTime)); - + // If there's an end time, check if we've reached it if (timerEndTime && Date.now() >= timerEndTime) { stopTimer(); diff --git a/app/(main)/clue/[clueId]/page.js b/app/(main)/clue/[clueId]/page.js index 61f6726..efb8ccf 100644 --- a/app/(main)/clue/[clueId]/page.js +++ b/app/(main)/clue/[clueId]/page.js @@ -32,6 +32,7 @@ export default function CluePage({ params }) { const [scanMessage, setScanMessage] = useState(null); const [lastRefresh, setLastRefresh] = useState(0); const [clueFound, setClueFound] = useState(false); + const [clueSkipped, setClueSkipped] = useState(false); // Add state for skipped status const [allClues, setAllClues] = useState([]); const [nextClueId, setNextClueId] = useState(null); const [nextClueAvailable, setNextClueAvailable] = useState(false); @@ -126,6 +127,7 @@ export default function CluePage({ params }) { if (isMounted) { setClue(data); setClueFound(data.found); + setClueSkipped(data.skipped); // Set skipped status from API response setLoading(false); setLastRefresh(Date.now()); } @@ -398,6 +400,56 @@ export default function CluePage({ params }) { }; }, []); + // Replace the previous clue function with skip clue functionality + const handleSkipClue = async () => { + // Show confirmation dialog + if ( + !confirm( + "Are you sure you want to skip this clue? You can come back to it later." + ) + ) { + return; + } + + try { + // Make an API call to mark this clue as skipped + const response = await fetch("/api/mark-clue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clueId: parseInt(clueId), + skipped: true, + }), + }); + + // Get the response data to handle errors better + const data = await response.json(); + + if (response.ok) { + setScanMessage("Clue skipped. You can return to it later."); + + // Make next clue available immediately + setNextClueAvailable(true); + + // Navigate to the next clue if available, otherwise go to map + if (nextClueId) { + router.push(`/clue/${nextClueId}`); + } else { + router.push("/map"); + } + } else { + console.error( + "Failed to skip clue:", + data.error || response.statusText + ); + setScanMessage(`Error: ${data.error || "Could not skip clue"}`); + } + } catch (err) { + console.error("Error skipping clue:", err); + setScanMessage("Error: Could not connect to server"); + } + }; + // Navigate to the next clue const handleNextClue = () => { if (nextClueId) { @@ -423,20 +475,6 @@ export default function CluePage({ params }) { } }; - // Navigate to the previous clue - const handlePrevClue = () => { - if (allClues.length > 0) { - const currentIndex = allClues.findIndex((c) => c.id === parseInt(clueId)); - if (currentIndex > 0) { - router.push(`/clue/${allClues[currentIndex - 1].id}`); - } else { - router.push("/map"); - } - } else { - router.push("/map"); - } - }; - // Function for manual refresh - for admins to test without waiting const handleManualRefresh = () => { setLastRefresh(0); @@ -541,7 +579,7 @@ export default function CluePage({ params }) { )}
- {/* Show a notification when the clue is already found */} + {/* Show notification based on found OR skipped status */} {clueFound && (
✓
@@ -550,6 +588,15 @@ export default function CluePage({ params }) {
)} + + {clueSkipped && !clueFound && ( +
+
↷
+
+ You skipped this clue earlier. Try to solve it now! +
+
+ )} {/* Admin tools section - only visible to admins */} {isAdmin && ( @@ -598,8 +645,8 @@ export default function CluePage({ params }) { {/* Navigation Buttons */}
-
+ + ); } diff --git a/app/(main)/stats/stats.module.css b/app/(main)/stats/stats.module.css index 18b5b9a..a270d03 100644 --- a/app/(main)/stats/stats.module.css +++ b/app/(main)/stats/stats.module.css @@ -16,12 +16,14 @@ .title { font-size: 1.5rem; margin: 0 0 5px 0; + color: white; } .teamName { font-size: 1.2rem; margin: 0; font-weight: normal; + color: white; } .statsCard { diff --git a/app/api/clues/[clueId]/route.js b/app/api/clues/[clueId]/route.js index 5eab2ff..689c8ce 100644 --- a/app/api/clues/[clueId]/route.js +++ b/app/api/clues/[clueId]/route.js @@ -43,9 +43,9 @@ export async function GET(request, context) { 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 = ?", + // Check if this team has found or skipped this clue before + const clueStatus = await queryOne( + "SELECT found_at, skipped FROM team_clues WHERE team_id = ? AND clue_id = ?", [token.teamId, clueId] ); @@ -66,7 +66,8 @@ export async function GET(request, context) { return Response.json( { ...clue, - found: !!found, // Only mark as found if it was previously found + found: !!clueStatus && !(clueStatus.skipped === 1), // Mark as found only if it exists and is not skipped + skipped: !!clueStatus && clueStatus.skipped === 1, // Add skipped status isAdmin: token.role === "admin", // Add flag so client knows user is admin }, { headers: cacheHeaders } diff --git a/app/api/clues/route.js b/app/api/clues/route.js index 034058c..d2e40af 100644 --- a/app/api/clues/route.js +++ b/app/api/clues/route.js @@ -1,4 +1,4 @@ -import { query, queryOne } from "@/lib/db"; +import { query, queryOne, run } from "@/lib/db"; import { getToken } from "next-auth/jwt"; export const dynamic = "force-dynamic"; // Disable caching @@ -13,18 +13,50 @@ export async function GET(request) { }); if (!token) { + console.log("API: Unauthorized request to /api/clues - no token"); return Response.json({ error: "Unauthorized" }, { status: 401 }); } // Check if user has a team if (!token.teamId) { + console.log("API: User without team tried to access clues"); return Response.json( { error: "You must be part of a team to view clues" }, { status: 403 } ); } - // Get all clues with explicit ordering - removed coordinates + // First, ensure the team_clues table has the 'skipped' column + try { + // Check if team_clues table exists, create it if it doesn't + await run(` + 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) + ) + `); + + // Check if skipped column exists + const tableInfo = await query("PRAGMA table_info(team_clues)"); + const hasSkippedColumn = tableInfo.some((col) => col.name === "skipped"); + + if (!hasSkippedColumn) { + console.log("Adding 'skipped' column to team_clues table"); + await run( + "ALTER TABLE team_clues ADD COLUMN skipped BOOLEAN DEFAULT 0" + ); + } + } catch (schemaError) { + console.error("Error ensuring database schema:", schemaError); + // Continue execution - if this fails, the main query might still work + // with existing columns + } + + // Get all clues with explicit ordering const clues = await query(` SELECT id, title, description, location FROM clues @@ -34,21 +66,49 @@ export async function GET(request) { // 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] - ); + let clueStatus; + try { + clueStatus = await queryOne( + "SELECT found_at, skipped FROM team_clues WHERE team_id = ? AND clue_id = ?", + [token.teamId, clue.id] + ); + } catch (error) { + console.error(`Error querying status for clue ${clue.id}:`, error); + // Fallback if query fails (e.g., skipped column still doesn't exist) + clueStatus = await queryOne( + "SELECT found_at FROM team_clues WHERE team_id = ? AND clue_id = ?", + [token.teamId, clue.id] + ); + } return { ...clue, - found: !!found, + found: !!clueStatus && !(clueStatus.skipped === 1), + skipped: !!clueStatus && clueStatus.skipped === 1, + available: false, // Will be determined after processing all clues }; }) ); - console.log(`Returning ${cluesWithStatus.length} clues`); + // Determine which clues should be available + let firstUnavailableFound = false; + const finalClues = cluesWithStatus.map((clue, index, arr) => { + if (clue.found || clue.skipped) { + return { ...clue, available: true }; + } - return Response.json(cluesWithStatus, { + // If we haven't found an unavailable clue yet, this one is available + if (!firstUnavailableFound) { + firstUnavailableFound = true; + return { ...clue, available: true }; + } + + return { ...clue, available: false }; + }); + + console.log(`API: Returning ${finalClues.length} clues`); + + return Response.json(finalClues, { headers: { "Cache-Control": "no-store, no-cache, must-revalidate", Pragma: "no-cache", diff --git a/app/api/mark-clue/route.js b/app/api/mark-clue/route.js index 9ac2b0b..b5efebf 100644 --- a/app/api/mark-clue/route.js +++ b/app/api/mark-clue/route.js @@ -1,4 +1,4 @@ -import { query, queryOne } from "@/lib/db"; +import { query, queryOne, run } from "@/lib/db"; // Add 'run' to the import import { getToken } from "next-auth/jwt"; export const dynamic = "force-dynamic"; // Disable caching @@ -25,7 +25,7 @@ export async function POST(request) { } // Parse request body - const { clueId } = await request.json(); + const { clueId, skipped = false } = await request.json(); // Validate clueId is a number if (isNaN(parseInt(clueId))) { @@ -38,49 +38,71 @@ export async function POST(request) { return Response.json({ error: "Clue not found" }, { status: 404 }); } - // Check if clue was already found - const alreadyFound = await queryOne( + // Check if clue was already found or skipped + const alreadyProcessed = await queryOne( "SELECT * FROM team_clues WHERE team_id = ? AND clue_id = ?", [token.teamId, clueId] ); - if (alreadyFound) { + if (alreadyProcessed) { + // If skipping is requested, update the record regardless of previous state + if (skipped) { + await run( + "UPDATE team_clues SET skipped = 1 WHERE team_id = ? AND clue_id = ?", + [token.teamId, clueId] + ); + + return Response.json({ + success: true, + message: "Clue marked as skipped", + skipped: true, + }); + } + + // For non-skip requests on already processed clues return Response.json({ success: true, - message: "This clue was already marked as found", - alreadyFound: true, + message: "This clue was already processed", + alreadyFound: !(alreadyProcessed.skipped === 1), + alreadySkipped: alreadyProcessed.skipped === 1, }); } - // Create team_clues table if it doesn't exist - await queryOne(` + // Ensure the team_clues table has a skipped column + await run(` CREATE TABLE IF NOT EXISTS team_clues ( team_id INTEGER NOT NULL, clue_id INTEGER NOT NULL, found_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + skipped BOOLEAN DEFAULT 0, 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, - ]); + // Add column if it doesn't exist (safe operation) + try { + await run("ALTER TABLE team_clues ADD COLUMN skipped BOOLEAN DEFAULT 0"); + } catch (e) { + // Column likely already exists, continue + } - console.log(`Clue ${clueId} marked as found for team ${token.teamId}`); + // Record that the team found or skipped this clue + await run( + "INSERT INTO team_clues (team_id, clue_id, skipped) VALUES (?, ?, ?)", + [token.teamId, clueId, skipped ? 1 : 0] + ); - // Return successful response return Response.json({ success: true, - message: "Clue marked as found successfully", + message: skipped ? "Clue skipped successfully" : "Clue marked as found", + skipped: skipped, }); } catch (error) { - console.error("Error marking clue as found:", error); + console.error("Error marking clue:", error); return Response.json( - { error: "Failed to mark clue as found: " + error.message }, + { error: "Failed to process clue: " + error.message }, { status: 500 } ); } diff --git a/app/api/team-stats/route.js b/app/api/team-stats/route.js index 41f5eeb..a07f67a 100644 --- a/app/api/team-stats/route.js +++ b/app/api/team-stats/route.js @@ -42,12 +42,25 @@ export async function GET(request) { const totalClues = clueCountResult?.total || 0; // Get all found clues with timestamps, ordered by found time + // Only include clues that aren't marked as skipped const foundClues = await query( ` SELECT c.id, c.title, tc.found_at FROM clues c JOIN team_clues tc ON c.id = tc.clue_id - WHERE tc.team_id = ? + WHERE tc.team_id = ? AND (tc.skipped = 0 OR tc.skipped IS NULL) + ORDER BY tc.found_at ASC + `, + [token.teamId] + ); + + // Get skipped clues separately + const skippedClues = await query( + ` + SELECT c.id, c.title, tc.found_at + FROM clues c + JOIN team_clues tc ON c.id = tc.clue_id + WHERE tc.team_id = ? AND tc.skipped = 1 ORDER BY tc.found_at ASC `, [token.teamId] @@ -69,12 +82,12 @@ export async function GET(request) { ); // If there are unfound clues, the ones after the first unfound are "skipped" - let skippedClues = 0; + let skippedCluesCount = 0; if (unfoundClueIds.length > 0) { const lowestUnfoundId = Math.min(...unfoundClueIds); // Skipped clues are found clues with IDs higher than the lowest unfound - skippedClues = foundClues.filter((c) => c.id > lowestUnfoundId).length; + skippedCluesCount = foundClues.filter((c) => c.id > lowestUnfoundId).length; } // Get current team rank @@ -107,12 +120,13 @@ export async function GET(request) { totalClues, foundClues, remainingClues: totalClues - foundClues.length, - skippedClues, + skippedCluesCount, currentRank, progress: totalClues > 0 ? Math.round((foundClues.length / totalClues) * 100) : 0, }; + // Return only the teamStats object instead of trying to include undefined variables return Response.json(teamStats); } catch (error) { console.error("Error fetching team stats:", error); diff --git a/app/api/teams/create/route.js b/app/api/teams/create/route.js index 7e50a9f..be7a613 100644 --- a/app/api/teams/create/route.js +++ b/app/api/teams/create/route.js @@ -1,10 +1,9 @@ import { getToken } from "next-auth/jwt"; -import { query, queryOne, run } from "@/lib/db"; -import { nanoid } from "nanoid"; +import { queryOne, run } from "@/lib/db"; export async function POST(request) { try { - // Get user token from session + // Verify user authentication const token = await getToken({ req: request, secret: @@ -13,30 +12,23 @@ export async function POST(request) { }); if (!token) { - return Response.json({ error: "Invalid session" }, { status: 401 }); + return Response.json({ error: "Unauthorized" }, { status: 401 }); } - // Extract user ID from the token + // Get user ID from token const userId = token.id; - if (!userId) { - return Response.json( - { error: "Invalid user information in session" }, - { status: 401 } - ); - } - // Parse request body - const body = await request.json(); - const { name } = body; + // Get team name from request body + const { name } = await request.json(); - if (!name || name.trim() === "") { + if (!name || !name.trim()) { return Response.json({ error: "Team name is required" }, { status: 400 }); } // Generate a unique team code - const code = nanoid(6).toUpperCase(); + const code = generateTeamCode(); - // Create the team - updated schema to match actual database (no created_by column) + // Create teams table if it doesn't exist await run(` CREATE TABLE IF NOT EXISTS teams ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -50,6 +42,7 @@ export async function POST(request) { const existingTeam = await queryOne("SELECT id FROM teams WHERE name = ?", [ name, ]); + if (existingTeam) { return Response.json( { error: "Team name already exists" }, @@ -57,7 +50,7 @@ export async function POST(request) { ); } - // Insert team without created_by field + // Insert team const result = await run("INSERT INTO teams (name, code) VALUES (?, ?)", [ name, code, @@ -71,19 +64,26 @@ export async function POST(request) { // Update the user's team_id await run("UPDATE users SET team_id = ? WHERE id = ?", [teamId, userId]); - // Return a more complete response with all needed data + // Return success with team info return Response.json({ success: true, teamId, - teamName: name, - code, message: "Team created successfully", }); } catch (error) { - console.error("Create team error:", error); + console.error("Error creating team:", error); return Response.json( { error: "Failed to create team: " + error.message }, { status: 500 } ); } } + +function generateTeamCode() { + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let result = ""; + for (let i = 0; i < 6; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)); + } + return result; +} diff --git a/app/api/teams/leave/route.js b/app/api/teams/leave/route.js new file mode 100644 index 0000000..e69de29 diff --git a/clue_hunt.db b/clue_hunt.db index 1848e17..371a853 100644 Binary files a/clue_hunt.db and b/clue_hunt.db differ diff --git a/components/CreateTeamForm.js b/components/CreateTeamForm.js new file mode 100644 index 0000000..9d89151 --- /dev/null +++ b/components/CreateTeamForm.js @@ -0,0 +1,56 @@ +"use client"; +import { useState } from "react"; + +export default function CreateTeamForm({ onSubmit }) { + const [name, setName] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!name.trim()) { + return; // Don't submit if name is empty + } + + setSubmitting(true); + + try { + await onSubmit({ name }); + } catch (error) { + console.error("Error submitting form:", error); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Enter team name" + required + /> +
+ + +
+ ); +} diff --git a/components/TeamList.js b/components/TeamList.js new file mode 100644 index 0000000..d1bb51e --- /dev/null +++ b/components/TeamList.js @@ -0,0 +1,109 @@ +"use client"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function TeamList({ teams, onJoinTeam, onRefresh }) { + const [joinCode, setJoinCode] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const handleJoinTeam = async (e) => { + e.preventDefault(); + + if (!joinCode.trim()) { + setError("Please enter a team code"); + return; + } + + setError(""); + setLoading(true); + + try { + const response = await fetch("/api/teams/join", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: joinCode.trim() }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to join team"); + } + + // Call the onJoinTeam callback with the joined team + if (onJoinTeam) { + onJoinTeam(data); + } + + // Refresh the team list + if (onRefresh) { + onRefresh(); + } + + router.refresh(); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+

Join Existing Team

+ +
+
+ + setJoinCode(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Enter team code" + required + /> +
+ + + + {error &&

{error}

} +
+ + {teams && teams.length > 0 ? ( +
+

Available Teams:

+ +
+ ) : ( +

No teams available to join

+ )} +
+ ); +} diff --git a/public/images/map.svg b/public/images/map.svg index d3806eb..f433d30 100644 --- a/public/images/map.svg +++ b/public/images/map.svg @@ -23,84 +23,48 @@ - - - - - - + - - + - - + - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/styles/globals.css b/styles/globals.css new file mode 100644 index 0000000..e69de29 diff --git a/utils/map-populate.js b/utils/map-populate.js index 1c1c57b..483307b 100644 --- a/utils/map-populate.js +++ b/utils/map-populate.js @@ -409,8 +409,10 @@ export function updateCluePointStatus(svgElement, clues) { `Found ${cluePoints.length} clue points and ${clickAreas.length} click areas to update` ); - // Find the first unfound clue (the current clue) - const currentIndex = clues.findIndex((clue) => !clue.found); + // Find the first unfound and unskipped clue + const currentIndex = clues.findIndex( + (clue) => !clue.found && !clue.skipped + ); const currentClueId = currentIndex !== -1 ? clues[currentIndex].id : null; console.log(`Current clue ID: ${currentClueId}`); @@ -439,15 +441,22 @@ export function updateCluePointStatus(svgElement, clues) { if (clue.found) { point.classList.add("clue-point-found"); tooltip.textContent = `Clue ${clueId}: ${clue.title || "Completed"}`; + } else if (clue.skipped) { + // Explicitly mark skipped clues with blue color + point.classList.add("clue-point-skipped"); + tooltip.textContent = `Skipped Clue ${clueId}: ${ + clue.title || "Skipped" + }`; } else if (clueId === currentClueId) { point.classList.add("clue-point-current"); tooltip.textContent = `Current Clue: ${ clue.title || "Go to this location" }`; - } else if (clueId < currentClueId) { - point.classList.add("clue-point-skipped"); - tooltip.textContent = `Skipped Clue ${clueId}: ${ - clue.title || "Skipped" + } else if (clue.available) { + // Available but not current - could be previous clue or next after skipped + point.classList.add("clue-point-current"); + tooltip.textContent = `Available Clue ${clueId}: ${ + clue.title || "Available" }`; } else { point.classList.add("clue-point-locked"); @@ -455,7 +464,11 @@ export function updateCluePointStatus(svgElement, clues) { } // Store availability data - const isAvailable = clue.found || clueId === currentClueId; + const isAvailable = + clue.found || + clue.skipped || + clueId === currentClueId || + clue.available; point.setAttribute("data-available", isAvailable.toString()); // Make sure tooltip is attached