Files
ShooterHub/docs/frontend.md

294 lines
10 KiB
Markdown
Raw Normal View History

# 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:
```html
<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:
```js
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.
```js
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
```js
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
```js
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
```js
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:
```js
grid.addEventListener('click', e => {
const btn = e.target.closest('[data-action="delete"]');
if (!btn) return;
deleteItem(btn.dataset.id);
});
```