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",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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