diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..9f6f0d0 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,21 @@ +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* \ No newline at end of file diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..a11777c Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..aa069f2 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + React App + + + +
+ + + diff --git a/frontend/public/logo192.png b/frontend/public/logo192.png new file mode 100644 index 0000000..fc44b0a Binary files /dev/null and b/frontend/public/logo192.png differ diff --git a/frontend/public/logo512.png b/frontend/public/logo512.png new file mode 100644 index 0000000..a4e47a6 Binary files /dev/null and b/frontend/public/logo512.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..080d6c7 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..c2f0a3e --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,414 @@ +.App { + text-align: center; + background-color: #ffffff; + width: 100vw; + height: 100vh; + /* display: flex; */ + color: white; +} + +body { + padding: 0%; + margin: 0; +} + +nav { + height: 60px; + width: 100%; + margin: 0; + border-bottom: 1px solid grey; + display: grid; + place-items: center; +} + +.registerForm { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 150px; + text-align: center; + border: 3px solid black; + border-radius: 15px; + padding: 20px; + width: 500px; +} + +.registerForm-title { + color: black; + font-size: 36px; + font-weight: bold; +} + +.registerForm-input { + width: 280px; + border: 2px solid black; + border-radius: 10px; + height: 30px; + margin-top: 15px; +} + +.registerForm-error { + color: red; + margin-top: 10px; +} + +nav h1 { + margin: 0; + font-family: Helvetica, Arial, sans-serif; + color: white; + font-size: 45px; +} +.game { + width: 100vw; + height: calc(100vh - 170px); + display: flex; + align-items: center; + padding-top: 50px; + flex-direction: column; +} + +.board { + width: 450px; + height: 600px; + padding-bottom: 10px; + border: 1px solid black; + display: flex; + flex-direction: column; +} + +.row { + flex: 33%; + display: flex; + flex-direction: row; + margin: 5px; + user-select: none; +} + +.letter { + flex: 33%; + height: 100%; + border: 1px solid grey; + margin: 5px; + display: grid; + place-items: center; + font-size: 30px; + user-select: none; + font-weight: bolder; + color: rgb(0, 0, 0); + font-family: Arial, Helvetica, sans-serif; +} + +.app { + display: flex; + justify-content: center; + align-items: center; +} + +.loginForm { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 150px; + text-align: center; + border: 3px solid black; + border-radius: 15px; + height: 400px; + width: 500px; +} + +.loginForm-title { + color: black; + font-size: 36px; + font-weight: bold; + margin-top: 50px; +} + +.loginForm-input { + width: 280px; + border: 2px solid black; + border-radius: 10px; + height: 30px; + margin-top: 15px; +} + +.loginForm-error { + color: red; +} + +.correct { + color: white; + background-color: #528d4e; +} + +.almost { + color: white; + background-color: #b49f39; +} + +.error { + color: white; + background-color: #3a393c; +} + +.keyboard { + width: 700px; + height: 150px; + margin-top: 20px; + user-select: none; +} + +.line1 { + flex: 33%; + display: flex; + flex-direction: row; + display: flex; + justify-content: center; + margin: 5px; +} +.line2 { + flex: 33%; + display: flex; + flex-direction: row; + justify-content: center; + margin: 5px; +} +.line3 { + flex: 33%; + display: flex; + flex-direction: row; + justify-content: center; + margin: 5px; +} + +.key { + width: 50px; + height: 70px; + margin: 5px; + border-radius: 4px; + display: grid; + place-items: center; + font-size: 20px; + background-color: rgb(0, 0, 0); + color: white; + font-family: Arial, Helvetica, sans-serif; + cursor: pointer; + user-select: none; +} + +.key.correct { + background-color: #528d4e; +} + +.key.almost { + background-color: #b49f39; +} + +.key.error { + background-color: #3a393c; +} + +.key.big { + width: 100px; +} + +.key:hover { + opacity: 0.8; +} +.box{ + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + background-color: #f8f9fa; +} + + +.leaderboard { + position: absolute; + right: 70px; + top: 100px; + margin: 20px; + font-family: Arial, sans-serif; + margin-top: 20px; + border-top: 2px solid #ddd; + padding-top: 20px; +} + +.user-stats { + margin-bottom: 20px; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: 10px; + border: 1px solid #ddd; + text-align: center; +} + +thead { + background-color: #f4f4f4; +} + +tr:nth-child(even) { + background-color: #f9f9f9; +} + +.logo { + height: 105px; + width: 105px; +} + +.info { + margin-top: 20px; + display: flex; + flex-direction: column; + align-items: center; +} + +.title { + font-size: 42px; + font-weight: bold; + color: #333; + text-align: center; + margin: 20px 0 0 0; +} + +.subtitle { + font-size: 24px; + color: #555; + text-align: center; + margin: 10px 0; +} + +.mainButtons { + display: flex; + gap: 20px; + margin-top: 30px; +} + +.home-button { + margin-top: 15px; + height: 40px; + width: 150px; + background: rgb(255, 255, 255); + border-radius: 25px; + border: 2px solid #333; + font-size: 14px; + color: #333; + cursor: pointer; + transition: all 0.3s ease; +} + +.home-button:hover { + background-color: #000000; + color: rgb(255, 255, 255); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); +} + +.home-button:active { + transform: scale(0.95); +} + +.home-container { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.home-button { + transition: opacity 0.3s ease; +} + +.home-button:disabled { + opacity: 0.5; + pointer-events: none; +} + + +.main-content { + flex: 1; +} + +.username-container { + position: absolute; + top: 20px; + right: 20px; + display: flex; + align-items: center; +} + +.username { + margin-right: 10px; + font-size: 18px; + font-weight: bold; + color: #333; +} + +.logout-button { + font-size: 16px; + background-color: #f44336; + color: white; + border: none; + padding: 5px 10px; + cursor: pointer; + border-radius: 5px; +} + +.logout-button:hover { + background-color: #d32f2f; +} + +.popup-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 30vh; + display: flex; + justify-content: center; + align-items: top; + z-index: 1000; +} + +.popup { + position: fixed; + background: rgb(53, 53, 53); + height: 50px; + padding: 20px; + margin-top: 40px; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); + text-align: center; + color: #ffffff; + animation: fadeIn 0.3s, fadeOut 0.3s linear 2.7s; +} + +.gameover{ + align-items: center; + justify-items: center; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..1283419 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,144 @@ +import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import Popup from "./components/Popup" + +const App: React.FC = () => { + const [login, setLogin] = useState(""); + const [password, setPassword] = useState(""); + const [loginDirty, setLoginDirty] = useState(false); + const [passwordDirty, setPasswordDirty] = useState(false); + const [passwordError, setPasswordError] = useState(""); + const [formValid, setFormValid] = useState(false); + const [popupMessage, setPopupMessage] = useState(null); + const [apiError, setApiError] = useState(null); + + const navigate = useNavigate(); + + useEffect(() => { + setFormValid(!passwordError && login.length > 0); + }, [passwordError, login]); + + const showPopup = (message: string) => { + setPopupMessage(message); + setTimeout(() => setPopupMessage(null), 3000); + }; + + const closePopup = () => { + setPopupMessage(null); + }; + + const loginHandler = (e: React.ChangeEvent) => { + setLogin(e.target.value); + }; + + const passwordHandler = (e: React.ChangeEvent) => { + setPassword(e.target.value); + if (e.target.value.length < 8 || e.target.value.length > 12) { + setPopupMessage("Пароль должен быть длиной от 8 до 12 символов"); + if (!e.target.value) { + setPopupMessage("Пароль не может быть пустым"); + } + } else { + setPopupMessage(""); + } + }; + + const blurHandler = (e: React.FocusEvent) => { + switch (e.target.name) { + case "login": + setLoginDirty(true); + break; + case "password": + setPasswordDirty(true); + break; + default: + break; + } + }; + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!formValid) { + showPopup("Заполните форму корректно"); + return; + } + + const userData = { + username: login, + password: password, + }; + + try { + const response = await fetch("http://localhost:5002/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(userData), + credentials: "include", + }); + + if (response.ok) { + navigate("/"); + } else { + const errorData = await response.json(); + showPopup(errorData.message || "Ошибка авторизации"); + } + } catch (error) { + console.error("Ошибка при отправке запроса:", error); + setApiError("Ошибка при соединении с сервером"); + } + }; + + return ( +
+
+

Авторизация

+ + + + + {passwordDirty && passwordError && ( +
{passwordError}
+ )} + {apiError &&
{apiError}
} + + + +
+ + {popupMessage && } +
+ ); +}; + +export default App; diff --git a/frontend/src/Logo.png b/frontend/src/Logo.png new file mode 100644 index 0000000..d71bd74 Binary files /dev/null and b/frontend/src/Logo.png differ diff --git a/frontend/src/components/Board.tsx b/frontend/src/components/Board.tsx new file mode 100644 index 0000000..6ee81fa --- /dev/null +++ b/frontend/src/components/Board.tsx @@ -0,0 +1,62 @@ +import Letter from "./Letter"; + +export const boardDefault = [ + ["", "", "", "", ""], + ["", "", "", "", ""], + ["", "", "", "", ""], + ["", "", "", "", ""], + ["", "", "", "", ""], + ["", "", "", "", ""], +]; + +function Board() { + return ( +
+ {" "} +
+ + + + + +
+
+ + + + + +
+
+ + + + + +
+
+ + + + + +
+
+ + + + + +
+
+ + + + + +
+
+ ); +} + +export default Board; diff --git a/frontend/src/components/Game.tsx b/frontend/src/components/Game.tsx new file mode 100644 index 0000000..d26fe3f --- /dev/null +++ b/frontend/src/components/Game.tsx @@ -0,0 +1,198 @@ +import "../App.css"; +import Board, { boardDefault } from "./Board"; +import Keyboard from "./Keyboard"; +import React, { useState, createContext, useEffect, useCallback } from "react"; +import GameOver from "./GameOver"; +import Popup from "./Popup" + +interface CurrAttempt { + attempt: number; + letter: number; +} + +interface GameOverState { + gameOver: boolean; + guessedWord: boolean; +} + +interface CheckResults { + [key: number]: string[]; +} + +interface KeyValColor { + [key: string]: string; +} + +interface GameContextProps { + board: string[][]; + setBoard: React.Dispatch>; + currAttempt: CurrAttempt; + setCurrAttempt: React.Dispatch>; + setCorrectWord: React.Dispatch>; + correctWord: string; + onSelectLetter: (key: string) => void; + onDelete: () => void; + onEnter: () => void; + gameOver: GameOverState; + checkResults: CheckResults; + keyValColor: KeyValColor; +} + +export const GameContext = createContext(undefined); + +function Game() { + const [board, setBoard] = useState(boardDefault); + const [currAttempt, setCurrAttempt] = useState({ attempt: 0, letter: 0 }); + const [correctWord, setCorrectWord] = useState(""); + const [gameOver, setGameOver] = useState({ + gameOver: false, + guessedWord: false, + }); + const [popupMessage, setPopupMessage] = useState(null); + const [checkResults, setCheckResults] = useState({}); + const [keyValColor, setKeyValColor] = useState({}); + + const showPopup = (message: string) => { + setPopupMessage(message); + setTimeout(() => setPopupMessage(null), 3000); + }; + + const closePopup = () => { + setPopupMessage(null); + }; + + const resetGame = useCallback(() => { + const newBoard = board.map((row) => row.map(() => "")); + setBoard(newBoard); + setCurrAttempt({ attempt: 0, letter: 0 }); + setGameOver({ gameOver: false, guessedWord: false }); + setCheckResults({}); + setKeyValColor({}); + }, [board]); + + useEffect(() => { + resetGame(); + }, [resetGame]); + + + const onEnter = async () => { + if (currAttempt.letter !== 5) return; + + let currWord = ""; + for (let i = 0; i < 5; i++) { + currWord += board[currAttempt.attempt][i]; + } + + const session_id = localStorage.getItem("sessionId"); + + try { + const response = await fetch("http://localhost:5002/wordle/check_word", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + session_id: session_id, + word: currWord.toLowerCase(), + }), + credentials: "include", + }); + + console.log(session_id) + + if (response.ok) { + const data = await response.json(); + const { game_status, check_result, attempt_number, word_to_guess } = data as { + game_status: string; + check_result: Record; + attempt_number: number; + word_to_guess: string; + }; + + if (game_status === "LOSS") { + setCorrectWord(word_to_guess.toUpperCase()); + } + + const upperCaseCheckResult: Record = Object.fromEntries( + Object.entries(check_result).map(([key, value]) => [key.toUpperCase(), value]) + ); + + setKeyValColor((prevKeyValColor) => ({ + ...prevKeyValColor, + ...upperCaseCheckResult, + })); + + const attemptResult = Array.from(currWord).map((letter) => { + const upperLetter = letter.toUpperCase(); + return upperCaseCheckResult[upperLetter]; + }); + + setCheckResults((prev) => ({ + ...prev, + [currAttempt.attempt]: attemptResult, + })); + + setCurrAttempt({ attempt: attempt_number, letter: 0 }); + + if (game_status === "WIN") { + setGameOver({ gameOver: true, guessedWord: true }); + } else if (attempt_number >= 6) { + setGameOver({ gameOver: true, guessedWord: false }); + } + } + else{ + showPopup("Не в списке слов"); + } + } catch (error) { + console.error("Ошибка при соединении с сервером:", error); + } + }; + + const onDelete = () => { + if (currAttempt.letter === 0) return; + const newBoard = [...board]; + newBoard[currAttempt.attempt][currAttempt.letter - 1] = ""; + setBoard(newBoard); + setCurrAttempt({ ...currAttempt, letter: currAttempt.letter - 1 }); + }; + + const onSelectLetter = (key: string) => { + if (currAttempt.letter > 4) return; + const newBoard = [...board]; + newBoard[currAttempt.attempt][currAttempt.letter] = key; + setBoard(newBoard); + setCurrAttempt({ + attempt: currAttempt.attempt, + letter: currAttempt.letter + 1, + }); + }; + + return ( +
+ +
+ + {gameOver.gameOver ? : } + {popupMessage && } +
+
+
+ ); +} + +export default Game; diff --git a/frontend/src/components/GameOver.tsx b/frontend/src/components/GameOver.tsx new file mode 100644 index 0000000..63ca32f --- /dev/null +++ b/frontend/src/components/GameOver.tsx @@ -0,0 +1,42 @@ +import React, { useContext } from "react"; +import { useNavigate } from "react-router-dom"; +import { GameContext } from "./Game"; +import "../App.css"; + +interface GameContextType { + currAttempt: { attempt: number; letter: number }; + gameOver: { gameOver: boolean; guessedWord: boolean }; +} + +const GameOver: React.FC = () => { + const gameContext = useContext(GameContext); + const navigate = useNavigate(); + + if (!gameContext) { + return null; + } + + const { currAttempt, gameOver, correctWord } = gameContext; + + const handleGoHome = () => { + navigate("/"); + }; + + return ( +
+

{gameOver.guessedWord ? "Молодец" : "Не молодец"}

+ {gameOver.guessedWord && ( +

Ты угадал за {currAttempt.attempt} попыток

+ )} + {!gameOver.guessedWord && ( +

Правильное слово: {correctWord}

+ )} + +
+ ); +}; + + +export default GameOver; diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx new file mode 100644 index 0000000..408ad51 --- /dev/null +++ b/frontend/src/components/Home.tsx @@ -0,0 +1,158 @@ +import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import "../App.css"; +import Logo from "../Logo.png"; +import Popup from "./Popup"; +import Leaderboard from "./Leaderboard"; + +interface UserInfo { + username: string; +} + +const Home: React.FC = () => { + const navigate = useNavigate(); + const [userInfo, setUserInfo] = useState(null); + const [error, setError] = useState(null); + + const refreshTokens = async (): Promise => { + try { + const response = await fetch("http://localhost:5002/auth/tokens", { + method: "POST", + credentials: "include", + }); + + if (response.ok) { + const data = await response.json(); + console.log("Токены успешно обновлены:", data); + return true; + } else { + console.error("Ошибка при обновлении токенов"); + return false; + } + } catch (error) { + console.error("Ошибка соединения при обновлении токенов:", error); + return false; + } + }; + + + useEffect(() => { + refreshTokens(); + const storedUserData = sessionStorage.getItem("userInfo"); + + if (storedUserData) { + const { username } = JSON.parse(storedUserData) as UserInfo; + setUserInfo(username); + } else { + const fetchData = async () => { + try { + const response = await fetch("http://localhost:5002/account/user_information", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + + if (response.ok) { + const data: UserInfo = await response.json(); + setUserInfo(data.username); + sessionStorage.setItem("userInfo", JSON.stringify(data)); + } + } catch (error) { + console.error("Ошибка при отправке запроса:", error); + } + }; + + fetchData(); + } + }, []); + + const handleLoginClickApp = () => { + navigate("/auth"); + }; + + const handleLogout = async () => { + try { + const response = await fetch("http://localhost:5002/auth/logout", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + + if (response.ok) { + sessionStorage.removeItem("userInfo"); + setUserInfo(null); + navigate("/"); + } else { + console.error("Ошибка при выходе из аккаунта"); + } + } catch (error) { + console.error("Ошибка при отправке запроса на выход:", error); + } + }; + + const handlePlayClick = async () => { + + try { + const response = await fetch("http://localhost:5002/wordle/create_game_session", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + + if (response.ok) { + const data: { session_id: string } = await response.json(); + const { session_id } = data; + + localStorage.setItem("sessionId", session_id); + navigate("/game"); + } else { + setError("Не удалось создать игровую сессию"); + } + } catch (error: unknown) { + setError("Ошибка при отправке запроса: " + (error instanceof Error ? error.message : "Неизвестная ошибка")); + } + }; + + return ( +
+ Logo +
+

Wordle

+

Угадай 5-буквенное слово с 6 попыток.

+
+ {!userInfo && ( + + )} + +
+
+ + {userInfo && ( +
+ {userInfo} + +
+ )} + + {error && setError(null)} duration={3000} />} + +
+ +
+
+ ); +}; + +export default Home; diff --git a/frontend/src/components/Key.tsx b/frontend/src/components/Key.tsx new file mode 100644 index 0000000..9e2de68 --- /dev/null +++ b/frontend/src/components/Key.tsx @@ -0,0 +1,76 @@ +import React, { useContext } from "react"; +import { motion } from "framer-motion"; +import { GameContext } from "./Game"; + +interface KeyProps { + keyVal: string; + bigKey?: boolean; +} + +interface GameContextType { + gameOver: { gameOver: boolean }; + onSelectLetter: (key: string) => void; + onDelete: () => void; + onEnter: () => Promise; + keyValColor: { [key: string]: string }; +} + +const Key: React.FC = ({ keyVal, bigKey }) => { + const gameContext = useContext(GameContext); + + if (!gameContext) { + return null; + } + + const { gameOver, onSelectLetter, onDelete, onEnter, keyValColor = {} } = gameContext; + + const selectLetter = () => { + if (gameOver.gameOver) return; + + if (keyVal === "ENTER") { + onEnter(); + } else if (keyVal === "DELETE") { + onDelete(); + } else { + onSelectLetter(keyVal); + } + }; + + const keyColor = keyValColor[keyVal] || ""; + const keyState = + keyColor === "GREEN" + ? "correct" + : keyColor === "YELLOW" + ? "almost" + : keyColor === "BLACK" + ? "error" + : ""; + + return ( + + {keyVal} + + ); +}; + +export default Key; \ No newline at end of file diff --git a/frontend/src/components/Keyboard.tsx b/frontend/src/components/Keyboard.tsx new file mode 100644 index 0000000..9fdc633 --- /dev/null +++ b/frontend/src/components/Keyboard.tsx @@ -0,0 +1,70 @@ +import React, { useCallback, useEffect, useContext, useMemo } from "react"; +import Key from "./Key"; +import { GameContext } from "./Game"; + +const Keyboard: React.FC = () => { + const keys1 = useMemo(() => ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"], []); + const keys2 = useMemo(() => ["A", "S", "D", "F", "G", "H", "J", "K", "L"], []); + const keys3 = useMemo(() => ["Z", "X", "C", "V", "B", "N", "M"], []); + + const gameContext = useContext(GameContext); + + const handleKeyboard = useCallback( + (event: KeyboardEvent) => { + if (!gameContext || gameContext.gameOver.gameOver) return; + + const { onEnter, onDelete, onSelectLetter } = gameContext; + + if (event.key === "Enter") { + onEnter(); + } else if (event.key === "Backspace") { + onDelete(); + } else { + [...keys1, ...keys2, ...keys3].forEach((key) => { + if (event.key.toLowerCase() === key.toLowerCase()) { + onSelectLetter(key); + } + }); + } + }, + [gameContext, keys1, keys2, keys3] + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyboard); + + return () => { + document.removeEventListener("keydown", handleKeyboard); + }; + }, [handleKeyboard]); + + if (!gameContext) { + return null; + } + + const { currAttempt, gameOver } = gameContext; + + return ( +
+
+ {keys1.map((key) => ( + + ))} +
+
+ {keys2.map((key) => ( + + ))} +
+
+ + {keys3.map((key) => ( + + ))} + +
+
+ ); +}; + +export default Keyboard; diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx new file mode 100644 index 0000000..228d6e1 --- /dev/null +++ b/frontend/src/components/Leaderboard.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useState } from "react"; + +interface UserStats { + username: string; + wins_count: number; + w_l: number; +} + +interface LeaderboardResponse { + user_stats: UserStats; + leaderboard_stats: UserStats[]; +} + +const Leaderboard: React.FC = () => { + const [leaderboard, setLeaderboard] = useState([]); + const [userStats, setUserStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchLeaderboard = async () => { + try { + const response = await fetch("http://localhost:5002/leaderboard/?top_n=20", { + credentials: "include", + }); + if (!response.ok) { + throw new Error("Failed to fetch leaderboard"); + } + const data: LeaderboardResponse = await response.json(); + setUserStats(data.user_stats); + setLeaderboard(data.leaderboard_stats); + console.log(data) + } catch (error) { + console.error("Error fetching leaderboard:", error); + } finally { + setLoading(false); + } + }; + + fetchLeaderboard(); + }, []); + + if (loading) { + return
Загрузка списка лидеров...
; + } + + return ( +
+

Список лидеров

+ {userStats && ( +
+

Ваша статистика

+

Победы: {userStats.wins_count}

+

Отношение W/L: {userStats.w_l}

+
+ )} + + + + + + + + + + + {leaderboard.map((user, index) => ( + + + + + + + ))} + +
МестоИмя пользователяПобедыОтношение W/L
{index + 1}{user.username}{user.wins_count}{user.w_l}
+
+ ); +}; + +export default Leaderboard; diff --git a/frontend/src/components/Letter.tsx b/frontend/src/components/Letter.tsx new file mode 100644 index 0000000..7cdd5e3 --- /dev/null +++ b/frontend/src/components/Letter.tsx @@ -0,0 +1,51 @@ +import React, { useContext } from "react"; +import { GameContext } from "./Game"; +import { motion } from "framer-motion"; + + +interface GameContextType { + board: string[][]; + checkResults: Record>; +} + + +interface LetterProps { + letterPos: number; + attemptVal: number; +} + +const Letter: React.FC = ({ letterPos, attemptVal }) => { + const gameContext = useContext(GameContext); + + if (!gameContext) { + return null; + } + + const { board, checkResults } = gameContext; + const letter = board[attemptVal]?.[letterPos] || ""; + const checkResult = checkResults[attemptVal] || {}; + const letterColor = checkResult[letterPos]; + + const letterState = + letterColor === "GREEN" + ? "correct" + : letterColor === "YELLOW" + ? "almost" + : letterColor === "BLACK" + ? "error" + : ""; + + return ( + + {letter} + + ); +}; + +export default Letter; diff --git a/frontend/src/components/Popup.tsx b/frontend/src/components/Popup.tsx new file mode 100644 index 0000000..62a4b73 --- /dev/null +++ b/frontend/src/components/Popup.tsx @@ -0,0 +1,25 @@ +import React, { useEffect } from 'react'; +import '../App.css'; + +interface PopupProps { + message: string; + duration?: number; + onClose: () => void; +} + +const Popup: React.FC = ({ message, duration = 3000, onClose }) => { + useEffect(() => { + const timer = setTimeout(onClose, duration); + return () => clearTimeout(timer); + }, [duration, onClose]); + + return ( +
+
+

{message}

+
+
+ ); +}; + +export default Popup; diff --git a/frontend/src/components/Registration.tsx b/frontend/src/components/Registration.tsx new file mode 100644 index 0000000..d2f3178 --- /dev/null +++ b/frontend/src/components/Registration.tsx @@ -0,0 +1,147 @@ +import "../App.css"; +import React, { useState, useEffect, ChangeEvent, FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import Popup from "./Popup"; + +const Register: React.FC = () => { + const [login, setLogin] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [formValid, setFormValid] = useState(false); + const [popupMessage, setPopupMessage] = useState(''); + + const navigate = useNavigate(); + + useEffect(() => { + if (password && confirmPassword && password !== confirmPassword) { + showPopup('Пароли не совпадают'); + setFormValid(false); + } else if (password.length < 8 || password.length > 12) { + showPopup('Пароль должен быть длиннее 8 и меньше 12 символов'); + setFormValid(false); + } else { + showPopup(''); + setFormValid(true); + } + }, [password, confirmPassword]); + + const showPopup = (message: string) => { + setPopupMessage(message); + }; + + const closePopup = () => { + setPopupMessage(''); + }; + + const loginHandler = (e: ChangeEvent) => { + setLogin(e.target.value); + }; + const passwordHandler = (e: React.ChangeEvent) => { + setPassword(e.target.value); + if (e.target.value.length < 8 || e.target.value.length > 12) { + setPopupMessage("Пароль должен быть длиной от 8 до 12 символов"); + } else { + setPopupMessage(""); + } + }; + + const confirmPasswordHandler = (e: React.ChangeEvent) => { + setConfirmPassword(e.target.value); + if (e.target.value !== password && e.target.value.length > 0) { + setPopupMessage("Пароли не совпадают"); + } else { + setPopupMessage(""); + } + }; + + const handleRegister = async (e: FormEvent) => { + e.preventDefault(); + if (!formValid) { + showPopup('Заполните форму корректно'); + return; + } + + const userData = { + username: login, + password, + }; + + try { + const response = await fetch('http://localhost:5002/auth/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(userData), + credentials: 'include', + }); + + if (response.status === 201) { + showPopup('Пользователь успешно зарегистрирован!'); + setTimeout(() => navigate('/'), 3000); + } else { + const errorData = await response.json(); + showPopup(errorData.message || 'Пользователь с таким именем уже существует'); + } + } catch (error) { + console.error('Ошибка при отправке запроса:', error); + showPopup('Ошибка при соединении с сервером'); + } + }; + + return ( +
+
+

Регистрация

+ + + + + {passwordError &&
{passwordError}
} + + + + +
+ + {popupMessage && } +
+ ); +}; + +export default Register; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..ec2585e --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,13 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx new file mode 100644 index 0000000..753dd56 --- /dev/null +++ b/frontend/src/index.tsx @@ -0,0 +1,26 @@ +import React, { createContext } from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import App from "./App"; +import Home from "./components/Home"; +import Game from "./components/Game"; +import Registration from "./components/Registration"; +import "./App.css"; + +export const AppContext = createContext<{ login: string, password: string, formValid: boolean }>({ + login: '', + password: '', + formValid: false +}); + +const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); +root.render( + + + } /> + } /> + } /> + } /> + + +); diff --git a/frontend/src/logo.svg b/frontend/src/logo.svg new file mode 100644 index 0000000..9dfc1c0 --- /dev/null +++ b/frontend/src/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/react-app-env.d.ts b/frontend/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/frontend/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/src/reportWebVitals.ts b/frontend/src/reportWebVitals.ts new file mode 100644 index 0000000..49a2a16 --- /dev/null +++ b/frontend/src/reportWebVitals.ts @@ -0,0 +1,15 @@ +import { ReportHandler } from 'web-vitals'; + +const reportWebVitals = (onPerfEntry?: ReportHandler) => { + if (onPerfEntry && onPerfEntry instanceof Function) { + import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(onPerfEntry); + getFID(onPerfEntry); + getFCP(onPerfEntry); + getLCP(onPerfEntry); + getTTFB(onPerfEntry); + }); + } +}; + +export default reportWebVitals; diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts new file mode 100644 index 0000000..8f2609b --- /dev/null +++ b/frontend/src/setupTests.ts @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom';