diff --git a/backend/services/game_service.py b/backend/services/game_service.py index 893d581..34a387c 100644 --- a/backend/services/game_service.py +++ b/backend/services/game_service.py @@ -106,18 +106,31 @@ def broadcast_question_change(game, socketio_instance): if not current_question: return + # Determine previous category for category slide transitions + prev_category = None + if game.current_question_index > 0: + prev_gq = game.game_questions[game.current_question_index - 1] + prev_category = prev_gq.question.category + + current_category = current_question.category + category_changed = current_category != prev_category + # Emit to contestant room (without answer) socketio_instance.emit('question_changed', { 'question_index': game.current_question_index, 'question': current_question.to_dict(include_answer=False), - 'total_questions': len(game.game_questions) + 'total_questions': len(game.game_questions), + 'previous_category': prev_category, + 'category_changed': category_changed }, room=f'game_{game.id}_contestant') # Emit to admin room (with answer) socketio_instance.emit('question_with_answer', { 'question_index': game.current_question_index, 'question': current_question.to_dict(include_answer=True), - 'total_questions': len(game.game_questions) + 'total_questions': len(game.game_questions), + 'previous_category': prev_category, + 'category_changed': category_changed }, room=f'game_{game.id}_admin') diff --git a/frontend/frontend/src/components/contestant/ContestantView.jsx b/frontend/frontend/src/components/contestant/ContestantView.jsx index b4188c8..072865a 100644 --- a/frontend/frontend/src/components/contestant/ContestantView.jsx +++ b/frontend/frontend/src/components/contestant/ContestantView.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useParams } from "react-router-dom"; import { useSocket } from "../../hooks/useSocket"; import { gamesAPI } from "../../services/api"; @@ -23,6 +23,12 @@ export default function ContestantView() { const [questionIndex, setQuestionIndex] = useState(0); const [totalQuestions, setTotalQuestions] = useState(0); + // Category slide state + const [previousCategory, setPreviousCategory] = useState(null); + const [showCategorySlide, setShowCategorySlide] = useState(false); + const [pendingQuestion, setPendingQuestion] = useState(null); + const categorySlideTimeoutRef = useRef(null); + useEffect(() => { // Load initial game state const loadGameState = async () => { @@ -107,14 +113,41 @@ export default function ContestantView() { socket.on("question_changed", (data) => { console.log("Question changed:", data); - setCurrentQuestion(data.question); - setQuestionIndex(data.question_index); - setTotalQuestions(data.total_questions); - setShowAnswer(false); // Hide answer when question changes - setAnswer(""); - setTimerSeconds(30); - setTimerActive(true); - setTimerPaused(false); + + const applyQuestion = (questionData) => { + setCurrentQuestion(questionData.question); + setQuestionIndex(questionData.question_index); + setTotalQuestions(questionData.total_questions); + setShowAnswer(false); + setAnswer(""); + setTimerSeconds(30); + setTimerActive(true); + setTimerPaused(false); + setPreviousCategory(questionData.question?.category || null); + }; + + // Clear any existing category slide timeout + if (categorySlideTimeoutRef.current) { + clearTimeout(categorySlideTimeoutRef.current); + categorySlideTimeoutRef.current = null; + } + + if (data.category_changed) { + // Category changed — show category slide, then apply question after 3s + setPendingQuestion(data); + setShowCategorySlide(true); + categorySlideTimeoutRef.current = setTimeout(() => { + setShowCategorySlide(false); + setPendingQuestion(null); + applyQuestion(data); + categorySlideTimeoutRef.current = null; + }, 3000); + } else { + // Same category — apply immediately + setShowCategorySlide(false); + setPendingQuestion(null); + applyQuestion(data); + } }); socket.on("score_updated", (data) => { @@ -149,6 +182,13 @@ export default function ContestantView() { setTimerPaused(false); setCurrentTurnTeamId(null); setCurrentTurnTeamName(null); + setPreviousCategory(null); + setShowCategorySlide(false); + setPendingQuestion(null); + if (categorySlideTimeoutRef.current) { + clearTimeout(categorySlideTimeoutRef.current); + categorySlideTimeoutRef.current = null; + } }); socket.on("lifeline_updated", (data) => { @@ -484,7 +524,56 @@ export default function ContestantView() { - {/* Right side: Scoreboard */} + {/* Category slide overlay */} + {showCategorySlide && pendingQuestion && ( +
+
+ Next Category +
+
+ {pendingQuestion.question?.category || "Uncategorized"} +
+ +
+ )} + + {/* Right side: Scoreboard */}
{index + 1} - {question.category && ( - - {question.category} - - )} {question.type !== "text" && question.answer @@ -295,6 +293,26 @@ export default function GameSetupView() { [selectedQuestions, questions], ); + /* ─── Grouped queue (by category, preserving first-appearance order) ── */ + const groupedQueue = useMemo(() => { + const groups: CategoryGroup[] = []; + const catMap = new Map(); + const catOrder: string[] = []; + for (const id of selectedQuestions) { + const q = questions.find((qq) => qq.id === id); + const cat = q?.category || "Uncategorized"; + if (!catMap.has(cat)) { + catMap.set(cat, []); + catOrder.push(cat); + } + catMap.get(cat)!.push(id); + } + for (const cat of catOrder) { + groups.push({ category: cat, questionIds: catMap.get(cat)! }); + } + return groups; + }, [selectedQuestions, questions]); + /* ─── Pool virtualizer ─────────────────────────────────────── */ const poolVirtualizer = useVirtualizer({ count: poolRows.length, @@ -306,19 +324,49 @@ export default function GameSetupView() { overscan: 20, }); - /* ─── Queue virtualizer ────────────────────────────────────── */ - const queueVirtualizer = useVirtualizer({ - count: selectedQuestionObjects.length, - getScrollElement: () => queueScrollRef.current, - estimateSize: () => 34, - overscan: 10, - }); + /* ─── Utility: sort IDs by category grouping ──────────────── */ + const sortByCategory = useCallback( + (ids: number[]) => { + const groups = new Map(); + const order: string[] = []; + for (const id of ids) { + const q = questions.find((qq) => qq.id === id); + const cat = q?.category || "Uncategorized"; + if (!groups.has(cat)) { + groups.set(cat, []); + order.push(cat); + } + groups.get(cat)!.push(id); + } + return order.flatMap((cat) => groups.get(cat)!); + }, + [questions], + ); /* ─── Question actions ─────────────────────────────────────── */ const addQuestion = (id: number) => { - if (!selectedQuestions.includes(id)) { - setSelectedQuestions((prev) => [...prev, id]); - } + if (selectedQuestions.includes(id)) return; + const q = questions.find((qq) => qq.id === id); + const cat = q?.category || "Uncategorized"; + + setSelectedQuestions((prev) => { + // Find last index of a question in the same category + let insertAt = -1; + for (let i = prev.length - 1; i >= 0; i--) { + const existing = questions.find((qq) => qq.id === prev[i]); + if ((existing?.category || "Uncategorized") === cat) { + insertAt = i + 1; + break; + } + } + if (insertAt === -1) { + // New category — append to end + return [...prev, id]; + } + const next = [...prev]; + next.splice(insertAt, 0, id); + return next; + }); }; const removeQuestion = (id: number) => { @@ -346,13 +394,32 @@ export default function GameSetupView() { const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; - if (over && active.id !== over.id) { - setSelectedQuestions((items) => { - const oldIndex = items.indexOf(active.id as number); - const newIndex = items.indexOf(over.id as number); - return arrayMove(items, oldIndex, newIndex); - }); - } + if (!over || active.id === over.id) return; + + // Verify same category + const activeQ = questions.find((q) => q.id === active.id); + const overQ = questions.find((q) => q.id === over.id); + const activeCat = activeQ?.category || "Uncategorized"; + const overCat = overQ?.category || "Uncategorized"; + if (activeCat !== overCat) return; + + setSelectedQuestions((items) => { + const oldIndex = items.indexOf(active.id as number); + const newIndex = items.indexOf(over.id as number); + return arrayMove(items, oldIndex, newIndex); + }); + }; + + /* ─── Category group reorder ──────────────────────────────── */ + const moveCategoryGroup = (categoryIndex: number, direction: -1 | 1) => { + const newIndex = categoryIndex + direction; + if (newIndex < 0 || newIndex >= groupedQueue.length) return; + + const newGroups = [...groupedQueue]; + const [moved] = newGroups.splice(categoryIndex, 1); + newGroups.splice(newIndex, 0, moved); + + setSelectedQuestions(newGroups.flatMap((g) => g.questionIds)); }; /* ─── Random questions ─────────────────────────────────────── */ @@ -390,7 +457,9 @@ export default function GameSetupView() { } if (newQuestionIds.length > 0) { - setSelectedQuestions((prev) => [...prev, ...newQuestionIds]); + setSelectedQuestions((prev) => + sortByCategory([...prev, ...newQuestionIds]), + ); } setRandomSelections({}); setShowRandomPanel(false); @@ -423,7 +492,9 @@ export default function GameSetupView() { ); return [...prev, ...newQs]; }); - setSelectedQuestions(templateQuestions.map((q) => q.id)); + setSelectedQuestions( + sortByCategory(templateQuestions.map((q) => q.id)), + ); setTemplateDialogOpen(false); } catch (error) { console.error("Error loading template:", error); @@ -899,27 +970,60 @@ export default function GameSetupView() { onDragEnd={handleDragEnd} modifiers={[restrictToParentElement]} > - -
- {selectedQuestionObjects.map( - (q, i) => ( - +
+ {(() => { + let runningIndex = 0; + return groupedQueue.map( + (group, gi) => ( +
+ {/* Category header */} +
+ + {group.category} + + ({group.questionIds.length}) + + + + +
+ + {group.questionIds.map((id) => { + const q = questions.find((qq) => qq.id === id); + if (!q) return null; + const idx = runningIndex++; + return ( + + ); + })} + +
), - )} -
- + ); + })()} +
)}