721 lines
29 KiB
Markdown
721 lines
29 KiB
Markdown
# 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:**
|
||
```json
|
||
{
|
||
"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.
|
||
|
||
```css
|
||
: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-card` bg, 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, 14px
|
||
- `th`: 10px 12px padding, `--font-display`, 11px, 700, uppercase, `--text-muted`, bottom border, pointer cursor, hover → `--accent`
|
||
- `td`: 12px 12px padding, bottom border
|
||
- Row hover: background `rgba(0,212,255,0.03)`
|
||
- Active sort column header: `--accent` colored
|
||
- 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:
|
||
```js
|
||
{
|
||
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:
|
||
```js
|
||
{ 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
|
||
```js
|
||
{
|
||
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
|
||
```js
|
||
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):**
|
||
1. "Dedicated athlete with a passion for the game and improving every day."
|
||
2. "Competitive spirit who gives 110% on and off the field."
|
||
3. "Team player first, stats second. Love the game, love my teammates."
|
||
4. "Training hard to be the best version of myself every season."
|
||
5. "Multiple-sport athlete who believes cross-training builds champions."
|
||
6. "Community advocate and sports mentor for youth programs."
|
||
7. "Playing to inspire the next generation of athletes."
|
||
8. "Fueled by competition and the pursuit of excellence."
|
||
9. "Veteran player with years of experience and a championship mindset."
|
||
10. "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:**
|
||
```js
|
||
{
|
||
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)` with `backdrop-filter: blur(12px)`
|
||
- Left: Logo mark (36px square div with gradient `#00d4ff → #a855f7`, 8px radius, "S" text) + wordmark "STAT**SPHERE**" where SPHERE is `--accent` colored
|
||
- Right: horizontal nav links — Home, Leaders, Search, Athletes, Register
|
||
- Active link: `--accent` colored 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)` with `backdrop-filter: blur(12px)`
|
||
- 5 equal-width items: ⚡ Home, 🏆 Leaders, 🔍 Search, 👤 Athletes, ➕ Register
|
||
- Each item: icon (20px emoji) above label (10px, uppercase, `--font-display`)
|
||
- Active: `--accent` color; 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, colored `user.avatarColor`
|
||
- Font size: `size * 0.35`
|
||
|
||
### `SportBadge.jsx`
|
||
Props: `sport` (string id)
|
||
|
||
Renders: `<span className={\`badge sport-${sport}\`}>{emoji} {name}</span>`
|
||
|
||
### `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 with `sportStats[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-display` value in `--accent` and 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 `.card2` with `.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 `.card2` tiles
|
||
- Skip stats with value `0` or undefined (avoids showing irrelevant stats for wrong position)
|
||
- `text` type stats (position): show as `.stat-val` at 16px
|
||
- `decimal` type: `.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 `@handle` format
|
||
|
||
**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:
|
||
- `text` type 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:
|
||
- `position` field: `<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`)
|
||
|
||
```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:
|
||
```html
|
||
<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 `.page` class and CSS Grid `auto-fill` columns
|
||
- **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 `@media` rules in Nav component)
|
||
- **Tables:** horizontally scrollable wrapper `overflow-x: auto` on mobile
|
||
- **Grids:** `.grid-2` collapses to 1-col, `.grid-3` collapses 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:
|
||
1. `apt-get update` + install `curl git nginx`
|
||
2. Install Node.js 20 LTS via NodeSource setup script
|
||
3. Create system user `statsphere`
|
||
4. Copy app to `/opt/statsphere`, set ownership
|
||
5. `npm install` as statsphere user
|
||
6. `npm run build` as statsphere user
|
||
7. Write `/etc/nginx/sites-available/statsphere` with:
|
||
- `listen 80` + `listen [::]:80`
|
||
- `root /opt/statsphere/dist`
|
||
- `try_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`
|
||
8. Enable site, remove default, `nginx -t && systemctl restart nginx`
|
||
9. Create optional `statsphere-dev.service` systemd unit running `npm run preview` on port 4173
|
||
10. Print success message with server IP
|
||
|
||
---
|
||
|
||
## Data Persistence Notes
|
||
|
||
- All data lives in `localStorage` key `statsphere_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:
|
||
1. Add entry to `SPORTS` in `seedData.js`
|
||
2. Add stat definitions array to `SPORT_STATS_DEFS`
|
||
3. Add position list to `positions` in both `seedData.js` and `Register.jsx`
|
||
4. Optionally add seed data generator function and call it in the generation loop
|
||
5. Add sport CSS class `.sport-{id}` to `index.css`
|
||
|
||
No routing, component, or page changes required.
|