201 lines
9.6 KiB
Markdown
201 lines
9.6 KiB
Markdown
# Shark Game Frontend Patterns — API-Connected HTML
|
|
|
|
Created Jul 8, 2026. Three frontend pages (`index.html`, `draft-room.html`, `league.html`) converted from static mockups to real API consumers against the FastAPI backend at port 8083. All served through the backend's catch-all route at `shark.iamgmb.com`.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Browser → shak.iamgmb.com → Caddy reverse_proxy → FastAPI (port 8083)
|
|
├── /api/* endpoints
|
|
└── serve /var/www/shark-game/*.html
|
|
```
|
|
|
|
Frontend makes relative fetch calls (`/api/auth/login`, `/api/leagues/1/draft`, etc.) — no hardcoded API base URL. Same-origin because Caddy proxies the whole domain through the backend.
|
|
|
|
## API Helper Pattern (all pages)
|
|
|
|
```javascript
|
|
const API_BASE = ''; // same-origin
|
|
|
|
function getToken() { return localStorage.getItem('shark_token'); }
|
|
function getUser() { try { return JSON.parse(localStorage.getItem('shark_user') || '{}'); } catch { return {}; } }
|
|
|
|
async function apiCall(path, options = {}) {
|
|
const token = getToken();
|
|
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
|
const res = await fetch(API_BASE + path, { ...options, headers });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.detail || data.message || 'Request failed');
|
|
return data;
|
|
}
|
|
```
|
|
|
|
**Key decisions:**
|
|
- `localStorage` for token storage (not sessionStorage — survives tab close)
|
|
- Token stored as `shark_token`, user object as `shark_user` (JSON serialized)
|
|
- Error extraction favors `data.detail` (FastAPI's default) then `data.message`
|
|
- `API_BASE = ''` for same-origin — works with both Caddy split-routing and full-proxy patterns
|
|
- No `fetch` wrapper retries — caller handles errors and shows them to user
|
|
|
|
## Auth Flow (index.html)
|
|
|
|
### Registration → Post-Auth Redirect
|
|
|
|
```
|
|
User fills form → handleSignUp()
|
|
→ POST /api/auth/register { email, password, display_name, phone? }
|
|
→ Store token + user in localStorage
|
|
→ GET /api/leagues (list user's leagues)
|
|
→ 0 leagues: show league creation + join UI
|
|
→ 1 league: window.location.href = 'draft-room.html?league_id=...'
|
|
→ 2+ leagues: show league picker cards
|
|
```
|
|
|
|
Login follows the same post-auth flow. The `onAuthSuccess()` function is shared between registration and login.
|
|
|
|
**Mode toggle:** Sign Up / Log In share a submit button. Toggling swaps visibility of `#registerFields` vs `#loginFields` and changes the button text / toggle link text.
|
|
|
|
### League Management on Landing Page
|
|
|
|
- **Create league:** `POST /api/leagues { name }` → redirects to draft-room.html
|
|
- **Join league:** `POST /api/leagues/join { invite_code }` → redirects to draft-room.html
|
|
- **Invite codes** are 8-char uppercase hex; input auto-uppercases, requires 8+ chars
|
|
- **League list cards** show name, member count, status, invite code; clicking enters that league
|
|
|
|
### Session persistence
|
|
|
|
`DOMContentLoaded` checks `getToken()`. If present, skips auth form and goes straight to `onAuthSuccess()`. This means returning users don't re-login unless they explicitly log out (which clears both `shark_token` and `shark_user`).
|
|
|
|
## Draft Room (draft-room.html)
|
|
|
|
### URL-driven league selection
|
|
|
|
```
|
|
?league_id=123 → load that league
|
|
no ?league_id → show league picker overlay (GET /api/leagues, user clicks one)
|
|
```
|
|
|
|
The overlay shows a list of league cards with member counts and status. If a user navigates to draft-room.html without a league, they pick one here.
|
|
|
|
### Data loading pattern
|
|
|
|
```javascript
|
|
async function loadAll() {
|
|
const [league, board, scores, regions] = await Promise.all([
|
|
apiCall('/api/leagues/' + leagueId),
|
|
apiCall('/api/leagues/' + leagueId + '/draft'),
|
|
apiCall('/api/leagues/' + leagueId + '/scores'),
|
|
apiCall('/api/regions'),
|
|
]);
|
|
// Store state, render all tabs
|
|
}
|
|
```
|
|
|
|
**Four parallel API calls at load time.** All tabs share this state — switching tabs doesn't re-fetch unless leaderboard hasn't loaded yet (lazy load on first tab switch).
|
|
|
|
### Tab Structure
|
|
|
|
| Tab | Contents | Data Source |
|
|
|-----|----------|-------------|
|
|
| **Draft** | Grid of 12 region cards, draft order bar, status | `draftBoard.available_regions`, `draftBoard.picks`, `draftBoard.current_pick` |
|
|
| **My Team** | 2-column grid of your drafted regions | `draftBoard.picks` filtered by `user_id` |
|
|
| **Leaderboard** | Sorted score entries with medals | `leaderboardData` (loaded lazily on first tab switch) |
|
|
| **League** | Name, status, invite code, members, draft start | `leagueData` |
|
|
|
|
### Draft Board Rendering
|
|
|
|
- Combines `available_regions` (undrafted) with regions from `draftBoard.picks` (already drafted)
|
|
- Sorts: undrafted first (alphabetical by name), then drafted
|
|
- Each card shows: emoji, name, sighting/bite/fatality stats from `regionsMap`
|
|
- **Your pick:** amber glow border + shadow, gold "Draft" button, only clickable when it's your turn
|
|
- **Drafted by others:** grayed out, shows "Taken by {name}", disabled button
|
|
- **Your drafted regions:** amber glow, "Drafted ✓", own label, disabled
|
|
|
|
### Draft Action
|
|
|
|
```javascript
|
|
async function makePick(regionId) {
|
|
const result = await apiCall('/api/leagues/' + leagueId + '/draft/pick', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ region_id: regionId }),
|
|
});
|
|
await loadAll(); // Full reload after pick
|
|
}
|
|
```
|
|
|
|
**After every pick the entire page state reloads** (`loadAll()`). This is simple and correct for a turn-based game with 2-8 players. The API enforces turn order and duplicate protection server-side.
|
|
|
|
### Status Bar Messages
|
|
|
|
| Condition | Display |
|
|
|-----------|---------|
|
|
| Draft complete | "🏆 Draft Complete!" + green complete badge |
|
|
| Status = 'draft' (not started) | "⏳ Waiting for commissioner to start draft" + pending badge |
|
|
| Your turn | "🎯 YOUR PICK!" in amber |
|
|
| Someone else's turn | "⏳ Waiting for {name}" in teal |
|
|
|
|
### Draft Order Bar
|
|
|
|
Horizontal scroll of all members showing who has picked (dimmed), who's currently picking (amber border), and who is "you" (teal border). Order picks per iteration capacity (snake logic handled server-side).
|
|
|
|
### Turn Enforcement
|
|
|
|
The button is only clickable when `currentDraftingUserId === myUserId` AND `draftBoard.status === 'active'` AND `!draftBoard.draft_complete`. Server also enforces this — the POST returns 403 if it's not your turn.
|
|
|
|
### Bottom Bar (Your Team)
|
|
|
|
Horizontal scroll of picks chips showing emoji + region name + round. Remaining picks indicator ("+ 3 picks remaining"). Total score in top-right score box updated from leaderboard data.
|
|
|
|
### Commissioner Actions (League Info Tab)
|
|
|
|
- **Start draft button** shown only when: user is commissioner AND status is 'draft' AND ≥2 members
|
|
- **Need more members message** shown when commissioner but <2 members
|
|
- **"Waiting for commissioner" message** shown when non-commissioner and status is 'draft'
|
|
- **"Draft is live"** shown when status is 'active'
|
|
|
|
### Invite Code Copy
|
|
|
|
```javascript
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
await navigator.clipboard.writeText(code);
|
|
// Show "Copied!" for 2 seconds
|
|
} else {
|
|
// Fallback: create temporary textarea, select, execCommand('copy')
|
|
}
|
|
```
|
|
|
|
## League Settings Page (league.html)
|
|
|
|
Standalone page at `/web/league.html?league_id=...`. If no `league_id` in URL, auto-loads the user's first league (redirect fallback). Contains:
|
|
|
|
- League details card (name, status badge, season start, member count)
|
|
- Invite code with copy-to-clipboard
|
|
- Full member list with draft order
|
|
- Commissioner actions (same as draft room's League tab)
|
|
- Navigation links back to draft room and home
|
|
|
|
## CSS Theme
|
|
|
|
All pages share a consistent ocean theme:
|
|
|
|
- `--deep-ocean: #0a1628` (body background)
|
|
- `--amber: #f59e0b` (accent color, buttons)
|
|
- `--teal-accent: #0ea5e9` (secondary accent, borders)
|
|
- `--sonar-green: #22c55e` (score display)
|
|
- `--alert-red: #dc2626` (danger/warning elements)
|
|
- Wave overlay with `radial-gradient` and `waveShift` animation
|
|
- Sonar ping rings with `sonarPing` animation
|
|
- Card backgrounds with `backdrop-filter: blur(8px)` and top gradient border
|
|
- Responsive grid: 3 columns default, 2 columns below 360px
|
|
|
|
## Pitfalls
|
|
|
|
- **Token expiry is 30 days with no refresh endpoint** — the user gets 401 on any API call after expiry. Handle by catching 401 and redirecting to login. Currently no global 401 handler — individual `apiCall` callers show the error in the inline error box.
|
|
- **Data staleness after draft pick** — after `makePick`, `loadAll()` re-fetches everything. This is a full page refresh pattern. For a live draft with polling, add a `setInterval` that calls `GET /api/leagues/{id}/draft` every 5 seconds.
|
|
- **`history.replaceState`** used to set URL params silently — used in league picker overlay to set `?league_id=` without a page reload.
|
|
- **Same-origin fetch**: `API_BASE = ''` works only because frontend and API are served from the same domain (Caddy reverse proxy or full-proxy pattern). If frontend were on a different origin, CORS would need `Access-Control-Allow-Origin` on the backend.
|
|
- **Invite code expiry:** Backend rejects join attempts when league status is not 'draft'. The frontend shows the code even after draft starts — the user gets an API error when trying to join. Consider hiding the code after draft starts.
|
|
- **Button text swap pattern** — the auth submit button changes text from "Join the Frenzy" to "Registering..." and back. Use `innerHTML` restore pattern (save original in a `<span>` wrapper) rather than re-assigning `textContent`.
|
|
- **No CSRF protection** — token-in-header pattern is sufficient for this scale but CSP headers would be a good add.
|