Add category grouping in game queue and category slide transitions
Questions in the game queue are now grouped by category with per-group drag-and-drop constraints and category reorder buttons. During live games, a full-screen category slide appears for 3 seconds when transitioning between categories. Backend now includes previous_category and category_changed fields in question_changed events. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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,6 +524,55 @@ export default function ContestantView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category slide overlay */}
|
||||
{showCategorySlide && pendingQuestion && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "black",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
animation: "categorySlideIn 0.5s ease-out",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.2rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "8px",
|
||||
marginBottom: "1.5rem",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
>
|
||||
Next Category
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "5rem",
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
padding: "0 2rem",
|
||||
}}
|
||||
>
|
||||
{pendingQuestion.question?.category || "Uncategorized"}
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes categorySlideIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right side: Scoreboard */}
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
Shuffle,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Image,
|
||||
@@ -67,6 +68,11 @@ interface Template {
|
||||
total_questions: number;
|
||||
}
|
||||
|
||||
interface CategoryGroup {
|
||||
category: string;
|
||||
questionIds: number[];
|
||||
}
|
||||
|
||||
type PoolRow =
|
||||
| { kind: "category"; category: string; count: number; selectedCount: number }
|
||||
| { kind: "question"; question: Question; isSelected: boolean };
|
||||
@@ -114,14 +120,6 @@ function SortableQuestionRow({
|
||||
<span className="w-5 shrink-0 text-center text-xs tabular-nums text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
{question.category && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 text-[0.65rem]"
|
||||
>
|
||||
{question.category}
|
||||
</Badge>
|
||||
)}
|
||||
<TypeIcon type={question.type} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{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<CategoryGroup[]>(() => {
|
||||
const groups: CategoryGroup[] = [];
|
||||
const catMap = new Map<string, number[]>();
|
||||
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<string, number[]>();
|
||||
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) {
|
||||
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);
|
||||
@@ -898,28 +969,61 @@ export default function GameSetupView() {
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={[restrictToParentElement]}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedQuestions}
|
||||
strategy={
|
||||
verticalListSortingStrategy
|
||||
}
|
||||
>
|
||||
<div className="grid gap-1 p-2">
|
||||
{selectedQuestionObjects.map(
|
||||
(q, i) => (
|
||||
{(() => {
|
||||
let runningIndex = 0;
|
||||
return groupedQueue.map(
|
||||
(group, gi) => (
|
||||
<div key={group.category}>
|
||||
{/* Category header */}
|
||||
<div className="flex items-center gap-1 px-1 py-1.5">
|
||||
<span className="flex-1 text-xs font-semibold text-muted-foreground">
|
||||
{group.category}
|
||||
<span className="ml-1 font-normal">
|
||||
({group.questionIds.length})
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => moveCategoryGroup(gi, -1)}
|
||||
disabled={gi === 0}
|
||||
className="rounded p-0.5 text-muted-foreground hover:bg-muted disabled:opacity-30"
|
||||
title="Move category up"
|
||||
>
|
||||
<ChevronUp className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveCategoryGroup(gi, 1)}
|
||||
disabled={gi === groupedQueue.length - 1}
|
||||
className="rounded p-0.5 text-muted-foreground hover:bg-muted disabled:opacity-30"
|
||||
title="Move category down"
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<SortableContext
|
||||
items={group.questionIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{group.questionIds.map((id) => {
|
||||
const q = questions.find((qq) => qq.id === id);
|
||||
if (!q) return null;
|
||||
const idx = runningIndex++;
|
||||
return (
|
||||
<SortableQuestionRow
|
||||
key={q.id}
|
||||
question={q}
|
||||
index={i}
|
||||
onRemove={
|
||||
removeQuestion
|
||||
}
|
||||
index={idx}
|
||||
onRemove={removeQuestion}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user