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:
|
if not current_question:
|
||||||
return
|
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)
|
# Emit to contestant room (without answer)
|
||||||
socketio_instance.emit('question_changed', {
|
socketio_instance.emit('question_changed', {
|
||||||
'question_index': game.current_question_index,
|
'question_index': game.current_question_index,
|
||||||
'question': current_question.to_dict(include_answer=False),
|
'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')
|
}, room=f'game_{game.id}_contestant')
|
||||||
|
|
||||||
# Emit to admin room (with answer)
|
# Emit to admin room (with answer)
|
||||||
socketio_instance.emit('question_with_answer', {
|
socketio_instance.emit('question_with_answer', {
|
||||||
'question_index': game.current_question_index,
|
'question_index': game.current_question_index,
|
||||||
'question': current_question.to_dict(include_answer=True),
|
'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')
|
}, 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 { useParams } from "react-router-dom";
|
||||||
import { useSocket } from "../../hooks/useSocket";
|
import { useSocket } from "../../hooks/useSocket";
|
||||||
import { gamesAPI } from "../../services/api";
|
import { gamesAPI } from "../../services/api";
|
||||||
@@ -23,6 +23,12 @@ export default function ContestantView() {
|
|||||||
const [questionIndex, setQuestionIndex] = useState(0);
|
const [questionIndex, setQuestionIndex] = useState(0);
|
||||||
const [totalQuestions, setTotalQuestions] = 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(() => {
|
useEffect(() => {
|
||||||
// Load initial game state
|
// Load initial game state
|
||||||
const loadGameState = async () => {
|
const loadGameState = async () => {
|
||||||
@@ -107,14 +113,41 @@ export default function ContestantView() {
|
|||||||
|
|
||||||
socket.on("question_changed", (data) => {
|
socket.on("question_changed", (data) => {
|
||||||
console.log("Question changed:", data);
|
console.log("Question changed:", data);
|
||||||
setCurrentQuestion(data.question);
|
|
||||||
setQuestionIndex(data.question_index);
|
const applyQuestion = (questionData) => {
|
||||||
setTotalQuestions(data.total_questions);
|
setCurrentQuestion(questionData.question);
|
||||||
setShowAnswer(false); // Hide answer when question changes
|
setQuestionIndex(questionData.question_index);
|
||||||
setAnswer("");
|
setTotalQuestions(questionData.total_questions);
|
||||||
setTimerSeconds(30);
|
setShowAnswer(false);
|
||||||
setTimerActive(true);
|
setAnswer("");
|
||||||
setTimerPaused(false);
|
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) => {
|
socket.on("score_updated", (data) => {
|
||||||
@@ -149,6 +182,13 @@ export default function ContestantView() {
|
|||||||
setTimerPaused(false);
|
setTimerPaused(false);
|
||||||
setCurrentTurnTeamId(null);
|
setCurrentTurnTeamId(null);
|
||||||
setCurrentTurnTeamName(null);
|
setCurrentTurnTeamName(null);
|
||||||
|
setPreviousCategory(null);
|
||||||
|
setShowCategorySlide(false);
|
||||||
|
setPendingQuestion(null);
|
||||||
|
if (categorySlideTimeoutRef.current) {
|
||||||
|
clearTimeout(categorySlideTimeoutRef.current);
|
||||||
|
categorySlideTimeoutRef.current = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("lifeline_updated", (data) => {
|
socket.on("lifeline_updated", (data) => {
|
||||||
@@ -484,7 +524,56 @@ export default function ContestantView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side: Scoreboard */}
|
{/* 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
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: "1",
|
flex: "1",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
Shuffle,
|
Shuffle,
|
||||||
Trash2,
|
Trash2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileText,
|
FileText,
|
||||||
Image,
|
Image,
|
||||||
@@ -67,6 +68,11 @@ interface Template {
|
|||||||
total_questions: number;
|
total_questions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CategoryGroup {
|
||||||
|
category: string;
|
||||||
|
questionIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
type PoolRow =
|
type PoolRow =
|
||||||
| { kind: "category"; category: string; count: number; selectedCount: number }
|
| { kind: "category"; category: string; count: number; selectedCount: number }
|
||||||
| { kind: "question"; question: Question; isSelected: boolean };
|
| { 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">
|
<span className="w-5 shrink-0 text-center text-xs tabular-nums text-muted-foreground">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
{question.category && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="shrink-0 text-[0.65rem]"
|
|
||||||
>
|
|
||||||
{question.category}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<TypeIcon type={question.type} />
|
<TypeIcon type={question.type} />
|
||||||
<span className="min-w-0 flex-1 truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{question.type !== "text" && question.answer
|
{question.type !== "text" && question.answer
|
||||||
@@ -295,6 +293,26 @@ export default function GameSetupView() {
|
|||||||
[selectedQuestions, questions],
|
[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 ─────────────────────────────────────── */
|
/* ─── Pool virtualizer ─────────────────────────────────────── */
|
||||||
const poolVirtualizer = useVirtualizer({
|
const poolVirtualizer = useVirtualizer({
|
||||||
count: poolRows.length,
|
count: poolRows.length,
|
||||||
@@ -306,19 +324,49 @@ export default function GameSetupView() {
|
|||||||
overscan: 20,
|
overscan: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ─── Queue virtualizer ────────────────────────────────────── */
|
/* ─── Utility: sort IDs by category grouping ──────────────── */
|
||||||
const queueVirtualizer = useVirtualizer({
|
const sortByCategory = useCallback(
|
||||||
count: selectedQuestionObjects.length,
|
(ids: number[]) => {
|
||||||
getScrollElement: () => queueScrollRef.current,
|
const groups = new Map<string, number[]>();
|
||||||
estimateSize: () => 34,
|
const order: string[] = [];
|
||||||
overscan: 10,
|
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 ─────────────────────────────────────── */
|
/* ─── Question actions ─────────────────────────────────────── */
|
||||||
const addQuestion = (id: number) => {
|
const addQuestion = (id: number) => {
|
||||||
if (!selectedQuestions.includes(id)) {
|
if (selectedQuestions.includes(id)) return;
|
||||||
setSelectedQuestions((prev) => [...prev, id]);
|
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) => {
|
const removeQuestion = (id: number) => {
|
||||||
@@ -346,13 +394,32 @@ export default function GameSetupView() {
|
|||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (over && active.id !== over.id) {
|
if (!over || active.id === over.id) return;
|
||||||
setSelectedQuestions((items) => {
|
|
||||||
const oldIndex = items.indexOf(active.id as number);
|
// Verify same category
|
||||||
const newIndex = items.indexOf(over.id as number);
|
const activeQ = questions.find((q) => q.id === active.id);
|
||||||
return arrayMove(items, oldIndex, newIndex);
|
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 ─────────────────────────────────────── */
|
/* ─── Random questions ─────────────────────────────────────── */
|
||||||
@@ -390,7 +457,9 @@ export default function GameSetupView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newQuestionIds.length > 0) {
|
if (newQuestionIds.length > 0) {
|
||||||
setSelectedQuestions((prev) => [...prev, ...newQuestionIds]);
|
setSelectedQuestions((prev) =>
|
||||||
|
sortByCategory([...prev, ...newQuestionIds]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setRandomSelections({});
|
setRandomSelections({});
|
||||||
setShowRandomPanel(false);
|
setShowRandomPanel(false);
|
||||||
@@ -423,7 +492,9 @@ export default function GameSetupView() {
|
|||||||
);
|
);
|
||||||
return [...prev, ...newQs];
|
return [...prev, ...newQs];
|
||||||
});
|
});
|
||||||
setSelectedQuestions(templateQuestions.map((q) => q.id));
|
setSelectedQuestions(
|
||||||
|
sortByCategory(templateQuestions.map((q) => q.id)),
|
||||||
|
);
|
||||||
setTemplateDialogOpen(false);
|
setTemplateDialogOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading template:", error);
|
console.error("Error loading template:", error);
|
||||||
@@ -899,27 +970,60 @@ export default function GameSetupView() {
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
modifiers={[restrictToParentElement]}
|
modifiers={[restrictToParentElement]}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<div className="grid gap-1 p-2">
|
||||||
items={selectedQuestions}
|
{(() => {
|
||||||
strategy={
|
let runningIndex = 0;
|
||||||
verticalListSortingStrategy
|
return groupedQueue.map(
|
||||||
}
|
(group, gi) => (
|
||||||
>
|
<div key={group.category}>
|
||||||
<div className="grid gap-1 p-2">
|
{/* Category header */}
|
||||||
{selectedQuestionObjects.map(
|
<div className="flex items-center gap-1 px-1 py-1.5">
|
||||||
(q, i) => (
|
<span className="flex-1 text-xs font-semibold text-muted-foreground">
|
||||||
<SortableQuestionRow
|
{group.category}
|
||||||
key={q.id}
|
<span className="ml-1 font-normal">
|
||||||
question={q}
|
({group.questionIds.length})
|
||||||
index={i}
|
</span>
|
||||||
onRemove={
|
</span>
|
||||||
removeQuestion
|
<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={idx}
|
||||||
|
onRemove={removeQuestion}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
)}
|
);
|
||||||
</div>
|
})()}
|
||||||
</SortableContext>
|
</div>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user