This commit is contained in:
2026-04-07 16:42:17 -04:00
commit 23382d0301
27 changed files with 3525 additions and 0 deletions

336
CLAUDE.md Normal file
View File

@@ -0,0 +1,336 @@
# CLAUDE.md — PlayersEdge
This file tells Claude how to work in this codebase. Read it fully before making any changes.
---
## Current version
- `Version:` 0.0.1
---
## What This Project Is
PlayersEdge is a client-side Progressive Web App for tracking athlete biometric and sport-specific statistics. It supports five sports (American Football, Hockey, Baseball, Soccer, Basketball) with multi-sport athlete profiles. There is no backend, no database, and no API — all data lives in `localStorage`. The app is built with React 18 + Vite and deployed as static files behind Nginx on Ubuntu 24.04.
---
## Commands
```bash
npm run dev # Dev server at http://localhost:5173 (hot reload)
npm run build # Production build → ./dist/
npm run preview # Serve production build at http://localhost:4173
```
There are no tests. There is no linter config. There is no TypeScript.
---
## Architecture
### State Management
All app state flows through a single React Context defined in `src/hooks/useStore.jsx`.
- `StoreProvider` wraps the entire app in `App.jsx`
- Data is loaded from `localStorage` key `PlayersEdge_users_v1` on first render; falls back to `SEED_USERS` from `seedData.js` if the key is absent or unparseable
- Every change to `users` is auto-saved to `localStorage` via a `useEffect`
- There is no per-session state beyond what's in localStorage — refreshing the page preserves all data
**Do not use component-level state for anything that needs to persist.** Route it through `useStore`.
**The store API:**
```js
const { users, addUser, updateUser, deleteUser, getUserById, getUsersBySport, resetToSeed } = useStore();
```
- `addUser(user)` — generates `id` (Date.now()) and `joinDate` automatically
- `updateUser(id, updates)` — shallow-merges updates into the matching user
- `deleteUser(id)` — removes by id
- `getUsersBySport(sport)` — filters `users` where `u.sports.includes(sport)`
- `resetToSeed()` — replaces all users with `SEED_USERS`
### Routing
React Router v6. All routes are defined once in `App.jsx`. The Nginx config uses `try_files $uri $uri/ /index.html` for SPA fallback — all routes resolve client-side.
```
/ → Home.jsx
/leaders → Leaders.jsx (accepts ?sport= query param)
/filter → Filter.jsx
/athletes → Athletes.jsx
/athletes/:id → AthleteDetail.jsx
/register → Register.jsx (accepts ?edit={id} query param)
```
### Data Layer
`src/data/seedData.js` is the single source of truth for:
- `SPORTS` — sport registry object
- `SPORT_STATS_DEFS` — stat definitions per sport (key, label, type)
- `BIOMETRIC_FIELDS` — 12 biometric field definitions
- `SEED_USERS` — 125 generated fake athletes
- `getUsersBySport(sport)` — exported helper (also re-implemented in the store)
**`SPORT_STATS_DEFS` governs everything** — Leaders table columns, Filter dropdowns, Register form fields, and AthleteDetail stat grids all read from it. When adding a stat, add it here and nowhere else. The UI derives from the data.
Stat definition shape:
```js
{ key: 'touchdowns', label: 'TDs', type: 'number' }
// type: 'text' | 'number' | 'decimal'
```
- `text` — rendered as a select (position field only)
- `number` — integer; displayed as-is
- `decimal` — float; displayed with `.toFixed(2)` in tables, `.toFixed(1)` in biometrics
---
## User Object Shape
```js
{
id: String, // Date.now() string for new users; '101''225' for seed users
firstName: String,
lastName: String,
name: String, // firstName + ' ' + lastName — must be kept in sync
email: String,
phone: String,
city: String,
country: String,
bio: String, // max 300 chars
socials: {
facebook: String, // @handle or empty string
instagram: String,
bluesky: String,
snapchat: String,
},
avatarColor: String, // hex — used for avatar bg tint when no profileImage
profileImage: String, // base64 data URL or empty string
primarySport: String, // one of the SPORTS keys
sports: String[], // array of sport keys — always contains primarySport
biometrics: {
height_cm, weight_kg, age, reach_cm,
dominant_hand, dominant_foot,
body_fat_pct, vo2_max, vertical_jump_cm,
'40_yard_dash', bench_press_reps, years_pro
},
sportStats: {
football: { position, touchdowns, passing_yards, ... },
hockey: { position, goals, assists, ... },
// only present for sports in the user's sports array
},
joinDate: String, // YYYY-MM-DD
}
```
**Key invariants:**
- `user.sports` always has at least one entry
- `user.primarySport` always exists in `user.sports`
- `user.sportStats` only has keys that appear in `user.sports`
- `user.name` must equal `user.firstName + ' ' + user.lastName`
---
## Component Conventions
### File Organization
```
src/components/ — shared, reusable, no page logic
src/pages/ — one file per route, owns that route's state
src/hooks/ — useStore only (for now)
src/data/ — seedData only
```
### Import Style
All imports use explicit `.jsx` extensions. No index barrel files. Example:
```js
import Avatar from '../components/Avatar.jsx';
import { useStore } from '../hooks/useStore.jsx';
import { SPORTS, SPORT_STATS_DEFS } from '../data/seedData.js';
```
### Component Patterns
- All components are functional with hooks — no class components
- Default export only — no named component exports
- `useMemo` is used for any list that filters or sorts — don't compute in render body
- Hover effects are applied with `onMouseEnter`/`onMouseLeave` setting `e.currentTarget.style` directly (no CSS modules, no Tailwind)
- Navigation after an action (register, delete) uses `useNavigate` from react-router-dom
### Form State Pattern
The `Register.jsx` form uses a single `form` object in state and a `setField(dotPath, value)` helper that deep-clones and traverses the path:
```js
function setField(path, value) {
setForm(f => {
const clone = JSON.parse(JSON.stringify(f));
const parts = path.split('.');
let cur = clone;
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]];
cur[parts[parts.length - 1]] = value;
return clone;
});
}
// Usage:
setField('socials.instagram', '@handle');
setField('sportStats.football.touchdowns', 42);
```
Do not flatten the form structure. Do not use separate `useState` calls per field.
### Toast Notifications
The Register page shows a `.toast` div using a local `toast` state string. The toast CSS class in `index.css` handles the animation entirely (keyframes `toastIn` at 0s, `toastOut` at 2.5s). Set the toast message, then clear it after 3s with `setTimeout`. Navigate after 1.8s on success.
---
## Styling Conventions
**All styles live in two places only:**
1. `src/index.css` — global CSS custom properties and utility classes
2. Inline `style={{}}` props on JSX elements — for component-specific layout
There are no CSS modules, no styled-components, no Tailwind, no SCSS. Do not introduce any CSS tooling.
### CSS Variables (defined in `:root` in `index.css`)
```
Backgrounds: --bg-base, --bg-card, --bg-card2, --bg-input
Accents: --accent (#00d4ff), --accent2 (#ff6b35), --accent3 (#a855f7)
Text: --text-primary, --text-secondary, --text-muted
Borders: --border, --border-bright
Semantic: --success, --warning, --danger
Fonts: --font-display ('Barlow Condensed'), --font-body ('Barlow')
Layout: --nav-h (60px), --bottom-nav-h (64px mobile / 0px desktop), --radius (12px), --radius-sm (8px)
```
Always use CSS variables — never hardcode colors that match a variable.
### Utility Classes (defined in `index.css`)
Use these classes rather than recreating their styles inline:
| Class | Purpose |
|---|---|
| `.page` | Page content wrapper — handles top/bottom nav padding, max-width 900px, centered |
| `.card` | Primary surface — `--bg-card`, 1px border, 12px radius, 20px padding |
| `.card2` | Secondary surface — `--bg-card2`, 1px `--border-bright`, 8px radius, 16px padding |
| `.btn` | Base button — display, font, uppercase, transition |
| `.btn-primary` | Cyan fill button |
| `.btn-secondary` | Ghost button |
| `.btn-danger` | Red fill button |
| `.badge` | Small pill label |
| `.sport-{id}` | Sport-specific badge colors (football/hockey/baseball/soccer/basketball) |
| `.label` | Form field label — uppercase, 12px, `--font-display` |
| `.form-group` | Form field wrapper — 16px bottom margin |
| `.section-title` | Page heading — 28px, 800 weight, uppercase |
| `.section-sub` | Page subheading — 14px, `--text-secondary` |
| `.stat-val` | Large stat number — 24px, 800 weight, `--accent` |
| `.stat-lbl` | Stat label beneath value — 11px, uppercase, `--text-muted` |
| `.tab-bar` | Horizontal scrollable tab container |
| `.tab` / `.tab.active` | Tab item and active state |
| `.rank-badge` | Circular rank number (32px) |
| `.rank-1/2/3/n` | Gold/silver/bronze/neutral rank colors |
| `.grid-2` | 2-column grid, collapses to 1-col at 600px |
| `.grid-3` | 3-column grid, collapses to 2-col at 600px |
| `.avatar` | Circular avatar base styles |
| `.toast` | Fixed bottom notification pill |
| `.pwa-banner` | Fixed PWA install prompt |
### Responsive Breakpoints
- `768px` — desktop/mobile navigation switch (set in Nav.jsx `<style>` block)
- `600px` — grid column collapse (set in index.css media query)
The `.page` class handles the correct padding for both nav bars automatically. Every page component just needs `<div className="page">` as its root — don't add manual top/bottom padding.
### Navigation Height
`--bottom-nav-h` is set to `64px` on mobile and `0px` on desktop via media queries injected by `Nav.jsx`. The `.toast` and `.pwa-banner` use `calc(var(--bottom-nav-h) + Npx)` to position correctly above the bottom nav on mobile.
---
## Adding a New Sport
1. **`src/data/seedData.js`** — add to `SPORTS`:
```js
lacrosse: { id: 'lacrosse', name: 'Lacrosse', emoji: '🥍', color: '#facc15' }
```
2. **`src/data/seedData.js`** — add to `SPORT_STATS_DEFS`:
```js
lacrosse: [
{ key: 'position', label: 'Position', type: 'text' },
{ key: 'goals', label: 'Goals', type: 'number' },
// ...
]
```
3. **`src/data/seedData.js`** — add to the `positions` object inside `genUser`:
```js
lacrosse: ['A', 'M', 'D', 'G']
```
4. **`src/pages/Register.jsx`** — add to the local `positions` object (identical to above).
5. **`src/index.css`** — add sport badge color class:
```css
.sport-lacrosse { background: #2a2a1a; color: #facc15; border: 1px solid #854d0e; }
```
6. Optionally add a seed data generator function in `seedData.js` and wire it into `genSportStats` and the generation loop.
No changes needed to routing, Nav, Leaders, Filter, Athletes, AthleteDetail, or Avatar.
---
## Adding a New Stat to an Existing Sport
Edit `SPORT_STATS_DEFS` in `src/data/seedData.js` only. Add the new stat definition object to the correct sport's array. The Leaders table (shows first 10 numeric stats), Filter page, Register form, and AthleteDetail grid all derive from this array automatically.
If the stat should appear in seed data, also update the relevant `gen{Sport}Stats()` function.
---
## PWA
The PWA is configured in `vite.config.js` via `vite-plugin-pwa`. The service worker is auto-generated by Workbox and caches all `{js,css,html,ico,png,svg,woff2}` files. `registerType: 'autoUpdate'` means the SW updates silently on rebuild.
The install prompt (`PWABanner.jsx`) listens for `beforeinstallprompt`. Once dismissed, it sets `localStorage.getItem('pwa_dismissed')` and never shows again. This is separate from `PlayersEdge_users_v1`.
PWA icons (`public/icon-192.png`, `public/icon-512.png`, `public/apple-touch-icon.png`) are placeholder PNGs. Replace them with real images for production.
---
## Deployment
The app builds to `./dist/` as fully static files. Serve them with any web server. The Nginx config in `install.sh` is the reference deployment.
Critical Nginx requirements:
- `try_files $uri $uri/ /index.html` — required for React Router to work on direct URL access and refresh
- `Cache-Control: no-cache` on `sw.js` — required so the service worker updates properly
- Long-term cache headers on `*.js`, `*.css` — Vite adds content hashes to filenames so this is safe
To rebuild and redeploy after code changes:
```bash
npm run build
# dist/ is ready — rsync or cp to web root
```
---
## Known Limitations / Things to Be Aware Of
- **Profile images are base64 in localStorage.** Large images can approach the ~5MB localStorage limit. There is no file size validation.
- **No authentication.** Any visitor can edit or delete any athlete.
- **`40_yard_dash` key has a leading digit.** Access it as `biometrics['40_yard_dash']`, not `biometrics.40_yard_dash`. This is intentional — the label reads naturally in the UI.
- **`user.name` is not auto-derived.** When updating `firstName` or `lastName`, also update `name`. The `addUser` function handles this; `updateUser` does not — it takes whatever you pass.
- **Seed data is regenerated randomly on every `npm run build`** (if localStorage is cleared). The randomness is seeded by `Math.random()` with no fixed seed. This is intentional — seed data is fake and disposable.
- **The Leaders table shows the first 10 numeric stats** for the selected sport. If a sport has stats that should be prioritised in the table, order them first in `SPORT_STATS_DEFS` (after the `position` text field).
- **`fmtVal` in Leaders shows `` for `0`** — this is a display choice, not a bug. Stats that are genuinely 0 (e.g. a QB's sack count as a defender stat) display as `—` to reduce noise.

238
README.md Normal file
View File

@@ -0,0 +1,238 @@
# PlayersEdge Athlete Stats Platform
A full-featured PWA for tracking athlete biometric and sports statistics across 5 sports: American Football, Hockey, Baseball, Soccer, and Basketball.
---
## Features
- **5 Sports** with 16 stats each: Football, Hockey, Baseball, Soccer, Basketball
- **Athlete registration** with 4-step form: personal info, biometrics, sport stats, review
- **Multi-sport athletes** one profile, multiple sports
- **Stat Leaders board** top 25/50/100 athletes, sort by any numeric stat
- **Advanced filter/search** filter by any stat, biometric, sport, position, name, city
- **Athlete profiles** biometrics, all sport stats, social media, contact info, profile photo
- **125 seed athletes** (25 per sport, some multi-sport) with realistic fake data
- **PWA installable** iOS & Android home screen install
- **Responsive design** mobile-first with bottom navigation on mobile
- **localStorage persistence** data survives page refresh
- **Dark sports theme** high-contrast dark UI with accent colors
---
## Quick Start (Local Development)
```bash
# Prerequisites: Node.js 18+ installed
node --version # must be 18+
# Install dependencies
npm install
# Start dev server (hot reload, runs on http://localhost:5173)
npm run dev
# Build for production
npm run build
# Preview production build locally (http://localhost:4173)
npm run preview
```
---
## Ubuntu 24.04 LXC Installation
### Prerequisites
- Ubuntu 24.04 LXC container or bare metal
- Root or sudo access
- Ports 80 (and optionally 443) open
### Step 1 Transfer files to your server
**Option A: Git (recommended)**
```bash
# On your server
apt-get install -y git
git clone https://your-repo-url.git /opt/statsphere-src
cd /opt/statsphere-src
```
**Option B: SCP / SFTP**
```bash
# From your local machine
scp -r ./statsphere root@YOUR_SERVER_IP:/tmp/
ssh root@YOUR_SERVER_IP
cp -r /tmp/statsphere /opt/statsphere-src
cd /opt/statsphere-src
```
**Option C: Upload zip**
```bash
apt-get install -y unzip
unzip statsphere.zip -d /opt/statsphere-src
cd /opt/statsphere-src
```
### Step 2 Run the installer
```bash
chmod +x install.sh
sudo bash install.sh
```
The installer will:
1. Update system packages
2. Install Node.js 20 LTS
3. Install npm dependencies
4. Build the production bundle
5. Configure Nginx as a reverse proxy
6. Enable and start Nginx
### Step 3 Access the app
Open your browser to `http://YOUR_LXC_IP`
---
## Manual Installation (Step by Step)
```bash
# 1. Install Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs nginx
# 2. Navigate to project
cd /path/to/statsphere
# 3. Install dependencies
npm install
# 4. Build
npm run build
# Output goes to ./dist/
# 5. Configure Nginx (see install.sh for full config)
sudo nano /etc/nginx/sites-available/statsphere
# Paste the nginx config from install.sh
sudo ln -s /etc/nginx/sites-available/statsphere /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl restart nginx
```
---
## HTTPS / SSL (Production)
```bash
# Install Certbot
sudo apt-get install -y certbot python3-certbot-nginx
# Update your nginx config server_name with your actual domain first, then:
sudo certbot --nginx -d yourdomain.com
# Auto-renew
sudo systemctl enable certbot.timer
```
---
## Directory Structure
```
statsphere/
├── public/ # Static assets (icons, favicon)
├── src/
│ ├── components/ # Reusable components
│ │ ├── Nav.jsx # Top + bottom navigation
│ │ ├── Avatar.jsx # Profile avatar with initials fallback
│ │ ├── SportBadge.jsx
│ │ └── PWABanner.jsx # PWA install prompt
│ ├── data/
│ │ └── seedData.js # 125 fake athletes + stat definitions
│ ├── hooks/
│ │ └── useStore.jsx # Global state (localStorage)
│ ├── pages/
│ │ ├── Home.jsx # Dashboard
│ │ ├── Leaders.jsx # Stat leaders board
│ │ ├── Filter.jsx # Advanced search
│ │ ├── Athletes.jsx # Athlete list
│ │ ├── AthleteDetail.jsx
│ │ └── Register.jsx # 4-step registration form
│ ├── App.jsx # Routes
│ ├── main.jsx
│ └── index.css # Global styles + design tokens
├── index.html
├── vite.config.js # Build config + PWA plugin
├── package.json
└── install.sh # Ubuntu auto-installer
```
---
## Adding More Sports
Edit `src/data/seedData.js`:
1. Add the sport to `SPORTS`:
```js
lacrosse: { id: 'lacrosse', name: 'Lacrosse', emoji: '🥍', color: '#facc15' },
```
2. Add stat definitions to `SPORT_STATS_DEFS`:
```js
lacrosse: [
{ key: 'position', label: 'Position', type: 'text' },
{ key: 'goals', label: 'Goals', type: 'number' },
// ...
],
```
3. Add positions to the `positions` object in `Register.jsx`.
4. Optionally add seed data generation for the new sport in `seedData.js`.
---
## PWA Installation
### Android (Chrome)
- Visit the app in Chrome
- Tap the "Install" banner that appears, or tap ⋮ → "Add to Home Screen"
### iOS (Safari)
- Visit the app in Safari
- Tap Share → "Add to Home Screen"
### Desktop (Chrome/Edge)
- Click the install icon (⊕) in the address bar
---
## Data Storage
All athlete data is stored in **browser localStorage** under the key `statsphere_users_v1`. Data persists across page reloads but is per-device/per-browser.
To reset to seed data: open the browser console and run:
```js
localStorage.removeItem('statsphere_users_v1'); location.reload();
```
---
## Tech Stack
| Layer | Technology |
|---|---|
| Framework | React 18 |
| Routing | React Router v6 |
| Build | Vite 5 |
| PWA | vite-plugin-pwa + Workbox |
| Icons | Lucide React |
| Web Server | Nginx |
| Styling | Pure CSS (no Tailwind) |
| State | React Context + localStorage |
| Data | In-memory seed + localStorage |
---
## License
MIT

16
generate-icons.js Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env node
// Simple script to create placeholder PNG icons for PWA
// Run: node generate-icons.js
const fs = require('fs');
// Minimal 1x1 PNG in base64 (navy blue pixel) - replace with real icons in production
// These are valid PNG files that satisfy PWA requirements during dev
const png192 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAYAAABS3GwHAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEwAACxMBAJqcGAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAH8SURBVHic7dAxAQAAAMKg9U9tDB+gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeAMAAAEAAf8AAAAASUVORK5CYII=',
'base64'
);
fs.writeFileSync('public/icon-192.png', png192);
fs.writeFileSync('public/icon-512.png', png192);
fs.writeFileSync('public/apple-touch-icon.png', png192);
console.log('Icons generated. Replace with real icons for production.');

