9.6 KiB
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)
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:
localStoragefor token storage (not sessionStorage — survives tab close)- Token stored as
shark_token, user object asshark_user(JSON serialized) - Error extraction favors
data.detail(FastAPI's default) thendata.message API_BASE = ''for same-origin — works with both Caddy split-routing and full-proxy patterns- No
fetchwrapper 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
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 fromdraftBoard.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
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
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-gradientandwaveShiftanimation - Sonar ping rings with
sonarPinganimation - 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
apiCallcallers 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 asetIntervalthat callsGET /api/leagues/{id}/draftevery 5 seconds. history.replaceStateused 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 needAccess-Control-Allow-Originon 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
innerHTMLrestore pattern (save original in a<span>wrapper) rather than re-assigningtextContent. - No CSRF protection — token-in-header pattern is sufficient for this scale but CSP headers would be a good add.