refactoring ui into separate components, updating ui, adding color scheme
This commit is contained in:
parent
ea33b7fcc8
commit
b7b1cd383f
4 changed files with 184 additions and 55 deletions
27
apps/web/src/components/game/OptionButton.tsx
Normal file
27
apps/web/src/components/game/OptionButton.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
type OptionButtonProps = {
|
||||||
|
text: string;
|
||||||
|
state: "idle" | "disabled" | "correct" | "wrong";
|
||||||
|
onSelect: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => {
|
||||||
|
const base =
|
||||||
|
"w-full py-3 px-6 rounded-2xl text-lg font-semibold transition-all duration-200 border-b-4 cursor-pointer";
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
idle: "bg-white text-purple-900 border-purple-200 hover:bg-purple-50 hover:border-purple-300 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2",
|
||||||
|
disabled: "bg-gray-100 text-gray-400 border-gray-200 cursor-default",
|
||||||
|
correct: "bg-emerald-400 text-white border-emerald-600 scale-[1.02]",
|
||||||
|
wrong: "bg-pink-400 text-white border-pink-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${base} ${styles[state]}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
disabled={state !== "idle"}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
apps/web/src/components/game/QuestionCard.tsx
Normal file
66
apps/web/src/components/game/QuestionCard.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import type { GameQuestion, AnswerResult } from "@glossa/shared";
|
||||||
|
import { OptionButton } from "./OptionButton";
|
||||||
|
|
||||||
|
type QuestionCardProps = {
|
||||||
|
question: GameQuestion;
|
||||||
|
questionNumber: number;
|
||||||
|
totalQuestions: number;
|
||||||
|
currentResult: AnswerResult | null;
|
||||||
|
onAnswer: (optionId: number) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QuestionCard = ({
|
||||||
|
question,
|
||||||
|
questionNumber,
|
||||||
|
totalQuestions,
|
||||||
|
currentResult,
|
||||||
|
onAnswer,
|
||||||
|
onNext,
|
||||||
|
}: QuestionCardProps) => {
|
||||||
|
const getOptionState = (optionId: number) => {
|
||||||
|
if (!currentResult) return "idle" as const;
|
||||||
|
if (optionId === currentResult.correctOptionId) return "correct" as const;
|
||||||
|
if (optionId === currentResult.selectedOptionId) return "wrong" as const;
|
||||||
|
return "disabled" as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-purple-400">
|
||||||
|
<span>
|
||||||
|
{questionNumber} / {totalQuestions}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-3xl shadow-lg p-8 w-full text-center">
|
||||||
|
<h2 className="text-3xl font-bold text-purple-900 mb-2">
|
||||||
|
{question.prompt}
|
||||||
|
</h2>
|
||||||
|
{question.gloss && (
|
||||||
|
<p className="text-sm text-gray-400 italic">{question.gloss}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
{question.options.map((option) => (
|
||||||
|
<OptionButton
|
||||||
|
key={option.optionId}
|
||||||
|
text={option.text}
|
||||||
|
state={getOptionState(option.optionId)}
|
||||||
|
onSelect={() => onAnswer(option.optionId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentResult && (
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
className="mt-2 py-3 px-10 rounded-2xl text-lg font-bold bg-purple-600 text-white border-b-4 border-purple-800 hover:bg-purple-500 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
{questionNumber === totalQuestions ? "See Results" : "Next"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
60
apps/web/src/components/game/ScoreScreen.tsx
Normal file
60
apps/web/src/components/game/ScoreScreen.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { AnswerResult } from "@glossa/shared";
|
||||||
|
|
||||||
|
type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void };
|
||||||
|
|
||||||
|
export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => {
|
||||||
|
const score = results.filter((r) => r.isCorrect).length;
|
||||||
|
const total = results.length;
|
||||||
|
const percentage = Math.round((score / total) * 100);
|
||||||
|
|
||||||
|
const getMessage = () => {
|
||||||
|
if (percentage === 100) return "Perfect! 🎉";
|
||||||
|
if (percentage >= 80) return "Great job! 🌟";
|
||||||
|
if (percentage >= 60) return "Not bad! 💪";
|
||||||
|
if (percentage >= 40) return "Keep practicing! 📚";
|
||||||
|
return "Don't give up! 🔄";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-8 w-full max-w-md mx-auto">
|
||||||
|
<div className="bg-white rounded-3xl shadow-lg p-10 w-full text-center">
|
||||||
|
<p className="text-lg font-medium text-purple-400 mb-2">Your Score</p>
|
||||||
|
<h2 className="text-6xl font-bold text-purple-900 mb-1">
|
||||||
|
{score}/{total}
|
||||||
|
</h2>
|
||||||
|
<p className="text-2xl mb-6">{getMessage()}</p>
|
||||||
|
|
||||||
|
<div className="w-full bg-purple-100 rounded-full h-4 mb-2">
|
||||||
|
<div
|
||||||
|
className="bg-linear-to-r from-pink-400 to-purple-500 h-4 rounded-full transition-all duration-700"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-400">{percentage}% correct</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<div
|
||||||
|
key={result.questionId}
|
||||||
|
className={`flex items-center gap-3 py-2 px-4 rounded-xl text-sm ${
|
||||||
|
result.isCorrect
|
||||||
|
? "bg-emerald-50 text-emerald-700"
|
||||||
|
: "bg-pink-50 text-pink-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-bold">{index + 1}.</span>
|
||||||
|
<span>{result.isCorrect ? "✓ Correct" : "✗ Wrong"}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onPlayAgain}
|
||||||
|
className="py-3 px-10 rounded-2xl text-lg font-bold bg-purple-600 text-white border-b-4 border-purple-800 hover:bg-purple-500 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
Play Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { GameSession, AnswerResult } from "@glossa/shared";
|
import type { GameSession, AnswerResult } from "@glossa/shared";
|
||||||
|
import { QuestionCard } from "../components/game/QuestionCard";
|
||||||
|
import { ScoreScreen } from "../components/game/ScoreScreen";
|
||||||
|
|
||||||
const GAME_SETTINGS = {
|
const GAME_SETTINGS = {
|
||||||
source_language: "en",
|
source_language: "en",
|
||||||
|
|
@ -16,16 +18,20 @@ function Play() {
|
||||||
const [results, setResults] = useState<AnswerResult[]>([]);
|
const [results, setResults] = useState<AnswerResult[]>([]);
|
||||||
const [currentResult, setCurrentResult] = useState<AnswerResult | null>(null);
|
const [currentResult, setCurrentResult] = useState<AnswerResult | null>(null);
|
||||||
|
|
||||||
|
const startGame = async () => {
|
||||||
|
const response = await fetch("/api/v1/game/start", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(GAME_SETTINGS),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
setGameSession(data.data);
|
||||||
|
setCurrentQuestionIndex(0);
|
||||||
|
setResults([]);
|
||||||
|
setCurrentResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const startGame = async () => {
|
|
||||||
const response = await fetch("/api/v1/game/start", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(GAME_SETTINGS),
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
|
||||||
setGameSession(data.data);
|
|
||||||
};
|
|
||||||
startGame();
|
startGame();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -57,18 +63,18 @@ function Play() {
|
||||||
|
|
||||||
// Phase: loading
|
// Phase: loading
|
||||||
if (!gameSession) {
|
if (!gameSession) {
|
||||||
return <p>Loading...</p>;
|
return (
|
||||||
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
|
||||||
|
<p className="text-purple-400 text-lg font-medium">Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase: finished
|
// Phase: finished
|
||||||
if (currentQuestionIndex >= gameSession.questions.length) {
|
if (currentQuestionIndex >= gameSession.questions.length) {
|
||||||
const score = results.filter((r) => r.isCorrect).length;
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<h2>
|
<ScoreScreen results={results} onPlayAgain={startGame} />
|
||||||
{score} / {results.length}
|
|
||||||
</h2>
|
|
||||||
<p>Game over!</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -77,45 +83,15 @@ function Play() {
|
||||||
const question = gameSession.questions[currentQuestionIndex]!;
|
const question = gameSession.questions[currentQuestionIndex]!;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<p>
|
<QuestionCard
|
||||||
Question {currentQuestionIndex + 1} / {gameSession.questions.length}
|
question={question}
|
||||||
</p>
|
questionNumber={currentQuestionIndex + 1}
|
||||||
<h2>{question.prompt}</h2>
|
totalQuestions={gameSession.questions.length}
|
||||||
{question.gloss && <p>{question.gloss}</p>}
|
currentResult={currentResult}
|
||||||
<div>
|
onAnswer={handleAnswer}
|
||||||
{question.options.map((option) => {
|
onNext={handleNext}
|
||||||
let style = {};
|
/>
|
||||||
if (currentResult) {
|
|
||||||
if (option.optionId === currentResult.correctOptionId) {
|
|
||||||
style = { backgroundColor: "green", color: "white" };
|
|
||||||
} else if (option.optionId === currentResult.selectedOptionId) {
|
|
||||||
style = { backgroundColor: "red", color: "white" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.optionId}
|
|
||||||
onClick={() => handleAnswer(option.optionId)}
|
|
||||||
disabled={!!currentResult}
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
margin: "4px 0",
|
|
||||||
padding: "8px 16px",
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.text}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{currentResult && (
|
|
||||||
<button onClick={handleNext} style={{ marginTop: "16px" }}>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue