feat: setup 'web' frontend

This commit is contained in:
Ryan Walters
2025-09-25 12:37:21 -05:00
parent c524fdb3e7
commit 55b31ba31e
19 changed files with 950 additions and 0 deletions

145
web/.gitignore vendored Normal file
View File

@@ -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

145
web/.prettierignore Normal file
View File

@@ -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

64
web/eslint.config.ts Normal file
View File

@@ -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],
);

View File

@@ -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: <IconDeviceGamepad3 size={28} />,
},
{
label: "Leaderboard",
href: "/leaderboard",
icon: <IconTrophy size={28} />,
},
{
label: "Download",
href: "/download",
icon: <IconDownload size={28} />,
},
{
label: "GitHub",
href: "https://github.com/Xevion/Pac-Man",
icon: <IconBrandGithub size={28} />,
},
];
export function Link({ href, label }: { href: string; label: string }) {
const pageContext = usePageContext();
const { urlPathname } = pageContext;
const isActive = href === "/" ? urlPathname === href : urlPathname.startsWith(href);
return (
<a href={href} className={isActive ? "text-yellow-400" : "text-gray-400"}>
{label}
</a>
);
}
export default function LayoutDefault({ children }: { children: React.ReactNode }) {
const [opened, { toggle, close }] = useDisclosure();
const mainLinks = links
.filter((link) => link.href.startsWith("/"))
.map((link) => <Link href={link.href} label={link.label} />);
const sourceLinks = links
.filter((link) => !link.href.startsWith("/"))
.map((link) => (
<a
href={link.href}
title={link.label}
target="_blank"
className="transition-all duration-300 hover:drop-shadow-sm hover:drop-shadow-yellow-400"
>
{link.icon}
</a>
));
return (
<MantineProvider forceColorScheme="dark" theme={theme}>
<div className="bg-black text-yellow-400 min-h-screen flex flex-col">
<AppShell header={{ height: 60 }} padding="md">
<AppShell.Header>
<Flex h="100%" px="md" align="center" justify="space-between">
<Flex h="100%" align="center" gap="md">
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Group visibleFrom="sm">{mainLinks}</Group>
</Flex>
<Group visibleFrom="sm">{sourceLinks}</Group>
</Flex>
</AppShell.Header>
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
<Drawer opened={opened} onClose={close} title="Navigation">
<Stack>
{links.map((link) => (
<Link href={link.href} label={link.label} />
))}
</Stack>
</Drawer>
</div>
</MantineProvider>
);
}

1
web/layouts/tailwind.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

29
web/layouts/theme.ts Normal file
View File

@@ -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;

42
web/package.json Normal file
View File

@@ -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"
}

13
web/pages/+Head.tsx Normal file
View File

@@ -0,0 +1,13 @@
// https://vike.dev/Head
//# BATI.has("mantine")
import { ColorSchemeScript } from "@mantine/core";
export default function HeadDefault() {
return (
<>
<link rel="icon" href="/favicon.ico" />
<ColorSchemeScript />
</>
);
}

17
web/pages/+config.ts Normal file
View File

@@ -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;

View File

@@ -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");
};

View File

@@ -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");
};

View File

@@ -0,0 +1,19 @@
import { usePageContext } from "vike-react/usePageContext";
export default function Page() {
const { is404 } = usePageContext();
if (is404) {
return (
<>
<h1>Page Not Found</h1>
<p>This page could not be found.</p>
</>
);
}
return (
<>
<h1>Internal Error</h1>
<p>Something went wrong.</p>
</>
);
}

16
web/pages/index/+Page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { AspectRatio } from "@mantine/core";
export default function Page() {
return (
<div className="mt-4 flex justify-center h-[calc(100vh-120px)]">
<AspectRatio ratio={1.0 / 1.2} w="min(100vh * 1.2, 100vw)" maw="95vw">
<canvas
className="block border-1 border-yellow-400/50 w-full h-full"
style={{
boxShadow: "0 0 12px rgba(250, 204, 21, 0.35), 0 0 2px rgba(255, 255, 255, 0.25)",
}}
/>
</AspectRatio>
</div>
);
}

View File

@@ -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) => (
<Table.Tr
key={entry.id}
style={{
backgroundColor: "#000",
marginBottom: "8px",
}}
>
<Table.Td>
<Group gap="sm">
<Avatar src={entry.avatar} size="md" radius="sm" alt={entry.name} />
<Stack gap={2}>
<Text fw={600} size="lg" c="yellow.4">
{entry.name}
</Text>
<Text size="xs" c="dimmed">
{entry.submittedAt}
</Text>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<Text fw={500} size="lg" c="yellow.3">
{entry.score.toLocaleString()}
</Text>
</Table.Td>
<Table.Td>
<Text size="md" c="gray.3">
{entry.duration}
</Text>
</Table.Td>
<Table.Td>Level {entry.levelCount}</Table.Td>
</Table.Tr>
));
return (
<Table>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
);
}
export default function Page() {
const [activeTab, setActiveTab] = useState<string | null>("global");
return (
<Container size="md" py="xl">
<Stack gap="xl">
<Paper shadow="lg" p="xl" radius="md" bg="none">
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab value="global" leftSection={<IconTrophy size={16} />}>
Global
</Tabs.Tab>
<Tabs.Tab value="monthly" leftSection={<IconCalendar size={16} />}>
Monthly
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="global" pt="md">
<LeaderboardTable data={mockGlobalData} />
</Tabs.Panel>
<Tabs.Panel value="monthly" pt="md">
<LeaderboardTable data={mockMonthlyData} />
</Tabs.Panel>
</Tabs>
</Paper>
</Stack>
</Container>
);
}

14
web/postcss.config.cjs Normal file
View File

@@ -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",
},
},
},
};

9
web/prettier.config.js Normal file
View File

@@ -0,0 +1,9 @@
/**
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
*/
const config = {
printWidth: 120,
};
export default config;

BIN
web/public/favicon.ico Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

28
web/tsconfig.json Normal file
View File

@@ -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"
]
}

11
web/vite.config.ts Normal file
View File

@@ -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",
},
});