29 KiB
StatSphere – Athlete Stats Platform
Vibe-Coding Prompt
Project Overview
Build StatSphere, a full-featured Progressive Web App (PWA) for tracking athlete biometric and sport-specific statistics. The app supports five sports at launch with a data model designed for easy expansion. Athletes can be registered in multiple sports simultaneously. The platform features a stat leaders board, advanced filtering, athlete profiles, and a multi-step registration form. All data is stored in the browser via localStorage. No backend or database is required.
Tech Stack
| Layer | Choice | Notes |
|---|---|---|
| Framework | React 18 | Functional components + hooks throughout |
| Routing | React Router v6 | BrowserRouter, Routes, Route, Link, useNavigate, useSearchParams |
| Build tool | Vite 5 | Fast dev server + production build |
| PWA | vite-plugin-pwa + Workbox | Auto service worker, manifest, offline caching |
| Styling | Pure CSS (no framework) | CSS custom properties for theming, no Tailwind |
| Icons | lucide-react | Minimal usage; emoji used for sport icons |
| State | React Context + localStorage | StoreProvider wraps the whole app |
| Fonts | Google Fonts | Barlow Condensed (display) + Barlow (body) |
| Web server | Nginx | SPA fallback, static file serving, gzip |
| Target OS | Ubuntu 24.04 LXC | Auto-installer script included |
package.json dependencies:
{
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"lucide-react": "^0.460.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.3",
"vite": "^5.4.10",
"vite-plugin-pwa": "^0.20.5",
"workbox-window": "^7.3.0"
}
}
Visual Design System
Theme: Dark Sports
A high-contrast dark theme inspired by professional sports analytics dashboards.
:root {
--bg-base: #0a0f1e; /* page background — near-black navy */
--bg-card: #111827; /* primary card surface */
--bg-card2: #1a2235; /* secondary card / nested surface */
--bg-input: #1e2d45; /* form input background */
--accent: #00d4ff; /* primary accent — electric cyan */
--accent2: #ff6b35; /* secondary accent — orange */
--accent3: #a855f7; /* tertiary accent — purple */
--text-primary: #f0f4ff; /* near-white body text */
--text-secondary: #8899bb;/* muted labels */
--text-muted: #4a5875; /* de-emphasised text */
--border: #1e2d45; /* card borders */
--border-bright: #2a3d5a; /* input/hover borders */
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--font-display: 'Barlow Condensed', sans-serif;
--font-body: 'Barlow', sans-serif;
--nav-h: 60px;
--bottom-nav-h: 64px; /* 0px on desktop, 64px on mobile */
--radius: 12px;
--radius-sm: 8px;
}
Typography
- Display / headings:
Barlow Condensed— weight 700–900, uppercase, letter-spacing 0.02–0.1em - Body:
Barlow— weight 400–600 - Section titles: 28px, weight 800, uppercase,
--font-display - Stat values: 20–24px, weight 800,
--font-display, colored--accent - Stat labels: 11px, uppercase, letter-spacing 0.06em,
--text-muted,--font-display - Table headers: 11px, uppercase, letter-spacing 0.1em,
--text-muted,--font-display
Sport Color Coding
Each sport has a distinct badge color scheme (background / text / border):
| Sport | Class | BG | Text |
|---|---|---|---|
| American Football | .sport-football |
#1a3a1a |
#4ade80 |
| Hockey | .sport-hockey |
#1a2e4a |
#60a5fa |
| Baseball | .sport-baseball |
#3a1a1a |
#f87171 |
| Soccer | .sport-soccer |
#2a1a3a |
#c084fc |
| Basketball | .sport-basketball |
#3a2a1a |
#fb923c |
Badges use class .badge + .sport-{id} and are uppercase, 11px, weight 700, --font-display.
Rank Badges
Circular 32px badges for leaderboard ranks:
- Rank 1: gold
#ffd700, black text - Rank 2: silver
#c0c0c0, black text - Rank 3: bronze
#cd7f32, black text - Rank 4+:
--bg-input,--text-secondary
Reusable CSS Classes
.page— content wrapper:padding: (nav-h + 20px) 16px (bottom-nav-h + 20px), max-width 900px, centered.card—background: --bg-card, 1px border, radius 12px, padding 20px.card2—background: --bg-card2, 1px border, radius 8px, padding 16px.btn— inline-flex, gap 8px, padding 10px 20px, radius 8px, font-display, 14px, 600 weight, uppercase.btn-primary— background--accent, color black.btn-secondary— transparent, border--border-bright, hover: border + text →--accent.btn-danger— background--danger, white text.badge— inline-flex, padding 3px 10px, radius 20px, 11px, 700 weight, uppercase.label— 12px, uppercase, letter-spacing 0.08em,--text-secondary,--font-display, display block, margin-bottom 6px.form-group— margin-bottom 16px.tab-bar— flex row,--bg-cardbg, border, radius 8px, padding 4px, overflow-x auto.tab— padding 8px 16px, radius 6px, 13px, 700 weight, uppercase;.active→ background--accent, color black.grid-2— 2-column grid, gap 16px; collapses to 1-col below 600px.grid-3— 3-column grid, gap 12px; collapses to 2-col below 600px.section-title—--font-display, 28px, 800, uppercase.section-sub— 14px,--text-secondary, margin-bottom 24px.stat-val—--font-display, 24px, 800,--accent, line-height 1.stat-lbl— 11px,--text-muted, uppercase, letter-spacing 0.06em,--font-display.toast— fixed bottom (above bottom-nav), centered, success green pill, animated in/out.pwa-banner— fixed bottom (above bottom-nav), full-width,--bg-card2, cyan border, install prompt
Forms
All input, select, textarea elements:
- Background:
--bg-input - Border: 1px solid
--border-bright - Border-radius:
--radius-sm - Padding: 10px 14px
- Color:
--text-primary - Focus: border-color →
--accent - Width: 100%
Tables
border-collapse: collapse, full width, 14pxth: 10px 12px padding,--font-display, 11px, 700, uppercase,--text-muted, bottom border, pointer cursor, hover →--accenttd: 12px 12px padding, bottom border- Row hover: background
rgba(0,212,255,0.03) - Active sort column header:
--accentcolored - Active sort column values:
--accent, 16px, weight 800
File Structure
statsphere/
├── public/
│ ├── favicon.svg
│ ├── icon-192.png
│ ├── icon-512.png
│ └── apple-touch-icon.png
├── src/
│ ├── components/
│ │ ├── Nav.jsx
│ │ ├── Avatar.jsx
│ │ ├── SportBadge.jsx
│ │ └── PWABanner.jsx
│ ├── data/
│ │ └── seedData.js
│ ├── hooks/
│ │ └── useStore.jsx
│ ├── pages/
│ │ ├── Home.jsx
│ │ ├── Leaders.jsx
│ │ ├── Filter.jsx
│ │ ├── Athletes.jsx
│ │ ├── AthleteDetail.jsx
│ │ └── Register.jsx
│ ├── App.jsx
│ ├── main.jsx
│ └── index.css
├── index.html
├── vite.config.js
├── package.json
├── install.sh
└── README.md
Data Model
Sports Registry (SPORTS)
An object keyed by sport ID. Each entry:
{
id: 'football',
name: 'American Football',
emoji: '🏈',
color: '#4ade80',
}
Sports: football, hockey, baseball, soccer, basketball
Stat Definitions (SPORT_STATS_DEFS)
An object keyed by sport ID. Each sport has an array of stat definition objects:
{ key: 'touchdowns', label: 'TDs', type: 'number' }
// type: 'text' | 'number' | 'decimal'
// 'text' = select/dropdown (used for position)
// 'number' = integer
// 'decimal' = float, displayed with toFixed(2)
Football (16 stats): position (text), touchdowns, passing_yards, rushing_yards, receiving_yards, completions, attempts, completion_pct (decimal), interceptions, sacks (decimal), tackles, receptions, fumbles, field_goals, games_played, passer_rating (decimal)
Hockey (16 stats): position (text), goals, assists, points, plus_minus, penalty_minutes, shots, shot_pct (decimal), games_played, save_pct (decimal), gaa (decimal), shutouts, toi (decimal), faceoff_pct (decimal), power_play_goals, shorthanded_goals
Baseball (16 stats): position (text), batting_avg (decimal), home_runs, rbi, runs, hits, stolen_bases, obp (decimal), slg (decimal), ops (decimal), era (decimal), strikeouts_p, wins, whip (decimal), games_played, war (decimal)
Soccer (16 stats): position (text), goals, assists, games_played, minutes_played, shots, shots_on_target, pass_accuracy (decimal), key_passes, dribbles, tackles, yellow_cards, red_cards, save_pct (decimal), clean_sheets, xg (decimal)
Basketball (16 stats): position (text), points_per_game (decimal), rebounds_per_game (decimal), assists_per_game (decimal), steals_per_game (decimal), blocks_per_game (decimal), fg_pct (decimal), three_pt_pct (decimal), ft_pct (decimal), games_played, minutes_per_game (decimal), turnovers (decimal), plus_minus (decimal), efficiency (decimal), true_shooting (decimal), usage_rate (decimal)
Biometric Fields (BIOMETRIC_FIELDS)
12 fields displayed on every athlete profile:
height_cm (number), weight_kg (number), age (number), reach_cm (number),
dominant_hand (text), dominant_foot (text), body_fat_pct (decimal),
vo2_max (decimal), vertical_jump_cm (number), 40_yard_dash (decimal),
bench_press_reps (number), years_pro (number)
Athlete User Object
{
id: String, // unique, Date.now() for new users
firstName: String,
lastName: String,
name: String, // firstName + ' ' + lastName
email: String,
phone: String,
city: String,
country: String,
bio: String, // max 300 chars
socials: {
facebook: String, // optional @handle
instagram: String,
bluesky: String,
snapchat: String,
},
avatarColor: String, // hex color for avatar fallback
profileImage: String, // base64 data URL or empty string
primarySport: String, // sport id
sports: String[], // array of sport ids (1 or more)
biometrics: {
height_cm: Number,
weight_kg: Number,
age: Number,
reach_cm: Number,
dominant_hand: String,
dominant_foot: String,
body_fat_pct: Number,
vo2_max: Number,
vertical_jump_cm: Number,
'40_yard_dash': Number,
bench_press_reps: Number,
years_pro: Number,
},
sportStats: {
football: { position, touchdowns, passing_yards, ... },
hockey: { position, goals, assists, ... },
// only sports the athlete is registered in
},
joinDate: String, // ISO date string YYYY-MM-DD
}
Positions Per Sport
football: ['QB','RB','WR','TE','OL','DE','DT','LB','CB','S','K']
hockey: ['C','LW','RW','D','G']
baseball: ['SP','RP','C','1B','2B','3B','SS','LF','CF','RF','DH']
soccer: ['GK','CB','LB','RB','CDM','CM','CAM','LW','RW','ST']
basketball: ['PG','SG','SF','PF','C']
Seed Data Generation
Generate 125 athlete records (25 per sport). Approximately 30% of athletes play a second sport randomly selected from the remaining four. All data is fake but realistic.
Name pool:
- First names (30): Marcus, Tyler, Jordan, Devon, Chase, Zach, Ryan, Cole, Drew, Cody, Logan, Austin, Blake, Jalen, Myles, Trey, Dylan, Caleb, Eli, Noah, Liam, Aiden, Cameron, Hunter, Bryce, Jake, Luke, Owen, Seth, Finn
- Last names (30): Thompson, Williams, Johnson, Davis, Martinez, Anderson, Taylor, Jackson, White, Harris, Martin, Garcia, Walker, Robinson, Lewis, Lee, Allen, Young, Hernandez, King, Wright, Scott, Torres, Nguyen, Hill, Flores, Green, Adams, Nelson, Baker
Biometric ranges by sport (height cm / weight kg):
- Football: h 175–205, w 85–140
- Hockey: h 175–198, w 82–105
- Baseball: h 170–200, w 82–110
- Soccer: h 165–195, w 70–90
- Basketball: h 185–220, w 85–120
Common biometrics: age 20–38, reach = height + 5–20, body_fat_pct 6–18%, vo2_max 48–72, vertical_jump 55–90cm, 40_yard_dash 4.30–5.20s, bench_press_reps 10–35, years_pro 1–18
Stat generation is position-aware. Key rules:
- Football: QBs get passing_yards (2800–5200), rushers get rushing_yards, WR/TE get receiving_yards; defenders get tackles/sacks/interceptions; non-QBs get 0 for passing stats
- Hockey: Goalies (G) get save_pct (0.900–0.935), gaa (1.8–3.5), shutouts (1–12); skaters get goals (5–55), assists, points; Centers get faceoff_pct (42–58%)
- Baseball: Pitchers (SP/RP) get era (2.10–5.80), wins (4–22), whip (0.92–1.60), strikeouts_p; position players get batting_avg (0.220–0.350), home_runs, obp, slg, ops
- Soccer: GK gets save_pct (65–82%), clean_sheets; defenders get fewer goals; attackers get more shots/xg
- Basketball: Centers/PFs get higher rebounds; PGs get higher assists; all get per-game averages
Socials: each athlete randomly gets 0–4 platforms (60% chance per platform), handle = @firstname_lastname##
Cities: New York, Los Angeles, Chicago, Houston, Phoenix, Toronto, Vancouver, Dallas, Miami, Boston, Seattle, Denver, Atlanta, Minneapolis, Detroit
Countries: 80% USA, 20% Canada/Mexico/UK/Brazil/Australia
Avatar colors pool: ['#00d4ff','#ff6b35','#a855f7','#22c55e','#f59e0b','#ec4899','#06b6d4','#84cc16']
Bio pool (10 rotating):
- "Dedicated athlete with a passion for the game and improving every day."
- "Competitive spirit who gives 110% on and off the field."
- "Team player first, stats second. Love the game, love my teammates."
- "Training hard to be the best version of myself every season."
- "Multiple-sport athlete who believes cross-training builds champions."
- "Community advocate and sports mentor for youth programs."
- "Playing to inspire the next generation of athletes."
- "Fueled by competition and the pursuit of excellence."
- "Veteran player with years of experience and a championship mindset."
- "Rising star hungry for the next level."
Export SEED_USERS as the merged, deduplicated array of all generated athletes.
Global State (useStore.jsx)
A React Context provider wrapping the entire app. Persists to localStorage under key statsphere_users_v1. Loads seed data on first run (when localStorage is empty).
Exposed API:
{
users, // User[] — full array
addUser(user), // adds new user, generates id + joinDate, returns new user
updateUser(id, updates), // merges updates into matching user
deleteUser(id), // removes user by id
getUserById(id), // returns single user or undefined
getUsersBySport(sport), // returns users where sports array includes sport
resetToSeed(), // restores SEED_USERS to localStorage
}
Load order: try JSON.parse(localStorage.getItem('statsphere_users_v1')) → fall back to SEED_USERS. Save: useEffect on users changes → localStorage.setItem(...).
Components
Nav.jsx
Two navigation bars rendered simultaneously:
Desktop top bar (visible ≥768px):
- Fixed, full-width, height
--nav-h(60px) - Background:
rgba(10,15,30,0.95)withbackdrop-filter: blur(12px) - Left: Logo mark (36px square div with gradient
#00d4ff → #a855f7, 8px radius, "S" text) + wordmark "STATSPHERE" where SPHERE is--accentcolored - Right: horizontal nav links — Home, Leaders, Search, Athletes, Register
- Active link:
--accentcolored text +rgba(0,212,255,0.08)background - Font:
--font-display, 12px, 700, uppercase, letter-spacing 0.08em
Mobile bottom nav (visible <768px):
- Fixed, full-width, height
var(--bottom-nav-h)(64px) - Background:
rgba(10,15,30,0.97)withbackdrop-filter: blur(12px) - 5 equal-width items: ⚡ Home, 🏆 Leaders, 🔍 Search, 👤 Athletes, ➕ Register
- Each item: icon (20px emoji) above label (10px, uppercase,
--font-display) - Active:
--accentcolor; inactive:--text-muted - Accounts for
env(safe-area-inset-bottom)padding
Routes: / /leaders /filter /athletes /register
Avatar.jsx
Props: user object, size (number, default 40)
If user.profileImage is set: render <img> with border-radius: 50%, object-fit: cover
Else: render div with:
border-radius: 50%- Background:
${user.avatarColor}22(10% opacity) - Border:
2px solid ${user.avatarColor}44(25% opacity) - Text: initials (
firstName[0] + lastName[0]),--font-display, 800 weight, coloreduser.avatarColor - Font size:
size * 0.35
SportBadge.jsx
Props: sport (string id)
Renders: <span className={\badge sport-${sport}`}>{emoji} {name}`
PWABanner.jsx
Listens for the browser beforeinstallprompt event. If not already dismissed (check localStorage.getItem('pwa_dismissed')), shows a fixed banner above the bottom nav when the event fires.
Banner content:
- ⚡ icon
- Title: "Install StatSphere"
- Subtitle: "Add to home screen for the best experience"
- "Install" button: calls
prompt.prompt()on click - "×" dismiss button: sets
localStorage.setItem('pwa_dismissed','1')and hides
Styling: .pwa-banner class — --bg-card2 background, 1px --accent border, 12px radius, box-shadow 0 4px 32px rgba(0,212,255,0.15)
Pages
Home.jsx — Dashboard (/)
Hero section:
- Full-width, centered
- Radial gradient overlay:
rgba(0,212,255,0.08)at top, transparent by 60% - Heading (responsive font clamp 36px–72px, 900 weight): "YOUR STATS," (white) + line break + "YOUR LEGACY" (cyan)
- Subtitle: 16px,
--text-secondary, max-width 500px - Two CTA buttons: "Register Athlete" (primary) + "View Leaders" (secondary)
Stats overview: 3-column .grid-3 of .card tiles — Total Athletes, Number of Sports (5), Multi-Sport Athletes count
Sports grid:
repeat(auto-fill, minmax(160px, 1fr))grid- Each sport is a card with: emoji (36px), sport name (13px, uppercase, sport's color), athlete count (12px, muted)
- Links to
/leaders?sport={id} - Hover: border-color → sport.color, translateY(-2px)
Recent Athletes: last 5 athletes by joinDate, shown as a stacked list inside a single .card. Each row: Avatar (42px) + name/city + sport badges. Links to /athletes/:id.
Leaders.jsx — Stat Leaders (/leaders)
Controls:
- Sport tab bar (5 tabs, horizontal scroll on mobile)
- "Sort By" select: all numeric stats for the current sport
- "Direction" select: "High → Low" / "Low → High"
- "Show" select: 25 (default) / 50 / 100
Behavior:
- Reads
?sport=query param on mount to set initial sport - When sport changes, resets sort key to the second numeric stat for that sport (first meaningful stat after position)
- Filtered list =
getUsersBySport(sport)→ only users withsportStats[sport]defined → sorted by selected stat → sliced to row limit
Table columns:
#— rank badge (1/2/3 gold/silver/bronze, rest neutral)- Athlete — Avatar (34px) + name (link to profile) + city
- Pos — position string from sportStats
- 10 numeric stat columns (first 10 numeric stats for the sport, skipping
position)
Active sort column: header colored --accent with ↑/↓ arrow; cell values colored --accent, 16px, 800 weight
Value formatting: 0 or null/undefined → display —; type decimal → .toFixed(2); type number → raw value
Table is horizontally scrollable on mobile via overflow-x: auto wrapper
Footer: "Showing N athletes · Sorted by {stat label}"
Filter.jsx — Search & Filter (/filter)
Three filter card sections:
Section 1 — General Search:
- Text input: search by name, city, country, bio (case-insensitive substring)
- "Sport" select: All Sports + 5 sports
- "Position" select: appears only when a specific sport is selected; populated dynamically from unique positions found in that sport's data
Section 2 — Sport Stat Filter:
- "Sport" select (defaults to football)
- "Stat" select: numeric stats for the selected sport
- Min / Max number inputs (2-column inline grid)
- Filters athletes where
sportStats[filterSport][filterKey]is within [min, max]
Section 3 — Biometric Filter:
- "Biometric" select: all non-text biometric fields
- Min / Max number inputs
Result controls:
- Sort By: Name / Age / Join Date
- Order: A→Z/Low→High or Z→A/High→Low
- Show: 25 / 50 / 100
- "Clear All" button resets every filter state
Results list: flat stacked list inside .card, same row format as Home recent athletes (Avatar + name/city/age/height + sport badges). Links to athlete profile. Shows count above list. Empty state: "No athletes match your filters"
Filtering is applied in order: text search → sport → position → stat filter → biometric filter → sort → slice.
Athletes.jsx — Athlete List (/athletes)
Controls: Text search (name or city) + sport dropdown filter, shown inline
Grid: repeat(auto-fill, minmax(280px, 1fr))
Each card:
- Avatar (48px) + name + city/country
- Sport badges row
- 3 quick-stat tiles in a flex row: Age, Height (cm), Years Pro — each with
--font-displayvalue in--accentand tiny uppercase label - Hover: border-color →
--accent, translateY(-2px) - Links to
/athletes/:id
"+ Add" button (primary, small) top-right links to /register
Empty state: centered message
AthleteDetail.jsx — Athlete Profile (/athletes/:id)
If athlete not found: 🏟️ emoji + "Athlete not found" + back link
Header card:
- Gradient background:
linear-gradient(135deg, --bg-card 0%, --bg-card2 100%) - Avatar (80px) + name (32px, 900 weight) + city/country + join date
- Sport badges row
- Bio in italic quotes
- Edit link →
/register?edit={id}(secondary button) - Delete button (danger) with
confirm()dialog → deletes and navigates to/athletes
Social media row (if any socials exist):
- Separator line above
- Icons: 📘 facebook, 📷 instagram, 🦋 bluesky, 👻 snapchat
- Handle text next to each icon
Contact card:
- Email (colored
--accent) + Phone in 2-column grid
Biometrics card:
repeat(auto-fill, minmax(130px, 1fr))grid- Each field as a
.card2with.stat-lbl+.stat-val - Decimal fields:
.toFixed(1)— skip fields with no value
Sport Stats card:
- If athlete has >1 sport: show sport tab bar to switch between sports
- Active sport stats shown as
repeat(auto-fill, minmax(130px, 1fr))grid of.card2tiles - Skip stats with value
0or undefined (avoids showing irrelevant stats for wrong position) texttype stats (position): show as.stat-valat 16pxdecimaltype:.toFixed(2)
Back link → /athletes
Register.jsx — Registration / Edit Form (/register)
Dual mode: if ?edit={id} query param is present, loads existing user data and updates on submit. Otherwise creates new user.
4-step wizard: step indicator bar at top shows all 4 steps with completion state (✓ for completed, number for incomplete). Clicking a completed step navigates back to it.
Steps: ['Personal Info', 'Biometrics', 'Sport Stats', 'Review']
Step 1 — Personal Info
Profile photo section:
- 80px circular avatar preview (shows initials or uploaded image)
- File input for photo upload → reads as base64 DataURL → stored in
form.profileImage - 6 avatar color swatches (circular buttons):
['#00d4ff','#ff6b35','#a855f7','#22c55e','#f59e0b','#ec4899']— active swatch has 3px white border
Personal fields (2-column grid):
- First Name * (required)
- Last Name * (required)
- Email * (required)
- Phone
- City
- Country
Bio field: <textarea> maxLength 300, character counter bottom-right
Social media section (labeled "optional"):
- 4 inputs in 2-column grid: Facebook, Instagram, Bluesky, Snapchat — all accept
@handleformat
Sports selection:
- Instruction text: "Select all sports you compete in:"
- 5 sport badge buttons (toggle on/off); at least 1 must remain selected
- Active badges: full opacity, scale 1.05; inactive: opacity 0.4
- At least one sport always stays selected
Step 2 — Biometrics
All 12 biometric fields in 2-column grid:
texttype fields (dominant_hand,dominant_foot):<select>with options Right / Left / Both- All other fields:
<input type="number">, step 0.1 for decimal, step 1 for integer
Step 3 — Sport Stats
One card section per selected sport. Each card:
- Header:
{emoji} {sport name} Stats - 2-column grid of all 16 stat fields:
positionfield:<select>populated with positions for that sport- Decimal stats:
<input type="number" step="0.01"> - Integer stats:
<input type="number" step="1">
Step 4 — Review
Summary view:
- Avatar preview (64px) + full name + email + city/country
- Sport badges
- Bio in italic quotes
- Count: "N biometric fields filled · N sport stats filled"
- Submit / Save button
Form State Management
Single form object in state. setField(dotPath, value) function handles nested updates by deep-cloning and traversing the path (e.g. setField('sportStats.football.touchdowns', 42) or setField('socials.instagram', '@handle')).
Validation on submit: check firstName, lastName, email are non-empty — show toast on failure.
Toast: fixed bottom pill, green background, "Athlete registered!" or "Athlete updated!", auto-hides after 2.5s with CSS keyframe animation. On success, navigate to /athletes after 1.8s.
Edit mode: on mount if editId present, hydrate form from getUserById(editId). Merge existing socials with empty defaults so all 4 fields always exist.
PWA Configuration (vite.config.js)
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'StatSphere – Athlete Stats Platform',
short_name: 'StatSphere',
description: 'Track and compare athlete stats across multiple sports',
theme_color: '#0a0f1e',
background_color: '#0a0f1e',
display: 'standalone',
orientation: 'portrait-primary',
scope: '/',
start_url: '/',
icons: [
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}']
}
})
HTML Meta Tags (index.html)
Include:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0a0f1e" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="StatSphere" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
Responsive Design
- Mobile-first layout via the
.pageclass and CSS Gridauto-fillcolumns - Navigation: desktop horizontal top bar ≥768px; mobile bottom bar <768px
- Bottom nav height: CSS variable
--bottom-nav-h— 64px on mobile, 0px on desktop (set via@mediarules in Nav component) - Tables: horizontally scrollable wrapper
overflow-x: autoon mobile - Grids:
.grid-2collapses to 1-col,.grid-3collapses to 2-col below 600px - Hero text:
clamp(36px, 8vw, 72px)for fluid scaling
Ubuntu 24.04 LXC Install Script (install.sh)
A bash script that:
apt-get update+ installcurl git nginx- Install Node.js 20 LTS via NodeSource setup script
- Create system user
statsphere - Copy app to
/opt/statsphere, set ownership npm installas statsphere usernpm run buildas statsphere user- Write
/etc/nginx/sites-available/statspherewith:listen 80+listen [::]:80root /opt/statsphere/disttry_files $uri $uri/ /index.html(SPA fallback)- Gzip on for js/css/html/json/svg/xml
- 1-year cache headers for static assets
- No-cache for
sw.js
- Enable site, remove default,
nginx -t && systemctl restart nginx - Create optional
statsphere-dev.servicesystemd unit runningnpm run previewon port 4173 - Print success message with server IP
Data Persistence Notes
- All data lives in
localStoragekeystatsphere_users_v1 - To reset:
localStorage.removeItem('statsphere_users_v1'); location.reload() - Profile images stored as base64 data URLs — large images may approach localStorage limits; no validation enforced
- No authentication; any user can edit/delete any athlete
Extensibility Notes
To add a new sport in the future:
- Add entry to
SPORTSinseedData.js - Add stat definitions array to
SPORT_STATS_DEFS - Add position list to
positionsin bothseedData.jsandRegister.jsx - Optionally add seed data generator function and call it in the generation loop
- Add sport CSS class
.sport-{id}toindex.css
No routing, component, or page changes required.