diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a5e3437 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,145 @@ +# MacOS +.DS_Store + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Cloudflare +.wrangler/ + +# Vercel +.vercel/ + +# Sentry Vite Plugin +.env.sentry-build-plugin + +# aws-cdk +.cdk.staging +cdk.out diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..a5e3437 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,145 @@ +# MacOS +.DS_Store + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Cloudflare +.wrangler/ + +# Vercel +.vercel/ + +# Sentry Vite Plugin +.env.sentry-build-plugin + +# aws-cdk +.cdk.staging +cdk.out diff --git a/web/eslint.config.ts b/web/eslint.config.ts new file mode 100644 index 0000000..16062db --- /dev/null +++ b/web/eslint.config.ts @@ -0,0 +1,64 @@ +import eslint from "@eslint/js"; +import prettier from "eslint-plugin-prettier/recommended"; +import react from "eslint-plugin-react"; +import globals from "globals"; +import tseslint, { type ConfigArray } from "typescript-eslint"; + +export default tseslint.config( + { + ignores: [ + "dist/*", + // Temporary compiled files + "**/*.ts.build-*.mjs", + + // JS files at the root of the project + "*.js", + "*.cjs", + "*.mjs", + ], + }, + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + languageOptions: { + parserOptions: { + warnOnUnsupportedTypeScriptVersion: false, + sourceType: "module", + ecmaVersion: "latest", + }, + }, + }, + { + rules: { + "@typescript-eslint/no-unused-vars": [ + 1, + { + argsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-namespace": 0, + }, + }, + + { + files: ["**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}"], + ...react.configs.flat.recommended, + languageOptions: { + ...react.configs.flat.recommended.languageOptions, + globals: { + ...globals.serviceworker, + ...globals.browser, + }, + }, + + settings: { + react: { + version: "detect", + }, + }, + } as ConfigArray[number], + + react.configs.flat["jsx-runtime"] as ConfigArray[number], + + prettier as ConfigArray[number], +); diff --git a/web/layouts/LayoutDefault.tsx b/web/layouts/LayoutDefault.tsx new file mode 100644 index 0000000..096e3b4 --- /dev/null +++ b/web/layouts/LayoutDefault.tsx @@ -0,0 +1,91 @@ +import "@mantine/core/styles.css"; +import "./tailwind.css"; +import "@fontsource/pixelify-sans"; +import "@fontsource/nunito/800.css"; + +import { AppShell, Burger, Group, MantineProvider, Flex, Stack, Drawer } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import theme from "./theme"; +import { usePageContext } from "vike-react/usePageContext"; +import { IconBrandGithub, IconDownload, IconDeviceGamepad3, IconTrophy } from "@tabler/icons-react"; + +const links = [ + { + label: "Play", + href: "/", + icon: , + }, + { + label: "Leaderboard", + href: "/leaderboard", + icon: , + }, + { + label: "Download", + href: "/download", + icon: , + }, + { + label: "GitHub", + href: "https://github.com/Xevion/Pac-Man", + icon: , + }, +]; + +export function Link({ href, label }: { href: string; label: string }) { + const pageContext = usePageContext(); + const { urlPathname } = pageContext; + const isActive = href === "/" ? urlPathname === href : urlPathname.startsWith(href); + return ( + + {label} + + ); +} + +export default function LayoutDefault({ children }: { children: React.ReactNode }) { + const [opened, { toggle, close }] = useDisclosure(); + + const mainLinks = links + .filter((link) => link.href.startsWith("/")) + .map((link) => ); + + const sourceLinks = links + .filter((link) => !link.href.startsWith("/")) + .map((link) => ( + + {link.icon} + + )); + + return ( + +
+ + + + + + {mainLinks} + + {sourceLinks} + + + {children} + + + + {links.map((link) => ( + + ))} + + +
+
+ ); +} diff --git a/web/layouts/tailwind.css b/web/layouts/tailwind.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/web/layouts/tailwind.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/web/layouts/theme.ts b/web/layouts/theme.ts new file mode 100644 index 0000000..5870023 --- /dev/null +++ b/web/layouts/theme.ts @@ -0,0 +1,29 @@ +import type { MantineThemeOverride } from "@mantine/core"; +import { createTheme } from "@mantine/core"; + +const theme: MantineThemeOverride = createTheme({ + /** Put your mantine theme override here */ + primaryColor: "yellow", + + fontFamily: "'Nunito', sans-serif", + headings: { + fontFamily: "'Nunito', sans-serif", + fontWeight: "800", + }, + components: { + AppShell: { + styles: { + header: { + backgroundColor: "#000", + borderBottom: "1px solid rgba(250, 204, 21, 0.25)", + }, + navbar: { + backgroundColor: "#000", + borderRight: "1px solid rgba(250, 204, 21, 0.25)", + }, + }, + }, + }, +}); + +export default theme; diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..4ecef42 --- /dev/null +++ b/web/package.json @@ -0,0 +1,42 @@ +{ + "scripts": { + "dev": "vike dev", + "build": "vike build", + "preview": "vike preview", + "lint": "eslint ." + }, + "name": "pacman-web", + "description": "A web frontend for the Pac-Man game, including leaderboards and OAuth.", + "dependencies": { + "@fontsource/nunito": "^5.2.7", + "@fontsource/pixelify-sans": "^5.2.7", + "@mantine/core": "^8.2.8", + "@mantine/hooks": "^8.2.8", + "@tabler/icons-react": "^3.35.0", + "@vitejs/plugin-react": "^5.0.2", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "vike": "^0.4.240", + "vike-react": "^0.6.5" + }, + "devDependencies": { + "@eslint/js": "^9.35.0", + "@tailwindcss/vite": "^4.1.13", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-react": "^7.37.5", + "globals": "^16.3.0", + "postcss": "^8.5.6", + "postcss-preset-mantine": "^1.18.0", + "postcss-simple-vars": "^7.0.1", + "prettier": "^3.6.2", + "tailwindcss": "^4.1.13", + "typescript": "^5.9.2", + "typescript-eslint": "^8.42.0", + "vite": "^7.1.4" + }, + "type": "module" +} diff --git a/web/pages/+Head.tsx b/web/pages/+Head.tsx new file mode 100644 index 0000000..a57896e --- /dev/null +++ b/web/pages/+Head.tsx @@ -0,0 +1,13 @@ +// https://vike.dev/Head + +//# BATI.has("mantine") +import { ColorSchemeScript } from "@mantine/core"; + +export default function HeadDefault() { + return ( + <> + + + + ); +} diff --git a/web/pages/+config.ts b/web/pages/+config.ts new file mode 100644 index 0000000..599459a --- /dev/null +++ b/web/pages/+config.ts @@ -0,0 +1,17 @@ +import type { Config } from "vike/types"; +import vikeReact from "vike-react/config"; +import Layout from "../layouts/LayoutDefault.js"; + +// Default config (can be overridden by pages) +// https://vike.dev/config + +export default { + // https://vike.dev/Layout + Layout, + + // https://vike.dev/head-tags + title: "Pac-Man", + description: "A Pac-Man game built with Rust and React.", + + extends: vikeReact, +} satisfies Config; diff --git a/web/pages/+onPageTransitionEnd.ts b/web/pages/+onPageTransitionEnd.ts new file mode 100644 index 0000000..75af2e0 --- /dev/null +++ b/web/pages/+onPageTransitionEnd.ts @@ -0,0 +1,6 @@ +import type { OnPageTransitionEndAsync } from "vike/types"; + +export const onPageTransitionEnd: OnPageTransitionEndAsync = async () => { + console.log("Page transition end"); + document.querySelector("body")?.classList.remove("page-is-transitioning"); +}; diff --git a/web/pages/+onPageTransitionStart.ts b/web/pages/+onPageTransitionStart.ts new file mode 100644 index 0000000..12c344b --- /dev/null +++ b/web/pages/+onPageTransitionStart.ts @@ -0,0 +1,6 @@ +import type { OnPageTransitionStartAsync } from "vike/types"; + +export const onPageTransitionStart: OnPageTransitionStartAsync = async () => { + console.log("Page transition start"); + document.querySelector("body")?.classList.add("page-is-transitioning"); +}; diff --git a/web/pages/_error/+Page.tsx b/web/pages/_error/+Page.tsx new file mode 100644 index 0000000..cbfa6de --- /dev/null +++ b/web/pages/_error/+Page.tsx @@ -0,0 +1,19 @@ +import { usePageContext } from "vike-react/usePageContext"; + +export default function Page() { + const { is404 } = usePageContext(); + if (is404) { + return ( + <> +

Page Not Found

+

This page could not be found.

+ + ); + } + return ( + <> +

Internal Error

+

Something went wrong.

+ + ); +} diff --git a/web/pages/index/+Page.tsx b/web/pages/index/+Page.tsx new file mode 100644 index 0000000..0de207d --- /dev/null +++ b/web/pages/index/+Page.tsx @@ -0,0 +1,16 @@ +import { AspectRatio } from "@mantine/core"; + +export default function Page() { + return ( +
+ + + +
+ ); +} diff --git a/web/pages/leaderboard/+Page.tsx b/web/pages/leaderboard/+Page.tsx new file mode 100644 index 0000000..b36983c --- /dev/null +++ b/web/pages/leaderboard/+Page.tsx @@ -0,0 +1,294 @@ +import { useState } from "react"; +import { Container, Title, Table, Tabs, Avatar, Text, Group, Badge, Stack, Paper } from "@mantine/core"; +import { IconTrophy, IconCalendar } from "@tabler/icons-react"; + +interface LeaderboardEntry { + id: number; + rank: number; + name: string; + score: number; + duration: string; + levelCount: number; + submittedAt: string; + avatar?: string; +} + +const mockGlobalData: LeaderboardEntry[] = [ + { + id: 1, + rank: 1, + name: "PacMaster2024", + score: 125000, + duration: "45:32", + levelCount: 12, + submittedAt: "2 hours ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=PacMaster2024", + }, + { + id: 2, + rank: 2, + name: "GhostHunter", + score: 118750, + duration: "42:18", + levelCount: 11, + submittedAt: "5 hours ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=GhostHunter", + }, + { + id: 3, + rank: 3, + name: "DotCollector", + score: 112500, + duration: "38:45", + levelCount: 10, + submittedAt: "1 day ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=DotCollector", + }, + { + id: 4, + rank: 4, + name: "MazeRunner", + score: 108900, + duration: "41:12", + levelCount: 10, + submittedAt: "2 days ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=MazeRunner", + }, + { + id: 5, + rank: 5, + name: "PowerPellet", + score: 102300, + duration: "36:28", + levelCount: 9, + submittedAt: "3 days ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=PowerPellet", + }, + { + id: 6, + rank: 6, + name: "CherryPicker", + score: 98750, + duration: "39:15", + levelCount: 9, + submittedAt: "4 days ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=CherryPicker", + }, + { + id: 7, + rank: 7, + name: "BlinkyBeater", + score: 94500, + duration: "35:42", + levelCount: 8, + submittedAt: "5 days ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=BlinkyBeater", + }, + { + id: 8, + rank: 8, + name: "PinkyPac", + score: 91200, + duration: "37:55", + levelCount: 8, + submittedAt: "1 week ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=PinkyPac", + }, + { + id: 9, + rank: 9, + name: "InkyDestroyer", + score: 88800, + duration: "34:18", + levelCount: 8, + submittedAt: "1 week ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=InkyDestroyer", + }, + { + id: 10, + rank: 10, + name: "ClydeChaser", + score: 85600, + duration: "33:45", + levelCount: 7, + submittedAt: "1 week ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=ClydeChaser", + }, +]; + +const mockMonthlyData: LeaderboardEntry[] = [ + { + id: 1, + rank: 1, + name: "JanuaryChamp", + score: 115000, + duration: "43:22", + levelCount: 11, + submittedAt: "1 day ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=JanuaryChamp", + }, + { + id: 2, + rank: 2, + name: "NewYearPac", + score: 108500, + duration: "40:15", + levelCount: 10, + submittedAt: "3 days ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=NewYearPac", + }, + { + id: 3, + rank: 3, + name: "WinterWarrior", + score: 102000, + duration: "38:30", + levelCount: 10, + submittedAt: "5 days ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=WinterWarrior", + }, + { + id: 4, + rank: 4, + name: "FrostyPac", + score: 98500, + duration: "37:45", + levelCount: 9, + submittedAt: "1 week ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=FrostyPac", + }, + { + id: 5, + rank: 5, + name: "IceBreaker", + score: 95200, + duration: "36:12", + levelCount: 9, + submittedAt: "1 week ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=IceBreaker", + }, + { + id: 6, + rank: 6, + name: "SnowPac", + score: 91800, + duration: "35:28", + levelCount: 8, + submittedAt: "2 weeks ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=SnowPac", + }, + { + id: 7, + rank: 7, + name: "BlizzardBeast", + score: 88500, + duration: "34:15", + levelCount: 8, + submittedAt: "2 weeks ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=BlizzardBeast", + }, + { + id: 8, + rank: 8, + name: "ColdSnap", + score: 85200, + duration: "33:42", + levelCount: 8, + submittedAt: "3 weeks ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=ColdSnap", + }, + { + id: 9, + rank: 9, + name: "FrozenFury", + score: 81900, + duration: "32:55", + levelCount: 7, + submittedAt: "3 weeks ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=FrozenFury", + }, + { + id: 10, + rank: 10, + name: "ArcticAce", + score: 78600, + duration: "31:18", + levelCount: 7, + submittedAt: "4 weeks ago", + avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=ArcticAce", + }, +]; + +function LeaderboardTable({ data }: { data: LeaderboardEntry[] }) { + const rows = data.map((entry) => ( + + + + + + + {entry.name} + + + {entry.submittedAt} + + + + + + + {entry.score.toLocaleString()} + + + + + {entry.duration} + + + Level {entry.levelCount} + + )); + + return ( + + {rows} +
+ ); +} + +export default function Page() { + const [activeTab, setActiveTab] = useState("global"); + + return ( + + + + + + }> + Global + + }> + Monthly + + + + + + + + + + + + + + + ); +} diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs new file mode 100644 index 0000000..e817f56 --- /dev/null +++ b/web/postcss.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + plugins: { + "postcss-preset-mantine": {}, + "postcss-simple-vars": { + variables: { + "mantine-breakpoint-xs": "36em", + "mantine-breakpoint-sm": "48em", + "mantine-breakpoint-md": "62em", + "mantine-breakpoint-lg": "75em", + "mantine-breakpoint-xl": "88em", + }, + }, + }, +}; diff --git a/web/prettier.config.js b/web/prettier.config.js new file mode 100644 index 0000000..f668b0a --- /dev/null +++ b/web/prettier.config.js @@ -0,0 +1,9 @@ +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + printWidth: 120, +}; + +export default config; diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000..19c6b7c Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..069283a --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "module": "ESNext", + "noEmit": true, + "moduleResolution": "Bundler", + "target": "ES2022", + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "types": [ + "vite/client", + "vike-react" + ], + "jsx": "react-jsx", + "jsxImportSource": "react" + }, + "exclude": [ + "dist" + ] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..4a7314a --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,11 @@ +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import vike from "vike/plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [vike(), react(), tailwindcss()], + build: { + target: "es2022", + }, +});