move to public repo

This commit is contained in:
Nathaniel Tampus
2023-01-03 15:47:30 +08:00
commit 17ce3cf4c4
58 changed files with 2430 additions and 0 deletions

32
client/src/App.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { Route, Routes } from "react-router-dom";
import ContextProvider from "./context/ContextProvider";
import Header from "./components/Header/Header";
import Footer from "./components/Footer/Footer";
import ProtectedRoutes from "./routes/ProtectedRoutes";
import Home from "./routes/Home/Home";
import Game from "./routes/Game/Game";
import NotFound from "./routes/NotFound/NotFound";
import "./global.css";
const App = (): JSX.Element => {
return (
<ContextProvider>
<Header />
<main>
<Routes>
<Route index element={<Home />} />
<Route element={<ProtectedRoutes />}>
<Route path="/game/:gameCode" element={<Game />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</main>
<Footer />
</ContextProvider>
);
};
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,125 @@
.authBox {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
border-radius: 4px;
position: relative;
}
.orRegister {
font-size: 70%;
}
.orRegister a {
color: var(--blue11);
text-decoration: underline;
}
.authBox h3 {
margin-bottom: 1em;
}
.section {
flex: 1;
width: 50%;
min-width: 250px;
padding: 20px;
position: relative;
}
.sectionDisabled {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 100%;
background-color: rgba(0, 0, 0, 0.4);
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
backdrop-filter: blur(1px);
z-index: 10;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
}
.sectionLeft {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
/*border-right: 1px solid #eee;*/
}
.sectionRight {
display: flex;
flex-direction: column;
pointer-events: none;
}
.form {
display: flex;
flex-direction: column;
align-items: stretch;
}
.formGroup {
display: flex;
flex-direction: column;
margin-bottom: 10px;
}
.formLabel {
font-size: 14px;
font-weight: 500;
margin-bottom: 5px;
}
.formInput {
background-color: var(--blue4);
box-shadow: 0 0 0 1px var(--blue6);
color: var(--blue12);
border-radius: 4px;
padding: 8px;
font-size: 14px;
}
.formInput:focus {
box-shadow: 0 0 0 1px var(--blue8);
}
.formButton {
border-radius: 4px;
background-color: var(--blue10);
color: var(--blue1);
font-size: 14px;
font-weight: 500;
padding: 8px 16px;
cursor: pointer;
}
.formButton:focus,
.formButton:hover {
background-color: var(--blue11);
}
.oauth {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-top: 10px;
}
.oauthButton {
width: 50%;
background-color: transparent;
cursor: pointer;
display: inline-flex;
}
.oauthButton img {
width: 100%;
}

View File

@@ -0,0 +1,90 @@
import { MouseEvent, useRef, useContext } from "react";
import { SessionContext } from "../../context/session";
import { setGuestSession } from "../../utils/auth";
import styles from "./Auth.module.css";
import GoogleOAuth from "../../assets/oauth_google.png";
import FacebookOAuth from "../../assets/oauth_facebook.png";
// TODO: clean up this component
const Auth = () => {
const guestNameRef = useRef<HTMLInputElement | null>(null);
const session = useContext(SessionContext);
async function handleGuestLogin(e: MouseEvent<HTMLButtonElement>) {
e.preventDefault();
if (!guestNameRef.current || !guestNameRef.current.value) return;
const user = await setGuestSession(guestNameRef.current.value);
if (user) {
session?.setUser(user);
} else {
console.log("guest auth failed");
}
}
return (
<div className={styles.authBox}>
<div className={`${styles.section} ${styles.sectionLeft}`}>
<h3>Guest login</h3>
<form>
<div className={styles.formGroup}>
<label className={styles.formLabel} htmlFor="guest-username">
Username
</label>
<input
type="text"
id="guest-username"
ref={guestNameRef}
className={styles.formInput}
pattern="[a-zA-Z0-9_-]+"
title="_ - and alphanumeric characters only"
required
/>
</div>
<button className={styles.formButton} type="submit" onClick={handleGuestLogin}>
Continue as Guest
</button>
</form>
</div>
<div className={`${styles.section} ${styles.sectionRight}`}>
<div className={styles.sectionDisabled}>coming soon.</div>
<h3>
Sign In{" "}
<span className={styles.orRegister}>
or <a href="#">register</a>
</span>
</h3>
<form className={styles.form}>
<div className={styles.formGroup}>
<label className={styles.formLabel} htmlFor="email">
Username/Email
</label>
<input type="email" id="email" className={styles.formInput} />
</div>
<div className={styles.formGroup}>
<label className={styles.formLabel} htmlFor="password">
Password
</label>
<input type="password" id="password" className={styles.formInput} />
</div>
<button className={styles.formButton} disabled>
Sign In
</button>
</form>
<div className={styles.oauth}>
<button className={styles.oauthButton} disabled>
<img src={GoogleOAuth} alt="Google" />
</button>
<button className={styles.oauthButton} disabled>
<img src={FacebookOAuth} alt="Facebook" />
</button>
</div>
</div>
</div>
);
};
export default Auth;

View File

@@ -0,0 +1,210 @@
import { useContext, useState, useEffect, useReducer } from "react";
import { Chess, Move } from "chess.js";
import { Chessboard, Square, Pieces } from "react-chessboard";
import { SocketContext } from "../../context/socket";
import { SessionContext } from "../../context/session";
import type { Game } from "@types";
/**
* bug: always on initial position on page load, regardless of game.fen()
but works fine in production without StrictMode rendering the component twice
* */
const Board = () => {
const socket = useContext(SocketContext);
const session = useContext(SessionContext);
const [size, setSize] = useState(400);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, forceUpdate] = useReducer((x) => x + 1, 0);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [game, _setGame] = useState(new Chess());
const [side, setSide] = useState<"b" | "w" | "s">("s");
const [moveFrom, setMoveFrom] = useState<string | Square>("");
const [rightClickedSquares, setRightClickedSquares] = useState<{
[square: string]: { backgroundColor: string } | undefined;
}>({});
const [optionSquares, setOptionSquares] = useState<{
[square: string]: { background: string; borderRadius?: string };
}>({});
useEffect(() => {
if (socket === null) return;
window.addEventListener("resize", handleResize);
handleResize();
socket.on("receivedLatestGame", (latestGame: Game) => {
if (latestGame.pgn) {
const loadSuccess = game.loadPgn(latestGame.pgn);
if (loadSuccess) {
forceUpdate();
}
}
if (latestGame.black?.id === session?.user.id) {
if (side !== "b") setSide("b");
} else if (latestGame.white?.id === session?.user.id) {
if (side !== "w") setSide("w");
} else if (side !== "s") {
setSide("s");
}
});
socket.on("receivedMove", (m: { from: string; to: string; promotion?: string }) => {
const success = makeMove(m);
if (!success) {
socket.emit("getLatestGame");
}
});
return () => {
window.removeEventListener("resize", handleResize);
socket.off("receivedMove");
socket.off("receivedLatestGame");
};
}, []);
function handleResize() {
const container = document.getElementById("root");
if (!container || !container.offsetWidth) return;
if (container.offsetWidth > 1600) {
setSize(container.offsetWidth * 0.25);
} else if (container.offsetWidth > 700) {
setSize(container.offsetWidth * 0.35);
} else {
setSize(container.offsetWidth - 100);
}
}
function makeMove(m: { from: string; to: string; promotion?: string }) {
const result = game.move({ from: m.from, to: m.to });
if (result) {
setOptionSquares({
[m.from]: { background: "rgba(255, 255, 0, 0.4)" },
[m.to]: { background: "rgba(255, 255, 0, 0.4)" }
});
} else {
setOptionSquares({});
}
return result;
}
function isDraggablePiece({ piece }: { piece: Pieces }) {
if (side === "s") return true;
return piece.startsWith(side);
}
function onDrop(sourceSquare: Square, targetSquare: Square) {
if (side !== game.turn()) return false;
const move = makeMove({
from: sourceSquare,
to: targetSquare,
promotion: "q"
});
if (move === null) return false; // illegal move
socket?.emit("sendMove", { from: move.from, to: move.to });
return true;
}
function getMoveOptions(square: Square) {
const moves = game.moves({
square,
verbose: true
}) as Move[];
if (moves.length === 0) {
return;
}
const newSquares: {
[square: string]: { background: string; borderRadius?: string };
} = {};
moves.map((move) => {
newSquares[move.to] = {
background:
game.get(move.to as Square) &&
game.get(move.to as Square)?.color !== game.get(square)?.color
? "radial-gradient(circle, rgba(0,0,0,.1) 85%, transparent 85%)"
: "radial-gradient(circle, rgba(0,0,0,.1) 25%, transparent 25%)",
borderRadius: "50%"
};
return move;
});
newSquares[square] = {
background: "rgba(255, 255, 0, 0.4)"
};
setOptionSquares(newSquares);
}
function onPieceDragBegin(_piece: Pieces, sourceSquare: Square) {
if (side !== game.turn()) return;
getMoveOptions(sourceSquare);
}
function onPieceDragEnd() {
setOptionSquares({});
}
function onSquareClick(square: Square) {
setRightClickedSquares({});
if (side !== game.turn()) return;
function resetFirstMove(square: Square) {
setMoveFrom(square);
getMoveOptions(square);
}
// from square
if (!moveFrom) {
resetFirstMove(square);
return;
}
const move = makeMove({
from: moveFrom as Square,
to: square,
promotion: "q"
});
if (move === null) {
resetFirstMove(square);
} else {
setMoveFrom("");
socket?.emit("sendMove", { from: move.from, to: move.to });
}
}
function onSquareRightClick(square: Square) {
const colour = "rgba(0, 0, 255, 0.4)";
setRightClickedSquares({
...rightClickedSquares,
[square]:
rightClickedSquares[square] && rightClickedSquares[square]?.backgroundColor === colour
? undefined
: { backgroundColor: colour }
});
}
return (
<Chessboard
position={game.fen()}
animationDuration={200}
isDraggablePiece={isDraggablePiece}
boardOrientation={side === "b" ? "black" : "white"}
boardWidth={size}
onPieceDragBegin={onPieceDragBegin}
onPieceDragEnd={onPieceDragEnd}
onPieceDrop={onDrop}
onSquareClick={onSquareClick}
onSquareRightClick={onSquareRightClick}
customSquareStyles={{
...optionSquares,
...rightClickedSquares
}}
/>
);
};
export default Board;

