Files
ShooterHub/docs/frontend.md
2026-04-02 11:24:30 +02:00

10 KiB

Frontend Architecture

ShooterHub's frontend is vanilla JS + Bootstrap 5.3, served as-is by Nginx. There is no build step, no bundler, and no framework.


File Structure

frontend/
├── index.html            # Landing page / community feed
├── dashboard.html        # User dashboard (stats summary)
├── sessions.html         # Shooting sessions list + detail panel
├── chrono.html           # Chronograph analysis
├── group-size.html       # Target photo annotation tool
├── photos.html           # Group photo gallery
├── reloads.html          # Reloading recipes + batches
├── messages.html         # Private messaging
├── friends.html          # Friendships / follow requests
├── css/
│   └── app.css           # Global styles (minimal, Bootstrap-first)
└── js/
    ├── api.js            # HTTP client (JWT, refresh, helpers)
    ├── utils.js          # Shared UI utilities
    ├── i18n.js           # Client-side translations
    ├── nav.js            # Navbar builder + auth gate + badge polling
    ├── sessions.js       # Sessions page
    ├── chrono.js         # Chrono page
    ├── group-size.js     # Annotation canvas tool
    ├── photos.js         # Photo gallery + lightbox
    ├── reloads.js        # Reloads page
    ├── messages.js       # Messaging page
    └── friends.js        # Friends page

Script Load Order

Every HTML page loads scripts in this fixed order:

<script src="/js/api.js"></script>      <!-- 1. HTTP client -->
<script src="/js/utils.js"></script>    <!-- 2. UI helpers -->
<script src="/js/i18n.js"></script>     <!-- 3. Translations -->
<script src="/js/nav.js"></script>      <!-- 4. Navbar / auth gate -->
<script src="/js/PAGE.js"></script>     <!-- 5. Page-specific code -->

nav.js blocks unauthenticated access to protected routes. It checks for a JWT in localStorage and redirects to the login modal if absent.


api.js — HTTP Client

Central API client. All page scripts call these functions; none call fetch() directly.

Core functions

Function Description
apiGet(url) GET, returns parsed JSON
apiPost(url, body) POST JSON
apiPatch(url, body) PATCH JSON
apiDelete(url) DELETE
apiFetch(url, opts) Raw fetch with JWT header + auto-refresh

JWT handling

  • Access token stored in localStorage as access.
  • Refresh token stored in localStorage as refresh.
  • On 401, apiFetch automatically calls /api/auth/token/refresh/, stores the new access token, and retries the original request once.
  • On refresh failure (401 again), clears storage and redirects to login.

asList(data)

Unwraps DRF paginated responses:

asList(data)  // → data.results if paginated, data if already an array

Used throughout page scripts after every list endpoint call.


utils.js — Shared UI Utilities

Shared helpers used by all page scripts. Must be loaded after api.js.

showToast(msg, type = 'success')

Injects a Bootstrap toast into #toastContainer and auto-dismisses it. Type maps to Bootstrap contextual colours (success, danger, warning, info).

Every HTML page includes <div id="toastContainer"></div> at the bottom.

esc(s)

HTML-escapes a string. Used when injecting user-controlled text into innerHTML.

Unit system

Units are stored in localStorage:

Function Key Values
getDistUnit() / setDistUnit(u) distUnit 'mm' (default), 'moa', 'mrad'
getVelUnit() / setVelUnit(u) velUnit 'ms' (default), 'fps'

fDist(mm, moa, distM)

Formats a distance value according to the current unit:

  • mm → shows X mm
  • moa → shows X MOA (requires moa param to be non-null)
  • mrad → converts mm to mrad using distM (requires distM in metres)

Falls back to mm when MOA/MRAD data is unavailable.

applyDistUnitButtons(container)

Marks the active [data-dist-unit] button within a container by adding/removing the Bootstrap active class. Call this whenever the unit changes or a modal opens.

applyDistUnitButtons(document.getElementById('myModal'));

renderPublicToggle(isPublic, { btnId, iconId, labelId, privateClass })

Updates a "Make public / Make private" toggle button's icon and label. The privateClass option sets the button variant when private (default 'btn-outline-secondary'; pass 'btn-outline-light' inside dark modals).


i18n.js — Translations

