diff --git a/app/(main)/admin/clues/page.js b/app/(main)/admin/clues/page.js new file mode 100644 index 0000000..24137e4 --- /dev/null +++ b/app/(main)/admin/clues/page.js @@ -0,0 +1,468 @@ +"use client"; +import { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; + +export default function AdminCluesPage() { + const { data: session, status } = useSession(); + const [clues, setClues] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const router = useRouter(); + + // Form state for adding new clues - removed coordinates + const [newClue, setNewClue] = useState({ + title: "", + description: "", + location: "", + }); + + // Check if user is admin + useEffect(() => { + if (status === "authenticated") { + if (session?.user?.role !== "admin") { + router.push("/team-selection"); + } else { + fetchClues(); + } + } else if (status === "unauthenticated") { + router.push("/login"); + } + }, [session, status, router]); + + // Fetch all clues + const fetchClues = async () => { + try { + setLoading(true); + const response = await fetch("/api/admin/clues", { + method: "GET", + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + credentials: "include", + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.error || `Failed to fetch clues (${response.status})` + ); + } + + const data = await response.json(); + + // Validate the response data + if (!Array.isArray(data)) { + throw new Error("Invalid response format from server"); + } + + setClues(data); + setError(""); + } catch (err) { + console.error("Error fetching clues:", err); + setError(err.message); + } finally { + setLoading(false); + } + }; + + // Handle input change for new clue form + const handleInputChange = (e) => { + const { name, value } = e.target; + setNewClue((prev) => ({ + ...prev, + [name]: value, + })); + }; + + // Handle form submission to add new clue + const handleAddClue = async (e) => { + e.preventDefault(); + try { + setLoading(true); + const response = await fetch("/api/admin/clues", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(newClue), + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to add clue"); + } + + // Refresh the clue list + fetchClues(); + + // Reset the form - removed coordinates + setNewClue({ + title: "", + description: "", + location: "", + }); + + setError(""); + } catch (err) { + console.error("Error adding clue:", err); + setError(err.message); + } finally { + setLoading(false); + } + }; + + // Handle deleting a clue + const handleDeleteClue = async (clueId) => { + if (!confirm("Are you sure you want to delete this clue?")) return; + + try { + setLoading(true); + const response = await fetch(`/api/admin/clues/${clueId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to delete clue"); + } + + // Refresh the clue list + fetchClues(); + setError(""); + } catch (err) { + console.error("Error deleting clue:", err); + setError(err.message); + } finally { + setLoading(false); + } + }; + + if (status === "loading" || loading) { + return
Loading...
; + } + + if (status === "unauthenticated") { + return null; // Will redirect via useEffect + } + + return ( +
+
+
Manage Clues
+
+
+ +
+ {error &&

{error}

} + + {/* Add New Clue Form - removed coordinate fields */} +
+

Add New Clue

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + {/* Existing Clues List - updated to remove coordinates */} +
+

Existing Clues

+ {clues.length === 0 ? ( +

No clues found.

+ ) : ( +
+ {clues.map((clue) => ( +
+
+

{clue.title}

+ +
+

{clue.description}

+ {clue.location && ( +

+ Location: {clue.location} +

+ )} +

+ Position: Clue #{clue.id} in sequence +

+
+ ))} +
+ )} +
+
+ + {/* Admin Navigation Bar */} +
+ + + +
+ + +
+ ); +} diff --git a/app/(main)/admin/leaderboard/page.js b/app/(main)/admin/leaderboard/page.js new file mode 100644 index 0000000..e1042b6 --- /dev/null +++ b/app/(main)/admin/leaderboard/page.js @@ -0,0 +1,360 @@ +"use client"; +import { useState, useEffect } from "react"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; + +export default function AdminLeaderboardPage() { + const { data: session, status } = useSession(); + const [teams, setTeams] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const router = useRouter(); + + // Check if user is admin + useEffect(() => { + if (status === "authenticated") { + if (session?.user?.role !== "admin") { + router.push("/team-selection"); + } else { + fetchLeaderboardData(); + } + } else if (status === "unauthenticated") { + router.push("/login"); + } + }, [session, status, router]); + + // Fetch leaderboard data + const fetchLeaderboardData = async () => { + try { + setLoading(true); + setError(""); + + // This is a placeholder - you'll need to implement the actual API + // For now, we'll just set dummy data + const dummyTeams = [ + { + id: 1, + name: "Team Alpha", + code: "ALPHA1", + memberCount: 4, + cluesFound: 5, + totalClues: 10, + lastActivity: new Date().toISOString(), + }, + { + id: 2, + name: "Team Beta", + code: "BETA12", + memberCount: 3, + cluesFound: 8, + totalClues: 10, + lastActivity: new Date().toISOString(), + }, + { + id: 3, + name: "Team Gamma", + code: "GAMMA3", + memberCount: 5, + cluesFound: 2, + totalClues: 10, + lastActivity: new Date().toISOString(), + }, + ]; + + setTeams(dummyTeams); + + // Uncomment this when API is ready + /* + const response = await fetch("/api/admin/leaderboard", { + method: "GET", + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache, no-store, must-revalidate" + }, + credentials: "include" + }); + + if (!response.ok) { + throw new Error("Failed to fetch leaderboard data"); + } + + const data = await response.json(); + setTeams(data); + */ + } catch (err) { + console.error("Error fetching leaderboard data:", err); + setError(err.message); + } finally { + setLoading(false); + } + }; + + if (status === "loading" || loading) { + return
Loading...
; + } + + if (status === "unauthenticated") { + return null; // Will redirect via useEffect + } + + return ( +
+
+
Admin Leaderboard
+
+
+ +
+ {error &&

{error}

} + +
+

Team Progress

+ + + + {teams.length === 0 ? ( +

No team data available.

+ ) : ( +
+ + + + + + + + + + + + + {teams.map((team, index) => ( + + + + + + + + + ))} + +
RankTeam NameMembersProgressLast ActivityActions
{index + 1} + {team.name} ({team.code}) + {team.memberCount} + {team.cluesFound} / {team.totalClues} + + {team.lastActivity + ? new Date(team.lastActivity).toLocaleString() + : "Never"} + + +
+
+ )} +
+
+ + {/* Admin Navigation Bar */} +
+ + + +
+ + +
+ ); +} diff --git a/app/(main)/admin/page.js b/app/(main)/admin/page.js index 9056d33..59e66fc 100644 --- a/app/(main)/admin/page.js +++ b/app/(main)/admin/page.js @@ -336,6 +336,31 @@ export default function AdminDashboard() { + {/* Admin Navigation Bar */} +
+ + + +
+ ); diff --git a/app/(main)/map/page.js b/app/(main)/map/page.js index c48a356..bc02ae3 100644 --- a/app/(main)/map/page.js +++ b/app/(main)/map/page.js @@ -200,17 +200,20 @@ export default function MapPage() { // Convert NodeList to Array for easier manipulation Array.from(circleSvgElements).forEach((circle, index) => { - // Only process if we have a clue for this index - if (clues[index]) { + // Only process if we have a clue for this index and it's within bounds + if (index < clues.length) { + // Map SVG circle at position index to the clue at index + // This uses sequential order rather than coordinates + const clue = clues[index]; + // Check if this clue is available (either found or next in sequence) - const isAvailable = - clues[index].found || index <= nextAvailableClueIndex; + const isAvailable = clue.found || index <= nextAvailableClueIndex; // Make circle interactive circle.style.cursor = isAvailable ? "pointer" : "not-allowed"; // Set appropriate colors - if (clues[index].found) { + if (clue.found) { // Found clues are green circle.style.fill = "#4caf50"; circle.style.stroke = "#2e7d32"; @@ -232,8 +235,8 @@ export default function MapPage() { e.stopPropagation(); if (isAvailable) { - console.log(`Navigating to clue ${clues[index].id}`); - router.push(`/clue/${clues[index].id}`); + console.log(`Navigating to clue ${clue.id}`); + router.push(`/clue/${clue.id}`); } else { alert("Complete previous clues first to unlock this one!"); } @@ -247,7 +250,7 @@ export default function MapPage() { }; circle.onmouseleave = () => { - if (clues[index].found) { + if (clue.found) { circle.style.fill = "#4caf50"; // Back to green for found clues } else if (isAvailable) { circle.style.fill = "#ff9800"; // Back to orange for available clues @@ -256,13 +259,13 @@ export default function MapPage() { } }; - // Add tooltip/title + // Add tooltip/title with actual clue ID and position information const label = document.createElementNS( "http://www.w3.org/2000/svg", "title" ); label.textContent = `Clue #${index + 1}: ${ - clues[index].title || "Mystery clue" + clue.title || "Mystery clue" }${!isAvailable ? " (Locked)" : ""}`; circle.appendChild(label); } @@ -409,26 +412,26 @@ export default function MapPage() { try { // First try to get timer data from the centralized service let timerData = getHuntTimer(); - + // If no timer data yet, fetch it from API if (!timerData || !timerData.startTime) { console.log("No timer data found, fetching from API..."); const startTimeStr = await fetchTeamHuntStartTime(); console.log("Got start time:", startTimeStr); - + if (startTimeStr) { setHuntTimer(startTimeStr); timerData = getHuntTimer(); } } - + // Set up interval for timer updates const timerInterval = setInterval(() => { if (!isMounted.current) { clearInterval(timerInterval); return; } - + const currentTimerData = getHuntTimer(); if (currentTimerData && currentTimerData.startTime) { const formattedTime = formatElapsedTime(currentTimerData.startTime); @@ -438,12 +441,12 @@ export default function MapPage() { setElapsedTime("00:00:00"); } }, 1000); - + // Initial timer update if (timerData && timerData.startTime) { setElapsedTime(formatElapsedTime(timerData.startTime)); } - + return () => { clearInterval(timerInterval); }; @@ -451,7 +454,7 @@ export default function MapPage() { console.error("Error initializing timer:", error); } }; - + initializeTimer(); }, []); // Empty dependency array since fetchTeamHuntStartTime is defined inside the component @@ -459,7 +462,7 @@ export default function MapPage() { 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}`, { @@ -471,15 +474,15 @@ export default function MapPage() { }, credentials: "include", }); - + if (!response.ok) { console.error("Failed to fetch hunt status:", response.statusText); return; } - + const data = await response.json(); console.log("Map page hunt status check:", data); - + // If hunt is not active, force redirect to waiting page if (!data.active) { console.log("Hunt not active, redirecting to waiting page"); @@ -488,7 +491,7 @@ export default function MapPage() { } 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"); @@ -497,7 +500,7 @@ export default function MapPage() { } return; } - + // Otherwise, hunt is active and timer is running if (isMounted.current) { setHuntActive(true); @@ -506,11 +509,11 @@ export default function MapPage() { console.error("Failed to check hunt status:", err); } }; - + // Check immediately and every second checkHuntStatus(); const intervalId = setInterval(checkHuntStatus, 1000); - + return () => clearInterval(intervalId); }, [softRedirect]); diff --git a/app/api/admin/clues/[clueId]/route.js b/app/api/admin/clues/[clueId]/route.js new file mode 100644 index 0000000..8759173 --- /dev/null +++ b/app/api/admin/clues/[clueId]/route.js @@ -0,0 +1,167 @@ +import { getToken } from "next-auth/jwt"; +import { queryOne, run } from "@/lib/db"; + +export const dynamic = "force-dynamic"; // Disable caching + +// Ensure the user is an admin +async function verifyAdmin(request) { + const token = await getToken({ + req: request, + secret: + process.env.NEXTAUTH_SECRET || + "your-fallback-secret-should-be-at-least-32-chars", + }); + + if (!token) { + return { authorized: false, error: "Unauthorized", status: 401 }; + } + + if (token.role !== "admin") { + return { authorized: false, error: "Admin access required", status: 403 }; + } + + return { authorized: true, token }; +} + +// DELETE - Remove a specific clue +export async function DELETE(request, context) { + try { + // Verify admin access + const { authorized, error, status } = await verifyAdmin(request); + if (!authorized) { + return Response.json({ error }, { status }); + } + + const { clueId } = context.params; + + // Validate clueId is a number + if (isNaN(parseInt(clueId))) { + return Response.json({ error: "Invalid clue ID" }, { status: 400 }); + } + + // Check if clue exists + const clue = await queryOne("SELECT id FROM clues WHERE id = ?", [clueId]); + if (!clue) { + return Response.json({ error: "Clue not found" }, { status: 404 }); + } + + // Delete clue record + await run("DELETE FROM clues WHERE id = ?", [clueId]); + + // Also delete any references in team_clues table + 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) + ) + `); + await run("DELETE FROM team_clues WHERE clue_id = ?", [clueId]); + + return Response.json({ + success: true, + message: "Clue deleted successfully", + }); + } catch (error) { + console.error("Admin clue deletion error:", error); + return Response.json( + { error: "Failed to delete clue: " + error.message }, + { status: 500 } + ); + } +} + +// GET - Get a specific clue +export async function GET(request, context) { + try { + // Verify admin access + const { authorized, error, status } = await verifyAdmin(request); + if (!authorized) { + return Response.json({ error }, { status }); + } + + 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 - removed coordinates + const clue = await queryOne( + "SELECT id, title, description, location FROM clues WHERE id = ?", + [clueId] + ); + + if (!clue) { + return Response.json({ error: "Clue not found" }, { status: 404 }); + } + + return Response.json(clue); + } catch (error) { + console.error("Admin clue fetch error:", error); + return Response.json( + { error: "Failed to fetch clue: " + error.message }, + { status: 500 } + ); + } +} + +// PUT - Update a specific clue +export async function PUT(request, context) { + try { + // Verify admin access + const { authorized, error, status } = await verifyAdmin(request); + if (!authorized) { + return Response.json({ error }, { status }); + } + + const { clueId } = context.params; + + // Validate clueId is a number + if (isNaN(parseInt(clueId))) { + return Response.json({ error: "Invalid clue ID" }, { status: 400 }); + } + + // Parse the request body - removed coordinates + const { title, description, location } = await request.json(); + + // Validate required fields + if (!title || !description) { + return Response.json( + { error: "Title and description are required" }, + { status: 400 } + ); + } + + // Check if clue exists + const clue = await queryOne("SELECT id FROM clues WHERE id = ?", [clueId]); + if (!clue) { + return Response.json({ error: "Clue not found" }, { status: 404 }); + } + + // Update the clue - removed coordinates + await run( + ` + UPDATE clues + SET title = ?, description = ?, location = ? + WHERE id = ? + `, + [title, description, location || "", clueId] + ); + + return Response.json({ + success: true, + message: "Clue updated successfully", + }); + } catch (error) { + console.error("Admin clue update error:", error); + return Response.json( + { error: "Failed to update clue: " + error.message }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/clues/route.js b/app/api/admin/clues/route.js new file mode 100644 index 0000000..a867380 --- /dev/null +++ b/app/api/admin/clues/route.js @@ -0,0 +1,118 @@ +import { getToken } from "next-auth/jwt"; +import { query, queryOne, run } from "@/lib/db"; + +export const dynamic = "force-dynamic"; // Disable caching + +// Ensure the user is an admin +async function verifyAdmin(request) { + const token = await getToken({ + req: request, + secret: + process.env.NEXTAUTH_SECRET || + "your-fallback-secret-should-be-at-least-32-chars", + }); + + if (!token) { + return { authorized: false, error: "Unauthorized", status: 401 }; + } + + if (token.role !== "admin") { + return { authorized: false, error: "Admin access required", status: 403 }; + } + + return { authorized: true, token }; +} + +// GET - Fetch all clues for admin +export async function GET(request) { + try { + // Verify admin access + const { authorized, error, status, token } = await verifyAdmin(request); + if (!authorized) { + return Response.json({ error }, { status }); + } + + // Ensure clues table exists with simplified structure (no coordinates) + 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 + ) + `); + + // Fetch all clues with sequential ordering + const clues = await query(` + SELECT id, title, description, location, created_at + FROM clues + ORDER BY id ASC + `); + + console.log(`Admin API: Returning ${clues.length} clues`); + + return Response.json(clues, { + headers: { + "Cache-Control": "no-store, no-cache, must-revalidate", + Pragma: "no-cache", + Expires: "0", + }, + }); + } catch (error) { + console.error("Admin clue fetch error:", error); + return Response.json( + { error: "Failed to fetch clues: " + error.message }, + { status: 500 } + ); + } +} + +// POST - Add a new clue +export async function POST(request) { + try { + // Verify admin access + const { authorized, error, status } = await verifyAdmin(request); + if (!authorized) { + return Response.json({ error }, { status }); + } + + // Parse the request body - simplified without coordinates + const { title, description, location = "" } = await request.json(); + + // Validate required fields + if (!title || !description) { + return Response.json( + { error: "Title and description are required" }, + { status: 400 } + ); + } + + // Insert the new clue - simplified without coordinates + await run( + ` + INSERT INTO clues (title, description, location) + VALUES (?, ?, ?) + `, + [title, description, location] + ); + + // Get the inserted clue ID + const newClue = await queryOne("SELECT last_insert_rowid() as id"); + + return Response.json( + { + success: true, + message: "Clue added successfully", + clueId: newClue.id, + }, + { status: 201 } + ); + } catch (error) { + console.error("Admin clue creation error:", error); + return Response.json( + { error: "Failed to add clue: " + error.message }, + { status: 500 } + ); + } +} diff --git a/app/api/clues/[clueId]/route.js b/app/api/clues/[clueId]/route.js index cf464a7..e8ffef4 100644 --- a/app/api/clues/[clueId]/route.js +++ b/app/api/clues/[clueId]/route.js @@ -31,9 +31,9 @@ export async function GET(request, context) { return Response.json({ error: "Invalid clue ID" }, { status: 400 }); } - // Get the specific clue + // Get the specific clue - removed coordinates const clue = await queryOne( - "SELECT id, title, description, location, x_coord, y_coord FROM clues WHERE id = ?", + "SELECT id, title, description, location FROM clues WHERE id = ?", [clueId] ); diff --git a/app/api/clues/route.js b/app/api/clues/route.js index bfbe844..034058c 100644 --- a/app/api/clues/route.js +++ b/app/api/clues/route.js @@ -24,9 +24,9 @@ export async function GET(request) { ); } - // Get all clues with explicit ordering to ensure consistency + // Get all clues with explicit ordering - removed coordinates const clues = await query(` - SELECT id, title, description, location, x_coord, y_coord + SELECT id, title, description, location FROM clues ORDER BY id ASC `); diff --git a/clue_hunt.db b/clue_hunt.db index 003581c..20c6409 100644 Binary files a/clue_hunt.db and b/clue_hunt.db differ