22
.github/dependabot.yml
vendored
22
.github/dependabot.yml
vendored
@@ -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"
|
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
8
client/src/app/archive/[id]/not-found.tsx
Normal file
8
client/src/app/archive/[id]/not-found.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
client/src/app/archive/[id]/page.tsx
Normal file
45
client/src/app/archive/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
119
client/src/app/settings/page.tsx
Normal file
119
client/src/app/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
client/src/app/user/[name]/not-found.tsx
Normal file
8
client/src/app/user/[name]/not-found.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
client/src/app/user/[name]/page.tsx
Normal file
158
client/src/app/user/[name]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,12 @@ export default function Footer() {
|
|||||||
<div className="items-center">
|
<div className="items-center">
|
||||||
<p>
|
<p>
|
||||||
© 2023{" "}
|
© 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} />
|
||||||
|
|||||||
@@ -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
|
||||||
363
client/src/components/archive/ArchivedGame.tsx
Normal file
363
client/src/components/archive/ArchivedGame.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
26
client/src/components/auth/Guest.tsx
Normal file
26
client/src/components/auth/Guest.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
client/src/components/auth/Login.tsx
Normal file
35
client/src/components/auth/Login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
client/src/components/auth/Register.tsx
Normal file
46
client/src/components/auth/Register.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" : "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
37
client/src/components/user/CopyLink.tsx
Normal file
37
client/src/components/user/CopyLink.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
18
client/src/lib/user.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
12
package.json
12
package.json
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
33
server/src/controllers/users.controller.ts
Normal file
33
server/src/controllers/users.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|||||||
@@ -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",
|
|
||||||
);
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
8
server/src/routes/users.route.ts
Normal file
8
server/src/routes/users.route.ts
Normal 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;
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
13
types/index.d.ts
vendored
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user