22
index.html Normal file
View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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" />
<meta name="description" content="Track and compare athlete biometric and sports stats across multiple sports" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<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" />
<title>StatSphere Athlete Stats Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

128
install.sh Normal file
View File

@@ -0,0 +1,128 @@
#!/bin/bash
# StatSphere - Ubuntu 24.04 LXC Install Script
# Run as root or with sudo
set -e
APP_DIR="/opt/statsphere"
APP_USER="statsphere"
NODE_VERSION="20"
echo "============================================"
echo " StatSphere Athlete Stats Platform Installer"
echo "============================================"
# 1. System update
echo "[1/8] Updating system packages..."
apt-get update -qq
apt-get install -y -qq curl git nginx
# 2. Install Node.js 20 LTS via NodeSource
echo "[2/8] Installing Node.js ${NODE_VERSION}..."
curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash -
apt-get install -y -qq nodejs
echo " Node: $(node --version)"
echo " NPM: $(npm --version)"
# 3. Create app user
echo "[3/8] Creating app user..."
id -u $APP_USER &>/dev/null || useradd -r -s /bin/false -d $APP_DIR $APP_USER
# 4. Copy app files
echo "[4/8] Setting up application directory..."
mkdir -p $APP_DIR
cp -r . $APP_DIR/
chown -R $APP_USER:$APP_USER $APP_DIR
# 5. Install dependencies and build
echo "[5/8] Installing Node dependencies (this may take 2-3 minutes)..."
cd $APP_DIR
sudo -u $APP_USER npm install
echo "[6/8] Building production bundle..."
sudo -u $APP_USER npm run build
# 6. Configure Nginx
echo "[7/8] Configuring Nginx..."
cat > /etc/nginx/sites-available/statsphere <<'NGINX'
server {
listen 80;
listen [::]:80;
server_name _;
root /opt/statsphere/dist;
index index.html;
# Gzip
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2|woff)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Service worker - no cache
location = /sw.js {
add_header Cache-Control "no-cache";
}
# SPA fallback - all routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
}
NGINX
# Enable site
ln -sf /etc/nginx/sites-available/statsphere /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl restart nginx
systemctl enable nginx
# 7. Setup systemd service for dev server (optional)
echo "[8/8] Setting up systemd service (dev preview mode)..."
cat > /etc/systemd/system/statsphere-dev.service <<'SERVICE'
[Unit]
Description=StatSphere Development Server
After=network.target
[Service]
Type=simple
User=statsphere
WorkingDirectory=/opt/statsphere
ExecStart=/usr/bin/npm run preview
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
SERVICE
systemctl daemon-reload
echo ""
echo "============================================"
echo " Installation Complete!"
echo "============================================"
echo ""
echo " Production (Nginx): http://$(hostname -I | awk '{print $1}')"
echo ""
echo " To rebuild after changes:"
echo " cd $APP_DIR && npm run build"
echo ""
echo " To update the app:"
echo " cd $APP_DIR"
echo " git pull (if using git)"
echo " npm install"
echo " npm run build"
echo " systemctl restart nginx"
echo ""
echo " Dev server (optional, port 4173):"
echo " systemctl start statsphere-dev"
echo " systemctl enable statsphere-dev"
echo ""

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "statsphere",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"lucide-react": "^0.460.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"vite": "^5.4.10",
"vite-plugin-pwa": "^0.20.5",
"workbox-window": "^7.3.0"
}
}

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 B

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0a0f1e"/>
<text x="16" y="23" font-family="sans-serif" font-weight="900" font-size="20" text-anchor="middle" fill="#00d4ff">S</text>
</svg>

