Rebuild GameSetupView with two-column layout and drag-and-drop
Replace the linear scrolling game creation form with a two-column layout: left panel for browsing/searching the question pool with collapsible categories, right panel for the game queue with drag-and-drop reordering via @dnd-kit/sortable. Add shadcn Dialog for template picker, search/filter bar, random question panel, progress indicators, and sticky create button. Convert to TypeScript. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
56
frontend/frontend/package-lock.json
generated
56
frontend/frontend/package-lock.json
generated
@@ -9,6 +9,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@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",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -703,6 +706,59 @@
|
|||||||
"node": ">=14.21.3"
|
"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": {
|
"node_modules/@dotenvx/dotenvx": {
|
||||||
"version": "1.59.1",
|
"version": "1.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.59.1.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@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",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<>
|
|
||||||
<AdminNavbar />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "1rem 2rem",
|
|
||||||
minHeight: "calc(100vh - 60px)",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ maxWidth: "900px", width: "100%" }}>
|
|
||||||
<h1 style={{ margin: "0 0 1.5rem 0" }}>Create New Game</h1>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
loadTemplates();
|
|
||||||
setShowTemplateModal(true);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
marginBottom: "1.5rem",
|
|
||||||
background: "#9C27B0",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Load from Template
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "1.5rem" }}>
|
|
||||||
<h2 style={{ marginTop: "0", marginBottom: "0.75rem" }}>
|
|
||||||
1. Game Name
|
|
||||||
</h2>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={gameName}
|
|
||||||
onChange={(e) => setGameName(e.target.value)}
|
|
||||||
placeholder="Enter game name"
|
|
||||||
style={{
|
|
||||||
padding: "0.5rem",
|
|
||||||
width: "100%",
|
|
||||||
fontSize: "1rem",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: "1.5rem",
|
|
||||||
padding: "1rem",
|
|
||||||
background: "#fff3e0",
|
|
||||||
borderRadius: "8px",
|
|
||||||
border: "2px solid #ff9800",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
marginTop: "0",
|
|
||||||
marginBottom: "0.75rem",
|
|
||||||
color: "#e65100",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
2. Add Random Questions (Quick Setup)
|
|
||||||
</h2>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
margin: "0 0 1rem 0",
|
|
||||||
color: "#666",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Select how many random questions to add from each category
|
|
||||||
</p>
|
|
||||||
{categoryNames.length === 0 ? (
|
|
||||||
<p>No questions available yet.</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gap: "0.75rem",
|
|
||||||
marginBottom: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{categoryNames.map((category) => {
|
|
||||||
const categoryCount = groupedQuestions[category].length;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={category}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "1rem",
|
|
||||||
padding: "0.75rem",
|
|
||||||
background: "white",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<strong>{category}</strong>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: "#666",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
marginLeft: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
({categoryCount} available)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
htmlFor={`random-${category}`}
|
|
||||||
style={{ fontSize: "0.9rem" }}
|
|
||||||
>
|
|
||||||
Add:
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={`random-${category}`}
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max={categoryCount}
|
|
||||||
value={randomSelections[category] || 0}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateRandomSelection(
|
|
||||||
category,
|
|
||||||
parseInt(e.target.value) || 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
width: "70px",
|
|
||||||
padding: "0.5rem",
|
|
||||||
fontSize: "1rem",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: "0.9rem", color: "#666" }}>
|
|
||||||
questions
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={addRandomQuestions}
|
|
||||||
disabled={Object.values(randomSelections).every(
|
|
||||||
(count) => count === 0,
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
padding: "0.75rem 1.5rem",
|
|
||||||
background: Object.values(randomSelections).every(
|
|
||||||
(count) => count === 0,
|
|
||||||
)
|
|
||||||
? "#ccc"
|
|
||||||
: "#ff9800",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: Object.values(randomSelections).every(
|
|
||||||
(count) => count === 0,
|
|
||||||
)
|
|
||||||
? "not-allowed"
|
|
||||||
: "pointer",
|
|
||||||
fontWeight: "bold",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Random Questions to Game
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "1.5rem" }}>
|
|
||||||
<h2 style={{ marginTop: "0", marginBottom: "0.75rem" }}>
|
|
||||||
3. Select Questions Manually ({selectedQuestions.length} selected)
|
|
||||||
</h2>
|
|
||||||
{questions.length === 0 ? (
|
|
||||||
<p>
|
|
||||||
No questions available.{" "}
|
|
||||||
<Link to="/questions">Create some questions first</Link>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: "grid", gap: "1.5rem" }}>
|
|
||||||
{categoryNames.map((category) => (
|
|
||||||
<div
|
|
||||||
key={category}
|
|
||||||
style={{
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
marginTop: 0,
|
|
||||||
marginBottom: "1rem",
|
|
||||||
color: "#2e7d32",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{category}
|
|
||||||
</h3>
|
|
||||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
|
||||||
{groupedQuestions[category].map((q) => (
|
|
||||||
<div
|
|
||||||
key={q.id}
|
|
||||||
onClick={() => 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",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedQuestions.includes(q.id)}
|
|
||||||
readOnly
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
/>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
q.type === "image" ? "#e3f2fd" : "#f3e5f5",
|
|
||||||
padding: "0.25rem 0.5rem",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
marginRight: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{q.type.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span>{q.question_content}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedQuestions.length > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: "1.5rem",
|
|
||||||
padding: "1rem",
|
|
||||||
background: "#f5f5f5",
|
|
||||||
borderRadius: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 style={{ marginTop: 0, marginBottom: "0.75rem" }}>
|
|
||||||
Question Order
|
|
||||||
</h3>
|
|
||||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
|
||||||
{selectedQuestions.map((qId, index) => {
|
|
||||||
const question = getQuestionById(qId);
|
|
||||||
if (!question) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={qId}
|
|
||||||
style={{
|
|
||||||
padding: "0.75rem",
|
|
||||||
background: "white",
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
borderRadius: "4px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontWeight: "bold", minWidth: "30px" }}>
|
|
||||||
#{index + 1}
|
|
||||||
</span>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
{question.category && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
background: "#e8f5e9",
|
|
||||||
padding: "0.25rem 0.5rem",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
color: "#2e7d32",
|
|
||||||
marginRight: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{question.category}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
question.type === "image" ? "#e3f2fd" : "#f3e5f5",
|
|
||||||
padding: "0.25rem 0.5rem",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
marginRight: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{question.type.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span>{question.question_content}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: "0.25rem" }}>
|
|
||||||
<button
|
|
||||||
onClick={() => moveQuestion(index, "up")}
|
|
||||||
disabled={index === 0}
|
|
||||||
style={{
|
|
||||||
padding: "0.25rem 0.5rem",
|
|
||||||
background: index === 0 ? "#ddd" : "#2196F3",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: index === 0 ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => moveQuestion(index, "down")}
|
|
||||||
disabled={index === selectedQuestions.length - 1}
|
|
||||||
style={{
|
|
||||||
padding: "0.25rem 0.5rem",
|
|
||||||
background:
|
|
||||||
index === selectedQuestions.length - 1
|
|
||||||
? "#ddd"
|
|
||||||
: "#2196F3",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor:
|
|
||||||
index === selectedQuestions.length - 1
|
|
||||||
? "not-allowed"
|
|
||||||
: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => removeQuestion(qId)}
|
|
||||||
style={{
|
|
||||||
padding: "0.25rem 0.5rem",
|
|
||||||
background: "#f44336",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginBottom: "1.5rem" }}>
|
|
||||||
<h2 style={{ marginTop: "0", marginBottom: "0.75rem" }}>
|
|
||||||
4. Add Teams
|
|
||||||
</h2>
|
|
||||||
<div
|
|
||||||
style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem", position: "relative" }}
|
|
||||||
>
|
|
||||||
<div style={{ flex: 1, position: "relative" }}>
|
|
||||||
<input
|
|
||||||
ref={teamInputRef}
|
|
||||||
type="text"
|
|
||||||
value={newTeamName}
|
|
||||||
onChange={(e) => {
|
|
||||||
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 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "100%",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
background: "white",
|
|
||||||
border: "1px solid #ccc",
|
|
||||||
borderRadius: "4px",
|
|
||||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
|
||||||
zIndex: 10,
|
|
||||||
maxHeight: "200px",
|
|
||||||
overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filteredSuggestions.slice(0, 8).map((name) => (
|
|
||||||
<div
|
|
||||||
key={name}
|
|
||||||
onClick={() => 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}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button onClick={() => addTeam()} style={{ padding: "0.5rem 1rem" }}>
|
|
||||||
Add Team
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{quickAddSuggestions.length > 0 && (
|
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
|
||||||
<p style={{ margin: "0 0 0.5rem 0", fontSize: "0.9rem", color: "#666" }}>
|
|
||||||
Quick add from past games:
|
|
||||||
</p>
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
|
||||||
{quickAddSuggestions.map((name) => (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
onClick={() => addTeam(name)}
|
|
||||||
style={{
|
|
||||||
padding: "0.4rem 0.75rem",
|
|
||||||
background: "#f5f5f5",
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
borderRadius: "16px",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.target.style.background = "#e3f2fd";
|
|
||||||
e.target.style.borderColor = "#2196F3";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.target.style.background = "#f5f5f5";
|
|
||||||
e.target.style.borderColor = "#ddd";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+ {name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
|
||||||
{teams.map((team, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
background: "#e3f2fd",
|
|
||||||
borderRadius: "20px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{team}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => removeTeam(index)}
|
|
||||||
style={{
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "#f44336",
|
|
||||||
fontWeight: "bold",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleCreateGame}
|
|
||||||
disabled={
|
|
||||||
!gameName || selectedQuestions.length === 0 || teams.length === 0
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
padding: "1rem 2rem",
|
|
||||||
fontSize: "1.1rem",
|
|
||||||
background:
|
|
||||||
!gameName ||
|
|
||||||
selectedQuestions.length === 0 ||
|
|
||||||
teams.length === 0
|
|
||||||
? "#ccc"
|
|
||||||
: "#4CAF50",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "8px",
|
|
||||||
cursor:
|
|
||||||
!gameName ||
|
|
||||||
selectedQuestions.length === 0 ||
|
|
||||||
teams.length === 0
|
|
||||||
? "not-allowed"
|
|
||||||
: "pointer",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create Game & Go to Admin View
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showTemplateModal && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
background: "rgba(0,0,0,0.5)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "white",
|
|
||||||
padding: "2rem",
|
|
||||||
borderRadius: "8px",
|
|
||||||
maxWidth: "600px",
|
|
||||||
maxHeight: "80vh",
|
|
||||||
overflow: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 style={{ marginTop: 0 }}>Select Template</h2>
|
|
||||||
{templates.length === 0 ? (
|
|
||||||
<p>No templates available</p>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{templates.map((template) => (
|
|
||||||
<div
|
|
||||||
key={template.id}
|
|
||||||
style={{
|
|
||||||
padding: "1rem",
|
|
||||||
border: "1px solid #ccc",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => handleLoadTemplate(template)}
|
|
||||||
>
|
|
||||||
<strong>{template.name}</strong>
|
|
||||||
<p style={{ margin: "0.5rem 0 0 0", color: "#666" }}>
|
|
||||||
{template.total_questions} questions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTemplateModal(false)}
|
|
||||||
style={{
|
|
||||||
marginTop: "1rem",
|
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
background: "#ccc",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
957
frontend/frontend/src/components/questionbank/GameSetupView.tsx
Normal file
957
frontend/frontend/src/components/questionbank/GameSetupView.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-2 rounded-md border bg-background px-2 py-1.5 text-sm",
|
||||||
|
isDragging && "z-50 border-ring shadow-lg ring-2 ring-ring/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="shrink-0 cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<span className="w-5 shrink-0 text-center text-xs tabular-nums text-muted-foreground">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
{question.category && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 text-[0.65rem]"
|
||||||
|
>
|
||||||
|
{question.category}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<TypeIcon type={question.type} />
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{question.question_content}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(question.id)}
|
||||||
|
className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tiny type icon ───────────────────────────────────────────────── */
|
||||||
|
function TypeIcon({ type }: { type: string }) {
|
||||||
|
const cls = "size-3 shrink-0 text-muted-foreground";
|
||||||
|
switch (type) {
|
||||||
|
case "image":
|
||||||
|
return <Image className={cls} />;
|
||||||
|
case "audio":
|
||||||
|
return <Music className={cls} />;
|
||||||
|
case "video":
|
||||||
|
return <Video className={cls} />;
|
||||||
|
default:
|
||||||
|
return <FileText className={cls} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════ */
|
||||||
|
export default function GameSetupView() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
/* ─── State ────────────────────────────────────────────────── */
|
||||||
|
const [questions, setQuestions] = useState<Question[]>([]);
|
||||||
|
const [selectedQuestions, setSelectedQuestions] = useState<number[]>([]);
|
||||||
|
const [gameName, setGameName] = useState("");
|
||||||
|
const [teams, setTeams] = useState<string[]>([]);
|
||||||
|
const [newTeamName, setNewTeamName] = useState("");
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
|
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
|
||||||
|
const [randomSelections, setRandomSelections] = useState<
|
||||||
|
Record<string, number>
|
||||||
|
>({});
|
||||||
|
const [pastTeamNames, setPastTeamNames] = useState<string[]>([]);
|
||||||
|
const [showTeamSuggestions, setShowTeamSuggestions] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [collapsedCategories, setCollapsedCategories] = useState<
|
||||||
|
Set<string>
|
||||||
|
>(new Set());
|
||||||
|
const [showRandomPanel, setShowRandomPanel] = useState(false);
|
||||||
|
|
||||||
|
const teamInputRef = useRef<HTMLInputElement>(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<string, Question[]> = {};
|
||||||
|
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<string, Question[]> = {};
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<AdminNavbar />
|
||||||
|
<div className="mx-auto min-h-[calc(100vh-60px)] max-w-7xl px-4 py-6 lg:px-8">
|
||||||
|
{/* ── Header row ─────────────────────────────────── */}
|
||||||
|
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="mb-1 text-2xl font-bold tracking-tight">
|
||||||
|
New Game
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{checklistItems.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item.label}
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
item.done
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mr-1 inline-block size-1.5 rounded-full",
|
||||||
|
item.done
|
||||||
|
? "bg-emerald-500"
|
||||||
|
: "bg-muted-foreground/30",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Dialog
|
||||||
|
open={templateDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setTemplateDialogOpen(open);
|
||||||
|
if (open) loadTemplates();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Bookmark className="size-3.5" />
|
||||||
|
Template
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Load from Template
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Pre-fill your game with questions from a saved template.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
No templates available.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{templates.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() =>
|
||||||
|
handleLoadTemplate(t)
|
||||||
|
}
|
||||||
|
className="flex items-center justify-between rounded-md border px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{t.total_questions} Qs
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!canCreate}
|
||||||
|
onClick={handleCreateGame}
|
||||||
|
>
|
||||||
|
<Rocket className="size-3.5" />
|
||||||
|
Create Game
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Game name ──────────────────────────────────── */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Input
|
||||||
|
value={gameName}
|
||||||
|
onChange={(e) => setGameName(e.target.value)}
|
||||||
|
placeholder="Game name..."
|
||||||
|
className="h-10 text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Two-column layout ─────────────────────────── */}
|
||||||
|
<div className="mb-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{/* LEFT: Question Pool */}
|
||||||
|
<div className="flex flex-col rounded-lg border">
|
||||||
|
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||||
|
<h2 className="text-sm font-semibold">
|
||||||
|
Question Pool
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant={
|
||||||
|
showRandomPanel ? "secondary" : "ghost"
|
||||||
|
}
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() =>
|
||||||
|
setShowRandomPanel(!showRandomPanel)
|
||||||
|
}
|
||||||
|
title="Random questions"
|
||||||
|
>
|
||||||
|
<Dices className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="border-b px-3 py-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSearchQuery(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Search questions..."
|
||||||
|
className="h-7 pl-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Random panel */}
|
||||||
|
{showRandomPanel && (
|
||||||
|
<div className="border-b bg-amber-50 px-3 py-3 dark:bg-amber-950/20">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-amber-800 dark:text-amber-200">
|
||||||
|
Quick Random Add
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() =>
|
||||||
|
setShowRandomPanel(false)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{categoryNames.map((cat) => {
|
||||||
|
const total =
|
||||||
|
groupedQuestions[cat].length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cat}
|
||||||
|
className="flex items-center gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-muted-foreground">
|
||||||
|
/ {total}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={total}
|
||||||
|
value={
|
||||||
|
randomSelections[cat] ||
|
||||||
|
0
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="mt-2 w-full"
|
||||||
|
disabled={Object.values(
|
||||||
|
randomSelections,
|
||||||
|
).every((c) => c === 0)}
|
||||||
|
onClick={addRandomQuestions}
|
||||||
|
>
|
||||||
|
<Shuffle className="size-3" />
|
||||||
|
Add Random
|
||||||
|
</Button>
|
||||||
|
</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]"
|
||||||
|
>
|
||||||
|
{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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT: Game Queue */}
|
||||||
|
<div className="flex flex-col rounded-lg border">
|
||||||
|
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||||
|
<h2 className="text-sm font-semibold">
|
||||||
|
Game Queue
|
||||||
|
{selectedQuestions.length > 0 && (
|
||||||
|
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
||||||
|
{selectedQuestions.length} question
|
||||||
|
{selectedQuestions.length !== 1 &&
|
||||||
|
"s"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{selectedQuestions.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() => setSelectedQuestions([])}
|
||||||
|
title="Clear all"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[28rem] 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">
|
||||||
|
<Plus className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Click questions on the left
|
||||||
|
<br />
|
||||||
|
to add them here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={selectedQuestions}
|
||||||
|
strategy={
|
||||||
|
verticalListSortingStrategy
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1 p-2">
|
||||||
|
{selectedQuestionObjects.map(
|
||||||
|
(q, i) => (
|
||||||
|
<SortableQuestionRow
|
||||||
|
key={q.id}
|
||||||
|
question={q}
|
||||||
|
index={i}
|
||||||
|
onRemove={
|
||||||
|
removeQuestion
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Teams section ─────────────────────────────── */}
|
||||||
|
<div className="mb-8 rounded-lg border p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<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]">
|
||||||
|
{teams.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input + suggestions */}
|
||||||
|
<div className="relative mb-3 flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
ref={teamInputRef}
|
||||||
|
value={newTeamName}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 z-10 mt-1 max-h-48 overflow-auto rounded-md border bg-popover shadow-md">
|
||||||
|
{filteredSuggestions
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((name) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onMouseDown={() =>
|
||||||
|
addTeam(name)
|
||||||
|
}
|
||||||
|
className="block w-full px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => addTeam()}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick add chips */}
|
||||||
|
{quickAddSuggestions.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="mb-1.5 block text-xs text-muted-foreground">
|
||||||
|
Past teams
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{quickAddSuggestions.map((name) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => addTeam(name)}
|
||||||
|
className="rounded-full border border-dashed px-2.5 py-0.5 text-xs transition-colors hover:border-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
+ {name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Added teams */}
|
||||||
|
{teams.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{teams.map((team, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary"
|
||||||
|
>
|
||||||
|
{team}
|
||||||
|
<button
|
||||||
|
onClick={() => removeTeam(index)}
|
||||||
|
className="text-primary/50 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Bottom create bar (sticky on mobile) ──────── */}
|
||||||
|
<div className="sticky bottom-0 -mx-4 border-t bg-background/95 px-4 py-3 backdrop-blur lg:-mx-8 lg:px-8">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
disabled={!canCreate}
|
||||||
|
onClick={handleCreateGame}
|
||||||
|
>
|
||||||
|
<Rocket className="size-4" />
|
||||||
|
Create Game & Go to Admin View
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
frontend/frontend/src/components/ui/badge.tsx
Normal file
52
frontend/frontend/src/components/ui/badge.tsx
Normal file
@@ -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<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
160
frontend/frontend/src/components/ui/dialog.tsx
Normal file
160
frontend/frontend/src/components/ui/dialog.tsx
Normal file
@@ -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 <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
20
frontend/frontend/src/components/ui/input.tsx
Normal file
20
frontend/frontend/src/components/ui/input.tsx
Normal file
@@ -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 (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
55
frontend/frontend/src/components/ui/scroll-area.tsx
Normal file
55
frontend/frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -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 (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Thumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-border"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
23
frontend/frontend/src/components/ui/separator.tsx
Normal file
23
frontend/frontend/src/components/ui/separator.tsx
Normal file
@@ -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 (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
Reference in New Issue
Block a user