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:
2026-04-03 13:18:35 -04:00
parent 5aba7b5aa1
commit 67cc877e30
9 changed files with 1326 additions and 845 deletions

View File

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

View File

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

View File

@@ -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>
</>
);
}

View 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>
</>
);
}

View 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 }

View 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,
}

View 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 }

View 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 }

View 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 }