After

Width:  |  Height:  |  Size: 248 B

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 B

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 B

29
src/App.jsx Normal file
View File

@@ -0,0 +1,29 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { StoreProvider } from './hooks/useStore.jsx';
import Nav from './components/Nav.jsx';
import PWABanner from './components/PWABanner.jsx';
import Home from './pages/Home.jsx';
import Leaders from './pages/Leaders.jsx';
import Filter from './pages/Filter.jsx';
import Athletes from './pages/Athletes.jsx';
import AthleteDetail from './pages/AthleteDetail.jsx';
import Register from './pages/Register.jsx';
export default function App() {
return (
<StoreProvider>
<BrowserRouter>
<Nav />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/leaders" element={<Leaders />} />
<Route path="/filter" element={<Filter />} />
<Route path="/athletes" element={<Athletes />} />
<Route path="/athletes/:id" element={<AthleteDetail />} />
<Route path="/register" element={<Register />} />
</Routes>
<PWABanner />
</BrowserRouter>
</StoreProvider>
);
}

29
src/components/Avatar.jsx Normal file
View File

@@ -0,0 +1,29 @@
export default function Avatar({ user, size = 40 }) {
const initials = `${user.firstName?.[0] || ''}${user.lastName?.[0] || ''}`.toUpperCase();
const color = user.avatarColor || '#00d4ff';
if (user.profileImage) {
return (
<img
src={user.profileImage}
alt={user.name}
className="avatar"
style={{ width: size, height: size, fontSize: size * 0.35 }}
/>
);
}
return (
<div
className="avatar"
style={{
width: size, height: size, fontSize: size * 0.35,
background: `${color}22`,
border: `2px solid ${color}44`,
color,
}}
>
{initials}
</div>
);
}

86
src/components/Nav.jsx Normal file
View File

@@ -0,0 +1,86 @@
import { Link, useLocation } from 'react-router-dom';
const navItems = [
{ to: '/', label: 'Home', icon: '⚡' },
{ to: '/leaders', label: 'Leaders', icon: '🏆' },
{ to: '/filter', label: 'Search', icon: '🔍' },
{ to: '/athletes', label: 'Athletes', icon: '👤' },
{ to: '/register', label: 'Register', icon: '' },
];
export default function Nav() {
const { pathname } = useLocation();
return (
<>
<header style={{
position: 'fixed', top: 0, left: 0, right: 0, height: 'var(--nav-h)',
background: 'rgba(10,15,30,0.95)', backdropFilter: 'blur(12px)',
borderBottom: '1px solid var(--border)', zIndex: 100,
display: 'flex', alignItems: 'center', padding: '0 20px',
justifyContent: 'space-between',
}}>
<Link to="/" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: 'linear-gradient(135deg, var(--accent), var(--accent3))',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, fontWeight: 900,
}}>S</div>
<span style={{
fontFamily: 'var(--font-display)', fontSize: 22, fontWeight: 800,
letterSpacing: '0.05em', color: 'var(--text-primary)',
}}>STAT<span style={{ color: 'var(--accent)' }}>SPHERE</span></span>
</Link>
<nav style={{ display: 'flex', gap: 4 }} className="desktop-nav">
{navItems.map(n => (
<Link key={n.to} to={n.to} style={{
padding: '6px 14px',
borderRadius: 6,
fontSize: 12,
fontWeight: 700,
fontFamily: 'var(--font-display)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: pathname === n.to ? 'var(--accent)' : 'var(--text-secondary)',
background: pathname === n.to ? 'rgba(0,212,255,0.08)' : 'transparent',
transition: 'all 0.15s',
}}>{n.label}</Link>
))}
</nav>
</header>
{/* Mobile bottom nav */}
<nav style={{
position: 'fixed', bottom: 0, left: 0, right: 0,
height: 'var(--bottom-nav-h)',
background: 'rgba(10,15,30,0.97)', backdropFilter: 'blur(12px)',
borderTop: '1px solid var(--border)',
display: 'flex', alignItems: 'center',
zIndex: 100,
paddingBottom: 'env(safe-area-inset-bottom)',
}} className="mobile-nav">
{navItems.map(n => (
<Link key={n.to} to={n.to} style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', gap: 3, padding: '8px 4px',
color: pathname === n.to ? 'var(--accent)' : 'var(--text-muted)',
transition: 'color 0.15s',
}}>
<span style={{ fontSize: 20 }}>{n.icon}</span>
<span style={{ fontSize: 10, fontFamily: 'var(--font-display)', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
{n.label}
</span>
</Link>
))}
</nav>
<style>{`
@media (min-width: 768px) { .mobile-nav { display: none !important; } }
@media (max-width: 767px) { .desktop-nav { display: none !important; } }
@media (max-width: 767px) { :root { --bottom-nav-h: 64px; } }
@media (min-width: 768px) { :root { --bottom-nav-h: 0px; } }
`}</style>
</>
);
}

View File

