From 3bb39088538f06c3868350ec63a30c83498b3966 Mon Sep 17 00:00:00 2001 From: Xevion Date: Mon, 29 Dec 2025 03:33:43 -0600 Subject: [PATCH] feat(web): add smooth page transitions and WASM loading states - Implement navigation state tracking with optimistic UI updates - Add loading spinner and error handling for WASM initialization - Insert browser yield points during game initialization to prevent freezing - Redesign leaderboard with tabbed navigation and mock data structure - Add utility CSS classes for consistent page layouts --- pacman/src/app.rs | 11 ++ pacman/src/game.rs | 21 +++ pacman/src/platform/desktop.rs | 4 + pacman/src/platform/emscripten.rs | 9 + web/bun.lock | 15 +- web/layouts/LayoutDefault.tsx | 84 ++++++---- web/layouts/tailwind.css | 79 ++++++++- web/lib/navigation.ts | 24 +++ web/lib/pacman.ts | 9 + web/package.json | 2 +- web/pages/+onPageTransitionEnd.ts | 6 +- web/pages/+onPageTransitionStart.ts | 5 +- web/pages/_error/+Page.tsx | 16 +- web/pages/download/+Page.tsx | 8 +- web/pages/index/+Layout.tsx | 2 - web/pages/index/+Page.tsx | 106 +++++++++++- web/pages/leaderboard/+Page.tsx | 251 ++-------------------------- web/pages/leaderboard/+config.ts | 4 +- web/pages/leaderboard/mockData.ts | 216 ++++++++++++++++++++++++ web/pnpm-lock.yaml | 44 +++-- web/tsconfig.json | 9 +- web/vite.config.ts | 5 +- 22 files changed, 602 insertions(+), 328 deletions(-) create mode 100644 web/lib/navigation.ts create mode 100644 web/pages/leaderboard/mockData.ts diff --git a/pacman/src/app.rs b/pacman/src/app.rs index dd25af9..206a3c5 100644 --- a/pacman/src/app.rs +++ b/pacman/src/app.rs @@ -32,11 +32,16 @@ impl App { pub fn new() -> GameResult { info!("Initializing SDL2 application"); let sdl_context = sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?; + trace!("Yielding after SDL init"); + platform::yield_to_browser(); + debug!("Initializing SDL2 subsystems"); let ttf_context = sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?; let video_subsystem = sdl_context.video().map_err(|e| GameError::Sdl(e.to_string()))?; let audio_subsystem = sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?; let event_pump = sdl_context.event_pump().map_err(|e| GameError::Sdl(e.to_string()))?; + trace!("Yielding after subsystem init"); + platform::yield_to_browser(); trace!( width = (CANVAS_SIZE.x as f32 * SCALE).round() as u32, @@ -96,6 +101,8 @@ impl App { // .index(index) .build() .map_err(|e| GameError::Sdl(e.to_string()))?; + trace!("Yielding after canvas creation"); + platform::yield_to_browser(); trace!( logical_width = CANVAS_SIZE.x, @@ -106,12 +113,16 @@ impl App { .set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y) .map_err(|e| GameError::Sdl(e.to_string()))?; debug!(renderer_info = ?canvas.info(), "Canvas renderer initialized"); + trace!("Yielding after logical size"); + platform::yield_to_browser(); trace!("Creating texture factory"); let texture_creator = canvas.texture_creator(); info!("Starting game initialization"); let game = Game::new(canvas, ttf_context, texture_creator, event_pump)?; + trace!("Yielding after game init"); + platform::yield_to_browser(); info!("Application initialization completed successfully"); Ok(App { diff --git a/pacman/src/game.rs b/pacman/src/game.rs index d630127..9e0294e 100644 --- a/pacman/src/game.rs +++ b/pacman/src/game.rs @@ -48,6 +48,7 @@ use crate::{ asset::Asset, events::GameCommand, map::render::MapRenderer, + platform, systems::{BatchedLinesResource, Bindings, CursorPosition, TtfAtlasResource}, texture::sprite::{AtlasMapper, SpriteAtlas}, }; @@ -116,18 +117,27 @@ impl Game { debug!("Setting up textures and fonts"); let (backbuffer, mut map_texture, debug_texture, ttf_atlas) = Self::setup_textures_and_fonts(&mut canvas, &texture_creator, ttf_context)?; + trace!("Yielding after texture setup"); + platform::yield_to_browser(); debug!("Initializing audio subsystem"); let audio = crate::audio::Audio::new(); + trace!("Yielding after audio init"); + platform::yield_to_browser(); debug!("Loading sprite atlas and map tiles"); let (mut atlas, map_tiles) = Self::load_atlas_and_map_tiles(&texture_creator)?; + trace!("Yielding after atlas load"); + platform::yield_to_browser(); + debug!("Rendering static map to texture cache"); canvas .with_texture_canvas(&mut map_texture, |map_canvas| { MapRenderer::render_map(map_canvas, &mut atlas, &map_tiles); }) .map_err(|e| GameError::Sdl(e.to_string()))?; + trace!("Yielding after map render"); + platform::yield_to_browser(); debug!("Building navigation graph from map layout"); let map = Map::new(constants::RAW_BOARD)?; @@ -235,30 +245,41 @@ impl Game { sdl2::render::Texture, crate::texture::ttf::TtfAtlas, )> { + trace!("Creating backbuffer texture"); let mut backbuffer = texture_creator .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y) .map_err(|e| GameError::Sdl(e.to_string()))?; backbuffer.set_scale_mode(ScaleMode::Nearest); + platform::yield_to_browser(); + trace!("Creating map texture"); let mut map_texture = texture_creator .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y) .map_err(|e| GameError::Sdl(e.to_string()))?; map_texture.set_scale_mode(ScaleMode::Nearest); + platform::yield_to_browser(); + trace!("Creating debug texture"); let output_size = constants::LARGE_CANVAS_SIZE; let mut debug_texture = texture_creator .create_texture_target(Some(sdl2::pixels::PixelFormatEnum::ARGB8888), output_size.x, output_size.y) .map_err(|e| GameError::Sdl(e.to_string()))?; debug_texture.set_blend_mode(BlendMode::Blend); debug_texture.set_scale_mode(ScaleMode::Nearest); + platform::yield_to_browser(); + trace!("Loading font"); let font_data: &'static [u8] = Asset::Font.get_bytes()?.to_vec().leak(); let font_asset = RWops::from_bytes(font_data).map_err(|_| GameError::Sdl("Failed to load font".to_string()))?; let debug_font = ttf_context .load_font_from_rwops(font_asset, constants::ui::DEBUG_FONT_SIZE) .map_err(|e| GameError::Sdl(e.to_string()))?; + trace!("Creating TTF atlas"); let mut ttf_atlas = crate::texture::ttf::TtfAtlas::new(texture_creator, &debug_font)?; + platform::yield_to_browser(); + + trace!("Populating TTF atlas"); ttf_atlas.populate_atlas(canvas, texture_creator, &debug_font)?; Ok((backbuffer, map_texture, debug_texture, ttf_atlas)) diff --git a/pacman/src/platform/desktop.rs b/pacman/src/platform/desktop.rs index 0e24fe4..bc40598 100644 --- a/pacman/src/platform/desktop.rs +++ b/pacman/src/platform/desktop.rs @@ -20,6 +20,10 @@ pub fn sleep(duration: Duration, focused: bool) { } } +/// No-op on desktop - only needed for browser event loop yielding. +#[inline] +pub fn yield_to_browser() {} + #[allow(unused_variables)] pub fn init_console(force_console: bool) -> Result<(), PlatformError> { use crate::formatter::CustomFormatter; diff --git a/pacman/src/platform/emscripten.rs b/pacman/src/platform/emscripten.rs index b74ef56..7af6cef 100644 --- a/pacman/src/platform/emscripten.rs +++ b/pacman/src/platform/emscripten.rs @@ -49,6 +49,15 @@ pub fn sleep(duration: Duration, _focused: bool) { } } +/// Yields control to browser event loop without delay. +/// Allows page transitions, animations, and events to process during initialization. +/// Uses ASYNCIFY to pause/resume WASM execution. +pub fn yield_to_browser() { + unsafe { + emscripten_sleep(0); + } +} + pub fn init_console(_force_console: bool) -> Result<(), PlatformError> { use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter}; diff --git a/web/bun.lock b/web/bun.lock index 564f4f0..3da9fcb 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,11 +5,13 @@ "": { "name": "pacman-web", "dependencies": { - "@fontsource/nunito": "^5.2.7", + "@fontsource/outfit": "^5.2.8", "@fontsource/pixelify-sans": "^5.2.7", "@fontsource/russo-one": "^5.2.7", "@tabler/icons-react": "^3.35.0", "@vitejs/plugin-react": "^5.0.2", + "overlayscrollbars": "^2.13.0", + "overlayscrollbars-react": "^0.5.6", "react": "^19.1.1", "react-dom": "^19.1.1", "vike": "^0.4.240", @@ -18,6 +20,7 @@ "devDependencies": { "@eslint/js": "^9.35.0", "@tailwindcss/vite": "^4.1.13", + "@types/node": "^25.0.3", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", "eslint": "^9.35.0", @@ -154,7 +157,7 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], - "@fontsource/nunito": ["@fontsource/nunito@5.2.7", "", {}, "sha512-pmtBq0H9ex9nk+RtJYEJOD9pag393iHETnl/PVKleF4i06cd0ttngK5ZCTgYb5eOqR3Xdlrjtev8m7bmgYprew=="], + "@fontsource/outfit": ["@fontsource/outfit@5.2.8", "", {}, "sha512-rXC6g0MqD7cOBjht0bMqc43qM6lRqDLML9KXsmg9uykz0wLQhy8Z/ajrMG6iyoT3NJR+MYgU+OEHp7uHoTb+Yw=="], "@fontsource/pixelify-sans": ["@fontsource/pixelify-sans@5.2.7", "", {}, "sha512-F/UuV2M9poAj/BJ5/6u95mOy6ptp8/+dpfnxh4TFzKeAB+vdanAjQ8fLJcS0q+WHYgesRRxPwNFr2Pqm18CVGg=="], @@ -280,6 +283,8 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], "@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="], @@ -668,6 +673,10 @@ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "overlayscrollbars": ["overlayscrollbars@2.13.0", "", {}, "sha512-uQGpLESrbFDLTWucWAKX9ceIANj7detMwH/2yJ315Llt72ZcWN3P6ckMotoqVv2Mk29R/pnhDtgYjy4K+kwAyQ=="], + + "overlayscrollbars-react": ["overlayscrollbars-react@0.5.6", "", { "peerDependencies": { "overlayscrollbars": "^2.0.0", "react": ">=16.8.0" } }, "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw=="], + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -812,6 +821,8 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], diff --git a/web/layouts/LayoutDefault.tsx b/web/layouts/LayoutDefault.tsx index b455338..8105b03 100644 --- a/web/layouts/LayoutDefault.tsx +++ b/web/layouts/LayoutDefault.tsx @@ -9,6 +9,32 @@ import { useState, useEffect } from "react"; import { usePageContext } from "vike-react/usePageContext"; import { IconBrandGithub, IconDownload, IconDeviceGamepad3, IconTrophy } from "@tabler/icons-react"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; +import { usePendingNavigation } from "@/lib/navigation"; + +function ClientOnlyScrollbars({ children, className }: { children: React.ReactNode; className?: string }) { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + if (!mounted) { + return
{children}
; + } + + return ( + + {children} + + ); +} const links = [ { @@ -36,18 +62,17 @@ const links = [ export function Link({ href, label, icon }: { href: string; label: string; icon?: React.ReactNode }) { const pageContext = usePageContext(); const { urlPathname } = pageContext; - const isActive = href === "/" ? urlPathname === href : urlPathname.startsWith(href); + const pendingUrl = usePendingNavigation(); + const effectiveUrl = pendingUrl ?? urlPathname; + const isActive = href === "/" ? effectiveUrl === href : effectiveUrl.startsWith(href); return ( - {icon} @@ -60,12 +85,12 @@ export default function LayoutDefault({ children }: { children: React.ReactNode const [opened, setOpened] = useState(false); const toggle = () => setOpened((v) => !v); const close = () => setOpened(false); - + const pageContext = usePageContext(); const { urlPathname } = pageContext; - const isIndexPage = urlPathname === "/"; - const isLeaderboardPage = urlPathname.startsWith("/leaderboard"); - const isDownloadPage = urlPathname.startsWith("/download"); + const pendingUrl = usePendingNavigation(); + const effectiveUrl = pendingUrl ?? urlPathname; + const isIndexPage = effectiveUrl === "/"; const sourceLinks = links .filter((link) => !link.href.startsWith("/")) @@ -93,11 +118,11 @@ export default function LayoutDefault({ children }: { children: React.ReactNode Toggle menu
- +
} /> - - { if (isIndexPage) { @@ -105,38 +130,27 @@ export default function LayoutDefault({ children }: { children: React.ReactNode } }} > -

PAC-MAN

- + } />
- +
{sourceLinks}
- - + +
{children}
-
+ {opened && (
diff --git a/web/layouts/tailwind.css b/web/layouts/tailwind.css index 5a45f63..66f74ab 100644 --- a/web/layouts/tailwind.css +++ b/web/layouts/tailwind.css @@ -66,19 +66,88 @@ } } +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* Loading spinner - CSS animation runs on compositor thread, + continues even during main thread blocking */ +.loading-spinner { + width: 48px; + height: 48px; + border: 4px solid rgb(250 204 21 / 0.3); + border-top-color: rgb(250 204 21); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +/* Error indicator - X mark with shake animation */ +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20%, 60% { transform: translateX(-4px); } + 40%, 80% { transform: translateX(4px); } +} + +.error-indicator { + width: 48px; + height: 48px; + position: relative; + animation: shake 0.5s ease-out; +} + +.error-indicator::before, +.error-indicator::after { + content: ''; + position: absolute; + width: 4px; + height: 32px; + background: rgb(239 68 68); + border-radius: 2px; + top: 50%; + left: 50%; +} + +.error-indicator::before { + transform: translate(-50%, -50%) rotate(45deg); +} + +.error-indicator::after { + transform: translate(-50%, -50%) rotate(-45deg); +} + @layer utilities { + .page-container { + @apply mx-auto max-w-3xl py-8 px-4; + } + + .card { + @apply border border-yellow-400/20 rounded-md bg-transparent p-6 shadow-[0_4px_20px_rgba(250,204,21,0.08)]; + } + .title-hover { transition: transform 0.2s ease-out, filter 0.2s ease-out; } - + .title-hover:hover { transform: scale(1.03); filter: brightness(1.15); } - .glimmer-text { + .title-base { + position: relative; + } + + .title-base::before { + content: attr(data-text); + position: absolute; + inset: 0; background: linear-gradient( 90deg, rgb(156 163 175) 0%, @@ -93,5 +162,11 @@ -webkit-background-clip: text; -webkit-text-fill-color: transparent; animation: glimmer 3s ease-in-out infinite; + opacity: 0; + transition: opacity 0.2s ease-out; + } + + .title-base.title-glimmer::before { + opacity: 1; } } diff --git a/web/lib/navigation.ts b/web/lib/navigation.ts new file mode 100644 index 0000000..7b8bd57 --- /dev/null +++ b/web/lib/navigation.ts @@ -0,0 +1,24 @@ +import { useSyncExternalStore } from "react"; + +type Listener = (pendingUrl: string | null) => void; + +let pendingUrl: string | null = null; +const listeners = new Set(); + +export function setPendingNavigation(url: string | null) { + pendingUrl = url; + listeners.forEach((listener) => listener(pendingUrl)); +} + +export function getPendingNavigation(): string | null { + return pendingUrl; +} + +export function subscribeToPendingNavigation(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function usePendingNavigation(): string | null { + return useSyncExternalStore(subscribeToPendingNavigation, getPendingNavigation, () => null); +} diff --git a/web/lib/pacman.ts b/web/lib/pacman.ts index a00b886..a33c7a2 100644 --- a/web/lib/pacman.ts +++ b/web/lib/pacman.ts @@ -5,11 +5,20 @@ export interface PacmanModule { _restart_game?: () => void; locateFile: (path: string) => string; preRun: unknown[]; + // Emscripten error hooks + onAbort?: (what: unknown) => void; + onRuntimeInitialized?: () => void; } +export type LoadingError = + | { type: "timeout" } + | { type: "script"; message: string } + | { type: "runtime"; message: string }; + export interface PacmanWindow extends Window { Module?: PacmanModule; pacmanReady?: () => void; + pacmanError?: (error: LoadingError) => void; SDL_CANVAS_ID?: string; } diff --git a/web/package.json b/web/package.json index 267b582..6a14b1b 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,6 @@ "name": "pacman-web", "description": "A web frontend for the Pac-Man game, including leaderboards and OAuth.", "dependencies": { - "@fontsource/nunito": "^5.2.7", "@fontsource/outfit": "^5.2.8", "@fontsource/pixelify-sans": "^5.2.7", "@fontsource/russo-one": "^5.2.7", @@ -25,6 +24,7 @@ "devDependencies": { "@eslint/js": "^9.35.0", "@tailwindcss/vite": "^4.1.13", + "@types/node": "^25.0.3", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", "eslint": "^9.35.0", diff --git a/web/pages/+onPageTransitionEnd.ts b/web/pages/+onPageTransitionEnd.ts index e438cd2..7dd66ba 100644 --- a/web/pages/+onPageTransitionEnd.ts +++ b/web/pages/+onPageTransitionEnd.ts @@ -1,10 +1,10 @@ import type { OnPageTransitionEndAsync } from "vike/types"; import { getPacmanWindow } from "@/lib/pacman"; +import { setPendingNavigation } from "@/lib/navigation"; -export const onPageTransitionEnd: OnPageTransitionEndAsync = async ( - pageContext -) => { +export const onPageTransitionEnd: OnPageTransitionEndAsync = async (pageContext) => { console.log("Page transition end"); + setPendingNavigation(null); document.querySelector("body")?.classList.remove("page-is-transitioning"); // Restart the game loop when returning to the game page diff --git a/web/pages/+onPageTransitionStart.ts b/web/pages/+onPageTransitionStart.ts index e80c8ce..93a4f6e 100644 --- a/web/pages/+onPageTransitionStart.ts +++ b/web/pages/+onPageTransitionStart.ts @@ -1,10 +1,13 @@ import type { OnPageTransitionStartAsync } from "vike/types"; import { getPacmanWindow } from "@/lib/pacman"; +import { setPendingNavigation } from "@/lib/navigation"; +// Must match --transition-duration in layouts/tailwind.css const TRANSITION_DURATION = 200; -export const onPageTransitionStart: OnPageTransitionStartAsync = async () => { +export const onPageTransitionStart: OnPageTransitionStartAsync = async (pageContext) => { console.log("Page transition start"); + setPendingNavigation(pageContext.urlPathname); document.querySelector("body")?.classList.add("page-is-transitioning"); // Stop the game loop when navigating away from the game page diff --git a/web/pages/_error/+Page.tsx b/web/pages/_error/+Page.tsx index cbfa6de..1cff194 100644 --- a/web/pages/_error/+Page.tsx +++ b/web/pages/_error/+Page.tsx @@ -2,18 +2,10 @@ 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.

- +
+

{is404 ? "Page Not Found" : "Internal Error"}

+

{is404 ? "This page could not be found." : "Something went wrong."}

+
); } diff --git a/web/pages/download/+Page.tsx b/web/pages/download/+Page.tsx index c239ea1..b5ce3f4 100644 --- a/web/pages/download/+Page.tsx +++ b/web/pages/download/+Page.tsx @@ -1,12 +1,10 @@ export default function Page() { return ( -
+
-
+

Download Pac-Man

-

- Download instructions and releases will be available here soon. -

+

Download instructions and releases will be available here soon.

diff --git a/web/pages/index/+Layout.tsx b/web/pages/index/+Layout.tsx index 35a909b..56b6a6e 100644 --- a/web/pages/index/+Layout.tsx +++ b/web/pages/index/+Layout.tsx @@ -1,5 +1,3 @@ -import "../../layouts/tailwind.css"; - export default function GameLayout({ children }: { children: React.ReactNode }) { return (
diff --git a/web/pages/index/+Page.tsx b/web/pages/index/+Page.tsx index fba5ca7..fe0941a 100644 --- a/web/pages/index/+Page.tsx +++ b/web/pages/index/+Page.tsx @@ -1,18 +1,50 @@ -import { useCallback, useEffect, useState } from "react"; -import { getPacmanWindow } from "@/lib/pacman"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getPacmanWindow, LoadingError } from "@/lib/pacman"; + +const LOADING_FADE_DURATION = 300; +const LOADING_TIMEOUT_MS = 15000; export default function Page() { const [gameReady, setGameReady] = useState(false); const [gameStarted, setGameStarted] = useState(false); + const [loadingVisible, setLoadingVisible] = useState(true); + const [loadError, setLoadError] = useState(null); + const timeoutRef = useRef | null>(null); + + // Fade out loading overlay when game becomes ready + useEffect(() => { + if (gameReady && loadingVisible) { + const timer = setTimeout(() => { + setLoadingVisible(false); + }, LOADING_FADE_DURATION); + return () => clearTimeout(timer); + } + }, [gameReady, loadingVisible]); + + // Clear timeout when game is ready or error occurs + useEffect(() => { + if (gameReady || loadError) { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + } + }, [gameReady, loadError]); useEffect(() => { const win = getPacmanWindow(); - + // Always set up the ready callback (restart_game will call it too) win.pacmanReady = () => { setGameReady(true); }; + // Error callback for WASM runtime errors + win.pacmanError = (error: LoadingError) => { + console.error("Pacman error:", error); + setLoadError(error); + }; + const module = win.Module; // If Module already exists (returning after navigation), @@ -27,6 +59,7 @@ export default function Page() { const canvas = document.getElementById("canvas") as HTMLCanvasElement | null; if (!canvas) { console.error("Canvas element not found"); + setLoadError({ type: "runtime", message: "Canvas element not found" }); return; } @@ -36,15 +69,36 @@ export default function Page() { return path.startsWith("/") ? path : `/${path}`; }, preRun: [], + // Emscripten calls this on fatal errors (abort/trap/etc) + onAbort: (what: unknown) => { + const message = typeof what === "string" ? what : "WebAssembly execution aborted"; + console.error("WASM abort:", what); + setLoadError({ type: "runtime", message }); + }, }; const script = document.createElement("script"); script.src = "/pacman.js"; script.async = false; + + // Handle script load errors + script.onerror = () => { + setLoadError({ type: "script", message: "Failed to load game script" }); + }; + document.body.appendChild(script); + // Set up loading timeout - the separate effect clears this if game loads successfully + timeoutRef.current = setTimeout(() => { + setLoadError((prev) => prev ?? { type: "timeout" }); + }, LOADING_TIMEOUT_MS); + return () => { delete win.pacmanReady; + delete win.pacmanError; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } }; }, []); @@ -81,12 +135,52 @@ export default function Page() { > + {/* Loading overlay - CSS animation continues during main thread blocking */} + {loadingVisible && ( +
+ {loadError ? ( + <> +
+ + {loadError.type === "timeout" + ? "Loading timed out" + : loadError.type === "script" + ? "Failed to load" + : "Error occurred"} + + + {loadError.type === "timeout" + ? "The game took too long to load. Please refresh the page." + : loadError.type === "script" + ? "Could not load game files. Check your connection and refresh." + : loadError.message} + + + + ) : ( + <> +
+ Loading... + + )} +
+ )} + {/* Click to Start overlay */} {gameReady && !gameStarted && (
- - Click to Start - + Click to Start
)}
diff --git a/web/pages/leaderboard/+Page.tsx b/web/pages/leaderboard/+Page.tsx index c259678..24bc2d4 100644 --- a/web/pages/leaderboard/+Page.tsx +++ b/web/pages/leaderboard/+Page.tsx @@ -1,228 +1,12 @@ import { useState } from "react"; 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", - }, -]; +import { mockGlobalData, mockMonthlyData, type LeaderboardEntry } from "./mockData"; function LeaderboardTable({ data }: { data: LeaderboardEntry[] }) { return ( - {data.map((entry, entryIndex) => ( + {data.map((entry) => (
@@ -234,9 +18,7 @@ function LeaderboardTable({ data }: { data: LeaderboardEntry[] }) {
- - {entry.score.toLocaleString()} - + {entry.score.toLocaleString()} {entry.duration} @@ -249,33 +31,24 @@ function LeaderboardTable({ data }: { data: LeaderboardEntry[] }) { ); } +const tabButtonClass = (isActive: boolean) => + `inline-flex items-center gap-1 px-3 py-1 rounded border ${ + isActive ? "border-yellow-400/40 text-yellow-300" : "border-transparent text-gray-300 hover:text-yellow-200" + }`; + export default function Page() { const [activeTab, setActiveTab] = useState<"global" | "monthly">("global"); return ( -
+
-
+
- - diff --git a/web/pages/leaderboard/+config.ts b/web/pages/leaderboard/+config.ts index c2d8d70..baf9aee 100644 --- a/web/pages/leaderboard/+config.ts +++ b/web/pages/leaderboard/+config.ts @@ -1,6 +1,6 @@ import type { Config } from "vike/types"; export default { - prerender: true, // Generate static HTML for deployment - ssr: false, // Force client-side only rendering + prerender: true, // Generate static HTML for deployment + ssr: false, // Force client-side only rendering } satisfies Config; diff --git a/web/pages/leaderboard/mockData.ts b/web/pages/leaderboard/mockData.ts new file mode 100644 index 0000000..fb16467 --- /dev/null +++ b/web/pages/leaderboard/mockData.ts @@ -0,0 +1,216 @@ +export interface LeaderboardEntry { + id: number; + rank: number; + name: string; + score: number; + duration: string; + levelCount: number; + submittedAt: string; + avatar?: string; +} + +export 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", + }, +]; + +export 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", + }, +]; diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 0625b17..afcbec5 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: version: 3.36.0(react@19.2.3) '@vitejs/plugin-react': specifier: ^5.0.2 - version: 5.1.2(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2)) + version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)) overlayscrollbars: specifier: ^2.13.0 version: 2.13.0 @@ -40,17 +40,20 @@ importers: version: 19.2.3(react@19.2.3) vike: specifier: ^0.4.240 - version: 0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2)) + version: 0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)) vike-react: specifier: ^0.6.5 - version: 0.6.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vike@0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2))) + version: 0.6.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vike@0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))) devDependencies: '@eslint/js': specifier: ^9.35.0 version: 9.39.2 '@tailwindcss/vite': specifier: ^4.1.13 - version: 4.1.18(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2)) + version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)) + '@types/node': + specifier: ^25.0.3 + version: 25.0.3 '@types/react': specifier: ^19.1.12 version: 19.2.7 @@ -92,7 +95,7 @@ importers: version: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.4 - version: 7.3.0(jiti@2.6.1)(lightningcss@1.30.2) + version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) packages: @@ -668,6 +671,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.0.3': + resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1767,6 +1773,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2297,12 +2306,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.0(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) '@types/babel__core@7.20.5': dependencies: @@ -2329,6 +2338,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@25.0.3': + dependencies: + undici-types: 7.16.0 + '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: '@types/react': 19.2.7 @@ -2428,7 +2441,7 @@ snapshots: '@typescript-eslint/types': 8.50.1 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@5.1.2(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2))': + '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -2436,7 +2449,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.53 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.0(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) transitivePeerDependencies: - supports-color @@ -3672,6 +3685,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@7.16.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -3682,14 +3697,14 @@ snapshots: dependencies: punycode: 2.3.1 - vike-react@0.6.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vike@0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2))): + vike-react@0.6.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vike@0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))): dependencies: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) react-streaming: 0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - vike: 0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2)) + vike: 0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)) - vike@0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2)): + vike@0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)): dependencies: '@babel/core': 7.28.5 '@babel/types': 7.28.5 @@ -3710,11 +3725,11 @@ snapshots: tinyglobby: 0.2.15 optionalDependencies: react-streaming: 0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - vite: 7.3.0(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) transitivePeerDependencies: - supports-color - vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2): + vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -3723,6 +3738,7 @@ snapshots: rollup: 4.54.0 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 25.0.3 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 diff --git a/web/tsconfig.json b/web/tsconfig.json index 069283a..ef1dec5 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -17,10 +17,15 @@ ], "types": [ "vite/client", - "vike-react" + "vike-react", + "node" ], "jsx": "react-jsx", - "jsxImportSource": "react" + "jsxImportSource": "react", + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } }, "exclude": [ "dist" diff --git a/web/vite.config.ts b/web/vite.config.ts index 5bb0c1a..8c231e2 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ alias: { "@": path.resolve(__dirname, "."), }, + dedupe: ["react", "react-dom"], }, build: { target: "es2022", @@ -18,8 +19,8 @@ export default defineConfig({ // Proxy API requests to the backend server during local development // In production, both frontend and API are served from the same origin proxy: { - '/api': { - target: process.env.VITE_API_TARGET || 'http://localhost:3001', + "/api": { + target: process.env.VITE_API_TARGET || "http://localhost:3001", changeOrigin: true, }, },