Files
hermes-skills/skills/devops/python-web-service-deployment/references/shark-game-frontend-pattern.md
T

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:

  • 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

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

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-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.