From 0046b1eb980710a015a0e9db758aae33c9130c5f Mon Sep 17 00:00:00 2001 From: Martin Kedmenec Date: Tue, 23 Apr 2024 21:23:38 +0200 Subject: [PATCH] Fix #123. Major refactoring --- .env.sample | 1 + .prettierignore | 1 + README.md | 12 + app/about/page.tsx | 2 +- app/account/page.tsx | 23 +- app/authentication-context.ts | 14 - app/color-mode-context.ts | 17 - app/layout.tsx | 32 +- app/match/[mid]/page.tsx | 30 +- app/page.tsx | 22 +- app/theme.ts | 48 +-- components/{ => client}/account-page.tsx | 42 +- components/{ => client}/chat-provider.tsx | 43 +- components/{ => client}/chat.tsx | 36 +- components/{ => client}/chessboard.tsx | 51 ++- components/client/color-mode.tsx | 76 ++++ components/{ => client}/home-page-popover.tsx | 17 +- .../client/init-color-scheme-script.tsx | 9 + components/{ => client}/loader.tsx | 2 + .../{lobby.tsx => client/lobby-grid.tsx} | 153 ++----- components/client/lobby-header.tsx | 104 +++++ components/client/player-header.tsx | 68 +++ components/client/providers.tsx | 20 + .../providers/authentication-provider.tsx | 48 +++ .../client/providers/match-provider.tsx | 147 +++++++ components/{ => client}/sign-in-button.tsx | 15 +- components/{ => client}/toolbar-menu.tsx | 2 + components/main-container.tsx | 106 ----- components/match-page.tsx | 249 ----------- components/navigation.tsx | 72 ---- components/{ => server}/about-card.tsx | 7 +- components/{ => server}/about-page.tsx | 22 +- components/{ => server}/home-page.tsx | 2 +- components/server/lobby-page.tsx | 26 ++ components/server/main-container.tsx | 38 ++ components/server/match-page.tsx | 54 +++ components/server/navigation.tsx | 46 ++ components/server/toolbar-display.tsx | 16 + components/toolbar-display.tsx | 21 - contexts/authentication.ts | 25 ++ contexts/match.ts | 31 ++ eslint.config.js | 5 + firebase/auth.ts | 45 +- middleware.ts | 21 + package-lock.json | 405 +++++++++--------- package.json | 40 +- ....2.1.patch => next+14.3.0-canary.18.patch} | 4 +- tsconfig.json | 24 +- types/chess.js.d.ts | 2 + types/database.ts | 2 +- types/session.ts | 8 + utils/error-handler.tsx | 28 -- utils/server-actions.ts | 33 ++ utils/session.ts | 64 +++ utils/utils.ts | 13 +- 55 files changed, 1368 insertions(+), 1076 deletions(-) delete mode 100644 app/authentication-context.ts delete mode 100644 app/color-mode-context.ts rename components/{ => client}/account-page.tsx (80%) rename components/{ => client}/chat-provider.tsx (78%) rename components/{ => client}/chat.tsx (92%) rename components/{ => client}/chessboard.tsx (55%) create mode 100644 components/client/color-mode.tsx rename components/{ => client}/home-page-popover.tsx (81%) create mode 100644 components/client/init-color-scheme-script.tsx rename components/{ => client}/loader.tsx (94%) rename components/{lobby.tsx => client/lobby-grid.tsx} (56%) create mode 100644 components/client/lobby-header.tsx create mode 100644 components/client/player-header.tsx create mode 100644 components/client/providers.tsx create mode 100644 components/client/providers/authentication-provider.tsx create mode 100644 components/client/providers/match-provider.tsx rename components/{ => client}/sign-in-button.tsx (68%) rename components/{ => client}/toolbar-menu.tsx (99%) delete mode 100644 components/main-container.tsx delete mode 100644 components/match-page.tsx delete mode 100644 components/navigation.tsx rename components/{ => server}/about-card.tsx (85%) rename components/{ => server}/about-page.tsx (81%) rename components/{ => server}/home-page.tsx (97%) create mode 100644 components/server/lobby-page.tsx create mode 100644 components/server/main-container.tsx create mode 100644 components/server/match-page.tsx create mode 100644 components/server/navigation.tsx create mode 100644 components/server/toolbar-display.tsx delete mode 100644 components/toolbar-display.tsx create mode 100644 contexts/authentication.ts create mode 100644 contexts/match.ts create mode 100644 middleware.ts rename patches/{next+14.2.1.patch => next+14.3.0-canary.18.patch} (87%) create mode 100644 types/session.ts delete mode 100644 utils/error-handler.tsx create mode 100644 utils/server-actions.ts create mode 100644 utils/session.ts diff --git a/.env.sample b/.env.sample index 14e24ee..3d2e50b 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,4 @@ +SECRET_KEY= NEXT_PUBLIC_FIREBASE_API_KEY= NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= NEXT_PUBLIC_FIREBASE_DATABASE_URL= diff --git a/.prettierignore b/.prettierignore index 9f11b75..5e58a38 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ .idea/ +tsconfig.json diff --git a/README.md b/README.md index 88e0d97..bb11eb0 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,18 @@ first: npm run prettier && npx eslint . ``` +To check whether the project can be compiled for production, run the `build` command: + +```shell +NODE_ENV=production npm run build +``` + +Then, start the production server with: + +```shell +NODE_ENV=production npm run start +``` + After a finished development cycle, exit the `server` container and run: ```shell diff --git a/app/about/page.tsx b/app/about/page.tsx index 5c1bf26..7e84c6d 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -1,5 +1,5 @@ import type { FC, ReactElement } from "react"; -import AboutPage from "@/components/about-page"; +import AboutPage from "@/components/server/about-page"; const About: FC = (): ReactElement | null => { return ; diff --git a/app/account/page.tsx b/app/account/page.tsx index 665885b..4a2db57 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -1,24 +1,15 @@ -"use client"; - import type { FC, ReactElement } from "react"; -import { useContext } from "react"; -import Loader from "@/components/loader"; -import AccountPage from "@/components/account-page"; -import AuthenticationContext from "@/app/authentication-context"; -import { redirect } from "next/navigation"; - -const Account: FC = (): ReactElement | null => { - const { isLoading, user } = useContext(AuthenticationContext); +import AccountPage from "@/components/client/account-page"; +import { verifySession } from "@/utils/server-actions"; - if (isLoading) { - return ; - } +const Account: FC = async (): Promise => { + const session = await verifySession(); - if (!user) { - redirect("/"); + if (!session) { + return null; } - return ; + return ; }; export default Account; diff --git a/app/authentication-context.ts b/app/authentication-context.ts deleted file mode 100644 index 839d4e4..0000000 --- a/app/authentication-context.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createContext } from "react"; -import type { User } from "@firebase/auth"; - -interface AuthenticationContextProperties { - isLoading: boolean; - user: User | null; -} - -const AuthenticationContext = createContext({ - isLoading: false, - user: null, -}); - -export default AuthenticationContext; diff --git a/app/color-mode-context.ts b/app/color-mode-context.ts deleted file mode 100644 index f7dcd03..0000000 --- a/app/color-mode-context.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createContext } from "react"; - -export type ColorMode = "dark" | "light"; - -export const dark: ColorMode = "dark"; -export const light: ColorMode = "light"; - -interface ColorModeContextProps { - readonly toggleColorMode: () => void; -} - -const ColorModeContext = createContext({ - // eslint-disable-next-line @typescript-eslint/no-empty-function - toggleColorMode: () => {}, -}); - -export default ColorModeContext; diff --git a/app/layout.tsx b/app/layout.tsx index fc57e96..5b9d945 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,35 +1,31 @@ import type { Metadata } from "next"; -import type { FC, ReactNode } from "react"; +import type { FC, PropsWithChildren } from "react"; +import MainContainer from "@/components/server/main-container"; +import Providers from "@/components/client/providers"; +import { Experimental_CssVarsProvider as CssVarsProvider } from "@mui/material/styles"; +import { theme } from "@/app/theme"; import { AppRouterCacheProvider } from "@mui/material-nextjs/v14-appRouter"; import CssBaseline from "@mui/material/CssBaseline"; -import ErrorHandler from "@/utils/error-handler"; -import dynamic from "next/dynamic"; - -const MainContainer = dynamic( - async () => import("@/components/main-container"), - { - ssr: false, - }, -); +import InitColorSchemeScript from "@/components/client/init-color-scheme-script"; export const metadata: Metadata = { title: "ChessTeacher", description: "Next-gen Online Chess Instructor", }; -export interface Children { - readonly children: ReactNode; -} - // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types -const RootLayout: FC = ({ children }: Readonly) => { +const RootLayout: FC = ({ children }: PropsWithChildren) => { return ( + - - - {children} + + + + {children} + + diff --git a/app/match/[mid]/page.tsx b/app/match/[mid]/page.tsx index 385c36e..b26c24b 100644 --- a/app/match/[mid]/page.tsx +++ b/app/match/[mid]/page.tsx @@ -1,12 +1,8 @@ -"use client"; - import type { FC, ReactElement } from "react"; -import { useContext } from "react"; -import { redirect } from "next/navigation"; -import AuthenticationContext from "@/app/authentication-context"; -import MatchPage from "@/components/match-page"; -import Loader from "@/components/loader"; +import MatchPage from "@/components/server/match-page"; import type { MatchId } from "@/types/database"; +import { verifySession } from "@/utils/server-actions"; +import MatchProvider from "@/components/client/providers/match-provider"; interface MatchProps { readonly params: { @@ -14,18 +10,20 @@ interface MatchProps { }; } -const Match: FC = ({ params }: MatchProps): ReactElement | null => { - const { isLoading, user } = useContext(AuthenticationContext); - - if (isLoading) { - return ; - } +const Match: FC = async ({ + params, +}: MatchProps): Promise => { + const session = await verifySession(); - if (!user) { - redirect("/"); + if (!session) { + return null; } - return ; + return ( + + + + ); }; export default Match; diff --git a/app/page.tsx b/app/page.tsx index 3eecf43..8219929 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,24 +1,16 @@ -"use client"; - import type { FC, ReactElement } from "react"; -import { useContext } from "react"; -import Lobby from "@/components/lobby"; -import HomePage from "@/components/home-page"; -import Loader from "@/components/loader"; -import AuthenticationContext from "@/app/authentication-context"; - -const Home: FC = (): ReactElement | null => { - const { isLoading, user } = useContext(AuthenticationContext); +import HomePage from "@/components/server/home-page"; +import LobbyPage from "@/components/server/lobby-page"; +import { verifySession } from "@/utils/server-actions"; - if (isLoading) { - return ; - } +const Home: FC = async (): Promise => { + const session = await verifySession(); - if (!user) { + if (!session) { return ; } - return ; + return ; }; export default Home; diff --git a/app/theme.ts b/app/theme.ts index 2aa4da1..05a398f 100644 --- a/app/theme.ts +++ b/app/theme.ts @@ -1,37 +1,37 @@ +"use client"; + import { Roboto } from "next/font/google"; -import type { PaletteMode, PaletteOptions, ThemeOptions } from "@mui/material"; +import type { ColorSystemOptions, CssVarsThemeOptions } from "@mui/material"; +import { experimental_extendTheme as extendTheme } from "@mui/material/styles"; const roboto = Roboto({ subsets: ["latin"], weight: "500" }); -const commonPalette: PaletteOptions = {}; +const commonPalette: ColorSystemOptions = { + palette: {}, +}; -const lightPalette: PaletteOptions = { +const lightPalette: ColorSystemOptions = { ...commonPalette, - primary: { - main: "#9e8d8d", + palette: { + primary: { + main: "#9e8d8d", + }, }, }; -const darkPalette: PaletteOptions = { +const darkPalette: ColorSystemOptions = { ...commonPalette, + palette: {}, }; -export const getThemeOptions = (paletteMode: PaletteMode): ThemeOptions => { - let palette: PaletteOptions = {}; - - if ("light" === paletteMode) { - palette = lightPalette; - } else { - palette = darkPalette; - } - - return { - palette: { - mode: paletteMode, - ...palette, - }, - typography: { - fontFamily: roboto.style.fontFamily, - }, - }; +export const cssVarsThemeOptions: CssVarsThemeOptions = { + colorSchemes: { + dark: { ...darkPalette }, + light: { ...lightPalette }, + }, + typography: { + fontFamily: roboto.style.fontFamily, + }, }; + +export const theme = extendTheme(cssVarsThemeOptions); diff --git a/components/account-page.tsx b/components/client/account-page.tsx similarity index 80% rename from components/account-page.tsx rename to components/client/account-page.tsx index f959796..6f2fb85 100644 --- a/components/account-page.tsx +++ b/components/client/account-page.tsx @@ -1,30 +1,32 @@ +"use client"; + import type { FC, ReactElement } from "react"; -import type { User } from "@firebase/auth"; import Paper from "@mui/material/Paper"; import Typography from "@mui/material/Typography"; -import Container from "@mui/material/Container"; import { Avatar, - TextField, - InputAdornment, - IconButton, Grid, + IconButton, + InputAdornment, + TextField, } from "@mui/material"; import LockIcon from "@mui/icons-material/Lock"; import Box from "@mui/material/Box"; +import { useAuthentication } from "@/contexts/authentication"; +import Loader from "@/components/client/loader"; const firstCharPos = 0; -interface AccountPageProps { - readonly user: User; -} +const AccountPage: FC = (): ReactElement | null => { + const { isUserLoading, user } = useAuthentication(); + + if (isUserLoading) { + return ; + } -const AccountPage: FC = ({ - user, -}: AccountPageProps): ReactElement | null => { return ( - - + <> + = ({ - {user.displayName?.charAt(firstCharPos)} + {user?.displayName?.charAt(firstCharPos)} Username: - {user.displayName} + {user?.displayName} @@ -75,7 +77,7 @@ const AccountPage: FC = ({ = ({ = ({ - + ); }; diff --git a/components/chat-provider.tsx b/components/client/chat-provider.tsx similarity index 78% rename from components/chat-provider.tsx rename to components/client/chat-provider.tsx index 9d0ec2d..0e4f6ce 100644 --- a/components/chat-provider.tsx +++ b/components/client/chat-provider.tsx @@ -1,3 +1,5 @@ +"use client"; + import type { FC, ReactElement } from "react"; import { useMemo } from "react"; import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; @@ -24,17 +26,10 @@ import { } from "@chatscope/use-chat"; import { nanoid } from "nanoid"; import { ChatService } from "@/chat/chat-service"; -import Chat from "@/components/chat"; -import type { MatchId, MatchPlayerInfo } from "@/types/database"; -import type { User as FirebaseUser } from "@firebase/auth"; - -interface ChatProviderProps { - readonly fen: string; - readonly player: MatchPlayerInfo | null; - readonly legalMoveCount: number; - readonly user: FirebaseUser; - readonly mid: MatchId; -} +import Chat from "@/components/client/chat"; +import type { MatchId } from "@/types/database"; +import { useMatch } from "@/contexts/match"; +import { useAuthentication } from "@/contexts/authentication"; const messageIdGenerator = (): string => nanoid(); const groupIdGenerator = (): string => nanoid(); @@ -99,25 +94,22 @@ const stockfishUser = new UseChatUser({ bio: "", }); -const ChatProvider: FC = ({ - fen, - player, - legalMoveCount, - user, - mid, -}: ChatProviderProps): ReactElement | null => { +const ChatProvider: FC = (): ReactElement | null => { + const { user } = useAuthentication(); + const { mid } = useMatch(); + const userUser = useMemo( () => new UseChatUser({ - id: user.email ?? "", + id: user?.email ?? "", presence: new Presence({ status: UserStatus.Available, description: "", }), firstName: "", lastName: "", - username: user.email ?? "", - email: user.email ?? "", + username: user?.email ?? "", + email: user?.email ?? "", avatar: "", bio: "", }), @@ -135,14 +127,7 @@ const ChatProvider: FC = ({ autoDraft: AutoDraft.Save | AutoDraft.Restore, }} > - + ); }; diff --git a/components/chat.tsx b/components/client/chat.tsx similarity index 92% rename from components/chat.tsx rename to components/client/chat.tsx index a809384..5af2058 100644 --- a/components/chat.tsx +++ b/components/client/chat.tsx @@ -1,3 +1,5 @@ +"use client"; + import type { FC, ReactElement } from "react"; import { useCallback, useEffect, useMemo } from "react"; import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; @@ -23,18 +25,15 @@ import { import type { StockfishMessageResponse } from "@/app/api/stockfish/route"; import { onChildAdded, push } from "firebase/database"; import { getMessagesRef } from "@/firebase/firebase"; -import type { User as FirebaseUser } from "@firebase/auth"; -import type { MatchId, MatchPlayerInfo } from "@/types/database"; -import Fen from "chess-fen"; +import type { MatchId } from "@/types/database"; import { getLatestMoveColor, normalizeColor } from "@/utils/utils"; +import { useAuthentication } from "@/contexts/authentication"; +import { useMatch } from "@/contexts/match"; +import Loader from "@/components/client/loader"; +import { Chess } from "chess.js"; interface ChatProps { - readonly fen: string; - readonly player: MatchPlayerInfo | null; - readonly legalMoveCount: number; readonly useChatUser: UseChatUser; - readonly firebaseUser: FirebaseUser; - readonly mid: MatchId; } const pushNewMessage = async ( @@ -73,13 +72,11 @@ const callStockfish = async (mid: MatchId, fen: string): Promise => { const openingMoveWasPlayed = 1; const Chat: FC = ({ - fen, - player, - legalMoveCount, useChatUser, - firebaseUser, - mid, }: ChatProps): ReactElement | null => { + const { isUserLoading, user } = useAuthentication(); + const { mid, fen, player, legalMoveCount } = useMatch(); + const { currentMessages, activeConversation, @@ -107,7 +104,7 @@ const Chat: FC = ({ let direction = MessageDirection.Outgoing; - if (message.senderId !== firebaseUser.uid) { + if (message.senderId !== user?.uid) { direction = MessageDirection.Incoming; } @@ -126,11 +123,10 @@ const Chat: FC = ({ senderId: message.senderId, }); }); - }, [firebaseUser, mid, sendMessage]); + }, [user, mid, sendMessage]); useEffect(() => { - const { toMove } = new Fen(fen); - const latestMoveColor = getLatestMoveColor(toMove); + const latestMoveColor = getLatestMoveColor(new Chess(fen).turn()); if ( legalMoveCount < openingMoveWasPlayed || @@ -180,7 +176,7 @@ const Chat: FC = ({ id: "", content: text as unknown as MessageContent, contentType: MessageContentType.TextHtml, - senderId: firebaseUser.uid, + senderId: user?.uid ?? "", direction: MessageDirection.Outgoing, status: MessageStatus.Sent, updatedTime: new Date(), @@ -215,6 +211,10 @@ const Chat: FC = ({ return undefined; }, [activeConversation, getUser]); + if (isUserLoading) { + return ; + } + return ( diff --git a/components/chessboard.tsx b/components/client/chessboard.tsx similarity index 55% rename from components/chessboard.tsx rename to components/client/chessboard.tsx index e5b6f31..563fa26 100644 --- a/components/chessboard.tsx +++ b/components/client/chessboard.tsx @@ -1,21 +1,34 @@ +"use client"; + import type { FC, ReactElement } from "react"; import { useMemo } from "react"; import type { Move, Square } from "chess.js"; import { Chess } from "chess.js"; import { Chessboard as ReactChessboard } from "react-chessboard"; -import type { MatchPlayerInfo } from "@/types/database"; - -interface ChessboardProps { - readonly fen: string; - readonly player: MatchPlayerInfo | null; - readonly onLegalMove: (fen: string) => void; -} - -const Chessboard: FC = ({ - fen, - player, - onLegalMove, -}: ChessboardProps): ReactElement | null => { +import { useMatch } from "@/contexts/match"; +import Loader from "@/components/client/loader"; +import type { MatchId } from "@/types/database"; +import { update } from "firebase/database"; +import { getMatchStateRef } from "@/firebase/firebase"; + +const updateRemoteState = async (mid: MatchId, fen: string): Promise => { + await update(getMatchStateRef(mid), { + fen, + }); +}; + +const Chessboard: FC = (): ReactElement | null => { + const { + mid, + fen, + setFen, + player, + legalMoveCount, + setLegalMoveCount, + legalMoveCountIncrease, + isRemoteFenLoading, + } = useMatch(); + const makeAMove = (move: Readonly): Move | null => { if (!player) { return null; @@ -29,7 +42,13 @@ const Chessboard: FC = ({ return null; } - onLegalMove(gameCopy.fen()); + const newFen = gameCopy.fen(); + + setFen(newFen); + setLegalMoveCount(legalMoveCount + legalMoveCountIncrease); + updateRemoteState(mid, newFen).catch((error: unknown) => { + console.error(error); + }); return result; }; @@ -52,6 +71,10 @@ const Chessboard: FC = ({ return "black"; }, [player]); + if (isRemoteFenLoading) { + return ; + } + return ( = ({ + colorMode, +}: BrightnessIconProps): ReactElement | null => { + if (undefined === colorMode) { + return null; + } + + if ("system" === colorMode) { + return ; + } + + if ("dark" === colorMode) { + return ; + } + + return ; +}; + +const ColorMode: FC = (): ReactElement | null => { + const { mode, setMode } = useColorScheme(); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return null; + } + + const toggleColorModeHandler = (): void => { + let newMode: Mode = "light"; + + if ("light" === mode) { + newMode = "dark"; + } + + if ("dark" === mode) { + newMode = "system"; + } + + setMode(newMode); + }; + + return ( + <> + {mode} + + + + + ); +}; + +export default ColorMode; diff --git a/components/home-page-popover.tsx b/components/client/home-page-popover.tsx similarity index 81% rename from components/home-page-popover.tsx rename to components/client/home-page-popover.tsx index 176526b..fdbbc71 100644 --- a/components/home-page-popover.tsx +++ b/components/client/home-page-popover.tsx @@ -1,4 +1,12 @@ -import type { FC, MouseEvent, ReactElement, ReactNode } from "react"; +"use client"; + +import type { + FC, + MouseEvent, + PropsWithChildren, + ReactElement, + ReactNode, +} from "react"; import { useState } from "react"; import Popover from "@mui/material/Popover"; import Typography from "@mui/material/Typography"; @@ -6,16 +14,15 @@ import Button from "@mui/material/Button"; import Box from "@mui/material/Box"; import SvgIcon from "@mui/material/SvgIcon"; -interface PopoverProps { +interface HomePagePopoverProps { readonly icon: ReactNode; - readonly children: ReactNode; } // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types -const HomePagePopover: FC = ({ +const HomePagePopover: FC> = ({ icon, children, -}: PopoverProps): ReactElement | null => { +}: PropsWithChildren): ReactElement | null => { const [anchorEl, setAnchorEl] = useState(null); const handleClick = (event: MouseEvent): void => { diff --git a/components/client/init-color-scheme-script.tsx b/components/client/init-color-scheme-script.tsx new file mode 100644 index 0000000..d279440 --- /dev/null +++ b/components/client/init-color-scheme-script.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { getInitColorSchemeScript } from "@mui/material/styles"; +import type { FC, ReactElement } from "react"; + +const InitColorSchemeScript: FC = (): ReactElement | null => + getInitColorSchemeScript(); + +export default InitColorSchemeScript; diff --git a/components/loader.tsx b/components/client/loader.tsx similarity index 94% rename from components/loader.tsx rename to components/client/loader.tsx index c1172fb..933f923 100644 --- a/components/loader.tsx +++ b/components/client/loader.tsx @@ -1,3 +1,5 @@ +"use client"; + import CircularProgress from "@mui/material/CircularProgress"; import type { FC, ReactElement } from "react"; diff --git a/components/lobby.tsx b/components/client/lobby-grid.tsx similarity index 56% rename from components/lobby.tsx rename to components/client/lobby-grid.tsx index f7cdb59..8b4915c 100644 --- a/components/lobby.tsx +++ b/components/client/lobby-grid.tsx @@ -1,33 +1,30 @@ -import type { FC, ReactElement, ReactNode } from "react"; -import { useEffect, useMemo, useState } from "react"; -import type { - GridColDef, - GridRenderCellParams, - GridRowsProp, +"use client"; + +import type { FC, ReactElement } from "react"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { + DataGrid, + type GridColDef, + type GridRenderCellParams, + type GridRowsProp, } from "@mui/x-data-grid"; -import { DataGrid } from "@mui/x-data-grid"; -import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; -import Paper from "@mui/material/Paper"; -import Typography from "@mui/material/Typography"; +import { useAuthentication } from "@/contexts/authentication"; +import type { MatchId, MatchPlayerInfo, MatchRecord } from "@/types/database"; +import { type Match, PlayerNumber } from "@/types/database"; +import { + type DataSnapshot, + onChildChanged, + onValue, + set, +} from "firebase/database"; +import { getMatchesRef, getMatchPlayerRef } from "@/firebase/firebase"; import type { User } from "@firebase/auth"; -import FormControl from "@mui/material/FormControl"; -import InputLabel from "@mui/material/InputLabel"; -import Select, { type SelectChangeEvent } from "@mui/material/Select"; -import MenuItem from "@mui/material/MenuItem"; +import type { SessionUser } from "@/types/session"; +import { findPlayerUidByPlayerNumber } from "@/utils/utils"; import type { Color } from "chess.js"; -import type { DataSnapshot } from "firebase/database"; -import { onChildChanged, onValue, push, set } from "firebase/database"; -import { getMatchesRef, getMatchPlayerRef } from "@/firebase/firebase"; -import type { - Match, - MatchId, - MatchPlayerInfo, - MatchRecord, -} from "@/types/database"; -import { PlayerNumber } from "@/types/database"; import { toast } from "react-toastify"; -import { findPlayerUidByPlayerNumber } from "@/utils/utils"; +import Button from "@mui/material/Button"; +import Loader from "@/components/client/loader"; type MatchRowPlayerData = MatchPlayerInfo & { readonly displayName: User["displayName"]; @@ -39,14 +36,10 @@ interface MatchRowModel { readonly playerTwo: MatchRowPlayerData | undefined; } -interface LobbyProperties { - readonly user: User; -} - const getMatchRowPlayerData = ( matchRecord: MatchRecord, key: string, - uid: User["uid"] | undefined, + uid: SessionUser["uid"] | undefined, ): MatchRowPlayerData | undefined => { if (undefined === uid) { return undefined; @@ -99,21 +92,6 @@ const joinMatch = async ( } satisfies MatchPlayerInfo); }; -const createNewMatch = async (user: User, color: Color): Promise => { - await push(getMatchesRef(), { - state: { - fen: false, - }, - players: { - [user.uid]: { - displayName: user.displayName, - color, - playerNumber: 1, - }, - }, - } satisfies Match); -}; - const joinMatchHandler = ( params: GridRenderCellParams, user: User, @@ -200,10 +178,9 @@ const columns = (user: User): GridColDef[] => [ }, ]; -const Lobby: FC = ({ - user, -}: LobbyProperties): ReactElement | null => { - const [color, setColor] = useState("w"); +const LobbyGrid: FC = (): ReactElement | null => { + const { isUserLoading, user } = useAuthentication(); + const [matches, setMatches] = useState(); useEffect(() => { @@ -222,9 +199,9 @@ const Lobby: FC = ({ return; } - displayToast(matchSnapshot, user.uid); + displayToast(matchSnapshot, user?.uid ?? ""); }); - }, [user.uid]); + }, [user?.uid]); const rows: GridRowsProp = useMemo(() => { if (!matches) { @@ -234,73 +211,17 @@ const Lobby: FC = ({ return getMatchRowModelArray(matches); }, [matches]); - const createNewMatchHandler = (): void => { - createNewMatch(user, color).catch((error: unknown) => { - console.error(error); - }); - }; + if (isUserLoading) { + return ; + } - // SelectChangeEvent couldn't be whitelisted even in eslint.config.js... - // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types - const colorSelectionHandler = (event: SelectChangeEvent): void => { - setColor(event.target.value as Color); - }; + if (!user) { + return null; + } return ( - - - - - Welcome back, {user.displayName}! - - - - Color - - - - - - - - - - + ); }; -export default Lobby; +export default LobbyGrid; diff --git a/components/client/lobby-header.tsx b/components/client/lobby-header.tsx new file mode 100644 index 0000000..df0fcb0 --- /dev/null +++ b/components/client/lobby-header.tsx @@ -0,0 +1,104 @@ +"use client"; + +import type { FC, ReactElement } from "react"; +import { useState } from "react"; +import Typography from "@mui/material/Typography"; +import Box from "@mui/material/Box"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import Select, { type SelectChangeEvent } from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import Button from "@mui/material/Button"; +import type { Color } from "chess.js"; +import type { User } from "@firebase/auth"; +import { push } from "firebase/database"; +import { getMatchesRef } from "@/firebase/firebase"; +import type { Match } from "@/types/database"; +import { useAuthentication } from "@/contexts/authentication"; +import Loader from "@/components/client/loader"; + +const createNewMatch = async (user: User, color: Color): Promise => { + await push(getMatchesRef(), { + state: { + fen: false, + }, + players: { + [user.uid]: { + displayName: user.displayName, + color, + playerNumber: 1, + }, + }, + } satisfies Match); +}; + +const LobbyHeader: FC = (): ReactElement | null => { + const { isUserLoading, user } = useAuthentication(); + + const [color, setColor] = useState("w"); + + const createNewMatchHandler = (): void => { + if (!user) { + return; + } + + createNewMatch(user, color).catch((error: unknown) => { + console.error(error); + }); + }; + + // SelectChangeEvent couldn't be whitelisted even in eslint.config.js... + // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types + const colorSelectionHandler = (event: SelectChangeEvent): void => { + setColor(event.target.value as Color); + }; + + if (isUserLoading) { + return ; + } + + return ( + + + Welcome back, {user?.displayName}! + + + Welcome back, {user?.displayName}! + + + + Color + + + + + + ); +}; + +export default LobbyHeader; diff --git a/components/client/player-header.tsx b/components/client/player-header.tsx new file mode 100644 index 0000000..854aaed --- /dev/null +++ b/components/client/player-header.tsx @@ -0,0 +1,68 @@ +"use client"; + +import Typography from "@mui/material/Typography"; +import type { + MatchId, + MatchPlayerInfo, + MatchPlayerRecord, +} from "@/types/database"; +import { get } from "firebase/database"; +import { getMatchPlayersRef } from "@/firebase/firebase"; +import type { FC, ReactElement } from "react"; +import { useMatch } from "@/contexts/match"; + +const getRemotePlayerDisplayNames = async ( + mid: MatchId, +): Promise<{ + readonly playerOne: MatchPlayerInfo; + readonly playerTwo: MatchPlayerInfo | undefined; +}> => { + const playerSnapshot = await get(getMatchPlayersRef(mid)); + + const matchPlayerRecord = playerSnapshot.val() as MatchPlayerRecord; + const [playerOneUid, playerTwoUid] = Object.keys(matchPlayerRecord); + + return { + playerOne: matchPlayerRecord[playerOneUid], + playerTwo: matchPlayerRecord[playerTwoUid] ?? undefined, + }; +}; + +const getPlayerHeader = ( + mid: MatchId, + player: MatchPlayerInfo | null, + opponent: MatchPlayerInfo | null, +): string => { + // Viewer + if (!player && !opponent) { + getRemotePlayerDisplayNames(mid) + .then(({ playerOne, playerTwo }) => { + const playerTwoDisplayName = playerTwo?.displayName ?? "TBA"; + + return `${playerOne.displayName} vs. ${playerTwoDisplayName}`; + }) + .catch((error: unknown) => { + console.error(error); + }); + } + + // Player waiting + if (player && !opponent) { + return `${player.displayName} vs. TBA`; + } + + // Player and opponent + return `${player?.displayName} vs. ${opponent?.displayName}`; +}; + +const PlayerHeader: FC = (): ReactElement | null => { + const { mid, player, opponent } = useMatch(); + + return ( + + {getPlayerHeader(mid, player, opponent)} + + ); +}; + +export default PlayerHeader; diff --git a/components/client/providers.tsx b/components/client/providers.tsx new file mode 100644 index 0000000..5bf0092 --- /dev/null +++ b/components/client/providers.tsx @@ -0,0 +1,20 @@ +"use client"; + +import type { FC, PropsWithChildren, ReactElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import AuthenticationProvider from "@/components/client/providers/authentication-provider"; + +const queryClient = new QueryClient(); + +// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types +const Providers: FC = ({ + children, +}): ReactElement | null => { + return ( + + {children} + + ); +}; + +export default Providers; diff --git a/components/client/providers/authentication-provider.tsx b/components/client/providers/authentication-provider.tsx new file mode 100644 index 0000000..345c5e9 --- /dev/null +++ b/components/client/providers/authentication-provider.tsx @@ -0,0 +1,48 @@ +"use client"; + +import type { FC, PropsWithChildren, ReactElement } from "react"; +import { useEffect, useMemo, useState } from "react"; +import type { User } from "@firebase/auth"; +import { onAuthStateChanged } from "@firebase/auth"; +import { auth } from "@/firebase/firebase"; +import Authentication from "@/contexts/authentication"; + +// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types +const AuthenticationProvider: FC = ({ + children, +}: PropsWithChildren): ReactElement | null => { + const [isUserLoading, setIsUserLoading] = useState(true); + const [user, setUser] = useState(null); + + const value = useMemo( + () => ({ + isUserLoading, + user, + }), + [isUserLoading, user], + ); + + useEffect(() => { + return onAuthStateChanged( + auth, + (firebaseUser) => { + if (!firebaseUser) { + return; + } + + setUser(firebaseUser); + + setIsUserLoading(false); + }, + (error: unknown) => { + console.error("Authentication error", error); + }, + ); + }, []); + + return ( + {children} + ); +}; + +export default AuthenticationProvider; diff --git a/components/client/providers/match-provider.tsx b/components/client/providers/match-provider.tsx new file mode 100644 index 0000000..41bfe7f --- /dev/null +++ b/components/client/providers/match-provider.tsx @@ -0,0 +1,147 @@ +"use client"; + +import type { FC, PropsWithChildren } from "react"; +import { useEffect, useMemo, useState } from "react"; +import Match from "@/contexts/match"; +import { type QueryKey, useQuery } from "@tanstack/react-query"; +import { get, onValue } from "firebase/database"; +import { getMatchPlayersRef, getMatchStateRef } from "@/firebase/firebase"; +import type { + MatchId, + MatchPlayerInfo, + MatchPlayerRecord, + State, +} from "@/types/database"; +import { Chess } from "chess.js"; +import { getLatestMoveColor, normalizeColor } from "@/utils/utils"; +import { useAuthentication } from "@/contexts/authentication"; + +interface MatchProviderProps { + readonly mid: MatchId; +} + +const initialLegalMoveCount = 0; +const legalMoveCountIncrease = 1; + +const getRemoteFen = async ({ + queryKey, +}: { + readonly queryKey: QueryKey; +}): Promise => { + // @ts-expect-error It's challenging to define a query key type + const [, { mid }] = queryKey; + + const snapshot = await get(getMatchStateRef(mid as MatchId)); + + if (!snapshot.exists()) { + return false; + } + + const match = snapshot.val() as State; + + return match.fen; +}; + +// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types +const MatchProvider: FC> = ({ + mid, + children, +}: PropsWithChildren) => { + const { isUserLoading, user } = useAuthentication(); + + const game = useMemo(() => new Chess(), []); + const [fen, setFen] = useState(game.fen()); + const [legalMoveCount, setLegalMoveCount] = useState(initialLegalMoveCount); + const [player, setPlayer] = useState(null); + const [opponent, setOpponent] = useState(null); + + const { isLoading: isRemoteFenLoading, data: remoteFen } = useQuery({ + queryKey: ["remoteFen", { mid }], + queryFn: getRemoteFen, + }); + + const value = useMemo( + () => ({ + mid, + player, + opponent, + fen, + isRemoteFenLoading, + setFen, + legalMoveCount, + setLegalMoveCount, + legalMoveCountIncrease, + }), + [fen, isRemoteFenLoading, legalMoveCount, mid, opponent, player], + ); + + useEffect(() => { + if (false === remoteFen || undefined === remoteFen) { + return; + } + + setFen(remoteFen); + }, [remoteFen]); + + useEffect(() => { + return onValue(getMatchPlayersRef(mid), (matchSnapshot) => { + if (!matchSnapshot.exists()) { + return; + } + + const matchPlayers = matchSnapshot.val() as MatchPlayerRecord; + + const matchPlayerUids = Object.keys(matchPlayers); + + const playerUid = matchPlayerUids.find((uid) => uid === user?.uid); + + const opponentUid = matchPlayerUids.find((uid) => uid !== user?.uid); + + if (undefined !== playerUid) { + setPlayer(matchPlayers[playerUid]); + } + + if (undefined !== playerUid && undefined !== opponentUid) { + setOpponent(matchPlayers[opponentUid]); + } + }); + }, [mid, user?.uid]); + + useEffect(() => { + return onValue(getMatchStateRef(mid), (stateSnapshot) => { + if (!stateSnapshot.exists()) { + return; + } + + const state = stateSnapshot.val() as State; + + // The match has been initialized, but no moves have been played yet + if (false === state.fen) { + return; + } + + const gameCopy = new Chess(state.fen); + + const latestMoveColor = getLatestMoveColor(gameCopy.turn()); + + // The client is a player, and the latest move was by the client/player + if ( + player && + normalizeColor(player.color) === normalizeColor(latestMoveColor) + ) { + return; + } + + // The latest move was by the opponent + setFen(gameCopy.fen()); + }); + }, [mid, player]); + + if (isUserLoading) { + return null; + } + + return {children}; +}; + +export default MatchProvider; diff --git a/components/sign-in-button.tsx b/components/client/sign-in-button.tsx similarity index 68% rename from components/sign-in-button.tsx rename to components/client/sign-in-button.tsx index 98946f9..5830bfd 100644 --- a/components/sign-in-button.tsx +++ b/components/client/sign-in-button.tsx @@ -1,19 +1,22 @@ +"use client"; + import type { FC, ReactElement } from "react"; import { signInWithGoogle } from "@/firebase/auth"; import Button from "@mui/material/Button"; -const SignInButton: FC = (): ReactElement | null => { - const handleSignInWithGoogle = (): void => { - signInWithGoogle().catch((error: unknown) => { - console.error(error); - }); - }; +export const handleSignInWithGoogle = (): void => { + signInWithGoogle().catch((error: unknown) => { + console.error(error); + }); +}; +const SignInButton: FC = (): ReactElement | null => { return ( diff --git a/components/toolbar-menu.tsx b/components/client/toolbar-menu.tsx similarity index 99% rename from components/toolbar-menu.tsx rename to components/client/toolbar-menu.tsx index 841f435..4ce77dc 100644 --- a/components/toolbar-menu.tsx +++ b/components/client/toolbar-menu.tsx @@ -1,3 +1,5 @@ +"use client"; + import type { FC, MouseEvent, ReactElement } from "react"; import { useState } from "react"; import IconButton from "@mui/material/IconButton"; diff --git a/components/main-container.tsx b/components/main-container.tsx deleted file mode 100644 index b5737a0..0000000 --- a/components/main-container.tsx +++ /dev/null @@ -1,106 +0,0 @@ -"use client"; - -import type { FC, ReactElement } from "react"; -import { useEffect, useMemo, useState } from "react"; -import type { Children } from "@/app/layout"; -import Box from "@mui/material/Box"; -import Container from "@mui/material/Container"; -import AuthenticationContext from "@/app/authentication-context"; -import type { User } from "@firebase/auth"; -import { onAuthStateChanged } from "@firebase/auth"; -import { auth } from "@/firebase/firebase"; -import { createTheme, ThemeProvider } from "@mui/material/styles"; -import CssBaseline from "@mui/material/CssBaseline"; -import type { ColorMode } from "@/app/color-mode-context"; -import ColorModeContext, { dark, light } from "@/app/color-mode-context"; -import Navigation from "@/components/navigation"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useLocalStorage } from "usehooks-ts"; -import { ToastContainer } from "react-toastify"; -import "react-toastify/dist/ReactToastify.css"; -import { getThemeOptions } from "@/app/theme"; - -const queryClient = new QueryClient(); - -// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types -const MainContainer: FC = ({ - children, -}: Readonly): ReactElement | null => { - const [colorMode, setColorMode] = useState(light); - const [isLoading, setIsLoading] = useState(true); - const [user, setUser] = useState(null); - const [localStorageColorMode, setLocalStorageColorMode] = - useLocalStorage("color-mode", "light"); - - useEffect(() => { - setColorMode(localStorageColorMode); - }, [localStorageColorMode]); - - const colorModeMemo = useMemo( - () => ({ - toggleColorMode: (): void => { - setColorMode((previousMode) => { - if (light === previousMode) { - setLocalStorageColorMode(dark); - - return dark; - } - - setLocalStorageColorMode(light); - - return light; - }); - }, - }), - [setLocalStorageColorMode], - ); - - const themeMemo = useMemo( - () => createTheme(getThemeOptions(colorMode)), - [colorMode], - ); - - useEffect(() => { - // TODO Memoize auth state - return onAuthStateChanged( - auth, - (firebaseUser) => { - setUser(firebaseUser); - setIsLoading(false); - }, - (error: Readonly) => { - console.error("Authentication error", error); - }, - ); - }, []); - - return ( - - - - - - - - - - {children} - - - - - - - - - ); -}; - -export default MainContainer; diff --git a/components/match-page.tsx b/components/match-page.tsx deleted file mode 100644 index 3dcd11f..0000000 --- a/components/match-page.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import Chessboard from "@/components/chessboard"; -import type { FC, ReactElement } from "react"; -import { useEffect, useMemo, useState } from "react"; -import Grid from "@mui/material/Unstable_Grid2"; -import ChatProvider from "@/components/chat-provider"; -import Box from "@mui/material/Box"; -import type { - MatchId, - MatchPlayerInfo, - MatchPlayerRecord, - State, -} from "@/types/database"; -import { get, onValue, update } from "firebase/database"; -import { getMatchPlayersRef, getMatchStateRef } from "@/firebase/firebase"; -import { Chess } from "chess.js"; -import type { User } from "@firebase/auth"; -import Fen from "chess-fen"; -import { getLatestMoveColor, normalizeColor } from "@/utils/utils"; -import type { QueryKey } from "@tanstack/react-query"; -import { useQuery } from "@tanstack/react-query"; -import Loader from "@/components/loader"; -import Typography from "@mui/material/Typography"; -import Paper from "@mui/material/Paper"; - -interface MatchPageProps { - readonly user: User; - readonly mid: MatchId; -} - -const initialLegalMoveCount = 0; -const legalMoveCountIncrease = 1; - -const getRemotePlayerDisplayNames = async ( - mid: MatchId, -): Promise<{ - readonly playerOne: MatchPlayerInfo; - readonly playerTwo: MatchPlayerInfo | undefined; -}> => { - const playerSnapshot = await get(getMatchPlayersRef(mid)); - - const matchPlayerRecord = playerSnapshot.val() as MatchPlayerRecord; - const [playerOneUid, playerTwoUid] = Object.keys(matchPlayerRecord); - - return { - playerOne: matchPlayerRecord[playerOneUid], - playerTwo: matchPlayerRecord[playerTwoUid] ?? undefined, - }; -}; - -const getPlayerHeader = ( - mid: MatchId, - player: MatchPlayerInfo | null, - opponent: MatchPlayerInfo | null, -): string => { - // Viewer - if (!player && !opponent) { - getRemotePlayerDisplayNames(mid) - .then(({ playerOne, playerTwo }) => { - const playerTwoDisplayName = playerTwo?.displayName ?? "TBA"; - - return `${playerOne.displayName} vs. ${playerTwoDisplayName}`; - }) - .catch((error: unknown) => { - console.error(error); - }); - } - - // Player waiting - if (player && !opponent) { - return `${player.displayName} vs. TBA`; - } - - // Player and opponent - return `${player?.displayName} vs. ${opponent?.displayName}`; -}; - -const getRemoteFen = async ({ - queryKey, -}: { - readonly queryKey: QueryKey; -}): Promise => { - // @ts-expect-error It's challenging to define a query key type - const [, { mid }] = queryKey; - - const snapshot = await get(getMatchStateRef(mid as MatchId)); - - if (!snapshot.exists()) { - return false; - } - - const match = snapshot.val() as State; - - return match.fen; -}; - -const updateRemoteState = async (mid: MatchId, fen: string): Promise => { - await update(getMatchStateRef(mid), { - fen, - }); -}; - -const MatchPage: FC = ({ - user, - mid, -}: MatchPageProps): ReactElement | null => { - const game = useMemo(() => new Chess(), []); - const [fen, setFen] = useState(game.fen()); - const [legalMoveCount, setLegalMoveCount] = useState(initialLegalMoveCount); - const [player, setPlayer] = useState(null); - const [opponent, setOpponent] = useState(null); - - const { isLoading, data: remoteFen } = useQuery({ - queryKey: ["remoteFen", { mid }], - queryFn: getRemoteFen, - }); - - useEffect(() => { - if (false === remoteFen || undefined === remoteFen) { - return; - } - - setFen(remoteFen); - }, [remoteFen]); - - useEffect(() => { - return onValue(getMatchPlayersRef(mid), (matchSnapshot) => { - if (!matchSnapshot.exists()) { - return; - } - - const matchPlayers = matchSnapshot.val() as MatchPlayerRecord; - - const matchPlayerUids = Object.keys(matchPlayers); - - const playerUid = matchPlayerUids.find((uid) => uid === user.uid); - - const opponentUid = matchPlayerUids.find((uid) => uid !== user.uid); - - if (undefined !== playerUid) { - setPlayer(matchPlayers[playerUid]); - } - - if (undefined !== playerUid && undefined !== opponentUid) { - setOpponent(matchPlayers[opponentUid]); - } - }); - }, [mid, user.uid]); - - useEffect(() => { - return onValue(getMatchStateRef(mid), (stateSnapshot) => { - if (!stateSnapshot.exists()) { - return; - } - - const state = stateSnapshot.val() as State; - - // The match has been initialized, but no moves have been played yet - if (false === state.fen) { - return; - } - - const { toMove } = new Fen(state.fen); - const latestMoveColor = getLatestMoveColor(toMove); - - // The client is a player, and the latest move was by the client/player - if ( - player && - normalizeColor(player.color) === normalizeColor(latestMoveColor) - ) { - return; - } - - const gameCopy = new Chess(state.fen); - - // The latest move was by the opponent - setFen(gameCopy.fen()); - }); - }, [mid, player]); - - const onLegalMoveHandler = (liftedFen: string): void => { - setFen(liftedFen); - setLegalMoveCount(legalMoveCount + legalMoveCountIncrease); - - updateRemoteState(mid, liftedFen).catch((error: unknown) => { - console.error(error); - }); - }; - - if (isLoading) { - return ; - } - - return ( - - - - - - {getPlayerHeader(mid, player, opponent)} - - - - - - - - - - - - ); -}; - -export default MatchPage; diff --git a/components/navigation.tsx b/components/navigation.tsx deleted file mode 100644 index 89c3c86..0000000 --- a/components/navigation.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import Toolbar from "@mui/material/Toolbar"; -import Typography from "@mui/material/Typography"; -import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; -import IconButton from "@mui/material/IconButton"; -import Brightness7Icon from "@mui/icons-material/Brightness7"; -import Brightness4Icon from "@mui/icons-material/Brightness4"; -import ToolbarDisplay from "@/components/toolbar-display"; -import AppBar from "@mui/material/AppBar"; -import type { FC, ReactElement } from "react"; -import { useContext } from "react"; -import ColorModeContext from "@/app/color-mode-context"; -import { useTheme } from "@mui/material/styles"; -import type { PaletteMode } from "@mui/material"; - -interface BrightnessIconProps { - readonly colorMode: PaletteMode; -} - -const BrightnessIcon: FC = ({ - colorMode, -}: BrightnessIconProps): ReactElement | null => { - if ("dark" === colorMode) { - return ; - } - - return ; -}; - -const Navigation: FC = (): ReactElement | null => { - const theme = useTheme(); - const { toggleColorMode } = useContext(ColorModeContext); - - return ( - - - - ChessTeacher - - - - - - - - {theme.palette.mode} mode - - - - - - - - - ); -}; - -export default Navigation; diff --git a/components/about-card.tsx b/components/server/about-card.tsx similarity index 85% rename from components/about-card.tsx rename to components/server/about-card.tsx index 7050346..e7aacd0 100644 --- a/components/about-card.tsx +++ b/components/server/about-card.tsx @@ -1,4 +1,4 @@ -import type { FC, ReactElement, ReactNode } from "react"; +import type { FC, PropsWithChildren, ReactElement } from "react"; import Grid from "@mui/material/Grid"; import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; @@ -9,15 +9,14 @@ import CardActionArea from "@mui/material/CardActionArea"; interface AboutCardProps { readonly imageName: string; readonly name: string; - readonly children: ReactNode; } // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types -const AboutCard: FC = ({ +const AboutCard: FC> = ({ imageName, name, children, -}: AboutCardProps): ReactElement | null => { +}: PropsWithChildren): ReactElement | null => { return ( diff --git a/components/about-page.tsx b/components/server/about-page.tsx similarity index 81% rename from components/about-page.tsx rename to components/server/about-page.tsx index d889092..efecc5e 100644 --- a/components/about-page.tsx +++ b/components/server/about-page.tsx @@ -1,15 +1,27 @@ import type { FC, ReactElement } from "react"; import Typography from "@mui/material/Typography"; -import AboutCard from "@/components/about-card"; +import AboutCard from "@/components/server/about-card"; import Grid from "@mui/material/Grid"; +import Box from "@mui/material/Box"; const AboutPage: FC = (): ReactElement | null => { return ( - - + + About Us - + + About Us + + ChessTeacher is a semester project created by the group of computer science students seen below. The project was created to understand how a chess web app could be created, and to experiment with teaching @@ -47,7 +59,7 @@ const AboutPage: FC = (): ReactElement | null => { porttitor sapien. - + ); }; diff --git a/components/home-page.tsx b/components/server/home-page.tsx similarity index 97% rename from components/home-page.tsx rename to components/server/home-page.tsx index 2182aef..07bd5f6 100644 --- a/components/home-page.tsx +++ b/components/server/home-page.tsx @@ -1,7 +1,7 @@ import type { FC, ReactElement } from "react"; import Typography from "@mui/material/Typography"; import Grid from "@mui/material/Unstable_Grid2"; -import HomePagePopover from "@/components/home-page-popover"; +import HomePagePopover from "@/components/client/home-page-popover"; import PersonIcon from "@mui/icons-material/Person"; import StorageIcon from "@mui/icons-material/Storage"; import ComputerIcon from "@mui/icons-material/Computer"; diff --git a/components/server/lobby-page.tsx b/components/server/lobby-page.tsx new file mode 100644 index 0000000..ec0461d --- /dev/null +++ b/components/server/lobby-page.tsx @@ -0,0 +1,26 @@ +import type { FC, ReactElement } from "react"; +import Paper from "@mui/material/Paper"; +import LobbyHeader from "@/components/client/lobby-header"; +import LobbyGrid from "@/components/client/lobby-grid"; + +const LobbyPage: FC = (): ReactElement | null => { + return ( + <> + + + + + + ); +}; + +export default LobbyPage; diff --git a/components/server/main-container.tsx b/components/server/main-container.tsx new file mode 100644 index 0000000..e3c8d00 --- /dev/null +++ b/components/server/main-container.tsx @@ -0,0 +1,38 @@ +import type { FC, PropsWithChildren, ReactElement } from "react"; +import Box from "@mui/material/Box"; +import Container from "@mui/material/Container"; +import Navigation from "@/components/server/navigation"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; + +// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types +const MainContainer: FC = ({ + children, +}: Readonly): ReactElement | null => { + return ( + + + + + + {children} + + + + + + ); +}; + +export default MainContainer; diff --git a/components/server/match-page.tsx b/components/server/match-page.tsx new file mode 100644 index 0000000..d8b6558 --- /dev/null +++ b/components/server/match-page.tsx @@ -0,0 +1,54 @@ +import type { FC, ReactElement } from "react"; +import Grid from "@mui/material/Unstable_Grid2"; +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import PlayerHeader from "@/components/client/player-header"; +import Chessboard from "@/components/client/chessboard"; +import ChatProvider from "@/components/client/chat-provider"; + +const MatchPage: FC = (): ReactElement | null => { + return ( + + + + + + + + + + + + + + + + ); +}; + +export default MatchPage; diff --git a/components/server/navigation.tsx b/components/server/navigation.tsx new file mode 100644 index 0000000..b8b7a04 --- /dev/null +++ b/components/server/navigation.tsx @@ -0,0 +1,46 @@ +import Toolbar from "@mui/material/Toolbar"; +import Typography from "@mui/material/Typography"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import ToolbarDisplay from "@/components/server/toolbar-display"; +import AppBar from "@mui/material/AppBar"; +import type { FC, ReactElement } from "react"; +import ColorMode from "@/components/client/color-mode"; + +const Navigation: FC = (): ReactElement | null => { + return ( + + + + ChessTeacher + + + + + + + + + + + + ); +}; + +export default Navigation; diff --git a/components/server/toolbar-display.tsx b/components/server/toolbar-display.tsx new file mode 100644 index 0000000..a3f4ff3 --- /dev/null +++ b/components/server/toolbar-display.tsx @@ -0,0 +1,16 @@ +import type { FC, ReactElement } from "react"; +import SignInButton from "@/components/client/sign-in-button"; +import ToolbarMenu from "@/components/client/toolbar-menu"; +import { verifySession } from "@/utils/server-actions"; + +const ToolbarDisplay: FC = async (): Promise => { + const session = await verifySession(); + + if (!session) { + return ; + } + + return ; +}; + +export default ToolbarDisplay; diff --git a/components/toolbar-display.tsx b/components/toolbar-display.tsx deleted file mode 100644 index bd20eb7..0000000 --- a/components/toolbar-display.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { FC, ReactElement } from "react"; -import { useContext } from "react"; -import SignInButton from "@/components/sign-in-button"; -import ToolbarMenu from "@/components/toolbar-menu"; -import AuthenticationContext from "@/app/authentication-context"; - -const ToolbarDisplay: FC = (): ReactElement | null => { - const { isLoading, user } = useContext(AuthenticationContext); - - if (isLoading) { - return <>; - } - - if (!user) { - return ; - } - - return ; -}; - -export default ToolbarDisplay; diff --git a/contexts/authentication.ts b/contexts/authentication.ts new file mode 100644 index 0000000..c2b75f8 --- /dev/null +++ b/contexts/authentication.ts @@ -0,0 +1,25 @@ +"use client"; + +import { createContext, useContext } from "react"; +import type { User } from "@firebase/auth"; + +interface AuthenticationContextProps { + readonly isUserLoading: boolean; + readonly user: User | null; +} + +const Authentication = createContext(null); + +export const useAuthentication = (): AuthenticationContextProps => { + const authenticationContext = useContext(Authentication); + + if (!authenticationContext) { + throw new Error( + "useAuthentication has to be used within ", + ); + } + + return authenticationContext; +}; + +export default Authentication; diff --git a/contexts/match.ts b/contexts/match.ts new file mode 100644 index 0000000..24570c1 --- /dev/null +++ b/contexts/match.ts @@ -0,0 +1,31 @@ +"use client"; + +import type { Dispatch, SetStateAction } from "react"; +import { createContext, useContext } from "react"; +import type { MatchId, MatchPlayerInfo } from "@/types/database"; + +interface MatchContextProps { + readonly mid: MatchId; + readonly player: MatchPlayerInfo | null; + readonly opponent: MatchPlayerInfo | null; + readonly fen: string; + readonly isRemoteFenLoading: boolean; + readonly setFen: Dispatch>; + readonly legalMoveCount: number; + readonly setLegalMoveCount: Dispatch>; + readonly legalMoveCountIncrease: number; +} + +const Match = createContext(null); + +export const useMatch = (): MatchContextProps => { + const matchContext = useContext(Match); + + if (!matchContext) { + throw new Error("useMatch has to be used within "); + } + + return matchContext; +}; + +export default Match; diff --git a/eslint.config.js b/eslint.config.js index a40baaa..135be6c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -54,6 +54,11 @@ export default tseslint.config( package: "@mui/x-data-grid", name: ["GridRenderCellParams"], }, + { + from: "package", + package: "next", + name: ["NextRequest"], + }, { from: "package", package: "react", diff --git a/firebase/auth.ts b/firebase/auth.ts index e8c169f..bc76a2e 100644 --- a/firebase/auth.ts +++ b/firebase/auth.ts @@ -4,6 +4,10 @@ import { auth, getActiveUserRef } from "@/firebase/firebase"; import type { DataSnapshot } from "firebase/database"; import { get, remove, set, update } from "firebase/database"; import type { ActiveUser, ActiveUserRecord } from "@/types/database"; +import { + createSessionCookie, + deleteSessionCookie, +} from "@/utils/server-actions"; const sessionCountBeforeRemoval = 1; const sessionCountIncrement = 1; @@ -50,40 +54,45 @@ const setUserAsInactive = async ( await update(getActiveUserRef(uid), { sessionCount }); }; -export const signInWithGoogle = async (): Promise => { +export const signInWithGoogle = async (): Promise< + UserCredential | undefined +> => { const provider = new GoogleAuthProvider(); - try { - const user = await signInWithPopup(auth, provider); + let user: UserCredential | null = null; - const { uid, displayName } = user.user; + try { + user = await signInWithPopup(auth, provider); + } catch (error) { + console.error("Error signing in with Google", error); - const isOnline = await isUserActive(uid); + return undefined; + } - let sessionCount = 1; + const { uid, displayName } = user.user; - if (isOnline) { - const snapshot = await findActiveUser(uid); - const { sessionCount: currentSessionCount } = - snapshot.val() as ActiveUser; - sessionCount = currentSessionCount + sessionCountIncrement; + const isOnline = await isUserActive(uid); - await update(getActiveUserRef(uid), { sessionCount }); + let sessionCount = 1; - return null; - } + if (isOnline) { + const snapshot = await findActiveUser(uid); + const { sessionCount: currentSessionCount } = snapshot.val() as ActiveUser; + sessionCount = currentSessionCount + sessionCountIncrement; + await update(getActiveUserRef(uid), { sessionCount }); + } else { await setUserAsActive({ [uid]: { displayName, sessionCount, }, }); - } catch (error) { - console.error("Error signing in with Google", error); } - return null; + await createSessionCookie(uid); + + return user; }; export const signOut = async (): Promise => { @@ -93,6 +102,8 @@ export const signOut = async (): Promise => { try { await auth.signOut(); + + await deleteSessionCookie(); } catch (error) { console.error("Error signing out with Google", error); } diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..63d735d --- /dev/null +++ b/middleware.ts @@ -0,0 +1,21 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +const protectedRoutes = ["/account", "/match"]; + +export const middleware = (request: NextRequest): NextResponse => { + const path = request.nextUrl.pathname; + const isProtectedRoute = protectedRoutes.includes(path); + + const sessionCookieValue = request.cookies.get("session")?.value; + + if (isProtectedRoute && undefined === sessionCookieValue) { + return NextResponse.redirect(new URL("/", request.nextUrl)); + } + + return NextResponse.next(); +}; + +export const config = { + matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"], +}; diff --git a/package-lock.json b/package-lock.json index 3eca22f..53c73ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,35 +14,35 @@ "@chatscope/use-chat": "^3.1.2", "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.4", - "@emotion/styled": "^11.11.0", + "@emotion/styled": "^11.11.5", "@mui/icons-material": "^5.15.13", - "@mui/material": "^5.15.13", + "@mui/material": "^5.15.15", "@mui/material-nextjs": "^5.15.11", - "@mui/x-data-grid": "^7.1.0", - "@tanstack/react-query": "^5.29.0", - "chess-fen": "^1.0.2", + "@mui/x-data-grid": "^7.3.0", + "@tanstack/react-query": "^5.29.2", "chess.js": "^0.13.4", - "firebase": "^10.9.0", - "nanoid": "^5.0.6", - "next": "^14.1.3", + "firebase": "^10.11.0", + "jose": "^5.2.4", + "nanoid": "^5.0.7", + "next": "^14.3.0-canary.18", "patch-package": "^8.0.0", "react": "^18.2.0", "react-chessboard": "^4.5.0", "react-dom": "^18.2.0", - "react-toastify": "^10.0.5", - "usehooks-ts": "^3.1.0" + "react-toastify": "^10.0.5" }, "devDependencies": { "@eslint/eslintrc": "^3.0.2", - "@types/node": "^20.11.28", - "@types/react": "^18.2.66", - "@types/react-dom": "^18.2.22", + "@eslint/js": "^9.1.1", + "@types/node": "^20.12.7", + "@types/react": "^18.2.79", + "@types/react-dom": "^18.2.25", "eslint": "^8.57.0", - "eslint-config-next": "^14.1.3", + "eslint-config-next": "^14.2.2", "eslint-config-prettier": "^9.1.0", "prettier": "3.2.5", - "typescript": "^5.4.2", - "typescript-eslint": "^7.2.0" + "typescript": "^5.4.5", + "typescript-eslint": "^7.7.1" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -412,12 +412,12 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.1.1.tgz", + "integrity": "sha512-5WoDz3Y19Bg2BnErkZTp0en+c/i9PvgFS7MBe1+m60HjFr0hrphlAGp4yzI7pxpt4xShln4ZyYp4neJm8hmOkQ==", "dev": true, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@fastify/busboy": { @@ -1387,9 +1387,9 @@ } }, "node_modules/@mui/x-data-grid": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.2.0.tgz", - "integrity": "sha512-WKmFo0eKhj3W7Fv8u5n2XP4UcdzuJ+mEYALiMUDAYsah/hPBH9mA1miXn9DjXF3i3dxgzrTjdJemTgTJxAQZKg==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.3.0.tgz", + "integrity": "sha512-IIDS6Yvxe+1eRj65q8cgnJg5yF2aIJYuHrY00W/UaFyjxwj3xSzqg3bdEfbjE2gHGS7lEJJbXSenPNGybzW99A==", "dependencies": { "@babel/runtime": "^7.24.0", "@mui/system": "^5.15.14", @@ -1412,23 +1412,23 @@ } }, "node_modules/@next/env": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.1.tgz", - "integrity": "sha512-qsHJle3GU3CmVx7pUoXcghX4sRN+vINkbLdH611T8ZlsP//grzqVW87BSUgOZeSAD4q7ZdZicdwNe/20U2janA==" + "version": "14.3.0-canary.18", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.3.0-canary.18.tgz", + "integrity": "sha512-fJO724Lwo1OgbLQ7lmqIb5cBBq/lZG0LdFe1lZAXdEspN57uPVc8FYfbHjHvyR5FMiPfBguJGaMCx4phHKbD0w==" }, "node_modules/@next/eslint-plugin-next": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.1.tgz", - "integrity": "sha512-Fp+mthEBjkn8r9qd6o4JgxKp0IDEzW0VYHD8ZC05xS5/lFNwHKuOdr2kVhWG7BQCO9L6eeepshM1Wbs2T+LgSg==", + "version": "14.2.2", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.2.tgz", + "integrity": "sha512-q+Ec2648JtBpKiu/FSJm8HAsFXlNvioHeBCbTP12T1SGcHYwhqHULSfQgFkPgHDu3kzNp2Kem4J54bK4rPQ5SQ==", "dev": true, "dependencies": { "glob": "10.3.10" } }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.1.tgz", - "integrity": "sha512-kGjnjcIJehEcd3rT/3NAATJQndAEELk0J9GmGMXHSC75TMnvpOhONcjNHbjtcWE5HUQnIHy5JVkatrnYm1QhVw==", + "version": "14.3.0-canary.18", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.3.0-canary.18.tgz", + "integrity": "sha512-UW9Ax478Q4TtZgz6Aa00W2ryc67yLZUOId0EeLLuzLYDCI3xZQ8R9o4GgMRKMrPecCyiHvMuLMhEgJu53QLbQA==", "cpu": [ "arm64" ], @@ -1441,9 +1441,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.1.tgz", - "integrity": "sha512-dAdWndgdQi7BK2WSXrx4lae7mYcOYjbHJUhvOUnJjMNYrmYhxbbvJ2xElZpxNxdfA6zkqagIB9He2tQk+l16ew==", + "version": "14.3.0-canary.18", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.3.0-canary.18.tgz", + "integrity": "sha512-957SmYwzz3CD13lzvA3K2n0bvg4EWKPqP+Hw41OAUPo7dMey/8EYe6QBVJgMJD9G0hlSNsN9ukBtYSkQtCvdkg==", "cpu": [ "x64" ], @@ -1456,9 +1456,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.1.tgz", - "integrity": "sha512-2ZctfnyFOGvTkoD6L+DtQtO3BfFz4CapoHnyLTXkOxbZkVRgg3TQBUjTD/xKrO1QWeydeo8AWfZRg8539qNKrg==", + "version": "14.3.0-canary.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.3.0-canary.18.tgz", + "integrity": "sha512-0Dmzk5wcSoUy9snm4mAd77GiPZRQWytoPp9iDV68QbK/6/w+1kv9cox4ozGYf9proRjOPmiePuDGf2MDOM2UxQ==", "cpu": [ "arm64" ], @@ -1471,9 +1471,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.1.tgz", - "integrity": "sha512-jazZXctiaanemy4r+TPIpFP36t1mMwWCKMsmrTRVChRqE6putyAxZA4PDujx0SnfvZHosjdkx9xIq9BzBB5tWg==", + "version": "14.3.0-canary.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.3.0-canary.18.tgz", + "integrity": "sha512-jc+gTXXB+kGy6In6CwAenER1b7YCIN5aVQwwCs5DQPDw3TQf7yHTd4mwyuEkXEljSGHthxHf/kPeXZFOBnjCJg==", "cpu": [ "arm64" ], @@ -1486,9 +1486,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.1.tgz", - "integrity": "sha512-VjCHWCjsAzQAAo8lkBOLEIkBZFdfW+Z18qcQ056kL4KpUYc8o59JhLDCBlhg+hINQRgzQ2UPGma2AURGOH0+Qg==", + "version": "14.3.0-canary.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.3.0-canary.18.tgz", + "integrity": "sha512-qgqqQlbYsY93q7p+wBas5mOrfTKbYXBxF0uIGxHn/hpBE1d453TW46c5ZrphlsKtqzlL7TkSmfJebIcLPP0wiQ==", "cpu": [ "x64" ], @@ -1501,9 +1501,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.1.tgz", - "integrity": "sha512-7HZKYKvAp4nAHiHIbY04finRqjeYvkITOGOurP1aLMexIFG/1+oCnqhGogBdc4lao/lkMW1c+AkwWSzSlLasqw==", + "version": "14.3.0-canary.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.3.0-canary.18.tgz", + "integrity": "sha512-BOPzdvwIrNNEbCFXYMXoNGI8pCRaNg2vYUrxT8A3kk/TwYsJNC6i5dYKNEsMnNWUqYZ8g+X0oFYLOyfQpkVNQA==", "cpu": [ "x64" ], @@ -1516,9 +1516,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.1.tgz", - "integrity": "sha512-YGHklaJ/Cj/F0Xd8jxgj2p8po4JTCi6H7Z3Yics3xJhm9CPIqtl8erlpK1CLv+HInDqEWfXilqatF8YsLxxA2Q==", + "version": "14.3.0-canary.18", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.3.0-canary.18.tgz", + "integrity": "sha512-4yIkRxfNoWsvCIZPB3g7Q+WIyMg9NldfJTH5hSQXJeYct6BK8iEbtGqADjpXADkcipn6V+oSxs2eYJSghjyZCA==", "cpu": [ "arm64" ], @@ -1531,9 +1531,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.1.tgz", - "integrity": "sha512-o+ISKOlvU/L43ZhtAAfCjwIfcwuZstiHVXq/BDsZwGqQE0h/81td95MPHliWCnFoikzWcYqh+hz54ZB2FIT8RA==", + "version": "14.3.0-canary.18", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.3.0-canary.18.tgz", + "integrity": "sha512-CfPKj1IjaYW3+UmbG+W7ayCrPJ1D8FHgWG2YL/XiykxPah4BL0ZjS3VG5sphiUeggnxvIBD6+xav+n+xmHBnFg==", "cpu": [ "ia32" ], @@ -1546,9 +1546,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.1.tgz", - "integrity": "sha512-GmRoTiLcvCLifujlisknv4zu9/C4i9r0ktsA8E51EMqJL4bD4CpO7lDYr7SrUxCR0tS4RVcrqKmCak24T0ohaw==", + "version": "14.3.0-canary.18", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.3.0-canary.18.tgz", + "integrity": "sha512-Xz/myl3XjhU7wziJSBGWM3IV4ozqRpDBcECQtc2avcWPkdslkhIS5b71oQoN14OYEGc9cau+XQ2UI0OZe0qfCQ==", "cpu": [ "x64" ], @@ -1689,35 +1689,29 @@ "integrity": "sha512-hw437iINopmQuxWPSUEvqE56NCPsiU8N4AYtfHmJFckclktzK9YQJieD3XkDCDH4OjL+C7zgPUh73R/nrcHrqw==", "dev": true }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" - }, "node_modules/@swc/helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", - "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.10.tgz", + "integrity": "sha512-CU+RF9FySljn7HVSkkjiB84hWkvTaI3rtLvF433+jRSBL2hMu3zX5bGhHS8C80SM++h4xy8hBSnUHFQHmRXSBw==", "dependencies": { - "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, "node_modules/@tanstack/query-core": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.29.0.tgz", - "integrity": "sha512-WgPTRs58hm9CMzEr5jpISe8HXa3qKQ8CxewdYZeVnA54JrPY9B1CZiwsCoLpLkf0dGRZq+LcX5OiJb0bEsOFww==", + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.32.0.tgz", + "integrity": "sha512-Z3flEgCat55DRXU5UMwYU1U+DgFZKA3iufyOKs+II7iRAo0uXkeU7PH5e6sOH1CGEag0IpKmZxlUFpCg6roSKw==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.29.2.tgz", - "integrity": "sha512-nyuWILR4u7H5moLGSiifLh8kIqQDLNOHGuSz0rcp+J75fNc8aQLyr5+I2JCHU3n+nJrTTW1ssgAD8HiKD7IFBQ==", + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.32.0.tgz", + "integrity": "sha512-+E3UudQtarnx9A6xhpgMZapyF+aJfNBGFMgI459FnduEZqT/9KhOWnMOneZahLRt52yzskSA0AuOyLkXHK0yBA==", "dependencies": { - "@tanstack/query-core": "5.29.0" + "@tanstack/query-core": "5.32.0" }, "funding": { "type": "github", @@ -1790,16 +1784,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.7.0.tgz", - "integrity": "sha512-GJWR0YnfrKnsRoluVO3PRb9r5aMZriiMMM/RHj5nnTrBy1/wIgk76XCtCKcnXGjpZQJQRFtGV9/0JJ6n30uwpQ==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.7.1.tgz", + "integrity": "sha512-KwfdWXJBOviaBVhxO3p5TJiLpNuh2iyXyjmWN0f1nU87pwyvfS0EmjC6ukQVYVFJd/K1+0NWGPDXiyEyQorn0Q==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.7.0", - "@typescript-eslint/type-utils": "7.7.0", - "@typescript-eslint/utils": "7.7.0", - "@typescript-eslint/visitor-keys": "7.7.0", + "@typescript-eslint/scope-manager": "7.7.1", + "@typescript-eslint/type-utils": "7.7.1", + "@typescript-eslint/utils": "7.7.1", + "@typescript-eslint/visitor-keys": "7.7.1", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.3.1", @@ -1825,13 +1819,13 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.7.0.tgz", - "integrity": "sha512-/8INDn0YLInbe9Wt7dK4cXLDYp0fNHP5xKLHvZl3mOT5X17rK/YShXaiNmorl+/U4VKCVIjJnx4Ri5b0y+HClw==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.7.1.tgz", + "integrity": "sha512-PytBif2SF+9SpEUKynYn5g1RHFddJUcyynGpztX3l/ik7KmZEv19WCMhUBkHXPU9es/VWGD3/zg3wg90+Dh2rA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.7.0", - "@typescript-eslint/visitor-keys": "7.7.0" + "@typescript-eslint/types": "7.7.1", + "@typescript-eslint/visitor-keys": "7.7.1" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -1842,9 +1836,9 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.7.0.tgz", - "integrity": "sha512-G01YPZ1Bd2hn+KPpIbrAhEWOn5lQBrjxkzHkWvP6NucMXFtfXoevK82hzQdpfuQYuhkvFDeQYbzXCjR1z9Z03w==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.7.1.tgz", + "integrity": "sha512-AmPmnGW1ZLTpWa+/2omPrPfR7BcbUU4oha5VIbSbS1a1Tv966bklvLNXxp3mrbc+P2j4MNOTfDffNsk4o0c6/w==", "dev": true, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -1855,12 +1849,12 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.0.tgz", - "integrity": "sha512-h0WHOj8MhdhY8YWkzIF30R379y0NqyOHExI9N9KCzvmu05EgG4FumeYa3ccfKUSphyWkWQE1ybVrgz/Pbam6YA==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.1.tgz", + "integrity": "sha512-gBL3Eq25uADw1LQ9kVpf3hRM+DWzs0uZknHYK3hq4jcTPqVCClHGDnB6UUUV2SFeBeA4KWHWbbLqmbGcZ4FYbw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/types": "7.7.1", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1917,13 +1911,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.7.0.tgz", - "integrity": "sha512-bOp3ejoRYrhAlnT/bozNQi3nio9tIgv3U5C0mVDdZC7cpcQEDZXvq8inrHYghLVwuNABRqrMW5tzAv88Vy77Sg==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.7.1.tgz", + "integrity": "sha512-ZksJLW3WF7o75zaBPScdW1Gbkwhd/lyeXGf1kQCxJaOeITscoSl0MjynVvCzuV5boUz/3fOI06Lz8La55mu29Q==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.7.0", - "@typescript-eslint/utils": "7.7.0", + "@typescript-eslint/typescript-estree": "7.7.1", + "@typescript-eslint/utils": "7.7.1", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1944,9 +1938,9 @@ } }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.7.0.tgz", - "integrity": "sha512-G01YPZ1Bd2hn+KPpIbrAhEWOn5lQBrjxkzHkWvP6NucMXFtfXoevK82hzQdpfuQYuhkvFDeQYbzXCjR1z9Z03w==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.7.1.tgz", + "integrity": "sha512-AmPmnGW1ZLTpWa+/2omPrPfR7BcbUU4oha5VIbSbS1a1Tv966bklvLNXxp3mrbc+P2j4MNOTfDffNsk4o0c6/w==", "dev": true, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -1957,13 +1951,13 @@ } }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.7.0.tgz", - "integrity": "sha512-8p71HQPE6CbxIBy2kWHqM1KGrC07pk6RJn40n0DSc6bMOBBREZxSDJ+BmRzc8B5OdaMh1ty3mkuWRg4sCFiDQQ==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.7.1.tgz", + "integrity": "sha512-CXe0JHCXru8Fa36dteXqmH2YxngKJjkQLjxzoj6LYwzZ7qZvgsLSc+eqItCrqIop8Vl2UKoAi0StVWu97FQZIQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.7.0", - "@typescript-eslint/visitor-keys": "7.7.0", + "@typescript-eslint/types": "7.7.1", + "@typescript-eslint/visitor-keys": "7.7.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1985,12 +1979,12 @@ } }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.0.tgz", - "integrity": "sha512-h0WHOj8MhdhY8YWkzIF30R379y0NqyOHExI9N9KCzvmu05EgG4FumeYa3ccfKUSphyWkWQE1ybVrgz/Pbam6YA==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.1.tgz", + "integrity": "sha512-gBL3Eq25uADw1LQ9kVpf3hRM+DWzs0uZknHYK3hq4jcTPqVCClHGDnB6UUUV2SFeBeA4KWHWbbLqmbGcZ4FYbw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/types": "7.7.1", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2091,17 +2085,17 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.7.0.tgz", - "integrity": "sha512-LKGAXMPQs8U/zMRFXDZOzmMKgFv3COlxUQ+2NMPhbqgVm6R1w+nU1i4836Pmxu9jZAuIeyySNrN/6Rc657ggig==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.7.1.tgz", + "integrity": "sha512-QUvBxPEaBXf41ZBbaidKICgVL8Hin0p6prQDu6bbetWo39BKbWJxRsErOzMNT1rXvTll+J7ChrbmMCXM9rsvOQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.15", "@types/semver": "^7.5.8", - "@typescript-eslint/scope-manager": "7.7.0", - "@typescript-eslint/types": "7.7.0", - "@typescript-eslint/typescript-estree": "7.7.0", + "@typescript-eslint/scope-manager": "7.7.1", + "@typescript-eslint/types": "7.7.1", + "@typescript-eslint/typescript-estree": "7.7.1", "semver": "^7.6.0" }, "engines": { @@ -2116,13 +2110,13 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.7.0.tgz", - "integrity": "sha512-/8INDn0YLInbe9Wt7dK4cXLDYp0fNHP5xKLHvZl3mOT5X17rK/YShXaiNmorl+/U4VKCVIjJnx4Ri5b0y+HClw==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.7.1.tgz", + "integrity": "sha512-PytBif2SF+9SpEUKynYn5g1RHFddJUcyynGpztX3l/ik7KmZEv19WCMhUBkHXPU9es/VWGD3/zg3wg90+Dh2rA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.7.0", - "@typescript-eslint/visitor-keys": "7.7.0" + "@typescript-eslint/types": "7.7.1", + "@typescript-eslint/visitor-keys": "7.7.1" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2133,9 +2127,9 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.7.0.tgz", - "integrity": "sha512-G01YPZ1Bd2hn+KPpIbrAhEWOn5lQBrjxkzHkWvP6NucMXFtfXoevK82hzQdpfuQYuhkvFDeQYbzXCjR1z9Z03w==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.7.1.tgz", + "integrity": "sha512-AmPmnGW1ZLTpWa+/2omPrPfR7BcbUU4oha5VIbSbS1a1Tv966bklvLNXxp3mrbc+P2j4MNOTfDffNsk4o0c6/w==", "dev": true, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2146,13 +2140,13 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.7.0.tgz", - "integrity": "sha512-8p71HQPE6CbxIBy2kWHqM1KGrC07pk6RJn40n0DSc6bMOBBREZxSDJ+BmRzc8B5OdaMh1ty3mkuWRg4sCFiDQQ==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.7.1.tgz", + "integrity": "sha512-CXe0JHCXru8Fa36dteXqmH2YxngKJjkQLjxzoj6LYwzZ7qZvgsLSc+eqItCrqIop8Vl2UKoAi0StVWu97FQZIQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.7.0", - "@typescript-eslint/visitor-keys": "7.7.0", + "@typescript-eslint/types": "7.7.1", + "@typescript-eslint/visitor-keys": "7.7.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2174,12 +2168,12 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.0.tgz", - "integrity": "sha512-h0WHOj8MhdhY8YWkzIF30R379y0NqyOHExI9N9KCzvmu05EgG4FumeYa3ccfKUSphyWkWQE1ybVrgz/Pbam6YA==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.1.tgz", + "integrity": "sha512-gBL3Eq25uADw1LQ9kVpf3hRM+DWzs0uZknHYK3hq4jcTPqVCClHGDnB6UUUV2SFeBeA4KWHWbbLqmbGcZ4FYbw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/types": "7.7.1", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2608,9 +2602,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001610", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001610.tgz", - "integrity": "sha512-QFutAY4NgaelojVMjY63o6XlZyORPaLfyMnsl3HgnWdJUcX6K0oaJymHjH8PT5Gk7sTm8rvC/c5COUQKXqmOMA==", + "version": "1.0.30001612", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001612.tgz", + "integrity": "sha512-lFgnZ07UhaCcsSZgWW0K5j4e69dK1u/ltrL9lTUiFOwNHs12S3UMIEYgBV0Z6C6hRDev7iRnMzzYmKabYdXF9g==", "funding": [ { "type": "opencollective", @@ -2641,11 +2635,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chess-fen": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/chess-fen/-/chess-fen-1.0.2.tgz", - "integrity": "sha512-gXSgU0ZYcFmr5suukrgdp3r/P2xavj9Ng6//n8tKIdNfgZ6IGSikdxXi1GvL3K0S82VbS2kpqQstWx+F2tWEyQ==" - }, "node_modules/chess.js": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/chess.js/-/chess.js-0.13.4.tgz", @@ -2723,9 +2712,9 @@ } }, "node_modules/clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "engines": { "node": ">=6" } @@ -3218,12 +3207,12 @@ } }, "node_modules/eslint-config-next": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.1.tgz", - "integrity": "sha512-BgD0kPCWMlqoItRf3xe9fG0MqwObKfVch+f2ccwDpZiCJA8ghkz2wrASH+bI6nLZzGcOJOpMm1v1Q1euhfpt4Q==", + "version": "14.2.2", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.2.tgz", + "integrity": "sha512-12/uFc0KX+wUs7EDpOUGKMXBXZJiBVGdK5/m/QgXOCg2mQ0bQWoKSWNrCeOg7Vum6Kw1d1TW453W6xh+GbHquw==", "dev": true, "dependencies": { - "@next/eslint-plugin-next": "14.2.1", + "@next/eslint-plugin-next": "14.2.2", "@rushstack/eslint-patch": "^1.3.3", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0", "eslint-import-resolver-node": "^0.3.6", @@ -3550,6 +3539,15 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/eslint/node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -4667,6 +4665,14 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.4.tgz", + "integrity": "sha512-6ScbIk2WWCeXkmzF6bRPmEuaqy1m8SbsRFMa/FLrSCkGIhj8OLVG/IH+XHVmNMx/KUo8cVWEE6oKR4dJ+S0Rkg==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4843,11 +4849,6 @@ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4958,12 +4959,12 @@ "dev": true }, "node_modules/next": { - "version": "14.2.1", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.1.tgz", - "integrity": "sha512-SF3TJnKdH43PMkCcErLPv+x/DY1YCklslk3ZmwaVoyUfDgHKexuKlf9sEfBQ69w+ue8jQ3msLb+hSj1T19hGag==", + "version": "14.3.0-canary.18", + "resolved": "https://registry.npmjs.org/next/-/next-14.3.0-canary.18.tgz", + "integrity": "sha512-N5Bm0HYMSqPR7y+o4oOUfn9NX7kmRwrDXiV4AZ8vYyIneoR816Qk1L2/JRnCKqf6ZvwZRXg2ZgsvTyitU9bKqQ==", "dependencies": { - "@next/env": "14.2.1", - "@swc/helpers": "0.5.5", + "@next/env": "14.3.0-canary.18", + "@swc/helpers": "0.5.10", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "graceful-fs": "^4.2.11", @@ -4977,15 +4978,15 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.1", - "@next/swc-darwin-x64": "14.2.1", - "@next/swc-linux-arm64-gnu": "14.2.1", - "@next/swc-linux-arm64-musl": "14.2.1", - "@next/swc-linux-x64-gnu": "14.2.1", - "@next/swc-linux-x64-musl": "14.2.1", - "@next/swc-win32-arm64-msvc": "14.2.1", - "@next/swc-win32-ia32-msvc": "14.2.1", - "@next/swc-win32-x64-msvc": "14.2.1" + "@next/swc-darwin-arm64": "14.3.0-canary.18", + "@next/swc-darwin-x64": "14.3.0-canary.18", + "@next/swc-linux-arm64-gnu": "14.3.0-canary.18", + "@next/swc-linux-arm64-musl": "14.3.0-canary.18", + "@next/swc-linux-x64-gnu": "14.3.0-canary.18", + "@next/swc-linux-x64-musl": "14.3.0-canary.18", + "@next/swc-win32-arm64-msvc": "14.3.0-canary.18", + "@next/swc-win32-ia32-msvc": "14.3.0-canary.18", + "@next/swc-win32-x64-msvc": "14.3.0-canary.18" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -6443,14 +6444,14 @@ } }, "node_modules/typescript-eslint": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-7.7.0.tgz", - "integrity": "sha512-wZZ+7mTQJCn4mGAvzdERtL4vwKGM/mF9cMSMeKUllz3Hgbd1Mdd5L60Q+nJmCio9RB4OyMMr0EX4Ry2Q7jiAyw==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-7.7.1.tgz", + "integrity": "sha512-ykEBfa3xx3odjZy6GRED4SCPrjo0rgHwstLlEgLX4EMEuv7QeIDSmfV+S6Kk+XkbsYn4BDEcPvsci1X26lRpMQ==", "dev": true, "dependencies": { - "@typescript-eslint/eslint-plugin": "7.7.0", - "@typescript-eslint/parser": "7.7.0", - "@typescript-eslint/utils": "7.7.0" + "@typescript-eslint/eslint-plugin": "7.7.1", + "@typescript-eslint/parser": "7.7.1", + "@typescript-eslint/utils": "7.7.1" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -6469,15 +6470,15 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.7.0.tgz", - "integrity": "sha512-fNcDm3wSwVM8QYL4HKVBggdIPAy9Q41vcvC/GtDobw3c4ndVT3K6cqudUmjHPw8EAp4ufax0o58/xvWaP2FmTg==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.7.1.tgz", + "integrity": "sha512-vmPzBOOtz48F6JAGVS/kZYk4EkXao6iGrD838sp1w3NQQC0W8ry/q641KU4PrG7AKNAf56NOcR8GOpH8l9FPCw==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.7.0", - "@typescript-eslint/types": "7.7.0", - "@typescript-eslint/typescript-estree": "7.7.0", - "@typescript-eslint/visitor-keys": "7.7.0", + "@typescript-eslint/scope-manager": "7.7.1", + "@typescript-eslint/types": "7.7.1", + "@typescript-eslint/typescript-estree": "7.7.1", + "@typescript-eslint/visitor-keys": "7.7.1", "debug": "^4.3.4" }, "engines": { @@ -6497,13 +6498,13 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.7.0.tgz", - "integrity": "sha512-/8INDn0YLInbe9Wt7dK4cXLDYp0fNHP5xKLHvZl3mOT5X17rK/YShXaiNmorl+/U4VKCVIjJnx4Ri5b0y+HClw==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.7.1.tgz", + "integrity": "sha512-PytBif2SF+9SpEUKynYn5g1RHFddJUcyynGpztX3l/ik7KmZEv19WCMhUBkHXPU9es/VWGD3/zg3wg90+Dh2rA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.7.0", - "@typescript-eslint/visitor-keys": "7.7.0" + "@typescript-eslint/types": "7.7.1", + "@typescript-eslint/visitor-keys": "7.7.1" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -6514,9 +6515,9 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.7.0.tgz", - "integrity": "sha512-G01YPZ1Bd2hn+KPpIbrAhEWOn5lQBrjxkzHkWvP6NucMXFtfXoevK82hzQdpfuQYuhkvFDeQYbzXCjR1z9Z03w==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.7.1.tgz", + "integrity": "sha512-AmPmnGW1ZLTpWa+/2omPrPfR7BcbUU4oha5VIbSbS1a1Tv966bklvLNXxp3mrbc+P2j4MNOTfDffNsk4o0c6/w==", "dev": true, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -6527,13 +6528,13 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.7.0.tgz", - "integrity": "sha512-8p71HQPE6CbxIBy2kWHqM1KGrC07pk6RJn40n0DSc6bMOBBREZxSDJ+BmRzc8B5OdaMh1ty3mkuWRg4sCFiDQQ==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.7.1.tgz", + "integrity": "sha512-CXe0JHCXru8Fa36dteXqmH2YxngKJjkQLjxzoj6LYwzZ7qZvgsLSc+eqItCrqIop8Vl2UKoAi0StVWu97FQZIQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.7.0", - "@typescript-eslint/visitor-keys": "7.7.0", + "@typescript-eslint/types": "7.7.1", + "@typescript-eslint/visitor-keys": "7.7.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -6555,12 +6556,12 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.0.tgz", - "integrity": "sha512-h0WHOj8MhdhY8YWkzIF30R379y0NqyOHExI9N9KCzvmu05EgG4FumeYa3ccfKUSphyWkWQE1ybVrgz/Pbam6YA==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.1.tgz", + "integrity": "sha512-gBL3Eq25uADw1LQ9kVpf3hRM+DWzs0uZknHYK3hq4jcTPqVCClHGDnB6UUUV2SFeBeA4KWHWbbLqmbGcZ4FYbw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/types": "7.7.1", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -6643,20 +6644,6 @@ "punycode": "^2.1.0" } }, - "node_modules/usehooks-ts": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.0.tgz", - "integrity": "sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==", - "dependencies": { - "lodash.debounce": "^4.0.8" - }, - "engines": { - "node": ">=16.15.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18" - } - }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", diff --git a/package.json b/package.json index 2e81985..0eac5eb 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,10 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "NODE_OPTIONS='--inspect' node_modules/.bin/next dev", - "build": "node_modules/.bin/next build", - "start": "node_modules/.bin/next start", - "lint": "node_modules/.bin/next lint", + "dev": "NODE_OPTIONS='--inspect' next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", "lint:ci": "eslint -o eslint-report.json -f json .", "prettier": "prettier -c **/*.{js,cjs,mjs,jsx,ts,tsx,json,html,css,md,yaml}", "postinstall": "patch-package" @@ -18,34 +18,34 @@ "@chatscope/use-chat": "^3.1.2", "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.4", - "@emotion/styled": "^11.11.0", + "@emotion/styled": "^11.11.5", "@mui/icons-material": "^5.15.13", - "@mui/material": "^5.15.13", + "@mui/material": "^5.15.15", "@mui/material-nextjs": "^5.15.11", - "@mui/x-data-grid": "^7.1.0", - "@tanstack/react-query": "^5.29.0", - "chess-fen": "^1.0.2", + "@mui/x-data-grid": "^7.3.0", + "@tanstack/react-query": "^5.29.2", "chess.js": "^0.13.4", - "firebase": "^10.9.0", - "nanoid": "^5.0.6", - "next": "^14.1.3", + "firebase": "^10.11.0", + "jose": "^5.2.4", + "nanoid": "^5.0.7", + "next": "^14.3.0-canary.18", "patch-package": "^8.0.0", "react": "^18.2.0", "react-chessboard": "^4.5.0", "react-dom": "^18.2.0", - "react-toastify": "^10.0.5", - "usehooks-ts": "^3.1.0" + "react-toastify": "^10.0.5" }, "devDependencies": { "@eslint/eslintrc": "^3.0.2", - "@types/node": "^20.11.28", - "@types/react": "^18.2.66", - "@types/react-dom": "^18.2.22", + "@eslint/js": "^9.1.1", + "@types/node": "^20.12.7", + "@types/react": "^18.2.79", + "@types/react-dom": "^18.2.25", "eslint": "^8.57.0", - "eslint-config-next": "^14.1.3", + "eslint-config-next": "^14.2.2", "eslint-config-prettier": "^9.1.0", "prettier": "3.2.5", - "typescript": "^5.4.2", - "typescript-eslint": "^7.2.0" + "typescript": "^5.4.5", + "typescript-eslint": "^7.7.1" } } diff --git a/patches/next+14.2.1.patch b/patches/next+14.3.0-canary.18.patch similarity index 87% rename from patches/next+14.2.1.patch rename to patches/next+14.3.0-canary.18.patch index 1a5ee7a..02d7a46 100644 --- a/patches/next+14.2.1.patch +++ b/patches/next+14.3.0-canary.18.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/next/dist/cli/next-dev.js b/node_modules/next/dist/cli/next-dev.js -index f7a7240..a58ace1 100644 +index 2084b26..93cb842 100644 --- a/node_modules/next/dist/cli/next-dev.js +++ b/node_modules/next/dist/cli/next-dev.js -@@ -208,7 +208,7 @@ const nextDev = async (options, portSource, directory)=>{ +@@ -204,7 +204,7 @@ const nextDev = async (options, portSource, directory)=>{ NODE_OPTIONS = `${NODE_OPTIONS} --max-old-space-size=${Math.floor(totalMemInMB * 0.5)}`; } if (nodeDebugType) { diff --git a/tsconfig.json b/tsconfig.json index e7ff90f..d81d4ee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -18,9 +22,19 @@ } ], "paths": { - "@/*": ["./*"] - } + "@/*": [ + "./*" + ] + }, + "target": "ES2017" }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/types/chess.js.d.ts b/types/chess.js.d.ts index 58fb00a..95d46f5 100644 --- a/types/chess.js.d.ts +++ b/types/chess.js.d.ts @@ -88,6 +88,8 @@ declare module "chess.js" { public fen(): string; + public turn(): Color; + public game_over(): boolean; public in_draw(): boolean; diff --git a/types/database.ts b/types/database.ts index 0f96fc2..e79333e 100644 --- a/types/database.ts +++ b/types/database.ts @@ -30,6 +30,6 @@ export interface Match { readonly players: MatchPlayerRecord; } -export type MatchId = string; +export type MatchId = Readonly; export type MatchRecord = Readonly>; diff --git a/types/session.ts b/types/session.ts new file mode 100644 index 0000000..dee341b --- /dev/null +++ b/types/session.ts @@ -0,0 +1,8 @@ +import type { User } from "@firebase/auth"; +import type { JWTPayload } from "jose"; + +export type SessionUser = Pick; + +export interface SessionPayload extends JWTPayload, SessionUser { + readonly maxAge: number; +} diff --git a/utils/error-handler.tsx b/utils/error-handler.tsx deleted file mode 100644 index 19a21ec..0000000 --- a/utils/error-handler.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; - -/* eslint-disable */ -import type { FC } from "react"; -import { useEffect } from "react"; - -const ErrorHandler: FC = () => { - useEffect(() => { - const originalConsoleError = console.error; - - console.error = (...args: readonly any[]): void => { - if ("string" === typeof args[0] && args[0].includes("defaultProps")) { - return; - } - - originalConsoleError(...args); - }; - - return () => { - console.error = originalConsoleError; - }; - }, []); - - return null; -}; - -export default ErrorHandler; -/* eslint-enable */ diff --git a/utils/server-actions.ts b/utils/server-actions.ts new file mode 100644 index 0000000..3f19bc4 --- /dev/null +++ b/utils/server-actions.ts @@ -0,0 +1,33 @@ +"use server"; + +import { createSession, decrypt, deleteSession } from "@/utils/session"; +import type { User } from "@firebase/auth"; +import type { SessionPayload } from "@/types/session"; +import { cookies } from "next/headers"; + +export const createSessionCookie = async (uid: User["uid"]): Promise => { + await createSession(uid); +}; + +export const deleteSessionCookie = async (): Promise => { + await deleteSession(); +}; + +export const verifySession = async (): Promise => { + const sessionCookieValue = cookies().get("session")?.value; + + if (undefined === sessionCookieValue) { + return null; + } + + const sessionPayload = await decrypt(sessionCookieValue); + + if (undefined === sessionPayload) { + return null; + } + + return { + uid: sessionPayload.uid, + maxAge: sessionPayload.maxAge, + }; +}; diff --git a/utils/session.ts b/utils/session.ts new file mode 100644 index 0000000..6b7434c --- /dev/null +++ b/utils/session.ts @@ -0,0 +1,64 @@ +import "server-only"; + +import { jwtVerify, SignJWT } from "jose"; +import type { SessionPayload, SessionUser } from "@/types/session"; +import { cookies } from "next/headers"; + +const encodedKey = new TextEncoder().encode(process.env.SECRET_KEY); + +const secondsInMinute = 60; +const minutesInHour = 60; +const hoursInDay = 24; +const daysInWeek = 7; +const oneWeek = secondsInMinute * minutesInHour * hoursInDay * daysInWeek; + +export const encrypt = async ( + // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types + sessionPayload: SessionPayload, +): Promise => { + return new SignJWT(sessionPayload) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("7d") + .sign(encodedKey); +}; + +export const decrypt = async ( + sessionCookieValue: string, +): Promise => { + try { + const { payload } = await jwtVerify( + sessionCookieValue, + encodedKey, + { + algorithms: ["HS256"], + }, + ); + + return payload; + } catch (error) { + console.error("Failed to verify session", error); + } + + return undefined; +}; + +export const createSession = async (uid: SessionUser["uid"]): Promise => { + const sessionCookieValue = await encrypt({ + uid, + maxAge: oneWeek, + }); + + cookies().set("session", sessionCookieValue, { + httpOnly: true, + secure: true, + maxAge: oneWeek, + sameSite: "lax", + path: "/", + }); +}; + +// eslint-disable-next-line @typescript-eslint/require-await +export const deleteSession = async (): Promise => { + cookies().delete("session"); +}; diff --git a/utils/utils.ts b/utils/utils.ts index 7dfecba..770191c 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -1,5 +1,4 @@ -import type { Color as ChessFenColor } from "chess-fen"; -import type { Color as ChessJsColor } from "chess.js"; +import type { Color } from "chess.js"; import type { MatchPlayerRecord, PlayerNumber } from "@/types/database"; const colorMap: Record = { @@ -7,16 +6,16 @@ const colorMap: Record = { w: "white", }; -export const normalizeColor = (color: ChessFenColor | ChessJsColor): string => { +export const normalizeColor = (color: Color): string => { return colorMap[color] || color; }; -export const getLatestMoveColor = (color: ChessFenColor): ChessFenColor => { - if ("white" === color) { - return "black"; +export const getLatestMoveColor = (color: Color): Color => { + if ("w" === color) { + return "b"; } - return "white"; + return "w"; }; export const findPlayerUidByPlayerNumber = (