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