diff --git a/frontend/frontend/package-lock.json b/frontend/frontend/package-lock.json
index dd0617a..f351f21 100644
--- a/frontend/frontend/package-lock.json
+++ b/frontend/frontend/package-lock.json
@@ -9,6 +9,9 @@
"version": "0.0.0",
"dependencies": {
"@base-ui/react": "^1.3.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.8",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
@@ -703,6 +706,59 @@
"node": ">=14.21.3"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@dotenvx/dotenvx": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.59.1.tgz",
diff --git a/frontend/frontend/package.json b/frontend/frontend/package.json
index 808edab..db4d7ac 100644
--- a/frontend/frontend/package.json
+++ b/frontend/frontend/package.json
@@ -11,6 +11,9 @@
},
"dependencies": {
"@base-ui/react": "^1.3.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.8",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
diff --git a/frontend/frontend/src/components/questionbank/GameSetupView.jsx b/frontend/frontend/src/components/questionbank/GameSetupView.jsx
deleted file mode 100644
index dc074e9..0000000
--- a/frontend/frontend/src/components/questionbank/GameSetupView.jsx
+++ /dev/null
@@ -1,845 +0,0 @@
-import { useState, useEffect, useRef } from "react";
-import { Link, useNavigate } from "react-router-dom";
-import { questionsAPI, gamesAPI, teamsAPI } from "../../services/api";
-import AdminNavbar from "../common/AdminNavbar";
-
-export default function GameSetupView() {
- const navigate = useNavigate();
- const [questions, setQuestions] = useState([]);
- const [selectedQuestions, setSelectedQuestions] = useState([]);
- const [gameName, setGameName] = useState("");
- const [teams, setTeams] = useState([]);
- const [newTeamName, setNewTeamName] = useState("");
- const [showTemplateModal, setShowTemplateModal] = useState(false);
- const [templates, setTemplates] = useState([]);
- const [randomSelections, setRandomSelections] = useState({});
- const [pastTeamNames, setPastTeamNames] = useState([]);
- const [showTeamSuggestions, setShowTeamSuggestions] = useState(false);
- const teamInputRef = useRef(null);
-
- useEffect(() => {
- loadQuestions();
- loadPastTeamNames();
- }, []);
-
- const loadPastTeamNames = async () => {
- try {
- const response = await teamsAPI.getPastNames();
- setPastTeamNames(response.data);
- } catch (error) {
- console.error("Error loading past team names:", error);
- }
- };
-
- const loadQuestions = async () => {
- try {
- const response = await questionsAPI.getAll();
- setQuestions(response.data);
- } catch (error) {
- console.error("Error loading questions:", error);
- }
- };
-
- const loadTemplates = async () => {
- try {
- const response = await gamesAPI.getTemplates();
- setTemplates(response.data);
- } catch (error) {
- console.error("Error loading templates:", error);
- }
- };
-
- const handleLoadTemplate = async (template) => {
- try {
- const response = await gamesAPI.getOne(template.id);
- // Pre-fill form with template data
- setGameName(template.name + " (Copy)");
- const templateQuestions = response.data.game_questions.map(
- (gq) => gq.question,
- );
- setQuestions((prev) => {
- // Merge template questions with existing questions, avoiding duplicates
- const existingIds = new Set(prev.map((q) => q.id));
- const newQuestions = templateQuestions.filter(
- (q) => !existingIds.has(q.id),
- );
- return [...prev, ...newQuestions];
- });
- setSelectedQuestions(templateQuestions.map((q) => q.id));
- setShowTemplateModal(false);
- alert(
- `Loaded ${templateQuestions.length} questions from template "${template.name}"`,
- );
- } catch (error) {
- console.error("Error loading template:", error);
- alert("Error loading template");
- }
- };
-
- // Group questions by category
- const groupedQuestions = questions.reduce((groups, question) => {
- const category = question.category || "Uncategorized";
- if (!groups[category]) {
- groups[category] = [];
- }
- groups[category].push(question);
- return groups;
- }, {});
-
- const categoryNames = Object.keys(groupedQuestions).sort();
-
- const toggleQuestion = (questionId) => {
- setSelectedQuestions((prev) =>
- prev.includes(questionId)
- ? prev.filter((id) => id !== questionId)
- : [...prev, questionId],
- );
- };
-
- const moveQuestion = (index, direction) => {
- const newOrder = [...selectedQuestions];
- const targetIndex = direction === "up" ? index - 1 : index + 1;
-
- if (targetIndex >= 0 && targetIndex < newOrder.length) {
- [newOrder[index], newOrder[targetIndex]] = [
- newOrder[targetIndex],
- newOrder[index],
- ];
- setSelectedQuestions(newOrder);
- }
- };
-
- const removeQuestion = (questionId) => {
- setSelectedQuestions((prev) => prev.filter((id) => id !== questionId));
- };
-
- const addTeam = (name = newTeamName) => {
- const teamName = name.trim();
- if (teamName && !teams.includes(teamName)) {
- setTeams([...teams, teamName]);
- setNewTeamName("");
- setShowTeamSuggestions(false);
- }
- };
-
- const removeTeam = (index) => {
- setTeams(teams.filter((_, i) => i !== index));
- };
-
- // Filter past team names based on input and exclude already added teams
- const filteredSuggestions = pastTeamNames.filter(
- (name) =>
- name.toLowerCase().includes(newTeamName.toLowerCase()) &&
- !teams.includes(name)
- );
-
- // Get quick-add suggestions (past names not yet added to this game)
- const quickAddSuggestions = pastTeamNames
- .filter((name) => !teams.includes(name))
- .slice(0, 8);
-
- const updateRandomSelection = (category, count) => {
- setRandomSelections((prev) => ({
- ...prev,
- [category]: count,
- }));
- };
-
- const addRandomQuestions = async () => {
- try {
- const newQuestionIds = [];
-
- for (const [category, count] of Object.entries(randomSelections)) {
- if (count > 0) {
- const response = await questionsAPI.getRandomByCategory(
- category,
- count,
- );
- const randomQuestions = response.data.questions;
-
- // Add to questions list if not already there
- setQuestions((prev) => {
- const existingIds = new Set(prev.map((q) => q.id));
- const newQuestions = randomQuestions.filter(
- (q) => !existingIds.has(q.id),
- );
- return [...prev, ...newQuestions];
- });
-
- // Add question IDs to selected list
- randomQuestions.forEach((q) => {
- if (!selectedQuestions.includes(q.id)) {
- newQuestionIds.push(q.id);
- }
- });
- }
- }
-
- if (newQuestionIds.length > 0) {
- setSelectedQuestions((prev) => [...prev, ...newQuestionIds]);
- alert(`Added ${newQuestionIds.length} random questions to your game!`);
- }
-
- // Reset selections
- setRandomSelections({});
- } catch (error) {
- console.error("Error adding random questions:", error);
- alert(
- "Error adding random questions: " +
- (error.response?.data?.error || error.message),
- );
- }
- };
-
- const handleCreateGame = async () => {
- if (!gameName.trim()) {
- alert("Please enter a game name");
- return;
- }
- if (selectedQuestions.length === 0) {
- alert("Please select at least one question");
- return;
- }
- if (teams.length === 0) {
- alert("Please add at least one team");
- return;
- }
-
- try {
- // Create game
- const gameResponse = await gamesAPI.create({ name: gameName });
- const gameId = gameResponse.data.id;
-
- // Add questions to game in the specified order
- await gamesAPI.addQuestions(gameId, selectedQuestions);
-
- // Add teams to game
- for (const teamName of teams) {
- await gamesAPI.addTeam(gameId, teamName);
- }
-
- alert("Game created successfully!");
- navigate(`/games/${gameId}/admin`);
- } catch (error) {
- console.error("Error creating game:", error);
- alert("Error creating game");
- }
- };
-
- const getQuestionById = (id) => questions.find((q) => q.id === id);
-
- return (
- <>
-
-
-
-
Create New Game
-
-
-
-
-
- 1. Game Name
-
- setGameName(e.target.value)}
- placeholder="Enter game name"
- style={{
- padding: "0.5rem",
- width: "100%",
- fontSize: "1rem",
- boxSizing: "border-box",
- }}
- />
-
-
-
-
- 2. Add Random Questions (Quick Setup)
-
-
- Select how many random questions to add from each category
-
- {categoryNames.length === 0 ? (
-
No questions available yet.
- ) : (
- <>
-
- {categoryNames.map((category) => {
- const categoryCount = groupedQuestions[category].length;
- return (
-
-
- {category}
-
- ({categoryCount} available)
-
-
-
-
-
- updateRandomSelection(
- category,
- parseInt(e.target.value) || 0,
- )
- }
- style={{
- width: "70px",
- padding: "0.5rem",
- fontSize: "1rem",
- textAlign: "center",
- }}
- />
-
- questions
-
-
-
- );
- })}
-
-
- >
- )}
-
-
-
-
- 3. Select Questions Manually ({selectedQuestions.length} selected)
-
- {questions.length === 0 ? (
-
- No questions available.{" "}
- Create some questions first
-
- ) : (
-
- {categoryNames.map((category) => (
-
-
- {category}
-
-
- {groupedQuestions[category].map((q) => (
-
toggleQuestion(q.id)}
- style={{
- padding: "0.75rem",
- border: `2px solid ${selectedQuestions.includes(q.id) ? "#4CAF50" : "#ccc"}`,
- borderRadius: "4px",
- cursor: "pointer",
- background: selectedQuestions.includes(q.id)
- ? "#e8f5e9"
- : "white",
- }}
- >
-
-
-
-
- {q.type.toUpperCase()}
-
- {q.question_content}
-
-
-
- ))}
-
-
- ))}
-
- )}
-
-
- {selectedQuestions.length > 0 && (
-
-
- Question Order
-
-
- {selectedQuestions.map((qId, index) => {
- const question = getQuestionById(qId);
- if (!question) return null;
- return (
-
-
- #{index + 1}
-
-
- {question.category && (
-
- {question.category}
-
- )}
-
- {question.type.toUpperCase()}
-
- {question.question_content}
-
-
-
-
-
-
-
- );
- })}
-
-
- )}
-
-
-
- 4. Add Teams
-
-
-
-
{
- setNewTeamName(e.target.value);
- setShowTeamSuggestions(e.target.value.length > 0);
- }}
- onFocus={() => setShowTeamSuggestions(newTeamName.length > 0)}
- onBlur={() => {
- // Delay hiding to allow click on suggestion
- setTimeout(() => setShowTeamSuggestions(false), 200);
- }}
- onKeyPress={(e) => e.key === "Enter" && addTeam()}
- placeholder="Enter team name"
- style={{ padding: "0.5rem", width: "100%", boxSizing: "border-box" }}
- />
- {showTeamSuggestions && filteredSuggestions.length > 0 && (
-
- {filteredSuggestions.slice(0, 8).map((name) => (
-
addTeam(name)}
- style={{
- padding: "0.5rem 0.75rem",
- cursor: "pointer",
- borderBottom: "1px solid #eee",
- }}
- onMouseEnter={(e) => (e.target.style.background = "#f5f5f5")}
- onMouseLeave={(e) => (e.target.style.background = "white")}
- >
- {name}
-
- ))}
-
- )}
-
-
-
- {quickAddSuggestions.length > 0 && (
-
-
- Quick add from past games:
-
-
- {quickAddSuggestions.map((name) => (
-
- ))}
-
-
- )}
-
- {teams.map((team, index) => (
-
- {team}
-
-
- ))}
-
-
-
-
-
- {showTemplateModal && (
-
-
-
Select Template
- {templates.length === 0 ? (
-
No templates available
- ) : (
-
- {templates.map((template) => (
-
handleLoadTemplate(template)}
- >
-
{template.name}
-
- {template.total_questions} questions
-
-
- ))}
-
- )}
-
-
-
- )}
-
-
- >
- );
-}
diff --git a/frontend/frontend/src/components/questionbank/GameSetupView.tsx b/frontend/frontend/src/components/questionbank/GameSetupView.tsx
new file mode 100644
index 0000000..938bf03
--- /dev/null
+++ b/frontend/frontend/src/components/questionbank/GameSetupView.tsx
@@ -0,0 +1,957 @@
+import { useState, useEffect, useRef, useMemo } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+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,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { cn } from "@/lib/utils";
+import {
+ GripVertical,
+ Search,
+ Plus,
+ X,
+ Shuffle,
+ Trash2,
+ ChevronDown,
+ ChevronRight,
+ FileText,
+ Image,
+ Dices,
+ Users,
+ Bookmark,
+ Rocket,
+ Music,
+ Video,
+} from "lucide-react";
+
+interface Question {
+ id: number;
+ question_content: string;
+ type: string;
+ category?: string;
+ [key: string]: unknown;
+}
+
+interface Template {
+ id: number;
+ name: string;
+ total_questions: number;
+}
+
+/* ─── Sortable row inside the game queue ───────────────────────────── */
+function SortableQuestionRow({
+ question,
+ index,
+ onRemove,
+}: {
+ question: Question;
+ index: number;
+ onRemove: (id: number) => void;
+}) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: question.id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ };
+
+ return (
+
+
+
+ {index + 1}
+
+ {question.category && (
+
+ {question.category}
+
+ )}
+
+
+ {question.question_content}
+
+
+
+ );
+}
+
+/* ─── Tiny type icon ───────────────────────────────────────────────── */
+function TypeIcon({ type }: { type: string }) {
+ const cls = "size-3 shrink-0 text-muted-foreground";
+ switch (type) {
+ case "image":
+ return ;
+ case "audio":
+ return ;
+ case "video":
+ return ;
+ default:
+ return ;
+ }
+}
+
+/* ═══════════════════════════════════════════════════════════════════ */
+export default function GameSetupView() {
+ const navigate = useNavigate();
+
+ /* ─── State ────────────────────────────────────────────────── */
+ const [questions, setQuestions] = useState([]);
+ const [selectedQuestions, setSelectedQuestions] = useState([]);
+ const [gameName, setGameName] = useState("");
+ const [teams, setTeams] = useState([]);
+ const [newTeamName, setNewTeamName] = useState("");
+ const [templates, setTemplates] = useState([]);
+ const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
+ const [randomSelections, setRandomSelections] = useState<
+ Record
+ >({});
+ const [pastTeamNames, setPastTeamNames] = useState([]);
+ const [showTeamSuggestions, setShowTeamSuggestions] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [collapsedCategories, setCollapsedCategories] = useState<
+ Set
+ >(new Set());
+ const [showRandomPanel, setShowRandomPanel] = useState(false);
+
+ const teamInputRef = useRef(null);
+
+ /* ─── Load data ────────────────────────────────────────────── */
+ useEffect(() => {
+ loadQuestions();
+ loadPastTeamNames();
+ }, []);
+
+ const loadPastTeamNames = async () => {
+ try {
+ const response = await teamsAPI.getPastNames();
+ setPastTeamNames(response.data);
+ } catch (error) {
+ console.error("Error loading past team names:", error);
+ }
+ };
+
+ const loadQuestions = async () => {
+ try {
+ const response = await questionsAPI.getAll();
+ setQuestions(response.data);
+ } catch (error) {
+ console.error("Error loading questions:", error);
+ }
+ };
+
+ const loadTemplates = async () => {
+ try {
+ const response = await gamesAPI.getTemplates();
+ setTemplates(response.data);
+ } catch (error) {
+ console.error("Error loading templates:", error);
+ }
+ };
+
+ /* ─── Derived data ─────────────────────────────────────────── */
+ const groupedQuestions = useMemo(() => {
+ const groups: Record = {};
+ for (const q of questions) {
+ const cat = q.category || "Uncategorized";
+ (groups[cat] ||= []).push(q);
+ }
+ return groups;
+ }, [questions]);
+
+ const categoryNames = useMemo(
+ () => Object.keys(groupedQuestions).sort(),
+ [groupedQuestions],
+ );
+
+ const filteredGrouped = useMemo(() => {
+ if (!searchQuery.trim()) return groupedQuestions;
+ const q = searchQuery.toLowerCase();
+ const result: Record = {};
+ for (const [cat, qs] of Object.entries(groupedQuestions)) {
+ const matched = qs.filter(
+ (question) =>
+ question.question_content.toLowerCase().includes(q) ||
+ cat.toLowerCase().includes(q),
+ );
+ if (matched.length > 0) result[cat] = matched;
+ }
+ return result;
+ }, [groupedQuestions, searchQuery]);
+
+ const filteredCategoryNames = useMemo(
+ () => Object.keys(filteredGrouped).sort(),
+ [filteredGrouped],
+ );
+
+ const selectedQuestionObjects = useMemo(
+ () =>
+ selectedQuestions
+ .map((id) => questions.find((q) => q.id === id)!)
+ .filter(Boolean),
+ [selectedQuestions, questions],
+ );
+
+ /* ─── Question actions ─────────────────────────────────────── */
+ const addQuestion = (id: number) => {
+ if (!selectedQuestions.includes(id)) {
+ setSelectedQuestions((prev) => [...prev, id]);
+ }
+ };
+
+ const removeQuestion = (id: number) => {
+ setSelectedQuestions((prev) => prev.filter((qId) => qId !== id));
+ };
+
+ const toggleCategory = (category: string) => {
+ setCollapsedCategories((prev) => {
+ const next = new Set(prev);
+ if (next.has(category)) next.delete(category);
+ else next.add(category);
+ return next;
+ });
+ };
+
+ /* ─── Drag and drop ────────────────────────────────────────── */
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 4 },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ 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);
+ });
+ }
+ };
+
+ /* ─── Random questions ─────────────────────────────────────── */
+ const updateRandomSelection = (category: string, count: number) => {
+ setRandomSelections((prev) => ({ ...prev, [category]: count }));
+ };
+
+ const addRandomQuestions = async () => {
+ try {
+ const newQuestionIds: number[] = [];
+
+ for (const [category, count] of Object.entries(randomSelections)) {
+ if (count > 0) {
+ const response = await questionsAPI.getRandomByCategory(
+ category,
+ count,
+ );
+ const randomQuestions: Question[] =
+ response.data.questions;
+
+ setQuestions((prev) => {
+ const existingIds = new Set(prev.map((q) => q.id));
+ const newQs = randomQuestions.filter(
+ (q) => !existingIds.has(q.id),
+ );
+ return [...prev, ...newQs];
+ });
+
+ for (const q of randomQuestions) {
+ if (!selectedQuestions.includes(q.id)) {
+ newQuestionIds.push(q.id);
+ }
+ }
+ }
+ }
+
+ if (newQuestionIds.length > 0) {
+ setSelectedQuestions((prev) => [...prev, ...newQuestionIds]);
+ }
+ setRandomSelections({});
+ setShowRandomPanel(false);
+ } catch (error: unknown) {
+ const err = error as { response?: { data?: { error?: string } }; message?: string };
+ console.error("Error adding random questions:", error);
+ alert(
+ "Error adding random questions: " +
+ (err.response?.data?.error || err.message),
+ );
+ }
+ };
+
+ /* ─── Templates ────────────────────────────────────────────── */
+ const handleLoadTemplate = async (template: Template) => {
+ try {
+ const response = await gamesAPI.getOne(template.id);
+ setGameName(template.name + " (Copy)");
+ const templateQuestions: Question[] =
+ response.data.game_questions.map(
+ (gq: { question: Question }) => gq.question,
+ );
+ setQuestions((prev) => {
+ const existingIds = new Set(prev.map((q) => q.id));
+ const newQs = templateQuestions.filter(
+ (q) => !existingIds.has(q.id),
+ );
+ return [...prev, ...newQs];
+ });
+ setSelectedQuestions(templateQuestions.map((q) => q.id));
+ setTemplateDialogOpen(false);
+ } catch (error) {
+ console.error("Error loading template:", error);
+ alert("Error loading template");
+ }
+ };
+
+ /* ─── Teams ────────────────────────────────────────────────── */
+ const addTeam = (name = newTeamName) => {
+ const teamName = name.trim();
+ if (teamName && !teams.includes(teamName)) {
+ setTeams([...teams, teamName]);
+ setNewTeamName("");
+ setShowTeamSuggestions(false);
+ }
+ };
+
+ const removeTeam = (index: number) => {
+ setTeams(teams.filter((_, i) => i !== index));
+ };
+
+ const filteredSuggestions = pastTeamNames.filter(
+ (name) =>
+ name.toLowerCase().includes(newTeamName.toLowerCase()) &&
+ !teams.includes(name),
+ );
+
+ const quickAddSuggestions = pastTeamNames
+ .filter((name) => !teams.includes(name))
+ .slice(0, 8);
+
+ /* ─── Create game ──────────────────────────────────────────── */
+ const handleCreateGame = async () => {
+ if (!gameName.trim()) {
+ alert("Please enter a game name");
+ return;
+ }
+ if (selectedQuestions.length === 0) {
+ alert("Please select at least one question");
+ return;
+ }
+ if (teams.length === 0) {
+ alert("Please add at least one team");
+ return;
+ }
+
+ try {
+ const gameResponse = await gamesAPI.create({ name: gameName });
+ const gameId = gameResponse.data.id;
+ await gamesAPI.addQuestions(gameId, selectedQuestions);
+ for (const teamName of teams) {
+ await gamesAPI.addTeam(gameId, teamName);
+ }
+ navigate(`/games/${gameId}/admin`);
+ } catch (error) {
+ console.error("Error creating game:", error);
+ alert("Error creating game");
+ }
+ };
+
+ const canCreate =
+ gameName.trim() !== "" &&
+ selectedQuestions.length > 0 &&
+ teams.length > 0;
+
+ /* ─── Checklist counts ─────────────────────────────────────── */
+ const checklistItems = [
+ { done: gameName.trim() !== "", label: "Name" },
+ { done: selectedQuestions.length > 0, label: `${selectedQuestions.length} Qs` },
+ { done: teams.length > 0, label: `${teams.length} teams` },
+ ];
+
+ /* ═══ Render ═══════════════════════════════════════════════════ */
+ return (
+ <>
+
+
+ {/* ── Header row ─────────────────────────────────── */}
+
+
+
+ New Game
+
+
+ {checklistItems.map((item) => (
+
+
+ {item.label}
+
+ ))}
+
+
+
+
+
+
+
+
+ {/* ── Game name ──────────────────────────────────── */}
+
+ setGameName(e.target.value)}
+ placeholder="Game name..."
+ className="h-10 text-base"
+ />
+
+
+ {/* ── Two-column layout ─────────────────────────── */}
+
+ {/* LEFT: Question Pool */}
+
+
+
+ Question Pool
+
+
+
+
+
+
+ {/* Search */}
+
+
+
+
+ setSearchQuery(e.target.value)
+ }
+ placeholder="Search questions..."
+ className="h-7 pl-7 text-xs"
+ />
+
+
+
+ {/* Random panel */}
+ {showRandomPanel && (
+
+
+
+ Quick Random Add
+
+
+
+
+ {categoryNames.map((cat) => {
+ const total =
+ groupedQuestions[cat].length;
+ return (
+
+
+ {cat}
+
+
+ / {total}
+
+
+ updateRandomSelection(
+ cat,
+ parseInt(
+ e.target.value,
+ ) || 0,
+ )
+ }
+ className="h-6 w-12 rounded border bg-background px-1.5 text-center text-xs tabular-nums"
+ />
+
+ );
+ })}
+
+
+
+ )}
+
+ {/* Question list */}
+
+
+ {questions.length === 0 ? (
+
+ No questions yet.{" "}
+
+ Create some
+
+
+ ) : filteredCategoryNames.length === 0 ? (
+
+ No matches for "{searchQuery}"
+
+ ) : (
+ filteredCategoryNames.map((cat) => {
+ const isCollapsed =
+ collapsedCategories.has(cat);
+ const qs = filteredGrouped[cat];
+ const selectedInCat = qs.filter((q) =>
+ selectedQuestions.includes(q.id),
+ ).length;
+ return (
+
+
+ {!isCollapsed && (
+
+ {qs.map((q) => {
+ const isSelected =
+ selectedQuestions.includes(
+ q.id,
+ );
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+ })
+ )}
+
+
+
+
+ {/* RIGHT: Game Queue */}
+
+
+
+ Game Queue
+ {selectedQuestions.length > 0 && (
+
+ {selectedQuestions.length} question
+ {selectedQuestions.length !== 1 &&
+ "s"}
+
+ )}
+
+ {selectedQuestions.length > 0 && (
+
+ )}
+
+
+
+ {selectedQuestions.length === 0 ? (
+
+
+
+ Click questions on the left
+
+ to add them here
+
+
+ ) : (
+
+
+
+ {selectedQuestionObjects.map(
+ (q, i) => (
+
+ ),
+ )}
+
+
+
+ )}
+
+
+
+
+ {/* ── Teams section ─────────────────────────────── */}
+
+
+
+
Teams
+ {teams.length > 0 && (
+
+ {teams.length}
+
+ )}
+
+
+ {/* Input + suggestions */}
+
+
+
{
+ setNewTeamName(e.target.value);
+ setShowTeamSuggestions(
+ e.target.value.length > 0,
+ );
+ }}
+ onFocus={() =>
+ setShowTeamSuggestions(
+ newTeamName.length > 0,
+ )
+ }
+ onBlur={() =>
+ setTimeout(
+ () => setShowTeamSuggestions(false),
+ 200,
+ )
+ }
+ onKeyDown={(e) =>
+ e.key === "Enter" && addTeam()
+ }
+ placeholder="Team name..."
+ className="h-8"
+ />
+ {showTeamSuggestions &&
+ filteredSuggestions.length > 0 && (
+
+ {filteredSuggestions
+ .slice(0, 8)
+ .map((name) => (
+
+ ))}
+
+ )}
+
+
+
+
+ {/* Quick add chips */}
+ {quickAddSuggestions.length > 0 && (
+
+
+ Past teams
+
+
+ {quickAddSuggestions.map((name) => (
+
+ ))}
+
+
+ )}
+
+ {/* Added teams */}
+ {teams.length > 0 && (
+
+ {teams.map((team, index) => (
+
+ {team}
+
+
+ ))}
+
+ )}
+
+
+ {/* ── Bottom create bar (sticky on mobile) ──────── */}
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/frontend/src/components/ui/badge.tsx b/frontend/frontend/src/components/ui/badge.tsx
new file mode 100644
index 0000000..b20959d
--- /dev/null
+++ b/frontend/frontend/src/components/ui/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"span"> & VariantProps) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ })
+}
+
+export { Badge, badgeVariants }
diff --git a/frontend/frontend/src/components/ui/dialog.tsx b/frontend/frontend/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..014f5aa
--- /dev/null
+++ b/frontend/frontend/src/components/ui/dialog.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/frontend/frontend/src/components/ui/input.tsx b/frontend/frontend/src/components/ui/input.tsx
new file mode 100644
index 0000000..7d21bab
--- /dev/null
+++ b/frontend/frontend/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/frontend/frontend/src/components/ui/scroll-area.tsx b/frontend/frontend/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..84c1e9f
--- /dev/null
+++ b/frontend/frontend/src/components/ui/scroll-area.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: ScrollAreaPrimitive.Root.Props) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ScrollAreaPrimitive.Scrollbar.Props) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/frontend/frontend/src/components/ui/separator.tsx b/frontend/frontend/src/components/ui/separator.tsx
new file mode 100644
index 0000000..4f65961
--- /dev/null
+++ b/frontend/frontend/src/components/ui/separator.tsx
@@ -0,0 +1,23 @@
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }