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