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
localStorageasaccess. - Refresh token stored in
localStorageasrefresh. - On 401,
apiFetchautomatically 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→ showsX mmmoa→ showsX MOA(requiresmoaparam to be non-null)mrad→ converts mm to mrad usingdistM(requiresdistMin 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 darkmodal-xlwith 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=IDto link a photo to an analysis.
group-size.js — Annotation Canvas
The most complex page script. Manages an HTML5 <canvas> for bullet-hole annotation:
- Reference line — drawn first; establishes px→mm scale from the user's
refLengthinput. - POA — single point, sets the aim reference.
- 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
GroupPhotocards 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 byrenderLbStats(gp)usingfDist. - Upload modal: posts to
/api/photos/upload/then creates aGroupPhotorecord. - 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_aton open. - Compose modal: recipient search via
/api/social/members/?q=with debounced autocomplete._recipientIdstores 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:
- HTML: a
<div class="btn-group">with<button data-dist-unit="mm|moa|mrad">buttons. - On page load / modal open:
applyDistUnitButtons(container)marks the active button. - On click:
setDistUnit(value)→applyDistUnitButtons(container)→ re-render data. 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);
});