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 (
+
+
+
+
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}
+
+ )}
+
+
+
+ Место |
+ Имя пользователя |
+ Победы |
+ Отношение W/L |
+
+
+
+ {leaderboard.map((user, index) => (
+
+ {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 (
+
+ );
+};
+
+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';