diff --git a/apps/web/src/components/game/GameSetup.tsx b/apps/web/src/components/game/GameSetup.tsx new file mode 100644 index 0000000..b6e16b8 --- /dev/null +++ b/apps/web/src/components/game/GameSetup.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { + SUPPORTED_LANGUAGE_CODES, + SUPPORTED_POS, + DIFFICULTY_LEVELS, + GAME_ROUNDS, +} from "@glossa/shared"; +import type { GameRequest } from "@glossa/shared"; + +const LABELS: Record = { + en: "English", + it: "Italian", + noun: "Nouns", + verb: "Verbs", + easy: "Easy", + intermediate: "Intermediate", + hard: "Hard", + "3": "3 rounds", + "10": "10 rounds", +}; + +type GameSetupProps = { onStart: (settings: GameRequest) => void }; + +type SettingGroupProps = { + label: string; + options: readonly string[]; + selected: string; + onSelect: (value: string) => void; +}; + +const SettingGroup = ({ + label, + options, + selected, + onSelect, +}: SettingGroupProps) => ( +
+

{label}

+
+ {options.map((option) => ( + + ))} +
+
+); + +export const GameSetup = ({ onStart }: GameSetupProps) => { + const [sourceLanguage, setSourceLanguage] = useState( + SUPPORTED_LANGUAGE_CODES[0], + ); + const [targetLanguage, setTargetLanguage] = useState( + SUPPORTED_LANGUAGE_CODES[1], + ); + const [pos, setPos] = useState(SUPPORTED_POS[0]); + const [difficulty, setDifficulty] = useState(DIFFICULTY_LEVELS[0]); + const [rounds, setRounds] = useState(GAME_ROUNDS[0]); + + const handleSourceLanguage = (value: string) => { + if (value === targetLanguage) { + setTargetLanguage(sourceLanguage); + } + setSourceLanguage(value); + }; + + const handleTargetLanguage = (value: string) => { + if (value === sourceLanguage) { + setSourceLanguage(targetLanguage); + } + setTargetLanguage(value); + }; + + const handleStart = () => { + onStart({ + source_language: sourceLanguage, + target_language: targetLanguage, + pos, + difficulty, + rounds, + } as GameRequest); + }; + + return ( +
+
+

Glossa

+

Set up your quiz

+
+ +
+ + + + + +
+ + +
+ ); +}; diff --git a/apps/web/src/components/game/OptionButton.tsx b/apps/web/src/components/game/OptionButton.tsx index 9b4a8b3..e01e4ae 100644 --- a/apps/web/src/components/game/OptionButton.tsx +++ b/apps/web/src/components/game/OptionButton.tsx @@ -1,6 +1,6 @@ type OptionButtonProps = { text: string; - state: "idle" | "disabled" | "correct" | "wrong"; + state: "idle" | "selected" | "disabled" | "correct" | "wrong"; onSelect: () => void; }; @@ -10,6 +10,8 @@ export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => { 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", + selected: + "bg-purple-100 text-purple-900 border-purple-400 ring-2 ring-purple-400", 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", @@ -19,7 +21,9 @@ export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => { diff --git a/apps/web/src/components/game/QuestionCard.tsx b/apps/web/src/components/game/QuestionCard.tsx index 38334c0..439cb0e 100644 --- a/apps/web/src/components/game/QuestionCard.tsx +++ b/apps/web/src/components/game/QuestionCard.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import type { GameQuestion, AnswerResult } from "@glossa/shared"; import { OptionButton } from "./OptionButton"; @@ -18,11 +19,31 @@ export const QuestionCard = ({ onAnswer, onNext, }: QuestionCardProps) => { + const [selectedOptionId, setSelectedOptionId] = useState(null); + 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; + if (currentResult) { + if (optionId === currentResult.correctOptionId) return "correct" as const; + if (optionId === currentResult.selectedOptionId) return "wrong" as const; + return "disabled" as const; + } + if (optionId === selectedOptionId) return "selected" as const; + return "idle" as const; + }; + + const handleSelect = (optionId: number) => { + if (currentResult) return; + setSelectedOptionId(optionId); + }; + + const handleSubmit = () => { + if (selectedOptionId === null) return; + onAnswer(selectedOptionId); + }; + + const handleNext = () => { + setSelectedOptionId(null); + onNext(); }; return ( @@ -48,15 +69,24 @@ export const QuestionCard = ({ key={option.optionId} text={option.text} state={getOptionState(option.optionId)} - onSelect={() => onAnswer(option.optionId)} + onSelect={() => handleSelect(option.optionId)} /> ))} + {!currentResult && selectedOptionId !== null && ( + + )} + {currentResult && ( diff --git a/apps/web/src/routes/play.tsx b/apps/web/src/routes/play.tsx index 905f92e..55a0051 100644 --- a/apps/web/src/routes/play.tsx +++ b/apps/web/src/routes/play.tsx @@ -1,38 +1,38 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useState, useEffect } from "react"; -import type { GameSession, AnswerResult } from "@glossa/shared"; +import { useState, useCallback } from "react"; +import type { GameSession, GameRequest, AnswerResult } from "@glossa/shared"; import { QuestionCard } from "../components/game/QuestionCard"; import { ScoreScreen } from "../components/game/ScoreScreen"; - -const GAME_SETTINGS = { - source_language: "en", - target_language: "it", - pos: "noun", - difficulty: "easy", - rounds: "3", -} as const; +import { GameSetup } from "../components/game/GameSetup"; function Play() { const [gameSession, setGameSession] = useState(null); + const [isLoading, setIsLoading] = useState(false); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [results, setResults] = useState([]); const [currentResult, setCurrentResult] = useState(null); - const startGame = async () => { + const startGame = useCallback(async (settings: GameRequest) => { + setIsLoading(true); const response = await fetch("/api/v1/game/start", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(GAME_SETTINGS), + body: JSON.stringify(settings), }); const data = await response.json(); setGameSession(data.data); setCurrentQuestionIndex(0); setResults([]); setCurrentResult(null); - }; + setIsLoading(false); + }, []); - useEffect(() => { - startGame(); + const resetToSetup = useCallback(() => { + setGameSession(null); + setIsLoading(false); + setCurrentQuestionIndex(0); + setResults([]); + setCurrentResult(null); }, []); const handleAnswer = async (optionId: number) => { @@ -61,8 +61,17 @@ function Play() { setCurrentResult(null); }; + // Phase: setup + if (!gameSession && !isLoading) { + return ( +
+ +
+ ); + } + // Phase: loading - if (!gameSession) { + if (isLoading || !gameSession) { return (

Loading...

@@ -74,7 +83,7 @@ function Play() { if (currentQuestionIndex >= gameSession.questions.length) { return (
- +
); }