diff --git a/.vscode/settings.json b/.vscode/settings.json index 768a9bef..84213dbb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,7 +23,9 @@ "dayjs", "embla", "esnext", + "filesize", "mantine", + "Marston", "nextron", "tabler", "tanstack", diff --git a/README.md b/README.md index bd634644..851ef2ad 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ ## :sparkles: Features -- **Create & Manage Profiles:** Easily create and manage player profiles for your games. -- **Create A Game With Up to 8 Players:** Set up a game with up to 8 players and customize various game settings. +- **Create & Manage Profiles:** Easily create and manage player profiles for your matches. +- **Create A Match:** Set up a game with and customize various game settings. +- **Analyze Your Latest Matches:** Gain valuable insights into your performance and track your progress. ## :robot: Scripts diff --git a/package-lock.json b/package-lock.json index e2fd8501..a0f43b4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,9 @@ "hasInstallScript": true, "dependencies": { "@tanstack/react-query": "^4.33.0", - "@tanstack/react-query-devtools": "^4.33.0", "electron-serve": "^1.1.0", - "electron-store": "^8.1.0" + "electron-store": "^8.1.0", + "filesize": "^10.0.12" }, "devDependencies": { "@emotion/react": "^11.11.1", @@ -29,6 +29,7 @@ "@next/eslint-plugin-next": "^13.4.19", "@tabler/icons-react": "^2.32.0", "@tanstack/eslint-plugin-query": "^4.34.1", + "@tanstack/react-query-devtools": "^4.36.1", "@types/node": "^18.17.12", "@types/react": "^18.2.21", "@types/react-avatar-editor": "^13.0.0", @@ -3048,6 +3049,7 @@ "version": "8.8.4", "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.8.4.tgz", "integrity": "sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw==", + "dev": true, "dependencies": { "remove-accents": "0.4.2" }, @@ -3060,20 +3062,20 @@ } }, "node_modules/@tanstack/query-core": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.33.0.tgz", - "integrity": "sha512-qYu73ptvnzRh6se2nyBIDHGBQvPY1XXl3yR769B7B6mIDD7s+EZhdlWHQ67JI6UOTFRaI7wupnTnwJ3gE0Mr/g==", + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.36.1.tgz", + "integrity": "sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tanstack/react-query": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.33.0.tgz", - "integrity": "sha512-97nGbmDK0/m0B86BdiXzx3EW9RcDYKpnyL2+WwyuLHEgpfThYAnXFaMMmnTDuAO4bQJXEhflumIEUfKmP7ESGA==", + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.36.1.tgz", + "integrity": "sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw==", "dependencies": { - "@tanstack/query-core": "4.33.0", + "@tanstack/query-core": "4.36.1", "use-sync-external-store": "^1.2.0" }, "funding": { @@ -3095,9 +3097,10 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.33.0.tgz", - "integrity": "sha512-6gegkuDmOoiY5e6ZKj1id48vlCXchjfE/6tIpYO8dFlVMQ7t1bYna/Ce6qQJ69+kfEHbYiTTn2lj+FDjIBH7Hg==", + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.36.1.tgz", + "integrity": "sha512-WYku83CKP3OevnYSG8Y/QO9g0rT75v1om5IvcWUwiUZJ4LanYGLVCZ8TdFG5jfsq4Ej/lu2wwDAULEUnRIMBSw==", + "dev": true, "dependencies": { "@tanstack/match-sorter-utils": "^8.7.0", "superjson": "^1.10.0", @@ -3108,7 +3111,7 @@ "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^4.33.0", + "@tanstack/react-query": "^4.36.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } @@ -4798,6 +4801,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dev": true, "dependencies": { "is-what": "^4.1.8" }, @@ -6442,6 +6446,14 @@ "node": ">=10" } }, + "node_modules/filesize": { + "version": "10.0.12", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.0.12.tgz", + "integrity": "sha512-6RS9gDchbn+qWmtV2uSjo5vmKizgfCQeb5jKmqx8HyzA3MoLqqyQxN+QcjkGBJt7FjJ9qFce67Auyya5rRRbpw==", + "engines": { + "node": ">= 10.4.0" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -7529,6 +7541,7 @@ "version": "4.1.15", "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.15.tgz", "integrity": "sha512-uKua1wfy3Yt+YqsD6mTUEa2zSi3G1oPlqTflgaPJ7z63vUGN5pxFpnQfeSLMFnJDEsdvOtkp1rUWkYjB4YfhgA==", + "dev": true, "engines": { "node": ">=12.13" }, @@ -8760,6 +8773,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "devOptional": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -9047,7 +9061,8 @@ "node_modules/remove-accents": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", - "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" + "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==", + "dev": true }, "node_modules/require-directory": { "version": "2.1.1", @@ -9246,6 +9261,7 @@ "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "devOptional": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -9653,9 +9669,10 @@ } }, "node_modules/superjson": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.13.1.tgz", - "integrity": "sha512-AVH2eknm9DEd3qvxM4Sq+LTCkSXE2ssfh1t11MHMXyYXFQyQ1HLgVvV+guLTsaQnJU3gnaVo34TohHPulY/wLg==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.13.3.tgz", + "integrity": "sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==", + "dev": true, "dependencies": { "copy-anything": "^3.0.2" }, diff --git a/package.json b/package.json index a58626cc..c9dcfd8f 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,9 @@ }, "dependencies": { "@tanstack/react-query": "^4.33.0", - "@tanstack/react-query-devtools": "^4.33.0", "electron-serve": "^1.1.0", - "electron-store": "^8.1.0" + "electron-store": "^8.1.0", + "filesize": "^10.0.12" }, "devDependencies": { "@emotion/react": "^11.11.1", @@ -40,6 +40,7 @@ "@next/eslint-plugin-next": "^13.4.19", "@tabler/icons-react": "^2.32.0", "@tanstack/eslint-plugin-query": "^4.34.1", + "@tanstack/react-query-devtools": "^4.36.1", "@types/node": "^18.17.12", "@types/react": "^18.2.21", "@types/react-avatar-editor": "^13.0.0", diff --git a/renderer/components/content/ActionButton.tsx b/renderer/components/content/ActionButton.tsx new file mode 100644 index 00000000..54e976ff --- /dev/null +++ b/renderer/components/content/ActionButton.tsx @@ -0,0 +1,20 @@ +import type { ActionIconProps } from "@mantine/core"; +import { ActionIcon, Tooltip } from "@mantine/core"; + +interface ActionButtonProps extends ActionIconProps { + action: () => void; + icon: JSX.Element; + label: string; +} + +const ActionButton = ({ action, icon, label, ...rest }: ActionButtonProps) => { + return ( + + action()}> + {icon} + + + ); +}; + +export default ActionButton; diff --git a/renderer/components/content/BadgeMatchStatus.tsx b/renderer/components/content/BadgeMatchStatus.tsx new file mode 100644 index 00000000..80544885 --- /dev/null +++ b/renderer/components/content/BadgeMatchStatus.tsx @@ -0,0 +1,15 @@ +import { Badge } from "@mantine/core"; +import type { BadgeProps, MantineColor } from "@mantine/core"; +import type { MatchStatus } from "types/match"; + +interface BadgeMatchStatusProps extends BadgeProps { + matchStatus: MatchStatus; +} + +const BadgeMatchStatus = ({ matchStatus }: BadgeMatchStatusProps) => { + const badgeColor: MantineColor = matchStatus === "finished" ? "blue" : "red"; + + return Status: {matchStatus}; +}; + +export default BadgeMatchStatus; diff --git a/renderer/components/content/PageHeader.tsx b/renderer/components/content/PageHeader.tsx index 1b9b8119..5e7e4686 100644 --- a/renderer/components/content/PageHeader.tsx +++ b/renderer/components/content/PageHeader.tsx @@ -1,11 +1,12 @@ -import { Title, Text, Box } from "@mantine/core"; +import { Box, Title, Text } from "@mantine/core"; +import type { SpaceProps } from "@mantine/core"; -type Props = { +interface PageHeaderProps extends SpaceProps { children: React.ReactNode; title: string; -}; +} -const PageHeader = ({ children, title }: Props) => { +const PageHeader = ({ children, title }: PageHeaderProps) => { return ( {title} diff --git a/renderer/components/layouts/Default.tsx b/renderer/components/layouts/Default.tsx index 6f6c30e2..087f7d2c 100644 --- a/renderer/components/layouts/Default.tsx +++ b/renderer/components/layouts/Default.tsx @@ -1,171 +1,171 @@ import { + ActionIcon, AppShell, Button, - createStyles, + Center, Group, + Header, + Menu, Modal, - Navbar, - rem, Stack, + Text, Title, - Tooltip, - UnstyledButton, } from "@mantine/core"; import { - Icon, IconDisc, - IconHome2, - IconListNumbers, - IconSchool, + IconDots, + IconList, + // IconListNumbers, + // IconSchool, IconSettings, - IconSquareRoundedX, - IconTournament, + IconSquareLetterD, + // IconTournament, IconUsersGroup, + IconX, } from "@tabler/icons-react"; import { useRouter } from "next/router"; import { useDisclosure } from "@mantine/hooks"; import { ipcRenderer } from "electron"; +import LoadingOverlay from "../LoadingOverlay"; +import ActionButton from "../content/ActionButton"; -type Props = { +type DefaultLayoutProps = { children: React.ReactNode; + isFetching?: boolean; + isLoading?: boolean; + isSuccess?: boolean; }; type NavbarLinkProps = { - action?: () => void; - active?: boolean; - disabled?: boolean; - icon: Icon; + icon: JSX.Element; label: string; route: string; }; export const navbarWidth = 70; +export const headerHeight = 45; -const DefaultLayout = ({ children }: Props) => { +const DefaultLayout = ({ + children, + isFetching, + isLoading, + isSuccess, +}: DefaultLayoutProps) => { const [opened, { open, close }] = useDisclosure(false); const { route, push } = useRouter(); + // TODO: Some routes are currently unfinished and disabled. Reactivate the routes when the pages are created const mainRoutes: NavbarLinkProps[] = [ - { icon: IconHome2, label: "Home", route: "/" }, - { icon: IconDisc, label: "Lobby", route: "/lobby" }, + { icon: , label: "Lobby", route: "/lobby" }, + /* { - icon: IconSchool, - disabled: true, + icon: , label: "Training", route: "/training", }, + { - icon: IconTournament, - disabled: true, + icon: , label: "Tournament", route: "/tournament", }, + */ { - icon: IconUsersGroup, + icon: , label: "Profiles", route: "/profiles", }, { - icon: IconListNumbers, - disabled: true, + icon: , + label: "Replays", + route: "/replays", + }, + /* + { + icon: , label: "Ranking", route: "/ranking", }, + */ ]; const miscRoutes: NavbarLinkProps[] = [ { - icon: IconSettings, + icon: , label: "Settings", route: "/settings", }, - { - icon: IconSquareRoundedX, - action: () => open(), - label: "Quit App", - route: "#?quitApp", - }, ]; - const useStyles = createStyles((theme) => ({ - link: { - width: rem(50), - height: rem(50), - borderRadius: theme.radius.md, - display: "flex", - alignItems: "center", - justifyContent: "center", - color: - theme.colorScheme === "dark" - ? theme.colors.dark[0] - : theme.colors.dark[5], - - "&:hover": { - backgroundColor: - theme.colorScheme === "dark" - ? theme.colors.dark[5] - : theme.colors.gray[1], - }, - "&:disabled": { - cursor: "not-allowed", - }, - }, - active: { - "&, &:hover": { - backgroundColor: theme.fn.variant({ - variant: "light", - color: theme.primaryColor, - }).background, - color: theme.fn.variant({ variant: "light", color: theme.primaryColor }) - .color, - }, - }, - })); - - const NavbarLink = ({ - action, - icon: Icon, - label, - disabled, - active, - route, - }: NavbarLinkProps) => { - const { classes, cx } = useStyles(); - return ( - - void push(route)} - className={cx(classes.link, { [classes.active]: active })} - disabled={disabled} - > - - - - ); + const isActiveRoute = (currentRoute: string) => { + return currentRoute === route; }; - const appendNavbarRoutes = (links: NavbarLinkProps[]) => { - return links.map((link) => ( - - )); - }; + if (isLoading || isFetching) { + return ; + } return ( - - - {appendNavbarRoutes(mainRoutes)} - - - - - {appendNavbarRoutes(miscRoutes)} - - - + header={ +
+ + void push("/")} + > + + + + {mainRoutes.map((route) => ( + + ))} + + + + + + + + + + + {miscRoutes.map((route) => ( + void push(route.route)} + > + {route.label} + + ))} + + + open()} + icon={} + label="Quit App" + /> + + +
} > { }} > - - Quit DartMate? - - + Confirm Quit + + Any unsaved data will be lost. Are you sure you want to quit the + app? + + - + - {children} + {isSuccess ? ( + children + ) : ( +
+ + Oh snap! + + An internal error has occurred, preventing the creation of the + page you requested. We apologize for the inconvenience. To resolve + this issue, we recommend restarting the application and attempting + the operation again. If the problem persists, please contact our + support team for further assistance. + + +
+ )}
); }; diff --git a/renderer/hooks/useCurrentMatch.ts b/renderer/hooks/useCurrentMatch.ts new file mode 100644 index 00000000..ab8794a0 --- /dev/null +++ b/renderer/hooks/useCurrentMatch.ts @@ -0,0 +1,69 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { readFileSync } from "fs"; +import path from "path"; +import { Match } from "types/match"; +import { MATCHES_DIR } from "utils/constants"; +import { readFolder } from "utils/fs/readFolder"; +import type { UUID } from "crypto"; + +const getCurrentMatch = (uuid?: UUID) => { + const files = readFolder(MATCHES_DIR); + + // Find the file that matches the provided UUID + const matchingFile = files.find((matchFile) => { + const data = readFileSync(path.join(MATCHES_DIR, matchFile), "utf8"); + const match = JSON.parse(data) as Match; + return match.matchUUID === uuid; + }); + + const data = readFileSync( + path.join(MATCHES_DIR, matchingFile as string), + "utf8" + ); + return JSON.parse(data) as Match; +}; + +const addCurrentMatch = (uuid: UUID) => { + const files = readFolder(MATCHES_DIR); + + const matchingFile = files.find((matchFile) => { + const data = readFileSync(path.join(MATCHES_DIR, matchFile), "utf8"); + const match = JSON.parse(data) as Match; + return match.matchUUID === uuid; + }); + + return matchingFile; +}; + +export const useCurrentMatch = (uuid: UUID) => { + return useQuery({ + queryKey: ["currentMatch", uuid], + queryFn: () => getCurrentMatch(uuid), + cacheTime: 10 * 60 * 1000, // 10 minutes + }); +}; + +export const useAddCurrentMatch = () => { + const queryClient = useQueryClient(); + + return useMutation({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Temporarily ignoring TypeScript errors type issues + + // No overload matches this call. + // The last overload gave the following error. + + // Argument of type '{ mutationFn: (uuid: `${string}-${string}-${string}- + // ${string}-${string}`) => string | undefined; onSuccess: () => void; }' + // is not assignable to parameter of type 'MutationKey'. + + // Object literal may only specify known properties, and 'mutationFn' does + // not exist in type 'readonly unknown[]'. + mutationFn: (uuid: UUID) => addCurrentMatch(uuid), + onSuccess: () => { + // Remove old current match query + void queryClient.invalidateQueries(["currentMatch"]); + void queryClient.getQueryCache().clear(); + }, + }); +}; diff --git a/renderer/pages/_app.tsx b/renderer/pages/_app.tsx index 0a5cae47..a50a2ddc 100644 --- a/renderer/pages/_app.tsx +++ b/renderer/pages/_app.tsx @@ -6,7 +6,6 @@ import { MantineProvider, } from "@mantine/core"; import { useHotkeys, useLocalStorage } from "@mantine/hooks"; -import pkg from "../../package.json"; import "../styles/globals.css"; import "../styles/scrollbar.css"; import { useEffect, useState } from "react"; @@ -17,6 +16,7 @@ import { QueryClient } from "@tanstack/query-core"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import type { DehydratedState } from "@tanstack/react-query"; import { Notifications } from "@mantine/notifications"; +import { APP_NAME } from "utils/constants"; const App = ({ Component, @@ -49,7 +49,7 @@ const App = ({ return ( <> - {pkg.productName} + {APP_NAME} { - return ...; -}; - -export default GamePlayingPage; diff --git a/renderer/pages/game/[uuid]/results.tsx b/renderer/pages/game/[uuid]/results.tsx deleted file mode 100644 index 10283c79..00000000 --- a/renderer/pages/game/[uuid]/results.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type { NextPage } from "next"; -import DefaultLayout from "@/components/layouts/Default"; - -const GameResultsPage: NextPage = () => { - return ...; -}; - -export default GameResultsPage; diff --git a/renderer/pages/index.tsx b/renderer/pages/index.tsx index 651acda7..be6662f8 100644 --- a/renderer/pages/index.tsx +++ b/renderer/pages/index.tsx @@ -2,21 +2,16 @@ import type { NextPage } from "next"; import { Center, Divider, Flex, Title } from "@mantine/core"; import pkg from "../../package.json"; import DefaultLayout from "@/components/layouts/Default"; -import { useMatches } from "hooks/useMatches"; -import { useProfiles } from "hooks/useProfiles"; +import { APP_NAME } from "utils/constants"; const IndexPage: NextPage = () => { - // Prefetch the matches and profiles data on app start - useMatches(); - useProfiles(); - // TODO: Currently only shows a logo... Show the user more useful content return ( - +
- {pkg.productName} + {APP_NAME} diff --git a/renderer/pages/lobby.tsx b/renderer/pages/lobby.tsx index eb6dba4f..27d636e5 100644 --- a/renderer/pages/lobby.tsx +++ b/renderer/pages/lobby.tsx @@ -2,221 +2,206 @@ import type { NextPage } from "next"; import DefaultLayout from "@/components/layouts/Default"; import PageHeader from "@/components/content/PageHeader"; import { - Avatar, Button, - Checkbox, + Card, Grid, Group, + Indicator, NativeSelect, - ScrollArea, + NumberInput, Stack, - Stepper, + Tabs, Text, UnstyledButton, } from "@mantine/core"; import { useState } from "react"; -import { - IconAdjustmentsHorizontal, - IconCheck, - IconTargetArrow, - IconUsersPlus, -} from "@tabler/icons-react"; import { useProfiles } from "hooks/useProfiles"; import { Profile } from "types/profile"; -import { getUsernameInitials } from "utils/misc/getUsernameInitials"; -import { notifications } from "@mantine/notifications"; -import { useForm } from "@mantine/form"; -import { Match } from "types/match"; +import { isInRange, isNotEmpty, useForm } from "@mantine/form"; +import { Match, Player } from "types/match"; import { randomUUID } from "crypto"; +import { createFile } from "utils/fs/createFile"; +import { MATCHES_DIR, MATCH_FILENAME_EXTENSION } from "utils/constants"; +import path from "path"; +import { useRouter } from "next/router"; +import pkg from "../../package.json"; +import { useAddCurrentMatch } from "hooks/useCurrentMatch"; +import ProfileAvatar from "@/components/content/ProfileAvatar"; +import { + IconCheck, + IconSettings, + IconTarget, + IconUserCircle, +} from "@tabler/icons-react"; const LobbyPage: NextPage = () => { - const { isSuccess, data: profiles } = useProfiles(); - const [matchPlayerList, setMatchPlayerList] = useState([]); - const [activeStepIndex, setActiveStepIndex] = useState(0); + const router = useRouter(); + const { isFetching, isLoading, isSuccess, data: profiles } = useProfiles(); + const { mutate } = useAddCurrentMatch(); + const [matchPlayerList, setMatchPlayerList] = useState([]); const form = useForm({ initialValues: { + appVersion: pkg.version, createdAt: Date.now(), - profiles: matchPlayerList, + initialScore: 501, + players: matchPlayerList, + matchCheckout: "Double", + matchStatus: "started", + matchUUID: randomUUID(), updatedAt: Date.now(), - uuid: randomUUID(), - gameType: 501, - checkout: "Double", - randomizePlayerOrder: false, - disabledStatistics: false, }, - validate: {}, + validate: { + initialScore: isInRange( + { min: 3, max: 901 }, + "Your Score must be between 3 and 901!" + ), + players: isNotEmpty("You need to selected at least one Player!"), + }, }); - const handlePlayerSelection = (profile: Profile) => { - if (matchPlayerList.includes(profile)) { - // If the profile is already in the list and user clicks again on the avatar - // remove the profile from the player list - setMatchPlayerList((prev) => prev.filter((item) => item !== profile)); - notifications.show({ - color: "red", - title: `${profile.username} was removed from the next game!`, - message: "Click on the profile picture again to add them again.", - }); - } else { - notifications.show({ - title: `${profile.username} will be in the next game!`, - message: "Click on the profile picture again to remove them.", - }); - setMatchPlayerList((prev) => [...prev, profile]); + const handleStartMatch = () => { + form.validate(); + if (!form.isValid()) return; + + try { + createFile( + path.join( + MATCHES_DIR, + form.values.matchUUID + MATCH_FILENAME_EXTENSION + ), + JSON.stringify(form.values) + ); - console.info("BEFORE_FIELDSET", matchPlayerList); - // TODO: BREAKING_BUG: - // Currently there seems to be a bug, that the last player - // wont be added to the match save file... This problem requires - // some research - form.setFieldValue("profiles", matchPlayerList); - console.info("AFTER_FIELDSET", matchPlayerList); - console.info("FORM_STATE:", form.values); + void mutate(form.values.matchUUID); + + void router.push(`/match/${form.values.matchUUID}/playing`); + } catch (err) { + console.error(err); } }; - const steps = [ - { - label: "Select Players", - description: "Choose the Players for the Match", - icon: , - content: ( - - - {isSuccess - ? profiles.map((profile) => ( - - handlePlayerSelection(profile)} + const handlePlayerListUpdate = (profile: Profile): void => { + // Add missing object keys, so the profile will be of type Player + const player: Player = { + ...profile, + scoreLeft: -1, // -1 means that the player has not thrown yet + isWinner: false, + rounds: [], + }; + + setMatchPlayerList((prevPlayerList) => { + // Check if the player is already in matchPlayerList + const isPlayerInList = prevPlayerList.some( + (existingPlayer) => existingPlayer.uuid === player.uuid + ); + + if (isPlayerInList) { + // Remove the player from the matchPlayerList + const updatedPlayerList = prevPlayerList.filter( + (existingPlayer) => existingPlayer.uuid !== player.uuid + ); + form.setFieldValue("players", updatedPlayerList); + return updatedPlayerList; + } else { + // Add the player to the matchPlayerList state + const updatedPlayerList = [...prevPlayerList, player]; + form.setFieldValue("players", updatedPlayerList); + return updatedPlayerList; + } + }); + }; + + return ( + + + + You are just a few clicks away from starting your match. Please select + the players and if needed, update the settings. Let's get started! + + + + + }> + Select Players + + }> + Settings + + + + + + + {profiles.map((profile) => { + // Check if the profile is already in matchPlayerList + const isProfileInList = matchPlayerList.some( + (player) => player.uuid === profile.uuid + ); + + return ( + + handlePlayerListUpdate(profile)} + miw="100%" + > + - - - {matchPlayerList.includes(profile) ? ( - - ) : ( - getUsernameInitials(profile.username) - )} - - + } + disabled={!isProfileInList} + size={24} > + {" "} + + {profile.username} - - - )) - : null} + + + + ); + })} - - ), - }, - { - label: "Configure Settings", - description: "Review and Adjust Match Settings", - icon: , - content: ( -
+ + - - - - - ), - }, - { - label: "Start the Game", - description: "Good Darts!", - icon: , - content: ( - - ), - }, - ]; - - const moveToNextStep = () => { - setActiveStepIndex((cur) => (cur < steps.length ? cur + 1 : cur)); - }; - - const moveToPrevStep = () => { - setActiveStepIndex((cur) => (cur > 0 ? cur - 1 : cur)); - }; - - return ( - - - - You are just three steps away from starting your match. Let's get - started! - - - {steps.map((step) => ( - - {step.content} - - ))} - - - - - - - +
+ ); }; diff --git a/renderer/pages/match/[uuid]/playing.tsx b/renderer/pages/match/[uuid]/playing.tsx new file mode 100644 index 00000000..a0079e8b --- /dev/null +++ b/renderer/pages/match/[uuid]/playing.tsx @@ -0,0 +1,287 @@ +import type { NextPage } from "next"; +import { useRouter } from "next/router"; +import { useCurrentMatch } from "hooks/useCurrentMatch"; +import type { UUID } from "crypto"; +import LoadingOverlay from "@/components/LoadingOverlay"; +import { useEffect, useState } from "react"; +import DefaultLayout from "@/components/layouts/Default"; +import { + Button, + Card, + Grid, + Group, + Modal, + Stack, + Text, + useMantineTheme, +} from "@mantine/core"; +import { + DARTBOARD_ZONES, + MATCHES_DIR, + MATCH_FILENAME_EXTENSION, + SCORE_BULLSEYE, + SCORE_MISSED, + SCORE_OUTER_BULL, + THROWS_PER_ROUND, +} from "utils/constants"; +import { DartThrow, Match } from "types/match"; +import { getTotalRoundScore } from "utils/match/getTotalRoundScore"; +import { handleRoundUpdate } from "utils/match/handleRoundUpdate"; +import { createFile } from "utils/fs/createFile"; +import path from "path"; +import { getTotalMatchAvg } from "utils/match/getTotalMatchAvg"; +import { useDisclosure, useInterval } from "@mantine/hooks"; +import { handleAbortMatch } from "utils/match/handleAbortMatch"; + +const GamePlayingPage: NextPage = () => { + const router = useRouter(); + const theme = useMantineTheme(); + const { uuid } = router.query; + const [currentPlayerIndex, setCurrentPlayerIndex] = useState(0); // TODO: Add randomize player Start + const [multipliers, setMultipliers] = useState({ + double: false, + triple: false, + }); + const [roundThrows, setRoundThrows] = useState([]); + const [elapsedRoundTimeSeconds, setElapsedRoundTimeSeconds] = useState(0); + const [openedModal, { open: openModal, close: closeModal }] = + useDisclosure(false); + const roundTimer = useInterval( + () => setElapsedRoundTimeSeconds((s) => s + 1), + 1000 + ); + + const { + isLoading, + isSuccess, + data: matchData, + refetch, + } = useCurrentMatch(uuid as UUID); + + useEffect(() => { + roundTimer.start(); + if (matchData) { + const latestPlayerIndex = + matchData.players.length > 1 + ? currentPlayerIndex - 1 + : currentPlayerIndex; + + // Redirect to results page if the current player won the match + if (matchData.players[latestPlayerIndex]?.isWinner) { + const updatedMatchData: Match = { + ...matchData, + matchStatus: "finished", + updatedAt: Date.now(), + }; + + createFile( + path.join( + MATCHES_DIR, + matchData.matchUUID + MATCH_FILENAME_EXTENSION + ), + JSON.stringify(updatedMatchData) + ); + + void router.push(`/match/${matchData.matchUUID}/results`); + } + } + }, [matchData]); + + if (isLoading) { + return ; + } + + if (!isSuccess || !matchData) { + return Unable to Load the Match!; + } + + const handleMultipliers = (multiplier: "DOUBLE" | "TRIPLE") => { + if (multiplier === "DOUBLE") { + return setMultipliers({ double: !multipliers.double, triple: false }); + } + + return setMultipliers({ double: false, triple: !multipliers.triple }); + }; + + const handleAddThrow = (zone: number) => { + if (roundThrows.length > THROWS_PER_ROUND) return; + + // Disable multipliers when the zone is missed, outer bull or bullseye + const disableMultiplier = + zone === SCORE_MISSED || + zone === SCORE_OUTER_BULL || + zone === SCORE_BULLSEYE; + + const newThrow: DartThrow = { + isBullseye: zone === 50, + isDouble: disableMultiplier ? false : multipliers.double, + isTriple: disableMultiplier ? false : multipliers.triple, + isMissed: zone === 0, + isOuterBull: zone === 25, + dartboardZone: zone, + score: disableMultiplier + ? zone + : multipliers.double + ? zone * 2 + : multipliers.triple + ? zone * 3 + : zone, + }; + + setRoundThrows((prevThrows) => [...prevThrows, newThrow]); + + // Reset multipliers + setMultipliers({ double: false, triple: false }); + }; + + const handleRemoveLastThrow = () => { + setRoundThrows((prevThrows) => prevThrows.slice(0, roundThrows.length - 1)); + }; + + const handleNewRound = async () => { + handleRoundUpdate( + matchData.players[currentPlayerIndex], + roundThrows, + matchData, + elapsedRoundTimeSeconds + ); + + // Reset timer + setElapsedRoundTimeSeconds(0); + + setRoundThrows([]); + + await refetch(); + + setCurrentPlayerIndex((currentPlayerIndex + 1) % matchData.players.length); + }; + + const handleAbort = () => { + handleAbortMatch(matchData); + void router.push(`/match/${matchData.matchUUID}/results/`); + }; + + return ( + <> + + Are you sure you want to abort the current match? + + + + + + + + + {matchData?.players.map((player, _idx) => ( + + + + + {player.username} + + + {player.scoreLeft === -1 // -1 indicates that the player hasn't thrown yet + ? matchData.initialScore + : player.scoreLeft} + + {getTotalMatchAvg(player.rounds)} + + + + ))} + + + + + {DARTBOARD_ZONES.map((zone) => ( + + + + ))} + + + + + + + + + {getTotalRoundScore(roundThrows.map((throws) => throws.score))} + + + {Array.from({ length: THROWS_PER_ROUND }, (_, _idx) => ( + + {roundThrows[_idx]?.isDouble + ? "D" + : roundThrows[_idx]?.isTriple + ? "T" + : undefined} + {roundThrows[_idx]?.dartboardZone ?? "-"} + + ))} + + + + + + + + + + ); +}; + +export default GamePlayingPage; diff --git a/renderer/pages/match/[uuid]/results.tsx b/renderer/pages/match/[uuid]/results.tsx new file mode 100644 index 00000000..8528b4cc --- /dev/null +++ b/renderer/pages/match/[uuid]/results.tsx @@ -0,0 +1,181 @@ +import type { NextPage } from "next"; +import DefaultLayout from "@/components/layouts/Default"; +import { Badge, Card, Group, Table, Tabs, Text, Tooltip } from "@mantine/core"; +import { useRouter } from "next/router"; +import { useCurrentMatch } from "hooks/useCurrentMatch"; +import type { UUID } from "crypto"; +import PageHeader from "@/components/content/PageHeader"; +import ProfileAvatar from "@/components/content/ProfileAvatar"; +import { getTotalMatchAvg } from "utils/match/getTotalMatchAvg"; +import { + IconCrown, + IconHistory, + IconRepeat, + IconTable, +} from "@tabler/icons-react"; +import BadgeMatchStatus from "@/components/content/BadgeMatchStatus"; + +const GameResultsPage: NextPage = () => { + const router = useRouter(); + const matchQueryUuid = router.query.uuid; + const { + isFetching, + isLoading, + isSuccess, + data: matchData, + } = useCurrentMatch(matchQueryUuid as UUID); + + return ( + + + + + {`${matchData?.players.length || 0} Players - ${ + matchData?.initialScore || 0 + } ${matchData?.matchCheckout || "Any"}-Out`} + + + + + + + + }> + Overview + + }> + Round Details + + }> + Match Replay + + + + + + + + + + + + + + + + {matchData?.players.map((player) => ( + + + + + + + ))} + +
PlayerScore LeftRoundsDartsAVG
+ + + + {player.isWinner ? ( + + + + ) : undefined} + {player.username} + + + {player.scoreLeft} {player.rounds.length} + {player.rounds.reduce( + (total, round) => total + round.throwDetails.length, + 0 + )} + {getTotalMatchAvg(player.rounds)}
+
+ + + + + {matchData?.players.map((player) => ( + } + > + {player.username} + + ))} + + + {matchData?.players.map((player, _idx) => ( + + + + + + + + + + + + + + {matchData.players[_idx].rounds.map((round, _idx) => ( + + + + + + + + ))} + +
RoundScoreRound AVGThrowsRound Time
{_idx + 1}{round.roundTotal}{round.roundAverage.toFixed(2)} + + {round.throwDetails.map((roundThrow, _idx) => ( + + + {roundThrow.isDouble + ? `D${roundThrow.dartboardZone}` + : roundThrow.isTriple + ? `T${roundThrow.dartboardZone}` + : roundThrow.dartboardZone} + + + ))} + + {round.elapsedTime} Seconds
+
+
+ ))} +
+
+ + + {/* TODO: Add Match Replay Page */} + +
+
+ ); +}; + +export default GameResultsPage; diff --git a/renderer/pages/profiles/create/index.tsx b/renderer/pages/profiles/create/index.tsx deleted file mode 100644 index b9fa1748..00000000 --- a/renderer/pages/profiles/create/index.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import type { NextPage } from "next"; -import DefaultLayout from "@/components/layouts/Default"; -import PageHeader from "@/components/content/PageHeader"; -import { - ActionIcon, - Avatar, - Button, - Center, - ColorSwatch, - DefaultMantineColor, - Flex, - Group, - Stack, - Textarea, - TextInput, - Tooltip, - UnstyledButton, - useMantineTheme, -} from "@mantine/core"; -// import { Dropzone } from "@mantine/dropzone"; -import { IconSquareRoundedX } from "@tabler/icons-react"; -import { useRouter } from "next/router"; -import { hasLength, isNotEmpty, useForm } from "@mantine/form"; -import { useState } from "react"; -import { Profile } from "types/profile"; -import { randomUUID } from "crypto"; -import { createFile } from "utils/fs/createFile"; -import { PROFILES_DIR } from "utils/constants"; -import path from "path"; -import { getUsernameInitials } from "utils/misc/getUsernameInitials"; - -const CreateProfilePage: NextPage = () => { - const { back } = useRouter(); - const [avatarColor, setAvatarColor] = useState("blue"); - const form = useForm({ - initialValues: { - bio: "", - color: avatarColor, - createdAt: Date.now(), - username: "", - updatedAt: Date.now(), - uuid: randomUUID(), - }, - - validate: { - bio: hasLength( - { min: 0, max: 300 }, - "Your bio can only be 300 characters long" - ), - color: isNotEmpty(), - username: hasLength( - { min: 3, max: 16 }, - "Username must be 3-16 characters long" - ), - }, - }); - - // Manually update the color, since the ...props method doesn't work on the color swatches - const updateAvatarColor = (color: DefaultMantineColor) => { - setAvatarColor(color); - form.setValues({ - color: color, - }); - }; - - const theme = useMantineTheme(); - const swatches = Object.keys(theme.colors).map((color) => ( - updateAvatarColor(color)}> - - - )); - - const createProfile = () => { - // console.info("Creating profile...", form.values); - if (form.isValid()) { - form.clearErrors(); - createFile( - `${path.join(PROFILES_DIR, form.values.uuid)}.json`, - JSON.stringify(form.values) - ); - back(); - } - }; - - return ( -
{ - createProfile(); - })} - > - - - - - Your profile stores your games, statistics and achievements in - DartMate. You can update your profile at any time. - - - back()}> - - - - - -
- - - {getUsernameInitials(form.values.username)} - - {swatches} - -