Provides t(key) for client-side string lookup. Supported languages: en, fr, de, es. The active language is read from localStorage (set from the user's profile preference after login).


nav.js — Navbar + Auth Gate

Navbar

Builds the <div id="navbar"> element present on every page. Injects navigation links, the user avatar dropdown, and a login/register modal.

After building the navbar, nav.js checks login state and:

  • If logged in: fetches unread message count → populates #navMsgBadge
  • If logged in: fetches pending friend requests → populates #navFriendsBadge

Protected routes

const PROTECTED = ['/sessions.html', '/chrono.html', '/photos.html',
                   '/reloads.html', '/dashboard.html', '/group-size.html',
                   '/messages.html', '/friends.html'];

If the current path is in PROTECTED and no JWT is present, the login modal opens automatically.


Page Scripts

sessions.js

  • Loads session list (PRS / Free Practice / Speed Shooting tabs).
  • Clicking a session opens a detail panel inline (right column).
  • openGroupPhotoModal(gpId) opens a dark modal-xl with photo + stats + unit switch.
  • Unit switch in the detail panel calls applyUnits('dist'|'vel', value) which updates localStorage, re-renders the panel, and syncs unit buttons in the open photo modal if any.

chrono.js

  • Loads chronograph analyses, renders velocity stats and shot tables.
  • Public/private toggle via renderPublicToggle.
  • Navigates to group-size.html?analysis=ID to link a photo to an analysis.

group-size.js — Annotation Canvas

The most complex page script. Manages an HTML5 <canvas> for bullet-hole annotation:

  1. Reference line — drawn first; establishes px→mm scale from the user's refLength input.
  2. POA — single point, sets the aim reference.
  3. POIs — multiple bullet-hole points; scaled circles drawn at bullet diameter.

Calculations (group ES, mean radius, centroid, offset from POA, correction) are done client-side in updateResults(). Results are displayed using fDist() from utils.js.

The "Save POIs & compute server-side" button (saveBtn) posts annotations to /api/photos/group-photos/{id}/ and triggers server-side recomputation.

Launched from photos.html or sessions.html via ?gp=ID query parameter; the ?from=chrono&analysis=ID parameter activates the back-button linking flow.

photos.js

  • Grid of GroupPhoto cards with lazy pagination (loadMoreBtn).
  • Filter by measured / unmeasured (client-side, no refetch).
  • Lightbox: dark modal-xl, shows full photo + stats bar + unit switch buttons. Stats rendered by renderLbStats(gp) using fDist.
  • Upload modal: posts to /api/photos/upload/ then creates a GroupPhoto record.
  • Compute button: calls /api/photos/group-photos/{id}/compute-group-size/.

messages.js

  • Two-pane: left list (Inbox/Sent tabs), right detail.
  • Marking a message read: PATCH read_at on open.
  • Compose modal: recipient search via /api/social/members/?q= with debounced autocomplete. _recipientId stores the selected user id.
  • Reply: prefillCompose(recipientDetail, 'Re: ' + subject) pre-fills the compose modal.

friends.js

  • Three tabs: Friends / Requests / Sent requests.
  • Add Friend modal: live search with debounced input, send request on click.
  • sendRequest(userId, btn) handles HTTP 409 (already connected) by showing a badge.
  • Accept/Decline: PATCH to /api/social/friends/{id}/accept/ or .../decline/.

reloads.js

  • Lists reloading recipes and batches.
  • Public/private toggle on batches.

Unit Switch Pattern

The unit switch is consistent across all pages that show ballistic data:

  1. HTML: a <div class="btn-group"> with <button data-dist-unit="mm|moa|mrad"> buttons.
  2. On page load / modal open: applyDistUnitButtons(container) marks the active button.
  3. On click: setDistUnit(value)applyDistUnitButtons(container) → re-render data.
  4. fDist(mm, moa, distM) formats the value for the current unit.

This pattern lives in utils.js so any page can adopt it with minimal code.


Data Flow Summary

User action
    │
    ▼
Page script (e.g. sessions.js)
    │  calls
    ▼
api.js (apiGet / apiPost / …)
    │  adds JWT header, handles 401 refresh
    ▼
Django REST API (/api/…)
    │  returns JSON
    ▼
Page script
    │  calls asList(), fDist(), esc(), showToast() from utils.js
    ▼
DOM update (innerHTML / classList / …)

Common Patterns

Pagination

let _nextUrl = null;
let _allItems = [];

async function load(reset = false) {
  if (reset) { _allItems = []; _nextUrl = null; }
  const data = await apiGet(_nextUrl || '/api/endpoint/?page_size=24');
  const items = asList(data);
  _nextUrl = data.next ? new URL(data.next).pathname + new URL(data.next).search : null;
  _allItems = [..._allItems, ...items];
  render(items);
  document.getElementById('loadMoreBtn').style.display = _nextUrl ? '' : 'none';
}

Inline card refresh after mutation

const idx = _allItems.findIndex(x => x.id === id);
_allItems[idx] = { ..._allItems[idx], ...updatedFields };
const old = document.querySelector(`[data-item-id="${id}"]`);
if (old) { old.remove(); renderCards([_allItems[idx]]); }

Delegated event handling

Click handlers for dynamically generated cards are set up as delegated listeners on the static parent, not on each card element:

grid.addEventListener('click', e => {
  const btn = e.target.closest('[data-action="delete"]');
  if (!btn) return;
  deleteItem(btn.dataset.id);
});