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/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@tanstack/react-virtual": "^3.13.23",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -2551,6 +2552,33 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "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": { "node_modules/@ts-morph/common": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", "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/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@tanstack/react-virtual": "^3.13.23",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.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 { Link, useNavigate } from "react-router-dom";
import { import {
DndContext, DndContext,
@@ -17,12 +17,12 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { useVirtualizer } from "@tanstack/react-virtual";
import { questionsAPI, gamesAPI, teamsAPI } from "../../services/api"; import { questionsAPI, gamesAPI, teamsAPI } from "../../services/api";
import AdminNavbar from "../common/AdminNavbar"; import AdminNavbar from "../common/AdminNavbar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -54,6 +54,7 @@ import {
interface Question { interface Question {
id: number; id: number;
question_content: string; question_content: string;
answer?: string;
type: string; type: string;
category?: string; category?: string;
[key: string]: unknown; [key: string]: unknown;
@@ -65,6 +66,10 @@ interface Template {
total_questions: number; 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 ───────────────────────────── */ /* ─── Sortable row inside the game queue ───────────────────────────── */
function SortableQuestionRow({ function SortableQuestionRow({
question, question,
@@ -118,7 +123,9 @@ function SortableQuestionRow({
)} )}
<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.question_content} {question.type !== "text" && question.answer
? question.answer
: question.question_content}
</span> </span>
<button <button
onClick={() => onRemove(question.id)} 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() { export default function GameSetupView() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -169,6 +181,8 @@ export default function GameSetupView() {
const [showRandomPanel, setShowRandomPanel] = useState(false); const [showRandomPanel, setShowRandomPanel] = useState(false);
const teamInputRef = useRef<HTMLInputElement>(null); const teamInputRef = useRef<HTMLInputElement>(null);
const poolScrollRef = useRef<HTMLDivElement>(null);
const queueScrollRef = useRef<HTMLDivElement>(null);
/* ─── Load data ────────────────────────────────────────────── */ /* ─── Load data ────────────────────────────────────────────── */
useEffect(() => { useEffect(() => {
@@ -226,6 +240,8 @@ export default function GameSetupView() {
const matched = qs.filter( const matched = qs.filter(
(question) => (question) =>
question.question_content.toLowerCase().includes(q) || question.question_content.toLowerCase().includes(q) ||
(question.answer &&
question.answer.toLowerCase().includes(q)) ||
cat.toLowerCase().includes(q), cat.toLowerCase().includes(q),
); );
if (matched.length > 0) result[cat] = matched; if (matched.length > 0) result[cat] = matched;
@@ -238,6 +254,38 @@ export default function GameSetupView() {
[filteredGrouped], [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( const selectedQuestionObjects = useMemo(
() => () =>
selectedQuestions selectedQuestions
@@ -246,6 +294,25 @@ export default function GameSetupView() {
[selectedQuestions, questions], [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 ─────────────────────────────────────── */ /* ─── Question actions ─────────────────────────────────────── */
const addQuestion = (id: number) => { const addQuestion = (id: number) => {
if (!selectedQuestions.includes(id)) { if (!selectedQuestions.includes(id)) {
@@ -327,7 +394,10 @@ export default function GameSetupView() {
setRandomSelections({}); setRandomSelections({});
setShowRandomPanel(false); setShowRandomPanel(false);
} catch (error: unknown) { } 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); console.error("Error adding random questions:", error);
alert( alert(
"Error adding random questions: " + "Error adding random questions: " +
@@ -418,10 +488,12 @@ export default function GameSetupView() {
selectedQuestions.length > 0 && selectedQuestions.length > 0 &&
teams.length > 0; teams.length > 0;
/* ─── Checklist counts ─────────────────────────────────────── */
const checklistItems = [ const checklistItems = [
{ done: gameName.trim() !== "", label: "Name" }, { 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` }, { done: teams.length > 0, label: `${teams.length} teams` },
]; ];
@@ -470,10 +542,7 @@ export default function GameSetupView() {
> >
<DialogTrigger <DialogTrigger
render={ render={
<Button <Button variant="outline" size="sm" />
variant="outline"
size="sm"
/>
} }
> >
<Bookmark className="size-3.5" /> <Bookmark className="size-3.5" />
@@ -485,7 +554,8 @@ export default function GameSetupView() {
Load from Template Load from Template
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Pre-fill your game with questions from a saved template. Pre-fill your game with questions from a
saved template.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{templates.length === 0 ? ( {templates.length === 0 ? (
@@ -542,11 +612,16 @@ export default function GameSetupView() {
<div className="flex items-center justify-between border-b px-3 py-2"> <div className="flex items-center justify-between border-b px-3 py-2">
<h2 className="text-sm font-semibold"> <h2 className="text-sm font-semibold">
Question Pool Question Pool
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
{questions.length} total
</span>
</h2> </h2>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant={ variant={
showRandomPanel ? "secondary" : "ghost" showRandomPanel
? "secondary"
: "ghost"
} }
size="icon-xs" size="icon-xs"
onClick={() => onClick={() =>
@@ -611,8 +686,9 @@ export default function GameSetupView() {
min={0} min={0}
max={total} max={total}
value={ value={
randomSelections[cat] || randomSelections[
0 cat
] || 0
} }
onChange={(e) => onChange={(e) =>
updateRandomSelection( updateRandomSelection(
@@ -642,120 +718,138 @@ export default function GameSetupView() {
</div> </div>
)} )}
{/* Question list */} {/* Virtualized question list */}
<ScrollArea className="h-[28rem] lg:h-[32rem]"> <div
<div className="p-2"> ref={poolScrollRef}
{questions.length === 0 ? ( className="h-[28rem] overflow-auto lg:h-[32rem]"
<p className="py-8 text-center text-sm text-muted-foreground"> >
No questions yet.{" "} {questions.length === 0 ? (
<Link <p className="py-8 text-center text-sm text-muted-foreground">
to="/questions" No questions yet.{" "}
className="underline" <Link
> to="/questions"
Create some className="underline"
</Link> >
</p> Create some
) : filteredCategoryNames.length === 0 ? ( </Link>
<p className="py-8 text-center text-sm text-muted-foreground"> </p>
No matches for "{searchQuery}" ) : filteredCategoryNames.length === 0 ? (
</p> <p className="py-8 text-center text-sm text-muted-foreground">
) : ( No matches for &ldquo;{searchQuery}&rdquo;
filteredCategoryNames.map((cat) => { </p>
const isCollapsed = ) : (
collapsedCategories.has(cat); <div
const qs = filteredGrouped[cat]; style={{
const selectedInCat = qs.filter((q) => height: poolVirtualizer.getTotalSize(),
selectedQuestions.includes(q.id), position: "relative",
).length; }}
return ( >
<div key={cat} className="mb-1"> {poolVirtualizer
<button .getVirtualItems()
onClick={() => .map((virtualRow) => {
toggleCategory(cat) const row =
} poolRows[virtualRow.index];
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" if (row.kind === "category") {
> return (
{isCollapsed ? ( <div
<ChevronRight className="size-3" /> key={`cat-${row.category}`}
) : ( className="absolute left-0 w-full px-2"
<ChevronDown className="size-3" /> style={{
)} top: virtualRow.start,
<span className="flex-1"> height: virtualRow.size,
{cat} }}
</span> >
{selectedInCat > 0 && ( <button
<Badge onClick={() =>
variant="secondary" toggleCategory(
className="text-[0.6rem]" 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} {collapsedCategories.has(
</Badge> row.category,
)} ) ? (
<span className="text-[0.65rem] text-muted-foreground"> <ChevronRight className="size-3" />
{qs.length} ) : (
</span> <ChevronDown className="size-3" />
</button> )}
{!isCollapsed && ( <span className="flex-1">
<div className="ml-1 grid gap-0.5 py-0.5"> {row.category}
{qs.map((q) => { </span>
const isSelected = {row.selectedCount >
selectedQuestions.includes( 0 && (
q.id, <Badge
); variant="secondary"
return ( className="text-[0.6rem]"
<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",
)}
> >
<span {
className={cn( row.selectedCount
"flex size-3.5 shrink-0 items-center justify-center rounded border text-[0.5rem]", }
isSelected </Badge>
? "border-emerald-500 bg-emerald-500 text-white" )}
: "border-muted-foreground/30", <span className="text-[0.65rem] text-muted-foreground">
)} {row.count}
> </span>
{isSelected && </button>
"✓"}
</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>
);
})}
</div> </div>
)} );
</div> }
); const { question, isSelected } =
}) row;
)} return (
</div> <div
</ScrollArea> 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> </div>
{/* RIGHT: Game Queue */} {/* RIGHT: Game Queue */}
@@ -766,8 +860,7 @@ export default function GameSetupView() {
{selectedQuestions.length > 0 && ( {selectedQuestions.length > 0 && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground"> <span className="ml-1.5 text-xs font-normal text-muted-foreground">
{selectedQuestions.length} question {selectedQuestions.length} question
{selectedQuestions.length !== 1 && {selectedQuestions.length !== 1 && "s"}
"s"}
</span> </span>
)} )}
</h2> </h2>
@@ -783,7 +876,10 @@ export default function GameSetupView() {
)} )}
</div> </div>
<ScrollArea className="h-[28rem] lg:h-[32rem]"> <div
ref={queueScrollRef}
className="h-[28rem] overflow-auto lg:h-[32rem]"
>
{selectedQuestions.length === 0 ? ( {selectedQuestions.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center"> <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"> <div className="rounded-full bg-muted p-3">
@@ -824,7 +920,7 @@ export default function GameSetupView() {
</SortableContext> </SortableContext>
</DndContext> </DndContext>
)} )}
</ScrollArea> </div>
</div> </div>
</div> </div>
@@ -834,7 +930,10 @@ export default function GameSetupView() {
<Users className="size-4 text-muted-foreground" /> <Users className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Teams</h2> <h2 className="text-sm font-semibold">Teams</h2>
{teams.length > 0 && ( {teams.length > 0 && (
<Badge variant="secondary" className="text-[0.65rem]"> <Badge
variant="secondary"
className="text-[0.65rem]"
>
{teams.length} {teams.length}
</Badge> </Badge>
)} )}