Show answer text for image questions and virtualize question pool

Display the answer field instead of question_content for non-text
questions (image/audio/video) in both the pool and queue panels.
Add @tanstack/react-virtual to virtualize the question pool list
for smooth scrolling with large question banks. Search also matches
against the answer field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 13:24:27 -04:00
parent 67cc877e30
commit bc4eaf53bf
3 changed files with 258 additions and 130 deletions

View File

@@ -13,6 +13,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.8",
"@tanstack/react-virtual": "^3.13.23",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -2551,6 +2552,33 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.23"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@ts-morph/common": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",

View File

@@ -15,6 +15,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.8",
"@tanstack/react-virtual": "^3.13.23",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { Link, useNavigate } from "react-router-dom";
import {
DndContext,
@@ -17,12 +17,12 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useVirtualizer } from "@tanstack/react-virtual";
import { questionsAPI, gamesAPI, teamsAPI } from "../../services/api";
import AdminNavbar from "../common/AdminNavbar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
@@ -54,6 +54,7 @@ import {
interface Question {
id: number;
question_content: string;
answer?: string;
type: string;
category?: string;
[key: string]: unknown;
@@ -65,6 +66,10 @@ interface Template {
total_questions: number;
}
type PoolRow =
| { kind: "category"; category: string; count: number; selectedCount: number }
| { kind: "question"; question: Question; isSelected: boolean };
/* ─── Sortable row inside the game queue ───────────────────────────── */
function SortableQuestionRow({
question,
@@ -118,7 +123,9 @@ function SortableQuestionRow({
)}
<TypeIcon type={question.type} />
<span className="min-w-0 flex-1 truncate">
{question.question_content}
{question.type !== "text" && question.answer
? question.answer
: question.question_content}
</span>
<button
onClick={() => onRemove(question.id)}
@@ -145,6 +152,11 @@ function TypeIcon({ type }: { type: string }) {
}
}
/* ─── Question label helper ────────────────────────────────────────── */
function questionLabel(q: Question) {
return q.type !== "text" && q.answer ? q.answer : q.question_content;
}
/* ═══════════════════════════════════════════════════════════════════ */
export default function GameSetupView() {
const navigate = useNavigate();
@@ -169,6 +181,8 @@ export default function GameSetupView() {
const [showRandomPanel, setShowRandomPanel] = useState(false);
const teamInputRef = useRef<HTMLInputElement>(null);
const poolScrollRef = useRef<HTMLDivElement>(null);
const queueScrollRef = useRef<HTMLDivElement>(null);
/* ─── Load data ────────────────────────────────────────────── */
useEffect(() => {
@@ -226,6 +240,8 @@ export default function GameSetupView() {
const matched = qs.filter(
(question) =>
question.question_content.toLowerCase().includes(q) ||
(question.answer &&
question.answer.toLowerCase().includes(q)) ||
cat.toLowerCase().includes(q),
);
if (matched.length > 0) result[cat] = matched;
@@ -238,6 +254,38 @@ export default function GameSetupView() {
[filteredGrouped],
);
/* Flatten categories + questions into a single list for virtualization */
const poolRows = useMemo<PoolRow[]>(() => {
const rows: PoolRow[] = [];
for (const cat of filteredCategoryNames) {
const qs = filteredGrouped[cat];
const selectedCount = qs.filter((q) =>
selectedQuestions.includes(q.id),
).length;
rows.push({
kind: "category",
category: cat,
count: qs.length,
selectedCount,
});
if (!collapsedCategories.has(cat)) {
for (const q of qs) {
rows.push({
kind: "question",
question: q,
isSelected: selectedQuestions.includes(q.id),
});
}
}
}
return rows;
}, [
filteredCategoryNames,
filteredGrouped,
collapsedCategories,
selectedQuestions,
]);
const selectedQuestionObjects = useMemo(
() =>
selectedQuestions
@@ -246,6 +294,25 @@ export default function GameSetupView() {
[selectedQuestions, questions],
);
/* ─── Pool virtualizer ─────────────────────────────────────── */
const poolVirtualizer = useVirtualizer({
count: poolRows.length,
getScrollElement: () => poolScrollRef.current,
estimateSize: useCallback(
(index: number) => (poolRows[index]?.kind === "category" ? 32 : 30),
[poolRows],
),
overscan: 20,
});
/* ─── Queue virtualizer ────────────────────────────────────── */
const queueVirtualizer = useVirtualizer({
count: selectedQuestionObjects.length,
getScrollElement: () => queueScrollRef.current,
estimateSize: () => 34,
overscan: 10,
});
/* ─── Question actions ─────────────────────────────────────── */
const addQuestion = (id: number) => {
if (!selectedQuestions.includes(id)) {
@@ -327,7 +394,10 @@ export default function GameSetupView() {
setRandomSelections({});
setShowRandomPanel(false);
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string };
const err = error as {
response?: { data?: { error?: string } };
message?: string;
};
console.error("Error adding random questions:", error);
alert(
"Error adding random questions: " +
@@ -418,10 +488,12 @@ export default function GameSetupView() {
selectedQuestions.length > 0 &&
teams.length > 0;
/* ─── Checklist counts ─────────────────────────────────────── */
const checklistItems = [
{ done: gameName.trim() !== "", label: "Name" },
{ done: selectedQuestions.length > 0, label: `${selectedQuestions.length} Qs` },
{
done: selectedQuestions.length > 0,
label: `${selectedQuestions.length} Qs`,
},
{ done: teams.length > 0, label: `${teams.length} teams` },
];
@@ -470,10 +542,7 @@ export default function GameSetupView() {
>
<DialogTrigger
render={
<Button
variant="outline"
size="sm"
/>
<Button variant="outline" size="sm" />
}
>
<Bookmark className="size-3.5" />
@@ -485,7 +554,8 @@ export default function GameSetupView() {
Load from Template
</DialogTitle>
<DialogDescription>
Pre-fill your game with questions from a saved template.
Pre-fill your game with questions from a
saved template.
</DialogDescription>
</DialogHeader>
{templates.length === 0 ? (
@@ -542,11 +612,16 @@ export default function GameSetupView() {
<div className="flex items-center justify-between border-b px-3 py-2">
<h2 className="text-sm font-semibold">
Question Pool
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
{questions.length} total
</span>
</h2>
<div className="flex items-center gap-1">
<Button
variant={
showRandomPanel ? "secondary" : "ghost"
showRandomPanel
? "secondary"
: "ghost"
}
size="icon-xs"
onClick={() =>
@@ -611,8 +686,9 @@ export default function GameSetupView() {
min={0}
max={total}
value={
randomSelections[cat] ||
0
randomSelections[
cat
] || 0
}
onChange={(e) =>
updateRandomSelection(
@@ -642,120 +718,138 @@ export default function GameSetupView() {
</div>
)}
{/* Question list */}
<ScrollArea className="h-[28rem] lg:h-[32rem]">
<div className="p-2">
{questions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No questions yet.{" "}
<Link
to="/questions"
className="underline"
>
Create some
</Link>
</p>
) : filteredCategoryNames.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No matches for "{searchQuery}"
</p>
) : (
filteredCategoryNames.map((cat) => {
const isCollapsed =
collapsedCategories.has(cat);
const qs = filteredGrouped[cat];
const selectedInCat = qs.filter((q) =>
selectedQuestions.includes(q.id),
).length;
return (
<div key={cat} className="mb-1">
<button
onClick={() =>
toggleCategory(cat)
}
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-xs font-semibold text-muted-foreground hover:bg-muted"
>
{isCollapsed ? (
<ChevronRight className="size-3" />
) : (
<ChevronDown className="size-3" />
)}
<span className="flex-1">
{cat}
</span>
{selectedInCat > 0 && (
<Badge
variant="secondary"
className="text-[0.6rem]"
{/* Virtualized question list */}
<div
ref={poolScrollRef}
className="h-[28rem] overflow-auto lg:h-[32rem]"
>
{questions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No questions yet.{" "}
<Link
to="/questions"
className="underline"
>
Create some
</Link>
</p>
) : filteredCategoryNames.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No matches for &ldquo;{searchQuery}&rdquo;
</p>
) : (
<div
style={{
height: poolVirtualizer.getTotalSize(),
position: "relative",
}}
>
{poolVirtualizer
.getVirtualItems()
.map((virtualRow) => {
const row =
poolRows[virtualRow.index];
if (row.kind === "category") {
return (
<div
key={`cat-${row.category}`}
className="absolute left-0 w-full px-2"
style={{
top: virtualRow.start,
height: virtualRow.size,
}}
>
<button
onClick={() =>
toggleCategory(
row.category,
)
}
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-xs font-semibold text-muted-foreground hover:bg-muted"
>
{selectedInCat}
</Badge>
)}
<span className="text-[0.65rem] text-muted-foreground">
{qs.length}
</span>
</button>
{!isCollapsed && (
<div className="ml-1 grid gap-0.5 py-0.5">
{qs.map((q) => {
const isSelected =
selectedQuestions.includes(
q.id,
);
return (
<button
key={q.id}
onClick={() =>
isSelected
? removeQuestion(
q.id,
)
: addQuestion(
q.id,
)
}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
isSelected
? "bg-emerald-50 text-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-100"
: "hover:bg-muted",
)}
{collapsedCategories.has(
row.category,
) ? (
<ChevronRight className="size-3" />
) : (
<ChevronDown className="size-3" />
)}
<span className="flex-1">
{row.category}
</span>
{row.selectedCount >
0 && (
<Badge
variant="secondary"
className="text-[0.6rem]"
>
<span
className={cn(
"flex size-3.5 shrink-0 items-center justify-center rounded border text-[0.5rem]",
isSelected
? "border-emerald-500 bg-emerald-500 text-white"
: "border-muted-foreground/30",
)}
>
{isSelected &&
"✓"}
</span>
<TypeIcon
type={
q.type
}
/>
<span className="min-w-0 flex-1 truncate">
{
q.question_content
}
</span>
{!isSelected && (
<Plus className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
)}
</button>
);
})}
{
row.selectedCount
}
</Badge>
)}
<span className="text-[0.65rem] text-muted-foreground">
{row.count}
</span>
</button>
</div>
)}
</div>
);
})
)}
</div>
</ScrollArea>
);
}
const { question, isSelected } =
row;
return (
<div
key={`q-${question.id}`}
className="absolute left-0 w-full px-2 pl-3"
style={{
top: virtualRow.start,
height: virtualRow.size,
}}
>
<button
onClick={() =>
isSelected
? removeQuestion(
question.id,
)
: addQuestion(
question.id,
)
}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
isSelected
? "bg-emerald-50 text-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-100"
: "hover:bg-muted",
)}
>
<span
className={cn(
"flex size-3.5 shrink-0 items-center justify-center rounded border text-[0.5rem]",
isSelected
? "border-emerald-500 bg-emerald-500 text-white"
: "border-muted-foreground/30",
)}
>
{isSelected && "✓"}
</span>
<TypeIcon
type={
question.type
}
/>
<span className="min-w-0 flex-1 truncate">
{questionLabel(
question,
)}
</span>
</button>
</div>
);
})}
</div>
)}
</div>
</div>
{/* RIGHT: Game Queue */}
@@ -766,8 +860,7 @@ export default function GameSetupView() {
{selectedQuestions.length > 0 && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
{selectedQuestions.length} question
{selectedQuestions.length !== 1 &&
"s"}
{selectedQuestions.length !== 1 && "s"}
</span>
)}
</h2>
@@ -783,7 +876,10 @@ export default function GameSetupView() {
)}
</div>
<ScrollArea className="h-[28rem] lg:h-[32rem]">
<div
ref={queueScrollRef}
className="h-[28rem] overflow-auto lg:h-[32rem]"
>
{selectedQuestions.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
<div className="rounded-full bg-muted p-3">
@@ -824,7 +920,7 @@ export default function GameSetupView() {
</SortableContext>
</DndContext>
)}
</ScrollArea>
</div>
</div>
</div>
@@ -834,7 +930,10 @@ export default function GameSetupView() {
<Users className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Teams</h2>
{teams.length > 0 && (
<Badge variant="secondary" className="text-[0.65rem]">
<Badge
variant="secondary"
className="text-[0.65rem]"
>
{teams.length}
</Badge>
)}