Merge pull request #11 from nizewn/auth

Optional user authentication
This commit is contained in:
Nathaniel Tampus
2023-04-07 15:03:50 +08:00
committed by GitHub
45 changed files with 1986 additions and 265 deletions

View File

@@ -1,22 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "monthly"
- package-ecosystem: "npm"
directory: "/client"
schedule:
interval: "monthly"
- package-ecosystem: "npm"
directory: "/server"
schedule:
interval: "monthly"

View File

@@ -6,8 +6,9 @@ Yet another Chess web app. Live demo at [ches.su](https://ches.su).
- play against other users in real-time - play against other users in real-time
- spectate and chat in ongoing games with other users - spectate and chat in ongoing games with other users
- ~~_optional_ user accounts for tracking stats and game history~~ (wip) - _optional_ user accounts for tracking stats and game history
- mobile-friendly (wip) - ~~play solo against Stockfish~~ (wip)
- mobile-friendly
- ... and more ([view roadmap](https://github.com/users/nizewn/projects/2)) - ... and more ([view roadmap](https://github.com/users/nizewn/projects/2))
Built with Next.js 13, Tailwind CSS + daisyUI, react-chessboard, chess.js, Express.js, socket.io and PostgreSQL. Built with Next.js 13, Tailwind CSS + daisyUI, react-chessboard, chess.js, Express.js, socket.io and PostgreSQL.

View File

@@ -9,9 +9,9 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@tabler/icons-react": "^2.11.0", "@tabler/icons-react": "^2.14.0",
"chess.js": "^1.0.0-beta.4", "chess.js": "^1.0.0-beta.4",
"next": "13.2.4", "next": "13.3.0",
"react": "18.2.0", "react": "18.2.0",
"react-chessboard": "^2.1.2", "react-chessboard": "^2.1.2",
"react-dom": "18.2.0", "react-dom": "18.2.0",
@@ -19,14 +19,14 @@
}, },
"devDependencies": { "devDependencies": {
"@chessu/types": "*", "@chessu/types": "*",
"@types/node": "18.15.3", "@types/node": "18.15.11",
"@types/react": "18.0.28", "@types/react": "18.0.33",
"@types/react-dom": "18.0.11", "@types/react-dom": "18.0.11",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"daisyui": "^2.51.4", "daisyui": "^2.51.5",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.3.1",
"typescript": "5.0.2" "typescript": "5.0.3"
}, },
"optionalDependencies": { "optionalDependencies": {
"bufferutil": "^4.0.7", "bufferutil": "^4.0.7",

View File

@@ -1,9 +1,9 @@
import GameAuthWrapper from "@/components/game/GameAuthWrapper"; import GameAuthWrapper from "@/components/game/GameAuthWrapper";
import { getGame } from "@/lib/game"; import { fetchActiveGame } from "@/lib/game";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
export async function generateMetadata({ params }: { params: { code: string } }) { export async function generateMetadata({ params }: { params: { code: string } }) {
const game = await getGame(params.code); const game = await fetchActiveGame(params.code);
if (!game) { if (!game) {
return { return {
description: "Game not found", description: "Game not found",
@@ -35,7 +35,7 @@ export async function generateMetadata({ params }: { params: { code: string } })
} }
export default async function Game({ params }: { params: { code: string } }) { export default async function Game({ params }: { params: { code: string } }) {
const game = await getGame(params.code); const game = await fetchActiveGame(params.code);
if (!game) { if (!game) {
notFound(); notFound();
} }

View File

@@ -0,0 +1,8 @@
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center gap-4">
<div className="text-2xl font-bold">Error 404</div>
<div className="text-xl">Game not found</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import ArchivedGame from "@/components/archive/ArchivedGame";
import { fetchArchivedGame } from "@/lib/game";
import type { Game } from "@chessu/types";
import { notFound } from "next/navigation";
export async function generateMetadata({ params }: { params: { id: number } }) {
const game = (await fetchArchivedGame({ id: params.id })) as Game | undefined;
if (!game) {
return {
description: "Game not found",
robots: {
index: false,
follow: false,
nocache: true,
noarchive: true
}
};
}
return {
description: `Archived game: ${game.white?.name} vs ${game.black?.name}`,
openGraph: {
title: "chessu",
description: `Archived game: ${game.white?.name} vs ${game.black?.name}`,
url: `https://ches.su/archive/${game.id}`,
siteName: "chessu",
locale: "en_US",
type: "website"
},
robots: {
index: false,
follow: false,
nocache: true,
noarchive: true
}
};
}
export default async function Archive({ params }: { params: { id: number } }) {
const game = (await fetchArchivedGame({ id: params.id })) as Game | undefined;
if (!game) {
notFound();
}
return <ArchivedGame game={game} />;
}

View File

@@ -1,6 +1,6 @@
import "@/styles/globals.css"; import "@/styles/globals.css";
import Navbar from "@/components/Navbar"; import Header from "@/components/Header";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import AuthModal from "@/components/auth/AuthModal"; import AuthModal from "@/components/auth/AuthModal";
@@ -30,7 +30,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="en" className="overflow-x-hidden"> <html lang="en" className="overflow-x-hidden">
<body className="overflow-x-hidden"> <body className="overflow-x-hidden">
<ContextProvider> <ContextProvider>
<Navbar /> <Header />
<main className="mx-1 flex min-h-[70vh] justify-center md:mx-16 lg:mx-40"> <main className="mx-1 flex min-h-[70vh] justify-center md:mx-16 lg:mx-40">
{children} {children}

View File

@@ -0,0 +1,119 @@
"use client";
import type { FormEvent } from "react";
import { useRouter } from "next/navigation";
import { SessionContext } from "@/context/session";
import { useContext, useState } from "react";
import { updateUser } from "@/lib/auth";
export default function Settings() {
const session = useContext(SessionContext);
const router = useRouter();
const [buttonLoading, setButtonLoading] = useState(false);
const [serverMessage, setServerMessage] = useState<string | null>(null);
if (!session || session.user === undefined) {
return (
<div className="flex flex-col items-center justify-center gap-4">
<div className="text-xl">Loading user...</div>
</div>
);
}
if (session.user === null) {
router.replace("/");
return;
}
async function updateAccount(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const target = e.target as HTMLFormElement;
const updateUsername = target.elements.namedItem("updateUsername") as HTMLInputElement;
const updateEmail = target.elements.namedItem("updateEmail") as HTMLInputElement;
const updatePassword = target.elements.namedItem("updatePassword") as HTMLInputElement;
const newName =
!updateUsername.value || updateUsername.value === session?.user?.name
? undefined
: updateUsername.value;
const newEmail =
!updateEmail.value || updateEmail.value === session?.user?.email
? undefined
: updateEmail.value;
if (!newName && !newEmail && !updatePassword.value) return;
setButtonLoading(true);
const user = await updateUser(newName, newEmail, updatePassword.value || undefined);
if (typeof user === "string") {
setServerMessage(user);
} else if (user?.id) {
session?.setUser(user);
setServerMessage("Account updated successfully");
setTimeout(() => {
setServerMessage(null);
}, 5000);
}
updatePassword.value = "";
setButtonLoading(false);
}
return (
<div className="flex w-full flex-col justify-center md:w-1/2">
<h2 className="text-2xl font-bold">Account settings</h2>
<form className="form-control mt-4" onSubmit={updateAccount}>
<label className="label" htmlFor="updateUsername">
<span className="label-text">Username</span>
</label>
<input
type="text"
pattern="[A-Za-z0-9]+"
title="Alphanumeric characters only"
id="updateUsername"
name="updateUsername"
placeholder={session.user.name || "Username"}
defaultValue={session.user.name || undefined}
className="input input-bordered w-full"
maxLength={16}
minLength={2}
/>
<label className="label" htmlFor="updateEmail">
<span className="label-text">Email</span>
</label>
<input
type="email"
className="input input-bordered w-full"
id="updateEmail"
name="updateEmail"
placeholder={session.user.email || "Email address"}
defaultValue={session.user.email}
minLength={4}
/>
<label className="label" htmlFor="updatePassword">
<span className="label-text">Password</span>
</label>
<input
type="password"
className="input input-bordered w-full"
id="updatePassword"
name="updatePassword"
placeholder="New password (optional)"
minLength={3}
/>
{serverMessage && (
<div className={serverMessage.includes("successful") ? "text-success" : "text-error"}>
{serverMessage}
</div>
)}
<div className="mt-4">
<button type="submit" className={"btn" + (buttonLoading ? " loading" : "")}>
Update
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,8 @@
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center gap-4">
<div className="text-2xl font-bold">Error 404</div>
<div className="text-xl">User not found</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import CopyLink from "@/components/user/CopyLink";
import { fetchProfileData } from "@/lib/user";
import { notFound } from "next/navigation";
export async function generateMetadata({ params }: { params: { name: string } }) {
const data = await fetchProfileData(params.name);
if (!data) {
return {
description: "User not found",
robots: {
index: false,
follow: false,
nocache: true,
noarchive: true
}
};
}
return {
title: `${data.name} | chessu`,
description: `${data.name}'s profile`,
openGraph: {
title: `${data.name} | chessu`,
description: `${data.name}'s profile on chessu`,
url: `https://ches.su/user/${data.name}`,
siteName: "chessu",
locale: "en_US",
type: "website"
},
robots: {
index: true,
follow: false,
nocache: true
}
};
}
export default async function Profile({ params }: { params: { name: string } }) {
const data = await fetchProfileData(params.name);
if (!data) {
notFound();
}
return (
<div className="mt-8 flex w-full flex-col gap-8">
<div className="flex flex-wrap items-center justify-between gap-4 md:gap-8">
<h1 className="text-4xl font-bold">{data.name}</h1>
<div className="flex gap-6">
<div className="flex flex-col items-center">
<span className="text-sm">Wins</span>
<span className="text-xl font-bold">{data.wins}</span>
</div>
<div className="flex flex-col items-center">
<span className="text-sm">Draws</span>
<span className="text-xl font-bold">{data.draws}</span>
</div>
<div className="flex flex-col items-center">
<span className="text-sm">Losses</span>
<span className="text-xl font-bold">{data.losses}</span>
</div>
</div>
<CopyLink name={data.name as string} />
</div>
<div>
<h2 className="mb-1 text-lg font-bold">Recent games</h2>
<ul className="bg-base-300 flex h-[60vh] flex-col gap-1 overflow-y-scroll rounded-lg">
{data.recentGames.map((game) => (
<li
key={game.id}
className="border-base-100 flex flex-wrap items-center justify-between gap-8 border-b-2 p-3"
>
<div className="flex w-72 justify-between">
<div className="flex flex-col">
<span className="flex items-center gap-1 text-sm">
White
{game.winner === "white" && (
<span className="badge badge-xs badge-success">winner</span>
)}
</span>
<a
className={
"font-bold" +
(typeof game.white?.id === "number"
? " text-primary link-hover"
: " cursor-default")
}
href={
typeof game.white?.id === "number" && game.white?.id !== data.id
? `/user/${game.white?.name}`
: undefined
}
target="_blank"
rel="noopener noreferrer"
>
{game.white?.name}
</a>
</div>
<div className="flex flex-col items-end">
<span className="flex items-center gap-1 text-sm">
{game.winner === "black" && (
<span className="badge badge-xs badge-success">winner</span>
)}
Black
</span>
<a
className={
"font-bold" +
(typeof game.black?.id === "number"
? " text-primary link-hover"
: " cursor-default")
}
href={
typeof game.black?.id === "number" && game.black?.id !== data.id
? `/user/${game.black?.name}`
: undefined
}
target="_blank"
rel="noopener noreferrer"
>
{game.black?.name}
</a>
</div>
</div>
<div className="flex-grow text-end text-xs">{game.endReason}</div>
<div className="flex flex-grow flex-col text-end">
<span className="text-xs">
Started
<span className="font-bold">
{" "}
{new Date(game.startedAt as number).toLocaleString()}
</span>
</span>
<span className="text-xs">
Ended
<span className="font-bold">
{" "}
{new Date(game.endedAt as number).toLocaleString()}
</span>
</span>
</div>
<a
className="btn btn-ghost flex-grow"
href={`/archive/${game.id}`}
target="_blank"
rel="noopener noreferrer"
>
Review game
</a>
</li>
))}
</ul>
</div>
</div>
);
}

View File

@@ -6,7 +6,12 @@ export default function Footer() {
<div className="items-center"> <div className="items-center">
<p> <p>
&copy; 2023{" "} &copy; 2023{" "}
<a href="https://nize.ph" target="_blank" rel="noreferrer" className="link-hover"> <a
href="https://nize.ph"
target="_blank"
rel="noopener noreferrer"
className="link-hover"
>
nize nize
</a> </a>
</p> </p>
@@ -15,7 +20,7 @@ export default function Footer() {
<a <a
href="https://github.com/nizewn/chessu" href="https://github.com/nizewn/chessu"
target="_blank" target="_blank"
rel="noreferrer" rel="noopener noreferrer"
className="btn btn-ghost btn-sm gap-1 normal-case" className="btn btn-ghost btn-sm gap-1 normal-case"
> >
<IconBrandGithub className="inline-block" size={16} /> <IconBrandGithub className="inline-block" size={16} />

View File

@@ -2,7 +2,7 @@ import { IconUser } from "@tabler/icons-react";
import Link from "next/link"; import Link from "next/link";
import ThemeToggle from "./ThemeToggle"; import ThemeToggle from "./ThemeToggle";
export default function Navbar() { export default function Header() {
return ( return (
<header className="navbar mx-1 w-auto justify-center drop-shadow-sm md:mx-16 lg:mx-40"> <header className="navbar mx-1 w-auto justify-center drop-shadow-sm md:mx-16 lg:mx-40">
<div className="flex flex-1 items-center gap-1"> <div className="flex flex-1 items-center gap-1">
@@ -26,7 +26,7 @@ export default function Navbar() {
<a <a
href="https://github.com/users/nizewn/projects/2" href="https://github.com/users/nizewn/projects/2"
target="_blank" target="_blank"
rel="noreferrer" rel="noopener noreferrer"
className="link" className="link"
> >
here here

View File

@@ -0,0 +1,363 @@
"use client";
import { Game } from "@chessu/types";
import { Chessboard } from "react-chessboard";
import { useEffect, useReducer, useState, useRef } from "react";
import {
IconChevronLeft,
IconChevronRight,
IconCopy,
IconPlayerSkipBack,
IconPlayerSkipForward
} from "@tabler/icons-react";
import type { Square } from "chess.js";
import { Chess } from "chess.js";
import type { CustomSquares } from "@/types";
import { IconRotateClockwise2 } from "@tabler/icons-react";
export default function ArchivedGame({ game }: { game: Game }) {
const [boardWidth, setBoardWidth] = useState(480);
const moveListRef = useRef<HTMLDivElement>(null);
const [navFen, setNavFen] = useState<string | null>(null);
const [navIndex, setNavIndex] = useState<number | null>(null);
const [flipBoard, setFlipBoard] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
const [showPgn, setShowPgn] = useState(true);
const actualGame = new Chess();
actualGame.loadPgn(game.pgn as string);
const [customSquares, updateCustomSquares] = useReducer(
(squares: CustomSquares, action: Partial<CustomSquares>) => {
return { ...squares, ...action };
},
{
options: {},
lastMove: {},
rightClicked: {},
check: {}
}
);
function handleResize() {
if (window.innerWidth >= 1920) {
setBoardWidth(580);
} else if (window.innerWidth >= 1536) {
setBoardWidth(540);
} else if (window.innerWidth >= 768) {
setBoardWidth(480);
} else {
setBoardWidth(350);
}
}
// auto scroll for moves
useEffect(() => {
const activeMoveEl = document.getElementById("activeNavMove");
const moveList = moveListRef.current;
if (!activeMoveEl || !moveList) return;
moveList.scrollTop = activeMoveEl.offsetTop;
});
useEffect(() => {
handleResize();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
function copyLink() {
const text = `https://ches.su/archive/${game.id}`;
if ("clipboard" in navigator) {
navigator.clipboard.writeText(text);
} else {
document.execCommand("copy", true, text);
}
setCopiedLink(true);
setTimeout(() => {
setCopiedLink(false);
}, 5000);
}
function onSquareRightClick(square: Square) {
const colour = "rgba(0, 0, 255, 0.4)";
updateCustomSquares({
rightClicked: {
...customSquares.rightClicked,
[square]:
customSquares.rightClicked[square] &&
customSquares.rightClicked[square]?.backgroundColor === colour
? undefined
: { backgroundColor: colour }
}
});
}
function getMoveListHtml() {
const history = actualGame.history({ verbose: true });
const movePairs = history
.slice(history.length / 2)
.map((_, i) => history.slice((i *= 2), i + 2));
return movePairs.map((moves, i) => {
return (
<tr className="flex w-full items-center gap-1" key={i + 1}>
<td className="">{i + 1}.</td>
<td
className={
"btn btn-ghost btn-xs h-full w-2/5 font-normal normal-case" +
((history.indexOf(moves[0]) === history.length - 1 && navIndex === null) ||
navIndex === history.indexOf(moves[0])
? " btn-active pointer-events-none rounded-none"
: "")
}
id={
(history.indexOf(moves[0]) === history.length - 1 && navIndex === null) ||
navIndex === history.indexOf(moves[0])
? "activeNavMove"
: ""
}
onClick={() => navigateMove(history.indexOf(moves[0]))}
>
{moves[0].san}
</td>
{moves[1] && (
<td
className={
"btn btn-ghost btn-xs h-full w-2/5 font-normal normal-case" +
((history.indexOf(moves[1]) === history.length - 1 && navIndex === null) ||
navIndex === history.indexOf(moves[1])
? " btn-active pointer-events-none rounded-none"
: "")
}
id={
(history.indexOf(moves[1]) === history.length - 1 && navIndex === null) ||
navIndex === history.indexOf(moves[1])
? "activeNavMove"
: ""
}
onClick={() => navigateMove(history.indexOf(moves[1]))}
>
{moves[1].san}
</td>
)}
</tr>
);
});
}
function navigateMove(index: number | null | "prev") {
const history = actualGame.history({ verbose: true });
if (index === null || (index !== "prev" && index >= history.length - 1) || !history.length) {
// last move
setNavIndex(null);
setNavFen(null);
return;
}
if (index === "prev") {
index = history.length - 2;
} else if (index < 0) {
index = 0;
}
setNavIndex(index);
setNavFen(history[index].after);
}
function getNavMoveSquares() {
const history = actualGame.history({ verbose: true });
if (!history.length) return;
let index = navIndex ?? history.length - 1;
return {
[history[index].from]: { background: "rgba(255, 255, 0, 0.4)" },
[history[index].to]: { background: "rgba(255, 255, 0, 0.4)" }
};
}
function getPlayerHtml(side: "top" | "bottom") {
const blackHtml = (
<div className="flex w-full flex-col justify-center">
<a
className={
"font-bold" +
(typeof game.black?.id === "number" ? " text-primary link-hover" : " cursor-default")
}
href={typeof game.black?.id === "number" ? `/user/${game.black?.name}` : undefined}
target="_blank"
rel="noopener noreferrer"
>
{game.black?.name}
</a>
<span className="flex items-center gap-1 text-xs">black</span>
</div>
);
const whiteHtml = (
<div className="flex w-full flex-col justify-center">
<a
className={
"font-bold" +
(typeof game.white?.id === "number" ? " text-primary link-hover" : " cursor-default")
}
href={typeof game.white?.id === "number" ? `/user/${game.white?.name}` : undefined}
target="_blank"
rel="noopener noreferrer"
>
{game.white?.name}
</a>
<span className="flex items-center gap-1 text-xs">white</span>
</div>
);
if (flipBoard) {
return side === "top" ? whiteHtml : blackHtml;
} else {
return side === "top" ? blackHtml : whiteHtml;
}
}
return (
<div className="flex w-full flex-wrap justify-center gap-6 px-4 py-4 lg:gap-10 2xl:gap-16">
<div className="h-min">
<Chessboard
boardWidth={boardWidth}
customDarkSquareStyle={{ backgroundColor: "#4b7399" }}
customLightSquareStyle={{ backgroundColor: "#eae9d2" }}
position={navFen || actualGame.fen()}
boardOrientation={flipBoard ? "black" : "white"}
isDraggablePiece={() => false}
onSquareClick={() => updateCustomSquares({ rightClicked: {} })}
onSquareRightClick={onSquareRightClick}
customSquareStyles={{
...getNavMoveSquares(),
...customSquares.rightClicked
}}
/>
</div>
<div className="flex max-w-lg flex-1 flex-col items-center justify-center gap-4">
<div className="mb-auto flex w-full p-2">
<div className="flex flex-1 flex-col items-center justify-between">
{getPlayerHtml("top")}
<div className="my-auto flex w-full items-center">
<button className="btn btn-sm gap-1" onClick={() => setFlipBoard(!flipBoard)}>
<IconRotateClockwise2 size={18} />
Flip board
</button>
</div>
{getPlayerHtml("bottom")}
</div>
<div className="flex flex-1 flex-col gap-1">
<div className="mb-2 flex w-full flex-col items-end gap-1">
Archived link:
<div
className={
"dropdown dropdown-top dropdown-end" + (copiedLink ? " dropdown-open" : "")
}
>
<label
tabIndex={0}
className="badge badge-md bg-base-300 text-base-content h-8 gap-1 font-mono text-xs sm:h-5 sm:text-sm"
onClick={copyLink}
>
<IconCopy size={16} />
ches.su/archive/{game.id}
</label>
<div tabIndex={0} className="dropdown-content badge badge-neutral text-xs shadow">
copied to clipboard
</div>
</div>
</div>
<div className="h-32 w-full overflow-y-scroll" ref={moveListRef}>
<table className="table-compact table w-full">
<tbody>{getMoveListHtml()}</tbody>
</table>
</div>
<div className="flex h-4 w-full">
<button
className={
"btn btn-sm flex-grow rounded-r-none" +
(navIndex === 0 || actualGame.history().length <= 1 ? " btn-disabled" : "")
}
onClick={() => navigateMove(0)}
>
<IconPlayerSkipBack size={18} />
</button>
<button
className={
"btn btn-sm flex-grow rounded-none" +
(navIndex === 0 || actualGame.history().length <= 1 ? " btn-disabled" : "")
}
onClick={() => navigateMove(navIndex === null ? "prev" : navIndex - 1)}
>
<IconChevronLeft size={18} />
</button>
<button
className={
"btn btn-sm flex-grow rounded-none" + (navIndex === null ? " btn-disabled" : "")
}
onClick={() => navigateMove(navIndex === null ? null : navIndex + 1)}
>
<IconChevronRight size={18} />
</button>
<button
className={
"btn btn-sm flex-grow rounded-l-none" + (navIndex === null ? " btn-disabled" : "")
}
onClick={() => navigateMove(null)}
>
<IconPlayerSkipForward size={18} />
</button>
</div>
</div>
</div>
<div className="relative h-60 w-full min-w-fit">
<div className="bg-base-300 flex h-full w-full min-w-[64px] flex-col rounded-lg p-4 shadow-sm">
{game.endReason === "abandoned"
? game.winner === "draw"
? "The game ended in a draw due to abandonment."
: `The game was won by ${game.winner} due to abandonment.`
: game.winner === "draw"
? "The game ended in a draw."
: `The game was won by checkmate (${game.winner}).`}
<div className="mt-2 flex items-center justify-end">
<button
className={
"btn btn-sm rounded-b-none rounded-tr-none normal-case" +
(showPgn ? " btn-primary" : "")
}
onClick={() => setShowPgn(true)}
>
Final PGN
</button>
<div className="tooltip" data-tip="Current board position is shown.">
<button
className={
"btn btn-sm rounded-b-none rounded-tl-none normal-case" +
(showPgn ? "" : " btn-primary")
}
onClick={() => setShowPgn(false)}
>
Current FEN
</button>
</div>
</div>
<textarea
className="textarea h-full rounded-tr-none"
readOnly
value={showPgn ? game.pgn : navFen || actualGame.fen()}
onFocus={(e) => e.target.select()}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,21 +1,37 @@
"use client"; "use client";
import type { FormEvent } from "react";
import { useRef, useState, useContext } from "react";
import { SessionContext } from "@/context/session"; import { SessionContext } from "@/context/session";
import { setGuestSession } from "@/lib/auth"; import { login, logout, register, setGuestSession } from "@/lib/auth";
import type { FormEvent } from "react";
import { useContext, useEffect, useRef, useState } from "react";
import Link from "next/link";
// TODO: add login and register views import Guest from "./Guest";
import Login from "./Login";
import Register from "./Register";
import { IconSettings2, IconUserCircle } from "@tabler/icons-react";
export default function AuthModal() { export default function AuthModal() {
const session = useContext(SessionContext); const session = useContext(SessionContext);
const [activeTab, setActiveTab] = useState<"guest" | "login" | "register">("guest");
const [serverMessage, setServerMessage] = useState<string | null>(null);
const [buttonLoading, setButtonLoading] = useState(false); const [buttonLoading, setButtonLoading] = useState(false);
const modalToggleRef = useRef<HTMLInputElement>(null); const modalToggleRef = useRef<HTMLInputElement>(null);
async function updateGuestName(e: FormEvent<HTMLFormElement>) { async function clickLogout() {
if (serverMessage) {
setServerMessage(null);
}
setActiveTab("login");
await logout();
session?.setUser(null);
}
async function submitAuth(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
const target = e.target as HTMLFormElement; const target = e.target as HTMLFormElement;
if (activeTab === "guest") {
const guestName = target.elements.namedItem("guestName") as HTMLInputElement; const guestName = target.elements.namedItem("guestName") as HTMLInputElement;
if (!guestName || !guestName.value) return; if (!guestName || !guestName.value) return;
@@ -28,9 +44,58 @@ export default function AuthModal() {
} }
} }
guestName.value = ""; guestName.value = "";
} else if (activeTab === "login") {
const loginName = target.elements.namedItem("loginName") as HTMLInputElement;
const loginPassword = target.elements.namedItem("loginPassword") as HTMLInputElement;
if (!loginName || !loginName.value || !loginPassword || !loginPassword.value) return;
setButtonLoading(true);
const user = await login(loginName.value, loginPassword.value);
if (typeof user === "string") {
setServerMessage(user);
} else if (user?.id) {
session?.setUser(user);
if (serverMessage) {
setServerMessage(null);
}
if (modalToggleRef.current?.checked) {
modalToggleRef.current.checked = false;
}
}
} else if (activeTab === "register") {
const registerName = target.elements.namedItem("registerName") as HTMLInputElement;
const registerEmail = target.elements.namedItem("registerEmail") as HTMLInputElement;
const registerPassword = target.elements.namedItem("registerPassword") as HTMLInputElement;
if (!registerName || !registerName.value || !registerPassword || !registerPassword.value) {
return;
}
setButtonLoading(true);
const user = await register(
registerName.value,
registerPassword.value,
registerEmail.value || undefined
);
if (typeof user === "string") {
setServerMessage(user);
} else if (user?.id) {
session?.setUser(user);
if (serverMessage) {
setServerMessage(null);
}
if (modalToggleRef.current?.checked) {
modalToggleRef.current.checked = false;
}
}
}
setButtonLoading(false); setButtonLoading(false);
} }
useEffect(() => {
setServerMessage(null);
}, [activeTab]);
return ( return (
<> <>
<input type="checkbox" id="auth-modal" className="modal-toggle" ref={modalToggleRef} /> <input type="checkbox" id="auth-modal" className="modal-toggle" ref={modalToggleRef} />
@@ -39,51 +104,103 @@ export default function AuthModal() {
htmlFor="auth-modal" htmlFor="auth-modal"
className={"modal" + (session?.user === null ? " modal-open" : "")} className={"modal" + (session?.user === null ? " modal-open" : "")}
> >
<label className="modal-box flex flex-col gap-4 pt-2"> <label className="modal-box flex max-w-sm flex-col gap-4 pt-2">
<div className="flex w-full gap-2"> {session?.user?.id && typeof session.user.id === "number" ? (
<div className="tabs flex-grow"> <div className="flex flex-col gap-2 pt-2">
<span className="tab tab-bordered tab-active flex-grow">Guest</span> <div className="flex w-full justify-between">
<span className="tab tab-bordered tab-disabled flex-grow" aria-disabled> <div>
Logged in as <b>{session.user.name}</b>
</div>
<a className="link" onClick={clickLogout}>
Logout
</a>
</div>
<div className="flex w-full flex-col">
<Link
className="btn btn-ghost gap-1 normal-case"
href={`/user/${session.user.name}`}
onClick={() => ((modalToggleRef.current as HTMLInputElement).checked = false)}
>
<IconUserCircle /> View profile
</Link>
<Link
className="btn btn-ghost gap-1 normal-case"
href="/settings"
onClick={() => ((modalToggleRef.current as HTMLInputElement).checked = false)}
>
<IconSettings2 /> Account settings
</Link>
</div>
<div className="modal-action">
<label htmlFor="auth-modal" className="btn">
Close
</label>
</div>
</div>
) : (
<>
<div className="tabs flex-nowap w-full">
<span
onClick={() => {
setActiveTab("guest");
}}
className={
"tab tab-bordered tab-border-2 flex-grow rounded-tl-lg" +
(activeTab === "guest"
? " tab-active text-base-content"
: " text-base-content border-opacity-10 hover:border-opacity-30")
}
>
Guest
</span>
<span
onClick={() => setActiveTab("login")}
className={
"tab tab-bordered tab-border-2 flex-grow rounded-tl-lg" +
(activeTab === "login"
? " tab-active"
: " text-base-content border-opacity-10 hover:border-opacity-30")
}
>
Login Login
</span> </span>
<span className="tab tab-bordered tab-disabled flex-grow" aria-disabled> <span
onClick={() => setActiveTab("register")}
className={
"tab tab-bordered tab-border-2 flex-grow rounded-tl-lg" +
(activeTab === "register"
? " tab-active"
: " text-base-content border-opacity-10 hover:border-opacity-30")
}
>
Register Register
</span> </span>
</div> </div>
<form className="flex flex-col px-2" onSubmit={submitAuth}>
{activeTab === "guest" && (
<Guest currentName={session?.user?.name || "unknown user"} />
)}
{activeTab === "login" && <Login />}
{activeTab === "register" && <Register />}
{serverMessage && <div className="text-error mt-2">{serverMessage}</div>}
<div className="modal-action items-center">
{session?.user !== null && ( {session?.user !== null && (
<label htmlFor="auth-modal" className="btn btn-sm btn-circle btn-ghost"> <label htmlFor="auth-modal" className="btn btn-ghost">
Close
</label> </label>
)} )}
</div>
<form className="flex flex-col" onSubmit={updateGuestName}>
<div className="form-control">
<label className="label">
<span className="label-text">Hello, {session?.user?.name || "unknown guest"}!</span>
</label>
<label className="input-group">
<span>Name</span>
<input
type="text"
pattern="[A-Za-z0-9]+"
title="Alphanumeric characters only"
id="guestName"
name="guestName"
placeholder="Enter name here..."
className="input input-bordered w-full"
maxLength={16}
minLength={2}
required
/>
</label>
</div>
<div className="modal-action">
<button className={"btn" + (buttonLoading ? " loading" : "")} type="submit"> <button className={"btn" + (buttonLoading ? " loading" : "")} type="submit">
Update {activeTab === "guest" && "Confirm"}
{activeTab === "login" && "Login"}
{activeTab === "register" && "Register"}
</button> </button>
</div> </div>
</form> </form>
</>
)}
</label> </label>
</label> </label>
</> </>

View File

@@ -0,0 +1,26 @@
"use client";
export default function Guest({ currentName }: { currentName: string }) {
return (
<div className="form-control">
<label className="label">
<span className="label-text">Hello, {currentName}!</span>
</label>
<label className="input-group">
<span>Name</span>
<input
type="text"
pattern="[A-Za-z0-9]+"
title="Alphanumeric characters only"
id="guestName"
name="guestName"
placeholder="Enter name here..."
className="input input-bordered w-full"
maxLength={16}
minLength={2}
required
/>
</label>
</div>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
export default function Login() {
return (
<div className="form-control">
<label htmlFor="loginName" className="label">
<span className="label-text">Username or email</span>
</label>
<input
type="text"
pattern="[A-Za-z0-9]+"
title="Alphanumeric characters only"
id="loginName"
name="loginName"
placeholder="username"
className="input input-bordered"
maxLength={16}
minLength={2}
required
/>
<label htmlFor="loginPassword" className="label">
<span className="label-text">Password</span>
</label>
<input
type="password"
id="loginPassword"
name="loginPassword"
placeholder="password"
className="input input-bordered"
minLength={3}
required
/>
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
export default function Register() {
return (
<div className="form-control">
<label htmlFor="registerName" className="label">
<span className="label-text">Username</span>
</label>
<input
type="text"
pattern="[A-Za-z0-9]+"
title="Alphanumeric characters only"
id="registerName"
name="registerName"
placeholder="username"
className="input input-bordered"
maxLength={16}
minLength={2}
required
/>
<label htmlFor="registerEmail" className="label">
<span className="label-text">Email (optional)</span>
</label>
<input
type="email"
id="registerEmail"
name="registerEmail"
placeholder="email"
className="input input-bordered"
minLength={4}
/>
<label htmlFor="registerPassword" className="label">
<span className="label-text">Password</span>
</label>
<input
type="password"
id="registerPassword"
name="registerPassword"
placeholder="password"
className="input input-bordered"
minLength={3}
required
/>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import type { FormEvent, KeyboardEvent } from "react"; import type { FormEvent, KeyboardEvent } from "react";
import Link from "next/link";
import { SessionContext } from "@/context/session"; import { SessionContext } from "@/context/session";
import { useContext, useEffect, useReducer, useRef, useState } from "react"; import { useContext, useEffect, useReducer, useRef, useState } from "react";
@@ -65,6 +66,36 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
const chatListRef = useRef<HTMLUListElement>(null); const chatListRef = useRef<HTMLUListElement>(null);
const moveListRef = useRef<HTMLDivElement>(null); const moveListRef = useRef<HTMLDivElement>(null);
const [abandonSeconds, setAbandonSeconds] = useState(60);
useEffect(() => {
if (
lobby.side === "s" ||
lobby.endReason ||
lobby.winner ||
!lobby.pgn ||
!lobby.white ||
!lobby.black ||
(lobby.white.id !== session?.user?.id && lobby.black.id !== session?.user?.id)
)
return;
let interval: number;
if (!lobby.white?.connected || !lobby.black?.connected) {
setAbandonSeconds(60);
interval = Number(
setInterval(() => {
if (abandonSeconds === 0 || (lobby.white?.connected && lobby.black?.connected)) {
clearInterval(interval);
return;
}
setAbandonSeconds((s) => s - 1);
}, 1000)
);
}
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lobby.black, lobby.white, lobby.black?.disconnectedOn, lobby.white?.disconnectedOn]);
useEffect(() => { useEffect(() => {
if (!session?.user || !session.user?.id) return; if (!session?.user || !session.user?.id) return;
socket.connect(); socket.connect();
@@ -118,7 +149,7 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
function updateTurnTitle() { function updateTurnTitle() {
if (lobby.side === "s" || !lobby.white?.id || !lobby.black?.id) return; if (lobby.side === "s" || !lobby.white?.id || !lobby.black?.id) return;
if (lobby.side === lobby.actualGame.turn()) { if (lobby.side === lobby.actualGame.turn() && !lobby.actualGame.isGameOver()) {
document.title = "(your turn) chessu"; document.title = "(your turn) chessu";
} else { } else {
document.title = "chessu"; document.title = "chessu";
@@ -216,11 +247,11 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
} }
function isDraggablePiece({ piece }: { piece: string }) { function isDraggablePiece({ piece }: { piece: string }) {
return piece.startsWith(lobby.side); return piece.startsWith(lobby.side) && !lobby.endReason && !lobby.winner;
} }
function onDrop(sourceSquare: Square, targetSquare: Square) { function onDrop(sourceSquare: Square, targetSquare: Square) {
if (lobby.side === "s" || navFen) return false; if (lobby.side === "s" || navFen || lobby.endReason || lobby.winner) return false;
// premove // premove
if (lobby.side !== lobby.actualGame.turn()) return true; if (lobby.side !== lobby.actualGame.turn()) return true;
@@ -267,7 +298,7 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
} }
function onPieceDragBegin(_piece: string, sourceSquare: Square) { function onPieceDragBegin(_piece: string, sourceSquare: Square) {
if (lobby.side !== lobby.actualGame.turn() || navFen) return; if (lobby.side !== lobby.actualGame.turn() || navFen || lobby.endReason || lobby.winner) return;
getMoveOptions(sourceSquare); getMoveOptions(sourceSquare);
} }
@@ -278,7 +309,7 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
function onSquareClick(square: Square) { function onSquareClick(square: Square) {
updateCustomSquares({ rightClicked: {} }); updateCustomSquares({ rightClicked: {} });
if (lobby.side !== lobby.actualGame.turn() || navFen) return; if (lobby.side !== lobby.actualGame.turn() || navFen || lobby.endReason || lobby.winner) return;
function resetFirstMove(square: Square) { function resetFirstMove(square: Square) {
setMoveFrom(square); setMoveFrom(square);
@@ -329,9 +360,17 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
function getPlayerHtml(side: "top" | "bottom") { function getPlayerHtml(side: "top" | "bottom") {
const blackHtml = ( const blackHtml = (
<div className="flex w-full flex-col justify-center"> <div className="flex w-full flex-col justify-center">
<span className={lobby.black?.name ? "font-bold" : ""}> <a
className={
(lobby.black?.name ? "font-bold" : "") +
(typeof lobby.black?.id === "number" ? " text-primary link-hover" : " cursor-default")
}
href={typeof lobby.black?.id === "number" ? `/user/${lobby.black?.name}` : undefined}
target="_blank"
rel="noopener noreferrer"
>
{lobby.black?.name || "(no one)"} {lobby.black?.name || "(no one)"}
</span> </a>
<span className="flex items-center gap-1 text-xs"> <span className="flex items-center gap-1 text-xs">
black black
{lobby.black?.connected === false && ( {lobby.black?.connected === false && (
@@ -342,9 +381,17 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
); );
const whiteHtml = ( const whiteHtml = (
<div className="flex w-full flex-col justify-center"> <div className="flex w-full flex-col justify-center">
<span className={lobby.white?.name ? "font-bold" : ""}> <a
className={
(lobby.white?.name ? "font-bold" : "") +
(typeof lobby.white?.id === "number" ? " text-primary link-hover" : " cursor-default")
}
href={typeof lobby.white?.id === "number" ? `/user/${lobby.white?.name}` : undefined}
target="_blank"
rel="noopener noreferrer"
>
{lobby.white?.name || "(no one)"} {lobby.white?.name || "(no one)"}
</span> </a>
<span className="flex items-center gap-1 text-xs"> <span className="flex items-center gap-1 text-xs">
white white
{lobby.white?.connected === false && ( {lobby.white?.connected === false && (
@@ -362,7 +409,7 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
} }
function copyInvite() { function copyInvite() {
const text = `https://ches.su/${initialLobby.code}`; const text = `https://ches.su/${lobby.endReason ? `archive/${lobby.id}` : initialLobby.code}`;
if ("clipboard" in navigator) { if ("clipboard" in navigator) {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
} else { } else {
@@ -461,13 +508,27 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
}; };
} }
function claimAbandoned(type: "win" | "draw") {
if (
lobby.side === "s" ||
lobby.endReason ||
lobby.winner ||
!lobby.pgn ||
abandonSeconds > 0 ||
(lobby.black?.connected && lobby.white?.connected)
) {
return;
}
socket.emit("claimAbandoned", type);
}
return ( return (
<div className="flex w-full flex-wrap justify-center gap-6 px-4 py-4 lg:gap-10 2xl:gap-16"> <div className="flex w-full flex-wrap justify-center gap-6 px-4 py-4 lg:gap-10 2xl:gap-16">
<div className="relative h-min"> <div className="relative h-min">
{/* overlay */} {/* overlay */}
{(!lobby.white?.id || !lobby.black?.id) && ( {(!lobby.white?.id || !lobby.black?.id) && (
<div className="absolute top-0 right-0 bottom-0 z-10 flex h-full w-full items-center justify-center bg-black bg-opacity-70"> <div className="absolute bottom-0 right-0 top-0 z-10 flex h-full w-full items-center justify-center bg-black bg-opacity-70">
<div className="bg-base-200 flex w-full items-center justify-center gap-4 py-4 px-2"> <div className="bg-base-200 flex w-full items-center justify-center gap-4 px-2 py-4">
Waiting for opponent. Waiting for opponent.
{session?.user?.id !== lobby.white?.id && session?.user?.id !== lobby.black?.id && ( {session?.user?.id !== lobby.white?.id && session?.user?.id !== lobby.black?.id && (
<button <button
@@ -514,7 +575,7 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
<div className="flex flex-1 flex-col gap-1"> <div className="flex flex-1 flex-col gap-1">
<div className="mb-2 flex w-full flex-col items-end gap-1"> <div className="mb-2 flex w-full flex-col items-end gap-1">
Invite friends: {lobby.endReason ? "Archived link:" : "Invite friends:"}
<div <div
className={ className={
"dropdown dropdown-top dropdown-end" + (copiedLink ? " dropdown-open" : "") "dropdown dropdown-top dropdown-end" + (copiedLink ? " dropdown-open" : "")
@@ -526,7 +587,7 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
onClick={copyInvite} onClick={copyInvite}
> >
<IconCopy size={16} /> <IconCopy size={16} />
ches.su/{initialLobby.code} ches.su/{lobby.endReason ? `archive/${lobby.id}` : initialLobby.code}
</label> </label>
<div tabIndex={0} className="dropdown-content badge badge-neutral text-xs shadow"> <div tabIndex={0} className="dropdown-content badge badge-neutral text-xs shadow">
copied to clipboard copied to clipboard
@@ -577,7 +638,62 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
</div> </div>
</div> </div>
<div className="h-60 w-full min-w-fit"> <div className="relative h-60 w-full min-w-fit">
{(lobby.endReason ||
(lobby.pgn &&
lobby.white &&
session?.user?.id === lobby.white?.id &&
lobby.black &&
!lobby.black?.connected) ||
(lobby.pgn &&
lobby.black &&
session?.user?.id === lobby.black?.id &&
lobby.white &&
!lobby.white?.connected)) && (
<div className="bg-neutral absolute w-full rounded-t-lg bg-opacity-95 p-2">
{lobby.endReason ? (
<div>
{lobby.endReason === "abandoned"
? lobby.winner === "draw"
? `The game ended in a draw due to abandonment.`
: `The game was won by ${lobby.winner} due to abandonment.`
: lobby.winner === "draw"
? "The game ended in a draw."
: `The game was won by checkmate (${lobby.winner}).`}{" "}
<br />
You can review the archived game at{" "}
<a
className="link"
href={`/archive/${lobby.id}`}
target="_blank"
rel="noopener noreferrer"
>
ches.su/archive/{lobby.id}
</a>
.
</div>
) : abandonSeconds > 0 ? (
`Your opponent has disconnected. You can claim the win or draw in ${abandonSeconds} second${
abandonSeconds > 1 ? "s" : ""
}.`
) : (
<div className="flex flex-wrap items-center justify-center gap-2">
<span>Your opponent has disconnected.</span>
<div className="flex items-center gap-1">
<button
onClick={() => claimAbandoned("win")}
className="btn btn-sm btn-primary"
>
Claim win
</button>
<button onClick={() => claimAbandoned("draw")} className="btn btn-sm btn-ghost">
Draw
</button>
</div>
</div>
)}
</div>
)}
<div className="bg-base-300 flex h-full w-full min-w-[64px] flex-col rounded-lg p-4 shadow-sm"> <div className="bg-base-300 flex h-full w-full min-w-[64px] flex-col rounded-lg p-4 shadow-sm">
<ul <ul
className="mb-4 flex h-full flex-col gap-1 overflow-y-scroll break-words" className="mb-4 flex h-full flex-col gap-1 overflow-y-scroll break-words"
@@ -596,7 +712,22 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
<span> <span>
{m.author.id && ( {m.author.id && (
<span> <span>
<span className="font-bold">{m.author.name}</span>:{" "} <a
className={
"font-bold" +
(typeof m.author.id === "number"
? " text-primary link-hover"
: " cursor-default")
}
href={
typeof m.author.id === "number" ? `/user/${m.author.name}` : undefined
}
target="_blank"
rel="noopener noreferrer"
>
{m.author.name}
</a>
:{" "}
</span> </span>
)} )}
<span>{m.message}</span> <span>{m.message}</span>

View File

@@ -58,17 +58,25 @@ export function initSocket(
({ ({
reason, reason,
winnerName, winnerName,
winnerSide winnerSide,
id
}: { }: {
reason: string; reason: Game["endReason"];
winnerName?: string; winnerName?: string;
winnerSide?: string; winnerSide?: "white" | "black" | "draw";
id: number;
}) => { }) => {
const m = { const m = {
author: { name: "server" } author: { name: "server" }
} as Message; } as Message;
if (reason === "checkmate") { if (reason === "abandoned") {
if (!winnerSide) {
m.message = `${winnerName} has claimed a draw due to abandonment.`;
} else {
m.message = `${winnerName} (${winnerSide}) has claimed the win due to abandonment.`;
}
} else if (reason === "checkmate") {
m.message = `${winnerName} (${winnerSide}) has won by checkmate.`; m.message = `${winnerName} (${winnerSide}) has won by checkmate.`;
} else { } else {
let message = "The game has ended in a draw"; let message = "The game has ended in a draw";
@@ -81,6 +89,10 @@ export function initSocket(
} }
m.message = message.concat("."); m.message = message.concat(".");
} }
actions.updateLobby({
type: "updateLobby",
payload: { endReason: reason, winner: winnerSide || "draw", id }
});
actions.addMessage(m); actions.addMessage(m);
} }
); );

View File

@@ -3,7 +3,7 @@
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { useState, useContext } from "react"; import { useState, useContext } from "react";
import { SessionContext } from "@/context/session"; import { SessionContext } from "@/context/session";
import { getGame } from "@/lib/game"; import { fetchActiveGame } from "@/lib/game";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function JoinGame() { export default function JoinGame() {
@@ -31,7 +31,7 @@ export default function JoinGame() {
code = new URL(code).pathname.split("/")[1]; code = new URL(code).pathname.split("/")[1];
} }
const game = await getGame(code); const game = await fetchActiveGame(code);
if (game && game.code) { if (game && game.code) {
router.push(`/${game.code}`); router.push(`/${game.code}`);
@@ -58,7 +58,7 @@ export default function JoinGame() {
/> />
<button <button
className={ className={
"btn btn-square" + "btn" +
(buttonLoading ? " loading" : "") + (buttonLoading ? " loading" : "") +
(!session?.user?.id ? " btn-disabled text-base-content" : "") (!session?.user?.id ? " btn-disabled text-base-content" : "")
} }

View File

@@ -1,9 +1,9 @@
import { getPublicGames } from "@/lib/game"; import { fetchPublicGames } from "@/lib/game";
import JoinButton from "./JoinButton"; import JoinButton from "./JoinButton";
import RefreshButton from "./RefreshButton"; import RefreshButton from "./RefreshButton";
export default async function PublicGames() { export default async function PublicGames() {
const games = await getPublicGames(); const games = await fetchPublicGames();
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
@@ -24,8 +24,18 @@ export default async function PublicGames() {
{games && games.length > 0 ? ( {games && games.length > 0 ? (
games.map((game) => ( games.map((game) => (
<tr key={game.code} className="group"> <tr key={game.code} className="group">
<td>{game.host?.name}</td> <td className={typeof game.host?.id === "number" ? "text-primary" : ""}>
<td> {game.host?.name}
</td>
<td
className={
typeof (game.host?.id === game.white?.id
? game.black?.id
: game.white?.id) === "number"
? "text-primary"
: ""
}
>
{(game.host?.id === game.white?.id ? game.black?.name : game.white?.name) || ""} {(game.host?.id === game.white?.id ? game.black?.name : game.white?.name) || ""}
</td> </td>
<th> <th>

View File

@@ -17,7 +17,7 @@ export default function RefreshButton() {
return ( return (
<button <button
aria-label="Refresh public games" aria-label="Refresh public games"
className={"btn btn-sm btn-ghost" + (isLoading ? " loading" : "")} className={"btn btn-sm btn-square btn-ghost" + (isLoading ? " loading" : "")}
onClick={handleRefresh} onClick={handleRefresh}
> >
<IconRefresh size={16} /> <IconRefresh size={16} />

View File

@@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
import { IconCopy } from "@tabler/icons-react";
export default function CopyLink({ name }: { name: string }) {
const [copiedLink, setCopiedLink] = useState(false);
function copyLink() {
const text = `https://ches.su/user/${name}`;
if ("clipboard" in navigator) {
navigator.clipboard.writeText(text);
} else {
document.execCommand("copy", true, text);
}
setCopiedLink(true);
setTimeout(() => {
setCopiedLink(false);
}, 5000);
}
return (
<div className={"dropdown dropdown-top dropdown-end" + (copiedLink ? " dropdown-open" : "")}>
<label
tabIndex={0}
className="badge badge-md bg-base-300 text-base-content h-8 gap-1 font-mono text-xs sm:h-5 sm:text-sm"
onClick={copyLink}
>
<IconCopy size={16} />
ches.su/user/{name}
</label>
<div tabIndex={0} className="dropdown-content badge badge-neutral text-xs shadow">
copied to clipboard
</div>
</div>
);
}

View File

@@ -11,9 +11,7 @@ export const fetchSession = async () => {
const user: User = await res.json(); const user: User = await res.json();
return user; return user;
} }
} catch (err) { } catch (err) {}
console.error(err);
}
}; };
export const setGuestSession = async (name: string) => { export const setGuestSession = async (name: string) => {
@@ -34,3 +32,84 @@ export const setGuestSession = async (name: string) => {
console.error(err); console.error(err);
} }
}; };
export const register = async (name: string, password: string, email?: string) => {
try {
const res = await fetch(`${API_URL}/v1/auth/register`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ name, password, email })
});
if (res.status === 201) {
const user: User = await res.json();
return user;
} else if (res.status === 409) {
const { message } = await res.json();
return message as string;
}
} catch (err) {
console.error(err);
}
};
export const login = async (name: string, password: string) => {
try {
const res = await fetch(`${API_URL}/v1/auth/login`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ name, password })
});
if (res.status === 200) {
const user: User = await res.json();
return user;
} else if (res.status === 404 || res.status === 401) {
const { message } = await res.json();
return message as string;
}
} catch (err) {
console.error(err);
}
};
export const logout = async () => {
try {
const res = await fetch(`${API_URL}/v1/auth/logout`, {
method: "POST",
credentials: "include"
});
if (res.status === 204) {
return true;
}
} catch (err) {
console.error(err);
}
};
export const updateUser = async (name?: string, email?: string, password?: string) => {
try {
if (!name && !email && !password) return;
const res = await fetch(`${API_URL}/v1/auth/`, {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ name, email, password })
});
if (res.status === 200) {
const user: User = await res.json();
return user;
} else if (res.status === 409) {
const { message } = await res.json();
return message as string;
}
} catch (err) {
console.error(err);
}
};

View File

@@ -22,7 +22,7 @@ export const createGame = async (side: string, unlisted: boolean) => {
} }
}; };
export const getGame = async (code: string) => { export const fetchActiveGame = async (code: string) => {
try { try {
const res = await fetch(`${API_URL}/v1/games/${code}`, { cache: "no-store" }); const res = await fetch(`${API_URL}/v1/games/${code}`, { cache: "no-store" });
@@ -35,7 +35,7 @@ export const getGame = async (code: string) => {
} }
}; };
export const getPublicGames = async () => { export const fetchPublicGames = async () => {
try { try {
const res = await fetch(`${API_URL}/v1/games`, { cache: "no-store" }); const res = await fetch(`${API_URL}/v1/games`, { cache: "no-store" });
@@ -47,3 +47,30 @@ export const getPublicGames = async () => {
console.error(err); console.error(err);
} }
}; };
export const fetchArchivedGame = async ({ id, userid }: { id?: number; userid?: number }) => {
let url = `${API_URL}/v1/games?`;
if (id) {
url += `id=${id}`;
} else {
url += `userid=${userid}`;
}
try {
// TODO: handle caching more efficiently
const res = await fetch(url, {
next: { revalidate: 20 }
});
if (res && res.status === 200) {
if (id) {
const game: Game = await res.json();
if (game.id) return game;
} else {
const games: Game[] = await res.json();
if (games.length && games[0].id) return games;
}
}
} catch (err) {
console.error(err);
}
};

18
client/src/lib/user.ts Normal file
View File

@@ -0,0 +1,18 @@
import { API_URL } from "@/config";
import type { User, Game } from "@chessu/types";
export const fetchProfileData = async (name: string) => {
try {
// TODO: handle caching more efficiently?
const res = await fetch(`${API_URL}/v1/users/${name}`, {
next: { revalidate: 10 }
});
if (res && res.status === 200) {
const data: User & { recentGames: Game[] } = await res.json();
return data;
}
} catch (err) {
console.error(err);
}
};

View File

@@ -1,14 +0,0 @@
import { useRouter } from "next/router";
import { useEffect } from "react";
// Temporary. Move to app/ directory when it's supported
export default function NotFound() {
const router = useRouter();
useEffect(() => {
router.replace("/");
});
return null;
}

View File

@@ -15,12 +15,12 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^7.6.0", "concurrently": "^8.0.1",
"eslint": "^8.36.0", "eslint": "^8.37.0",
"eslint-config-next": "13.2.4", "eslint-config-next": "13.3.0",
"eslint-config-prettier": "^8.7.0", "eslint-config-prettier": "^8.8.0",
"prettier": "^2.8.5", "prettier": "^2.8.7",
"prettier-plugin-tailwindcss": "^0.2.5" "prettier-plugin-tailwindcss": "^0.2.7"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"

View File

@@ -9,13 +9,14 @@
"dev": "node --loader ts-node/esm --watch src/server.ts" "dev": "node --loader ts-node/esm --watch src/server.ts"
}, },
"dependencies": { "dependencies": {
"argon2": "^0.30.3",
"chess.js": "^1.0.0-beta.4", "chess.js": "^1.0.0-beta.4",
"connect-pg-simple": "^8.0.0", "connect-pg-simple": "^8.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"nanoid": "^4.0.1", "nanoid": "^4.0.2",
"pg": "^8.10.0", "pg": "^8.10.0",
"socket.io": "^4.6.1", "socket.io": "^4.6.1",
"xss": "^1.0.14" "xss": "^1.0.14"
@@ -25,10 +26,10 @@
"@types/connect-pg-simple": "^7.0.0", "@types/connect-pg-simple": "^7.0.0",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/express-session": "^1.17.7", "@types/express-session": "^1.17.7",
"@types/node": "^18.15.3", "@types/node": "^18.15.11",
"@types/pg": "^8.6.6", "@types/pg": "^8.6.6",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.0.2" "typescript": "^5.0.3"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"

View File

@@ -1,9 +1,11 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import type { User } from "@chessu/types"; import type { User } from "@chessu/types";
import xss from "xss"; import xss from "xss";
import { hash, verify } from "argon2";
import { activeGames } from "../db/models/game.model.js"; import { activeGames } from "../db/models/game.model.js";
import { io } from "../server.js"; import { io } from "../server.js";
import UserModel from "../db/models/user.model.js";
export const getCurrentSession = async (req: Request, res: Response) => { export const getCurrentSession = async (req: Request, res: Response) => {
try { try {
@@ -20,6 +22,10 @@ export const getCurrentSession = async (req: Request, res: Response) => {
export const guestSession = async (req: Request, res: Response) => { export const guestSession = async (req: Request, res: Response) => {
try { try {
if (req.session.user?.id && typeof req.session.user.id === "number") {
res.status(403).end();
return;
}
const name = xss(req.body.name); const name = xss(req.body.name);
const pattern = /^[A-Za-z0-9]+$/; const pattern = /^[A-Za-z0-9]+$/;
@@ -82,3 +88,228 @@ export const logoutSession = async (req: Request, res: Response) => {
res.status(500).end(); res.status(500).end();
} }
}; };
export const registerUser = async (req: Request, res: Response) => {
try {
if (req.session.user?.id && typeof req.session.user.id === "number") {
res.status(403).end();
return;
}
const name = xss(req.body.name);
const email = xss(req.body.email);
const password = await hash(req.body.password);
const pattern = /^[A-Za-z0-9]+$/;
if (!pattern.test(name)) {
res.status(400).end();
return;
}
const duplicateUsers = await UserModel.findByNameEmail({ name, email });
if (duplicateUsers && duplicateUsers.length) {
const dupl = duplicateUsers[0].name === name ? "Username" : "Email";
res.status(409).json({ message: `${dupl} is already in use.` });
return;
}
const newUser = await UserModel.create({ name, email }, password);
if (!newUser) {
throw new Error("Failed to create user");
}
const publicUser = {
id: newUser.id,
name: newUser.name
};
if (req.session.user?.id && typeof req.session.user.id === "string") {
const game = activeGames.find(
(g) =>
g.white?.id === req.session.user.id ||
g.black?.id === req.session.user.id ||
g.observers?.find((o) => o.id === req.session.user.id)
);
if (game) {
if (game.host?.id === req.session.user.id) {
game.host = publicUser;
}
if (game.white && game.white?.id === req.session.user.id) {
game.white = publicUser;
} else if (game.black && game.black?.id === req.session.user.id) {
game.black = publicUser;
} else {
const observer = game.observers?.find((o) => o.id === req.session.user.id);
if (observer) {
observer.id = publicUser.id;
observer.name = publicUser.name;
}
}
io.to(game.code as string).emit("receivedLatestGame", game);
}
}
req.session.user = newUser;
req.session.save(() => {
res.status(201).json(req.session.user);
});
} catch (err: unknown) {
console.log(err);
res.status(500).end();
}
};
export const loginUser = async (req: Request, res: Response) => {
try {
if (req.session.user?.id && typeof req.session.user.id === "number") {
res.status(403).end();
return;
}
const nameOrEmail = xss(req.body.name);
const password = req.body.password;
const users = await UserModel.findByNameEmail(
{ name: nameOrEmail, email: nameOrEmail },
true
);
if (!users || !users.length) {
res.status(404).json({ message: "Invalid username/email." });
return;
}
const validPassword = await verify(users[0].password as string, password);
if (!validPassword) {
res.status(401).json({ message: "Invalid password." });
return;
}
const publicUser = {
id: users[0].id,
name: users[0].name
};
if (req.session.user?.id && typeof req.session.user.id === "string") {
const game = activeGames.find(
(g) =>
g.white?.id === req.session.user.id ||
g.black?.id === req.session.user.id ||
g.observers?.find((o) => o.id === req.session.user.id)
);
if (game) {
if (game.host?.id === req.session.user.id) {
game.host = publicUser;
}
if (game.white && game.white?.id === req.session.user.id) {
game.white = publicUser;
} else if (game.black && game.black?.id === req.session.user.id) {
game.black = publicUser;
} else {
const observer = game.observers?.find((o) => o.id === req.session.user.id);
if (observer) {
observer.id = publicUser.id;
observer.name = publicUser.name;
}
}
io.to(game.code as string).emit("receivedLatestGame", game);
}
}
req.session.user = {
id: users[0].id,
name: users[0].name,
email: users[0].email,
wins: users[0].wins,
losses: users[0].losses,
draws: users[0].draws
};
req.session.save(() => {
res.status(200).json(req.session.user);
});
} catch (err: unknown) {
console.log(err);
res.status(500).end();
}
};
export const updateUser = async (req: Request, res: Response) => {
try {
if (!req.session.user?.id || typeof req.session.user.id === "string") {
res.status(403).end();
return;
}
if (!req.body.name && !req.body.email && !req.body.password) {
res.status(400).end();
return;
}
const name = xss(req.body.name || req.session.user.name);
const pattern = /^[A-Za-z0-9]+$/;
if (!pattern.test(name)) {
res.status(400).end();
return;
}
const email = xss(req.body.email || req.session.user.email);
const duplicateUsers = await UserModel.findByNameEmail({ name, email });
if (
duplicateUsers &&
duplicateUsers.length &&
duplicateUsers[0].id !== req.session.user.id
) {
const dupl = duplicateUsers[0].name === name ? "Username" : "Email";
res.status(409).json({ message: `${dupl} is already in use.` });
return;
}
let password: string | undefined = undefined;
if (req.body.password) {
password = await hash(req.body.password);
}
const updatedUser = await UserModel.update(req.session.user.id, { name, email, password });
if (!updatedUser) {
throw new Error("Failed to update user");
}
const publicUser = {
id: updatedUser.id,
name: updatedUser.name
};
const game = activeGames.find(
(g) =>
g.white?.id === req.session.user.id ||
g.black?.id === req.session.user.id ||
g.observers?.find((o) => o.id === req.session.user.id)
);
if (game) {
if (game.host?.id === req.session.user.id) {
game.host = publicUser;
}
if (game.white && game.white?.id === req.session.user.id) {
game.white = publicUser;
} else if (game.black && game.black?.id === req.session.user.id) {
game.black = publicUser;
} else {
const observer = game.observers?.find((o) => o.id === req.session.user.id);
if (observer) {
observer.id = publicUser.id;
observer.name = publicUser.name;
}
}
io.to(game.code as string).emit("receivedLatestGame", game);
}
req.session.user = updatedUser;
req.session.save(() => {
res.status(200).json(req.session.user);
});
} catch (err: unknown) {
console.log(err);
res.status(500).end();
}
};

View File

@@ -1,11 +1,43 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { activeGames } from "../db/models/game.model.js"; import GameModel, { activeGames } from "../db/models/game.model.js";
import type { Game, User } from "@chessu/types"; import type { Game, User } from "@chessu/types";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
export const getActivePublicGames = async (req: Request, res: Response) => { export const getGames = async (req: Request, res: Response) => {
try { try {
if (!req.query.id && !req.query.userid) {
// get all active games
res.status(200).json(activeGames.filter((g) => !g.unlisted && !g.winner)); res.status(200).json(activeGames.filter((g) => !g.unlisted && !g.winner));
return;
}
let id, userid;
if (req.query.id) {
id = parseInt(req.query.id as string);
}
if (req.query.userid) {
userid = parseInt(req.query.userid as string);
}
if (id && !isNaN(id)) {
// get finished game by id
const game = await GameModel.findById(id);
if (!game) {
res.status(404).end();
} else {
res.status(200).json(game);
}
} else if (userid && !isNaN(userid)) {
// get finished games by user id
const games = await GameModel.findByUserId(userid);
if (!games) {
res.status(404).end();
} else {
res.status(200).json(games);
}
} else {
res.status(400).end();
}
} catch (err: unknown) { } catch (err: unknown) {
console.log(err); console.log(err);
res.status(500).end(); res.status(500).end();

View File

@@ -0,0 +1,33 @@
import type { Request, Response } from "express";
import xss from "xss";
import GameModel from "../db/models/game.model.js";
import UserModel from "../db/models/user.model.js";
export const getUserProfile = async (req: Request, res: Response) => {
try {
const name = xss(req.params.name);
const users = await UserModel.findByNameEmail({ name, email: name });
if (!users || !users.length) {
res.status(404).end();
return;
}
const recentGames = await GameModel.findByUserId(users[0].id as number);
const publicUser = {
id: users[0].id,
name: users[0].name,
wins: users[0].wins,
losses: users[0].losses,
draws: users[0].draws
};
res.status(200).json({ ...publicUser, recentGames });
} catch (err: unknown) {
console.log(err);
res.status(500).end();
}
};

View File

@@ -1,3 +1,28 @@
import pg from "pg"; import pg from "pg";
export const db = new pg.Pool(); export const db = new pg.Pool();
export const INIT_TABLES = /* sql */ `
CREATE TABLE IF NOT EXISTS "user" (
id SERIAL PRIMARY KEY,
name VARCHAR(128) UNIQUE NOT NULL,
email VARCHAR(128),
password TEXT,
wins INTEGER DEFAULT 0,
losses INTEGER DEFAULT 0,
draws INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS "game" (
id SERIAL PRIMARY KEY,
winner VARCHAR(5),
end_reason VARCHAR(16),
pgn TEXT,
white_id INT REFERENCES "user",
white_name VARCHAR(32),
black_id INT REFERENCES "user",
black_name VARCHAR(32),
started_at TIMESTAMP NOT NULL,
ended_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`;

View File

@@ -1,15 +0,0 @@
-- users
CREATE TABLE "user" (
id SERIAL PRIMARY KEY,
name VARCHAR(128) UNIQUE NOT NULL,
email VARCHAR(128),
password TEXT
);
-- games
CREATE TABLE "game" (
id SERIAL PRIMARY KEY,
pgn TEXT,
white_id INT REFERENCES "user",
black_id INT REFERENCES "user",
);

View File

@@ -1,22 +1,72 @@
import { db } from "../index.js"; import { db } from "../index.js";
import type { Game } from "@chessu/types"; import type { Game, User } from "@chessu/types";
export const activeGames: Array<Game> = []; export const activeGames: Array<Game> = [];
// todo: join user and game relationship export const save = async (game: Game) => {
const create = async (game: Game) => {
try { try {
const white: User = {};
const black: User = {};
if (typeof game.white?.id === "string") {
white.name = game.white?.name;
} else {
white.id = game.white?.id;
}
if (typeof game.black?.id === "string") {
black.name = game.black?.name;
} else {
black.id = game.black?.id;
}
const res = await db.query( const res = await db.query(
`INSERT INTO "game"(pgn, white_id, black_id, winner) VALUES($1, $2, $3, $4) RETURNING *`, `INSERT INTO "game"(winner, end_reason, pgn, white_id, white_name, black_id, black_name, started_at) VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[game.pgn || null, game.white?.id || null, game.black?.id || null, game.winner || null] [
game.winner || null,
game.endReason || null,
game.pgn,
white.id || null,
white.name || null,
black.id || null,
black.name || null,
new Date(game.startedAt as number)
]
); );
if (black.id || white.id) {
// draws
if (game.winner === "draw") {
if (white.id) {
await db.query(`UPDATE "user" SET draws = draws + 1 WHERE id = $1`, [white.id]);
}
if (black.id) {
await db.query(`UPDATE "user" SET draws = draws + 1 WHERE id = $1`, [black.id]);
}
} else {
const winner = game.winner === "white" ? white : black;
const loser = game.winner === "white" ? black : white;
if (winner.id) {
await db.query(`UPDATE "user" SET wins = wins + 1 WHERE id = $1`, [winner.id]);
}
if (loser.id) {
await db.query(`UPDATE "user" SET losses = losses + 1 WHERE id = $1`, [
loser.id
]);
}
}
}
return { return {
id: res.rows[0].id, id: res.rows[0].id,
winner: res.rows[0].winner,
endReason: res.rows[0].reason,
pgn: res.rows[0].pgn, pgn: res.rows[0].pgn,
white: { id: res.rows[0].white_id }, white: {
black: { id: res.rows[0].black_id }, id: res.rows[0].white_id || undefined,
winner: res.rows[0].winner name: res.rows[0].white_name || undefined
},
black: {
id: res.rows[0].black_id || undefined,
name: res.rows[0].black_name || undefined
},
startedAt: res.rows[0].started_at.getTime(),
endedAt: res.rows[0].ended_at?.getTime() || undefined
} as Game; } as Game;
} catch (err: unknown) { } catch (err: unknown) {
console.log(err); console.log(err);
@@ -24,23 +74,50 @@ const create = async (game: Game) => {
} }
}; };
const find = async (where?: string, limit = 1) => { export const findById = async (id: number) => {
const query = where try {
? `SELECT * FROM "game"` const res = await db.query(
: { `SELECT game.id, game.winner, game.end_reason, game.pgn, white_user.id AS white_id, COALESCE(white_user.name, game.white_name) AS white_name, black_user.id AS black_id, started_at, ended_at, COALESCE(black_user.name, game.black_name) AS black_name FROM game LEFT JOIN "user" white_user ON white_user.id = game.white_id LEFT JOIN "user" black_user ON black_user.id = game.black_id WHERE game.id=$1`,
text: `SELECT * FROM "game" WHERE $1 LIMIT $2`, [id]
values: [where, limit] );
if (res.rowCount) {
return {
id: res.rows[0].id,
winner: res.rows[0].winner,
endReason: res.rows[0].end_reason,
pgn: res.rows[0].pgn,
white: { id: res.rows[0].white_id || undefined, name: res.rows[0].white_name },
black: { id: res.rows[0].black_id || undefined, name: res.rows[0].black_name },
startedAt: res.rows[0].started_at.getTime(),
endedAt: res.rows[0].ended_at?.getTime() || undefined
} as Game;
} else return null;
} catch (err: unknown) {
console.log(err);
return null;
}
}; };
export const findByUserId = async (id: number, limit = 10) => {
if (id == 0) {
return null;
}
try { try {
const res = await db.query(query); // TODO: pagination
const res = await db.query(
`SELECT game.id, game.winner, game.end_reason, game.pgn, white_user.id AS white_id, COALESCE(white_user.name, game.white_name) AS white_name, black_user.id AS black_id, started_at, ended_at, COALESCE(black_user.name, game.black_name) AS black_name FROM game LEFT JOIN "user" white_user ON white_user.id = game.white_id LEFT JOIN "user" black_user ON black_user.id = game.black_id WHERE white_user.id=$1 OR black_user.id=$1 ORDER BY id DESC LIMIT $2`,
[id, limit]
);
return res.rows.map((r) => { return res.rows.map((r) => {
return { return {
id: r.id, id: r.id,
winner: r.winner,
endReason: r.end_reason,
pgn: r.pgn, pgn: r.pgn,
white: { id: r.white_id }, white: { id: r.white_id || undefined, name: r.white_name },
black: { id: r.black_id }, black: { id: r.black_id || undefined, name: r.black_name },
winner: r.winner startedAt: r.started_at.getTime(),
endedAt: r.ended_at?.getTime() || undefined
} as Game; } as Game;
}); });
} catch (err: unknown) { } catch (err: unknown) {
@@ -49,15 +126,19 @@ const find = async (where?: string, limit = 1) => {
} }
}; };
const update = async (id: number, data: string) => { // TODO: update fields specifically, "data" string doesnt work
export const update = async (id: number, data: string) => {
try { try {
const res = await db.query(`UPDATE "game" SET $1 WHERE id = $2 RETURNING *`, [data, id]); const res = await db.query(`UPDATE "game" SET $1 WHERE id = $2 RETURNING *`, [data, id]);
return { return {
id: res.rows[0].id, id: res.rows[0].id,
winner: res.rows[0].winner,
endReason: res.rows[0].end_reason,
pgn: res.rows[0].pgn, pgn: res.rows[0].pgn,
white: { id: res.rows[0].white_id }, white: { id: res.rows[0].white_id, name: res.rows[0].white_name },
black: { id: res.rows[0].black_id }, black: { id: res.rows[0].black_id, name: res.rows[0].black_name },
winner: res.rows[0].winner startedAt: res.rows[0].started_at.getTime(),
endedAt: res.rows[0].ended_at?.getTime() || undefined
} as Game; } as Game;
} catch (err: unknown) { } catch (err: unknown) {
console.log(err); console.log(err);
@@ -65,15 +146,18 @@ const update = async (id: number, data: string) => {
} }
}; };
const remove = async (id: number) => { export const remove = async (id: number) => {
try { try {
const res = await db.query(`DELETE FROM "game" WHERE id = $1 RETURNING *`, [id]); const res = await db.query(`DELETE FROM "game" WHERE id = $1 RETURNING *`, [id]);
return { return {
id: res.rows[0].id, id: res.rows[0].id,
winner: res.rows[0].winner,
endReason: res.rows[0].end_reason,
pgn: res.rows[0].pgn, pgn: res.rows[0].pgn,
white: { id: res.rows[0].white_id }, white: { id: res.rows[0].white_id, name: res.rows[0].white_name },
black: { id: res.rows[0].black_id }, black: { id: res.rows[0].black_id, name: res.rows[0].black_name },
winner: res.rows[0].winner startedAt: res.rows[0].started_at.getTime(),
endedAt: res.rows[0].ended_at?.getTime() || undefined
} as Game; } as Game;
} catch (err: unknown) { } catch (err: unknown) {
console.log(err); console.log(err);
@@ -81,9 +165,12 @@ const remove = async (id: number) => {
} }
}; };
export const GameModel = { const GameModel = {
create, save,
find, findById,
findByUserId,
update, update,
remove remove
}; };
export default GameModel;

View File

@@ -1,15 +1,15 @@
import { db } from "../index.js"; import { db } from "../index.js";
import type { User } from "@chessu/types"; import type { User } from "@chessu/types";
const create = async (user: User, password: string) => { export const create = async (user: User, password: string) => {
if (user.name === "Guest" || user.email === undefined) { if (user.name === "Guest" || user.email === undefined) {
return null; return null;
} }
try { try {
const res = await db.query( const res = await db.query(
`INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email`, `INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email, wins, losses, draws`,
[user.name, user.email ?? null, password] [user.name, user.email || null, password]
); );
return res.rows[0] as User; return res.rows[0] as User;
} catch (err: unknown) { } catch (err: unknown) {
@@ -18,44 +18,67 @@ const create = async (user: User, password: string) => {
} }
}; };
const find = async (where?: string, limit = 1) => { export const findById = async (id: number) => {
// if user is not specified, get all users if (id === 0) {
if (!where) {
try {
const res = await db.query(`SELECT id, name, email FROM "user"`);
return res.rows as Array<User>;
} catch (err: unknown) {
console.log(err);
return null; return null;
} }
}
try { try {
/* const res = await db.query( const res = await db.query(
`SELECT id, name, email FROM "user" WHERE ${typeof user.id === "number" ? "id" : typeof user.name === "string" ? "name" : "email" } = $1 LIMIT $2`, `SELECT id, name, email, wins, losses, draws FROM "user" WHERE id=$1`,
[typeof user.id === "number" ? user.id : typeof user.name === "string" ? user.name : user.email, limit] [id]
); */ );
const res = await db.query(`SELECT id, name, email FROM "user" WHERE $1 LIMIT $2`, [ if (res.rowCount) {
where, return res.rows[0] as User;
limit } else return null;
]);
return res.rows as Array<User>;
} catch (err: unknown) { } catch (err: unknown) {
console.log(err); console.log(err);
return null; return null;
} }
}; };
const update = async (id: number, data: string) => { export const findByNameEmail = async (user: User, includePassword = false, limit?: number) => {
if (typeof id === "string" || id === 0) { // if user is not specified, get all users
if (!user) {
try {
const res = await db.query(
`SELECT id, name, email, wins, losses, draws FROM "user" LIMIT $1`,
[limit ?? 10]
);
return res.rows as Array<User & { password?: string }>;
} catch (err: unknown) {
console.log(err);
return null;
}
}
try {
const res = await db.query(
`SELECT id, name, email, wins, losses, draws${
includePassword ? `, password` : ""
} FROM "user" WHERE name=$1 OR email=$2 LIMIT $3`,
[user.name, user.email, limit ?? 1]
);
return res.rows as Array<User & { password?: string }>;
} catch (err: unknown) {
console.log(err);
return null;
}
};
export const update = async (id: number, updatedUser: User & { password?: string }) => {
if (id === 0) {
return null; return null;
} }
try { try {
const res = await db.query(`UPDATE "user" SET $1 WHERE id = $2 RETURNING id, name, email`, [ let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws`;
data, let values = [updatedUser.name, updatedUser.email, id];
id
]); if (updatedUser.password) {
query = `UPDATE "user" SET name=$1, email=$2, password=$3 WHERE id=$4 RETURNING id, name, email, wins, losses, draws`;
values = [updatedUser.name, updatedUser.email, updatedUser.password, id];
}
const res = await db.query(query, values);
return res.rows[0] as User; return res.rows[0] as User;
} catch (err: unknown) { } catch (err: unknown) {
console.log(err); console.log(err);
@@ -63,8 +86,8 @@ const update = async (id: number, data: string) => {
} }
}; };
const remove = async (id: number) => { export const remove = async (id: number) => {
if (typeof id === "string" || id === 0) { if (id === 0) {
return null; return null;
} }
@@ -79,9 +102,12 @@ const remove = async (id: number) => {
} }
}; };
export const UserModel = { const UserModel = {
create, create,
find, findById,
findByNameEmail,
update, update,
remove remove
}; };
export default UserModel;

View File

@@ -3,14 +3,14 @@ import * as controller from "../controllers/auth.controller.js";
const router = Router(); const router = Router();
router.route("/").get(controller.getCurrentSession); router.route("/").get(controller.getCurrentSession).patch(controller.updateUser);
// create or update guest sessions // create or update guest sessions
router.route("/guest").post(controller.guestSession); router.route("/guest").post(controller.guestSession);
router.route("/logout").post(controller.logoutSession); router.route("/logout").post(controller.logoutSession);
//router.route("/register").post(controller.registerUser); router.route("/register").post(controller.registerUser);
//router.route("/login").post(controller.loginUser); router.route("/login").post(controller.loginUser);
export default router; export default router;

View File

@@ -3,7 +3,7 @@ import * as controller from "../controllers/games.controller.js";
const router = Router(); const router = Router();
router.route("/").get(controller.getActivePublicGames).post(controller.createGame); router.route("/").get(controller.getGames).post(controller.createGame);
router.route("/:code").get(controller.getActiveGame); router.route("/:code").get(controller.getActiveGame);

View File

@@ -1,10 +1,12 @@
import { Router } from "express"; import { Router } from "express";
import games from "./games.route.js"; import games from "./games.route.js";
import auth from "./auth.route.js"; import auth from "./auth.route.js";
import users from "./users.route.js";
const router = Router(); const router = Router();
router.use("/games", games); router.use("/games", games);
router.use("/auth", auth); router.use("/auth", auth);
router.use("/users", users);
export default router; export default router;

View File

@@ -0,0 +1,8 @@
import { Router } from "express";
import * as controller from "../controllers/users.controller.js";
const router = Router();
router.route("/:name").get(controller.getUserProfile);
export default router;

View File

@@ -6,7 +6,7 @@ import { createServer } from "http";
import session from "./middleware/session.js"; import session from "./middleware/session.js";
import { Server } from "socket.io"; import { Server } from "socket.io";
import { init as initSocket } from "./socket/index.js"; import { init as initSocket } from "./socket/index.js";
import { db } from "./db/index.js"; import { db, INIT_TABLES } from "./db/index.js";
import routes from "./routes/index.js"; import routes from "./routes/index.js";
const corsConfig = { const corsConfig = {
@@ -18,7 +18,14 @@ const app = express();
const server = createServer(app); const server = createServer(app);
// database // database
db.connect(); await db.connect();
db.query(INIT_TABLES, (err) => {
if (err) {
console.error(err);
} else {
console.log("Tables initialized");
}
});
// middleware // middleware
app.use(cors(corsConfig)); app.use(cors(corsConfig));

View File

@@ -1,4 +1,5 @@
import { activeGames } from "../db/models/game.model.js"; import GameModel, { activeGames } from "../db/models/game.model.js";
import type { Game } from "@chessu/types";
import type { DisconnectReason, Socket } from "socket.io"; import type { DisconnectReason, Socket } from "socket.io";
import { Chess } from "chess.js"; import { Chess } from "chess.js";
import { io } from "../server.js"; import { io } from "../server.js";
@@ -18,11 +19,13 @@ export async function joinLobby(this: Socket, gameCode: string) {
} }
if (game.white && game.white?.id === this.request.session.user.id) { if (game.white && game.white?.id === this.request.session.user.id) {
game.white.connected = true; game.white.connected = true;
game.white.disconnectedOn = undefined;
if (game.white.name !== this.request.session.user.name) { if (game.white.name !== this.request.session.user.name) {
game.white.name = this.request.session.user.name; game.white.name = this.request.session.user.name;
} }
} else if (game.black && game.black?.id === this.request.session.user.id) { } else if (game.black && game.black?.id === this.request.session.user.id) {
game.black.connected = true; game.black.connected = true;
game.black.disconnectedOn = undefined;
if (game.black.name !== this.request.session.user.name) { if (game.black.name !== this.request.session.user.name) {
game.black.name = this.request.session.user.name; game.black.name = this.request.session.user.name;
} }
@@ -50,7 +53,7 @@ export async function joinLobby(this: Socket, gameCode: string) {
export async function leaveLobby(this: Socket, reason?: DisconnectReason, code?: string) { export async function leaveLobby(this: Socket, reason?: DisconnectReason, code?: string) {
if (this.rooms.size >= 3 && !code) { if (this.rooms.size >= 3 && !code) {
console.log(`[WARNING] leaveLobby: room size is ${this.rooms.size}, aborting...`); console.log(`leaveLobby: room size is ${this.rooms.size}, aborting...`);
return; return;
} }
const game = activeGames.find( const game = activeGames.find(
@@ -68,8 +71,10 @@ export async function leaveLobby(this: Socket, reason?: DisconnectReason, code?:
} }
if (game.black && game.black?.id === this.request.session.user.id) { if (game.black && game.black?.id === this.request.session.user.id) {
game.black.connected = false; game.black.connected = false;
game.black.disconnectedOn = Date.now();
} else if (game.white && game.white?.id === this.request.session.user.id) { } else if (game.white && game.white?.id === this.request.session.user.id) {
game.white.connected = false; game.white.connected = false;
game.white.disconnectedOn = Date.now();
} }
// count sockets // count sockets
@@ -94,6 +99,58 @@ export async function leaveLobby(this: Socket, reason?: DisconnectReason, code?:
await this.leave(code || Array.from(this.rooms)[1]); await this.leave(code || Array.from(this.rooms)[1]);
} }
export async function claimAbandoned(this: Socket, type: "win" | "draw") {
const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]);
if (
!game ||
!game.pgn ||
!game.white ||
!game.black ||
(game.white.id !== this.request.session.user.id &&
game.black.id !== this.request.session.user.id)
) {
console.log(`claimAbandoned: Invalid game or user is not a player.`);
return;
}
if (
(game.white &&
game.white.id === this.request.session.user.id &&
(game.black?.connected ||
Date.now() - (game.black?.disconnectedOn as number) < 50000)) ||
(game.black &&
game.black.id === this.request.session.user.id &&
(game.white?.connected || Date.now() - (game.white?.disconnectedOn as number) < 50000))
) {
console.log(
`claimAbandoned: Invalid claim by ${this.request.session.user.name}. Opponent is still connected or disconnected less than 50 seconds ago.`
);
return;
}
game.endReason = "abandoned";
if (type === "draw") {
game.winner = "draw";
} else if (game.white && game.white?.id === this.request.session.user.id) {
game.winner = "white";
} else if (game.black && game.black?.id === this.request.session.user.id) {
game.winner = "black";
}
const { id } = (await GameModel.save(game)) as Game;
game.id = id;
const gameOver = {
reason: game.endReason,
winnerName: this.request.session.user.name,
winnerSide: game.winner === "draw" ? undefined : game.winner,
id
};
io.to(game.code as string).emit("gameOver", gameOver);
}
export async function getLatestGame(this: Socket) { export async function getLatestGame(this: Socket) {
const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]); const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]);
if (game) this.emit("receivedLatestGame", game); if (game) this.emit("receivedLatestGame", game);
@@ -101,7 +158,7 @@ export async function getLatestGame(this: Socket) {
export async function sendMove(this: Socket, m: { from: string; to: string; promotion?: string }) { export async function sendMove(this: Socket, m: { from: string; to: string; promotion?: string }) {
const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]); const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]);
if (!game) return; if (!game || game.endReason || game.winner) return;
const chess = new Chess(); const chess = new Chess();
if (game.pgn) { if (game.pgn) {
chess.loadPgn(game.pgn); chess.loadPgn(game.pgn);
@@ -118,8 +175,12 @@ export async function sendMove(this: Socket, m: { from: string; to: string; prom
} }
const newMove = chess.move(m); const newMove = chess.move(m);
if (newMove) {
game.pgn = chess.pgn();
this.to(game.code as string).emit("receivedMove", m);
if (chess.isGameOver()) { if (chess.isGameOver()) {
let reason = ""; let reason: Game["endReason"];
if (chess.isCheckmate()) reason = "checkmate"; if (chess.isCheckmate()) reason = "checkmate";
else if (chess.isStalemate()) reason = "stalemate"; else if (chess.isStalemate()) reason = "stalemate";
else if (chess.isThreefoldRepetition()) reason = "repetition"; else if (chess.isThreefoldRepetition()) reason = "repetition";
@@ -139,11 +200,12 @@ export async function sendMove(this: Socket, m: { from: string; to: string; prom
} else { } else {
game.winner = "draw"; game.winner = "draw";
} }
io.to(game.code as string).emit("gameOver", { reason, winnerName, winnerSide }); game.endReason = reason;
const { id } = (await GameModel.save(game)) as Game; // save game to db
game.id = id;
io.to(game.code as string).emit("gameOver", { reason, winnerName, winnerSide, id });
} }
if (newMove) {
game.pgn = chess.pgn();
this.to(game.code as string).emit("receivedMove", m);
} else { } else {
throw new Error("invalid move"); throw new Error("invalid move");
} }
@@ -169,6 +231,7 @@ export async function joinAsPlayer(this: Socket) {
name: this.request.session.user.name, name: this.request.session.user.name,
side: "white" side: "white"
}); });
game.startedAt = Date.now();
} else if (!game.black) { } else if (!game.black) {
const sessionUser = { const sessionUser = {
id: this.request.session.user.id, id: this.request.session.user.id,
@@ -181,8 +244,9 @@ export async function joinAsPlayer(this: Socket) {
name: this.request.session.user.name, name: this.request.session.user.name,
side: "black" side: "black"
}); });
game.startedAt = Date.now();
} else { } else {
console.log("[WARNING] attempted to join a game with already 2 players"); console.log("joinAsPlayer: attempted to join a game with already 2 players");
} }
io.to(game.code as string).emit("receivedLatestGame", game); io.to(game.code as string).emit("receivedLatestGame", game);
} }

View File

@@ -6,13 +6,14 @@ import {
getLatestGame, getLatestGame,
sendMove, sendMove,
joinAsPlayer, joinAsPlayer,
chat chat,
claimAbandoned
} from "./game.socket.js"; } from "./game.socket.js";
const socketConnect = (socket: Socket) => { const socketConnect = (socket: Socket) => {
const req = socket.request; const req = socket.request;
// re-analyze if this is necessary, or if io.use will handle logout // review if this is necessary, or if io.use will handle logout
socket.use((__, next) => { socket.use((__, next) => {
req.session.reload((err) => { req.session.reload((err) => {
if (err) { if (err) {
@@ -34,6 +35,7 @@ const socketConnect = (socket: Socket) => {
socket.on("sendMove", sendMove); socket.on("sendMove", sendMove);
socket.on("joinAsPlayer", joinAsPlayer); socket.on("joinAsPlayer", joinAsPlayer);
socket.on("chat", chat); socket.on("chat", chat);
socket.on("claimAbandoned", claimAbandoned);
}; };
export const init = () => { export const init = () => {

13
types/index.d.ts vendored
View File

@@ -4,16 +4,25 @@ export interface Game {
white?: User; white?: User;
black?: User; black?: User;
winner?: "white" | "black" | "draw"; winner?: "white" | "black" | "draw";
endReason?: "draw" | "checkmate" | "stalemate" | "repetition" | "insufficient" | "abandoned";
host?: User; host?: User;
code?: string; code?: string;
unlisted?: boolean; unlisted?: boolean;
timeout?: number; timeout?: number;
observers?: User[]; observers?: User[];
startedAt?: number;
endedAt?: number;
} }
export interface User { export interface User {
id?: number | string; // string for guest IDs id?: number | string; // string for guest IDs
name?: string; name?: string | null;
email?: string; email?: string;
connected?: boolean; // mainly for players, not spectators wins?: number;
losses?: number;
draws?: number;
// mainly for players, not spectators
connected?: boolean;
disconnectedOn?: number;
} }