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
This commit is contained in:
2025-12-29 03:33:43 -06:00
parent d3514b84e9
commit 3bb3908853
22 changed files with 602 additions and 328 deletions
+11
View File
@@ -32,11 +32,16 @@ impl App {
pub fn new() -> GameResult<Self> {
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 {
+21
View File
@@ -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))
+4
View File
@@ -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;
+9
View File
@@ -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};
+13 -2
View File
@@ -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=="],
+49 -35
View File
@@ -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 <div className={`${className} overflow-auto`}>{children}</div>;
}
return (
<OverlayScrollbarsComponent
defer
options={{
scrollbars: {
theme: "os-theme-light",
autoHide: "scroll",
autoHideDelay: 1300,
},
}}
className={className}
>
{children}
</OverlayScrollbarsComponent>
);
}
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 (
<a
href={href}
<a
href={href}
className={`
flex items-center gap-1.5
tracking-wide
transition-colors duration-200
${isActive
? "text-white"
: "text-gray-500 hover:text-gray-300"
}
${isActive ? "text-white" : "text-gray-500 hover:text-gray-300"}
`}
>
{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
<span className="sr-only">Toggle menu</span>
<div className="w-5 h-0.5 bg-yellow-400" />
</button>
<div className="flex items-center gap-8">
<Link href="/leaderboard" label="Leaderboard" icon={<IconTrophy size={18} />} />
<a
<a
href="/"
onClick={(e) => {
if (isIndexPage) {
@@ -105,38 +130,27 @@ export default function LayoutDefault({ children }: { children: React.ReactNode
}
}}
>
<h1
className={`text-3xl tracking-[0.3em] title-hover ${
isIndexPage
? 'text-yellow-400'
: 'glimmer-text'
<h1
className={`text-3xl tracking-[0.3em] text-yellow-400 title-base ${
isIndexPage ? "" : "title-glimmer title-hover"
}`}
style={{ fontFamily: 'Russo One' }}
style={{ fontFamily: "Russo One" }}
data-text="PAC-MAN"
>
PAC-MAN
</h1>
</a>
<Link href="/download" label="Download" icon={<IconDownload size={18} />} />
</div>
<div className="absolute right-4 hidden sm:flex gap-4 items-center">{sourceLinks}</div>
</div>
</header>
<OverlayScrollbarsComponent
defer
options={{
scrollbars: {
theme: 'os-theme-light',
autoHide: 'scroll',
autoHideDelay: 1300,
},
}}
className="flex-1"
>
<ClientOnlyScrollbars className="flex-1">
<main>{children}</main>
</OverlayScrollbarsComponent>
</ClientOnlyScrollbars>
{opened && (
<div className="fixed inset-0 z-30">
+77 -2
View File
@@ -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;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { useSyncExternalStore } from "react";
type Listener = (pendingUrl: string | null) => void;
let pendingUrl: string | null = null;
const listeners = new Set<Listener>();
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);
}
+9
View File
@@ -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;
}
+1 -1
View File
@@ -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",
+3 -3
View File
@@ -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
+4 -1
View File
@@ -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
+4 -12
View File
@@ -2,18 +2,10 @@ 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>
</>
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center px-4">
<h1 className="text-4xl font-bold mb-4">{is404 ? "Page Not Found" : "Internal Error"}</h1>
<p className="text-gray-400">{is404 ? "This page could not be found." : "Something went wrong."}</p>
</div>
);
}
+3 -5
View File
@@ -1,12 +1,10 @@
export default function Page() {
return (
<div className="mx-auto max-w-3xl py-8 px-4">
<div className="page-container">
<div className="space-y-6">
<div className="border border-yellow-400/20 rounded-md bg-transparent p-6 shadow-[0_4px_20px_rgba(250,204,21,0.08)]">
<div className="card">
<h2 className="text-2xl font-bold mb-4">Download Pac-Man</h2>
<p className="text-gray-300 mb-4">
Download instructions and releases will be available here soon.
</p>
<p className="text-gray-300 mb-4">Download instructions and releases will be available here soon.</p>
</div>
</div>
</div>
-2
View File
@@ -1,5 +1,3 @@
import "../../layouts/tailwind.css";
export default function GameLayout({ children }: { children: React.ReactNode }) {
return (
<div className="bg-black text-yellow-400 h-full flex flex-col overflow-hidden">
+100 -6
View File
@@ -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<LoadingError | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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() {
>
<canvas id="canvas" className="w-full h-full" />
{/* Loading overlay - CSS animation continues during main thread blocking */}
{loadingVisible && (
<div
className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 transition-opacity"
style={{
transitionDuration: `${LOADING_FADE_DURATION}ms`,
opacity: gameReady ? 0 : 1,
}}
>
{loadError ? (
<>
<div className="error-indicator" />
<span className="text-red-500 text-2xl mt-4 font-semibold">
{loadError.type === "timeout"
? "Loading timed out"
: loadError.type === "script"
? "Failed to load"
: "Error occurred"}
</span>
<span className="text-gray-400 text-sm mt-2 max-w-xs text-center">
{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}
</span>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-yellow-400 text-black font-semibold rounded hover:bg-yellow-300 transition-colors"
>
Reload
</button>
</>
) : (
<>
<div className="loading-spinner" />
<span className="text-yellow-400 text-2xl mt-4">Loading...</span>
</>
)}
</div>
)}
{/* Click to Start overlay */}
{gameReady && !gameStarted && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60 cursor-pointer">
<span className="text-yellow-400 text-5xl font-bold">
Click to Start
</span>
<span className="text-yellow-400 text-5xl font-bold">Click to Start</span>
</div>
)}
</div>
+12 -239
View File
@@ -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 (
<table className="w-full border-separate border-spacing-y-2">
<tbody>
{data.map((entry, entryIndex) => (
{data.map((entry) => (
<tr key={entry.id} className="bg-black">
<td className="py-2">
<div className="flex items-center gap-2">
@@ -234,9 +18,7 @@ function LeaderboardTable({ data }: { data: LeaderboardEntry[] }) {
</div>
</td>
<td className="py-2">
<span className="text-yellow-300 font-[600] text-lg">
{entry.score.toLocaleString()}
</span>
<span className="text-yellow-300 font-[600] text-lg">{entry.score.toLocaleString()}</span>
</td>
<td className="py-2">
<span className="text-gray-300">{entry.duration}</span>
@@ -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 (
<div className="mx-auto max-w-3xl py-8 px-4">
<div className="page-container">
<div className="space-y-6">
<div className="border border-yellow-400/20 rounded-md bg-transparent p-6 shadow-[0_4px_20px_rgba(250,204,21,0.08)]">
<div className="card">
<div className="flex gap-2 border-b border-yellow-400/20 pb-2 mb-4">
<button
onClick={() => setActiveTab("global")}
className={
activeTab === "global"
? "inline-flex items-center gap-1 px-3 py-1 rounded border border-yellow-400/40 text-yellow-300"
: "inline-flex items-center gap-1 px-3 py-1 rounded border border-transparent text-gray-300 hover:text-yellow-200"
}
>
<button onClick={() => setActiveTab("global")} className={tabButtonClass(activeTab === "global")}>
<IconTrophy size={16} />
Global
</button>
<button
onClick={() => setActiveTab("monthly")}
className={
activeTab === "monthly"
? "inline-flex items-center gap-1 px-3 py-1 rounded border border-yellow-400/40 text-yellow-300"
: "inline-flex items-center gap-1 px-3 py-1 rounded border border-transparent text-gray-300 hover:text-yellow-200"
}
>
<button onClick={() => setActiveTab("monthly")} className={tabButtonClass(activeTab === "monthly")}>
<IconCalendar size={16} />
Monthly
</button>
+2 -2
View File
@@ -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;
+216
View File
@@ -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",
},
];
+30 -14
View File
@@ -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
+7 -2
View File
@@ -17,10 +17,15 @@
],
"types": [
"vite/client",
"vike-react"
"vike-react",
"node"
],
"jsx": "react-jsx",
"jsxImportSource": "react"
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"exclude": [
"dist"
+3 -2
View File
@@ -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,
},
},