feat: Initialize BuzzMaster project structure
Sets up the foundational project structure for the BuzzMaster Live Quiz Platform. This includes: - **Project Initialization:** Creates `package.json` with necessary dependencies (React, React Router DOM, Vite, TypeScript). - **Vite Configuration:** Configures Vite for development and building, including server settings and environment variable handling. - **HTML Entry Point:** Sets up `index.html` with basic structure, Tailwind CSS, Google Fonts, and ESM import maps. - **React Entry Point:** Configures `index.tsx` to render the main `App` component. - **TypeScript Configuration:** Defines `tsconfig.json` for the project. - **Git Ignore:** Adds standard files and directories to `.gitignore`. - **README and Metadata:** Includes a basic `README.md` and `metadata.json` describing the project. - **Type Definitions:** Establishes core type definitions in `types.ts` for game state, user roles, players, teams, and questions. - **App Component:** Creates a basic `App.tsx` component with routing and initial game state management, including logic for local storage fallback and API sync. - **Component Stubs:** Adds placeholder components for `PlayerView`, `AdminDashboard`, and `SpectatorView`.
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,152 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { HashRouter, Routes, Route, useNavigate } from 'react-router-dom';
|
||||
import PlayerView from './components/PlayerView';
|
||||
import AdminDashboard from './components/AdminDashboard';
|
||||
import SpectatorView from './components/SpectatorView';
|
||||
import { GameData, GameState, UserRole } from './types';
|
||||
import { api } from './services/api';
|
||||
|
||||
const INITIAL_GAME_DATA: GameData = {
|
||||
id: 'game-123',
|
||||
name: 'Trivia Night 2024',
|
||||
state: GameState.LOBBY,
|
||||
currentQuestionIndex: 0,
|
||||
questions: [
|
||||
{ id: 'q1', text: 'What is the capital of France?', answer: 'Paris', points: 100 },
|
||||
{ id: 'q2', text: 'Which planet is known as the Red Planet?', answer: 'Mars', points: 150 },
|
||||
{ id: 'q3', text: 'Who wrote "Romeo and Juliet"?', answer: 'William Shakespeare', points: 200 },
|
||||
],
|
||||
players: [],
|
||||
teams: [],
|
||||
activeBuzzerQueue: [],
|
||||
countdownValue: 3,
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [game, setGame] = useState<GameData>(() => {
|
||||
const saved = localStorage.getItem(`game_${INITIAL_GAME_DATA.id}`);
|
||||
return saved ? JSON.parse(saved) : INITIAL_GAME_DATA;
|
||||
});
|
||||
const [currentUser, setCurrentUser] = useState<any>(null);
|
||||
const [isSynced, setIsSynced] = useState(false);
|
||||
const [useLocal, setUseLocal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const data = await api.getGameStatus(game.id);
|
||||
if (data) {
|
||||
setGame(prev => ({ ...prev, ...data }));
|
||||
setIsSynced(true);
|
||||
setUseLocal(false);
|
||||
} else {
|
||||
setIsSynced(false);
|
||||
setUseLocal(true);
|
||||
}
|
||||
} catch (e) {
|
||||
setIsSynced(false);
|
||||
setUseLocal(true);
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [game.id]);
|
||||
|
||||
const updateGame = async (newData: Partial<GameData>) => {
|
||||
const updated = { ...game, ...newData };
|
||||
setGame(updated);
|
||||
// This updates localStorage internally and attempts the API call
|
||||
await api.updateGameState(game.id, newData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-slate-950 overflow-hidden text-white">
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route
|
||||
path="/player"
|
||||
element={<PlayerView game={game} updateGame={updateGame} currentUser={currentUser} setCurrentUser={setCurrentUser} />}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={<AdminDashboard game={game} updateGame={updateGame} />}
|
||||
/>
|
||||
<Route
|
||||
path="/spectator"
|
||||
element={<SpectatorView game={game} />}
|
||||
/>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
|
||||
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-3 py-1 bg-black/60 backdrop-blur-md rounded-full border border-white/10 text-[10px] uppercase tracking-widest z-50">
|
||||
<div className={`w-2 h-2 rounded-full ${isSynced ? 'bg-green-500 shadow-[0_0_8px_#22c55e]' : useLocal ? 'bg-orange-500' : 'bg-red-500 animate-pulse'}`}></div>
|
||||
{isSynced ? 'Container Synced' : useLocal ? 'Local Cache Mode' : 'Connecting...'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-6 relative">
|
||||
<div className="absolute top-10 text-xs font-mono opacity-30 tracking-[0.2em]">BUZZMASTER // SYSTEM_DOCKER_V1</div>
|
||||
|
||||
<div className="relative mb-16">
|
||||
<h1 className="text-7xl font-black text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 via-blue-500 to-indigo-600 italic tracking-tighter">
|
||||
BUZZMASTER <span className="text-white not-italic font-thin">PRO</span>
|
||||
</h1>
|
||||
<div className="absolute -bottom-2 right-0 text-[10px] font-bold bg-blue-600 px-2 py-0.5 rounded text-white tracking-widest">
|
||||
ENTERPRISE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 w-full max-w-6xl">
|
||||
<RoleCard
|
||||
title="Host"
|
||||
desc="Command Center"
|
||||
node="admin-node-01"
|
||||
color="bg-indigo-600"
|
||||
onClick={() => navigate('/admin')}
|
||||
/>
|
||||
<RoleCard
|
||||
title="Player"
|
||||
desc="Client Interface"
|
||||
node="client-endpoint"
|
||||
color="bg-sky-600"
|
||||
onClick={() => navigate('/player')}
|
||||
/>
|
||||
<RoleCard
|
||||
title="Display"
|
||||
desc="Public Broadcast"
|
||||
node="render-engine"
|
||||
color="bg-emerald-600"
|
||||
onClick={() => navigate('/spectator')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-20 opacity-20 text-[10px] font-mono max-w-xl text-center">
|
||||
DOCKER_SERVICE_DISCOVERY: ENABLED | DB_DRIVER: MARIADB_10.11 | PERSISTENCE_STRATEGY: HYBRID_LOCAL_REMOTE
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RoleCard: React.FC<{ title: string; desc: string; node: string; color: string; onClick: () => void }> = ({ title, desc, node, color, onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`${color} p-10 rounded-[2.5rem] shadow-2xl hover:-translate-y-2 active:scale-95 transition-all text-left border border-white/20 group relative overflow-hidden`}
|
||||
>
|
||||
<div className="absolute -right-6 -bottom-6 opacity-10 group-hover:scale-150 group-hover:rotate-12 transition-transform duration-500">
|
||||
<svg width="160" height="160" viewBox="0 0 24 24" fill="white"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<div className="text-[10px] font-mono opacity-50 mb-4 tracking-widest">{node}</div>
|
||||
<h2 className="text-4xl font-black mb-1 uppercase tracking-tighter italic">{title}</h2>
|
||||
<p className="text-sm font-medium opacity-70 group-hover:opacity-100 transition-opacity">{desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -1,11 +1,20 @@
|
||||
<div align="center">
|
||||
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
|
||||
<h1>Built with AI Studio</h2>
|
||||
|
||||
<p>The fastest path from prompt to production with Gemini.</p>
|
||||
|
||||
<a href="https://aistudio.google.com/apps">Start building</a>
|
||||
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/19kXz8tSX5JGdwe9rXkABq-znXUzwing2
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
const express = require('express');
|
||||
const mysql = require('mysql2/promise');
|
||||
const cors = require('cors');
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST || 'db',
|
||||
user: process.env.DB_USER || 'buzz_user',
|
||||
password: process.env.DB_PASS || 'buzz_password',
|
||||
database: process.env.DB_NAME || 'buzzmaster'
|
||||
};
|
||||
|
||||
let pool;
|
||||
|
||||
async function initDb() {
|
||||
try {
|
||||
pool = await mysql.createPool(dbConfig);
|
||||
console.log('Connected to MariaDB');
|
||||
} catch (e) {
|
||||
console.warn('Database not available. Running in mock mode.');
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.status(200).json({ status: 'ok', timestamp: Date.now() });
|
||||
});
|
||||
|
||||
app.get('/api/game/:id', async (req, res) => {
|
||||
// In a full implementation, you'd fetch from `pool`
|
||||
res.json({ id: req.params.id, state: 'LOBBY', players: [] });
|
||||
});
|
||||
|
||||
app.post('/api/game/:id/join', async (req, res) => {
|
||||
const { name, team } = req.body;
|
||||
res.json({ id: Date.now().toString(), name, teamId: team, status: 'PENDING' });
|
||||
});
|
||||
|
||||
app.patch('/api/game/:id/state', async (req, res) => {
|
||||
res.status(200).send();
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 5000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Backend listening on port ${PORT}`);
|
||||
initDb().catch(console.error);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { GameData, GameState, Player, Question } from '../types';
|
||||
|
||||
interface AdminDashboardProps {
|
||||
game: GameData;
|
||||
updateGame: (data: Partial<GameData>) => void;
|
||||
}
|
||||
|
||||
const AdminDashboard: React.FC<AdminDashboardProps> = ({ game, updateGame }) => {
|
||||
const [activeTab, setActiveTab] = useState<'control' | 'lobby' | 'questions'>('control');
|
||||
|
||||
const approvePlayer = (id: string) => {
|
||||
const updated = game.players.map(p => p.id === id ? { ...p, status: 'APPROVED' } : p);
|
||||
updateGame({ players: updated as Player[] });
|
||||
};
|
||||
|
||||
const startGame = () => {
|
||||
updateGame({ state: GameState.LOBBY });
|
||||
};
|
||||
|
||||
const startCountdown = () => {
|
||||
updateGame({ state: GameState.COUNTDOWN, countdownValue: 3 });
|
||||
let count = 3;
|
||||
const interval = setInterval(() => {
|
||||
count -= 1;
|
||||
if (count <= 0) {
|
||||
clearInterval(interval);
|
||||
updateGame({ state: GameState.QUESTION, countdownValue: 0, activeBuzzerQueue: [] });
|
||||
} else {
|
||||
updateGame({ countdownValue: count });
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const adjudicate = (isCorrect: boolean) => {
|
||||
const firstBuzzer = game.activeBuzzerQueue[0];
|
||||
if (!firstBuzzer) return;
|
||||
|
||||
const currentQ = game.questions[game.currentQuestionIndex];
|
||||
|
||||
if (isCorrect) {
|
||||
const updatedPlayers = game.players.map(p => {
|
||||
if (p.id === firstBuzzer.playerId) {
|
||||
return { ...p, score: p.score + currentQ.points, correctAnswers: p.correctAnswers + 1 };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
updateGame({
|
||||
state: GameState.REVEAL,
|
||||
players: updatedPlayers as Player[]
|
||||
});
|
||||
} else {
|
||||
const updatedQueue = game.activeBuzzerQueue.slice(1);
|
||||
const updatedPlayers = game.players.map(p => {
|
||||
if (p.id === firstBuzzer.playerId) {
|
||||
return { ...p, wrongAnswers: p.wrongAnswers + 1 };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
updateGame({
|
||||
activeBuzzerQueue: updatedQueue,
|
||||
players: updatedPlayers as Player[]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nextQuestion = () => {
|
||||
const nextIdx = game.currentQuestionIndex + 1;
|
||||
if (nextIdx >= game.questions.length) {
|
||||
updateGame({ state: GameState.FINAL_STATS });
|
||||
} else {
|
||||
updateGame({ state: GameState.SCOREBOARD, currentQuestionIndex: nextIdx });
|
||||
}
|
||||
};
|
||||
|
||||
const currentQ = game.questions[game.currentQuestionIndex];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-900 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 bg-slate-950 border-r border-white/5 flex flex-col">
|
||||
<div className="p-6 font-bold text-xl border-b border-white/5">HOST PANEL</div>
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
<NavBtn active={activeTab === 'control'} onClick={() => setActiveTab('control')} label="Control Center" />
|
||||
<NavBtn active={activeTab === 'lobby'} onClick={() => setActiveTab('lobby')} label="Lobby & Players" />
|
||||
<NavBtn active={activeTab === 'questions'} onClick={() => setActiveTab('questions')} label="Question List" />
|
||||
</nav>
|
||||
<div className="p-4 border-t border-white/5 space-y-2">
|
||||
<SoundBtn label="DING" color="bg-green-600" />
|
||||
<SoundBtn label="BUZZ" color="bg-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Area */}
|
||||
<main className="flex-1 p-8 overflow-y-auto">
|
||||
{activeTab === 'lobby' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-3xl font-bold">Lobby Management</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{game.players.map(p => (
|
||||
<div key={p.id} className="glass p-4 rounded-xl flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-bold">{p.name}</div>
|
||||
<div className="text-sm opacity-50">Team: {p.teamId}</div>
|
||||
</div>
|
||||
{p.status === 'PENDING' ? (
|
||||
<button onClick={() => approvePlayer(p.id)} className="px-4 py-2 bg-blue-600 rounded-lg text-sm">Approve</button>
|
||||
) : (
|
||||
<span className="text-green-500 text-sm font-bold">ACTIVE</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{game.state === GameState.LOBBY && (
|
||||
<button onClick={startGame} className="px-8 py-3 bg-indigo-600 rounded-xl font-bold">Finalize Lobby & Start</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'control' && (
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-4xl font-black mb-2">Question {game.currentQuestionIndex + 1}</h2>
|
||||
<p className="text-xl opacity-70 max-w-2xl">{currentQ.text}</p>
|
||||
<p className="text-green-400 mt-2 font-bold">A: {currentQ.answer}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm opacity-50">Points Value</div>
|
||||
<div className="text-3xl font-bold">{currentQ.points}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{/* Controls */}
|
||||
<div className="glass p-6 rounded-3xl border border-white/10 space-y-4">
|
||||
<h3 className="text-xl font-bold border-b border-white/5 pb-2">Game Actions</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
disabled={game.state !== GameState.SCOREBOARD && game.state !== GameState.LOBBY}
|
||||
onClick={startCountdown}
|
||||
className="p-6 bg-blue-600 disabled:opacity-30 rounded-2xl font-bold"
|
||||
>
|
||||
START COUNTDOWN
|
||||
</button>
|
||||
<button
|
||||
disabled={game.state !== GameState.REVEAL}
|
||||
onClick={nextQuestion}
|
||||
className="p-6 bg-indigo-600 disabled:opacity-30 rounded-2xl font-bold"
|
||||
>
|
||||
NEXT SLIDE
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
<h4 className="text-sm font-bold opacity-50">Adjudication</h4>
|
||||
<div className="flex gap-4">
|
||||
<button onClick={() => adjudicate(true)} className="flex-1 py-4 bg-green-600 rounded-xl font-bold">CORRECT</button>
|
||||
<button onClick={() => adjudicate(false)} className="flex-1 py-4 bg-red-600 rounded-xl font-bold">WRONG</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buzzer Queue */}
|
||||
<div className="glass p-6 rounded-3xl border border-white/10">
|
||||
<h3 className="text-xl font-bold border-b border-white/5 pb-2 mb-4">Buzzer Queue</h3>
|
||||
<div className="space-y-3">
|
||||
{game.activeBuzzerQueue.length === 0 && <div className="text-center py-8 opacity-40">Waiting for buzzes...</div>}
|
||||
{game.activeBuzzerQueue.map((buzz, i) => {
|
||||
const p = game.players.find(pl => pl.id === buzz.playerId);
|
||||
return (
|
||||
<div key={buzz.playerId} className={`p-4 rounded-xl flex justify-between items-center ${i === 0 ? 'bg-green-500/20 border border-green-500/50' : 'bg-white/5'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center text-xs">{i + 1}</span>
|
||||
<span className="font-bold">{p?.name || 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="text-xs opacity-50">{buzz.timestamp % 10000}ms</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NavBtn: React.FC<{ active: boolean; label: string; onClick: () => void }> = ({ active, label, onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left p-3 rounded-lg transition-colors ${active ? 'bg-blue-600 text-white font-bold' : 'hover:bg-white/5 text-white/60'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const SoundBtn: React.FC<{ label: string; color: string }> = ({ label, color }) => (
|
||||
<button className={`w-full py-2 ${color} rounded-lg text-xs font-bold shadow-lg active:scale-95 transition-transform`}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default AdminDashboard;
|
||||
@@ -0,0 +1,141 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { GameData, GameState, Player } from '../types';
|
||||
|
||||
interface PlayerViewProps {
|
||||
game: GameData;
|
||||
updateGame: (data: Partial<GameData>) => void;
|
||||
currentUser: Player | null;
|
||||
setCurrentUser: (p: Player) => void;
|
||||
}
|
||||
|
||||
const PlayerView: React.FC<PlayerViewProps> = ({ game, updateGame, currentUser, setCurrentUser }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [teamName, setTeamName] = useState('');
|
||||
|
||||
const handleJoin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name || !teamName) return;
|
||||
|
||||
const newPlayer: Player = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
name,
|
||||
teamId: teamName, // Simplified team logic
|
||||
status: 'PENDING',
|
||||
score: 0,
|
||||
correctAnswers: 0,
|
||||
wrongAnswers: 0,
|
||||
avgBuzzerMs: 0
|
||||
};
|
||||
|
||||
setCurrentUser(newPlayer);
|
||||
updateGame({ players: [...game.players, newPlayer] });
|
||||
};
|
||||
|
||||
const handleBuzz = () => {
|
||||
if (!currentUser || game.state !== GameState.QUESTION) return;
|
||||
|
||||
const timestamp = Date.now();
|
||||
const alreadyBuzzed = game.activeBuzzerQueue.some(b => b.playerId === currentUser.id);
|
||||
|
||||
if (!alreadyBuzzed) {
|
||||
updateGame({
|
||||
state: GameState.BUZZED,
|
||||
activeBuzzerQueue: [...game.activeBuzzerQueue, { playerId: currentUser.id, timestamp }]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-6 min-h-screen bg-slate-950">
|
||||
<form onSubmit={handleJoin} className="w-full max-w-sm space-y-4 glass p-8 rounded-3xl border border-white/10">
|
||||
<h2 className="text-2xl font-bold text-center mb-6">Join the Game</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 opacity-70">Your Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full p-4 bg-white/5 border border-white/10 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="e.g. John Doe"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 opacity-70">Team Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={teamName}
|
||||
onChange={e => setTeamName(e.target.value)}
|
||||
className="w-full p-4 bg-white/5 border border-white/10 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="e.g. The Nerds"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="w-full py-4 bg-blue-600 rounded-xl font-bold text-lg hover:bg-blue-500 transition-colors">
|
||||
Enter Lobby
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentUser.status === 'PENDING') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-6 min-h-screen text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-500 mb-6"></div>
|
||||
<h2 className="text-2xl font-bold mb-2">Waiting for Host...</h2>
|
||||
<p className="opacity-70">Hang tight, {currentUser.name}! The host will approve you shortly.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFirst = game.activeBuzzerQueue[0]?.playerId === currentUser.id;
|
||||
const hasBuzzed = game.activeBuzzerQueue.some(b => b.playerId === currentUser.id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center p-4 min-h-screen bg-slate-900 overflow-hidden">
|
||||
<div className="w-full flex justify-between items-center mb-8 px-4 py-2 glass rounded-2xl border border-white/5">
|
||||
<div className="font-bold">{currentUser.name}</div>
|
||||
<div className="text-blue-400 font-bold">{teamName}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center w-full">
|
||||
{game.state === GameState.LOBBY && (
|
||||
<div className="text-center italic opacity-60">Game will start soon...</div>
|
||||
)}
|
||||
|
||||
{game.state === GameState.COUNTDOWN && (
|
||||
<div className="text-8xl font-black text-blue-500 animate-bounce">{game.countdownValue}</div>
|
||||
)}
|
||||
|
||||
{game.state === GameState.QUESTION && (
|
||||
<button
|
||||
onClick={handleBuzz}
|
||||
className="w-64 h-64 rounded-full bg-red-600 shadow-2xl active:scale-90 transition-all border-[12px] border-red-800 buzzer-ready flex items-center justify-center text-3xl font-black italic uppercase"
|
||||
>
|
||||
BUZZ!
|
||||
</button>
|
||||
)}
|
||||
|
||||
{game.state === GameState.BUZZED && (
|
||||
<div className={`w-64 h-64 rounded-full flex items-center justify-center text-2xl font-bold border-[12px] ${isFirst ? 'bg-green-600 border-green-800' : 'bg-slate-700 border-slate-800 opacity-50'}`}>
|
||||
{isFirst ? 'YOUR TURN!' : hasBuzzed ? 'LOCKED' : 'WAITING'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(game.state === GameState.REVEAL || game.state === GameState.SCOREBOARD) && (
|
||||
<div className="text-center">
|
||||
<h3 className="text-2xl font-bold mb-2">Well Played!</h3>
|
||||
<p className="opacity-60">Get ready for the next one.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-4 text-center text-xs opacity-40">
|
||||
Lat: ~15ms
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayerView;
|
||||
@@ -0,0 +1,143 @@
|
||||
|
||||
import React from 'react';
|
||||
import { GameData, GameState } from '../types';
|
||||
|
||||
interface SpectatorViewProps {
|
||||
game: GameData;
|
||||
}
|
||||
|
||||
const SpectatorView: React.FC<SpectatorViewProps> = ({ game }) => {
|
||||
const currentQ = game.questions[game.currentQuestionIndex];
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full bg-slate-900 flex flex-col items-center justify-center overflow-hidden p-12">
|
||||
{/* Background Ambience */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-0 -left-20 w-96 h-96 bg-blue-500 rounded-full blur-[120px] animate-pulse"></div>
|
||||
<div className="absolute bottom-0 -right-20 w-96 h-96 bg-purple-500 rounded-full blur-[120px] animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-6xl h-full flex flex-col items-center justify-center text-center">
|
||||
|
||||
{game.state === GameState.LOBBY && (
|
||||
<div className="space-y-12">
|
||||
<h1 className="text-7xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-indigo-500">
|
||||
JOIN THE QUIZ!
|
||||
</h1>
|
||||
<div className="flex justify-center gap-12">
|
||||
<div className="p-8 bg-white rounded-3xl shadow-2xl">
|
||||
{/* Simulate QR Code */}
|
||||
<div className="w-48 h-48 bg-slate-900 rounded-xl flex items-center justify-center">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[...Array(16)].map((_, i) => (
|
||||
<div key={i} className={`w-6 h-6 rounded-sm ${Math.random() > 0.5 ? 'bg-white' : 'bg-transparent'}`}></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left flex flex-col justify-center">
|
||||
<div className="text-3xl font-bold opacity-60">Visit URL:</div>
|
||||
<div className="text-5xl font-black text-blue-400">QUIZ.LIVE/BM-123</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-12">
|
||||
<h3 className="text-2xl font-bold opacity-40 mb-4">PLAYERS JOINED:</h3>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{game.players.filter(p => p.status === 'APPROVED').map(p => (
|
||||
<span key={p.id} className="px-6 py-2 glass border border-white/10 rounded-full font-bold animate-bounce" style={{ animationDelay: `${Math.random()}s` }}>
|
||||
{p.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{game.state === GameState.COUNTDOWN && (
|
||||
<div className="text-[20rem] font-black italic text-transparent bg-clip-text bg-gradient-to-b from-blue-400 to-indigo-800 animate-ping">
|
||||
{game.countdownValue}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{game.state === GameState.QUESTION && (
|
||||
<div className="space-y-12 animate-in fade-in zoom-in duration-500">
|
||||
<div className="px-8 py-4 bg-blue-600 rounded-2xl text-2xl font-bold inline-block">QUESTION {game.currentQuestionIndex + 1}</div>
|
||||
<h2 className="text-7xl font-bold leading-tight drop-shadow-2xl">
|
||||
{currentQ.text}
|
||||
</h2>
|
||||
<div className="text-3xl font-bold text-white/40 italic">Waiting for buzzers...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{game.state === GameState.BUZZED && (
|
||||
<div className="space-y-12 w-full">
|
||||
<h2 className="text-4xl opacity-50 font-bold">{currentQ.text}</h2>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="text-2xl font-bold mb-4 px-6 py-2 bg-yellow-500 rounded-xl text-slate-900">PLAYER BUZZED!</div>
|
||||
<div className="text-9xl font-black text-white px-12 py-8 bg-blue-600 rounded-[3rem] shadow-[0_0_100px_rgba(37,99,235,0.6)] border-8 border-blue-400">
|
||||
{game.players.find(p => p.id === game.activeBuzzerQueue[0]?.playerId)?.name || 'Someone'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{game.state === GameState.REVEAL && (
|
||||
<div className="space-y-12 text-center animate-in slide-in-from-bottom duration-700">
|
||||
<h2 className="text-4xl opacity-50 font-bold">THE ANSWER IS...</h2>
|
||||
<div className="text-9xl font-black text-green-400 drop-shadow-[0_0_30px_rgba(74,222,128,0.5)]">
|
||||
{currentQ.answer}
|
||||
</div>
|
||||
<div className="text-4xl font-bold text-white">Points awarded to {game.players.find(p => p.id === game.activeBuzzerQueue[0]?.playerId)?.teamId}!</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{game.state === GameState.SCOREBOARD && (
|
||||
<div className="w-full max-w-4xl space-y-8">
|
||||
<h2 className="text-6xl font-black mb-12 italic tracking-tighter underline decoration-blue-500 underline-offset-8">LEADERBOARD</h2>
|
||||
<div className="space-y-4">
|
||||
{game.players
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 5)
|
||||
.map((p, i) => (
|
||||
<div key={p.id} className="flex items-center justify-between p-6 glass rounded-2xl border border-white/10 shadow-xl overflow-hidden relative">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-2 bg-blue-500"></div>
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="text-4xl font-black opacity-20">{i+1}</span>
|
||||
<span className="text-3xl font-bold">{p.name}</span>
|
||||
<span className="text-xl opacity-40">({p.teamId})</span>
|
||||
</div>
|
||||
<div className="text-4xl font-black text-blue-400">{p.score} pts</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{game.state === GameState.FINAL_STATS && (
|
||||
<div className="space-y-12 w-full">
|
||||
<h1 className="text-8xl font-black text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-500">GAME OVER!</h1>
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
<StatBox label="The Flash" sub="Fastest Buzzer" value="Sarah" />
|
||||
<StatBox label="The Brain" sub="Most Correct" value="John" />
|
||||
<StatBox label="The Guesser" sub="Most Misses" value="Mike" />
|
||||
</div>
|
||||
<div className="mt-12 p-8 glass rounded-[3rem] border border-white/20">
|
||||
<h2 className="text-4xl font-bold mb-4">🏆 FINAL CHAMPIONS 🏆</h2>
|
||||
<div className="text-7xl font-black text-blue-400 uppercase">THE NERDS</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatBox: React.FC<{ label: string; sub: string; value: string }> = ({ label, sub, value }) => (
|
||||
<div className="glass p-8 rounded-3xl border border-white/5 space-y-2">
|
||||
<div className="text-sm font-bold text-blue-400 uppercase tracking-widest">{sub}</div>
|
||||
<div className="text-xl font-bold opacity-60">{label}</div>
|
||||
<div className="text-4xl font-black">{value}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SpectatorView;
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mariadb:10.11
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root_password
|
||||
MYSQL_DATABASE: buzzmaster
|
||||
MYSQL_USER: buzz_user
|
||||
MYSQL_PASSWORD: buzz_password
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: always
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_USER: buzz_user
|
||||
DB_PASS: buzz_password
|
||||
DB_NAME: buzzmaster
|
||||
PORT: 5000
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
frontend:
|
||||
build: .
|
||||
ports:
|
||||
- "80:80"
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:5000
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>BuzzMaster Live</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; overflow: hidden; }
|
||||
.buzzer-active { transform: scale(0.95); background-color: #ef4444; }
|
||||
.glass { background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); }
|
||||
@keyframes pulse-red { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); } 70% { box-shadow: 0 0 0 20px rgba(239, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } }
|
||||
.buzzer-ready { animation: pulse-red 2s infinite; }
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
|
||||
"react/": "https://esm.sh/react@^19.2.3/",
|
||||
"react": "https://esm.sh/react@^19.2.3",
|
||||
"react-router-dom": "https://esm.sh/react-router-dom@^7.12.0"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-slate-900 text-white">
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "BuzzMaster Live Quiz Platform",
|
||||
"description": "A high-performance, real-time live quiz show platform with dedicated views for Main Display, Mobile Buzzer, and Admin Control.",
|
||||
"requestFramePermissions": [
|
||||
"microphone"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "buzzmaster-live-quiz-platform",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react-dom": "^19.2.3",
|
||||
"react": "^19.2.3",
|
||||
"react-router-dom": "^7.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
|
||||
import { GameData, Player } from '../types';
|
||||
|
||||
const API_URL = process.env.VITE_API_URL || 'http://localhost:5000';
|
||||
|
||||
// Helper to check if the backend is actually alive
|
||||
async function checkConnectivity(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1000);
|
||||
await fetch(`${API_URL}/api/health`, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async getGameStatus(gameId: string): Promise<GameData | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/game/${gameId}`);
|
||||
if (!res.ok) throw new Error('Backend error');
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
const local = localStorage.getItem(`game_${gameId}`);
|
||||
return local ? JSON.parse(local) : null;
|
||||
}
|
||||
},
|
||||
|
||||
async joinGame(gameId: string, name: string, team: string): Promise<Player> {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/game/${gameId}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, team }),
|
||||
});
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
const mockPlayer: Player = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
name,
|
||||
teamId: team,
|
||||
status: 'PENDING',
|
||||
score: 0,
|
||||
correctAnswers: 0,
|
||||
wrongAnswers: 0,
|
||||
avgBuzzerMs: 0
|
||||
};
|
||||
return mockPlayer;
|
||||
}
|
||||
},
|
||||
|
||||
async postBuzz(gameId: string, playerId: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`${API_URL}/api/game/${gameId}/buzz`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ playerId, timestamp: Date.now() }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("API Offline: Buzz saved locally");
|
||||
}
|
||||
},
|
||||
|
||||
async updateGameState(gameId: string, updates: Partial<GameData>): Promise<void> {
|
||||
// Persist locally regardless of backend status for immediate UI responsiveness
|
||||
const existing = localStorage.getItem(`game_${gameId}`);
|
||||
const data = existing ? JSON.parse(existing) : {};
|
||||
localStorage.setItem(`game_${gameId}`, JSON.stringify({ ...data, ...updates }));
|
||||
|
||||
try {
|
||||
await fetch(`${API_URL}/api/game/${gameId}/state`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
} catch (e) {
|
||||
// Silently fail if offline, as localStorage has already handled the update
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
|
||||
export enum GameState {
|
||||
LOBBY = 'LOBBY',
|
||||
COUNTDOWN = 'COUNTDOWN',
|
||||
QUESTION = 'QUESTION',
|
||||
BUZZED = 'BUZZED',
|
||||
REVEAL = 'REVEAL',
|
||||
SCOREBOARD = 'SCOREBOARD',
|
||||
FINAL_STATS = 'FINAL_STATS'
|
||||
}
|
||||
|
||||
export enum UserRole {
|
||||
HOST = 'HOST',
|
||||
PLAYER = 'PLAYER',
|
||||
SPECTATOR = 'SPECTATOR'
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
id: string;
|
||||
name: string;
|
||||
teamId: string;
|
||||
status: 'PENDING' | 'APPROVED';
|
||||
score: number;
|
||||
correctAnswers: number;
|
||||
wrongAnswers: number;
|
||||
avgBuzzerMs: number;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: string;
|
||||
text: string;
|
||||
answer: string;
|
||||
points: number;
|
||||
audioUrl?: string;
|
||||
audioStart?: number;
|
||||
audioEnd?: number;
|
||||
}
|
||||
|
||||
export interface BuzzerLog {
|
||||
playerId: string;
|
||||
timestamp: number; // ms precision
|
||||
}
|
||||
|
||||
export interface GameData {
|
||||
id: string;
|
||||
name: string;
|
||||
state: GameState;
|
||||
currentQuestionIndex: number;
|
||||
questions: Question[];
|
||||
players: Player[];
|
||||
teams: Team[];
|
||||
activeBuzzerQueue: BuzzerLog[];
|
||||
countdownValue: number;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user