First commit of claude's rework in django + vanillajs fronted
This commit is contained in:
293
docs/frontend.md
Normal file
293
docs/frontend.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# 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);
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user