@@ -0,0 +1,46 @@
import { useState, useEffect } from 'react';
export default function PWABanner() {
const [prompt, setPrompt] = useState(null);
const [show, setShow] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
if (localStorage.getItem('pwa_dismissed')) return;
const handler = (e) => {
e.preventDefault();
setPrompt(e);
setShow(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
if (!show || dismissed) return null;
async function install() {
if (!prompt) return;
prompt.prompt();
const result = await prompt.userChoice;
if (result.outcome === 'accepted') setShow(false);
setDismissed(true);
}
function dismiss() {
setShow(false);
setDismissed(true);
localStorage.setItem('pwa_dismissed', '1');
}
return (
<div className="pwa-banner">
<div style={{ fontSize: 28 }}></div>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 14, letterSpacing: '0.04em' }}>Install StatSphere</div>
<div style={{ color: 'var(--text-secondary)', fontSize: 12, marginTop: 2 }}>Add to home screen for the best experience</div>
</div>
<button className="btn btn-primary" onClick={install} style={{ fontSize: 12, padding: '8px 14px' }}>Install</button>
<button onClick={dismiss} style={{ color: 'var(--text-muted)', fontSize: 20, padding: '4px 8px', cursor: 'pointer' }}>×</button>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { SPORTS } from '../data/seedData.js';
export default function SportBadge({ sport }) {
const s = SPORTS[sport];
if (!s) return null;
return (
<span className={`badge sport-${sport}`}>
{s.emoji} {s.name}
</span>
);
}

373
src/data/seedData.js Normal file
View File

@@ -0,0 +1,373 @@
export const SPORTS = {
football: { id: 'football', name: 'American Football', emoji: '🏈', color: '#4ade80' },
hockey: { id: 'hockey', name: 'Hockey', emoji: '🏒', color: '#60a5fa' },
baseball: { id: 'baseball', name: 'Baseball', emoji: '⚾', color: '#f87171' },
soccer: { id: 'soccer', name: 'Soccer', emoji: '⚽', color: '#c084fc' },
basketball: { id: 'basketball', name: 'Basketball', emoji: '🏀', color: '#fb923c' },
};
export const SPORT_STATS_DEFS = {
football: [
{ key: 'position', label: 'Position', type: 'text' },
{ key: 'touchdowns', label: 'TDs', type: 'number' },
{ key: 'passing_yards', label: 'Passing Yds', type: 'number' },
{ key: 'rushing_yards', label: 'Rushing Yds', type: 'number' },
{ key: 'receiving_yards', label: 'Receiving Yds', type: 'number' },
{ key: 'completions', label: 'Completions', type: 'number' },
{ key: 'attempts', label: 'Attempts', type: 'number' },
{ key: 'completion_pct', label: 'Comp %', type: 'decimal' },
{ key: 'interceptions', label: 'INTs', type: 'number' },
{ key: 'sacks', label: 'Sacks', type: 'decimal' },
{ key: 'tackles', label: 'Tackles', type: 'number' },
{ key: 'receptions', label: 'Receptions', type: 'number' },
{ key: 'fumbles', label: 'Fumbles', type: 'number' },
{ key: 'field_goals', label: 'FGs', type: 'number' },
{ key: 'games_played', label: 'Games', type: 'number' },
{ key: 'passer_rating', label: 'QBR', type: 'decimal' },
],
hockey: [
{ key: 'position', label: 'Position', type: 'text' },
{ key: 'goals', label: 'Goals', type: 'number' },
{ key: 'assists', label: 'Assists', type: 'number' },
{ key: 'points', label: 'Points', type: 'number' },
{ key: 'plus_minus', label: '+/-', type: 'number' },
{ key: 'penalty_minutes', label: 'PIM', type: 'number' },
{ key: 'shots', label: 'Shots', type: 'number' },
{ key: 'shot_pct', label: 'Shot %', type: 'decimal' },
{ key: 'games_played', label: 'GP', type: 'number' },
{ key: 'save_pct', label: 'SV%', type: 'decimal' },
{ key: 'gaa', label: 'GAA', type: 'decimal' },
{ key: 'shutouts', label: 'Shutouts', type: 'number' },
{ key: 'toi', label: 'TOI (min)', type: 'decimal' },
{ key: 'faceoff_pct', label: 'FOW%', type: 'decimal' },
{ key: 'power_play_goals', label: 'PPG', type: 'number' },
{ key: 'shorthanded_goals', label: 'SHG', type: 'number' },
],
baseball: [
{ key: 'position', label: 'Position', type: 'text' },
{ key: 'batting_avg', label: 'AVG', type: 'decimal' },
{ key: 'home_runs', label: 'HRs', type: 'number' },
{ key: 'rbi', label: 'RBI', type: 'number' },
{ key: 'runs', label: 'Runs', type: 'number' },
{ key: 'hits', label: 'Hits', type: 'number' },
{ key: 'stolen_bases', label: 'SB', type: 'number' },
{ key: 'obp', label: 'OBP', type: 'decimal' },
{ key: 'slg', label: 'SLG', type: 'decimal' },
{ key: 'ops', label: 'OPS', type: 'decimal' },
{ key: 'era', label: 'ERA', type: 'decimal' },
{ key: 'strikeouts_p', label: 'K (Pitching)', type: 'number' },
{ key: 'wins', label: 'Wins', type: 'number' },
{ key: 'whip', label: 'WHIP', type: 'decimal' },
{ key: 'games_played', label: 'Games', type: 'number' },
{ key: 'war', label: 'WAR', type: 'decimal' },
],
soccer: [
{ key: 'position', label: 'Position', type: 'text' },
{ key: 'goals', label: 'Goals', type: 'number' },
{ key: 'assists', label: 'Assists', type: 'number' },
{ key: 'games_played', label: 'Matches', type: 'number' },
{ key: 'minutes_played', label: 'Minutes', type: 'number' },
{ key: 'shots', label: 'Shots', type: 'number' },
{ key: 'shots_on_target', label: 'On Target', type: 'number' },
{ key: 'pass_accuracy', label: 'Pass Acc %', type: 'decimal' },
{ key: 'key_passes', label: 'Key Passes', type: 'number' },
{ key: 'dribbles', label: 'Dribbles', type: 'number' },
{ key: 'tackles', label: 'Tackles', type: 'number' },
{ key: 'yellow_cards', label: 'Yellow', type: 'number' },
{ key: 'red_cards', label: 'Red', type: 'number' },
{ key: 'save_pct', label: 'Save %', type: 'decimal' },
{ key: 'clean_sheets', label: 'Clean Sheets', type: 'number' },
{ key: 'xg', label: 'xG', type: 'decimal' },
],
basketball: [
{ key: 'position', label: 'Position', type: 'text' },
{ key: 'points_per_game', label: 'PPG', type: 'decimal' },
{ key: 'rebounds_per_game', label: 'RPG', type: 'decimal' },
{ key: 'assists_per_game', label: 'APG', type: 'decimal' },
{ key: 'steals_per_game', label: 'SPG', type: 'decimal' },
{ key: 'blocks_per_game', label: 'BPG', type: 'decimal' },
{ key: 'fg_pct', label: 'FG%', type: 'decimal' },
{ key: 'three_pt_pct', label: '3P%', type: 'decimal' },
{ key: 'ft_pct', label: 'FT%', type: 'decimal' },
{ key: 'games_played', label: 'Games', type: 'number' },
{ key: 'minutes_per_game', label: 'MPG', type: 'decimal' },
{ key: 'turnovers', label: 'TOV', type: 'decimal' },
{ key: 'plus_minus', label: '+/-', type: 'decimal' },
{ key: 'efficiency', label: 'Eff', type: 'decimal' },
{ key: 'true_shooting', label: 'TS%', type: 'decimal' },
{ key: 'usage_rate', label: 'USG%', type: 'decimal' },
],
};
export const BIOMETRIC_FIELDS = [
{ key: 'height_cm', label: 'Height (cm)', type: 'number' },
{ key: 'weight_kg', label: 'Weight (kg)', type: 'number' },
{ key: 'age', label: 'Age', type: 'number' },
{ key: 'reach_cm', label: 'Reach/Wingspan (cm)', type: 'number' },
{ key: 'dominant_hand', label: 'Dominant Hand', type: 'text' },
{ key: 'dominant_foot', label: 'Dominant Foot', type: 'text' },
{ key: 'body_fat_pct', label: 'Body Fat %', type: 'decimal' },
{ key: 'vo2_max', label: 'VO2 Max', type: 'decimal' },
{ key: 'vertical_jump_cm', label: 'Vertical Jump (cm)', type: 'number' },
{ key: '40_yard_dash', label: '40-Yard Dash (s)', type: 'decimal' },
{ key: 'bench_press_reps', label: 'Bench Press Reps (225lb)', type: 'number' },
{ key: 'years_pro', label: 'Years Pro', type: 'number' },
];
const positions = {
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'],
};
const firstNames = ['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'];
const lastNames = ['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'];
const r = (min, max, dec = 0) => {
const v = Math.random() * (max - min) + min;
return dec > 0 ? parseFloat(v.toFixed(dec)) : Math.round(v);
};
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
const avatarColors = ['#00d4ff','#ff6b35','#a855f7','#22c55e','#f59e0b','#ec4899','#06b6d4','#84cc16'];
function genBiometrics(sport) {
const heightMap = { football: [175,205], hockey:[175,198], baseball:[170,200], soccer:[165,195], basketball:[185,220] };
const weightMap = { football: [85,140], hockey:[82,105], baseball:[82,110], soccer:[70,90], basketball:[85,120] };
const [hMin, hMax] = heightMap[sport];
const [wMin, wMax] = weightMap[sport];
const h = r(hMin, hMax);
return {
height_cm: h,
weight_kg: r(wMin, wMax),
age: r(20, 38),
reach_cm: h + r(5, 20),
dominant_hand: Math.random() > 0.1 ? 'Right' : 'Left',
dominant_foot: Math.random() > 0.15 ? 'Right' : 'Left',
body_fat_pct: r(6, 18, 1),
vo2_max: r(48, 72, 1),
vertical_jump_cm: r(55, 90),
'40_yard_dash': r(4.3, 5.2, 2),
bench_press_reps: r(10, 35),
years_pro: r(1, 18),
};
}
function genFootballStats(pos) {
const isQB = pos === 'QB', isRB = pos === 'RB', isWR = pos === 'WR' || pos === 'TE';
const isDef = ['DE','DT','LB','CB','S'].includes(pos);
const gp = r(10, 17);
return {
position: pos,
games_played: gp,
touchdowns: isQB ? r(18,45) : isRB ? r(6,18) : isWR ? r(4,14) : isDef ? 0 : r(0,3),
passing_yards: isQB ? r(2800,5200) : 0,
rushing_yards: isQB ? r(100,600) : isRB ? r(600,1800) : 0,
receiving_yards: isWR ? r(600,1700) : isRB ? r(150,600) : 0,
completions: isQB ? r(230,420) : 0,
attempts: isQB ? r(380,650) : 0,
completion_pct: isQB ? r(58,72,1) : 0,
interceptions: isQB ? r(4,18) : isDef ? r(0,6) : 0,
sacks: isDef ? r(2,18,1) : isQB ? r(20,60,1) : 0,
tackles: isDef ? r(40,120) : isRB ? r(5,20) : 0,
receptions: isWR ? r(40,120) : isRB ? r(20,70) : 0,
fumbles: r(0,5),
field_goals: pos === 'K' ? r(18,40) : 0,
passer_rating: isQB ? r(78,118,1) : 0,
};
}
function genHockeyStats(pos) {
const isG = pos === 'G';
const gp = r(55,82);
const shots = r(80, 280);
const goals = isG ? 0 : r(5, 55);
return {
position: pos,
games_played: gp,
goals,
assists: isG ? 0 : r(8, 70),
points: isG ? 0 : goals + r(8, 70),
plus_minus: isG ? 0 : r(-20, 38),
penalty_minutes: r(4, 120),
shots,
shot_pct: isG ? 0 : r(6, 22, 1),
save_pct: isG ? r(0.900, 0.935, 3) : 0,
gaa: isG ? r(1.8, 3.5, 2) : 0,
shutouts: isG ? r(1, 12) : 0,
toi: isG ? 0 : r(14, 26, 1),
faceoff_pct: pos === 'C' ? r(42, 58, 1) : 0,
power_play_goals: isG ? 0 : r(1, 18),
shorthanded_goals: isG ? 0 : r(0, 5),
};
}
function genBaseballStats(pos) {
const isPitcher = ['SP','RP'].includes(pos);
const gp = isPitcher ? r(25, 35) : r(100, 162);
const hits = isPitcher ? 0 : r(80, 210);
const hr = isPitcher ? 0 : r(3, 55);
return {
position: pos,
games_played: gp,
batting_avg: isPitcher ? 0 : r(0.220, 0.350, 3),
home_runs: hr,
rbi: isPitcher ? 0 : r(25, 120),
runs: isPitcher ? 0 : r(40, 120),
hits,
stolen_bases: isPitcher ? 0 : r(0, 45),
obp: isPitcher ? 0 : r(0.290, 0.430, 3),
slg: isPitcher ? 0 : r(0.360, 0.620, 3),
ops: isPitcher ? 0 : r(0.650, 1.050, 3),
era: isPitcher ? r(2.10, 5.80, 2) : 0,
strikeouts_p: isPitcher ? r(80, 280) : 0,
wins: isPitcher ? r(4, 22) : 0,
whip: isPitcher ? r(0.92, 1.60, 2) : 0,
war: r(0.5, 8.5, 1),
};
}
function genSoccerStats(pos) {
const isGK = pos === 'GK';
const isDef = ['CB','LB','RB'].includes(pos);
const gp = r(20, 38);
const min = gp * r(60, 90);
return {
position: pos,
games_played: gp,
minutes_played: min,
goals: isGK ? 0 : isDef ? r(0, 4) : r(3, 32),
assists: isGK ? 0 : isDef ? r(1, 8) : r(2, 18),
shots: isGK ? 0 : r(15, 120),
shots_on_target: isGK ? 0 : r(8, 60),
pass_accuracy: r(68, 94, 1),
key_passes: isGK ? 0 : r(10, 80),
dribbles: isGK ? 0 : r(10, 120),
tackles: isDef ? r(40, 120) : r(5, 50),
yellow_cards: r(0, 8),
red_cards: r(0, 2),
save_pct: isGK ? r(65, 82, 1) : 0,
clean_sheets: isGK ? r(4, 18) : 0,
xg: isGK ? 0 : r(1.5, 22, 1),
};
}
function genBasketballStats(pos) {
const isC = pos === 'C' || pos === 'PF';
const isPG = pos === 'PG';
const gp = r(50, 82);
return {
position: pos,
games_played: gp,
points_per_game: r(8, 34, 1),
rebounds_per_game: isC ? r(6, 15, 1) : r(2, 8, 1),
assists_per_game: isPG ? r(5, 12, 1) : r(1, 6, 1),
steals_per_game: r(0.5, 2.5, 1),
blocks_per_game: isC ? r(0.5, 3.5, 1) : r(0.1, 1.2, 1),
fg_pct: r(40, 62, 1),
three_pt_pct: isC ? r(20, 38, 1) : r(30, 45, 1),
ft_pct: r(65, 94, 1),
minutes_per_game: r(22, 38, 1),
turnovers: r(1.0, 4.5, 1),
plus_minus: r(-8, 18, 1),
efficiency: r(12, 32, 1),
true_shooting: r(50, 68, 1),
usage_rate: r(16, 35, 1),
};
}
function genSportStats(sport, pos) {
switch(sport) {
case 'football': return genFootballStats(pos);
case 'hockey': return genHockeyStats(pos);
case 'baseball': return genBaseballStats(pos);
case 'soccer': return genSoccerStats(pos);
case 'basketball': return genBasketballStats(pos);
default: return {};
}
}
const socialPlatforms = ['facebook', 'instagram', 'bluesky', 'snapchat'];
function genSocials(name) {
const handle = name.toLowerCase().replace(' ', '_') + r(10,99);
const included = socialPlatforms.filter(() => Math.random() > 0.4);
const result = {};
included.forEach(p => { result[p] = `@${handle}`; });
return result;
}
const bios = [
'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.',
];
let idCounter = 100;
function genUser(firstName, lastName, primarySport) {
const pos = pick(positions[primarySport]);
const sports = [primarySport];
// 30% chance of a second sport
if (Math.random() < 0.3) {
const others = Object.keys(SPORTS).filter(s => s !== primarySport);
sports.push(pick(others));
}
const statsMap = {};
sports.forEach(s => {
const p = pick(positions[s]);
statsMap[s] = genSportStats(s, p);
});
const name = `${firstName} ${lastName}`;
return {
id: String(++idCounter),
firstName,
lastName,
name,
email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com`,
phone: `+1 (${r(200,999)}) ${r(200,999)}-${r(1000,9999)}`,
city: pick(['New York', 'Los Angeles', 'Chicago', 'Houston', 'Phoenix', 'Toronto', 'Vancouver', 'Dallas', 'Miami', 'Boston', 'Seattle', 'Denver', 'Atlanta', 'Minneapolis', 'Detroit']),
country: Math.random() > 0.2 ? 'USA' : pick(['Canada', 'Mexico', 'UK', 'Brazil', 'Australia']),
bio: pick(bios),
socials: genSocials(name),
avatarColor: pick(avatarColors),
primarySport,
sports,
biometrics: genBiometrics(primarySport),
sportStats: statsMap,
joinDate: new Date(Date.now() - r(30, 730) * 86400000).toISOString().split('T')[0],
};
}
// Generate 25 per sport, some multi-sport
const sportUsers = {};
let nameIdx = 0;
Object.keys(SPORTS).forEach(sport => {
sportUsers[sport] = [];
for (let i = 0; i < 25; i++) {
const fn = firstNames[(nameIdx) % firstNames.length];
const ln = lastNames[(nameIdx + 5) % lastNames.length];
nameIdx++;
sportUsers[sport].push(genUser(fn, ln, sport));
}
});
// Merge all, deduplicate by id
const allMap = {};
Object.values(sportUsers).flat().forEach(u => { allMap[u.id] = u; });
export const SEED_USERS = Object.values(allMap);
export function getUsersBySport(sport) {
return SEED_USERS.filter(u => u.sports.includes(sport));
}

61
src/hooks/useStore.jsx Normal file
View File

@@ -0,0 +1,61 @@
import { createContext, useContext, useState, useEffect } from 'react';
import { SEED_USERS } from '../data/seedData.js';
const StoreContext = createContext(null);
const STORAGE_KEY = 'statsphere_users_v1';
function loadUsers() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return SEED_USERS;
}
function saveUsers(users) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(users)); } catch {}
}
export function StoreProvider({ children }) {
const [users, setUsers] = useState(loadUsers);
const [currentUser, setCurrentUser] = useState(null);
useEffect(() => { saveUsers(users); }, [users]);
function addUser(user) {
const newUser = { ...user, id: String(Date.now()), joinDate: new Date().toISOString().split('T')[0] };
setUsers(prev => [...prev, newUser]);
return newUser;
}
function updateUser(id, updates) {
setUsers(prev => prev.map(u => u.id === id ? { ...u, ...updates } : u));
}
function deleteUser(id) {
setUsers(prev => prev.filter(u => u.id !== id));
}
function getUserById(id) {
return users.find(u => u.id === id);
}
function getUsersBySport(sport) {
return users.filter(u => u.sports && u.sports.includes(sport));
}
function resetToSeed() {
setUsers(SEED_USERS);
}
return (
<StoreContext.Provider value={{ users, currentUser, setCurrentUser, addUser, updateUser, deleteUser, getUserById, getUsersBySport, resetToSeed }}>
{children}
</StoreContext.Provider>
);
}
export function useStore() {
return useContext(StoreContext);
}

312
src/index.css Normal file
View File

@@ -0,0 +1,312 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-base: #0a0f1e;
--bg-card: #111827;
--bg-card2: #1a2235;
--bg-input: #1e2d45;
--accent: #00d4ff;
--accent2: #ff6b35;
--accent3: #a855f7;
--text-primary: #f0f4ff;
--text-secondary: #8899bb;
--text-muted: #4a5875;
--border: #1e2d45;
--border-bright: #2a3d5a;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--font-display: 'Barlow Condensed', sans-serif;
--font-body: 'Barlow', sans-serif;
--nav-h: 60px;
--bottom-nav-h: 64px;
--radius: 12px;
--radius-sm: 8px;
}
html { height: 100%; font-size: 16px; }
body {
background: var(--bg-base);
color: var(--text-primary);
font-family: var(--font-body);
min-height: 100%;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
#root { min-height: 100vh; display: flex; flex-direction: column; }
a { color: inherit; text-decoration: none; }
button {
font-family: var(--font-body);
cursor: pointer;
border: none;
background: none;
color: inherit;
}
input, select, textarea {
font-family: var(--font-body);
font-size: 15px;
color: var(--text-primary);
background: var(--bg-input);
border: 1px solid var(--border-bright);
border-radius: var(--radius-sm);
padding: 10px 14px;
width: 100%;
outline: none;
transition: border-color 0.2s;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
}
select option { background: var(--bg-card); }
textarea { resize: vertical; min-height: 80px; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg-base); }
::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 3px; }
.page { padding: calc(var(--nav-h) + 20px) 16px calc(var(--bottom-nav-h) + 20px); max-width: 900px; margin: 0 auto; width: 100%; }
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
}
.card2 {
background: var(--bg-card2);
border: 1px solid var(--border-bright);
border-radius: var(--radius-sm);
padding: 16px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
font-family: var(--font-display);
letter-spacing: 0.05em;
text-transform: uppercase;
transition: all 0.2s;
cursor: pointer;
}
.btn-primary {
background: var(--accent);
color: #000;
}
.btn-primary:hover { background: #00b8d9; }
.btn-secondary {
background: transparent;
border: 1px solid var(--border-bright);
color: var(--text-primary);
}
.btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
.btn-danger {
background: var(--danger);
color: #fff;
}
.badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
font-family: var(--font-display);
}
.sport-football { background: #1a3a1a; color: #4ade80; border: 1px solid #166534; }
.sport-hockey { background: #1a2e4a; color: #60a5fa; border: 1px solid #1e40af; }
.sport-baseball { background: #3a1a1a; color: #f87171; border: 1px solid #991b1b; }
.sport-soccer { background: #2a1a3a; color: #c084fc; border: 1px solid #7e22ce; }
.sport-basketball { background: #3a2a1a; color: #fb923c; border: 1px solid #9a3412; }
.section-title {
font-family: var(--font-display);
font-size: 28px;
font-weight: 800;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-primary);
margin-bottom: 4px;
}
.section-sub {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 24px;
}
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
@media (max-width: 600px) {
.grid-2 { grid-template-columns: 1fr; }
.grid-3 { grid-template-columns: 1fr 1fr; }
.page { padding-left: 12px; padding-right: 12px; }
}
.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
.label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
font-family: var(--font-display);
margin-bottom: 6px;
display: block;
}
.form-group { margin-bottom: 16px; }
.avatar {
border-radius: 50%;
object-fit: cover;
background: var(--bg-input);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 800;
color: var(--accent);
flex-shrink: 0;
}
.rank-badge {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 800;
font-size: 14px;
flex-shrink: 0;
}
.rank-1 { background: #ffd700; color: #000; }
.rank-2 { background: #c0c0c0; color: #000; }
.rank-3 { background: #cd7f32; color: #000; }
.rank-n { background: var(--bg-input); color: var(--text-secondary); }
.stat-val {
font-family: var(--font-display);
font-size: 24px;
font-weight: 800;
color: var(--accent);
line-height: 1;
}
.stat-lbl {
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
font-family: var(--font-display);
}
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th {
text-align: left;
padding: 10px 12px;
font-family: var(--font-display);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
th:hover { color: var(--accent); }
td {
padding: 12px 12px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
tr:hover td { background: rgba(0,212,255,0.03); }
tr:last-child td { border-bottom: none; }
.tab-bar {
display: flex;
gap: 4px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 4px;
margin-bottom: 20px;
overflow-x: auto;
}
.tab {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
}
.tab.active {
background: var(--accent);
color: #000;
}
.toast {
position: fixed;
bottom: calc(var(--bottom-nav-h) + 16px);
left: 50%;
transform: translateX(-50%);
background: var(--success);
color: #000;
padding: 10px 24px;
border-radius: 24px;
font-weight: 600;
font-size: 14px;
z-index: 1000;
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.5s forwards;
}
@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
@keyframes toastOut { to { opacity: 0; transform: translateX(-50%) translateY(10px); } }
.pwa-banner {
position: fixed;
bottom: calc(var(--bottom-nav-h) + 12px);
left: 12px;
right: 12px;
background: var(--bg-card2);
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 16px;
z-index: 900;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 32px rgba(0,212,255,0.15);
}

10
src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

131
src/pages/AthleteDetail.jsx Normal file
View File

@@ -0,0 +1,131 @@
import { useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useStore } from '../hooks/useStore.jsx';
import { SPORTS, SPORT_STATS_DEFS, BIOMETRIC_FIELDS } from '../data/seedData.js';
import Avatar from '../components/Avatar.jsx';
import SportBadge from '../components/SportBadge.jsx';
export default function AthleteDetail() {
const { id } = useParams();
const { getUserById, deleteUser } = useStore();
const navigate = useNavigate();
const [activeSport, setActiveSport] = useState(null);
const user = getUserById(id);
if (!user) return (
<div className="page" style={{ textAlign: 'center', paddingTop: 80 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>🏟</div>
<div style={{ color: 'var(--text-muted)' }}>Athlete not found</div>
<Link to="/athletes" className="btn btn-secondary" style={{ marginTop: 16 }}>Back to Athletes</Link>
</div>
);
const displaySport = activeSport || user.primarySport || user.sports?.[0];
const stats = user.sportStats?.[displaySport] || {};
const statDefs = SPORT_STATS_DEFS[displaySport] || [];
function handleDelete() {
if (confirm(`Delete ${user.name}?`)) { deleteUser(id); navigate('/athletes'); }
}
const socialIcons = { facebook: '📘', instagram: '📷', bluesky: '🦋', snapchat: '👻' };
return (
<div className="page">
{/* Header card */}
<div className="card" style={{ marginBottom: 16, background: 'linear-gradient(135deg, var(--bg-card) 0%, var(--bg-card2) 100%)', borderColor: 'var(--border-bright)' }}>
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<Avatar user={user} size={80} />
<div style={{ flex: 1 }}>
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 32, fontWeight: 900, letterSpacing: '0.02em', marginBottom: 4 }}>{user.name}</h1>
<div style={{ color: 'var(--text-secondary)', marginBottom: 10 }}>{user.city}, {user.country} · Joined {user.joinDate}</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
{user.sports?.map(s => <SportBadge key={s} sport={s} />)}
</div>
{user.bio && <p style={{ color: 'var(--text-secondary)', fontSize: 14, fontStyle: 'italic', maxWidth: 500 }}>"{user.bio}"</p>}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Link to={`/register?edit=${id}`} className="btn btn-secondary" style={{ padding: '8px 14px', fontSize: 12 }}>Edit</Link>
<button className="btn btn-danger" style={{ padding: '8px 14px', fontSize: 12 }} onClick={handleDelete}>Delete</button>
</div>
</div>
{/* Social media */}
{user.socials && Object.keys(user.socials).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border)', display: 'flex', gap: 14, flexWrap: 'wrap' }}>
{Object.entries(user.socials).map(([platform, handle]) => (
<div key={platform} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<span>{socialIcons[platform] || '🔗'}</span>
<span>{handle}</span>
</div>
))}
</div>
)}
</div>
{/* Contact */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--text-muted)', marginBottom: 14 }}>Contact</div>
<div className="grid-2">
<div><span className="label">Email</span><div style={{ color: 'var(--accent)', fontSize: 14 }}>{user.email}</div></div>
<div><span className="label">Phone</span><div style={{ fontSize: 14 }}>{user.phone}</div></div>
</div>
</div>
{/* Biometrics */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--text-muted)', marginBottom: 14 }}>Biometrics</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px,1fr))', gap: 12 }}>
{BIOMETRIC_FIELDS.map(f => {
const val = user.biometrics?.[f.key];
if (!val && val !== 0) return null;
return (
<div key={f.key} className="card2">
<div className="stat-lbl">{f.label}</div>
<div className="stat-val" style={{ fontSize: 20, marginTop: 4 }}>
{f.type === 'decimal' ? Number(val).toFixed(1) : val}
</div>
</div>
);
})}
</div>
</div>
{/* Sport stats */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--text-muted)', marginBottom: 14 }}>Sport Statistics</div>
{user.sports?.length > 1 && (
<div className="tab-bar" style={{ marginBottom: 20 }}>
{user.sports.map(s => (
<button key={s} className={`tab ${displaySport === s ? 'active' : ''}`} onClick={() => setActiveSport(s)}>
{SPORTS[s]?.emoji} {SPORTS[s]?.name}
</button>
))}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px,1fr))', gap: 12 }}>
{statDefs.map(def => {
const val = stats[def.key];
if (val === undefined || val === null) return null;
const display = def.type === 'decimal' ? Number(val).toFixed(2) : String(val);
if (display === '0' && def.type !== 'text') return null;
return (
<div key={def.key} className="card2">
<div className="stat-lbl">{def.label}</div>
<div style={{
fontFamily: 'var(--font-display)', fontWeight: 800,
fontSize: def.type === 'text' ? 16 : 20,
color: 'var(--accent)', marginTop: 4
}}>{display}</div>
</div>
);
})}
</div>
</div>
<Link to="/athletes" className="btn btn-secondary"> Back to Athletes</Link>
</div>
);
}

81
src/pages/Athletes.jsx Normal file
View File

@@ -0,0 +1,81 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useStore } from '../hooks/useStore.jsx';
import { SPORTS } from '../data/seedData.js';
import Avatar from '../components/Avatar.jsx';
import SportBadge from '../components/SportBadge.jsx';
export default function Athletes() {
const { users } = useStore();
const [search, setSearch] = useState('');
const [sport, setSport] = useState('all');
const filtered = users.filter(u => {
const matchSearch = !search || u.name?.toLowerCase().includes(search.toLowerCase()) || u.city?.toLowerCase().includes(search.toLowerCase());
const matchSport = sport === 'all' || u.sports?.includes(sport);
return matchSearch && matchSport;
});
return (
<div className="page">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 4 }}>
<h1 className="section-title">Athletes</h1>
<Link to="/register" className="btn btn-primary" style={{ fontSize: 13, padding: '8px 16px' }}>+ Add</Link>
</div>
<p className="section-sub">All registered athletes</p>
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 200 }}>
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search name or city..." />
</div>
<select value={sport} onChange={e => setSport(e.target.value)} style={{ width: 'auto', minWidth: 160 }}>
<option value="all">All Sports</option>
{Object.values(SPORTS).map(s => <option key={s.id} value={s.id}>{s.emoji} {s.name}</option>)}
</select>
</div>
<div style={{ color: 'var(--text-muted)', fontSize: 13, marginBottom: 12 }}>
{filtered.length} athlete{filtered.length !== 1 ? 's' : ''}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px,1fr))', gap: 12 }}>
{filtered.map(u => (
<Link key={u.id} to={`/athletes/${u.id}`}>
<div className="card" style={{ cursor: 'pointer', transition: 'border-color 0.15s, transform 0.15s' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--accent)'; e.currentTarget.style.transform = 'translateY(-2px)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.transform = ''; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<Avatar user={u} size={48} />
<div>
<div style={{ fontWeight: 700, fontSize: 16 }}>{u.name}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{u.city}, {u.country}</div>
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
{u.sports?.map(s => <SportBadge key={s} sport={s} />)}
</div>
<div style={{ display: 'flex', gap: 16 }}>
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>Age</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18, color: 'var(--accent)' }}>{u.biometrics?.age}</div>
</div>
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>Height</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18, color: 'var(--accent)' }}>{u.biometrics?.height_cm}<span style={{ fontSize: 11 }}>cm</span></div>
</div>
<div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'var(--font-display)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>Years Pro</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18, color: 'var(--accent)' }}>{u.biometrics?.years_pro}</div>
</div>
</div>
</div>
</Link>
))}
{filtered.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 48, gridColumn: '1/-1' }}>No athletes found</div>
)}
</div>
</div>
);
}

248
src/pages/Filter.jsx Normal file
View File

@@ -0,0 +1,248 @@
import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useStore } from '../hooks/useStore.jsx';
import { SPORTS, SPORT_STATS_DEFS, BIOMETRIC_FIELDS } from '../data/seedData.js';
import Avatar from '../components/Avatar.jsx';
import SportBadge from '../components/SportBadge.jsx';
export default function Filter() {
const { users } = useStore();
const [query, setQuery] = useState('');
const [sport, setSport] = useState('all');
const [position, setPosition] = useState('');
const [statFilter, setStatFilter] = useState({ key: '', min: '', max: '', sport: 'football' });
const [bioFilter, setBioFilter] = useState({ key: '', min: '', max: '' });
const [rowLimit, setRowLimit] = useState(25);
const [sortKey, setSortKey] = useState('name');
const [sortDir, setSortDir] = useState('asc');
const allPositions = useMemo(() => {
if (sport === 'all') return [];
return [...new Set(users.flatMap(u => u.sportStats?.[sport] ? [u.sportStats[sport].position] : []).filter(Boolean))].sort();
}, [sport, users]);
const statDefs = sport !== 'all' ? (SPORT_STATS_DEFS[sport] || []) : [];
const results = useMemo(() => {
let list = users;
if (query) {
const q = query.toLowerCase();
list = list.filter(u =>
u.name?.toLowerCase().includes(q) ||
u.city?.toLowerCase().includes(q) ||
u.country?.toLowerCase().includes(q) ||
u.bio?.toLowerCase().includes(q)
);
}
if (sport !== 'all') {
list = list.filter(u => u.sports?.includes(sport));
}
if (position && sport !== 'all') {
list = list.filter(u => u.sportStats?.[sport]?.position === position);
}
if (statFilter.key && statFilter.sport && (statFilter.min !== '' || statFilter.max !== '')) {
list = list.filter(u => {
const val = Number(u.sportStats?.[statFilter.sport]?.[statFilter.key]);
if (isNaN(val)) return false;
if (statFilter.min !== '' && val < Number(statFilter.min)) return false;
if (statFilter.max !== '' && val > Number(statFilter.max)) return false;
return true;
});
}
if (bioFilter.key && (bioFilter.min !== '' || bioFilter.max !== '')) {
list = list.filter(u => {
const val = Number(u.biometrics?.[bioFilter.key]);
if (isNaN(val)) return false;
if (bioFilter.min !== '' && val < Number(bioFilter.min)) return false;
if (bioFilter.max !== '' && val > Number(bioFilter.max)) return false;
return true;
});
}
list = [...list].sort((a, b) => {
let va, vb;
if (sortKey === 'name') { va = a.name || ''; vb = b.name || ''; }
else if (sortKey === 'age') { va = a.biometrics?.age || 0; vb = b.biometrics?.age || 0; }
else if (sortKey === 'joinDate') { va = a.joinDate || ''; vb = b.joinDate || ''; }
else { va = 0; vb = 0; }
if (typeof va === 'string') return sortDir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va);
return sortDir === 'asc' ? va - vb : vb - va;
});
return list.slice(0, rowLimit);
}, [users, query, sport, position, statFilter, bioFilter, sortKey, sortDir, rowLimit]);
function clearFilters() {
setQuery(''); setSport('all'); setPosition('');
setStatFilter({ key: '', min: '', max: '', sport: 'football' });
setBioFilter({ key: '', min: '', max: '' });
}
return (
<div className="page">
<h1 className="section-title">Search & Filter</h1>
<p className="section-sub">Find athletes by any stat, biometric or sport</p>
{/* Main search */}
<div className="card" style={{ marginBottom: 16 }}>
<div className="form-group">
<label className="label">Search Name / City / Bio</label>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder="e.g. Marcus Thompson, Chicago..." />
</div>
<div className="grid-2">
<div className="form-group">
<label className="label">Sport</label>
<select value={sport} onChange={e => { setSport(e.target.value); setPosition(''); }}>
<option value="all">All Sports</option>
{Object.values(SPORTS).map(s => <option key={s.id} value={s.id}>{s.emoji} {s.name}</option>)}
</select>
</div>
{sport !== 'all' && (
<div className="form-group">
<label className="label">Position</label>
<select value={position} onChange={e => setPosition(e.target.value)}>
<option value="">All Positions</option>
{allPositions.map(p => <option key={p} value={p}>{p}</option>)}
</select>
</div>
)}
</div>
</div>
{/* Stat filter */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 14 }}>
Sport Stat Filter
</div>
<div className="grid-3" style={{ marginBottom: 12 }}>
<div>
<label className="label">Sport</label>
<select value={statFilter.sport} onChange={e => setStatFilter(f => ({ ...f, sport: e.target.value, key: '' }))}>
{Object.values(SPORTS).map(s => <option key={s.id} value={s.id}>{s.emoji} {s.name}</option>)}
</select>
</div>
<div>
<label className="label">Stat</label>
<select value={statFilter.key} onChange={e => setStatFilter(f => ({ ...f, key: e.target.value }))}>
<option value="">Select stat...</option>
{(SPORT_STATS_DEFS[statFilter.sport] || []).filter(s => s.type !== 'text').map(s => (
<option key={s.key} value={s.key}>{s.label}</option>
))}
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label className="label">Min</label>
<input type="number" value={statFilter.min} onChange={e => setStatFilter(f => ({ ...f, min: e.target.value }))} placeholder="0" />
</div>
<div>
<label className="label">Max</label>
<input type="number" value={statFilter.max} onChange={e => setStatFilter(f => ({ ...f, max: e.target.value }))} placeholder="∞" />
</div>
</div>
</div>
</div>
{/* Bio filter */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent3)', marginBottom: 14 }}>
Biometric Filter
</div>
<div className="grid-3">
<div>
<label className="label">Biometric</label>
<select value={bioFilter.key} onChange={e => setBioFilter(f => ({ ...f, key: e.target.value }))}>
<option value="">Select biometric...</option>
{BIOMETRIC_FIELDS.filter(f => f.type !== 'text').map(f => (
<option key={f.key} value={f.key}>{f.label}</option>
))}
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div>
<label className="label">Min</label>
<input type="number" value={bioFilter.min} onChange={e => setBioFilter(f => ({ ...f, min: e.target.value }))} placeholder="0" />
</div>
<div>
<label className="label">Max</label>
<input type="number" value={bioFilter.max} onChange={e => setBioFilter(f => ({ ...f, max: e.target.value }))} placeholder="∞" />
</div>
</div>
<div />
</div>
</div>
{/* Result controls */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div>
<label className="label">Sort By</label>
<select value={sortKey} onChange={e => setSortKey(e.target.value)}>
<option value="name">Name</option>
<option value="age">Age</option>
<option value="joinDate">Join Date</option>
</select>
</div>
<div>
<label className="label">Order</label>
<select value={sortDir} onChange={e => setSortDir(e.target.value)}>
<option value="asc">A Z / Low High</option>
<option value="desc">Z A / High Low</option>
</select>
</div>
<div>
<label className="label">Show</label>
<select value={rowLimit} onChange={e => setRowLimit(Number(e.target.value))}>
<option value={25}>25 Results</option>
<option value={50}>50 Results</option>
<option value={100}>100 Results</option>
</select>
</div>
<button className="btn btn-secondary" onClick={clearFilters} style={{ marginBottom: 0, alignSelf: 'flex-end' }}>
Clear All
</button>
</div>
{/* Results */}
<div style={{ color: 'var(--text-muted)', fontSize: 13, marginBottom: 12 }}>
{results.length} result{results.length !== 1 ? 's' : ''}
</div>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{results.map((u, i) => (
<Link key={u.id} to={`/athletes/${u.id}`}>
<div style={{
display: 'flex', alignItems: 'center', gap: 14, padding: '14px 20px',
borderBottom: i < results.length - 1 ? '1px solid var(--border)' : 'none',
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,212,255,0.03)'}
onMouseLeave={e => e.currentTarget.style.background = ''}
>
<Avatar user={u} size={44} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 15 }}>{u.name}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>
{u.biometrics?.age} yrs · {u.biometrics?.height_cm}cm · {u.city}, {u.country}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{u.sports?.map(s => <SportBadge key={s} sport={s} />)}
</div>
</div>
</Link>
))}
{results.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 48 }}>
No athletes match your filters
</div>
)}
</div>
</div>
);
}

113
src/pages/Home.jsx Normal file
View File

@@ -0,0 +1,113 @@
import { Link } from 'react-router-dom';
import { useStore } from '../hooks/useStore.jsx';
import { SPORTS } from '../data/seedData.js';
import Avatar from '../components/Avatar.jsx';
export default function Home() {
const { users } = useStore();
const sportCounts = Object.keys(SPORTS).map(s => ({
sport: SPORTS[s],
count: users.filter(u => u.sports?.includes(s)).length,
}));
const recent = [...users].sort((a, b) => b.joinDate?.localeCompare(a.joinDate || '') || 0).slice(0, 5);
return (
<div className="page">
{/* Hero */}
<div style={{ textAlign: 'center', padding: '20px 0 32px', position: 'relative' }}>
<div style={{
position: 'absolute', inset: 0,
background: 'radial-gradient(ellipse at 50% 0%, rgba(0,212,255,0.08) 0%, transparent 60%)',
pointerEvents: 'none',
}} />
<div style={{
fontFamily: 'var(--font-display)',
fontSize: 'clamp(36px,8vw,72px)',
fontWeight: 900,
lineHeight: 1,
letterSpacing: '-0.02em',
marginBottom: 12,
}}>
<span style={{ color: 'var(--text-primary)' }}>YOUR STATS,</span><br />
<span style={{ color: 'var(--accent)' }}>YOUR LEGACY</span>
</div>
<p style={{ color: 'var(--text-secondary)', fontSize: 16, maxWidth: 500, margin: '0 auto 24px' }}>
Track biometrics and performance stats across multiple sports. Compare with athletes worldwide.
</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
<Link to="/register" className="btn btn-primary">Register Athlete</Link>
<Link to="/leaders" className="btn btn-secondary">View Leaders</Link>
</div>
</div>
{/* Stats overview */}
<div className="grid-3" style={{ marginBottom: 32 }}>
<div className="card" style={{ textAlign: 'center' }}>
<div className="stat-val">{users.length}</div>
<div className="stat-lbl">Athletes</div>
</div>
<div className="card" style={{ textAlign: 'center' }}>
<div className="stat-val">{Object.keys(SPORTS).length}</div>
<div className="stat-lbl">Sports</div>
</div>
<div className="card" style={{ textAlign: 'center' }}>
<div className="stat-val">{users.filter(u => u.sports?.length > 1).length}</div>
<div className="stat-lbl">Multi-Sport</div>
</div>
</div>
{/* Sports */}
<h2 className="section-title">Sports</h2>
<p className="section-sub">Browse stat leaders by sport</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px,1fr))', gap: 12, marginBottom: 32 }}>
{sportCounts.map(({ sport, count }) => (
<Link key={sport.id} to={`/leaders?sport=${sport.id}`}>
<div className="card" style={{
textAlign: 'center', cursor: 'pointer',
transition: 'border-color 0.2s, transform 0.15s',
border: `1px solid var(--border)`,
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = sport.color; e.currentTarget.style.transform = 'translateY(-2px)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.transform = ''; }}
>
<div style={{ fontSize: 36, marginBottom: 8 }}>{sport.emoji}</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 13, letterSpacing: '0.05em', textTransform: 'uppercase', color: sport.color }}>{sport.name}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12, marginTop: 4 }}>{count} athletes</div>
</div>
</Link>
))}
</div>
{/* Recent athletes */}
<h2 className="section-title">Recent Athletes</h2>
<p className="section-sub">Newly registered</p>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{recent.map((u, i) => (
<Link key={u.id} to={`/athletes/${u.id}`}>
<div style={{
display: 'flex', alignItems: 'center', gap: 14, padding: '14px 20px',
borderBottom: i < recent.length - 1 ? '1px solid var(--border)' : 'none',
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,212,255,0.04)'}
onMouseLeave={e => e.currentTarget.style.background = ''}
>
<Avatar user={u} size={42} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 15 }}>{u.name}</div>
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{u.city}, {u.country}</div>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{u.sports?.map(s => (
<span key={s} className={`badge sport-${s}`}>{SPORTS[s]?.emoji}</span>
))}
</div>
</div>
</Link>
))}
</div>
</div>
);
}

159
src/pages/Leaders.jsx Normal file
View File

@@ -0,0 +1,159 @@
import { useState, useMemo } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import { useStore } from '../hooks/useStore.jsx';
import { SPORTS, SPORT_STATS_DEFS } from '../data/seedData.js';
import Avatar from '../components/Avatar.jsx';
export default function Leaders() {
const { getUsersBySport } = useStore();
const [params, setParams] = useSearchParams();
const [sport, setSport] = useState(params.get('sport') || 'football');
const [sortKey, setSortKey] = useState('');
const [sortDir, setSortDir] = useState('desc');
const [rowLimit, setRowLimit] = useState(25);
const statDefs = SPORT_STATS_DEFS[sport] || [];
const numericStats = statDefs.filter(s => s.type !== 'text');
const activeSortKey = sortKey || (numericStats[1]?.key || numericStats[0]?.key);
const athletes = useMemo(() => {
const list = getUsersBySport(sport);
return list
.filter(u => u.sportStats?.[sport])
.sort((a, b) => {
const va = Number(a.sportStats[sport][activeSortKey]) || 0;
const vb = Number(b.sportStats[sport][activeSortKey]) || 0;
return sortDir === 'desc' ? vb - va : va - vb;
})
.slice(0, rowLimit);
}, [sport, activeSortKey, sortDir, rowLimit, getUsersBySport]);
function handleSort(key) {
if (key === activeSortKey) setSortDir(d => d === 'desc' ? 'asc' : 'desc');
else { setSortKey(key); setSortDir('desc'); }
}
function handleSport(s) {
setSport(s);
setSortKey('');
setSortDir('desc');
setParams({ sport: s });
}
const fmtVal = (val, type) => {
if (val === 0 || val === null || val === undefined) return '—';
if (type === 'decimal') return Number(val).toFixed(2);
return val;
};
const displayStats = numericStats.slice(0, 10);
return (
<div className="page">
<h1 className="section-title">Stat Leaders</h1>
<p className="section-sub">Top performers ranked by selected stat</p>
{/* Sport tabs */}
<div className="tab-bar">
{Object.values(SPORTS).map(s => (
<button key={s.id} className={`tab ${sport === s.id ? 'active' : ''}`} onClick={() => handleSport(s.id)}>
{s.emoji} {s.name}
</button>
))}
</div>
{/* Controls */}
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ flex: 1, minWidth: 200 }}>
<label className="label">Sort By</label>
<select value={activeSortKey} onChange={e => handleSort(e.target.value)}>
{numericStats.map(s => (
<option key={s.key} value={s.key}>{s.label}</option>
))}
</select>
</div>
<div>
<label className="label">Direction</label>
<select value={sortDir} onChange={e => setSortDir(e.target.value)}>
<option value="desc">High Low</option>
<option value="asc">Low High</option>
</select>
</div>
<div>
<label className="label">Show</label>
<select value={rowLimit} onChange={e => setRowLimit(Number(e.target.value))}>
<option value={25}>Top 25</option>
<option value={50}>Top 50</option>
<option value={100}>Top 100</option>
</select>
</div>
</div>
{/* Table */}
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table>
<thead>
<tr>
<th style={{ width: 50 }}>#</th>
<th>Athlete</th>
<th onClick={() => handleSort('position')} style={{ cursor: 'pointer' }}>Pos</th>
{displayStats.map(s => (
<th key={s.key} onClick={() => handleSort(s.key)} style={{
color: activeSortKey === s.key ? 'var(--accent)' : undefined,
}}>
{s.label} {activeSortKey === s.key ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
))}
</tr>
</thead>
<tbody>
{athletes.map((u, i) => {
const stats = u.sportStats[sport] || {};
const rank = i + 1;
return (
<tr key={u.id}>
<td>
<div className={`rank-badge rank-${rank <= 3 ? rank : 'n'}`}>{rank}</div>
</td>
<td>
<Link to={`/athletes/${u.id}`} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar user={u} size={34} />
<div>
<div style={{ fontWeight: 600, fontSize: 14, whiteSpace: 'nowrap' }}>{u.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{u.city}</div>
</div>
</Link>
</td>
<td>
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 13, color: 'var(--text-secondary)' }}>
{stats.position || '—'}
</span>
</td>
{displayStats.map(s => (
<td key={s.key} style={{
fontFamily: 'var(--font-display)',
fontWeight: activeSortKey === s.key ? 800 : 500,
fontSize: activeSortKey === s.key ? 16 : 14,
color: activeSortKey === s.key ? 'var(--accent)' : 'var(--text-primary)',
}}>
{fmtVal(stats[s.key], s.type)}
</td>
))}
</tr>
);
})}
{athletes.length === 0 && (
<tr><td colSpan={20} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>No athletes found</td></tr>
)}
</tbody>
</table>
</div>
</div>
<div style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: 13, marginTop: 16 }}>
Showing {athletes.length} athletes · Sorted by {numericStats.find(s => s.key === activeSortKey)?.label}
</div>
</div>
);
}

316
src/pages/Register.jsx Normal file
View File

@@ -0,0 +1,316 @@
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { useStore } from '../hooks/useStore.jsx';
import { SPORTS, SPORT_STATS_DEFS, BIOMETRIC_FIELDS } from '../data/seedData.js';
const STEPS = ['Personal Info', 'Biometrics', 'Sport Stats', 'Review'];
const positions = {
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'],
};
const COLORS = ['#00d4ff', '#ff6b35', '#a855f7', '#22c55e', '#f59e0b', '#ec4899'];
function emptyForm() {
return {
firstName: '', lastName: '', email: '', phone: '',
city: '', country: '', bio: '',
socials: { facebook: '', instagram: '', bluesky: '', snapchat: '' },
avatarColor: '#00d4ff',
profileImage: '',
primarySport: 'football',
sports: ['football'],
biometrics: {},
sportStats: {},
};
}
export default function Register() {
const [params] = useSearchParams();
const editId = params.get('edit');
const { addUser, updateUser, getUserById } = useStore();
const navigate = useNavigate();
const [step, setStep] = useState(0);
const [form, setForm] = useState(emptyForm);
const [toast, setToast] = useState('');
useEffect(() => {
if (editId) {
const u = getUserById(editId);
if (u) setForm({ ...emptyForm(), ...u, socials: { facebook: '', instagram: '', bluesky: '', snapchat: '', ...(u.socials || {}) } });
}
}, [editId]);
function setField(path, value) {
setForm(f => {
const clone = JSON.parse(JSON.stringify(f));
const parts = path.split('.');
let cur = clone;
for (let i = 0; i < parts.length - 1; i++) cur = cur[parts[i]];
cur[parts[parts.length - 1]] = value;
return clone;
});
}
function toggleSport(s) {
setForm(f => {
const sports = f.sports.includes(s) ? f.sports.filter(x => x !== s) : [...f.sports, s];
if (sports.length === 0) return f;
const primarySport = sports.includes(f.primarySport) ? f.primarySport : sports[0];
return { ...f, sports, primarySport };
});
}
function handleImageUpload(e) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => setField('profileImage', ev.target.result);
reader.readAsDataURL(file);
}
function handleSubmit() {
if (!form.firstName || !form.lastName || !form.email) {
setToast('Please fill in required fields'); setTimeout(() => setToast(''), 3000); return;
}
if (editId) {
updateUser(editId, { ...form, name: `${form.firstName} ${form.lastName}` });
} else {
addUser({ ...form, name: `${form.firstName} ${form.lastName}` });
}
setToast(editId ? 'Athlete updated!' : 'Athlete registered!');
setTimeout(() => navigate('/athletes'), 1800);
}
const activeSports = form.sports || [];
const currentSportForStats = activeSports[0] || 'football';
return (
<div className="page">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<h1 className="section-title" style={{ marginBottom: 0 }}>{editId ? 'Edit Athlete' : 'Register Athlete'}</h1>
</div>
{/* Step indicator */}
<div style={{ display: 'flex', gap: 8, marginBottom: 28, overflowX: 'auto' }}>
{STEPS.map((s, i) => (
<button key={s} onClick={() => i < step ? setStep(i) : null}
style={{
flex: 1, minWidth: 90, padding: '10px 8px',
borderRadius: 8, border: '1px solid',
borderColor: i === step ? 'var(--accent)' : i < step ? 'var(--success)' : 'var(--border)',
background: i === step ? 'rgba(0,212,255,0.1)' : i < step ? 'rgba(34,197,94,0.1)' : 'var(--bg-card)',
color: i === step ? 'var(--accent)' : i < step ? 'var(--success)' : 'var(--text-muted)',
fontFamily: 'var(--font-display)', fontSize: 11, fontWeight: 700,
letterSpacing: '0.06em', textTransform: 'uppercase', cursor: i < step ? 'pointer' : 'default',
}}>
{i < step ? '✓ ' : `${i + 1}. `}{s}
</button>
))}
</div>
{/* Step 1: Personal Info */}
{step === 0 && (
<div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 16 }}>Personal Information</div>
{/* Profile image */}
<div className="form-group" style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ width: 80, height: 80, borderRadius: '50%', overflow: 'hidden', background: `${form.avatarColor}22`, border: `2px solid ${form.avatarColor}44`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{form.profileImage
? <img src={form.profileImage} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="Profile" />
: <span style={{ fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: 28, color: form.avatarColor }}>{(form.firstName?.[0] || '') + (form.lastName?.[0] || '')}</span>
}
</div>
<div>
<label className="label">Profile Photo</label>
<input type="file" accept="image/*" onChange={handleImageUpload} style={{ fontSize: 13, padding: '6px 10px' }} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
{COLORS.map(c => (
<button key={c} onClick={() => setField('avatarColor', c)} style={{ width: 24, height: 24, borderRadius: '50%', background: c, border: form.avatarColor === c ? '3px solid white' : '2px solid transparent', cursor: 'pointer' }} />
))}
</div>
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label className="label">First Name *</label>
<input value={form.firstName} onChange={e => setField('firstName', e.target.value)} placeholder="First name" />
</div>
<div className="form-group">
<label className="label">Last Name *</label>
<input value={form.lastName} onChange={e => setField('lastName', e.target.value)} placeholder="Last name" />
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label className="label">Email *</label>
<input type="email" value={form.email} onChange={e => setField('email', e.target.value)} placeholder="email@example.com" />
</div>
<div className="form-group">
<label className="label">Phone</label>
<input value={form.phone} onChange={e => setField('phone', e.target.value)} placeholder="+1 (555) 000-0000" />
</div>
</div>
<div className="grid-2">
<div className="form-group">
<label className="label">City</label>
<input value={form.city} onChange={e => setField('city', e.target.value)} placeholder="City" />
</div>
<div className="form-group">
<label className="label">Country</label>
<input value={form.country} onChange={e => setField('country', e.target.value)} placeholder="Country" />
</div>
</div>
<div className="form-group">
<label className="label">Short Bio</label>
<textarea value={form.bio} onChange={e => setField('bio', e.target.value)} placeholder="Tell us about yourself as an athlete..." maxLength={300} />
<div style={{ fontSize: 11, color: 'var(--text-muted)', textAlign: 'right' }}>{form.bio?.length || 0}/300</div>
</div>
</div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 16 }}>Social Media <span style={{ color: 'var(--text-muted)', fontWeight: 400, textTransform: 'none', fontSize: 12 }}>(optional)</span></div>
<div className="grid-2">
{['facebook', 'instagram', 'bluesky', 'snapchat'].map(platform => (
<div className="form-group" key={platform}>
<label className="label">{platform.charAt(0).toUpperCase() + platform.slice(1)}</label>
<input value={form.socials[platform]} onChange={e => setField(`socials.${platform}`, e.target.value)} placeholder={`@username`} />
</div>
))}
</div>
</div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 16 }}>Sports</div>
<p style={{ color: 'var(--text-secondary)', fontSize: 14, marginBottom: 14 }}>Select all sports you compete in:</p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{Object.values(SPORTS).map(s => (
<button key={s.id} onClick={() => toggleSport(s.id)}
className={`badge sport-${s.id}`}
style={{
opacity: activeSports.includes(s.id) ? 1 : 0.4,
cursor: 'pointer', fontSize: 13, padding: '8px 16px',
transform: activeSports.includes(s.id) ? 'scale(1.05)' : 'scale(1)',
transition: 'all 0.15s',
}}>
{s.emoji} {s.name}
</button>
))}
</div>
</div>
</div>
)}
{/* Step 2: Biometrics */}
{step === 1 && (
<div className="card">
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 16 }}>Biometric Data</div>
<div className="grid-2">
{BIOMETRIC_FIELDS.map(f => (
<div className="form-group" key={f.key}>
<label className="label">{f.label}</label>
{f.type === 'text'
? <select value={form.biometrics[f.key] || ''} onChange={e => setField(`biometrics.${f.key}`, e.target.value)}>
<option value="">Select...</option>
{f.key === 'dominant_hand' || f.key === 'dominant_foot'
? ['Right', 'Left', 'Both'].map(o => <option key={o} value={o}>{o}</option>)
: null}
</select>
: <input type="number" step={f.type === 'decimal' ? '0.1' : '1'}
value={form.biometrics[f.key] || ''}
onChange={e => setField(`biometrics.${f.key}`, e.target.value)}
placeholder="Enter value" />
}
</div>
))}
</div>
</div>
)}
{/* Step 3: Sport Stats */}
{step === 2 && (
<div>
{activeSports.map(sport => {
const defs = SPORT_STATS_DEFS[sport] || [];
return (
<div key={sport} className="card" style={{ marginBottom: 16 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 16 }}>
{SPORTS[sport]?.emoji} {SPORTS[sport]?.name} Stats
</div>
<div className="grid-2">
{defs.map(def => (
<div className="form-group" key={def.key}>
<label className="label">{def.label}</label>
{def.type === 'text'
? <select value={form.sportStats?.[sport]?.[def.key] || ''} onChange={e => setField(`sportStats.${sport}.${def.key}`, e.target.value)}>
<option value="">Select position...</option>
{(positions[sport] || []).map(p => <option key={p} value={p}>{p}</option>)}
</select>
: <input type="number" step={def.type === 'decimal' ? '0.01' : '1'}
value={form.sportStats?.[sport]?.[def.key] || ''}
onChange={e => setField(`sportStats.${sport}.${def.key}`, e.target.value)}
placeholder="0" />
}
</div>
))}
</div>
</div>
);
})}
</div>
)}
{/* Step 4: Review */}
{step === 3 && (
<div className="card">
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', marginBottom: 16 }}>Review & Submit</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', marginBottom: 20 }}>
<div style={{ width: 64, height: 64, borderRadius: '50%', background: `${form.avatarColor}22`, border: `2px solid ${form.avatarColor}44`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{form.profileImage
? <img src={form.profileImage} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} alt="Preview" />
: <span style={{ fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: 24, color: form.avatarColor }}>{(form.firstName?.[0] || '') + (form.lastName?.[0] || '')}</span>
}
</div>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 24, fontWeight: 800 }}>{form.firstName} {form.lastName}</div>
<div style={{ color: 'var(--text-secondary)', fontSize: 14 }}>{form.email}</div>
<div style={{ color: 'var(--text-secondary)', fontSize: 14 }}>{form.city && form.country ? `${form.city}, ${form.country}` : ''}</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
{activeSports.map(s => <span key={s} className={`badge sport-${s}`}>{SPORTS[s]?.emoji} {SPORTS[s]?.name}</span>)}
</div>
{form.bio && <p style={{ color: 'var(--text-secondary)', fontSize: 14, fontStyle: 'italic', marginBottom: 16 }}>"{form.bio}"</p>}
<div style={{ color: 'var(--text-muted)', fontSize: 13 }}>
{Object.values(form.biometrics || {}).filter(Boolean).length} biometric fields filled ·{' '}
{activeSports.reduce((acc, s) => acc + Object.values(form.sportStats?.[s] || {}).filter(Boolean).length, 0)} sport stats filled
</div>
</div>
)}
{/* Navigation */}
<div style={{ display: 'flex', gap: 12, marginTop: 24, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', gap: 12 }}>
{step > 0 && <button className="btn btn-secondary" onClick={() => setStep(s => s - 1)}> Back</button>}
<Link to="/athletes" className="btn btn-secondary">Cancel</Link>
</div>
{step < STEPS.length - 1
? <button className="btn btn-primary" onClick={() => setStep(s => s + 1)}>Continue </button>
: <button className="btn btn-primary" onClick={handleSubmit}>{editId ? 'Save Changes' : 'Register Athlete'}</button>
}
</div>
{toast && <div className="toast">{toast}</div>}
</div>
);
}

720
vibecode-prompt.md Normal file
View File

@@ -0,0 +1,720 @@
# 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 700900, uppercase, letter-spacing 0.020.1em
- **Body:** `Barlow` — weight 400600
- Section titles: 28px, weight 800, uppercase, `--font-display`
- Stat values: 2024px, 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 175205, w 85140
- Hockey: h 175198, w 82105
- Baseball: h 170200, w 82110
- Soccer: h 165195, w 7090
- Basketball: h 185220, w 85120
**Common biometrics:** age 2038, reach = height + 520, body_fat_pct 618%, vo2_max 4872, vertical_jump 5590cm, 40_yard_dash 4.305.20s, bench_press_reps 1035, years_pro 118
**Stat generation is position-aware.** Key rules:
- Football: QBs get passing_yards (28005200), 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.9000.935), gaa (1.83.5), shutouts (112); skaters get goals (555), assists, points; Centers get faceoff_pct (4258%)
- Baseball: Pitchers (SP/RP) get era (2.105.80), wins (422), whip (0.921.60), strikeouts_p; position players get batting_avg (0.2200.350), home_runs, obp, slg, ops
- Soccer: GK gets save_pct (6582%), 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 04 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 36px72px, 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.

31
vite.config.js Normal file
View File

@@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
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}']
}
})
]
})