diff --git a/quizzly-backend/src/createQuiz.cpp b/quizzly-backend/src/createQuiz.cpp index a8f75bc..4dc33fa 100644 --- a/quizzly-backend/src/createQuiz.cpp +++ b/quizzly-backend/src/createQuiz.cpp @@ -7,13 +7,11 @@ #include "httplib.h" // Include httplib for API handling #include "mongo_instance.h" -// mongocxx::instance instance{}; +//mongocxx::instance instance{}; // Function to insert quiz into MongoDB -bool createQuiz(const std::string &jsonString) -{ - try - { +bool createQuiz(const std::string &jsonString) { + try { // Initialize MongoDB mongocxx::uri uri("mongodb+srv://ngelbloo:jxdnXevSBkquhl2E@se3313-cluster.7kcvssw.mongodb.net/"); mongocxx::client client(uri); @@ -30,10 +28,9 @@ bool createQuiz(const std::string &jsonString) // Return true if the insert was successful return result ? true : false; - } - catch (const std::exception &e) - { + + } catch (const std::exception &e) { std::cerr << "MongoDB Error: " << e.what() << std::endl; return false; } -} \ No newline at end of file +} diff --git a/quizzly-backend/src/main.cpp b/quizzly-backend/src/main.cpp index 9fe1efe..6d3c67b 100644 --- a/quizzly-backend/src/main.cpp +++ b/quizzly-backend/src/main.cpp @@ -1,12 +1,8 @@ #include -#include // For fork() -#include // For std::thread -#include // For std::vector -#include // For std::mutex -#include // For srand(), rand() -#include // For time() - -// header files +#include +#include +#include +#include #include "httplib.h" #include "createQuiz.h" #include "register.h" @@ -14,296 +10,564 @@ #include "login.h" #include "getQuizzes.h" #include "mongo_instance.h" - +#include +#include +#include #include #include #include #include #include #include -#include -#include -#include -// structure to store information about an active game session (each active session is a process and has unique pID) -struct GameSessionInfo -{ - pid_t pid; -}; +namespace beast = boost::beast; +namespace websocket = beast::websocket; +namespace net = boost::asio; +using tcp = net::ip::tcp; + +mongocxx::instance instance{}; +// Replace in your code +const std::string MONGODB_URI = + "mongodb+srv://ngelbloo:" + "jxdnXevSBkquhl2E" // URL-encode any special characters if present + "@se3313-cluster.7kcvssw.mongodb.net/" + "?retryWrites=true" + "&w=majority" + "&appName=QuizApp" + "&serverSelectionTimeoutMS=10000"; // Add timeout + +std::string generate_lobby_code() { + static const char alphanum[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + std::string code; + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution<> distrib(0, sizeof(alphanum) - 2); -// Global map and mutex to store active game sessions -std::unordered_map activeGames; -std::mutex activeGamesMutex; - -// helper function to generate a 6-digit game code -std::string generateGameCode() -{ - // Use current time as seed (for simplicity; in production, improve uniqueness) - // srand(time(NULL)); // move to main method - int code = 100000 + rand() % 900000; - return std::to_string(code); + for (int i = 0; i < 6; ++i) { + code += alphanum[distrib(gen)]; + } + return code; } -// helper function to check if the game code generated is already in use -std::string generateUniqueGameCode() -{ - std::string code; - while (true) - { - code = generateGameCode(); // generates a code - { - std::lock_guard lock(activeGamesMutex); - - // checks if the code is already in use - if (activeGames.find(code) == activeGames.end()) - { - break; + +struct GameLobby { + std::string id; + std::string quiz_id; + std::string host_id; + std::set player_ids; + std::set>> sockets; + std::mutex mutex; + + void broadcast(const std::string& message) { + std::lock_guard lock(mutex); + for(auto& socket : sockets) { + socket->write(net::buffer(message)); + } + } +}; + +class LobbyManager { + public: + std::shared_ptr create_lobby(const std::string& quiz_id, const std::string& host_id) { + std::lock_guard lock(mutex_); + auto lobby = std::make_shared(); + lobby->id = generate_lobby_code(); + lobby->quiz_id = quiz_id; + lobby->host_id = host_id; + lobbies_[lobby->id] = lobby; + return lobby; + } + + std::shared_ptr get_lobby(const std::string& id) { + std::lock_guard lock(mutex_); + auto it = lobbies_.find(id); + return (it != lobbies_.end()) ? it->second : nullptr; + } + std::mutex mutex_; + std::unordered_map> lobbies_; + }; + + void preload_lobbies(LobbyManager& lobby_manager) { + try { + mongocxx::client client{mongocxx::uri{MONGODB_URI}}; + auto collection = client["Quiz_App_DB"]["Lobbies"]; + + // Only preload lobbies that are still 'waiting' (game not started) + auto cursor = collection.find(bsoncxx::builder::stream::document{} + << "status" << "waiting" + << bsoncxx::builder::stream::finalize); + + for (auto&& doc : cursor) { + std::string id = std::string(doc["_id"].get_string().value); + std::string quiz_id = std::string(doc["quiz_id"].get_string().value); + std::string host_id = std::string(doc["host_id"].get_string().value); + + auto lobby = std::make_shared(); + lobby->id = id; + lobby->quiz_id = quiz_id; + lobby->host_id = host_id; + + // (Optional) preload existing players if needed + if (doc.find("players") != doc.end() && doc["players"].type() == bsoncxx::type::k_array) { + for (const auto& elem : doc["players"].get_array().value) { + lobby->player_ids.insert(std::string(elem.get_string().value)); + } } + + std::lock_guard lock(lobby_manager.mutex_); + lobby_manager.lobbies_[id] = lobby; } + + std::cout << "โœ… Preloaded lobbies from MongoDB\n"; + + } catch (const std::exception& e) { + std::cerr << "โŒ Error preloading lobbies: " << e.what() << std::endl; } - return code; // returns a unique game code } -// for testing, replace with dynamic behavior -void runGameSession(const std::string &gameCode) -{ - // print statement for debugging - std::cout << "Game session started for game code " << gameCode - << " in child process " << getpid() << "\n"; - - // Simulate a lobby period, when other users will join - // can remove ability to start game in frontend, this will be handled here - std::this_thread::sleep_for(std::chrono::seconds(30)); - - // Simulate starting the game by creating threads for each joined player - - // SAMPLE DATA - will remove and replace with logic for multithreading - - int simulatedPlayers = 3; - std::vector playerThreads; - for (int i = 0; i < simulatedPlayers; i++) - { - // add players to the player thread (game code and index pairing) - playerThreads.emplace_back([gameCode, i]() - { - std::cout << "Player thread " << i << " in game " << gameCode - << " started in process " << getpid() << "\n"; - std::this_thread::sleep_for(std::chrono::seconds(5)); // here, threads are put to sleep to simulate player activity - std::cout << "Player thread " << i << " in game " << gameCode - << " ended\n"; }); - } - // wait for all player threads to finish - for (auto &t : playerThreads) - { - // a thread that has finished execution, but has not yet been joined is considered an active thread of execution and is joinable - if (t.joinable()) - { - t.join(); // join a thread if joinable + + +void run_websocket_server(LobbyManager& lobby_manager, unsigned short port) { + try { + net::io_context ioc{1}; + tcp::acceptor acceptor{ioc, {tcp::v4(), port}}; + + while(true) { + tcp::socket socket{ioc}; + acceptor.accept(socket); + + std::thread([&lobby_manager, socket = std::move(socket)]() mutable { + try { + websocket::stream ws{std::move(socket)}; + ws.accept(); + + beast::flat_buffer buffer; + ws.read(buffer); + + auto doc = bsoncxx::from_json(beast::buffers_to_string(buffer.data())); + auto view = doc.view(); + + std::string lobby_id{view["lobby_id"].get_string().value}; + std::string user_id{view["user_id"].get_string().value}; + std::string action{view["action"].get_string().value}; + + auto lobby = lobby_manager.get_lobby(lobby_id); + if(!lobby) { + ws.close(websocket::close_code::normal); + return; + } + + if(action == "join") { + auto player_ws = std::make_shared>(std::move(ws)); + { + std::lock_guard lock(lobby->mutex); + lobby->player_ids.insert(user_id); + lobby->sockets.insert(player_ws); + } + + // Update MongoDB + try { + mongocxx::client client{mongocxx::uri{MONGODB_URI}}; + client["Quiz_App_DB"]["Lobbies"].update_one( + bsoncxx::builder::stream::document{} + << "_id" << lobby_id + << bsoncxx::builder::stream::finalize, + bsoncxx::builder::stream::document{} + << "$addToSet" << bsoncxx::builder::stream::open_document + << "players" << user_id + << bsoncxx::builder::stream::close_document + << "$inc" << bsoncxx::builder::stream::open_document + << "player_count" << 1 + << bsoncxx::builder::stream::close_document + << bsoncxx::builder::stream::finalize + ); + } catch(const std::exception& e) { + std::cerr << "DB update error: " << e.what() << std::endl; + } + + // Broadcast update + auto response = bsoncxx::builder::stream::document{} + << "action" << "player_joined" + << "lobby_id" << lobby_id + << "player_count" << static_cast(lobby->player_ids.size()) + << "players" << bsoncxx::builder::stream::open_array + << [&](bsoncxx::builder::stream::array_context<> arr) { + for (const auto& pid : lobby->player_ids) { + arr << pid; + } + } + << bsoncxx::builder::stream::close_array + << bsoncxx::builder::stream::finalize; + + lobby->broadcast(bsoncxx::to_json(response)); + + // Keep connection alive + try { + while(true) { + beast::flat_buffer loop_buffer; + player_ws->read(loop_buffer); + auto loop_doc = bsoncxx::from_json( + beast::buffers_to_string(loop_buffer.data()) + ); + auto loop_view = loop_doc.view(); + std::string loop_action = std::string(loop_view["action"].get_string().value); + + if(loop_action == "start_game") { + // Verify host privileges if needed + auto start_response = bsoncxx::builder::stream::document{} + << "action" << "start_game" + << "lobby_id" << lobby_id + << bsoncxx::builder::stream::finalize; + + lobby->broadcast(bsoncxx::to_json(start_response)); + } + } + } catch(const beast::system_error& se) { + if(se.code() == websocket::error::closed) { + std::lock_guard lock(lobby->mutex); + lobby->player_ids.erase(user_id); + lobby->sockets.erase(player_ws); + + // Update MongoDB on disconnect + try { + mongocxx::client client{mongocxx::uri{MONGODB_URI}}; + client["Quiz_App_DB"]["Lobbies"].update_one( + bsoncxx::builder::stream::document{} + << "_id" << lobby_id + << bsoncxx::builder::stream::finalize, + bsoncxx::builder::stream::document{} + << "$pull" << bsoncxx::builder::stream::open_document + << "players" << user_id + << bsoncxx::builder::stream::close_document + << "$inc" << bsoncxx::builder::stream::open_document + << "player_count" << -1 + << bsoncxx::builder::stream::close_document + << bsoncxx::builder::stream::finalize + ); + } catch(const std::exception& e) { + std::cerr << "DB update error: " << e.what() << std::endl; + } + + auto leave_response = bsoncxx::builder::stream::document{} + << "action" << "player_left" + << "lobby_id" << lobby_id + << "player_count" << static_cast(lobby->player_ids.size()) + << "players" << bsoncxx::builder::stream::open_array + << [&](bsoncxx::builder::stream::array_context<> arr) { + for (const auto& pid : lobby->player_ids) { + arr << pid; + } + } + << bsoncxx::builder::stream::close_array + << bsoncxx::builder::stream::finalize; + + lobby->broadcast(bsoncxx::to_json(leave_response)); + } + } + } + } + catch(const std::exception& e) { + std::cerr << "WebSocket error: " << e.what() << std::endl; + } + }).detach(); } + } + catch(const std::exception& e) { + std::cerr << "WebSocket server error: " << e.what() << std::endl; } - - std::cout << "Game session for game code " << gameCode - << " ended in process " << getpid() << "\n"; - exit(0); // End the child process when done. } -int main() -{ - // one global instance - // mongocxx::instance instance{}; - - // moved here, should produce more unqiue game codes - srand(time(NULL)); +int main() { + LobbyManager lobby_manager; + preload_lobbies(lobby_manager); + + std::thread ws_thread([&lobby_manager]{ + run_websocket_server(lobby_manager, 9002); + }); httplib::Server svr; + svr.set_default_headers({ + {"Access-Control-Allow-Origin", "*"}, + {"Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS"}, + {"Access-Control-Allow-Headers", "Content-Type"} + }); - // Set CORS headers - svr.set_default_headers({{"Access-Control-Allow-Origin", "*"}, - {"Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS"}, - {"Access-Control-Allow-Headers", "Content-Type"}}); + svr.Options(R"(.*)", [](const httplib::Request&, httplib::Response& res) { + res.status = 200; + }); - // Handle OPTIONS requests - svr.Options(R"(.*)", [](const httplib::Request &, httplib::Response &res) - { res.status = 200; }); + svr.Get("/api/data", [](const httplib::Request&, httplib::Response& res) { + res.set_content(R"({"message": "Hello from C++ Backend!"})", "application/json"); + }); - // Test endpoint - svr.Get("/api/data", [](const httplib::Request &, httplib::Response &res) - { res.set_content(R"({"message": "Hello from C++ Backend!"})", "application/json"); }); - - // Get all quizzes endpoint - svr.Get("/api/quizzes", [](const httplib::Request &, httplib::Response &res) - { + svr.Get("/api/quizzes", [](const httplib::Request&, httplib::Response& res) { std::string quizzes = getAllQuizzes(); - res.set_content(quizzes, "application/json"); }); + res.set_content(quizzes, "application/json"); + }); - // Create quiz endpoint - svr.Post("/api/create-quiz", [](const httplib::Request &req, httplib::Response &res) - { + svr.Post("/api/create-quiz", [](const httplib::Request& req, httplib::Response& res) { bool success = createQuiz(req.body); std::string jsonResponse = success ? R"({"success": true})" : R"({"success": false, "error": "Failed to create quiz"})"; - res.set_content(jsonResponse, "application/json"); }); + res.set_content(jsonResponse, "application/json"); + }); - // User registration endpoint - svr.Post("/api/register", [](const httplib::Request &req, httplib::Response &res) - { - + svr.Post("/api/register", [](const httplib::Request& req, httplib::Response& res) { bool success = registerUser(req.body); std::string jsonResponse = success ? R"({"success": true})" : R"({"success": false, "error": "Failed to register user"})"; - - res.set_content(jsonResponse, "application/json"); }); - - // GET quiz by title - // GET qui by ID (instead of by title) - svr.Get(R"(/api/quiz/id/([a-f0-9]{24}))", [](const httplib::Request &req, httplib::Response &res) - { - std::string quizId = req.matches[1]; - try { - mongocxx::uri uri("mongodb+srv://ngelbloo:jxdnXevSBkquhl2E@se3313-cluster.7kcvssw.mongodb.net/"); - mongocxx::client client(uri); - auto db = client["Quiz_App_DB"]; - auto collection = db["Quizzes"]; - - // Convert string ID to MongoDB ObjectId + res.set_content(jsonResponse, "application/json"); + }); + + svr.Get(R"(/api/quiz/id/([a-f0-9]{24}))", [](const httplib::Request& req, httplib::Response& res) { + std::string quizId = req.matches[1]; + try { + mongocxx::client client{mongocxx::uri{"mongodb+srv://ngelbloo:jxdnXevSBkquhl2E@se3313-cluster.7kcvssw.mongodb.net/"}}; + auto collection = client["Quiz_App_DB"]["Quizzes"]; bsoncxx::oid oid(quizId); - auto result = collection.find_one( - bsoncxx::builder::stream::document{} << "_id" << oid << bsoncxx::builder::stream::finalize - ); + auto result = collection.find_one(bsoncxx::builder::stream::document{} + << "_id" << oid + << bsoncxx::builder::stream::finalize); - if (result) { - std::string jsonStr = bsoncxx::to_json(result->view()); - res.set_content(R"({"success": true, "quiz": )" + jsonStr + "}", "application/json"); + if(result) { + res.set_content(R"({"success": true, "quiz": )" + bsoncxx::to_json(result->view()) + "}", "application/json"); } else { res.set_content(R"({"success": false, "error": "Quiz not found"})", "application/json"); } - } catch (const std::exception &e) { + } + catch(const std::exception& e) { res.set_content(std::string(R"({"success": false, "error": ")") + e.what() + R"("})", "application/json"); - } }); + } + }); - // PUT endpoint to edit fields in a Quizzes table entry - svr.Put("/api/edit-quiz", [](const httplib::Request &req, httplib::Response &res) - { + svr.Put("/api/edit-quiz", [](const httplib::Request& req, httplib::Response& res) { try { - // Parse the incoming JSON using bsoncxx auto doc = bsoncxx::from_json(req.body); auto view = doc.view(); - // Check for either "id" or "_id" field - if (!view["id"] && !view["_id"]) { - res.set_content( - R"({"success": false, "error": "Missing quiz ID in request body"})", - "application/json" - ); + if(!view["id"] && !view["_id"]) { + res.set_content(R"({"success": false, "error": "Missing quiz ID"})", "application/json"); return; } - - // Update the quiz using the ID - bool success = updateQuiz(req.body); - std::string jsonResponse = success + bool success = updateQuiz(req.body); + res.set_content(success ? R"({"success": true})" - : R"({"success": false, "error": "Failed to update quiz. Quiz may not exist or no changes were made."})"; - - res.set_content(jsonResponse, "application/json"); - } catch (const std::exception &e) { - res.set_content( - std::string(R"({"success": false, "error": ")") + e.what() + R"("})", - "application/json" - ); - res.status = 400; // Bad Request - } }); + : R"({"success": false, "error": "Update failed"})", "application/json"); + } + catch(const std::exception& e) { + res.set_content(std::string(R"({"success": false, "error": ")") + e.what() + R"("})", "application/json"); + } + }); - // User login endpoint - svr.Post("/api/login", [](const httplib::Request &req, httplib::Response &res) - { + svr.Post("/api/login", [](const httplib::Request& req, httplib::Response& res) { bool success = loginUser(req.body); std::string jsonResponse = success ? R"({"success": true})" : R"({"success": false, "error": "Failed to login"})"; - res.set_content(jsonResponse, "application/json"); }); - - // Start Game endpoint (host starts a game) - svr.Post("/api/start-game", [](const httplib::Request &req, httplib::Response &res) - { - // confirm the method is being called - std::cout << "POST /api/start-game called" << std::endl; - - // Generate a unique game code, used by other players to join - std::string gameCode = generateUniqueGameCode(); - - // Fork a new process: the host's game session - pid_t pid = fork(); // creates a duplicate of the parent process, this creates a child process (where the game executes) - - // check if fork() call was successful, or if fork failed (pid<0) - if (pid < 0) { - res.set_content(R"({"success": false, "error": "Fork failed"})", "application/json"); - return; - } else if (pid == 0) { - // when pid == 0, we are inside the child process - runGameSession(gameCode); // run the child process logic - } else { - // when pid > 0, we are inside the parent process - { - std::lock_guard lock(activeGamesMutex); - activeGames[gameCode] = GameSessionInfo{pid}; - } - std::cout << "Started game session with code " << gameCode - << " in child process " << pid << "\n"; - res.set_content(R"({"success": true, "gameCode": ")" + gameCode + R"("})", "application/json"); - } }); - - // Join Game endpoint (player joins an existing game) - svr.Post("/api/join-game", [](const httplib::Request &req, httplib::Response &res) - { - // each joining player enters a game code and nickname - std::string gameCode = ""; - std::string nickname = ""; - - // parse the request body to retrieve gameCode and nickname - - //can change this, just depends on format of request body when called - size_t pos1 = req.body.find("\"gameCode\":"); - if (pos1 != std::string::npos) { - size_t start = req.body.find("\"", pos1 + 11); - size_t end = req.body.find("\"", start + 1); - gameCode = req.body.substr(start + 1, end - start - 1); + res.set_content(jsonResponse, "application/json"); + }); + + + svr.Post("/api/lobbies", [&](const httplib::Request& req, httplib::Response& res) { + try { + auto doc = bsoncxx::from_json(req.body); + auto view = doc.view(); + + std::string quiz_id{view["quiz_id"].get_string().value}; + std::string host_id{view["host_id"].get_string().value}; + + auto lobby = lobby_manager.create_lobby(quiz_id, host_id); + + mongocxx::client client{mongocxx::uri{MONGODB_URI}}; + auto collection = client["Quiz_App_DB"]["Lobbies"]; + + auto lobby_doc = bsoncxx::builder::stream::document{} + << "_id" << lobby->id + << "quiz_id" << quiz_id + << "host_id" << host_id + << "status" << "waiting" + << "player_count" << 0 + << "players" << bsoncxx::builder::stream::open_array + << bsoncxx::builder::stream::close_array + << "created_at" << bsoncxx::types::b_date(std::chrono::system_clock::now()) + << bsoncxx::builder::stream::finalize; + + collection.insert_one(lobby_doc.view()); + + auto response = bsoncxx::builder::stream::document{} + << "success" << true + << "lobby_id" << lobby->id + << bsoncxx::builder::stream::finalize; + + res.set_content(bsoncxx::to_json(response), "application/json"); } - size_t pos2 = req.body.find("\"nickname\":"); - if (pos2 != std::string::npos) { - size_t start = req.body.find("\"", pos2 + 11); - size_t end = req.body.find("\"", start + 1); - nickname = req.body.substr(start + 1, end - start - 1); + catch (const std::exception& e) { + auto error = bsoncxx::builder::stream::document{} + << "success" << false + << "error" << e.what() + << bsoncxx::builder::stream::finalize; + res.set_content(bsoncxx::to_json(error), "application/json"); } - if (gameCode.empty() || nickname.empty()) { - res.set_content(R"({"success": false, "error": "Missing game code or nickname"})", "application/json"); - return; + }); + + svr.Post(R"(/api/lobbies/([A-Za-z0-9]{6})/submit-score)", [&](const httplib::Request& req, httplib::Response& res) { + try { + // 1. Extract and validate lobby code with explicit conversion + if (req.matches.size() < 2) { + throw std::runtime_error("Missing lobby code in URL"); + } + + std::string lobby_code(req.matches[1].first, req.matches[1].second); // Explicit conversion + if (lobby_code.empty() || lobby_code.length() != 6) { + throw std::runtime_error("Invalid lobby code - must be 6 characters"); + } + + // 2. Parse and validate request body + auto doc = bsoncxx::from_json(req.body); + auto view = doc.view(); + + if (!view["nickname"] || view["nickname"].type() != bsoncxx::type::k_string || + !view["score"] || view["score"].type() != bsoncxx::type::k_int32) { + throw std::runtime_error("Missing or invalid nickname/score"); + } + + // Explicit string conversion for nickname + bsoncxx::stdx::string_view nickname_sv = view["nickname"].get_string(); + std::string nickname(nickname_sv.data(), nickname_sv.length()); + int32_t score = view["score"].get_int32().value; + + // 3. Database operations + mongocxx::client client{mongocxx::uri{MONGODB_URI}}; + auto collection = client["Quiz_App_DB"]["QuizResults"]; + + // Create score document with explicit string conversion + auto score_doc = bsoncxx::builder::basic::make_document( + bsoncxx::builder::basic::kvp("nickname", nickname), + bsoncxx::builder::basic::kvp("score", score) + ); + + // Upsert operation + auto update_result = collection.update_one( + bsoncxx::builder::basic::make_document( + bsoncxx::builder::basic::kvp("lobbyCode", lobby_code) + ), + bsoncxx::builder::basic::make_document( + bsoncxx::builder::basic::kvp("$setOnInsert", + bsoncxx::builder::basic::make_document( + bsoncxx::builder::basic::kvp("lobbyCode", lobby_code) + ) + ), + bsoncxx::builder::basic::kvp("$push", + bsoncxx::builder::basic::make_document( + bsoncxx::builder::basic::kvp("scores", score_doc) + ) + ) + ), + mongocxx::options::update().upsert(true) + ); + + // 4. Return response + auto response = bsoncxx::builder::basic::make_document( + bsoncxx::builder::basic::kvp("success", true), + bsoncxx::builder::basic::kvp("lobbyCode", lobby_code), + bsoncxx::builder::basic::kvp("action", + update_result->upserted_id() ? "created" : "updated") + ); + + res.set_content(bsoncxx::to_json(response), "application/json"); + + } catch (const std::exception& e) { + auto error = bsoncxx::builder::basic::make_document( + bsoncxx::builder::basic::kvp("success", false), + bsoncxx::builder::basic::kvp("error", std::string(e.what())) // Explicit conversion + ); + res.set_content(bsoncxx::to_json(error), "application/json"); + res.status = 500; } - { - // find the live game by game code - std::lock_guard lock(activeGamesMutex); - if (activeGames.find(gameCode) == activeGames.end()) { - // if a live game with this game code cannot be found, return error - res.set_content(R"({"success": false, "error": "Game not found"})", "application/json"); + }); + svr.Get(R"(/api/results/([A-Za-z0-9]{6}))", [&](const httplib::Request& req, httplib::Response& res) { + try { + // 1. Extract and validate lobby code + if (req.matches.size() < 2) { + throw std::runtime_error("Missing lobby code in URL"); + } + + std::string lobby_code(req.matches[1].first, req.matches[1].second); + if (lobby_code.empty() || lobby_code.length() != 6) { + throw std::runtime_error("Invalid lobby code - must be 6 characters"); + } + + // 2. Database query + mongocxx::client client{mongocxx::uri{MONGODB_URI}}; + auto collection = client["Quiz_App_DB"]["QuizResults"]; + + // Find the lobby document + auto maybe_result = collection.find_one( + bsoncxx::builder::basic::make_document( + bsoncxx::builder::basic::kvp("lobbyCode", lobby_code) + ) + ); + + // 3. Handle response + if (!maybe_result) { + // Lobby not found + auto response = bsoncxx::builder::basic::make_document( + bsoncxx::builder::basic::kvp("success", false), + bsoncxx::builder::basic::kvp("error", "Lobby not found") + ); + res.set_content(bsoncxx::to_json(response), "application/json"); + res.status = 404; return; } + + // Lobby found - return the full document + auto response = bsoncxx::builder::basic::make_document( + bsoncxx::builder::basic::kvp("success", true), + bsoncxx::builder::basic::kvp("lobbyCode", lobby_code), + bsoncxx::builder::basic::kvp("data", *maybe_result) + ); + + res.set_content(bsoncxx::to_json(response), "application/json"); + + } catch (const std::exception& e) { + auto error = bsoncxx::builder::basic::make_document( + bsoncxx::builder::basic::kvp("success", false), + bsoncxx::builder::basic::kvp("error", std::string(e.what())) + ); + res.set_content(bsoncxx::to_json(error), "application/json"); + res.status = 500; + } + }); + svr.Get(R"(/api/lobbies/([A-Z0-9]{6}))", [&](const httplib::Request& req, httplib::Response& res) { + try { + std::string lobby_id = req.matches[1]; + mongocxx::client client{mongocxx::uri{MONGODB_URI}}; + auto collection = client["Quiz_App_DB"]["Lobbies"]; + + auto result = collection.find_one(bsoncxx::builder::stream::document{} + << "_id" << lobby_id + << bsoncxx::builder::stream::finalize); + + if (result) { + res.set_content(bsoncxx::to_json(result->view()), "application/json"); + } else { + res.status = 404; + res.set_content(R"({"success": false, "error": "Lobby not found"})", "application/json"); + } } - // Simulate adding a player by creating a new thread - // a new thread is created with the game code and nickname retrieved, each thread represents a player - std::thread joinThread([gameCode, nickname]() { - std::cout << "Player " << nickname << " is joining game " << gameCode - << " (handled in thread " << std::this_thread::get_id() << ")\n"; - }); - - // call detach() to detach the thread - // the thread will continue running in the background while th emain thread proceeds without waiting - joinThread.detach(); - res.set_content(R"({"success": true, "message": "Joined game successfully"})", "application/json"); }); - - std::cout << "Server is running on http://localhost:5001\n"; + catch (const std::exception& e) { + res.status = 500; + res.set_content(std::string(R"({"success": false, "error": ")") + e.what() + R"("})", "application/json"); + } + }); + std::cout << "HTTP Server running on http://localhost:5001\n"; + std::cout << "WebSocket Server running on ws://localhost:9002\n"; + svr.listen("0.0.0.0", 5001); + ws_thread.join(); return 0; } \ No newline at end of file diff --git a/quizzly-frontend/src/App.jsx b/quizzly-frontend/src/App.jsx index a9804bc..99b3161 100644 --- a/quizzly-frontend/src/App.jsx +++ b/quizzly-frontend/src/App.jsx @@ -7,17 +7,23 @@ import Home from './pages/Home'; import { AuthProvider } from './context/AuthContext'; import JoinGame from './pages/JoinGame'; import GameLobby from './pages/GameLobby'; +import PlayQuiz from './pages/PlayQuiz'; import Login from './pages/Login'; import Register from './pages/Register'; import Dashboard from './pages/Dashboard'; import CreateQuiz from './pages/CreateQuiz'; import EditQuiz from './pages/EditQuiz'; +import QuizResults from './pages/QuizResults' function AppContent() { + const location = useLocation(); + const hideNavbarRoutes = ['/login', '/register', '/']; + const shouldHideNavbar = hideNavbarRoutes.includes(location.pathname); + return (
- {} + {!shouldHideNavbar && }
} /> @@ -26,7 +32,9 @@ function AppContent() { } /> } /> } /> - } /> + } /> + } /> + } /> } /> } /> Page not found
} /> diff --git a/quizzly-frontend/src/components/Footer.jsx b/quizzly-frontend/src/components/Footer.jsx index 7fc0314..9c575ea 100644 --- a/quizzly-frontend/src/components/Footer.jsx +++ b/quizzly-frontend/src/components/Footer.jsx @@ -49,6 +49,24 @@ const Footer = () => { + +
+

Connect

+ +
diff --git a/quizzly-frontend/src/components/Navbar.jsx b/quizzly-frontend/src/components/Navbar.jsx index e096245..12573e6 100644 --- a/quizzly-frontend/src/components/Navbar.jsx +++ b/quizzly-frontend/src/components/Navbar.jsx @@ -20,7 +20,7 @@ const Navbar = () => { try { await logout(); closeMenu(); // Close the menu when logging out - navigate('/'); + navigate('/login'); } catch (error) { console.error('Failed to log out', error); } @@ -52,31 +52,29 @@ const Navbar = () => { Join Game - - {currentUser ? ( + + <>
  • Dashboard
  • +
  • + + Create Quiz + +
  • - ) : ( -
  • - - Login - -
  • - )}
    ); }; -export default Navbar; +export default Navbar; \ No newline at end of file diff --git a/quizzly-frontend/src/context/AuthContext.jsx b/quizzly-frontend/src/context/AuthContext.jsx index 1ae8193..8cb7779 100644 --- a/quizzly-frontend/src/context/AuthContext.jsx +++ b/quizzly-frontend/src/context/AuthContext.jsx @@ -1,5 +1,5 @@ // AuthContext.jsx -import { createContext, useContext, useState, useEffect } from "react"; +import { createContext, useContext, useState, useEffect } from 'react'; const AuthContext = createContext(); @@ -13,71 +13,66 @@ export function AuthProvider({ children }) { // On mount, load any stored user from localStorage useEffect(() => { - const storedUser = localStorage.getItem("quizzlyUser"); + const storedUser = localStorage.getItem('quizzlyUser'); if (storedUser) { - setCurrentUser(/*JSON.parse(*/storedUser); + //setCurrentUser(JSON.parse(storedUser)); + console.log("Current user: ", storedUser); + setCurrentUser(storedUser); } setLoading(false); }, []); // Register function: sends plaintext password to backend const register = async (name, email, password) => { - const response = await fetch( - `${import.meta.env.VITE_BACKEND_URL}/api/register`, - { - method: "POST", - mode: "cors", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name, - email, - password, - }), - } - ); - + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/register`, { + method: 'POST', + mode: 'cors', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name, + email, + password + }) + }); + const data = await response.json(); if (response.ok && data.success) { // Assuming your backend returns the created user object setCurrentUser(data.user); - localStorage.setItem("quizzlyUser", JSON.stringify(data.user)); + localStorage.setItem('quizzlyUser', JSON.stringify(data.user)); return data.user; } else { - throw new Error(data.error || "Registration failed on server"); + throw new Error(data.error || 'Registration failed on server'); } }; // Login function: sends plaintext password to backend for verification const login = async (email, password) => { - const response = await fetch( - `${import.meta.env.VITE_BACKEND_URL}/api/login`, - { - method: "POST", - mode: "cors", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ email, password }), - } - ); - + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/login`, { + method: 'POST', + mode: 'cors', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email, password }) + }); + const data = await response.json(); if (response.ok && data.success) { setCurrentUser(data.user); - localStorage.setItem("quizzlyUser", JSON.stringify(data.user)); + localStorage.setItem('quizzlyUser', JSON.stringify(data.user)); return data.user; } else { - throw new Error(data.error || "Login failed on server"); + throw new Error(data.error || 'Login failed on server'); } }; const logout = async () => { // Clear local auth state setCurrentUser(null); - localStorage.removeItem("quizzlyUser"); - console.log("Current user: ", localStorage.getItem("quizzlyUser")); + localStorage.removeItem('quizzlyUser'); }; const value = { @@ -85,7 +80,7 @@ export function AuthProvider({ children }) { login, register, logout, - loading, + loading }; return ( diff --git a/quizzly-frontend/src/pages/CreateQuiz.jsx b/quizzly-frontend/src/pages/CreateQuiz.jsx index 5496b85..59df4e2 100644 --- a/quizzly-frontend/src/pages/CreateQuiz.jsx +++ b/quizzly-frontend/src/pages/CreateQuiz.jsx @@ -146,8 +146,6 @@ const CreateQuiz = () => { const handleSubmit = async (e) => { e.preventDefault(); - - // should check if quiz name is unique if (step === 1) { if (!quizData.title.trim() || !quizData.description.trim() || !quizData.category) { @@ -162,7 +160,7 @@ const CreateQuiz = () => { alert('Your quiz needs at least one question.'); return; } - + const formattedQuiz = { title: quizData.title, description: quizData.description, diff --git a/quizzly-frontend/src/pages/Dashboard.jsx b/quizzly-frontend/src/pages/Dashboard.jsx index c46fa05..73842c0 100644 --- a/quizzly-frontend/src/pages/Dashboard.jsx +++ b/quizzly-frontend/src/pages/Dashboard.jsx @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import './Dashboard.css'; const Dashboard = () => { + const navigate = useNavigate(); const { currentUser } = useAuth(); const [quizzes, setQuizzes] = useState([]); const [recentGames, setRecentGames] = useState([]); @@ -18,49 +19,34 @@ const Dashboard = () => { useEffect(() => { const fetchData = async () => { try { - // Fetch quizzes from your C++ backend const quizzesResponse = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/quizzes`); const quizzesData = await quizzesResponse.json(); - + if (quizzesData.quizzes) { - // Transform the data to match your frontend structure const transformedQuizzes = quizzesData.quizzes.map(quiz => ({ - id: quiz._id?.$oid || '', // Handle MongoDB ObjectId + id: quiz._id?.$oid || '', title: quiz.title || 'Untitled Quiz', description: quiz.description || '', questions: quiz.questions?.length || 0, - plays: 0, // You'll need to add this to your backend or calculate it + plays: 0, createdAt: quiz.created_at || new Date().toISOString(), lastPlayed: quiz.last_played || new Date().toISOString(), isPublic: quiz.isPublic || false, category: quiz.category || 'General', timeLimit: quiz.timeLimit || 30 })); - + setQuizzes(transformedQuizzes); - - // Calculate stats based on quizzes + setStats({ totalQuizzes: transformedQuizzes.length, totalPlays: transformedQuizzes.reduce((sum, quiz) => sum + quiz.plays, 0), - totalPlayers: 0, // You'll need to track this in your backend - averageScore: 0 // You'll need to track this in your backend + totalPlayers: 0, + averageScore: 0 }); } - // TODO: Fetch recent games from your backend when you implement that endpoint - const mockRecentGames = [ - { - id: 'g1', - quizId: quizzesData.quizzes[0]?._id?.$oid || 'q1', - quizTitle: quizzesData.quizzes[0]?.title || 'Sample Quiz', - date: new Date().toISOString(), - players: 0, - averageScore: 0 - } - ]; - - setRecentGames(mockRecentGames); + setRecentGames([]); setIsLoading(false); } catch (error) { console.error('Error fetching data:', error); @@ -71,16 +57,42 @@ const Dashboard = () => { fetchData(); }, []); - if (isLoading) { - return ( -
    -
    -

    Loading your dashboard...

    -
    - ); - } - - // Format date for display + const handlePlayQuiz = async (quizId) => { + try { + const username = prompt("Choose your nickname:"); + if (!username || username.trim() === "") { + alert("You must enter a nickname to play!"); + return; + } + + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/lobbies`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + quiz_id: quizId, + host_id: username // ๐Ÿ‘ˆ USE THE nickname as the host_id + }) + }); + + const res = await response.json(); + if (!res.success) { + throw new Error(res.error || 'Failed to create lobby'); + } + + const lobbyId = res.lobby_id; + + sessionStorage.setItem('quizzlyPlayer', JSON.stringify({ + nickname: username, + gameCode: lobbyId + })); + + navigate(`/game-lobby/${quizId}/${lobbyId}`); + } catch (error) { + console.error('Error creating lobby:', error); + } + }; + + const formatDate = (dateString) => { const date = new Date(dateString); return date.toLocaleDateString('en-US', { @@ -90,39 +102,28 @@ const Dashboard = () => { }); }; + if (isLoading) { + return ( +
    +
    +

    Loading your dashboard...

    +
    + ); + } + return (

    Welcome{currentUser?.displayName ? `, ${currentUser.displayName}` : ''}!

    + + Create New Quiz +
    - {/* Stats Overview */} -
    -
    -

    {stats.totalQuizzes}

    -

    Quizzes Created

    -
    -
    -

    {stats.totalPlays}

    -

    Total Plays

    -
    -
    -

    {stats.totalPlayers}

    -

    Total Players

    -
    -
    -

    {stats.averageScore}%

    -

    Average Score

    -
    -
    - - {/* My Quizzes */}

    My Quizzes

    - - View All - + View All
    {quizzes.length === 0 ? ( @@ -153,20 +154,16 @@ const Dashboard = () => { Created: {formatDate(quiz.createdAt)}
    - - Play - - {/* When the user clicks "Edit," the quiz id is passed via the URL */} + onClick={() => handlePlayQuiz(quiz.id)} + > + Play + - Edit + Edit - +
    ))} @@ -183,53 +180,8 @@ const Dashboard = () => { )} - - {/* Recent Games */} -
    -
    -

    Recent Games

    - - View History - -
    - - {recentGames.length === 0 ? ( -
    -

    You haven't hosted any games yet.

    -
    - ) : ( -
    - - - - - - - - - - - - {recentGames.map((game) => ( - - - - - - - - ))} - -
    QuizDatePlayersAvg. ScoreActions
    {game.quizTitle}{formatDate(game.date)}{game.players}{game.averageScore}% - - Results - -
    -
    - )} -
    ); }; -export default Dashboard; \ No newline at end of file +export default Dashboard; diff --git a/quizzly-frontend/src/pages/GameLobby.jsx b/quizzly-frontend/src/pages/GameLobby.jsx index 098c701..e175e28 100644 --- a/quizzly-frontend/src/pages/GameLobby.jsx +++ b/quizzly-frontend/src/pages/GameLobby.jsx @@ -1,179 +1,127 @@ -import { useState, useEffect } from "react"; -import { useParams, useNavigate, useLocation } from "react-router-dom"; -import "./GameLobby.css"; +import { useState, useEffect, useRef } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import './GameLobby.css'; const GameLobby = () => { - const { id: gameCode } = useParams(); - const location = useLocation(); + const { id: quizId, lobby: lobbyCode } = useParams(); const navigate = useNavigate(); - - // State for game data const [players, setPlayers] = useState([]); - const [hostInfo, setHostInfo] = useState({ - name: "Quiz Host", - avatar: "๐Ÿ‘จโ€๐Ÿซ", - }); - const [quizInfo, setQuizInfo] = useState({ - title: "Loading quiz...", - description: "", - questions: 0, - timeLimit: 30, - }); - const [countdown, setCountdown] = useState(null); - const [playerNickname, setPlayerNickname] = useState(""); - const [quizId, setQuizId] = useState(null); + const [hostInfo, setHostInfo] = useState({ name: 'Quiz Host', avatar: '๐Ÿ‘จโ€๐Ÿซ' }); + const [quizInfo, setQuizInfo] = useState({ title: 'Loading quiz...', description: '', questions: 0, timeLimit: 30 }); + const [playerNickname, setPlayerNickname] = useState(''); const [isLoading, setIsLoading] = useState(true); + const [countdown, setCountdown] = useState(null); + const ws = useRef(null); - // Initialize lobby with quiz and player data - useEffect(() => { - // Get quiz ID from navigation state - const { quizId } = location.state || {}; - setQuizId(quizId); - const playerData = sessionStorage.getItem("quizzlyPlayer"); + const fetchLobbyData = async () => { + try { + const lobbyResponse = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/lobbies/${lobbyCode}`); + if (!lobbyResponse.ok) throw new Error('Failed to load lobby'); + const data = await lobbyResponse.json(); + + setHostInfo({ + name: data.host_id || 'Quiz Host', + avatar: '๐Ÿ‘จโ€๐Ÿซ' + }); + } catch (error) { + console.error('Error fetching lobby:', error); + } + }; + - // Load player info from session storage - /*const storedPlayer = sessionStorage.getItem('quizzlyPlayer'); + useEffect(() => { + const storedPlayer = sessionStorage.getItem('quizzlyPlayer'); if (!storedPlayer) { navigate('/join'); return; } - const playerData = JSON.parse(storedPlayer); - if (playerData.gameCode !== gameCode) { - navigate('/join'); - return; - }*/ + setPlayerNickname(playerData.nickname); + + const fetchQuizData = async () => { + try { + const quizResponse = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/quiz/id/${quizId}`); + if (!quizResponse.ok) throw new Error('Failed to load quiz'); + const data = await quizResponse.json(); + const quiz = data.quiz; + + setQuizInfo({ + title: quiz?.title || 'Untitled Quiz', + description: quiz?.description || '', + questions: quiz?.questions?.length || 0, + timeLimit: quiz?.timeLimit || 30 + }); - setPlayerNickname(playerData); + if (quiz?.created_by) { + setHostInfo({ + name: quiz.created_by, + avatar: '๐Ÿ‘จโ€๐Ÿซ' + }); + } + } catch (error) { + console.error('Error fetching quiz:', error); + } finally { + setIsLoading(false); + } + }; - // Add current player to players list - setPlayers([ - { - id: Date.now(), - nickname: playerData, - avatar: "๐Ÿ˜Ž", - isReady: false, - isYou: true, - }, - ]); + fetchQuizData(); + fetchLobbyData(); - // Fetch quiz data if ID exists - if (quizId) { - fetchQuizData(quizId); - } else { - setIsLoading(false); - } + ws.current = new WebSocket(`ws://${window.location.hostname}:9002`); - // Start player polling - const interval = setInterval(updatePlayers, 3000); - return () => clearInterval(interval); - }, [gameCode, navigate, location.state]); + ws.current.onopen = () => { + console.log('โœ… Connected to WebSocket server'); - // Fetch quiz data from backend - const fetchQuizData = async (quizId) => { - try { - const response = await fetch( - `${import.meta.env.VITE_BACKEND_URL}/api/quizzes/${quizId}` - ); - if (!response.ok) throw new Error("Failed to load quiz"); - - const data = await response.json(); - setQuizInfo({ - title: data.title || "Untitled Quiz", - description: data.description || "", - questions: data.questions?.length || 0, - timeLimit: data.timeLimit || 30, - }); + ws.current.send(JSON.stringify({ + lobby_id: lobbyCode, // 6-character lobby code + user_id: playerData.nickname, + action: "join" + })); + }; - // Set host info from quiz data if available - if (data.created_by) { - setHostInfo({ - name: data.created_by, - avatar: "๐Ÿ‘จโ€๐Ÿซ", - }); + ws.current.onmessage = (event) => { + const message = JSON.parse(event.data); + console.log('๐Ÿ“ฉ Message from server:', message); + + if (message.action === "player_joined" || message.action === "player_left") { + if (message.players && Array.isArray(message.players)) { + setPlayers(message.players.map(playerNickname => ({ + id: Date.now() + Math.random(), + nickname: playerNickname, + avatar: '๐Ÿ˜Ž', + isYou: playerNickname === playerData.nickname + }))); + } } - } catch (error) { - console.error("Error fetching quiz:", error); - // Fallback to mock data - setQuizInfo({ - title: "General Knowledge Quiz", - description: "Test your knowledge on various topics", - questions: 10, - timeLimit: 30, - }); - } finally { - setIsLoading(false); - } - }; - - // Simulate player updates - const updatePlayers = () => { - if (countdown !== null) return; // Don't update during countdown - - // Randomly add a player sometimes - if (players.length < 8 && Math.random() > 0.6) { - const avatars = [ - "๐ŸฆŠ", - "๐Ÿฑ", - "๐Ÿถ", - "๐Ÿฆ", - "๐Ÿผ", - "๐Ÿฏ", - "๐Ÿฆ„", - "๐Ÿข", - "๐Ÿฆ–", - "๐Ÿฌ", - ]; - const names = [ - "QuizKid", - "BrainPower", - "ThinkTank", - "MindMaster", - "QuizWizard", - "GameChamp", - "BrainStorm", - "TriviaAce", - ]; - - const newPlayer = { - id: Date.now(), - nickname: - names[Math.floor(Math.random() * names.length)] + - Math.floor(Math.random() * 100), - avatar: avatars[Math.floor(Math.random() * avatars.length)], - isReady: Math.random() > 0.3, - }; + + else if (message.action === "start_game") { + startCountdown(); + } + }; + - setPlayers((prevPlayers) => [...prevPlayers, newPlayer]); - } + ws.current.onerror = (err) => { + console.error('โŒ WebSocket error:', err); + }; - // Toggle ready status for existing players - setPlayers((prevPlayers) => - prevPlayers.map((player) => { - if (player.isYou) return player; - if (Math.random() > 0.8) { - return { ...player, isReady: !player.isReady }; - } - return player; - }) - ); + ws.current.onclose = () => { + console.warn('โšก WebSocket closed'); + }; - // Simulate game starting after enough players - if (players.length >= 2 && players.every((p) => p.isReady)) { - startCountdown(); - } - }; + return () => { + ws.current.close(); + }; + }, [quizId, lobbyCode, navigate]); const startCountdown = () => { setCountdown(5); - const timer = setInterval(() => { - setCountdown((prev) => { + setCountdown(prev => { if (prev <= 1) { clearInterval(timer); - // Redirect to game with both gameCode and quizId - navigate(`/play/${gameCode}`, { state: { quizId } }); + navigate(`/play-quiz/${quizId}/${lobbyCode}`); return 0; } return prev - 1; @@ -182,19 +130,19 @@ const GameLobby = () => { }; const toggleReady = () => { - setPlayers((prevPlayers) => - prevPlayers.map((player) => + setPlayers(prevPlayers => + prevPlayers.map(player => player.isYou ? { ...player, isReady: !player.isReady } : player ) ); }; const leaveGame = () => { - sessionStorage.removeItem("quizzlyPlayer"); - navigate("/dashboard"); + sessionStorage.removeItem('quizzlyPlayer'); + navigate('/dashboard'); }; - const currentPlayer = players.find((p) => p.isYou) || {}; + const currentPlayer = players.find(p => p.isYou) || {}; if (isLoading) { return ( @@ -222,15 +170,9 @@ const GameLobby = () => {

    {quizInfo.title}

    {quizInfo.description}

    - - Game Code: {gameCode} - - - {quizInfo.questions} Questions - - - {quizInfo.timeLimit}s per question - + Game Code: {lobbyCode} + {quizInfo.questions} Questions + {quizInfo.timeLimit}s per question
    @@ -247,26 +189,20 @@ const GameLobby = () => {

    Players ({players.length})

    - {players.every((p) => p.isReady) - ? "All players ready! Starting soon..." - : "Waiting for players to ready up..."} + {players.every(p => p.isReady) + ? 'All players ready! Starting soon...' + : 'Waiting for players to ready up...'}

    - {players.map((player) => ( + {players.map(player => (
    {player.avatar}
    - {player.nickname}{" "} - {player.isYou && (You)} -
    -
    - {player.isReady ? "Ready โœ“" : "Not Ready..."} + {player.nickname} {player.isYou && (You)}
    ))} @@ -274,34 +210,33 @@ const GameLobby = () => {
    -
    -
    - {currentPlayer.avatar || "๐Ÿ˜Ž"} -
    -

    {playerNickname}

    -
    - {currentPlayer.isReady ? "Ready to Play" : "Not Ready"} -
    -
    - -
    - - - -
    +
    +
    {currentPlayer.avatar || '๐Ÿ˜Ž'}
    +

    {playerNickname}

    +
    + +
    + {playerNickname === hostInfo.name && ( + + )} + + +

    How to Play

    diff --git a/quizzly-frontend/src/pages/Home.jsx b/quizzly-frontend/src/pages/Home.jsx index 560421f..22783f1 100644 --- a/quizzly-frontend/src/pages/Home.jsx +++ b/quizzly-frontend/src/pages/Home.jsx @@ -4,10 +4,38 @@ import { useAuth } from '../context/AuthContext'; const Home = () => { const { currentUser } = useAuth(); + + // Simple local nav bar for Home page +const HomeNavbar = () => ( + +); return (
    + {/* Hero Section */}
    diff --git a/quizzly-frontend/src/pages/JoinGame.jsx b/quizzly-frontend/src/pages/JoinGame.jsx index 5d5cb0e..0c5bd7b 100644 --- a/quizzly-frontend/src/pages/JoinGame.jsx +++ b/quizzly-frontend/src/pages/JoinGame.jsx @@ -9,78 +9,94 @@ const JoinGame = () => { const [isJoining, setIsJoining] = useState(false); const navigate = useNavigate(); - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); setError(''); - if (!gameCode.trim()) { - return setError('Please enter a game code'); - } - - if (!nickname.trim()) { - return setError('Please enter a nickname'); - } + if (!gameCode.trim()) return setError('Please enter a game code'); + if (!nickname.trim()) return setError('Please enter a nickname'); - // In a real app, you would validate the game code with your backend setIsJoining(true); - // Simulate API call to join game - setTimeout(() => { - // For demo purposes, let's say any 6-digit code works - if (gameCode.length === 6 && /^\d+$/.test(gameCode)) { - // Store player info in session storage - sessionStorage.setItem('quizzlyPlayer', JSON.stringify({ - nickname, - gameCode - })); - - // Navigate to game lobby - navigate(`/lobby/${gameCode}`); - } else { - setError('Invalid game code. Please check and try again.'); + try { + const res = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/lobbies/${gameCode}`); + if (!res.ok) throw new Error('Failed to find lobby'); + const data = await res.json(); + + if (!data || !data.quiz_id) { + setError('Invalid game code.'); setIsJoining(false); + return; } - }, 1500); + + // Save player info + sessionStorage.setItem('quizzlyPlayer', JSON.stringify({ + nickname, + gameCode + })); + + // โญ Immediately connect to WebSocket + const ws = new WebSocket(`ws://${window.location.hostname}:9002`); + + ws.onopen = () => { + console.log('Connected to WebSocket server from join'); + ws.send(JSON.stringify({ + action: "join", + lobby_id: gameCode, + user_id: nickname + })); + + // After connecting, navigate + navigate(`/game-lobby/${data.quiz_id}/${gameCode}`); + }; + + ws.onerror = (err) => { + console.error('WebSocket error on join:', err); + setError('WebSocket connection error'); + setIsJoining(false); + }; + + } catch (err) { + console.error('Join error:', err); + setError('Something went wrong. Try again.'); + setIsJoining(false); + } }; const handleGameCodeChange = (e) => { - // Only allow numbers and limit to 6 digits - const value = e.target.value.replace(/[^\d]/g, '').slice(0, 6); + const value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6); setGameCode(value); }; const handleNicknameChange = (e) => { - // Limit nickname to 15 characters const value = e.target.value.slice(0, 15); setNickname(value); }; + return (

    Join a Game

    - + {error &&
    {error}
    } - +
    -
    - -
    -
    Ask your teacher or host for the 6-digit game code
    +
    - +
    { autoComplete="off" disabled={isJoining} /> -
    This is how you'll appear on the leaderboard
    - +
    - -
    -

    How to Play

    -
      -
    1. Enter the game code provided by your host
    2. -
    3. Choose a nickname to appear on the leaderboard
    4. -
    5. Click "Join Game" to enter the lobby
    6. -
    7. Wait for the host to start the game
    8. -
    9. Answer questions as quickly as you can to earn points
    10. -
    -
    - -
    -

    Want to create your own quiz?

    - Sign Up Now -
    -
    - -
    -

    Recently Played Games

    -
    -
    -
    ๐ŸŒ
    -
    -

    Geography Trivia

    -

    Played 2 days ago

    -
    - -
    - -
    -
    ๐Ÿงช
    -
    -

    Science Quiz

    -

    Played 5 days ago

    -
    - -
    - -
    -
    ๐Ÿ’ป
    -
    -

    JavaScript Basics

    -

    Played 1 week ago

    -
    - -
    -
    ); }; -export default JoinGame; \ No newline at end of file +export default JoinGame; diff --git a/quizzly-frontend/src/pages/Login.jsx b/quizzly-frontend/src/pages/Login.jsx index 698712e..5c9ced2 100644 --- a/quizzly-frontend/src/pages/Login.jsx +++ b/quizzly-frontend/src/pages/Login.jsx @@ -25,7 +25,6 @@ const Login = () => { // Call login from AuthContext. Backend handles the verification. await login(email, password); - // store the current user localStorage.setItem('quizzlyUser', email); console.log("Current user: ", email); diff --git a/quizzly-frontend/src/pages/PlayQuiz.jsx b/quizzly-frontend/src/pages/PlayQuiz.jsx index 9baae7b..3721e54 100644 --- a/quizzly-frontend/src/pages/PlayQuiz.jsx +++ b/quizzly-frontend/src/pages/PlayQuiz.jsx @@ -1,10 +1,16 @@ import { useState, useEffect, useRef } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; import './PlayQuiz.css'; const PlayQuiz = () => { - const { id: quizId } = useParams(); + const { id: quizId} = useParams(); const navigate = useNavigate(); + const location = useLocation(); // Add this import + +// Extract lobby code from URL (last 6 characters after last slash) + const pathParts = location.pathname.split('/'); + const lobbyCode = pathParts[pathParts.length - 1].length === 6 ? pathParts[pathParts.length - 1] : null; + // State const [quizData, setQuizData] = useState(null); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [selectedOption, setSelectedOption] = useState(null); @@ -13,10 +19,24 @@ const PlayQuiz = () => { const [showAnswer, setShowAnswer] = useState(false); const [isAnswered, setIsAnswered] = useState(false); const [playerAnswers, setPlayerAnswers] = useState([]); - const [gameStatus, setGameStatus] = useState('playing'); // playing, review, ended + const [gameStatus, setGameStatus] = useState('playing'); const [playerNickname, setPlayerNickname] = useState(''); - const timerRef = useRef(null); + // Refs + const timerRef = useRef(null); + const quizDataRef = useRef(); + const currentQuestionIndexRef = useRef(); + const navigationTimeoutRef = useRef(); + const isAnsweredRef = useRef(); + const scoreRef = useRef(0); + // Sync refs with state + useEffect(() => { + quizDataRef.current = quizData; + currentQuestionIndexRef.current = currentQuestionIndex; + isAnsweredRef.current = isAnswered; + scoreRef.current = score; + }, [quizData, currentQuestionIndex, isAnswered, score]); + // Load player info and quiz data useEffect(() => { const storedPlayer = sessionStorage.getItem('quizzlyPlayer'); @@ -24,210 +44,157 @@ const PlayQuiz = () => { const playerData = JSON.parse(storedPlayer); setPlayerNickname(playerData.nickname); } - - // Fetch quiz data fetchQuizData(); return () => { - if (timerRef.current) clearInterval(timerRef.current); + clearInterval(timerRef.current); + clearTimeout(navigationTimeoutRef.current); }; }, [quizId]); - - // Mock fetch quiz data - const fetchQuizData = () => { - // Simulate API call - setTimeout(() => { - const mockQuizData = { - id: quizId, - title: 'Science Quiz: The Basics', - description: 'Test your knowledge of fundamental scientific concepts.', - settings: { - timeLimit: 20, - showAnswers: true, - randomizeQuestions: false - }, - questions: [ - { - id: 'q1', - text: 'What is the chemical symbol for water?', - type: 'multiple', - timeLimit: 20, - points: 100, - options: [ - { id: 'o1', text: 'H2O', isCorrect: true }, - { id: 'o2', text: 'CO2', isCorrect: false }, - { id: 'o3', text: 'O2', isCorrect: false }, - { id: 'o4', text: 'NaCl', isCorrect: false } - ] - }, - { - id: 'q2', - text: 'Which planet is known as the Red Planet?', - type: 'multiple', - timeLimit: 15, - points: 100, - options: [ - { id: 'o1', text: 'Venus', isCorrect: false }, - { id: 'o2', text: 'Mars', isCorrect: true }, - { id: 'o3', text: 'Jupiter', isCorrect: false }, - { id: 'o4', text: 'Saturn', isCorrect: false } - ] - }, - { - id: 'q3', - text: 'What is the largest organ in the human body?', - type: 'multiple', - timeLimit: 15, - points: 100, - options: [ - { id: 'o1', text: 'Heart', isCorrect: false }, - { id: 'o2', text: 'Liver', isCorrect: false }, - { id: 'o3', text: 'Skin', isCorrect: true }, - { id: 'o4', text: 'Brain', isCorrect: false } - ] - }, - { - id: 'q4', - text: 'What is the process by which plants make their own food using sunlight?', - type: 'multiple', - timeLimit: 20, - points: 150, - options: [ - { id: 'o1', text: 'Photosynthesis', isCorrect: true }, - { id: 'o2', text: 'Respiration', isCorrect: false }, - { id: 'o3', text: 'Digestion', isCorrect: false }, - { id: 'o4', text: 'Transpiration', isCorrect: false } - ] - }, - { - id: 'q5', - text: 'Which of these is NOT a state of matter?', - type: 'multiple', - timeLimit: 20, - points: 150, - options: [ - { id: 'o1', text: 'Solid', isCorrect: false }, - { id: 'o2', text: 'Liquid', isCorrect: false }, - { id: 'o3', text: 'Gas', isCorrect: false }, - { id: 'o4', text: 'Energy', isCorrect: true } - ] - } - ] - }; - - setQuizData(mockQuizData); - startQuestion(0, mockQuizData); - }, 1000); + + // Fetch quiz data from the server + const fetchQuizData = async () => { + try { + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/quiz/id/${quizId}`); + if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`); + + const data = await response.json(); + if (data.success) { + setQuizData(data.quiz); + startQuestion(0, data.quiz); + } else { + console.error("Error fetching quiz:", data.error); + } + } catch (error) { + console.error("Failed to fetch quiz data:", error); + } }; - + // Start a question with timer const startQuestion = (index, quiz = quizData) => { - if (!quiz || !quiz.questions[index]) return; - - const question = quiz.questions[index]; - const questionTime = question.timeLimit || quiz.settings.timeLimit; + const currentQuiz = quiz || quizDataRef.current; + if (!currentQuiz?.questions?.[index]) return; + const question = currentQuiz.questions[index]; + const questionTime = question.timeLimit || currentQuiz.timeLimit; + setIsAnswered(false); setSelectedOption(null); setShowAnswer(false); setTimeLeft(questionTime); - // Start timer - if (timerRef.current) clearInterval(timerRef.current); + // Clear existing timer + clearInterval(timerRef.current); + // Start new timer timerRef.current = setInterval(() => { setTimeLeft(prevTime => { if (prevTime <= 1) { clearInterval(timerRef.current); - if (!isAnswered) handleTimeout(); + if (!isAnsweredRef.current) { + const currentQ = quizDataRef.current?.questions?.[currentQuestionIndexRef.current]; + if (currentQ) { + handleTimeout(currentQ); + } + } return 0; } return prevTime - 1; }); }, 1000); }; - + // Handle option selection const handleOptionSelect = (optionId) => { - if (isAnswered) return; - - const currentQuestion = quizData.questions[currentQuestionIndex]; - const selectedOpt = currentQuestion.options.find(o => o.id === optionId); + if (isAnsweredRef.current) return; + + const currentQIndex = currentQuestionIndexRef.current; + const currentQuestion = quizDataRef.current?.questions?.[currentQIndex]; + if (!currentQuestion) return; + // For timeout case (optionId 5), we don't need to check the option + if (optionId !== 5) { + const selectedOpt = currentQuestion.options[optionId]; + if (!selectedOpt) { + console.error("Selected option not found!"); + return; + } + } + setSelectedOption(optionId); setIsAnswered(true); - + + // Determine if the selected option is correct + // Option 5 will always be incorrect since it's not a real option + const isCorrect = optionId !== 5 && currentQuestion.correctAnswer === optionId; + // Calculate score based on time left let questionScore = 0; - if (selectedOpt.isCorrect) { + if (isCorrect) { const timeBonus = Math.floor((timeLeft / currentQuestion.timeLimit) * 50); questionScore = currentQuestion.points + timeBonus; - setScore(prevScore => prevScore + questionScore); + setScore(prevScore => { + const newScore = prevScore + questionScore; + scoreRef.current = newScore; + return newScore; + }); } - + // Record answer setPlayerAnswers(prev => [ ...prev, { - questionId: currentQuestion.id, + questionId: currentQuestion._id, selectedOptionId: optionId, - isCorrect: selectedOpt.isCorrect, + isCorrect: isCorrect, timeLeft, score: questionScore } ]); - - // Show answer if enabled in settings - if (quizData.settings.showAnswers) { - setShowAnswer(true); - - // Proceed to next question after delay - setTimeout(() => { - goToNextQuestion(); - }, 3000); - } else { - // Proceed immediately - goToNextQuestion(); - } - }; + // Show answer + setShowAnswer(true); + + // Clear any existing navigation timeout + clearTimeout(navigationTimeoutRef.current); + // Proceed to next question after delay + navigationTimeoutRef.current = setTimeout(goToNextQuestion, 3000); + }; + // Handle timeout when no answer is selected - const handleTimeout = () => { - const currentQuestion = quizData.questions[currentQuestionIndex]; + const handleTimeout = (currentQuestion) => { + if (!currentQuestion) return; setIsAnswered(true); + setSelectedOption(5); // Record answer as timeout setPlayerAnswers(prev => [ ...prev, { - questionId: currentQuestion.id, - selectedOptionId: null, + questionId: currentQuestion._id, + selectedOptionId: 5, isCorrect: false, timeLeft: 0, score: 0 } ]); - // Show correct answer if enabled - if (quizData.settings.showAnswers) { - setShowAnswer(true); - - // Proceed to next question after delay - setTimeout(() => { - goToNextQuestion(); - }, 3000); - } else { - // Proceed immediately - goToNextQuestion(); - } + // Show correct answer + setShowAnswer(true); + + // Clear any existing navigation timeout + clearTimeout(navigationTimeoutRef.current); + // Proceed to next question after delay + navigationTimeoutRef.current = setTimeout(goToNextQuestion, 3000); }; - + // Go to next question or end quiz const goToNextQuestion = () => { - const nextIndex = currentQuestionIndex + 1; + const nextIndex = currentQuestionIndexRef.current + 1; + const questions = quizDataRef.current?.questions || []; - if (nextIndex < quizData.questions.length) { + if (nextIndex < questions.length) { setCurrentQuestionIndex(nextIndex); startQuestion(nextIndex); } else { @@ -239,38 +206,76 @@ const PlayQuiz = () => { saveResults(); } }; - + // Save quiz results - const saveResults = () => { - // In a real app, send results to server - console.log('Quiz completed with score:', score); - console.log('Player answers:', playerAnswers); - - // Navigate to results page after a delay - setTimeout(() => { + // Save quiz results + const saveResults = async () => { + const results = { + score: scoreRef.current, + answers: playerAnswers, + totalQuestions: quizDataRef.current?.questions?.length || 0, + quizTitle: quizDataRef.current?.title || '' + }; + + try { + // Only proceed if we have a valid lobby code + if (lobbyCode && lobbyCode.length === 6) { + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/lobbies/${lobbyCode}/submit-score`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + nickname: playerNickname, + score: scoreRef.current + }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Failed to submit score'); + } + + // Only navigate after successful save + navigate(`/results/${lobbyCode}`, { + state: results + }); + return; // Exit after successful navigation + } + + // If no lobby code, navigate to regular results page navigate(`/results/${quizId}`, { + state: results + }); + + } catch (error) { + console.error("Failed to submit score to lobby:", error); + // If submission fails, still navigate to results but show error + navigate(`/results/${quizId}${lobbyCode ? `/${lobbyCode}` : ''}`, { state: { - score, - answers: playerAnswers, - totalQuestions: quizData.questions.length, - quizTitle: quizData.title + ...results, + error: "Failed to save to lobby" } }); - }, 3000); + } }; - + // Format time const formatTime = (seconds) => { return `${seconds}s`; }; - + // Calculate progress const calculateProgress = () => { - if (!quizData) return 0; - return ((currentQuestionIndex) / quizData.questions.length) * 100; + const total = quizDataRef.current?.questions?.length || 1; + return (currentQuestionIndexRef.current / total) * 100; }; - - if (!quizData) { + + if (!quizData || !quizData.questions) { return (
    @@ -278,10 +283,9 @@ const PlayQuiz = () => {
    ); } - + const currentQuestion = quizData.questions[currentQuestionIndex]; - const correctOption = currentQuestion.options.find(o => o.isCorrect); - + return (
    {gameStatus === 'playing' && ( @@ -315,29 +319,29 @@ const PlayQuiz = () => {

    {currentQuestion.text}

    - {currentQuestion.options.map((option) => ( + {currentQuestion.options.map((option, index) => ( ))}
    {showAnswer && (
    - {selectedOption && correctOption.id === selectedOption ? ( + {selectedOption !== null && currentQuestion.correctAnswer === selectedOption ? (
    โœ“ Correct! +{playerAnswers[playerAnswers.length - 1].score} points @@ -346,9 +350,9 @@ const PlayQuiz = () => {
    โœ— - {selectedOption - ? `Incorrect. The correct answer is: ${correctOption.text}` - : `Time's up! The correct answer is: ${correctOption.text}`} + {selectedOption !== null + ? `Incorrect. The correct answer is: ${currentQuestion.options[currentQuestion.correctAnswer]}` + : `Time's up! The correct answer is: ${currentQuestion.options[currentQuestion.correctAnswer]}`}
    )} diff --git a/quizzly-frontend/src/pages/QuizResults.css b/quizzly-frontend/src/pages/QuizResults.css index aa11cad..36c0f73 100644 --- a/quizzly-frontend/src/pages/QuizResults.css +++ b/quizzly-frontend/src/pages/QuizResults.css @@ -243,4 +243,114 @@ .stats-grid { grid-template-columns: repeat(3, 1fr); } - } \ No newline at end of file + } + + .lobby-code { + font-size: 0.9rem; + color: var(--text-light); + margin-bottom: 0.25rem; + } + + .results-header h1 { + font-size: 2.8rem; + font-weight: 700; + color: var(--primary); + } + + .results-summary { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 3rem; + } + + .score-card { + background: linear-gradient(to right, var(--primary), var(--primary-light)); + color: var(--white); + padding: 1.5rem; + border-radius: 16px; + text-align: center; + margin-bottom: 2rem; + width: 200px; + } + + .stats-grid { + display: flex; + gap: 2rem; + justify-content: center; + } + + .stat-card { + background: var(--white); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + padding: 1.2rem; + border-radius: 12px; + text-align: center; + width: 120px; + } + + .leaderboard { + margin-top: 2rem; + overflow: hidden; + border-radius: 16px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); + } + + .leaderboard-header { + background: var(--primary); + color: var(--white); + display: grid; + grid-template-columns: 80px 1fr 100px; + padding: 1rem; + font-weight: bold; + } + + .leaderboard-row { + display: grid; + grid-template-columns: 80px 1fr 100px; + align-items: center; + padding: 1rem; + border-top: 1px solid var(--gray-light); + transition: background 0.3s; + } + + .leaderboard-row:hover { + background: rgba(0, 0, 0, 0.04); + } + + .current-player { + background: rgba(142, 68, 173, 0.1); + font-weight: bold; + } + + .results-actions { + display: flex; + justify-content: center; + gap: 1rem; + margin-top: 3rem; + } + + .btn { + padding: 0.8rem 1.5rem; + border-radius: 8px; + font-weight: 600; + transition: background 0.3s; + font-size: 1rem; + } + + .btn-primary { + background: var(--primary); + color: var(--white); + border: none; + } + + .btn-secondary { + background: var(--gray-light); + color: var(--primary); + border: none; + } + + .btn:hover { + opacity: 0.9; + } + \ No newline at end of file diff --git a/quizzly-frontend/src/pages/QuizResults.jsx b/quizzly-frontend/src/pages/QuizResults.jsx index 91a9048..7899f1a 100644 --- a/quizzly-frontend/src/pages/QuizResults.jsx +++ b/quizzly-frontend/src/pages/QuizResults.jsx @@ -3,237 +3,208 @@ import { useParams, useLocation, useNavigate, Link } from 'react-router-dom'; import './QuizResults.css'; const QuizResults = () => { - const { id: quizId } = useParams(); + // Extract lobbyCode from URL parameters + const { lobbyCode } = useParams(); const location = useLocation(); const navigate = useNavigate(); - const [results, setResults] = useState(null); + + const [results, setResults] = useState({ + score: 0, + quizTitle: `Lobby ${lobbyCode}`, + playerNickname: '' + }); const [loading, setLoading] = useState(true); - const [showDetails, setShowDetails] = useState(false); const [leaderboard, setLeaderboard] = useState([]); + const [error, setError] = useState(null); + - useEffect(() => { - // If results were passed via state, use them - if (location.state?.score !== undefined) { - setResults({ - score: location.state.score, - answers: location.state.answers, - totalQuestions: location.state.totalQuestions, - quizTitle: location.state.quizTitle - }); - setLoading(false); - - // Fetch leaderboard data - fetchLeaderboard(); - } else { - // Otherwise, fetch results from API - fetchResults(); - } - }, [location, quizId]); - - // Mock fetch results - const fetchResults = () => { - // Simulate API call - setTimeout(() => { - // Mock data - const mockResults = { - score: 350, - totalQuestions: 5, - quizTitle: 'Science Quiz: The Basics', - answers: [ - { questionId: 'q1', isCorrect: true, score: 120 }, - { questionId: 'q2', isCorrect: false, score: 0 }, - { questionId: 'q3', isCorrect: true, score: 100 }, - { questionId: 'q4', isCorrect: true, score: 130 }, - { questionId: 'q5', isCorrect: false, score: 0 } - ] + useEffect(() => { + let intervalId; + const pollingInterval = 7000; // 3 seconds + + const fetchLobbyData = async () => { + try { + // First validate we have a lobbyCode + if (!lobbyCode || lobbyCode.length !== 6) { + throw new Error('Invalid lobby code format'); + } + + // Get player info from session storage + const playerData = JSON.parse(sessionStorage.getItem('quizzlyPlayer')) || {}; + const playerNickname = playerData.nickname || 'You'; + + // Only set state from navigation on initial load + if (location.state && !results.score) { + setResults(prev => ({ + ...prev, + score: location.state.score || 0, + answers: location.state.answers, + totalQuestions: location.state.totalQuestions, + quizTitle: location.state.quizTitle || `Lobby ${lobbyCode}`, + playerNickname + })); + } else if (!results.playerNickname) { + setResults(prev => ({ + ...prev, + playerNickname, + quizTitle: `Lobby ${lobbyCode}` + })); + } + + // Fetch lobby results from backend + const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/api/results/${lobbyCode}`); + + if (!response.ok) { + throw new Error(`Failed to fetch lobby data: ${response.status}`); + } + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to load lobby data'); + } + + // Process scores from backend + const scores = data.data.scores || []; + const formattedLeaderboard = scores + .map(entry => ({ + nickname: entry.nickname, + score: entry.score, + isCurrentPlayer: entry.nickname === (results.playerNickname || playerNickname) + })) + .sort((a, b) => b.score - a.score) + .map((player, index) => ({ + ...player, + rank: index + 1 + })); + + setLeaderboard(formattedLeaderboard); + + // Update current player's score if not set from location.state + if (!location.state?.score) { + const playerEntry = formattedLeaderboard.find(p => p.isCurrentPlayer); + if (playerEntry) { + setResults(prev => ({ + ...prev, + score: playerEntry.score + })); + } + } + + } catch (err) { + console.error('Error loading lobby results:', err); + setError(err.message); + // Don't stop polling on error - try again next interval + } }; - - setResults(mockResults); - setLoading(false); - - // Fetch leaderboard data - fetchLeaderboard(); - }, 1000); - }; - - // Mock fetch leaderboard - const fetchLeaderboard = () => { - // Simulate API call - setTimeout(() => { - // Generate mock leaderboard with current player included - const playerNickname = JSON.parse(sessionStorage.getItem('quizzlyPlayer'))?.nickname || 'You'; - - const mockLeaderboard = [ - { rank: 1, name: 'ScienceWhiz', score: 480, isCurrentPlayer: false }, - { rank: 2, name: playerNickname, score: location.state?.score || 350, isCurrentPlayer: true }, - { rank: 3, name: 'BrainiacGamer', score: 320, isCurrentPlayer: false }, - { rank: 4, name: 'QuizMaster', score: 290, isCurrentPlayer: false }, - { rank: 5, name: 'MindExplorer', score: 270, isCurrentPlayer: false }, - { rank: 6, name: 'ThinkTank', score: 240, isCurrentPlayer: false }, - { rank: 7, name: 'QuizWizard', score: 210, isCurrentPlayer: false }, - { rank: 8, name: 'GameChamp', score: 180, isCurrentPlayer: false } - ]; - - setLeaderboard(mockLeaderboard); - }, 1500); - }; - - // Calculate accuracy percentage - const calculateAccuracy = () => { - if (!results || !results.answers || results.answers.length === 0) return 0; - const correctAnswers = results.answers.filter(a => a.isCorrect).length; - return Math.round((correctAnswers / results.totalQuestions) * 100); - }; - - // Calculate performance score (0-100) - const calculatePerformance = () => { - if (!results || !results.answers || results.answers.length === 0) return 0; + // Initial fetch + fetchLobbyData().finally(() => { + setLoading(false); + // Start polling after initial load + intervalId = setInterval(fetchLobbyData, pollingInterval); + }); - // Assuming maximum possible score is 150 points per question (100 base + 50 time bonus) - const maxPossibleScore = results.totalQuestions * 150; - return Math.round((results.score / maxPossibleScore) * 100); + // Cleanup interval on unmount + return () => { + if (intervalId) clearInterval(intervalId); + }; + }, [lobbyCode, location.state, results.playerNickname, results.score]); + // Calculate player's rank + const playerRank = leaderboard.findIndex(p => p.isCurrentPlayer) + 1 || null; + + // Calculate performance percentage (0-100) + const calculatePerformance = () => { + if (leaderboard.length === 0) return 0; + const topScore = leaderboard[0]?.score || 1; + return Math.min(Math.round((results.score / topScore) * 100), 100); }; - - // Play again with same quiz + + // Play again in the same lobby const playAgain = () => { - navigate(`/play/${quizId}`); + navigate(`/play-quiz/${location.state?.quizId || 'lobby'}/${lobbyCode}`); }; - - // Share results - const shareResults = () => { - // In a real app, this would generate a shareable link or open a share dialog - alert('Share functionality would be implemented here'); - }; - + if (loading) { return (
    -

    Loading your results...

    +

    Loading lobby results...

    ); } - + + if (error) { + return ( +
    +

    Error Loading Lobby

    +

    {error}

    +
    + + Back to Dashboard + + +
    +
    + ); + } + return (
    -
    -

    Quiz Results

    -

    {results.quizTitle}

    +
    +

    {lobbyCode}

    +

    Lobby Results

    + {results.quizTitle &&

    {results.quizTitle}

    } +
    + +
    +
    +
    {results.score}
    +
    Your Score
    +
    +
    + +
    +

    Leaderboard

    +
    +
    + Rank + Player + Score
    - -
    -
    -
    -
    {results.score}
    -
    Final Score
    -
    - -
    -
    -
    {calculateAccuracy()}%
    -
    Accuracy
    -
    - -
    -
    {results.answers.filter(a => a.isCorrect).length}/{results.totalQuestions}
    -
    Correct Answers
    -
    - -
    -
    {calculatePerformance()}/100
    -
    Performance
    + +
    + {leaderboard.map((player) => ( +
    +
    + {player.rank <= 3 ? ( + + {player.rank === 1 ? '๐Ÿฅ‡' : player.rank === 2 ? '๐Ÿฅˆ' : '๐Ÿฅ‰'} + + ) : ( + {player.rank} + )}
    -
    - -
    - - - -
    -
    - - {showDetails && ( -
    -

    Question Details

    - -
    - {results.answers.map((answer, index) => ( -
    -
    Q{index + 1}
    -
    - {answer.isCorrect ? 'โœ“ Correct' : 'โœ— Incorrect'} -
    -
    - +{answer.score} pts -
    -
    - ))} +
    + {player.nickname} {player.isCurrentPlayer && (You)}
    +
    {player.score}
    - )} - -
    -

    Leaderboard

    - - {leaderboard.length === 0 ? ( -
    -
    -

    Loading leaderboard...

    -
    - ) : ( -
    -
    - Rank - Player - Score -
    - -
    - {leaderboard.map((player) => ( -
    -
    - {player.rank <= 3 ? ( -
    - {player.rank === 1 ? '๐Ÿฅ‡' : player.rank === 2 ? '๐Ÿฅˆ' : '๐Ÿฅ‰'} -
    - ) : ( - {player.rank} - )} -
    -
    - {player.name} {player.isCurrentPlayer && '(You)'} -
    -
    {player.score}
    -
    - ))} -
    -
    - )} -
    - -
    - - Back to Dashboard - - - Join Another Game - -
    + ))}
    +
    + +
    + Join Another Lobby +
    +
    + ); };