move to public repo
This commit is contained in:
32
client/src/App.tsx
Normal file
32
client/src/App.tsx
Normal 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;
|
||||
BIN
client/src/assets/oauth_facebook.png
Normal file
BIN
client/src/assets/oauth_facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
client/src/assets/oauth_google.png
Normal file
BIN
client/src/assets/oauth_google.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
125
client/src/components/Auth/Auth.module.css
Normal file
125
client/src/components/Auth/Auth.module.css
Normal 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%;
|
||||
}
|
||||
90
client/src/components/Auth/Auth.tsx
Normal file
90
client/src/components/Auth/Auth.tsx
Normal 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;
|
||||
210
client/src/components/Board/Board.tsx
Normal file
210
client/src/components/Board/Board.tsx
Normal 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;
|
||||
14
client/src/components/Footer/Footer.module.css
Normal file
14
client/src/components/Footer/Footer.module.css
Normal 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;
|
||||
}
|
||||
24
client/src/components/Footer/Footer.tsx
Normal file
24
client/src/components/Footer/Footer.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import styles from "./Footer.module.css";
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
made with ♥ by{" "}
|
||||
<a href="https://nize.ph" target="_blank" rel="noreferrer">
|
||||
nize
|
||||
</a>
|
||||
<br />
|
||||
© {new Date().getFullYear()} —{" "}
|
||||
<a
|
||||
href="https://github.com/nizewn/chessu"
|
||||
className={styles.github}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
15
client/src/components/Header/Header.module.css
Normal file
15
client/src/components/Header/Header.module.css
Normal 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);
|
||||
}
|
||||
47
client/src/components/Header/Header.tsx
Normal file
47
client/src/components/Header/Header.tsx
Normal 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;
|
||||
1
client/src/config/config.ts
Normal file
1
client/src/config/config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const apiUrl = import.meta.env.APIURL || "https://api.ches.su";
|
||||
29
client/src/context/ContextProvider.tsx
Normal file
29
client/src/context/ContextProvider.tsx
Normal 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;
|
||||
7
client/src/context/session.ts
Normal file
7
client/src/context/session.ts
Normal 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);
|
||||
18
client/src/context/socket.ts
Normal file
18
client/src/context/socket.ts
Normal 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
39
client/src/global.css
Normal 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
10
client/src/main.tsx
Normal 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
5
client/src/routes.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const routes = () => {
|
||||
return <div>routes</div>;
|
||||
};
|
||||
|
||||
export default routes;
|
||||
121
client/src/routes/Game/Game.module.css
Normal file
121
client/src/routes/Game/Game.module.css
Normal 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);
|
||||
}
|
||||
238
client/src/routes/Game/Game.tsx
Normal file
238
client/src/routes/Game/Game.tsx
Normal 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;
|
||||
117
client/src/routes/Home/Home.module.css
Normal file
117
client/src/routes/Home/Home.module.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
139
client/src/routes/Home/Home.tsx
Normal file
139
client/src/routes/Home/Home.tsx
Normal 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;
|
||||
5
client/src/routes/NotFound/NotFound.tsx
Normal file
5
client/src/routes/NotFound/NotFound.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const NotFound = () => {
|
||||
return <div>Error 404: page not found</div>;
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
12
client/src/routes/ProtectedRoutes.tsx
Normal file
12
client/src/routes/ProtectedRoutes.tsx
Normal 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
29
client/src/utils/auth.ts
Normal 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
24
client/src/utils/games.ts
Normal 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
1
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user