410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useAuth } from 'react-oidc-context';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import { motion } from 'framer-motion';
|
|
import { RefreshCw, LogOut } from 'lucide-react';
|
|
import { useGame } from './hooks/useGame';
|
|
import { useQuizLibrary } from './hooks/useQuizLibrary';
|
|
import { useUserConfig } from './hooks/useUserConfig';
|
|
import { useUserPreferences } from './hooks/useUserPreferences';
|
|
import { Landing } from './components/Landing';
|
|
import { Lobby } from './components/Lobby';
|
|
import { GameScreen } from './components/GameScreen';
|
|
import { Scoreboard } from './components/Scoreboard';
|
|
import { Podium } from './components/Podium';
|
|
import { QuizCreator } from './components/QuizCreator';
|
|
import { RevealScreen } from './components/RevealScreen';
|
|
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
|
|
import { QuizEditor } from './components/QuizEditor';
|
|
import { SaveOptionsModal } from './components/SaveOptionsModal';
|
|
import { DisconnectedScreen } from './components/DisconnectedScreen';
|
|
import { WaitingToRejoin } from './components/WaitingToRejoin';
|
|
import { HostReconnected } from './components/HostReconnected';
|
|
import { SharedQuizView } from './components/SharedQuizView';
|
|
import { UpgradePage } from './components/UpgradePage';
|
|
import { PaymentResult } from './components/PaymentResult';
|
|
import type { Quiz, GameConfig } from './types';
|
|
|
|
const seededRandom = (seed: number) => {
|
|
const x = Math.sin(seed * 9999) * 10000;
|
|
return x - Math.floor(x);
|
|
};
|
|
|
|
const FLOATING_SHAPES = [...Array(15)].map((_, i) => ({
|
|
left: `${seededRandom(i * 1) * 100}%`,
|
|
width: `${seededRandom(i * 2) * 100 + 40}px`,
|
|
height: `${seededRandom(i * 3) * 100 + 40}px`,
|
|
animationDuration: `${seededRandom(i * 4) * 20 + 15}s`,
|
|
animationDelay: `-${seededRandom(i * 5) * 20}s`,
|
|
borderRadius: seededRandom(i * 6) > 0.5 ? '50%' : '20%',
|
|
background: 'rgba(255, 255, 255, 0.05)',
|
|
}));
|
|
|
|
const FloatingShapes = React.memo(() => {
|
|
return (
|
|
<>
|
|
{FLOATING_SHAPES.map((style, i) => (
|
|
<div key={i} className="floating-shape" style={style} />
|
|
))}
|
|
</>
|
|
);
|
|
});
|
|
|
|
function App() {
|
|
const auth = useAuth();
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const { saveQuiz, updateQuiz, saving } = useQuizLibrary();
|
|
const { defaultConfig } = useUserConfig();
|
|
const { subscription } = useUserPreferences();
|
|
const maxPlayersLimit = (!subscription || subscription.accessType === 'none') ? 10 : 150;
|
|
const [showSaveOptions, setShowSaveOptions] = useState(false);
|
|
const [pendingEditedQuiz, setPendingEditedQuiz] = useState<Quiz | null>(null);
|
|
const {
|
|
role,
|
|
gameState,
|
|
quiz,
|
|
players,
|
|
currentQuestionIndex,
|
|
timeLeft,
|
|
error,
|
|
gamePin,
|
|
startQuizGen,
|
|
startManualCreation,
|
|
cancelCreation,
|
|
finalizeManualQuiz,
|
|
loadSavedQuiz,
|
|
joinGame,
|
|
startGame,
|
|
handleAnswer,
|
|
hasAnswered,
|
|
lastPointsEarned,
|
|
lastAnswerCorrect,
|
|
nextQuestion,
|
|
showScoreboard,
|
|
currentCorrectShape,
|
|
selectedOption,
|
|
currentPlayerScore,
|
|
currentStreak,
|
|
currentPlayerId,
|
|
pendingQuizToSave,
|
|
dismissSavePrompt,
|
|
sourceQuizId,
|
|
updateQuizFromEditor,
|
|
startGameFromEditor,
|
|
backFromEditor,
|
|
gameConfig,
|
|
isReconnecting,
|
|
currentPlayerName,
|
|
attemptReconnect,
|
|
goHomeFromDisconnected,
|
|
endGame,
|
|
resumeGame,
|
|
presenterId,
|
|
setPresenterPlayer,
|
|
sendAdvance,
|
|
kickPlayer,
|
|
leaveGame
|
|
} = useGame(defaultConfig);
|
|
|
|
const handleSaveQuiz = async () => {
|
|
if (!pendingQuizToSave) return;
|
|
const source = pendingQuizToSave.topic ? 'ai_generated' : 'manual';
|
|
const topic = pendingQuizToSave.topic || undefined;
|
|
await saveQuiz(pendingQuizToSave.quiz, source, topic);
|
|
dismissSavePrompt();
|
|
};
|
|
|
|
const handleEditorSave = async (editedQuiz: Quiz) => {
|
|
updateQuizFromEditor(editedQuiz);
|
|
if (auth.isAuthenticated) {
|
|
if (sourceQuizId) {
|
|
// Quiz was loaded from library - show options modal
|
|
setPendingEditedQuiz(editedQuiz);
|
|
setShowSaveOptions(true);
|
|
} else {
|
|
// New quiz (AI-generated or manual) - save as new
|
|
const source = pendingQuizToSave?.topic ? 'ai_generated' : 'manual';
|
|
const topic = pendingQuizToSave?.topic || undefined;
|
|
await saveQuiz(editedQuiz, source, topic);
|
|
dismissSavePrompt();
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleOverwriteQuiz = async () => {
|
|
if (!pendingEditedQuiz || !sourceQuizId) return;
|
|
await updateQuiz(sourceQuizId, pendingEditedQuiz);
|
|
setShowSaveOptions(false);
|
|
setPendingEditedQuiz(null);
|
|
dismissSavePrompt();
|
|
};
|
|
|
|
const handleSaveAsNew = async () => {
|
|
if (!pendingEditedQuiz) return;
|
|
await saveQuiz(pendingEditedQuiz, 'manual');
|
|
setShowSaveOptions(false);
|
|
setPendingEditedQuiz(null);
|
|
dismissSavePrompt();
|
|
};
|
|
|
|
const currentQ = quiz?.questions[currentQuestionIndex];
|
|
|
|
const correctOpt = currentQ?.options.find(o => {
|
|
if (role === 'HOST') return o.isCorrect;
|
|
return o.shape === currentCorrectShape;
|
|
});
|
|
|
|
const sharedMatch = location.pathname.match(/^\/shared\/([a-zA-Z0-9_-]+)$/);
|
|
const isSharedQuizRoute = !!sharedMatch && gameState === 'LANDING';
|
|
const isUpgradeRoute = location.pathname === '/upgrade' && gameState === 'LANDING';
|
|
const isPaymentSuccessRoute = location.pathname === '/payment/success' && gameState === 'LANDING';
|
|
const isPaymentCancelRoute = location.pathname === '/payment/cancel' && gameState === 'LANDING';
|
|
|
|
const navigateHome = () => {
|
|
navigate('/', { replace: true });
|
|
};
|
|
|
|
if (isUpgradeRoute) {
|
|
return <UpgradePage onBack={navigateHome} />;
|
|
}
|
|
|
|
if (isPaymentSuccessRoute) {
|
|
return <PaymentResult status="success" onBack={navigateHome} />;
|
|
}
|
|
|
|
if (isPaymentCancelRoute) {
|
|
return <PaymentResult status="cancel" onBack={navigateHome} />;
|
|
}
|
|
|
|
if (isSharedQuizRoute) {
|
|
return (
|
|
<div className="h-screen text-white relative overflow-hidden">
|
|
<FloatingShapes />
|
|
<div className="relative z-10 h-full">
|
|
<SharedQuizView onHostQuiz={(sharedQuiz) => loadSavedQuiz(sharedQuiz)} shareToken={sharedMatch![1]} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-screen text-white relative overflow-hidden">
|
|
<FloatingShapes />
|
|
|
|
<div className="relative z-10 h-full">
|
|
{gameState === 'LANDING' || gameState === 'GENERATING' ? (
|
|
<Landing
|
|
onGenerate={startQuizGen}
|
|
onCreateManual={startManualCreation}
|
|
onLoadQuiz={loadSavedQuiz}
|
|
onJoin={joinGame}
|
|
isLoading={gameState === 'GENERATING'}
|
|
error={error}
|
|
initialPin={gamePin}
|
|
/>
|
|
) : null}
|
|
|
|
{gameState === 'CREATING' ? (
|
|
<QuizCreator
|
|
onFinalize={finalizeManualQuiz}
|
|
onCancel={cancelCreation}
|
|
/>
|
|
) : null}
|
|
|
|
{gameState === 'EDITING' && quiz ? (
|
|
<QuizEditor
|
|
quiz={quiz}
|
|
onSave={handleEditorSave}
|
|
onStartGame={startGameFromEditor}
|
|
onBack={backFromEditor}
|
|
showSaveButton={auth.isAuthenticated}
|
|
defaultConfig={defaultConfig}
|
|
maxPlayersLimit={maxPlayersLimit}
|
|
/>
|
|
) : null}
|
|
|
|
{gameState === 'LOBBY' ? (
|
|
<>
|
|
<Lobby
|
|
quizTitle={quiz?.title || 'Kaboot'}
|
|
players={players}
|
|
gamePin={gamePin}
|
|
role={role}
|
|
onStart={startGame}
|
|
onEndGame={role === 'HOST' ? endGame : undefined}
|
|
currentPlayerId={currentPlayerId}
|
|
hostParticipates={gameConfig.hostParticipates}
|
|
presenterId={presenterId}
|
|
onSetPresenter={setPresenterPlayer}
|
|
onKickPlayer={role === 'HOST' ? kickPlayer : undefined}
|
|
onLeaveGame={role === 'CLIENT' ? leaveGame : undefined}
|
|
/>
|
|
{auth.isAuthenticated && pendingQuizToSave && (
|
|
<SaveQuizPrompt
|
|
isOpen={true}
|
|
quizTitle={pendingQuizToSave.quiz.title}
|
|
onSave={handleSaveQuiz}
|
|
onSkip={dismissSavePrompt}
|
|
/>
|
|
)}
|
|
</>
|
|
) : null}
|
|
|
|
{(gameState === 'COUNTDOWN' || gameState === 'QUESTION') ? (
|
|
gameState === 'COUNTDOWN' ? (
|
|
<div className="flex flex-col items-center justify-center h-screen animate-bounce">
|
|
<div className="text-4xl font-display font-bold mb-4">Get Ready!</div>
|
|
<div className="text-[12rem] font-black leading-none drop-shadow-[0_10px_0_rgba(0,0,0,0.3)]">
|
|
{timeLeft}
|
|
</div>
|
|
</div>
|
|
) : quiz?.questions[currentQuestionIndex] ? (
|
|
<GameScreen
|
|
question={quiz.questions[currentQuestionIndex]}
|
|
timeLeft={timeLeft}
|
|
totalQuestions={quiz.questions.length}
|
|
currentQuestionIndex={currentQuestionIndex}
|
|
gameState={gameState}
|
|
role={role}
|
|
onAnswer={handleAnswer}
|
|
hasAnswered={hasAnswered}
|
|
lastPointsEarned={lastPointsEarned}
|
|
hostPlays={gameConfig.hostParticipates}
|
|
/>
|
|
) : role === 'CLIENT' && hasAnswered ? (
|
|
<div className="flex flex-col items-center justify-center h-screen">
|
|
<div className="bg-theme-primary/95 rounded-[2rem] p-12 text-center">
|
|
<div className="text-6xl mb-6">🚀</div>
|
|
<h2 className="text-4xl md:text-5xl font-black text-white font-display mb-4">Answer Sent!</h2>
|
|
<p className="text-xl font-bold opacity-80">Cross your fingers...</p>
|
|
</div>
|
|
</div>
|
|
) : currentPlayerName ? (
|
|
<WaitingToRejoin
|
|
playerName={currentPlayerName}
|
|
score={currentPlayerScore}
|
|
/>
|
|
) : null
|
|
) : null}
|
|
|
|
{gameState === 'REVEAL' ? (
|
|
correctOpt ? (
|
|
<RevealScreen
|
|
isCorrect={lastAnswerCorrect === true}
|
|
pointsEarned={lastPointsEarned || 0}
|
|
newScore={currentPlayerScore}
|
|
streak={currentStreak}
|
|
correctOption={correctOpt}
|
|
selectedOption={selectedOption}
|
|
role={role}
|
|
onNext={showScoreboard}
|
|
isPresenter={currentPlayerId === presenterId}
|
|
onPresenterAdvance={() => sendAdvance('SCOREBOARD')}
|
|
hostParticipates={gameConfig.hostParticipates}
|
|
/>
|
|
) : currentPlayerName ? (
|
|
<WaitingToRejoin
|
|
playerName={currentPlayerName}
|
|
score={currentPlayerScore}
|
|
/>
|
|
) : null
|
|
) : null}
|
|
|
|
{gameState === 'SCOREBOARD' ? (
|
|
<Scoreboard
|
|
players={players}
|
|
onNext={nextQuestion}
|
|
isHost={role === 'HOST'}
|
|
currentPlayerId={currentPlayerId}
|
|
isPresenter={currentPlayerId === presenterId}
|
|
onPresenterAdvance={() => sendAdvance('NEXT')}
|
|
/>
|
|
) : null}
|
|
|
|
{gameState === 'PODIUM' ? (
|
|
<Podium
|
|
players={players}
|
|
onRestart={endGame}
|
|
/>
|
|
) : null}
|
|
|
|
{gameState === 'DISCONNECTED' && currentPlayerName && gamePin ? (
|
|
role === 'HOST' ? (
|
|
// Host disconnected - show reconnecting state or allow ending game
|
|
<div className="flex flex-col items-center justify-center h-screen p-4 md:p-6 text-center overflow-hidden">
|
|
<motion.div
|
|
initial={{ scale: 0.8, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
className="bg-white/10 backdrop-blur-md p-8 md:p-12 rounded-[2rem] border-4 border-white/20 shadow-xl max-w-md w-full"
|
|
>
|
|
<motion.div
|
|
animate={{ rotate: 360 }}
|
|
transition={{ repeat: Infinity, duration: 2, ease: "linear" }}
|
|
className="bg-blue-500 p-6 rounded-full inline-block mb-6 shadow-lg"
|
|
>
|
|
<RefreshCw size={48} className="text-white" />
|
|
</motion.div>
|
|
|
|
<h1 className="text-3xl font-black font-display mb-4">Reconnecting...</h1>
|
|
|
|
<p className="text-lg opacity-70 mb-8">
|
|
Restoring your game session for <span className="font-mono font-bold">{gamePin}</span>
|
|
</p>
|
|
|
|
<motion.button
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
onClick={goHomeFromDisconnected}
|
|
className="w-full bg-white/20 text-white px-8 py-4 rounded-full text-lg font-bold hover:bg-white/30 transition-all flex items-center justify-center gap-2"
|
|
>
|
|
<LogOut size={20} />
|
|
Cancel & End Game
|
|
</motion.button>
|
|
</motion.div>
|
|
</div>
|
|
) : (
|
|
<DisconnectedScreen
|
|
playerName={currentPlayerName}
|
|
gamePin={gamePin}
|
|
isReconnecting={isReconnecting}
|
|
onReconnect={attemptReconnect}
|
|
onGoHome={goHomeFromDisconnected}
|
|
/>
|
|
)
|
|
) : null}
|
|
|
|
{gameState === 'WAITING_TO_REJOIN' && currentPlayerName ? (
|
|
<WaitingToRejoin
|
|
playerName={currentPlayerName}
|
|
score={currentPlayerScore}
|
|
/>
|
|
) : null}
|
|
|
|
{gameState === 'HOST_RECONNECTED' && quiz ? (
|
|
<HostReconnected
|
|
quizTitle={quiz.title}
|
|
currentQuestionIndex={currentQuestionIndex}
|
|
totalQuestions={quiz.questions.length}
|
|
playerCount={players.filter(p => p.id !== 'host').length}
|
|
onResume={resumeGame}
|
|
onEndGame={endGame}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
|
|
<SaveOptionsModal
|
|
isOpen={showSaveOptions}
|
|
onClose={() => {
|
|
setShowSaveOptions(false);
|
|
setPendingEditedQuiz(null);
|
|
}}
|
|
onOverwrite={handleOverwriteQuiz}
|
|
onSaveNew={handleSaveAsNew}
|
|
isSaving={saving}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|