View File

@@ -0,0 +1,14 @@
.footer {
margin-top: 4em;
padding-bottom: 2em;
width: 100%;
font-size: 0.8em;
}
.footer a {
color: var(--blue12);
}
.github {
text-decoration: underline;
}

View File

@@ -0,0 +1,24 @@
import styles from "./Footer.module.css";
const Footer = () => {
return (
<footer className={styles.footer}>
made with &hearts; by{" "}
<a href="https://nize.ph" target="_blank" rel="noreferrer">
nize
</a>
<br />
&copy; {new Date().getFullYear()} {" "}
<a
href="https://github.com/nizewn/chessu"
className={styles.github}
target="_blank"
rel="noreferrer"
>
GitHub
</a>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,15 @@
.header {
padding: 1.5em;
font-size: 2.3em;
text-align: center;
}
.title {
color: var(--blue12);
}
.themeToggle {
padding: 0 0.5em;
background: transparent;
color: var(--blue12);
}

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import styles from "./Header.module.css";
import { SunIcon, MoonIcon } from "@radix-ui/react-icons";
const Header = () => {
const [darkTheme, setDarkTheme] = useState(false);
useEffect(() => {
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => changeTheme(e.matches ? "dark" : "light"));
changeTheme(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
}, []);
function changeTheme(theme: "dark" | "light") {
if (theme === "dark") {
if (!document.body.classList.contains("dark-theme")) {
document.body.classList.add("dark-theme");
}
setDarkTheme(true);
} else {
if (document.body.classList.contains("dark-theme")) {
document.body.classList.remove("dark-theme");
}
setDarkTheme(false);
}
}
return (
<header className={styles.header}>
<Link to="/" className={styles.title}>
chessu
</Link>
<button
className={styles.themeToggle}
type="button"
onClick={() => changeTheme(darkTheme ? "light" : "dark")}
>
{darkTheme ? <SunIcon /> : <MoonIcon />}
</button>
</header>
);
};
export default Header;

View File

@@ -0,0 +1 @@
export const apiUrl = import.meta.env.APIURL || "https://api.ches.su";

View File

@@ -0,0 +1,29 @@
import { PropsWithChildren, useEffect, useState } from "react";
import type { User } from "@types";
import { SocketContext, socket } from "./socket";
import { SessionContext } from "./session";
import { fetchSession } from "../utils/auth";
const ContextProvider = (props: PropsWithChildren) => {
const [user, setUser] = useState<User>({});
async function getSession() {
const user = await fetchSession();
if (user) {
setUser(user);
}
}
useEffect(() => {
getSession();
}, []);
return (
<SocketContext.Provider value={socket}>
<SessionContext.Provider value={{ user, setUser }}>{props.children}</SessionContext.Provider>
</SocketContext.Provider>
);
};
export default ContextProvider;

View File

@@ -0,0 +1,7 @@
import { User } from "@types";
import { createContext, Dispatch, SetStateAction } from "react";
export const SessionContext = createContext<{
user: User;
setUser: Dispatch<SetStateAction<User>>;
} | null>(null);

View File

@@ -0,0 +1,18 @@
import { createContext } from "react";
import { io, Socket } from "socket.io-client";
import { apiUrl } from "../config/config";
export const socket: Socket = io(apiUrl, {
withCredentials: true,
autoConnect: false
});
socket.on("connect", () => {
console.log("socket connected");
});
socket.on("disconnect", () => {
console.log("socket disconnected");
});
export const SocketContext = createContext<Socket | null>(null);

39
client/src/global.css Normal file
View File

@@ -0,0 +1,39 @@
@import url("https://fonts.googleapis.com/css2?family=Poppins&display=swap");
@import "@radix-ui/colors/blue.css";
@import "@radix-ui/colors/blueDark.css";
* {
margin: 0;
padding: 0;
border: 0;
outline: 0;
box-sizing: border-box;
list-style: none;
text-decoration: none;
}
body {
text-align: center;
font-family: "Poppins", sans-serif;
background-color: var(--blue1);
color: var(--blue12);
}
main {
margin-top: 1em;
min-height: 300px;
padding: 1.5em;
border-radius: 0.5em;
background-color: var(--blue2);
margin: auto;
display: inline-block;
min-width: 450px;
max-width: 900px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
@media only screen and (max-width: 550px) {
main {
min-width: 90%;
}
}

10
client/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
createRoot(document.getElementById("root") as HTMLElement).render(
<BrowserRouter>
<App />
</BrowserRouter>
);

5
client/src/routes.tsx Normal file
View File

@@ -0,0 +1,5 @@
const routes = () => {
return <div>routes</div>;
};
export default routes;

View File

@@ -0,0 +1,121 @@
.game {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 1.5em;
}
.boardContainer {
text-align: left;
}
.playerNameTop {
margin-bottom: 0.5em;
}
.playerNameBottom {
margin-top: 0.5em;
}
.playButton {
padding: 0.5em;
margin: 0.5em 0;
cursor: pointer;
background-color: var(--blue10);
color: var(--blue1);
font-weight: bold;
font-size: 1em;
}
.playButton:hover,
.playButton:focus {
background-color: var(--blue11);
}
.sidebar {
text-align: left;
font-size: 0.9em;
display: flex;
flex-wrap: wrap;
flex-direction: column;
justify-content: space-between;
gap: 2em;
}
.invite {
text-align: right;
}
.copy {
font-size: 0.8em;
cursor: pointer;
padding: 6px;
border-radius: 8px;
background-color: var(--blue4);
}
.lobby {
height: 6em;
width: 260px;
}
.lobbyUsers {
font-size: 0.8em;
overflow: wrap;
}
.chatbox {
display: flex;
flex-direction: column;
gap: 1em;
padding: 1em;
background-color: var(--blue3);
border-radius: 10px;
width: 260px;
height: 280px;
}
.chatList {
overflow-y: scroll;
overflow-x: hidden;
scrollbar-width: thin;
height: 100%;
}
.chatList::-webkit-scrollbar {
width: 0.4em;
}
.chatList::-webkit-scrollbar-thumb {
background-color: var(--blue6);
border-radius: 2px;
}
.author {
font-weight: bold;
}
.player {
font-weight: bold;
color: var(--blue9);
}
.server {
color: var(--blue11);
}
.gameOver {
background-color: var(--blue12);
color: var(--blue1);
font-weight: bold;
padding: 0.4em;
}
.chatInput {
display: block;
height: 2em;
padding: 0 0.5em;
border-radius: 6px;
width: 228px;
margin-top: auto;
flex-shrink: 0;
background-color: var(--blue5);
box-shadow: 0 0 0 1px var(--blue6);
color: var(--blue12);
}
.chatInput:hover,
.chatInput:focus {
box-shadow: 0 0 0 1px var(--blue7);
}

View File

@@ -0,0 +1,238 @@
import { MouseEvent, KeyboardEvent, useRef } from "react";
import { useParams } from "react-router-dom";
import Board from "../../components/Board/Board";
import { useEffect, useContext, useState } from "react";
import { SocketContext } from "../../context/socket";
import { SessionContext } from "../../context/session";
import type { Game, User } from "@types";
import styles from "./Game.module.css";
import { CopyIcon, PersonIcon } from "@radix-ui/react-icons";
interface Message {
author: User;
message: string;
}
const Game = () => {
const { gameCode } = useParams();
const [game, setGame] = useState<Game>({});
const [messages, setMessages] = useState<Message[]>([]);
const socket = useContext(SocketContext);
const session = useContext(SessionContext);
const chatlistRef = useRef<HTMLUListElement>(null);
function joinAsPlayer(e: MouseEvent<HTMLButtonElement>) {
e.preventDefault();
socket?.emit("joinAsPlayer");
}
function handleCopy(type: "link" | "code") {
const text = type === "code" ? game.code : `https://ches.su/game/${game.code}`;
if (!text) return;
if ("clipboard" in navigator) {
navigator.clipboard.writeText(text);
} else {
document.execCommand("copy", true, text);
}
}
function addMessage(m: Message) {
setMessages((msgs) => [...msgs, m]);
}
function chatKeyUp(e: KeyboardEvent<HTMLInputElement>) {
e.preventDefault();
if (e.key === "Enter") {
const value = (e.target as HTMLInputElement).value;
if (!value || value.length === 0) return;
socket?.emit("chat", value);
addMessage({ author: session?.user as User, message: value });
(e.target as HTMLInputElement).value = "";
}
}
useEffect(() => {
// auto scroll down when new message is added
const box = chatlistRef.current;
if (!box) return;
box.scrollTop = box.scrollHeight;
}, [messages]);
useEffect(() => {
if (socket === null) {
console.log("socket is null");
return;
}
socket.on("receivedLatestLobby", (g: Game) => {
setGame(g);
});
socket.on("userJoined", (name: string) => {
addMessage({ author: { name: "server" }, message: `${name} has joined the lobby.` });
});
socket.on("userLeft", (name: string) => {
addMessage({ author: { name: "server" }, message: `${name} has left the lobby.` });
});
socket.on("userJoinedAsPlayer", ({ name, side }: { name: string; side: string }) => {
addMessage({ author: { name: "server" }, message: `${name} is now playing ${side}.` });
});
socket.on("chat", (m: Message) => {
addMessage(m);
});
socket.on(
"gameOver",
({
reason,
winnerName,
winnerSide
}: {
reason: string;
winnerName?: string;
winnerSide?: string;
}) => {
const m = {
author: { name: "game" }
} as Message;
if (reason === "checkmate") {
m.message = `${winnerName}(${winnerSide}) has won by checkmate.`;
} else {
let message = "The game has ended in a draw";
if (reason === "repetition") {
message = message.concat(" due to threefold repetition");
} else if (reason === "insufficient") {
message = message.concat(" due to insufficient material");
} else if (reason === "stalemate") {
message = "The game has been drawn due to stalemate";
}
m.message = message.concat(".");
}
addMessage(m);
}
);
socket.connect();
socket.emit("joinLobby", gameCode);
return () => {
socket.off("receivedLatestLobby");
socket.off("userJoined");
socket.off("userLeft");
socket.off("userJoinedAsPlayer");
socket.off("chat");
socket.off("gameOver");
socket.disconnect();
};
}, []);
return (
<div className={styles.game}>
<div className={styles.boardContainer}>
{/* had no brain cells left when i was writing this, sorry */}
{game.black?.id === session?.user.id ? (
game.white?.name ? (
<div className={styles.playerNameTop}>
<PersonIcon /> {game.white?.name}
</div>
) : (
""
)
) : game.white?.id === session?.user.id ? (
game.black?.name ? (
<div className={styles.playerNameTop}>
<PersonIcon /> {game.black?.name}
</div>
) : (
""
)
) : game.black?.name ? (
<div className={styles.playerNameTop}>
<PersonIcon /> {game.black?.name}
</div>
) : (
<button type="button" onClick={joinAsPlayer} className={styles.playButton}>
Play as black
</button>
)}
<Board />
{game.black?.id === session?.user.id ? (
<div className={styles.playerNameBottom}>
<PersonIcon /> {session?.user.name}
</div>
) : game.white?.name ? (
<div className={styles.playerNameBottom}>
<PersonIcon /> {game.white?.name}
</div>
) : (
<button type="button" onClick={joinAsPlayer} className={styles.playButton}>
Play as white
</button>
)}
</div>
<div className={styles.sidebar}>
<div className={styles.invite}>
Invite friends:{" "}
<span className={styles.copy} onClick={() => handleCopy("link")}>
ches.su/game/{game.code} <CopyIcon />
</span>
<div className={styles.code}>
or code{" "}
<span className={styles.copy} onClick={() => handleCopy("code")}>
{game.code} <CopyIcon />
</span>
</div>
</div>
<div className={styles.chatbox}>
<ul className={styles.chatList} ref={chatlistRef}>
{messages.map((m, i) => (
<li
key={i}
className={
!m.author.id && m.author.name === "server"
? styles.server
: !m.author.id && m.author.name === "game"
? styles.gameOver
: ""
}
>
{m.author.id ? (
<span>
<span
className={
m.author.id === game?.white?.id || m.author.id === game?.black?.id
? styles.player
: styles.author
}
>
{m.author.name}
</span>
{": "}
</span>
) : (
""
)}
{m.message}
</li>
))}
</ul>
<input
type="text"
name="chatbox"
id="chatbox"
className={styles.chatInput}
onKeyUp={chatKeyUp}
required
/>
</div>
<div className={styles.lobby}>
{game.observers && game.observers.length > 0 ? "Spectators: " : ""}
<div className={styles.lobbyUsers}>{game.observers?.map((o) => o.name).join(", ")}</div>
</div>
</div>
</div>
);
};
export default Game;

View File

@@ -0,0 +1,117 @@
.home {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.gameNotFound {
position: absolute;
color: var(--blue11);
}
.name {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 14px;
margin-bottom: 2em;
}
.label {
line-height: 2em;
user-select: none;
}
.input {
height: 2em;
width: 50%;
flex-grow: 0;
padding: 0 0.5em;
background-color: var(--blue3);
border-radius: 6px;
box-shadow: 0 0 0 1px var(--blue6);
color: var(--blue12);
}
.input:focus,
.select:focus {
box-shadow: 0 0 0 1px var(--blue8);
}
.tabs,
.tabContent {
width: 70%;
}
.tabContent {
height: 10em;
padding: 1.5em 0.5em 0.5em;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 10px;
}
.select {
width: 50%;
height: 2em;
padding: 0 0.5em;
background-color: var(--blue3);
box-shadow: 0 0 0 1px var(--blue6);
color: var(--blue12);
}
.submit {
cursor: pointer;
margin-top: 1em;
font-weight: bold;
border-radius: 8px;
padding: 0.4em 0;
flex-basis: 50%;
color: var(--blue1);
background-color: var(--blue10);
}
.submit:hover,
.submit:focus {
background-color: var(--blue11);
}
.tabContent .input {
width: 45%;
background-color: var(--blue4);
}
.tabContent {
font-size: 0.9em;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.tabLeft {
border-top-left-radius: 8px;
}
.tabRight {
border-top-right-radius: 8px;
}
.tab {
border: 2px solid var(--blue3);
padding: 4px 0;
background-color: var(--blue3);
color: var(--blue12);
width: 50%;
}
.tabActive,
.tabContent {
background-color: var(--blue5);
}
@media only screen and (max-width: 550px) {
.tabs,
.tabContent {
width: 75%;
}
}

View File

@@ -0,0 +1,139 @@
import { FormEvent, useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import styles from "./Home.module.css";
import { setGuestSession } from "../../utils/auth";
import { SessionContext } from "../../context/session";
import { createGame, findGame } from "../../utils/games";
const JoinGame = ({ notFound }: { notFound: boolean }) => {
return (
<fieldset className={styles.tabContent}>
{notFound ? <span className={styles.gameNotFound}>Game not found.</span> : ""}
<label className={styles.label} htmlFor="code">
Invite code
</label>
<input className={styles.input} type="text" id="code" name="code" required />
<button type="submit" className={styles.submit}>
Join Game
</button>
</fieldset>
);
};
const CreateGame = () => {
return (
<fieldset className={styles.tabContent}>
<label className={styles.label} htmlFor="side">
Starting side
</label>
<select className={styles.select} name="side" id="side">
<option value="random">Random</option>
<option value="white">White</option>
<option value="black">Black</option>
</select>
<button type="submit" className={styles.submit}>
Create Game
</button>
</fieldset>
);
};
const Home = () => {
const [creatingGame, setCreatingGame] = useState(false);
const [gameNotFound, setGameNotFound] = useState(false);
const session = useContext(SessionContext);
const navigate = useNavigate();
async function handleCreateGame(name: string, side: string) {
const user = await setGuestSession(name);
if (user) {
session?.setUser(user);
const game = await createGame(side);
if (game) {
navigate(`/game/${game.code}`);
} else {
// TODO error handling
console.log("handleCreateGame unsuccessful");
}
}
}
async function handleJoinGame(name: string, code: string) {
const user = await setGuestSession(name);
if (user) {
session?.setUser(user);
if (code.startsWith("http") || code.startsWith("ches.su")) {
code = new URL(code).pathname.split("/")[2];
}
const game = await findGame(code);
if (game) {
navigate(`/game/${game.code}`);
} else {
// TODO error handling
setGameNotFound(true);
}
}
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const target = e.target as HTMLFormElement;
const playerName = (target.elements.namedItem("name") as HTMLInputElement).value;
if (!playerName) return;
if (creatingGame) {
const startingSide = (target.elements.namedItem("side") as HTMLSelectElement).value;
handleCreateGame(playerName, startingSide);
} else {
const gameCode = (target.elements.namedItem("code") as HTMLInputElement).value;
if (!gameCode) return;
handleJoinGame(playerName, gameCode);
}
}
return (
<form className={styles.home} onSubmit={handleSubmit}>
<fieldset className={styles.name}>
<label className={styles.label} htmlFor="name">
Display name
</label>
<input
className={styles.input}
type="text"
id="name"
name="name"
pattern="[a-zA-Z0-9_-]+"
title="_ - and alphanumeric characters only"
defaultValue={session?.user.name}
required
/>
</fieldset>
<div className={styles.tabs}>
<button
type="button"
className={`${styles.tab} ${styles.tabLeft} ${creatingGame ? "" : styles.tabActive}`}
onClick={() => {
setCreatingGame(false);
setGameNotFound(false);
}}
>
Join
</button>
<button
type="button"
className={`${styles.tab} ${styles.tabRight} ${creatingGame ? styles.tabActive : ""}`}
onClick={() => setCreatingGame(true)}
>
Create
</button>
</div>
{creatingGame ? <CreateGame /> : <JoinGame notFound={gameNotFound} />}
</form>
);
};
export default Home;

View File

@@ -0,0 +1,5 @@
const NotFound = () => {
return <div>Error 404: page not found</div>;
};
export default NotFound;

View File

@@ -0,0 +1,12 @@
import { useContext } from "react";
import { Outlet } from "react-router-dom";
import Auth from "../components/Auth/Auth";
import { SessionContext } from "../context/session";
const ProtectedRoutes = () => {
const session = useContext(SessionContext);
return session && session?.user.id ? <Outlet /> : <Auth />;
};
export default ProtectedRoutes;

29
client/src/utils/auth.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { User } from "@types";
import { apiUrl } from "../config/config";
export const fetchSession = async () => {
const res = await fetch(`${apiUrl}/v1/auth`, {
credentials: "include"
});
if (res.status === 200) {
const user: User = await res.json();
return user;
}
};
export const setGuestSession = async (name: string) => {
const res = await fetch(`${apiUrl}/v1/auth/guest`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ name })
});
console.log(res);
if (res.status === 201) {
const user: User = await res.json();
return user;
}
};

24
client/src/utils/games.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { Game } from "@types";
import { apiUrl } from "../config/config";
export const createGame = async (side: string) => {
const res = await fetch(`${apiUrl}/v1/games`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ side })
});
const game: Game | undefined = await res.json();
return game;
};
export const findGame = async (code: string) => {
const res = await fetch(`${apiUrl}/v1/games`);
const games = await res.json();
const game: Game | undefined = games.find((g: Game) => g.code === code);
return game;
};

1
client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />