First commit of claude's rework in django + vanillajs fronted

This commit is contained in:
Gérald Colangelo
2026-04-02 11:24:30 +02:00
parent 7710a876df
commit fde92f92db
163 changed files with 84852 additions and 15 deletions

737
frontend/js/admin.js Normal file
View File

@@ -0,0 +1,737 @@
// ── Admin page logic ──────────────────────────────────────────────────────────
// ── Toast ─────────────────────────────────────────────────────────────────────
function showToast(msg, type = 'success') {
const el = document.createElement('div');
el.className = `toast align-items-center text-bg-${type} border-0 show`;
el.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
// ══ USERS TAB ════════════════════════════════════════════════════════════════
async function loadUsers() {
try {
const users = await apiGet('/users/admin/');
document.getElementById('usersSpinner').classList.add('d-none');
document.getElementById('usersTableWrap').classList.remove('d-none');
renderUsers(users);
} catch(e) {
document.getElementById('usersSpinner').innerHTML =
'<p class="text-danger">Failed to load users.</p>';
}
}
function renderUsers(users) {
document.getElementById('usersBody').innerHTML = users.map(u => `
<tr>
<td class="fw-semibold">${u.username}</td>
<td>${u.email}</td>
<td>${[u.first_name, u.last_name].filter(Boolean).join(' ') || '—'}</td>
<td>${u.is_staff ? '<span class="badge bg-warning text-dark">Staff</span>' : ''}</td>
<td>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" ${u.is_active ? 'checked' : ''}
onchange="toggleUserActive(${u.id}, this.checked)" title="Active">
</div>
</td>
<td class="text-muted small">${u.date_joined ? u.date_joined.slice(0,10) : '—'}</td>
<td>
<button class="btn btn-sm ${u.is_staff ? 'btn-outline-secondary' : 'btn-outline-warning'}"
onclick="toggleUserStaff(${u.id}, ${!u.is_staff})" title="${u.is_staff ? 'Remove staff' : 'Make staff'}">
<i class="bi bi-${u.is_staff ? 'person-dash' : 'person-up'}"></i>
</button>
</td>
</tr>
`).join('');
}
async function toggleUserActive(id, active) {
try {
await apiPatch(`/users/admin/${id}/`, { is_active: active });
showToast(`User ${active ? 'activated' : 'deactivated'}.`);
} catch(e) {
showToast('Failed to update user.', 'danger');
loadUsers();
}
}
async function toggleUserStaff(id, staff) {
if (!confirm(`${staff ? 'Grant' : 'Revoke'} staff rights for this user?`)) return;
try {
await apiPatch(`/users/admin/${id}/`, { is_staff: staff });
showToast(`Staff rights ${staff ? 'granted' : 'revoked'}.`);
loadUsers();
} catch(e) {
showToast('Failed to update user.', 'danger');
}
}
// ══ GEAR CATALOG TAB ═════════════════════════════════════════════════════════
const GEAR_ENDPOINTS = {
firearm: '/gears/firearms/',
scope: '/gears/scopes/',
suppressor: '/gears/suppressors/',
bipod: '/gears/bipods/',
magazine: '/gears/magazines/',
};
const CATALOG_PAGE_SIZE = 50;
let allCatalogItems = [];
let catalogTotalCount = 0;
let catalogSearchTerm = '';
let catalogPage = 1;
let catalogHasNext = false;
async function loadCatalog(status, page) {
if (status === undefined) status = document.getElementById('catalogStatusFilter').value;
if (page === undefined) page = 1;
catalogPage = page;
const spinnerEl = document.getElementById('catalogSpinner');
spinnerEl.innerHTML = '<div class="spinner-border text-primary"></div>';
spinnerEl.classList.remove('d-none');
document.getElementById('catalogTableWrap').classList.add('d-none');
const statusParam = (status && status !== 'all') ? `&status=${status}` : '';
const searchParam = catalogSearchTerm ? `&search=${encodeURIComponent(catalogSearchTerm)}` : '';
const pageParam = page > 1 ? `&page=${page}` : '';
try {
const results = await Promise.all(
Object.entries(GEAR_ENDPOINTS).map(([type, url]) =>
apiFetch(`${url}?page_size=${CATALOG_PAGE_SIZE}${statusParam}${searchParam}${pageParam}`)
.then(r => { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(data => ({
type,
items: (data.results || []).map(g => ({ ...g, gear_type: type })),
count: data.count || 0,
hasNext: !!data.next,
}))
)
);
allCatalogItems = results.flatMap(r => r.items);
catalogTotalCount = results.reduce((s, r) => s + r.count, 0);
catalogHasNext = results.some(r => r.hasNext);
spinnerEl.classList.add('d-none');
document.getElementById('catalogTableWrap').classList.remove('d-none');
renderCatalog();
} catch(e) {
spinnerEl.innerHTML = `<p class="text-danger">Failed to load catalog: ${e.message}</p>`;
}
}
function renderCatalog() {
const typeFilter = document.getElementById('catalogTypeFilter').value;
let items = allCatalogItems;
if (typeFilter !== 'all') items = items.filter(g => g.gear_type === typeFilter);
const statusBadge = s => ({
PENDING: '<span class="badge bg-warning text-dark">Pending</span>',
VERIFIED: '<span class="badge bg-success">Verified</span>',
REJECTED: '<span class="badge bg-danger">Rejected</span>',
}[s] || `<span class="badge bg-secondary">${s}</span>`);
// Count line
const showing = items.length;
const countEl = document.getElementById('catalogCount');
if (countEl) {
const pageDesc = catalogHasNext || catalogPage > 1
? ` — page ${catalogPage}` : '';
countEl.textContent = `${showing} item${showing !== 1 ? 's' : ''} shown (${catalogTotalCount} total${pageDesc})`;
}
// Table rows
document.getElementById('catalogBody').innerHTML = items.map(g => `
<tr>
<td><span class="badge bg-secondary">${g.gear_type}</span></td>
<td>${esc(g.brand)}</td>
<td>${esc(g.model_name)}</td>
<td>${esc(g.caliber_detail?.name || '—')}</td>
<td>${statusBadge(g.status)}</td>
<td class="text-end">
${g.status === 'PENDING' ? `
<button class="btn btn-sm btn-outline-success me-1"
onclick="verifyGear('${g.gear_type}', ${g.id})" title="Verify">
<i class="bi bi-check-lg"></i>
</button>
<button class="btn btn-sm btn-outline-danger me-1"
onclick="rejectGear('${g.gear_type}', ${g.id})" title="Reject">
<i class="bi bi-x-lg"></i>
</button>` : ''}
<button class="btn btn-sm btn-outline-danger"
onclick="deleteGear('${g.gear_type}', ${g.id})" title="Delete">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('') || '<tr><td colspan="6" class="text-muted text-center">No items.</td></tr>';
// Pagination controls
document.getElementById('catalogPagination').innerHTML = (catalogPage > 1 || catalogHasNext) ? `
<nav class="d-flex justify-content-between align-items-center mt-2">
<button class="btn btn-sm btn-outline-secondary" ${catalogPage <= 1 ? 'disabled' : ''}
onclick="loadCatalog(undefined, ${catalogPage - 1})">
<i class="bi bi-chevron-left"></i> Prev
</button>
<span class="small text-muted">Page ${catalogPage}</span>
<button class="btn btn-sm btn-outline-secondary" ${!catalogHasNext ? 'disabled' : ''}
onclick="loadCatalog(undefined, ${catalogPage + 1})">
Next <i class="bi bi-chevron-right"></i>
</button>
</nav>` : '';
}
document.getElementById('catalogTypeFilter').addEventListener('change', renderCatalog);
document.getElementById('catalogStatusFilter').addEventListener('change', () => loadCatalog());
async function verifyGear(type, id) {
try {
await apiPost(`/gears/${type}s/${id}/verify/`, {});
const item = allCatalogItems.find(g => g.gear_type === type && g.id === id);
if (item) item.status = 'VERIFIED';
renderCatalog();
showToast('Gear verified.');
} catch(e) { showToast('Failed to verify.', 'danger'); }
}
async function rejectGear(type, id) {
try {
await apiPost(`/gears/${type}s/${id}/reject/`, {});
const item = allCatalogItems.find(g => g.gear_type === type && g.id === id);
if (item) item.status = 'REJECTED';
renderCatalog();
showToast('Gear rejected.');
} catch(e) { showToast('Failed to reject.', 'danger'); }
}
async function deleteGear(type, id) {
if (!confirm('Delete this gear item from the catalog?')) return;
try {
await apiDelete(`/gears/${type}s/${id}/`);
allCatalogItems = allCatalogItems.filter(g => !(g.gear_type === type && g.id === id));
renderCatalog();
showToast('Gear deleted.');
} catch(e) { showToast('Failed to delete.', 'danger'); }
}
// Add gear form — show/hide type-specific fields
document.getElementById('addGearType').addEventListener('change', function() {
['Firearm','Scope','Suppressor','Bipod','Magazine'].forEach(t =>
document.getElementById(`fields${t}`).classList.add('d-none'));
if (this.value) {
const key = this.value.charAt(0).toUpperCase() + this.value.slice(1);
document.getElementById(`fields${key}`)?.classList.remove('d-none');
document.getElementById('addGearSubmit').disabled = false;
// Load calibers into the newly-visible caliber select
const calSelectId = { firearm: 'addFirearmCaliber', suppressor: 'addSuppCaliber', magazine: 'addMagCaliber' }[this.value];
if (calSelectId) {
const sel = document.getElementById(calSelectId);
if (sel && (!sel.options.length || sel.options[0].text === 'Loading…')) {
loadCalibersIntoSelect(sel);
}
}
} else {
document.getElementById('addGearSubmit').disabled = true;
}
});
document.getElementById('addGearSubmit').addEventListener('click', async () => {
const type = document.getElementById('addGearType').value;
const alertEl = document.getElementById('addGearAlert');
alertEl.classList.add('d-none');
if (!type) return;
const payload = {
brand: document.getElementById('addGearBrand').value.trim(),
model_name: document.getElementById('addGearModel').value.trim(),
description:document.getElementById('addGearDesc').value.trim(),
status: 'VERIFIED',
};
if (!payload.brand || !payload.model_name) {
alertEl.textContent = 'Brand and model are required.';
alertEl.classList.remove('d-none');
return;
}
if (type === 'firearm') {
payload.firearm_type = document.getElementById('addFirearmType').value;
const fc = document.getElementById('addFirearmCaliber').value;
if (fc) payload.caliber = parseInt(fc);
const bl = document.getElementById('addFirearmBarrel').value;
const mc = document.getElementById('addFirearmMag').value;
if (bl) payload.barrel_length_mm = parseFloat(bl);
if (mc) payload.magazine_capacity = parseInt(mc);
} else if (type === 'scope') {
const mn = document.getElementById('addScopeMagMin').value;
const mx = document.getElementById('addScopeMagMax').value;
const ob = document.getElementById('addScopeObj').value;
const tu = document.getElementById('addScopeTube').value;
const rt = document.getElementById('addScopeReticle').value;
const au = document.getElementById('addScopeAdj').value;
const fp = document.getElementById('addScopeFP').value;
if (rt) payload.reticle_type = rt;
if (mn) payload.magnification_min = parseFloat(mn);
if (mx) payload.magnification_max = parseFloat(mx);
if (ob) payload.objective_diameter_mm = parseFloat(ob);
if (tu) payload.tube_diameter_mm = parseFloat(tu);
if (au) payload.adjustment_unit = au;
if (fp) payload.focal_plane = fp;
} else if (type === 'suppressor') {
const sc = document.getElementById('addSuppCaliber').value;
if (sc) payload.max_caliber = parseInt(sc);
payload.thread_pitch = document.getElementById('addSuppThread').value.trim();
const ln = document.getElementById('addSuppLength').value;
const wt = document.getElementById('addSuppWeight').value;
if (ln) payload.length_mm = parseFloat(ln);
if (wt) payload.weight_g = parseFloat(wt);
} else if (type === 'bipod') {
const mn = document.getElementById('addBipodMin').value;
const mx = document.getElementById('addBipodMax').value;
payload.attachment_type = document.getElementById('addBipodAttach').value.trim();
if (mn) payload.min_height_mm = parseFloat(mn);
if (mx) payload.max_height_mm = parseFloat(mx);
} else if (type === 'magazine') {
const mc = document.getElementById('addMagCaliber').value;
if (mc) payload.caliber = parseInt(mc);
const cap = document.getElementById('addMagCapacity').value;
if (cap) payload.capacity = parseInt(cap);
}
try {
const created = await apiPost(`/gears/${type}s/`, payload);
allCatalogItems.push({ ...created, gear_type: type });
renderCatalog();
// Reset form
['addGearBrand','addGearModel','addGearDesc','addFirearmAction',
'addFirearmBarrel','addFirearmMag','addScopeMagMin','addScopeMagMax','addScopeObj',
'addScopeTube','addScopeReticle','addScopeAdj','addScopeFP',
'addSuppThread','addSuppLength',
'addSuppWeight','addBipodMin','addBipodMax','addBipodAttach',
'addMagCapacity'].forEach(id => { const el = document.getElementById(id); if(el) el.value=''; });
// Reset caliber selects to first option
['addFirearmCaliber','addSuppCaliber','addMagCaliber'].forEach(id => {
const el = document.getElementById(id); if (el) el.selectedIndex = 0;
});
showToast('Gear added to catalog!');
} catch(e) {
alertEl.textContent = formatErrors(e.data) || 'Failed to add gear.';
alertEl.classList.remove('d-none');
}
});
// ══ RELOAD COMPONENTS TAB ═════════════════════════════════════════════════════
function compStatusBadge(s) {
return s === 'PENDING' ? '<span class="badge bg-warning text-dark">Pending</span>'
: s === 'REJECTED' ? '<span class="badge bg-danger">Rejected</span>'
: '<span class="badge bg-success">Verified</span>';
}
const COMP_CONFIG = {
primers: {
title: 'primer',
endpoint: '/gears/components/primers/',
headers: ['Brand', 'Name', 'Size', 'Status', ''],
row: c => `<td>${c.brand}</td><td>${c.name}</td><td>${c.size}</td><td>${compStatusBadge(c.status)}</td>`,
form: () => `
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cBrand" placeholder="Brand *"></div>
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cName" placeholder="Name *"></div>
<div class="mb-2">
<select class="form-select form-select-sm" id="cSize">
<option value="SP">Small Pistol</option><option value="LP">Large Pistol</option>
<option value="SR">Small Rifle</option><option value="LR" selected>Large Rifle</option>
<option value="LRM">Large Rifle Magnum</option>
</select>
</div>`,
payload: () => ({ brand: v('cBrand'), name: v('cName'), size: v('cSize') }),
},
brass: {
title: 'brass',
endpoint: '/gears/components/brass/',
headers: ['Brand', 'Caliber', 'Pocket', 'Status', ''],
row: c => `<td>${c.brand}</td><td>${c.caliber_detail?.name || '—'}</td><td>${c.primer_pocket || '—'}</td><td>${compStatusBadge(c.status)}</td>`,
form: () => `
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cBrand" placeholder="Brand *"></div>
<div class="mb-2">
<select class="form-select form-select-sm" id="cCaliber"><option value="">Loading calibers…</option></select>
</div>
<div class="mb-2">
<select class="form-select form-select-sm" id="cPocket">
<option value="">Primer pocket (optional)</option>
<option value="SP">Small Pistol</option><option value="LP">Large Pistol</option>
<option value="SR">Small Rifle</option><option value="LR">Large Rifle</option>
<option value="LRM">Large Rifle Magnum</option>
</select>
</div>`,
payload: () => {
const cal = v('cCaliber');
return { brand: v('cBrand'), caliber: cal ? parseInt(cal) : undefined, primer_pocket: v('cPocket') || undefined };
},
},
bullets: {
title: 'bullet',
endpoint: '/gears/components/bullets/',
headers: ['Brand', 'Model', 'Weight', 'Type', 'Status', ''],
row: c => `<td>${c.brand}</td><td>${c.model_name}</td><td>${c.weight_gr} gr</td><td>${c.bullet_type}</td><td>${compStatusBadge(c.status)}</td>`,
form: () => `
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cBrand" placeholder="Brand *"></div>
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cModel" placeholder="Model name *"></div>
<div class="mb-2"><input type="number" class="form-control form-control-sm" id="cWeight" placeholder="Weight (gr) *" step="0.1"></div>
<div class="mb-2">
<select class="form-select form-select-sm" id="cBType">
<option value="FMJ">FMJ</option><option value="HP">HP</option>
<option value="BTHP">BTHP</option><option value="SP">SP</option>
<option value="HPBT" selected>HPBT</option><option value="SMK">SMK</option>
<option value="A_TIP">A-Tip</option><option value="MONO">Monolithic</option>
</select>
</div>
<div class="mb-2"><input type="number" class="form-control form-control-sm" id="cDiam" placeholder="Diameter mm (opt)" step="0.001"></div>`,
payload: () => {
const p = { brand: v('cBrand'), model_name: v('cModel'), weight_gr: v('cWeight'), bullet_type: v('cBType') };
const d = v('cDiam'); if (d) p.diameter_mm = d;
return p;
},
},
powders: {
title: 'powder',
endpoint: '/gears/components/powders/',
headers: ['Brand', 'Name', 'Type', 'Burn idx', 'Status', ''],
row: c => `<td>${c.brand}</td><td>${c.name}</td><td>${c.powder_type || '—'}</td><td>${c.burn_rate_index ?? '—'}</td><td>${compStatusBadge(c.status)}</td>`,
form: () => `
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cBrand" placeholder="Brand *"></div>
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="cName" placeholder="Name *"></div>
<div class="mb-2">
<select class="form-select form-select-sm" id="cPType">
<option value="">Type (optional)</option>
<option value="BALL">Ball/Spherical</option>
<option value="EXTRUDED">Extruded/Stick</option>
<option value="FLAKE">Flake</option>
</select>
</div>
<div class="mb-2"><input type="number" class="form-control form-control-sm" id="cBurn" placeholder="Burn rate index (opt)"></div>`,
payload: () => {
const p = { brand: v('cBrand'), name: v('cName') };
const t = v('cPType'); if (t) p.powder_type = t;
const b = v('cBurn'); if (b) p.burn_rate_index = parseInt(b);
return p;
},
},
};
let currentComp = 'primers';
let compData = {};
function v(id) { return document.getElementById(id)?.value?.trim() || ''; }
async function loadComp(type) {
currentComp = type;
const cfg = COMP_CONFIG[type];
const spinner = document.getElementById('compSpinner');
const wrap = document.getElementById('compTableWrap');
spinner.classList.remove('d-none');
wrap.classList.add('d-none');
document.getElementById('addCompTitle').textContent = `Add ${cfg.title}`;
document.getElementById('addCompForm').innerHTML = cfg.form();
document.getElementById('addCompAlert').classList.add('d-none');
// Load calibers into brass form select
if (type === 'brass') {
const sel = document.getElementById('cCaliber');
if (sel) loadCalibersIntoSelect(sel);
}
try {
compData[type] = await apiGetAll(cfg.endpoint + '?page_size=1000');
renderComp(type);
} catch(e) {
spinner.innerHTML = '<p class="text-danger">Failed to load.</p>';
}
}
function renderComp(type) {
const cfg = COMP_CONFIG[type];
const items = compData[type] || [];
document.getElementById('compSpinner').classList.add('d-none');
document.getElementById('compTableWrap').classList.remove('d-none');
document.getElementById('compTableHead').innerHTML =
cfg.headers.map(h => `<th>${h}</th>`).join('');
document.getElementById('compBody').innerHTML = items.length
? items.map(c => `
<tr>
${cfg.row(c)}
<td class="text-end">
${c.status === 'PENDING' ? `
<button class="btn btn-sm btn-outline-success me-1"
onclick="verifyComp('${type}', ${c.id})" title="Verify">
<i class="bi bi-check-lg"></i>
</button>
<button class="btn btn-sm btn-outline-warning me-1"
onclick="rejectComp('${type}', ${c.id})" title="Reject">
<i class="bi bi-x-lg"></i>
</button>` : ''}
<button class="btn btn-sm btn-outline-danger"
onclick="deleteComp('${type}', ${c.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`).join('')
: `<tr><td colspan="${cfg.headers.length}" class="text-muted text-center">No entries.</td></tr>`;
}
document.getElementById('addCompSubmit').addEventListener('click', async () => {
const cfg = COMP_CONFIG[currentComp];
const alertEl = document.getElementById('addCompAlert');
alertEl.classList.add('d-none');
try {
const payload = cfg.payload();
const created = await apiPost(cfg.endpoint, payload);
if (!compData[currentComp]) compData[currentComp] = [];
compData[currentComp].push(created);
renderComp(currentComp);
document.getElementById('addCompForm').innerHTML = cfg.form();
if (currentComp === 'brass') {
const sel = document.getElementById('cCaliber');
if (sel) loadCalibersIntoSelect(sel);
}
showToast(`${cfg.title} added!`);
} catch(e) {
alertEl.textContent = formatErrors(e.data) || 'Failed to add.';
alertEl.classList.remove('d-none');
}
});
async function verifyComp(type, id) {
const cfg = COMP_CONFIG[type];
try {
await apiPost(`${cfg.endpoint}${id}/verify/`, {});
const item = (compData[type] || []).find(c => c.id === id);
if (item) item.status = 'VERIFIED';
renderComp(type);
showToast('Component verified.');
} catch(e) { showToast('Failed to verify.', 'danger'); }
}
async function rejectComp(type, id) {
const cfg = COMP_CONFIG[type];
try {
await apiPost(`${cfg.endpoint}${id}/reject/`, {});
const item = (compData[type] || []).find(c => c.id === id);
if (item) item.status = 'REJECTED';
renderComp(type);
showToast('Component rejected.');
} catch(e) { showToast('Failed to reject.', 'danger'); }
}
async function deleteComp(type, id) {
if (!confirm('Delete this component?')) return;
const cfg = COMP_CONFIG[type];
try {
await apiDelete(`${cfg.endpoint}${id}/`);
compData[type] = compData[type].filter(c => c.id !== id);
renderComp(type);
showToast('Deleted.');
} catch(e) { showToast('Failed to delete.', 'danger'); }
}
// Pill switcher
document.getElementById('compPills').addEventListener('click', e => {
const btn = e.target.closest('[data-comp]');
if (!btn) return;
document.querySelectorAll('#compPills .nav-link').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadComp(btn.dataset.comp);
});
// ── Tab activation → lazy load ─────────────────────────────────────────────
document.querySelectorAll('#adminTabs button').forEach(btn => {
btn.addEventListener('shown.bs.tab', e => {
const target = e.target.dataset.bsTarget;
if (target === '#usersTab' && !document.getElementById('usersBody').innerHTML) loadUsers();
if (target === '#catalogTab' && !allCatalogItems.length) loadCatalog();
if (target === '#componentsTab' && !compData.primers) loadComp('primers');
if (target === '#calibersTab' && !caliberData.length) loadCalibersAdmin();
});
});
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('catalogSearch')?.addEventListener('keydown', e => {
if (e.key === 'Enter') {
catalogSearchTerm = e.target.value.trim();
loadCatalog(undefined, 1);
}
});
});
// ══ CALIBERS TAB ═════════════════════════════════════════════════════════════
let caliberData = [];
async function loadCalibersAdmin() {
try {
caliberData = await apiGetAll('/calibers/?page_size=1000');
document.getElementById('calibersSpinner').classList.add('d-none');
document.getElementById('calibersTableWrap').classList.remove('d-none');
renderCalibersAdmin();
} catch(e) {
document.getElementById('calibersSpinner').innerHTML = '<p class="text-danger">Failed to load.</p>';
}
}
function renderCalibersAdmin() {
const statusBadge = s => ({
PENDING: '<span class="badge bg-warning text-dark">Pending</span>',
VERIFIED: '<span class="badge bg-success">Verified</span>',
REJECTED: '<span class="badge bg-danger">Rejected</span>',
}[s] || `<span class="badge bg-secondary">${s}</span>`);
document.getElementById('calibersBody').innerHTML = caliberData.length
? caliberData.map(c => `
<tr>
<td><strong>${c.name}</strong></td>
<td class="text-muted">${c.short_name || '—'}</td>
<td>${c.case_length_mm ?? '—'}</td>
<td>${c.max_pressure_mpa ?? '—'}</td>
<td>${statusBadge(c.status)}</td>
<td class="text-end">
${c.status === 'PENDING' ? `
<button class="btn btn-sm btn-outline-success me-1" onclick="verifyCaliber(${c.id})" title="Verify">
<i class="bi bi-check-lg"></i>
</button>
<button class="btn btn-sm btn-outline-danger me-1" onclick="rejectCaliber(${c.id})" title="Reject">
<i class="bi bi-x-lg"></i>
</button>` : ''}
<button class="btn btn-sm btn-outline-danger" onclick="deleteCaliber(${c.id})" title="Delete">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`).join('')
: '<tr><td colspan="6" class="text-muted text-center">No calibers yet.</td></tr>';
}
async function verifyCaliber(id) {
try {
await apiPost(`/calibers/${id}/verify/`, {});
const item = caliberData.find(c => c.id === id);
if (item) item.status = 'VERIFIED';
renderCalibersAdmin();
showToast('Caliber verified.');
} catch(e) { showToast('Failed to verify.', 'danger'); }
}
async function rejectCaliber(id) {
try {
await apiPost(`/calibers/${id}/reject/`, {});
const item = caliberData.find(c => c.id === id);
if (item) item.status = 'REJECTED';
renderCalibersAdmin();
showToast('Caliber rejected.');
} catch(e) { showToast('Failed to reject.', 'danger'); }
}
async function deleteCaliber(id) {
if (!confirm('Delete this caliber?')) return;
try {
await apiDelete(`/calibers/${id}/`);
caliberData = caliberData.filter(c => c.id !== id);
renderCalibersAdmin();
showToast('Deleted.');
} catch(e) { showToast('Failed to delete.', 'danger'); }
}
document.getElementById('calibersAddBtn').addEventListener('click', async () => {
const alertEl = document.getElementById('calibersAddAlert');
alertEl.classList.add('d-none');
const name = document.getElementById('calName').value.trim();
if (!name) {
alertEl.textContent = 'Name is required.';
alertEl.classList.remove('d-none');
return;
}
const payload = { name };
const short = document.getElementById('calShort').value.trim();
if (short) payload.short_name = short;
const caseLen = document.getElementById('calCase').value;
const oal = document.getElementById('calOAL').value;
const bulDia = document.getElementById('calBulletDia').value;
const pressure = document.getElementById('calPressure').value;
if (caseLen) payload.case_length_mm = parseFloat(caseLen);
if (oal) payload.overall_length_mm = parseFloat(oal);
if (bulDia) payload.bullet_diameter_mm = parseFloat(bulDia);
if (pressure) payload.max_pressure_mpa = parseFloat(pressure);
try {
// Admin creates calibers directly as verified via the verify action after creation
const created = await apiPost('/calibers/', payload);
await apiPost(`/calibers/${created.id}/verify/`, {});
created.status = 'VERIFIED';
caliberData.push(created);
renderCalibersAdmin();
['calName','calShort','calCase','calOAL','calBulletDia','calPressure'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
showToast('Caliber added.');
} catch(e) {
alertEl.textContent = formatErrors(e.data) || 'Failed to add.';
alertEl.classList.remove('d-none');
}
});
// ══ CREATE USER ══════════════════════════════════════════════════════════════
document.getElementById('createUserBtn').addEventListener('click', () => {
const panel = document.getElementById('createUserPanel');
panel.classList.toggle('d-none');
if (!panel.classList.contains('d-none')) {
document.getElementById('newUsername').focus();
}
});
document.getElementById('cancelCreateUserBtn').addEventListener('click', () => {
document.getElementById('createUserPanel').classList.add('d-none');
document.getElementById('createUserAlert').classList.add('d-none');
});
document.getElementById('saveCreateUserBtn').addEventListener('click', async () => {
const alertEl = document.getElementById('createUserAlert');
alertEl.classList.add('d-none');
const username = document.getElementById('newUsername').value.trim();
const email = document.getElementById('newEmail').value.trim();
const password = document.getElementById('newPassword').value;
const first_name = document.getElementById('newFirstName').value.trim();
const last_name = document.getElementById('newLastName').value.trim();
const is_staff = document.getElementById('newIsStaff').checked;
if (!username || !email || !password) {
alertEl.textContent = 'Username, email and password are required.';
alertEl.classList.remove('d-none');
return;
}
try {
const user = await apiPost('/users/admin/', { username, email, password, first_name, last_name, is_staff });
await loadUsers();
document.getElementById('createUserPanel').classList.add('d-none');
['newUsername','newEmail','newPassword','newFirstName','newLastName'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('newIsStaff').checked = false;
showToast(`User "${user.username}" created.`);
} catch(e) {
alertEl.textContent = formatErrors(e.data) || 'Failed to create user.';
alertEl.classList.remove('d-none');
}
});
// ── Init: load users tab (active by default) ──────────────────────────────
loadUsers();

132
frontend/js/api.js Normal file
View File

@@ -0,0 +1,132 @@
// ── API client ────────────────────────────────────────────────────────────────
const API_BASE = '/api';
function getLang() { return localStorage.getItem('lang') || 'en'; }
function setLang(lang) { localStorage.setItem('lang', lang); }
function getAccess() { return localStorage.getItem('access'); }
function getRefresh() { return localStorage.getItem('refresh'); }
function setTokens(access, refresh) {
localStorage.setItem('access', access);
if (refresh) localStorage.setItem('refresh', refresh);
}
function clearTokens() {
localStorage.removeItem('access');
localStorage.removeItem('refresh');
}
async function refreshAccess() {
const refresh = getRefresh();
if (!refresh) throw new Error('no refresh token');
const res = await fetch(`${API_BASE}/auth/token/refresh/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh }),
});
if (!res.ok) throw new Error('refresh failed');
const data = await res.json();
setTokens(data.access, data.refresh || refresh);
return data.access;
}
async function apiFetch(path, opts = {}) {
const headers = { ...opts.headers };
if (!(opts.body instanceof FormData)) {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
}
headers['Accept-Language'] = getLang();
const access = getAccess();
if (access) headers['Authorization'] = `Bearer ${access}`;
let res = await fetch(`${API_BASE}${path}`, { ...opts, headers });
if (res.status === 401) {
try {
const newAccess = await refreshAccess();
headers['Authorization'] = `Bearer ${newAccess}`;
res = await fetch(`${API_BASE}${path}`, { ...opts, headers });
} catch {
clearTokens();
window.location.href = '/login.html';
throw new Error('session expired');
}
}
return res;
}
// Convenience helpers ─────────────────────────────────────────────────────────
async function apiGet(path) {
const res = await apiFetch(path);
if (!res.ok) throw new Error(`GET ${path}${res.status}`);
return res.json();
}
async function apiPost(path, data) {
const body = data instanceof FormData ? data : JSON.stringify(data);
const res = await apiFetch(path, { method: 'POST', body });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
const e = new Error(`POST ${path}${res.status}`);
e.data = err; e.status = res.status;
throw e;
}
return res.status === 204 ? null : res.json();
}
async function apiPatch(path, data) {
const body = data instanceof FormData ? data : JSON.stringify(data);
const res = await apiFetch(path, { method: 'PATCH', body });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
const e = new Error(`PATCH ${path}${res.status}`);
e.data = err; e.status = res.status;
throw e;
}
return res.json();
}
async function apiDelete(path) {
const res = await apiFetch(path, { method: 'DELETE' });
if (!res.ok) throw new Error(`DELETE ${path}${res.status}`);
}
// Fetch ALL pages of a paginated endpoint, returning a flat array.
// path must be relative to API_BASE (e.g. '/calibers/?status=VERIFIED').
async function apiGetAll(path) {
const results = [];
let url = path;
while (url) {
const res = await apiFetch(url);
if (!res.ok) throw new Error(`GET ${url}${res.status}`);
const data = await res.json();
if (Array.isArray(data)) { results.push(...data); break; }
results.push(...(data.results || []));
if (data.next) {
// DRF returns absolute next URLs; strip origin + API_BASE to get a bare path
try {
const u = new URL(data.next);
url = u.pathname.slice(API_BASE.length) + u.search;
} catch { url = null; }
} else {
url = null;
}
}
return results;
}
// Unwrap a DRF response that may be a plain array or a paginated object
function asList(data) {
return Array.isArray(data) ? data : (data.results || []);
}
// Format API validation errors into a readable string
function formatErrors(errData) {
if (!errData || typeof errData !== 'object') return 'An error occurred.';
return Object.entries(errData)
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
.join('\n');
}

295
frontend/js/ballistics.js Normal file
View File

@@ -0,0 +1,295 @@
// ── Ballistic Calculator — point-mass trajectory engine ──────────────────────
// No server dependency. All computation is done in-browser.
// ── Drag tables [Mach, Cd_ref] ─────────────────────────────────────────────
// Cd values for the reference projectile (G7 or G1).
// Used in: a_drag = (rho/rho_std) * Cd(M) * v² / (2 * BC_SI)
// BC_SI (kg/m²) = BC_lb_in2 * 703.07
const DRAG_G7 = [
[0.00,0.1198],[0.05,0.1197],[0.10,0.1196],[0.20,0.1194],[0.30,0.1194],
[0.40,0.1194],[0.50,0.1194],[0.60,0.1193],[0.70,0.1194],[0.80,0.1193],
[0.83,0.1193],[0.86,0.1194],[0.88,0.1194],[0.90,0.1194],[0.92,0.1198],
[0.95,0.1210],[0.975,0.1215],[1.00,0.1212],[1.025,0.1206],[1.05,0.1202],
[1.10,0.1208],[1.15,0.1220],[1.20,0.1240],[1.30,0.1262],[1.40,0.1280],
[1.50,0.1283],[1.60,0.1290],[1.80,0.1297],[2.00,0.1298],[2.20,0.1295],
[2.50,0.1290],[3.00,0.1280],[4.00,0.1245],[5.00,0.1210],
];
const DRAG_G1 = [
[0.00,0.2629],[0.05,0.2558],[0.10,0.2568],[0.20,0.2581],[0.30,0.2578],
[0.40,0.2561],[0.50,0.2537],[0.60,0.2528],[0.65,0.2591],[0.70,0.2668],
[0.75,0.2760],[0.80,0.2983],[0.825,0.3163],[0.85,0.3285],[0.875,0.3449],
[0.90,0.3680],[0.925,0.4000],[0.95,0.4258],[0.975,0.4465],[1.00,0.4581],
[1.025,0.4547],[1.05,0.4420],[1.10,0.4152],[1.20,0.3736],[1.30,0.3449],
[1.40,0.3231],[1.50,0.3064],[1.60,0.2927],[1.80,0.2722],[2.00,0.2573],
[2.50,0.2313],[3.00,0.2144],[4.00,0.1875],[5.00,0.1695],
];
const G = 9.80665; // m/s²
const RHO_STD = 1.2250; // ICAO std sea-level density, kg/m³
const BC_CONV = 703.0696; // lb/in² → kg/m²
const SUBSONIC = 340; // m/s threshold for subsonic display
// ── Physics helpers ───────────────────────────────────────────────────────────
function dragCd(mach, table) {
if (mach <= table[0][0]) return table[0][1];
if (mach >= table[table.length - 1][0]) return table[table.length - 1][1];
for (let i = 1; i < table.length; i++) {
if (mach <= table[i][0]) {
const [m0, c0] = table[i - 1], [m1, c1] = table[i];
return c0 + (c1 - c0) * (mach - m0) / (m1 - m0);
}
}
return table[table.length - 1][1];
}
// Air density (kg/m³) — accounts for temp, pressure and humidity.
function airDensity(temp_c, press_hpa, humid_pct) {
const T = temp_c + 273.15;
const P = press_hpa * 100;
const Psat = 610.78 * Math.exp(17.27 * temp_c / (temp_c + 237.3));
const Pv = (humid_pct / 100) * Psat;
return (P - Pv) / (287.058 * T) + Pv / (461.495 * T);
}
// Pressure at altitude h (m) via ISA lapse rate.
function pressureAtAltitude(h) {
return 1013.25 * Math.pow(1 - 0.0065 * h / 288.15, 5.2561);
}
// Speed of sound (m/s) at temperature.
function speedOfSound(temp_c) {
return 331.3 * Math.sqrt(1 + temp_c / 273.15);
}
// ── Trajectory integration (Euler, dt = 0.5 ms) ──────────────────────────────
//
// Coordinate system:
// x — range (forward)
// y — vertical (up = positive)
// z — lateral (right = positive)
//
// wind_cross_ms: effective lateral wind component.
// positive = wind FROM the right → bullet drifts LEFT (z becomes negative)
//
// Returns array of { x, y, z, v, t } sampled every step_m meters of range.
function integrate(v0, theta, bc_si, rho, sound, dragTable, scopeH_m, wind_cross_ms, step_m, max_m) {
const dt = 0.0005;
let x = 0, y = -scopeH_m, z = 0;
let vx = v0 * Math.cos(theta), vy = v0 * Math.sin(theta), vz = 0;
let t = 0;
let nextRecord = 0;
const pts = [];
while (x <= max_m + step_m * 0.5 && t < 15) {
if (x >= nextRecord - 1e-6) {
pts.push({ x: nextRecord, y, z, v: Math.hypot(vx, vy), t });
nextRecord += step_m;
}
const vb = Math.hypot(vx, vy);
const mach = vb / sound;
const cd = dragCd(mach, dragTable);
const k = (rho / RHO_STD) * cd / (2 * bc_si);
// Drag decelerates bullet along its velocity vector; wind creates lateral drag.
// az = -k*vb*(vz + wind_cross_ms) [wind from right → bullet pushed left]
const ax = -k * vb * vx;
const ay = -k * vb * vy - G;
const az = -k * vb * (vz + wind_cross_ms);
vx += ax * dt; vy += ay * dt; vz += az * dt;
x += vx * dt; y += vy * dt; z += vz * dt;
t += dt;
if (vb < 50) break;
}
return pts;
}
// Binary-search for the elevation angle (rad) that gives y = 0 at zero_m.
function findZeroAngle(v0, zero_m, bc_si, rho, sound, dragTable, scopeH_m, wind_cross_ms) {
let lo = -0.01, hi = 0.15;
for (let i = 0; i < 60; i++) {
const mid = (lo + hi) / 2;
const pts = integrate(v0, mid, bc_si, rho, sound, dragTable, scopeH_m, wind_cross_ms, zero_m, zero_m);
const y = pts.length ? pts[pts.length - 1].y : -999;
if (y > 0) hi = mid; else lo = mid;
}
return (lo + hi) / 2;
}
// ── Angular conversion helpers ────────────────────────────────────────────────
function moaFromMm(dist_m, mm) { return dist_m > 0 ? mm / (dist_m * 0.29089) : null; }
function mradFromMm(dist_m, mm) { return dist_m > 0 ? mm / (dist_m * 0.1) : null; }
function angFromMm(dist_m, mm, unit) {
return unit === 'MOA' ? moaFromMm(dist_m, mm) : mradFromMm(dist_m, mm);
}
function toMeters(val, unit) { return unit === 'yd' ? val * 0.9144 : val; }
function fmt(n, dec, showSign = false) {
if (n === null || n === undefined || isNaN(n)) return '—';
const s = Math.abs(n).toFixed(dec);
if (showSign) return (n >= 0 ? '+' : '') + s;
return s;
}
// ── Main entry point ──────────────────────────────────────────────────────────
function calculate() {
const alertEl = document.getElementById('calcAlert');
alertEl.classList.add('d-none');
// Read + validate inputs
const v0Raw = parseFloat(document.getElementById('v0').value);
const v0Unit = document.getElementById('v0Unit').value;
const bcRaw = parseFloat(document.getElementById('bc').value);
const bcModel = document.getElementById('dragModel').value;
const wtGr = parseFloat(document.getElementById('bulletWeight').value) || null;
const zeroRaw = parseFloat(document.getElementById('zeroDist').value);
const zeroUnit = document.getElementById('zeroUnit').value;
const scopeHMm = parseFloat(document.getElementById('scopeHeight').value) || 50;
let tempRaw = parseFloat(document.getElementById('temp').value);
const tempUnit = document.getElementById('tempUnit').value;
const press = parseFloat(document.getElementById('pressure').value) || 1013.25;
const humid = parseFloat(document.getElementById('humidity').value) || 50;
const altM = parseFloat(document.getElementById('altitude').value) || 0;
const windSpdRaw = parseFloat(document.getElementById('windSpeed').value) || 0;
const windUnit = document.getElementById('windUnit').value;
const windDirDeg = parseFloat(document.getElementById('windDir').value) || 0;
const outUnit = document.getElementById('outputUnit').value;
const distFrom = parseFloat(document.getElementById('distFrom').value) || 0;
const distTo = parseFloat(document.getElementById('distTo').value) || 800;
const distStep = parseFloat(document.getElementById('distStep').value) || 25;
if (!v0Raw || v0Raw <= 0) return showAlert('Muzzle velocity is required and must be > 0.');
if (!bcRaw || bcRaw <= 0) return showAlert('Ballistic coefficient is required and must be > 0.');
if (!zeroRaw || zeroRaw<=0) return showAlert('Zero distance is required and must be > 0.');
if (distTo <= distFrom) return showAlert('"To" must be greater than "From".');
if (distTo > 3000) return showAlert('Maximum distance is 3000 m/yd.');
// Convert to SI
const v0 = v0Unit === 'fps' ? v0Raw * 0.3048 : v0Raw;
const zero_m = toMeters(zeroRaw, zeroUnit);
const max_m = toMeters(distTo, zeroUnit);
const from_m = toMeters(distFrom, zeroUnit);
const step_m = toMeters(distStep, zeroUnit);
const scopeH = scopeHMm / 1000;
if (tempUnit === 'f') tempRaw = (tempRaw - 32) * 5 / 9;
// Wind: convert speed to m/s, compute crosswind component
let windMs = windSpdRaw;
if (windUnit === 'kph') windMs /= 3.6;
else if (windUnit === 'mph') windMs *= 0.44704;
// Positive windCross = wind FROM the right → bullet drifts left (z < 0)
const windCross = windMs * Math.sin(windDirDeg * Math.PI / 180);
// Atmosphere
const pressEff = (altM > 0 && press === 1013.25) ? pressureAtAltitude(altM) : press;
const rho = airDensity(tempRaw, pressEff, humid);
const sound = speedOfSound(tempRaw);
const bc_si = bcRaw * BC_CONV;
const table = bcModel === 'G7' ? DRAG_G7 : DRAG_G1;
// Find zero elevation angle, then compute full trajectory
const theta = findZeroAngle(v0, zero_m, bc_si, rho, sound, table, scopeH, windCross);
const pts = integrate(v0, theta, bc_si, rho, sound, table, scopeH, windCross, step_m, max_m + step_m);
const shown = pts.filter(p => p.x >= from_m - 0.1 && p.x <= max_m + 0.1);
if (!shown.length) return showAlert('No trajectory points in the requested range.');
renderResults(shown, { zero_m, zeroUnit, v0Raw, v0Unit, outUnit, wtGr, rho, sound });
}
function showAlert(msg) {
const el = document.getElementById('calcAlert');
el.textContent = msg;
el.classList.remove('d-none');
}
// ── Render trajectory table ───────────────────────────────────────────────────
function renderResults(pts, { zero_m, zeroUnit, v0Raw, v0Unit, outUnit, wtGr, rho, sound }) {
document.getElementById('resultsPlaceholder').classList.add('d-none');
document.getElementById('resultsSection').classList.remove('d-none');
const distLabel = zeroUnit === 'yd' ? 'yd' : 'm';
const velLabel = v0Unit === 'fps' ? 'fps' : 'm/s';
const uLabel = outUnit;
document.getElementById('trajHead').innerHTML = [
`<th>${distLabel}</th>`,
`<th>${velLabel}</th>`,
wtGr !== null ? '<th>E (J)</th>' : '',
'<th>TOF (s)</th>',
`<th>Drop (mm)</th>`,
`<th>Drop (${uLabel})</th>`,
`<th>Wind (mm)</th>`,
`<th>Wind (${uLabel})</th>`,
].join('');
// Find first subsonic distance
const subPt = pts.find(p => p.v < SUBSONIC);
const rows = pts.map(p => {
const distDisp = zeroUnit === 'yd'
? (p.x / 0.9144).toFixed(0)
: p.x.toFixed(0);
const vel = v0Unit === 'fps' ? p.v / 0.3048 : p.v;
const isZero = Math.abs(p.x - zero_m) < 0.5;
const isSub = p.v < SUBSONIC;
// Drop: y is negative when bullet is below LOS.
// We show how much bullet is BELOW (positive = drop below), correction sign = hold up (+).
const dropMm = p.y * 1000; // negative = below LOS
const dropCorr = angFromMm(p.x, -dropMm, outUnit); // positive = aim up / dial up
// Wind: z is negative when bullet drifts left (wind from right).
const windMm = p.z * 1000;
const windAbs = Math.abs(windMm);
const windDir = windMm < -0.5 ? 'L' : windMm > 0.5 ? 'R' : '';
const windCorr = windAbs > 0.5 ? angFromMm(p.x, windAbs, outUnit) : null;
// Energy
let eTd = '';
if (wtGr !== null) {
const mass_kg = wtGr / 15432.4;
const ke = 0.5 * mass_kg * p.v * p.v;
eTd = `<td>${ke.toFixed(0)}</td>`;
}
return `<tr class="${isZero ? 'zero-row' : ''}${isSub ? ' subsonic' : ''}">
<td>${distDisp}${isZero ? ' ★' : ''}</td>
<td>${vel.toFixed(0)}</td>
${eTd}
<td>${p.t.toFixed(3)}</td>
<td>${fmt(dropMm, 0, true)}</td>
<td>${fmt(dropCorr, 2, true)}</td>
<td>${windAbs < 0.5 ? '—' : fmt(windAbs, 0) + ' ' + windDir}</td>
<td>${windAbs < 0.5 || windCorr === null ? '—' : fmt(windCorr, 2) + ' ' + windDir}</td>
</tr>`;
});
document.getElementById('trajBody').innerHTML = rows.join('');
// Summary line
document.getElementById('resultsSummary').innerHTML =
`ρ = ${rho.toFixed(4)} kg/m³ | Speed of sound = ${sound.toFixed(1)} m/s` +
(subPt ? ` | Subsonic at ~${subPt.x.toFixed(0)} m` : '');
}
// ── Keep distance step unit label in sync ────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
const zeroUnitSel = document.getElementById('zeroUnit');
const stepLabel = document.getElementById('distStepUnit');
if (zeroUnitSel && stepLabel) {
zeroUnitSel.addEventListener('change', () => {
stepLabel.textContent = zeroUnitSel.value;
});
}
});

76
frontend/js/calibers.js Normal file
View File

@@ -0,0 +1,76 @@
// ── Shared caliber picker utilities ───────────────────────────────────────────
// Requires api.js loaded first.
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
async function loadCalibersIntoSelect(selectEl) {
selectEl.innerHTML = '<option value="">Loading calibers…</option>';
try {
const items = await apiGetAll('/calibers/?status=VERIFIED&page_size=1000');
selectEl.innerHTML = '<option value="">— Select caliber —</option>' +
items.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
} catch(e) {
selectEl.innerHTML = '<option value="">— Calibers unavailable —</option>';
}
}
// Returns HTML for a caliber <select> + inline suggest sub-form.
// selectId: id attribute for the <select>
// suggestId: id prefix for the suggest sub-form elements
function buildCaliberPickerHtml(selectId, suggestId) {
return `
<select class="form-select form-select-sm" id="${selectId}">
<option value="">Loading…</option>
</select>
<div class="mt-1">
<a href="#" class="small text-primary" onclick="event.preventDefault(); document.getElementById('${suggestId}').classList.toggle('d-none')">
<i class="bi bi-plus-circle me-1"></i>Suggest new caliber
</a>
<div id="${suggestId}" class="d-none mt-1 p-2 border rounded bg-light small">
<div class="row g-1 mb-1">
<div class="col"><input type="text" class="form-control form-control-sm" id="${suggestId}Name" placeholder="Name *"></div>
<div class="col"><input type="text" class="form-control form-control-sm" id="${suggestId}Short" placeholder="Short name (opt)"></div>
</div>
<div id="${suggestId}Alert" class="alert alert-danger d-none py-1 small mb-1"></div>
<button class="btn btn-sm btn-outline-primary w-100"
onclick="addCaliberSuggestion('${suggestId}', '${selectId}')">
<i class="bi bi-send me-1"></i>Submit
</button>
</div>
</div>`;
}
async function addCaliberSuggestion(suggestId, selectId) {
const alertEl = document.getElementById(suggestId + 'Alert');
alertEl.classList.add('d-none');
const name = document.getElementById(suggestId + 'Name')?.value?.trim();
const short = document.getElementById(suggestId + 'Short')?.value?.trim();
if (!name) {
alertEl.textContent = 'Caliber name is required.';
alertEl.classList.remove('d-none');
return;
}
try {
const payload = { name };
if (short) payload.short_name = short;
const created = await apiPost('/calibers/', payload);
const sel = document.getElementById(selectId);
if (sel) {
const opt = new Option(created.name, created.id, true, true);
sel.appendChild(opt);
}
document.getElementById(suggestId).classList.add('d-none');
document.getElementById(suggestId + 'Name').value = '';
document.getElementById(suggestId + 'Short').value = '';
} catch(e) {
alertEl.textContent = (e.data && formatErrors(e.data)) || 'Submission failed.';
alertEl.classList.remove('d-none');
}
}

448
frontend/js/chrono.js Normal file
View File

@@ -0,0 +1,448 @@
// ── Chronograph Analyser page logic ───────────────────────────────────────────
let sessions = [];
let currentSessionId = null;
let _photoTargetGroupId = null; // group id for current photo upload modal
// showToast, renderPublicToggle → utils.js
// ── Sessions list ─────────────────────────────────────────────────────────────
async function loadSessions() {
try {
sessions = await apiGetAll('/tools/chronograph/');
renderSessionsList();
} catch(e) {
document.getElementById('sessionsSpinner').innerHTML =
'<p class="text-danger small">Failed to load sessions.</p>';
}
}
function renderSessionsList() {
document.getElementById('sessionsSpinner').classList.add('d-none');
const list = document.getElementById('sessionsList');
const empty = document.getElementById('sessionsEmpty');
if (!sessions.length) {
empty.classList.remove('d-none');
list.innerHTML = '';
return;
}
empty.classList.add('d-none');
list.innerHTML = sessions.map(s => `
<div class="session-item mb-1 ${s.id === currentSessionId ? 'active' : ''}"
onclick="selectSession(${s.id})">
<div class="fw-semibold small">${s.name}</div>
<div class="text-muted small">${s.date || ''}</div>
</div>
`).join('');
}
// ── Select & display a session ────────────────────────────────────────────────
async function selectSession(id) {
currentSessionId = id;
renderSessionsList();
document.getElementById('noSessionMsg').classList.add('d-none');
document.getElementById('analysisContent').classList.remove('d-none');
document.getElementById('chartsSpinner').classList.remove('d-none');
document.getElementById('chartsContent').classList.add('d-none');
const session = sessions.find(s => s.id === id);
document.getElementById('sessionTitle').textContent = session?.name || 'Session';
document.getElementById('sessionDate').textContent = session?.date || '';
renderPublicToggle(session?.is_public || false);
try {
// Fetch detail (includes groups + per-group stats via serializer)
const detail = await apiGet(`/tools/chronograph/${id}/`);
// Fetch chart images
const charts = await apiGet(`/tools/chronograph/${id}/charts/`);
renderAnalysis(detail, charts);
} catch(e) {
document.getElementById('chartsSpinner').innerHTML =
'<p class="text-danger text-center py-4">Failed to load analysis.</p>';
}
}
function fmtFps(v) {
return v != null ? `${Number(v).toFixed(1)} fps` : '—';
}
function renderAnalysis(detail, charts) {
document.getElementById('chartsSpinner').classList.add('d-none');
document.getElementById('chartsContent').classList.remove('d-none');
// Overall stats from all shots
const allShots = (detail.shot_groups || []).flatMap(g => g.shots || []);
const speeds = allShots.map(s => parseFloat(s.velocity_fps)).filter(v => !isNaN(v));
const overallStats = computeStats(speeds);
document.getElementById('overallStatsWrap').innerHTML = renderStatsTable(overallStats);
// Overview chart
const overviewImg = document.getElementById('overviewChart');
if (charts.overview) {
overviewImg.src = `data:image/png;base64,${charts.overview}`;
overviewImg.style.display = '';
} else {
overviewImg.style.display = 'none';
}
// Per-group sections
const groups = detail.shot_groups || [];
const groupCharts = charts.groups || [];
const groupHtml = groups.map((g, i) => {
const gSpeeds = (g.shots || []).map(s => parseFloat(s.velocity_fps)).filter(v => !isNaN(v));
const gStats = computeStats(gSpeeds);
const chartB64 = groupCharts[i];
return `
<div class="card border-0 shadow-sm mb-3" id="group-card-${g.id}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<h6 class="fw-semibold mb-0">
<i class="bi bi-collection me-1"></i>
${g.label || 'Group ' + (i+1)}
<span class="text-muted fw-normal ms-2 small">${g.distance_m ? g.distance_m + ' m' : ''}</span>
</h6>
<span class="badge bg-secondary">${gSpeeds.length} shots</span>
</div>
<div class="row g-3">
<div class="col-md-5">
${renderStatsTable(gStats)}
</div>
<div class="col-md-7">
${chartB64
? `<img src="data:image/png;base64,${chartB64}" class="group-chart w-100" alt="Group chart">`
: '<p class="text-muted small">No chart available.</p>'}
</div>
</div>
<!-- Photos section -->
<div class="mt-3 pt-2 border-top">
<div class="d-flex align-items-center mb-2">
<span class="small fw-semibold text-muted"><i class="bi bi-images me-1"></i>Target photo</span>
</div>
<div id="photos-${g.id}">
<span class="text-muted small">Loading…</span>
</div>
</div>
</div>
</div>`;
}).join('');
document.getElementById('groupSections').innerHTML = groupHtml ||
'<p class="text-muted">No shot groups in this session.</p>';
// Load photos for each group asynchronously
groups.forEach(g => loadGroupPhotos(g.id));
}
function computeStats(speeds) {
if (!speeds.length) return null;
const n = speeds.length;
const avg = speeds.reduce((a,b) => a+b, 0) / n;
const min = Math.min(...speeds);
const max = Math.max(...speeds);
const es = max - min;
const sd = n > 1
? Math.sqrt(speeds.reduce((s,v) => s + (v-avg)**2, 0) / (n-1))
: null;
return { n, avg, min, max, es, sd };
}
function renderStatsTable(stats) {
if (!stats) return '<p class="text-muted small">No data.</p>';
const sdText = stats.sd != null ? `${stats.sd.toFixed(1)} fps` : '—';
return `
<table class="table table-sm stat-table mb-0">
<tbody>
<tr><td>Shots</td><td>${stats.n}</td></tr>
<tr><td>Average</td><td>${stats.avg.toFixed(1)} fps</td></tr>
<tr><td>Min</td><td>${stats.min.toFixed(1)} fps</td></tr>
<tr><td>Max</td><td>${stats.max.toFixed(1)} fps</td></tr>
<tr><td>Extreme spread (ES)</td><td>${stats.es.toFixed(1)} fps</td></tr>
<tr><td>Std deviation (SD)</td><td>${sdText}</td></tr>
</tbody>
</table>`;
}
// ── Public toggle (renderPublicToggle → utils.js) ────────────────────────────
document.getElementById('togglePublicBtn').addEventListener('click', async () => {
if (!currentSessionId) return;
const session = sessions.find(s => s.id === currentSessionId);
if (!session) return;
const newVal = !session.is_public;
try {
await apiPatch(`/tools/chronograph/${currentSessionId}/`, { is_public: newVal });
session.is_public = newVal;
renderPublicToggle(newVal);
showToast(newVal ? 'Analysis is now public.' : 'Analysis is now private.');
} catch(e) {
showToast('Failed to update visibility.', 'danger');
}
});
// ── Delete session ────────────────────────────────────────────────────────────
document.getElementById('deleteSessionBtn').addEventListener('click', async () => {
if (!currentSessionId) return;
if (!confirm('Delete this session and all its data?')) return;
try {
await apiDelete(`/tools/chronograph/${currentSessionId}/`);
sessions = sessions.filter(s => s.id !== currentSessionId);
currentSessionId = null;
document.getElementById('noSessionMsg').classList.remove('d-none');
document.getElementById('analysisContent').classList.add('d-none');
renderSessionsList();
showToast('Session deleted.');
} catch(e) {
showToast('Failed to delete session.', 'danger');
}
});
// ── Download PDF ──────────────────────────────────────────────────────────────
document.getElementById('downloadPdfBtn').addEventListener('click', () => {
if (!currentSessionId) return;
window.open(`/api/tools/chronograph/${currentSessionId}/report.pdf`, '_blank');
});
// ── Upload CSV ────────────────────────────────────────────────────────────────
const uploadModal = new bootstrap.Modal('#uploadModal');
document.getElementById('uploadBtn').addEventListener('click', () => {
document.getElementById('uploadName').value = '';
document.getElementById('uploadDate').value = new Date().toISOString().slice(0,10);
document.getElementById('csvFileInput').value = '';
document.getElementById('selectedFileName').classList.add('d-none');
document.getElementById('uploadAlert').classList.add('d-none');
document.getElementById('uploadSubmitBtn').disabled = true;
uploadModal.show();
});
// Drag & drop / click on drop area
const dropArea = document.getElementById('dropArea');
dropArea.addEventListener('click', () => document.getElementById('csvFileInput').click());
dropArea.addEventListener('dragover', e => { e.preventDefault(); dropArea.classList.add('drag-over'); });
dropArea.addEventListener('dragleave', () => dropArea.classList.remove('drag-over'));
dropArea.addEventListener('drop', e => {
e.preventDefault();
dropArea.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) setSelectedFile(file);
});
document.getElementById('csvFileInput').addEventListener('change', e => {
const file = e.target.files[0];
if (file) setSelectedFile(file);
});
function setSelectedFile(file) {
const nameEl = document.getElementById('selectedFileName');
nameEl.textContent = `Selected: ${file.name}`;
nameEl.classList.remove('d-none');
document.getElementById('uploadSubmitBtn').disabled = false;
// Pre-fill session name from filename if empty
const nameInput = document.getElementById('uploadName');
if (!nameInput.value) {
nameInput.value = file.name.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
}
}
document.getElementById('uploadSubmitBtn').addEventListener('click', async () => {
const alertEl = document.getElementById('uploadAlert');
const spinner = document.getElementById('uploadSpinnerInline');
const submitBtn = document.getElementById('uploadSubmitBtn');
alertEl.classList.add('d-none');
const file = document.getElementById('csvFileInput').files[0];
if (!file) {
alertEl.textContent = 'Please select a CSV file.';
alertEl.classList.remove('d-none');
return;
}
const formData = new FormData();
formData.append('file', file);
const name = document.getElementById('uploadName').value.trim();
const date = document.getElementById('uploadDate').value;
const velUnit = document.querySelector('input[name="uploadVelUnit"]:checked')?.value || 'fps';
const chronoType = document.getElementById('uploadChronoType').value;
if (name) formData.append('name', name);
if (date) formData.append('date', date);
formData.append('velocity_unit', velUnit);
formData.append('chrono_type', chronoType);
submitBtn.disabled = true;
spinner.classList.remove('d-none');
try {
const session = await apiFetch('/tools/chronograph/upload/', {
method: 'POST',
body: formData,
}).then(async r => {
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw Object.assign(new Error('Upload failed'), { data: err });
}
return r.json();
});
sessions.unshift(session);
renderSessionsList();
uploadModal.hide();
selectSession(session.id);
showToast('Session uploaded and analysed!');
} catch(e) {
alertEl.textContent = (e.data && (e.data.detail || JSON.stringify(e.data))) || 'Upload failed.';
alertEl.classList.remove('d-none');
submitBtn.disabled = false;
} finally {
spinner.classList.add('d-none');
}
});
// ── Photos per group (one per group) ──────────────────────────────────────────
const photoModal = new bootstrap.Modal('#photoModal');
// Track existing GroupPhoto id for the current modal (null = no existing photo)
let _existingGroupPhotoId = null;
function openPhotoModal(groupId, existingGroupPhotoId = null) {
_photoTargetGroupId = groupId;
_existingGroupPhotoId = existingGroupPhotoId;
document.getElementById('photoFileInput').value = '';
document.getElementById('photoCaption').value = '';
document.getElementById('photoAlert').classList.add('d-none');
const title = document.querySelector('#photoModal .modal-title');
if (title) title.innerHTML = existingGroupPhotoId
? '<i class="bi bi-image me-2"></i>Replace group photo'
: '<i class="bi bi-image me-2"></i>Add photo to group';
photoModal.show();
}
async function loadGroupPhotos(groupId) {
const container = document.getElementById(`photos-${groupId}`);
if (!container) return;
try {
const _gpData = await apiGet(`/photos/group-photos/?shot_group=${groupId}`);
const items = asList(_gpData);
if (!items.length) {
container.innerHTML = `
<button class="btn btn-sm btn-outline-secondary py-0 px-2"
onclick="openPhotoModal(${groupId})">
<i class="bi bi-plus-lg me-1"></i>Add photo
</button>`;
return;
}
// One photo per group — use first result
const gp = items[0];
const analysisHtml = gp.analysis ? (() => {
const esMm = gp.analysis.group_size_mm != null ? parseFloat(gp.analysis.group_size_mm).toFixed(1) + ' mm' : '—';
const esMoa = gp.analysis.group_size_moa != null ? ' / ' + parseFloat(gp.analysis.group_size_moa).toFixed(2) + ' MOA' : '';
return `<div class="small text-muted mt-1">ES: ${esMm}${esMoa}</div>`;
})() : '';
const hasPois = (gp.points_of_impact || []).length >= 2;
container.innerHTML = `
<div class="d-flex align-items-start gap-3">
<img src="/api/photos/${gp.photo.id}/data/"
style="width:120px;height:90px;object-fit:cover;border-radius:6px;cursor:pointer"
title="${gp.caption || ''}"
onclick="window.open('/api/photos/${gp.photo.id}/data/','_blank')">
<div class="d-flex flex-column gap-1">
${gp.caption ? `<span class="small text-muted">${gp.caption}</span>` : ''}
${analysisHtml}
<a href="/group-size.html?gp=${gp.id}" class="btn btn-xs btn-outline-primary py-0 px-2 small">
<i class="bi bi-crosshair2 me-1"></i>Measure group
</a>
${hasPois ? `<button class="btn btn-xs btn-outline-secondary py-0 px-1 small"
onclick="computeGroupSize(${gp.id}, ${groupId})">
Recompute
</button>` : ''}
<button class="btn btn-xs btn-outline-secondary py-0 px-2 mt-1"
onclick="openPhotoModal(${groupId}, ${gp.id})">
<i class="bi bi-arrow-repeat me-1"></i>Replace
</button>
</div>
</div>`;
} catch(e) {
if (container) container.innerHTML = '<span class="text-danger small">Failed to load photos.</span>';
}
}
document.getElementById('photoSubmitBtn').addEventListener('click', async () => {
const alertEl = document.getElementById('photoAlert');
const spinner = document.getElementById('photoSpinner');
const submitBtn = document.getElementById('photoSubmitBtn');
alertEl.classList.add('d-none');
const file = document.getElementById('photoFileInput').files[0];
if (!file) {
alertEl.textContent = 'Please select an image file.';
alertEl.classList.remove('d-none');
return;
}
submitBtn.disabled = true;
spinner.classList.remove('d-none');
try {
// Step 1: if replacing, delete the old GroupPhoto first
if (_existingGroupPhotoId) {
await apiDelete(`/photos/group-photos/${_existingGroupPhotoId}/`);
}
// Step 2: upload raw photo
const formData = new FormData();
formData.append('file', file);
const photo = await apiFetch('/photos/upload/', { method: 'POST', body: formData })
.then(async r => {
if (!r.ok) { const e = await r.json().catch(() => ({})); throw Object.assign(new Error('Upload failed'), { data: e }); }
return r.json();
});
// Step 3: link to group
const caption = document.getElementById('photoCaption').value.trim();
await apiPost('/photos/group-photos/', {
photo_id: photo.id,
shot_group: _photoTargetGroupId,
...(caption ? { caption } : {}),
});
photoModal.hide();
loadGroupPhotos(_photoTargetGroupId);
showToast(_existingGroupPhotoId ? 'Photo replaced.' : 'Photo added.');
} catch(e) {
alertEl.textContent = (e.data && (e.data.detail || JSON.stringify(e.data))) || 'Upload failed.';
alertEl.classList.remove('d-none');
} finally {
submitBtn.disabled = false;
spinner.classList.add('d-none');
}
});
async function computeGroupSize(groupPhotoId, groupId) {
try {
await apiPost(`/photos/group-photos/${groupPhotoId}/compute-group-size/`, {});
loadGroupPhotos(groupId);
showToast('Group size computed.');
} catch(e) {
showToast('Failed to compute group size.', 'danger');
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
async function init() {
await loadSessions();
const params = new URLSearchParams(window.location.search);
const id = parseInt(params.get('id'), 10);
if (id && sessions.find(s => s.id === id)) selectSession(id);
}
init();

280
frontend/js/friends.js Normal file
View File

@@ -0,0 +1,280 @@
// ── Friends page ──────────────────────────────────────────────────────────────
const addFriendModal = new bootstrap.Modal('#addFriendModal');
let _currentTab = 'friends';
// ── Tab switching ─────────────────────────────────────────────────────────────
document.querySelectorAll('#friendTabs .nav-link').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#friendTabs .nav-link').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_currentTab = btn.dataset.tab;
document.getElementById('tabFriends').classList.toggle('d-none', _currentTab !== 'friends');
document.getElementById('tabRequests').classList.toggle('d-none', _currentTab !== 'requests');
document.getElementById('tabSent').classList.toggle('d-none', _currentTab !== 'sent');
});
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function initials(detail) {
const name = detail?.display_name || detail?.username || '?';
return name.slice(0, 2).toUpperCase();
}
function displayName(detail) {
return esc(detail?.display_name || detail?.username || '—');
}
// ── Friends ───────────────────────────────────────────────────────────────────
async function loadFriends() {
try {
const data = await apiGet('/social/friends/');
const grid = document.getElementById('friendsGrid');
const count = document.getElementById('friendsCount');
count.textContent = data.length;
if (!data.length) {
grid.innerHTML = '<div class="col-12 text-center text-muted py-5"><i class="bi bi-people" style="font-size:2.5rem;opacity:.2;"></i><p class="mt-2 mb-0">No friends yet. Use "Add friend" to get started.</p></div>';
return;
}
// We need to know which side is "us" — resolved via unread-count trick:
// actually we can figure it out since from_user or to_user is the current user.
// But we don't have current user id here. Let's show both sides gracefully.
grid.innerHTML = data.map(f => {
// Show the "other" person — API returns both sides; we show both names
const fromD = f.from_user_detail;
const toD = f.to_user_detail;
return `
<div class="col-sm-6 col-md-4 col-lg-3">
<div class="card border-0 shadow-sm friend-card h-100">
<div class="card-body d-flex flex-column gap-2">
<div class="d-flex align-items-center gap-3">
<div class="avatar-lg">${initials(fromD)}</div>
<div class="overflow-hidden">
<div class="fw-semibold text-truncate">${displayName(fromD)}</div>
<div class="text-muted small">@${esc(fromD?.username || '—')}</div>
</div>
</div>
<div class="d-flex align-items-center gap-3">
<div class="avatar-lg" style="background:#e8f5e9;color:#2e7d32;">${initials(toD)}</div>
<div class="overflow-hidden">
<div class="fw-semibold text-truncate">${displayName(toD)}</div>
<div class="text-muted small">@${esc(toD?.username || '—')}</div>
</div>
</div>
<div class="mt-auto d-flex gap-2">
<button class="btn btn-sm btn-outline-danger flex-grow-1"
onclick="unfriend(${f.id})">
<i class="bi bi-person-dash me-1"></i>Unfriend
</button>
<a class="btn btn-sm btn-outline-primary" href="/messages.html"
title="Send message"><i class="bi bi-envelope"></i></a>
</div>
</div>
</div>
</div>`;
}).join('');
} catch (e) {
document.getElementById('friendsGrid').innerHTML =
'<div class="col-12 text-danger text-center py-3 small">Failed to load friends.</div>';
}
}
async function unfriend(id) {
if (!confirm('Remove this friendship?')) return;
try {
await apiDelete(`/social/friends/${id}/`);
showToast('Friendship removed.');
loadFriends();
} catch (e) {
showToast('Failed to remove friendship.', 'danger');
}
}
// ── Incoming requests ─────────────────────────────────────────────────────────
async function loadRequests() {
try {
const data = await apiGet('/social/friends/requests/');
const el = document.getElementById('requestsList');
const badge = document.getElementById('requestsCount');
if (data.length > 0) {
badge.textContent = data.length;
badge.classList.remove('d-none');
} else {
badge.classList.add('d-none');
}
if (!data.length) {
el.innerHTML = '<div class="text-center text-muted py-5"><p class="mb-0">No pending requests.</p></div>';
return;
}
el.innerHTML = data.map(f => `
<div class="req-card d-flex align-items-center gap-3 mb-2" id="req-${f.id}">
<div class="avatar-lg">${initials(f.from_user_detail)}</div>
<div class="flex-grow-1 overflow-hidden">
<div class="fw-semibold text-truncate">${displayName(f.from_user_detail)}</div>
<div class="text-muted small">@${esc(f.from_user_detail?.username || '—')}</div>
</div>
<div class="d-flex gap-2 flex-shrink-0">
<button class="btn btn-sm btn-success" onclick="acceptRequest(${f.id})">
<i class="bi bi-check-lg"></i> Accept
</button>
<button class="btn btn-sm btn-outline-danger" onclick="declineRequest(${f.id})">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>`
).join('');
} catch (e) {
document.getElementById('requestsList').innerHTML =
'<div class="text-danger text-center py-3 small">Failed to load requests.</div>';
}
}
async function acceptRequest(id) {
try {
await apiPost(`/social/friends/${id}/accept/`, {});
showToast('Friend request accepted!');
document.getElementById(`req-${id}`)?.remove();
loadFriends();
loadRequests();
} catch (e) {
showToast('Failed to accept request.', 'danger');
}
}
async function declineRequest(id) {
try {
await apiPost(`/social/friends/${id}/decline/`, {});
showToast('Request declined.');
document.getElementById(`req-${id}`)?.remove();
loadRequests();
} catch (e) {
showToast('Failed to decline request.', 'danger');
}
}
// ── Sent requests ─────────────────────────────────────────────────────────────
async function loadSentRequests() {
try {
const data = await apiGet('/social/friends/sent-requests/');
const el = document.getElementById('sentList');
const badge = document.getElementById('sentCount');
if (data.length > 0) {
badge.textContent = data.length;
badge.classList.remove('d-none');
} else {
badge.classList.add('d-none');
}
if (!data.length) {
el.innerHTML = '<div class="text-center text-muted py-5"><p class="mb-0">No sent requests.</p></div>';
return;
}
el.innerHTML = data.map(f => `
<div class="req-card d-flex align-items-center gap-3 mb-2" id="sent-${f.id}">
<div class="avatar-lg" style="background:#fff3cd;color:#856404;">${initials(f.to_user_detail)}</div>
<div class="flex-grow-1 overflow-hidden">
<div class="fw-semibold text-truncate">${displayName(f.to_user_detail)}</div>
<div class="text-muted small">@${esc(f.to_user_detail?.username || '—')}</div>
</div>
<span class="badge bg-warning text-dark me-2">Pending</span>
<button class="btn btn-sm btn-outline-secondary" onclick="cancelRequest(${f.id})">
Cancel
</button>
</div>`
).join('');
} catch (e) {
document.getElementById('sentList').innerHTML =
'<div class="text-danger text-center py-3 small">Failed to load sent requests.</div>';
}
}
async function cancelRequest(id) {
if (!confirm('Cancel this friend request?')) return;
try {
await apiPost(`/social/friends/${id}/decline/`, {});
showToast('Request cancelled.');
document.getElementById(`sent-${id}`)?.remove();
loadSentRequests();
} catch (e) {
showToast('Failed to cancel request.', 'danger');
}
}
// ── Add friend modal ──────────────────────────────────────────────────────────
document.getElementById('addFriendBtn').addEventListener('click', () => {
document.getElementById('friendSearch').value = '';
document.getElementById('friendSearchResults').innerHTML = '';
document.getElementById('addAlert').classList.add('d-none');
addFriendModal.show();
setTimeout(() => document.getElementById('friendSearch').focus(), 300);
});
let _friendSearchTimer = null;
document.getElementById('friendSearch').addEventListener('input', function () {
clearTimeout(_friendSearchTimer);
const q = this.value.trim();
const resultsEl = document.getElementById('friendSearchResults');
if (q.length < 2) { resultsEl.innerHTML = ''; return; }
_friendSearchTimer = setTimeout(async () => {
try {
const results = await apiGet(`/social/members/?q=${encodeURIComponent(q)}`);
if (!results.length) {
resultsEl.innerHTML = '<div class="list-group-item text-muted small">No users found.</div>';
return;
}
resultsEl.innerHTML = results.map(u => `
<div class="list-group-item list-group-item-action d-flex align-items-center justify-content-between py-2">
<div>
<strong>${esc(u.display_name || u.username)}</strong>
<span class="text-muted ms-1 small">@${esc(u.username)}</span>
</div>
<button class="btn btn-sm btn-primary" onclick="sendRequest(${u.id}, this)">
<i class="bi bi-person-plus-fill me-1"></i>Add
</button>
</div>`
).join('');
} catch (e) { /* ignore */ }
}, 300);
});
async function sendRequest(userId, btn) {
const alertEl = document.getElementById('addAlert');
alertEl.classList.add('d-none');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
await apiPost('/social/friends/', { to_user: userId });
btn.outerHTML = '<span class="badge bg-success">Request sent</span>';
showToast('Friend request sent!');
loadSentRequests();
} catch (e) {
if (e.status === 409) {
btn.outerHTML = '<span class="badge bg-secondary">Already connected</span>';
} else {
alertEl.textContent = 'Failed to send request.';
alertEl.classList.remove('d-none');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-person-plus-fill me-1"></i>Add';
}
}
}
// ── Boot ──────────────────────────────────────────────────────────────────────
loadFriends();
loadRequests();
loadSentRequests();

657
frontend/js/gears.js Normal file
View File

@@ -0,0 +1,657 @@
// ── Gears page logic ──────────────────────────────────────────────────────────
let inventory = []; // UserGear[]
let rigs = []; // Rig[]
// ── Toast helper ─────────────────────────────────────────────────────────────
function showToast(msg, type = 'success') {
const el = document.createElement('div');
el.className = `toast align-items-center text-bg-${type} border-0 show`;
el.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 3500);
}
// ── Inventory ─────────────────────────────────────────────────────────────────
function gearTypeLabel(t) {
const map = { firearm:'Firearm', scope:'Scope', suppressor:'Suppressor', bipod:'Bipod', magazine:'Magazine' };
return map[t] || t;
}
function renderInventory() {
const tbody = document.getElementById('invBody');
const wrap = document.getElementById('invTableWrap');
const empty = document.getElementById('invEmpty');
document.getElementById('invSpinner').classList.add('d-none');
if (!inventory.length) {
empty.classList.remove('d-none');
wrap.classList.add('d-none');
return;
}
empty.classList.add('d-none');
wrap.classList.remove('d-none');
tbody.innerHTML = inventory.map(ug => `
<tr>
<td>${ug.nickname || '<span class="text-muted">—</span>'}</td>
<td>${ug.gear_detail.brand}</td>
<td>${ug.gear_detail.model_name}</td>
<td><span class="badge bg-secondary">${gearTypeLabel(ug.gear_detail.gear_type)}</span></td>
<td>${ug.serial_number || '<span class="text-muted">—</span>'}</td>
<td>${ug.purchase_date || '<span class="text-muted">—</span>'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditGear(${ug.id})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteGear(${ug.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
}
async function loadInventory() {
try {
inventory = await apiGet('/inventory/');
renderInventory();
} catch(e) {
document.getElementById('invSpinner').classList.add('d-none');
showToast('Failed to load inventory', 'danger');
}
}
// ── Add Gear modal ────────────────────────────────────────────────────────────
const addGearModal = new bootstrap.Modal('#addGearModal');
document.getElementById('addGearBtn').addEventListener('click', () => {
document.getElementById('catalogSearch').value = '';
document.getElementById('catalogResults').innerHTML = '';
document.getElementById('gearDetailsForm').classList.add('d-none');
document.getElementById('confirmAddGearBtn').classList.add('d-none');
addGearModal.show();
});
document.getElementById('catalogSearchBtn').addEventListener('click', searchCatalog);
document.getElementById('catalogSearch').addEventListener('keydown', e => {
if (e.key === 'Enter') searchCatalog();
});
async function searchCatalog() {
const q = document.getElementById('catalogSearch').value.trim();
if (!q) return;
const spinner = document.getElementById('catalogSpinner');
const results = document.getElementById('catalogResults');
spinner.classList.remove('d-none');
results.innerHTML = '';
document.getElementById('gearDetailsForm').classList.add('d-none');
document.getElementById('confirmAddGearBtn').classList.add('d-none');
try {
const sq = encodeURIComponent(q);
const [firearms, scopes, suppressors, bipods, magazines] = await Promise.all([
apiFetch(`/gears/firearms/?search=${sq}&page_size=100`).then(r => r.json()),
apiFetch(`/gears/scopes/?search=${sq}&page_size=100`).then(r => r.json()),
apiFetch(`/gears/suppressors/?search=${sq}&page_size=100`).then(r => r.json()),
apiFetch(`/gears/bipods/?search=${sq}&page_size=100`).then(r => r.json()),
apiFetch(`/gears/magazines/?search=${sq}&page_size=100`).then(r => r.json()),
]);
const all = [
...(firearms.results || []).map(g => ({ ...g, gear_type: 'firearm' })),
...(scopes.results || []).map(g => ({ ...g, gear_type: 'scope' })),
...(suppressors.results|| []).map(g => ({ ...g, gear_type: 'suppressor' })),
...(bipods.results || []).map(g => ({ ...g, gear_type: 'bipod' })),
...(magazines.results || []).map(g => ({ ...g, gear_type: 'magazine' })),
];
const statusBadge = g =>
g.status === 'PENDING'
? '<span class="badge bg-warning text-dark ms-1">Pending</span>'
: '';
const esc = s => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const listHtml = all.length
? `<div class="list-group" id="catalogResultsList">${all.map(g => `
<button type="button" class="list-group-item list-group-item-action"
data-gear-id="${g.id}" data-gear-name="${esc(g.brand)} ${esc(g.model_name)}">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="badge bg-secondary">${gearTypeLabel(g.gear_type)}</span>
<strong>${esc(g.brand)}</strong> ${esc(g.model_name)}
${g.caliber_detail ? `<span class="text-muted small">(${esc(g.caliber_detail.name)})</span>` : ''}
${statusBadge(g)}
</div>
</button>
`).join('')}</div>`
: '<p class="text-muted mb-0">No results found.</p>';
results.innerHTML = listHtml + `
<div class="mt-3 border-top pt-3">
<a class="small text-primary" href="#" id="toggleSuggestGear">
<i class="bi bi-plus-circle me-1"></i>Not found? Suggest a new gear item
</a>
<div id="suggestGearForm" class="mt-2 d-none"></div>
</div>`;
if (all.length) {
document.getElementById('catalogResultsList').addEventListener('click', e => {
const btn = e.target.closest('[data-gear-id]');
if (!btn) return;
chooseGear(parseInt(btn.dataset.gearId), btn.dataset.gearName);
});
}
document.getElementById('toggleSuggestGear').addEventListener('click', e => {
e.preventDefault();
const box = document.getElementById('suggestGearForm');
if (box.innerHTML) { box.classList.toggle('d-none'); return; }
box.classList.remove('d-none');
box.innerHTML = buildSuggestGearForm();
document.getElementById('suggestGearType').addEventListener('change', onSuggestTypeChange);
document.getElementById('submitSuggestGear').addEventListener('click', submitSuggestGear);
});
} catch(e) {
results.innerHTML = '<p class="text-danger">Search failed.</p>';
} finally {
spinner.classList.add('d-none');
}
}
function chooseGear(id, name) {
document.getElementById('chosenGearId').value = id;
document.getElementById('chosenGearName').textContent = name;
document.getElementById('gearDetailsForm').classList.remove('d-none');
document.getElementById('confirmAddGearBtn').classList.remove('d-none');
document.getElementById('gearNickname').value = '';
document.getElementById('gearSerial').value = '';
document.getElementById('gearPurchase').value = '';
document.getElementById('gearNotes').value = '';
document.getElementById('addGearAlert').classList.add('d-none');
}
document.getElementById('confirmAddGearBtn').addEventListener('click', async () => {
const alertEl = document.getElementById('addGearAlert');
alertEl.classList.add('d-none');
const payload = {
gear: parseInt(document.getElementById('chosenGearId').value),
};
const nick = document.getElementById('gearNickname').value.trim();
const ser = document.getElementById('gearSerial').value.trim();
const pur = document.getElementById('gearPurchase').value;
const not = document.getElementById('gearNotes').value.trim();
if (nick) payload.nickname = nick;
if (ser) payload.serial_number = ser;
if (pur) payload.purchase_date = pur;
if (not) payload.notes = not;
try {
const ug = await apiPost('/inventory/', payload);
inventory.push(ug);
renderInventory();
addGearModal.hide();
showToast('Gear added to inventory!');
} catch(e) {
alertEl.textContent = formatErrors(e.data);
alertEl.classList.remove('d-none');
}
});
// ── Edit UserGear ─────────────────────────────────────────────────────────────
const editGearModal = new bootstrap.Modal('#editGearModal');
function openEditGear(id) {
const ug = inventory.find(g => g.id === id);
if (!ug) return;
document.getElementById('editGearId').value = id;
document.getElementById('editNickname').value = ug.nickname || '';
document.getElementById('editSerial').value = ug.serial_number || '';
document.getElementById('editPurchase').value = ug.purchase_date || '';
document.getElementById('editNotes').value = ug.notes || '';
document.getElementById('editGearAlert').classList.add('d-none');
editGearModal.show();
}
document.getElementById('saveEditGearBtn').addEventListener('click', async () => {
const id = parseInt(document.getElementById('editGearId').value);
const alertEl = document.getElementById('editGearAlert');
alertEl.classList.add('d-none');
try {
const updated = await apiPatch(`/inventory/${id}/`, {
nickname: document.getElementById('editNickname').value.trim(),
serial_number: document.getElementById('editSerial').value.trim(),
purchase_date: document.getElementById('editPurchase').value || null,
notes: document.getElementById('editNotes').value.trim(),
});
const idx = inventory.findIndex(g => g.id === id);
if (idx >= 0) inventory[idx] = updated;
renderInventory();
editGearModal.hide();
showToast('Gear updated!');
} catch(e) {
alertEl.textContent = formatErrors(e.data);
alertEl.classList.remove('d-none');
}
});
async function deleteGear(id) {
if (!confirm('Remove this gear from your inventory?')) return;
try {
await apiDelete(`/inventory/${id}/`);
inventory = inventory.filter(g => g.id !== id);
renderInventory();
showToast('Gear removed.');
} catch(e) {
showToast('Failed to delete gear.', 'danger');
}
}
// ── Rigs ──────────────────────────────────────────────────────────────────────
function renderRigs() {
const container = document.getElementById('rigCards');
const empty = document.getElementById('rigEmpty');
document.getElementById('rigSpinner').classList.add('d-none');
if (!rigs.length) {
empty.classList.remove('d-none');
container.innerHTML = '';
return;
}
empty.classList.add('d-none');
container.innerHTML = rigs.map(rig => `
<div class="col-md-6 col-lg-4">
<div class="card rig-card h-100 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="fw-bold mb-0">${rig.name}</h6>
<span class="badge ${rig.is_public ? 'bg-success' : 'bg-secondary'}">
${rig.is_public ? 'Public' : 'Private'}
</span>
</div>
${rig.description ? `<p class="text-muted small mb-2">${rig.description}</p>` : ''}
<div class="rig-items-list mb-3">
${rig.rig_items.length
? `<ul class="list-unstyled mb-0">${rig.rig_items.map(item => `
<li class="small text-muted">
<i class="bi bi-dot"></i>
${item.user_gear.gear_detail.brand} ${item.user_gear.gear_detail.model_name}
${item.role ? `<span class="text-secondary">(${item.role})</span>` : ''}
</li>`).join('')}</ul>`
: '<p class="text-muted small mb-0">No items yet.</p>'}
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-outline-primary" onclick="openManageItems(${rig.id})">
<i class="bi bi-list-check me-1"></i>Items
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="openEditRig(${rig.id})">
<i class="bi bi-pencil me-1"></i>Edit
</button>
<button class="btn btn-sm ${rig.is_public ? 'btn-outline-secondary' : 'btn-outline-success'}"
onclick="toggleRigPublic(${rig.id})">
<i class="bi bi-${rig.is_public ? 'lock' : 'globe2'} me-1"></i>
${rig.is_public ? 'Make private' : 'Make public'}
</button>
<button class="btn btn-sm btn-outline-danger ms-auto" onclick="deleteRig(${rig.id})">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
`).join('');
}
async function loadRigs() {
try {
rigs = await apiGet('/rigs/');
renderRigs();
} catch(e) {
document.getElementById('rigSpinner').classList.add('d-none');
showToast('Failed to load rigs', 'danger');
}
}
// Create/Edit Rig modal
const rigModal = new bootstrap.Modal('#rigModal');
document.getElementById('createRigBtn').addEventListener('click', () => {
document.getElementById('rigId').value = '';
document.getElementById('rigName').value = '';
document.getElementById('rigDesc').value = '';
document.getElementById('rigPublic').checked = false;
document.getElementById('rigModalTitle').textContent = 'New rig';
document.getElementById('rigModalAlert').classList.add('d-none');
rigModal.show();
});
function openEditRig(id) {
const rig = rigs.find(r => r.id === id);
if (!rig) return;
document.getElementById('rigId').value = id;
document.getElementById('rigName').value = rig.name;
document.getElementById('rigDesc').value = rig.description || '';
document.getElementById('rigPublic').checked = rig.is_public;
document.getElementById('rigModalTitle').textContent = 'Edit rig';
document.getElementById('rigModalAlert').classList.add('d-none');
rigModal.show();
}
document.getElementById('saveRigBtn').addEventListener('click', async () => {
const id = document.getElementById('rigId').value;
const alertEl = document.getElementById('rigModalAlert');
alertEl.classList.add('d-none');
const payload = {
name: document.getElementById('rigName').value.trim(),
description: document.getElementById('rigDesc').value.trim(),
is_public: document.getElementById('rigPublic').checked,
};
if (!payload.name) {
alertEl.textContent = 'Name is required.';
alertEl.classList.remove('d-none');
return;
}
try {
if (id) {
const updated = await apiPatch(`/rigs/${id}/`, payload);
const idx = rigs.findIndex(r => r.id === parseInt(id));
if (idx >= 0) rigs[idx] = { ...rigs[idx], ...updated };
} else {
const created = await apiPost('/rigs/', payload);
rigs.push(created);
}
renderRigs();
rigModal.hide();
showToast(id ? 'Rig updated!' : 'Rig created!');
} catch(e) {
alertEl.textContent = formatErrors(e.data);
alertEl.classList.remove('d-none');
}
});
async function toggleRigPublic(id) {
const rig = rigs.find(r => r.id === id);
if (!rig) return;
try {
const updated = await apiPatch(`/rigs/${id}/`, { is_public: !rig.is_public });
const idx = rigs.findIndex(r => r.id === id);
if (idx >= 0) rigs[idx] = { ...rigs[idx], ...updated };
renderRigs();
showToast(`Rig is now ${updated.is_public ? 'public' : 'private'}.`);
} catch(e) {
showToast('Failed to update rig.', 'danger');
}
}
async function deleteRig(id) {
if (!confirm('Delete this rig?')) return;
try {
await apiDelete(`/rigs/${id}/`);
rigs = rigs.filter(r => r.id !== id);
renderRigs();
showToast('Rig deleted.');
} catch(e) {
showToast('Failed to delete rig.', 'danger');
}
}
// ── Manage Rig Items modal ────────────────────────────────────────────────────
const rigItemsModal = new bootstrap.Modal('#rigItemsModal');
async function openManageItems(rigId) {
const rig = rigs.find(r => r.id === rigId);
if (!rig) return;
document.getElementById('rigItemsRigId').value = rigId;
document.getElementById('rigItemsName').textContent = rig.name;
document.getElementById('rigItemsAlert').classList.add('d-none');
renderRigItemsList(rig);
// Populate inventory dropdown (exclude already-added gear)
const addedIds = rig.rig_items.map(i => i.user_gear.id);
const available = inventory.filter(ug => !addedIds.includes(ug.id));
const sel = document.getElementById('rigItemSelect');
sel.innerHTML = available.length
? available.map(ug => `<option value="${ug.id}">${ug.gear_detail.brand} ${ug.gear_detail.model_name}${ug.nickname ? ' ' + ug.nickname : ''}</option>`).join('')
: '<option disabled>All inventory items already added</option>';
rigItemsModal.show();
}
function renderRigItemsList(rig) {
const list = document.getElementById('rigItemsList');
list.innerHTML = rig.rig_items.length
? rig.rig_items.map(item => `
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>${item.user_gear.gear_detail.brand} ${item.user_gear.gear_detail.model_name}</strong>
${item.role ? `<span class="text-muted ms-2 small">${item.role}</span>` : ''}
</div>
<button class="btn btn-sm btn-outline-danger" onclick="removeRigItem(${rig.id}, ${item.id})">
<i class="bi bi-x-lg"></i>
</button>
</li>`).join('')
: '<li class="list-group-item text-muted">No items yet.</li>';
}
document.getElementById('addRigItemBtn').addEventListener('click', async () => {
const rigId = parseInt(document.getElementById('rigItemsRigId').value);
const ugId = parseInt(document.getElementById('rigItemSelect').value);
const role = document.getElementById('rigItemRole').value.trim();
const alertEl = document.getElementById('rigItemsAlert');
alertEl.classList.add('d-none');
if (!ugId) return;
try {
const item = await apiPost(`/rigs/${rigId}/items/`, { user_gear: ugId, role });
const rig = rigs.find(r => r.id === rigId);
if (rig) {
rig.rig_items.push(item);
renderRigItemsList(rig);
renderRigs();
// Refresh dropdown
const addedIds = rig.rig_items.map(i => i.user_gear.id);
const available = inventory.filter(ug => !addedIds.includes(ug.id));
const sel = document.getElementById('rigItemSelect');
sel.innerHTML = available.length
? available.map(ug => `<option value="${ug.id}">${ug.gear_detail.brand} ${ug.gear_detail.model_name}</option>`).join('')
: '<option disabled>All inventory items already added</option>';
document.getElementById('rigItemRole').value = '';
}
showToast('Item added to rig.');
} catch(e) {
alertEl.textContent = formatErrors(e.data);
alertEl.classList.remove('d-none');
}
});
async function removeRigItem(rigId, itemId) {
try {
await apiDelete(`/rigs/${rigId}/items/${itemId}/`);
const rig = rigs.find(r => r.id === rigId);
if (rig) {
rig.rig_items = rig.rig_items.filter(i => i.id !== itemId);
renderRigItemsList(rig);
renderRigs();
}
showToast('Item removed.');
} catch(e) {
showToast('Failed to remove item.', 'danger');
}
}
// ── Suggest new gear (user submission) ───────────────────────────────────────
function buildSuggestGearForm() {
return `
<div class="card bg-light border-0 p-3">
<p class="small text-muted mb-2">
<i class="bi bi-info-circle me-1"></i>
Your suggestion will be available to you immediately and visible to everyone once an admin verifies it.
</p>
<div class="mb-2">
<select class="form-select form-select-sm" id="suggestGearType">
<option value="">Select type…</option>
<option value="firearm">Firearm</option>
<option value="scope">Scope</option>
<option value="suppressor">Suppressor</option>
<option value="bipod">Bipod</option>
<option value="magazine">Magazine</option>
</select>
</div>
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="sgBrand" placeholder="Brand *"></div>
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="sgModel" placeholder="Model *"></div>
<!-- Firearm extras -->
<div id="sgFirearm" class="d-none">
<div class="mb-2">
<select class="form-select form-select-sm" id="sgFirearmType">
<option value="RIFLE">Rifle</option><option value="PISTOL">Pistol</option>
<option value="SHOTGUN">Shotgun</option><option value="REVOLVER">Revolver</option>
<option value="CARBINE">Carbine</option>
</select>
</div>
<div class="mb-2">
${buildCaliberPickerHtml('sgCaliber', 'sgCaliberSuggest')}
</div>
</div>
<!-- Scope extras -->
<div id="sgScope" class="d-none">
<div class="row g-2 mb-2">
<div class="col"><input type="number" class="form-control form-control-sm" id="sgMagMin" placeholder="Mag min"></div>
<div class="col"><input type="number" class="form-control form-control-sm" id="sgMagMax" placeholder="Mag max"></div>
</div>
<div class="row g-2 mb-2">
<div class="col"><input type="number" class="form-control form-control-sm" id="sgObjDia" placeholder="Objective dia (mm) *" step="0.1"></div>
<div class="col"><input type="number" class="form-control form-control-sm" id="sgTubeDia" placeholder="Tube dia (mm, def 30)" step="0.1"></div>
</div>
<div class="mb-2">
<select class="form-select form-select-sm" id="sgReticle">
<option value="">Reticle type (optional)</option>
<option value="DUPLEX">Duplex</option>
<option value="MILDOT">Mil-Dot</option>
<option value="BDC">BDC</option>
<option value="ILLUMINATED">Illuminated</option>
<option value="ETCHED">Etched Glass</option>
</select>
</div>
<div class="row g-2 mb-2">
<div class="col">
<select class="form-select form-select-sm" id="sgAdjUnit">
<option value="">Adjustment (optional)</option>
<option value="MOA">MOA</option>
<option value="MRAD">MRAD</option>
</select>
</div>
<div class="col">
<select class="form-select form-select-sm" id="sgFocalPlane">
<option value="">Focal plane (optional)</option>
<option value="FFP">FFP</option>
<option value="SFP">SFP</option>
</select>
</div>
</div>
</div>
<!-- Magazine extras -->
<div id="sgMagazine" class="d-none">
<div class="mb-2">
${buildCaliberPickerHtml('sgMagCaliber', 'sgMagCaliberSuggest')}
</div>
<div class="mb-2"><input type="number" class="form-control form-control-sm" id="sgMagCapacity" placeholder="Capacity"></div>
</div>
<!-- Suppressor extras -->
<div id="sgSuppressor" class="d-none">
<div class="mb-2">
${buildCaliberPickerHtml('sgSuppCaliber', 'sgSuppCaliberSuggest')}
</div>
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="sgSuppThread" placeholder="Thread pitch"></div>
</div>
<!-- Bipod extras -->
<div id="sgBipod" class="d-none">
<div class="mb-2"><input type="text" class="form-control form-control-sm" id="sgBipodAttach" placeholder="Attachment type"></div>
</div>
<div id="sgAlert" class="alert alert-danger d-none small py-1 mb-2"></div>
<button class="btn btn-sm btn-primary w-100" id="submitSuggestGear" disabled>
<i class="bi bi-send me-1"></i>Submit suggestion
</button>
</div>`;
}
function onSuggestTypeChange() {
const type = document.getElementById('suggestGearType').value;
['sgFirearm','sgScope','sgSuppressor','sgBipod','sgMagazine'].forEach(id =>
document.getElementById(id)?.classList.add('d-none'));
if (type) {
const key = 'sg' + type.charAt(0).toUpperCase() + type.slice(1);
document.getElementById(key)?.classList.remove('d-none');
document.getElementById('submitSuggestGear').disabled = false;
// Load calibers into the newly-visible caliber select
const caliberSelectId = { firearm: 'sgCaliber', magazine: 'sgMagCaliber', suppressor: 'sgSuppCaliber' }[type];
if (caliberSelectId) {
const sel = document.getElementById(caliberSelectId);
if (sel) loadCalibersIntoSelect(sel);
}
} else {
document.getElementById('submitSuggestGear').disabled = true;
}
}
async function submitSuggestGear() {
const type = document.getElementById('suggestGearType').value;
const alertEl = document.getElementById('sgAlert');
alertEl.classList.add('d-none');
const sv = id => document.getElementById(id)?.value?.trim() || '';
const payload = { brand: sv('sgBrand'), model_name: sv('sgModel') };
if (!payload.brand || !payload.model_name) {
alertEl.textContent = 'Brand and model are required.';
alertEl.classList.remove('d-none');
return;
}
if (type === 'firearm') {
payload.firearm_type = sv('sgFirearmType') || 'RIFLE';
const cal = sv('sgCaliber'); if (cal) payload.caliber = parseInt(cal);
} else if (type === 'scope') {
const mn = sv('sgMagMin'), mx = sv('sgMagMax');
if (mn) payload.magnification_min = parseFloat(mn);
if (mx) payload.magnification_max = parseFloat(mx);
const ob = sv('sgObjDia'), tu = sv('sgTubeDia');
if (ob) payload.objective_diameter_mm = parseFloat(ob);
if (tu) payload.tube_diameter_mm = parseFloat(tu);
if (sv('sgReticle')) payload.reticle_type = sv('sgReticle');
if (sv('sgAdjUnit')) payload.adjustment_unit = sv('sgAdjUnit');
if (sv('sgFocalPlane')) payload.focal_plane = sv('sgFocalPlane');
} else if (type === 'magazine') {
const mc = sv('sgMagCaliber'); if (mc) payload.caliber = parseInt(mc);
if (sv('sgMagCapacity')) payload.capacity = parseInt(sv('sgMagCapacity'));
} else if (type === 'suppressor') {
const sc = sv('sgSuppCaliber'); if (sc) payload.max_caliber = parseInt(sc);
if (sv('sgSuppThread')) payload.thread_pitch = sv('sgSuppThread');
} else if (type === 'bipod') {
if (sv('sgBipodAttach')) payload.attachment_type = sv('sgBipodAttach');
}
try {
const created = await apiPost(`/gears/${type}s/`, payload);
// Auto-select the new gear so user can immediately add it to inventory
chooseGear(created.id, `${created.brand} ${created.model_name}`);
showToast('Suggestion submitted! You can now add it to your inventory.');
document.getElementById('suggestGearForm').classList.add('d-none');
} catch(e) {
alertEl.textContent = formatErrors(e.data) || 'Submission failed.';
alertEl.classList.remove('d-none');
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
loadInventory();
loadRigs();

455
frontend/js/group-size.js Normal file
View File

@@ -0,0 +1,455 @@
// ── Group Size Calculator ─────────────────────────────────────────────────────
// Canvas-based annotation tool. All geometry is computed client-side.
// Optional API integration when a GroupPhoto ID is provided via ?gp=<id>.
let canvas, ctx;
const imgEl = new Image();
// Annotation state
let mode = 'idle'; // 'idle' | 'ref1' | 'ref2' | 'poa' | 'poi'
let refP1 = null, refP2 = null;
let poa = null;
let pois = [];
let hoverPt = null;
// API context (set when ?gp= param is present)
let gpId = null; // GroupPhoto id
let gpPhotoId = null; // Photo id (for image URL)
// ── Boot ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
canvas = document.getElementById('annotCanvas');
ctx = canvas.getContext('2d');
canvas.addEventListener('click', onCanvasClick);
canvas.addEventListener('mousemove', onCanvasMove);
canvas.addEventListener('mouseleave', () => { hoverPt = null; if (mode !== 'idle') redraw(); });
// File upload wiring
const dropArea = document.getElementById('dropArea');
const fileInput = document.getElementById('fileInput');
dropArea.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', e => { if (e.target.files[0]) loadFile(e.target.files[0]); });
dropArea.addEventListener('dragover', e => { e.preventDefault(); dropArea.classList.add('drag-over'); });
dropArea.addEventListener('dragleave',() => dropArea.classList.remove('drag-over'));
dropArea.addEventListener('drop', e => {
e.preventDefault(); dropArea.classList.remove('drag-over');
if (e.dataTransfer.files[0]) loadFile(e.dataTransfer.files[0]);
});
// Recompute when setup inputs change
document.getElementById('refLength').addEventListener('input', () => { redraw(); updateResults(); });
document.getElementById('distanceM').addEventListener('input', updateResults);
document.getElementById('bulletDia').addEventListener('input', () => { redraw(); updateResults(); });
// Unit switch
const gsUnitBtns = document.getElementById('gsDistUnitBtns');
applyDistUnitButtons(gsUnitBtns);
gsUnitBtns.addEventListener('click', e => {
const btn = e.target.closest('[data-dist-unit]');
if (!btn) return;
setDistUnit(btn.dataset.distUnit);
applyDistUnitButtons(gsUnitBtns);
updateResults();
});
// Check for ?gp= query param
const params = new URLSearchParams(window.location.search);
const gpParam = params.get('gp');
if (gpParam) {
await loadFromApi(parseInt(gpParam));
}
// Show back button if we came from somewhere (history) or have a ?gp= param
const backWrap = document.getElementById('backBtnWrap');
if (backWrap) {
if (document.referrer) {
const ref = new URL(document.referrer);
document.getElementById('backBtn').href = ref.pathname + ref.search;
backWrap.classList.remove('d-none');
} else if (gpParam) {
// No referrer but linked from chrono — default back to chrono
backWrap.classList.remove('d-none');
}
}
});
// ── Image loading ─────────────────────────────────────────────────────────────
function loadFile(file) {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => { imgEl.onload = onImageReady; imgEl.src = e.target.result; };
reader.readAsDataURL(file);
}
async function loadFromApi(id) {
try {
const gp = await apiGet(`/photos/group-photos/${id}/`);
gpId = gp.id;
gpPhotoId = gp.photo.id;
// Prefill distance from linked shot group
if (gp.shot_group_detail?.distance_m) {
document.getElementById('distanceM').value = gp.shot_group_detail.distance_m;
}
// Show "linked" badge
const badge = document.getElementById('linkedBadge');
if (badge) {
badge.textContent = gp.shot_group_detail
? `Linked to: ${gp.shot_group_detail.label}${gp.shot_group_detail.distance_m ? ' @ ' + gp.shot_group_detail.distance_m + ' m' : ''}`
: 'Linked group photo';
badge.classList.remove('d-none');
}
// If existing POIs, prefill them (using pixel coords)
if (gp.points_of_impact && gp.points_of_impact.length) {
// POIs will be mapped after image load (need canvas size)
imgEl._existingPois = gp.points_of_impact;
}
imgEl.onload = onImageReady;
imgEl.src = `/api/photos/${gpPhotoId}/data/`;
} catch(e) {
showUploadError('Failed to load group photo from server.');
}
}
function onImageReady() {
document.getElementById('uploadSection').classList.add('d-none');
document.getElementById('annotSection').classList.remove('d-none');
// Reset annotations (keep any prefilled distance)
refP1 = refP2 = poa = null; pois = [];
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// Restore existing POIs from API (scaled to canvas)
if (imgEl._existingPois && imgEl._existingPois.length) {
const sx = canvas.width / imgEl.naturalWidth;
const sy = canvas.height / imgEl.naturalHeight;
pois = imgEl._existingPois.map(p => ({ x: p.x_px * sx, y: p.y_px * sy }));
imgEl._existingPois = null;
}
setMode('ref1');
}
function resizeCanvas() {
const w = document.getElementById('canvasWrap').clientWidth;
canvas.width = w;
canvas.height = Math.round(w * imgEl.naturalHeight / imgEl.naturalWidth);
redraw();
}
function showUploadError(msg) {
const el = document.getElementById('uploadError');
el.textContent = msg;
el.classList.remove('d-none');
}
// ── Mode management ───────────────────────────────────────────────────────────
const STATUS = {
idle: '',
ref1: '① Click the first point of your reference measurement.',
ref2: '② Click the second point of the reference measurement.',
poa: '③ Click the Point of Aim — the intended centre of the target.',
poi: '④ Click each bullet hole to add a Point of Impact. Click Compute when done.',
};
function setMode(m) {
mode = m;
canvas.style.cursor = m === 'idle' ? 'default' : 'crosshair';
document.getElementById('statusMsg').textContent = STATUS[m] || '';
['btnRef', 'btnPoa', 'btnPoi'].forEach(id => {
const active =
(id === 'btnRef' && (m === 'ref1' || m === 'ref2')) ||
(id === 'btnPoa' && m === 'poa') ||
(id === 'btnPoi' && m === 'poi');
const el = document.getElementById(id);
el.className = `btn btn-sm w-100 ${active ? 'btn-primary' : 'btn-outline-secondary'}`;
});
}
// ── Canvas events ─────────────────────────────────────────────────────────────
function canvasPt(e) {
const r = canvas.getBoundingClientRect();
return {
x: (e.clientX - r.left) * canvas.width / r.width,
y: (e.clientY - r.top) * canvas.height / r.height,
};
}
function onCanvasClick(e) {
const pt = canvasPt(e);
switch (mode) {
case 'ref1': refP1 = pt; refP2 = null; setMode('ref2'); break;
case 'ref2': refP2 = pt; setMode('idle');
document.getElementById('refLength').focus(); break;
case 'poa': poa = pt; setMode('poi'); break;
case 'poi': pois.push(pt); break;
default: return;
}
redraw();
updateResults();
}
function onCanvasMove(e) {
hoverPt = canvasPt(e);
if (mode !== 'idle') redraw();
}
// ── Drawing ───────────────────────────────────────────────────────────────────
function redraw() {
if (!canvas || !imgEl.naturalWidth) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(imgEl, 0, 0, canvas.width, canvas.height);
const scale = getScale(); // px/mm
const bulletDia = parseFloat(document.getElementById('bulletDia').value) || 0;
const poiR = scale && bulletDia ? (bulletDia / 2) * scale : 10;
// ── Reference line ──────────────────────────────────────────────────────────
if (refP1) {
const end = refP2 || (mode === 'ref2' && hoverPt) || refP1;
ctx.save();
ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2; ctx.setLineDash([7, 4]);
ctx.beginPath(); ctx.moveTo(refP1.x, refP1.y); ctx.lineTo(end.x, end.y); ctx.stroke();
drawDot(refP1.x, refP1.y, '#3b82f6', 5);
if (refP2) {
drawDot(refP2.x, refP2.y, '#3b82f6', 5);
// Length label midpoint
const mx = (refP1.x + refP2.x) / 2, my = (refP1.y + refP2.y) / 2;
const mm = document.getElementById('refLength').value;
if (mm) {
ctx.fillStyle = '#fff'; ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const label = mm + ' mm';
const tw = ctx.measureText(label).width + 6;
ctx.fillRect(mx - tw/2, my - 8, tw, 16);
ctx.fillStyle = '#3b82f6'; ctx.fillText(label, mx, my);
}
}
ctx.restore();
}
// ── POA crosshair ───────────────────────────────────────────────────────────
if (poa) drawCross(poa.x, poa.y, '#22c55e', 14);
// ── POIs ────────────────────────────────────────────────────────────────────
pois.forEach((p, i) => {
ctx.save();
ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 2;
ctx.fillStyle = 'rgba(239,68,68,0.18)';
const r = Math.max(poiR, 8);
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, 2 * Math.PI); ctx.fill(); ctx.stroke();
ctx.fillStyle = '#ef4444'; ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(i + 1, p.x, p.y);
ctx.restore();
});
// Hover ghost in POI mode
if (mode === 'poi' && hoverPt) {
ctx.save(); ctx.strokeStyle = 'rgba(239,68,68,0.4)'; ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.arc(hoverPt.x, hoverPt.y, Math.max(poiR, 8), 0, 2 * Math.PI); ctx.stroke();
ctx.restore();
}
// ── Centroid + offset line ──────────────────────────────────────────────────
const res = compute();
if (res && pois.length >= 1) {
const cx = poa.x + res.offsetX_mm * scale;
const cy = poa.y - res.offsetY_mm * scale; // Y flipped (up = positive elevation)
ctx.save(); ctx.strokeStyle = '#f97316'; ctx.lineWidth = 2; ctx.setLineDash([5, 3]);
ctx.beginPath(); ctx.moveTo(poa.x, poa.y); ctx.lineTo(cx, cy); ctx.stroke();
ctx.restore();
drawDot(cx, cy, '#f97316', 7);
}
}
function drawDot(x, y, color, r) {
ctx.save(); ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(x, y, r, 0, 2 * Math.PI); ctx.fill();
ctx.restore();
}
function drawCross(x, y, color, size) {
ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(x - size, y); ctx.lineTo(x + size, y);
ctx.moveTo(x, y - size); ctx.lineTo(x, y + size);
ctx.stroke();
ctx.beginPath(); ctx.arc(x, y, 4, 0, 2 * Math.PI); ctx.stroke();
ctx.restore();
}
// ── Computation ───────────────────────────────────────────────────────────────
// Returns px/mm, or null if reference is not set.
function getScale() {
const mm = parseFloat(document.getElementById('refLength').value);
if (!refP1 || !refP2 || !mm || mm <= 0) return null;
const d = Math.hypot(refP2.x - refP1.x, refP2.y - refP1.y);
return d > 2 ? d / mm : null;
}
function compute() {
const scale = getScale();
if (!scale || !poa || !pois.length) return null;
// Convert POIs to mm relative to POA.
// X: positive = right. Y: positive = up (canvas Y is flipped).
const pts = pois.map(p => ({
x: (p.x - poa.x) / scale,
y: -(p.y - poa.y) / scale,
}));
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length;
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length;
// Extreme spread (max pairwise distance)
let groupSizeMm = 0;
for (let i = 0; i < pts.length; i++)
for (let j = i + 1; j < pts.length; j++)
groupSizeMm = Math.max(groupSizeMm, Math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y));
// Mean radius from centroid
const meanRadius = pts.reduce((s, p) => s + Math.hypot(p.x - cx, p.y - cy), 0) / pts.length;
const dist_m = parseFloat(document.getElementById('distanceM').value) || null;
const toMoa = dist_m ? mm => mm / (dist_m * 0.29089) : null;
return { groupSizeMm, meanRadius, offsetX_mm: cx, offsetY_mm: cy, toMoa, scale, dist_m, pts };
}
// ── Results rendering ─────────────────────────────────────────────────────────
// fval: format a mm value using the user's chosen distance unit.
// fn = toMoa (from compute()), distM = shooting distance in metres.
function fval(mm, toMoa, distM) {
return fDist(mm, toMoa ? toMoa(mm) : null, distM);
}
function updateResults() {
const res = document.getElementById('resultsSection');
const hint = document.getElementById('resultsHint');
const saveBtn = document.getElementById('saveBtn');
const r = compute();
if (!r) {
res.classList.add('d-none');
if (saveBtn) saveBtn.classList.add('d-none');
const msg = !refP1 || !refP2 ? 'Draw a reference line on the image.'
: !parseFloat(document.getElementById('refLength').value) ? 'Enter the reference length in mm.'
: !poa ? 'Set the Point of Aim (POA).'
: 'Add at least one Point of Impact (POI).';
hint.textContent = msg;
hint.classList.remove('d-none');
return;
}
hint.classList.add('d-none');
res.classList.remove('d-none');
const { groupSizeMm, meanRadius, offsetX_mm, offsetY_mm, toMoa, dist_m } = r;
const absW = Math.abs(offsetX_mm);
const absE = Math.abs(offsetY_mm);
document.getElementById('resPOICount').textContent = pois.length;
document.getElementById('resGroupSize').innerHTML = pois.length >= 2
? fval(groupSizeMm, toMoa, dist_m)
: '<span class="text-muted">— (need ≥ 2 POIs)</span>';
document.getElementById('resMeanRadius').innerHTML = fval(meanRadius, toMoa, dist_m);
if (absW < 0.5 && absE < 0.5) {
document.getElementById('resOffset').innerHTML = '<span class="text-success">On POA ✓</span>';
document.getElementById('resCorrection').innerHTML = '<span class="text-success">None needed ✓</span>';
} else {
const wDir = offsetX_mm > 0.5 ? 'right' : offsetX_mm < -0.5 ? 'left' : null;
const eDir = offsetY_mm > 0.5 ? 'high' : offsetY_mm < -0.5 ? 'low' : null;
const cwDir = offsetX_mm > 0.5 ? 'left' : offsetX_mm < -0.5 ? 'right' : null;
const ceDir = offsetY_mm > 0.5 ? 'down' : offsetY_mm < -0.5 ? 'up' : null;
document.getElementById('resOffset').innerHTML =
(wDir ? `<span class="me-3">${fval(absW, toMoa, dist_m)} <strong>${wDir}</strong></span>` : '') +
(eDir ? `<span>${fval(absE, toMoa, dist_m)} <strong>${eDir}</strong></span>` : '');
document.getElementById('resCorrection').innerHTML =
(cwDir ? `<span class="me-3">${fval(absW, toMoa, dist_m)} <strong>${cwDir}</strong></span>` : '') +
(ceDir ? `<span>${fval(absE, toMoa, dist_m)} <strong>${ceDir}</strong></span>` : '');
}
// Hide the MOA note when the unit isn't mm (user explicitly chose a different unit)
const showNote = getDistUnit() === 'mm' && !dist_m;
document.getElementById('resMoaNote').classList.toggle('d-none', !showNote);
// Show save button only when linked to a GroupPhoto
if (saveBtn && gpId) saveBtn.classList.remove('d-none');
}
// ── Save back to API ──────────────────────────────────────────────────────────
async function saveToApi() {
const r = compute();
if (!r || !gpId) return;
const btn = document.getElementById('saveBtn');
btn.disabled = true;
try {
// 1. Delete existing POIs
const existing = await apiGet(`/photos/group-photos/${gpId}/`);
for (const poi of (existing.points_of_impact || [])) {
await apiFetch(`/photos/group-photos/${gpId}/points/${poi.id}/`, { method: 'DELETE' });
}
// 2. Post new POIs with pixel + mm coords
const sx = imgEl.naturalWidth / canvas.width;
const sy = imgEl.naturalHeight / canvas.height;
for (const [i, pt] of pois.entries()) {
const mm = r.pts[i];
await apiPost(`/photos/group-photos/${gpId}/points/`, {
order: i + 1,
x_px: Math.round(pt.x * sx),
y_px: Math.round(pt.y * sy),
x_mm: parseFloat(mm.x.toFixed(2)),
y_mm: parseFloat(mm.y.toFixed(2)),
});
}
// 3. Trigger server-side group size computation
await apiPost(`/photos/group-photos/${gpId}/compute-group-size/`, {});
showToast('Saved & computed — results stored in the session.');
} catch(e) {
showToast('Save failed: ' + (e.message || 'unknown error'), 'danger');
} finally {
btn.disabled = false;
}
}
// ── Control buttons (called from HTML) ────────────────────────────────────────
function btnRef() { setMode('ref1'); }
function btnPoa() { setMode('poa'); }
function btnPoi() { setMode('poi'); }
function btnUndo() { if (pois.length) { pois.pop(); redraw(); updateResults(); } }
function btnReset() {
refP1 = refP2 = poa = null; pois = [];
document.getElementById('refLength').value = '';
redraw(); updateResults(); setMode('ref1');
}
function loadNewPhoto() {
document.getElementById('uploadSection').classList.remove('d-none');
document.getElementById('annotSection').classList.add('d-none');
window.removeEventListener('resize', resizeCanvas);
gpId = gpPhotoId = null;
refP1 = refP2 = poa = null; pois = [];
}
// showToast → utils.js

925
frontend/js/i18n.js Normal file
View File

@@ -0,0 +1,925 @@
// ── Internationalisation ───────────────────────────────────────────────────────
// Depends on api.js (getLang)
const TRANSLATIONS = {
en: {
// ── Navigation ──
'nav.dashboard': 'Dashboard',
'nav.gear': 'My Gear',
'nav.reloads': 'My Reloads',
'nav.tools': 'Tools',
'nav.photos': 'Photos',
'nav.sessions': 'Sessions',
'nav.profile': 'Profile',
'nav.signout': 'Sign out',
'nav.signin': 'Sign in',
'nav.register': 'Register',
'nav.admin': 'Admin',
// ── Index ──
'index.lead': 'Your all-in-one platform to manage your firearms & gear, log and analyse your shooting performance, develop custom reloads, and share your results with other shooters.',
'index.cta': 'Get started free',
'index.cta2': 'Explore tools',
'index.cta3': 'Ready to elevate your shooting?',
'index.cta4': 'Create your account',
'index.feat.title': 'Everything a serious shooter needs',
'index.feat.sub': 'From the first round to competition-level analysis.',
'index.feat.gear.title': 'Gear Inventory',
'index.feat.gear.desc': 'Catalogue every firearm, scope, suppressor, bipod and magazine you own. Build custom rigs and share them publicly.',
'index.feat.sessions.title': 'Session Logging',
'index.feat.sessions.desc': 'Log every shooting session with chrono data, shot groups, weather conditions and notes in one place.',
'index.feat.analysis.title': 'Performance Analysis',
'index.feat.analysis.desc': 'Visualise velocity SD, ES, group sizes over time and identify trends in your shooting performance.',
'index.feat.reload.title': 'Reload Development',
'index.feat.reload.desc': 'Build load recipes, vary powder charge across batches, link each batch to shot groups and find the most accurate charge.',
'index.recent.title': 'Recent Analyses',
'index.recent.subtitle': 'Publicly shared chronograph sessions — no account required to view.',
'index.recent.upload': 'Upload your own',
'index.recent.empty': 'No analyses uploaded yet. Be the first!',
// ── Dashboard ──
'dash.title': 'Dashboard',
'dash.welcome': 'Welcome back!',
'dash.welcome.name': 'Welcome back, {name}!',
'dash.stat.gear': 'Gear items',
'dash.stat.rigs': 'Rigs',
'dash.stat.recipes': 'Load recipes',
'dash.stat.batches': 'Ammo batches',
'dash.quicklinks': 'Quick links',
'dash.quicklink.gears': 'Manage gears & rigs',
'dash.quicklink.reloads': 'Reload development',
'dash.quicklink.sessions': 'Shooting sessions',
'dash.quicklink.chrono': 'Chronograph analyses',
'dash.quicklink.profile': 'My profile',
'profile.link.analyses': 'My analyses',
'profile.link.sessions': 'My sessions',
'profile.link.photos': 'My photos',
'profile.link.gears': 'My gears',
// ── Gear page ──
'gear.title': 'Gears & Rigs',
'gear.tab.inv': 'My Inventory',
'gear.tab.rigs': 'My Rigs',
'gear.my.gear': 'My gear',
'gear.add': 'Add gear',
'gear.my.rigs': 'My rigs',
'gear.new.rig': 'New rig',
'gear.empty.inv': "You haven't added any gear yet.",
'gear.empty.rigs': "You haven't created any rig yet.",
// ── Reloads page ──
'reload.title': 'Reload Development',
'reload.recipes': 'Recipes',
'reload.no.sel': 'Select a recipe to view its batches',
// ── Profile page ──
'profile.title': 'My Profile',
'profile.tab.prof': 'Profile',
'profile.tab.pw': 'Change password',
'profile.avatar.upload': 'Upload avatar',
'profile.personal.title': 'Personal information',
'profile.form.first_name': 'First name',
'profile.form.last_name': 'Last name',
'profile.form.email': 'Email',
'profile.form.email.ro': '(read-only)',
'profile.form.save': 'Save changes',
'profile.pw.title': 'Change password',
'profile.form.pw.current': 'Current password',
'profile.form.pw.new': 'New password',
'profile.form.pw.confirm': 'Confirm new password',
'profile.form.pw.change': 'Change password',
'profile.rigs.title': 'My rigs',
'profile.rigs.empty': 'You have no rigs yet.',
'profile.table.name': 'Name',
'profile.table.items': 'Items',
'profile.table.visibility': 'Visibility',
// ── Admin page ──
'admin.title': 'Administration',
'admin.tab.users': 'Users',
'admin.tab.catalog': 'Gear catalog',
'admin.tab.comps': 'Reload components',
'admin.tab.calibers': 'Calibers',
'admin.add.gear': 'Add gear item',
'admin.users.new': 'New user',
'admin.users.create.title': 'Create new user',
'admin.users.username': 'Username',
'admin.users.email': 'Email',
'admin.users.password': 'Password',
'admin.users.staff': 'Staff / Admin',
'admin.users.first_name': 'First name',
'admin.users.last_name': 'Last name',
'admin.users.create.btn': 'Create user',
'admin.table.username': 'Username',
'admin.table.email': 'Email',
'admin.table.name': 'Name',
'admin.table.staff': 'Staff',
'admin.table.active': 'Active',
'admin.table.joined': 'Joined',
'admin.filter.all_types': 'All types',
'admin.filter.firearms': 'Firearms',
'admin.filter.scopes': 'Scopes',
'admin.filter.suppressors': 'Suppressors',
'admin.filter.bipods': 'Bipods',
'admin.filter.magazines': 'Magazines',
'admin.filter.all_statuses': 'All statuses',
'admin.filter.pending': 'Pending',
'admin.filter.verified': 'Verified',
'admin.filter.rejected': 'Rejected',
'admin.catalog.col.type': 'Type',
'admin.catalog.col.brand': 'Brand',
'admin.catalog.col.model': 'Model',
'admin.catalog.col.caliber': 'Caliber',
'admin.catalog.col.status': 'Status',
'admin.add.btn': 'Add to catalog',
'admin.comp.primers': 'Primers',
'admin.comp.brass': 'Brass',
'admin.comp.bullets': 'Bullets',
'admin.comp.powders': 'Powders',
// ── Tools page ──
'tools.title': 'Tools',
'tools.subtitle': 'Standalone ballistics and reloading tools — no account required.',
'tools.ballistics.title': 'Ballistics Calculator',
'tools.ballistics.desc': 'Point-mass trajectory — drop, wind drift, corrections in MOA/MRAD. No account required.',
'tools.ballistics.soon': 'Coming soon — trajectory, wind drift and holdover charts.',
'tools.chrono.title': 'Chronograph Analyser',
'tools.chrono.desc': 'Upload a CSV from your chrono — get group detection, SD, ES, velocity charts and a PDF report.',
'tools.open': 'Open',
'tools.groupsize.title': 'Group Size Calculator',
'tools.groupsize.desc': 'Annotate a target photo — measure ES, mean radius and scope correction in mm and MOA.',
'tools.oal.title': 'OAL Calculator',
'tools.oal.soon': 'Coming soon — calculate optimal cartridge overall length.',
// ── Sessions page ──
'sessions.title': 'Shooting Sessions',
'sessions.subtitle': 'Prepare and record PRS, free practice, or speed shooting sessions.',
'sessions.soon': 'Coming soon — session logging and analysis will be available here.',
'sessions.milestone': 'Session logging is part of the next milestone. Stay tuned!',
'sessions.tab.prs': 'PRS Match',
'sessions.tab.fp': 'Free Practice',
'sessions.tab.speed': 'Speed Shooting',
'sessions.new': 'New Session',
'sessions.none': 'Select a session or create a new one',
'sessions.empty': 'No sessions yet.',
'sessions.delete.confirm': 'Delete this session and all its data?',
'sessions.meta.competition': 'Competition',
'sessions.meta.category': 'Category',
'sessions.meta.date': 'Date',
'sessions.meta.location': 'Location',
'sessions.meta.rig': 'Rig',
'sessions.meta.ammo': 'Ammo',
'sessions.meta.distance': 'Distance',
'sessions.meta.target': 'Target',
'sessions.meta.rounds': 'Rounds fired',
'sessions.meta.format': 'Format',
'sessions.weather.title': 'Weather conditions',
'sessions.weather.temp': 'Temp (°C)',
'sessions.weather.wind': 'Wind (m/s)',
'sessions.weather.dir': 'Dir (°)',
'sessions.weather.humidity': 'Humidity (%)',
'sessions.weather.pressure': 'Pressure (hPa)',
'sessions.weather.notes': 'Notes',
'sessions.weather.save': 'Save weather',
'sessions.analysis.title': 'Linked analysis',
'sessions.analysis.none': 'No analysis linked.',
'sessions.analysis.link': 'Link analysis',
'sessions.analysis.unlink':'Unlink',
'sessions.analysis.view': 'View in Chronograph',
'sessions.analysis.photos':'Photos from this analysis',
'sessions.stages.title': 'Stages',
'sessions.stages.add': 'Add Stage',
'sessions.stages.none': 'No stages yet. Add the first stage to start planning.',
'sessions.stage.prep': 'Prep',
'sessions.stage.corrections': 'Scope corrections',
'sessions.stage.results': 'Results',
'sessions.stage.target': 'Target',
'sessions.stage.maxtime': 'Max time',
'sessions.stage.shots': 'Shots',
'sessions.stage.notes': 'Notes',
'sessions.stage.computed.elev': 'Computed elev.',
'sessions.stage.computed.wind': 'Computed wind.',
'sessions.stage.actual.elev': 'Actual elev.',
'sessions.stage.actual.wind': 'Actual wind.',
'sessions.stage.hits': 'Hits',
'sessions.stage.score': 'Score',
'sessions.stage.time': 'Time (s)',
'sessions.stage.save.corrections': 'Save',
'sessions.stage.save.results': 'Save results',
'sessions.stage.badge.done': 'Results recorded',
'sessions.stage.badge.pending': 'Pending',
'sessions.create.name': 'Session name',
'sessions.create.date': 'Date',
'sessions.create.location':'Location',
'sessions.create.competition': 'Competition name',
'sessions.create.category':'Category',
'sessions.create.distance':'Distance (m)',
'sessions.create.target': 'Target description',
'sessions.create.format': 'Format',
'sessions.create.rig': 'Rig',
'sessions.create.ammo': 'Ammo',
'sessions.create.ammo.none':'None',
'sessions.create.ammo.factory':'Factory',
'sessions.create.ammo.reload': 'Reloaded batch',
'sessions.create.ammo.suggest': "Can't find your ammo? Suggest it",
'sessions.create.ammo.suggest.note': 'Your suggestion will be visible to all once an admin verifies it.',
'sessions.stage.modal.title': 'Add Stage',
'sessions.stage.modal.order': 'Stage #',
'sessions.stage.modal.position': 'Position',
'sessions.stage.modal.distance': 'Distance (m)',
'sessions.stage.modal.maxtime': 'Max time (s)',
'sessions.stage.modal.shots': 'Shots',
'sessions.stage.modal.tw': 'Target W (cm)',
'sessions.stage.modal.th': 'Target H (cm)',
'sessions.stage.modal.notes': 'Prep notes',
// ── Common ──
'btn.save': 'Save',
'btn.cancel': 'Cancel',
'btn.delete': 'Delete',
'btn.add': 'Add',
'btn.edit': 'Edit',
'btn.verify': 'Verify',
'btn.reject': 'Reject',
'btn.search': 'Search',
'btn.submit': 'Submit',
},
fr: {
'nav.dashboard': 'Tableau de bord',
'nav.gear': 'Mon équipement',
'nav.reloads': 'Mes rechargements',
'nav.tools': 'Outils',
'nav.photos': 'Photos',
'nav.sessions': 'Sessions',
'nav.profile': 'Profil',
'nav.signout': 'Se déconnecter',
'nav.signin': 'Se connecter',
'nav.register': "S'inscrire",
'nav.admin': 'Administration',
'index.lead': 'Votre plateforme tout-en-un pour gérer vos armes et équipements, journaliser vos performances, développer des rechargements et partager vos résultats.',
'index.cta': 'Commencer gratuitement',
'index.cta2': 'Explorer les outils',
'index.cta3': 'Prêt à améliorer vos performances ?',
'index.cta4': 'Créer votre compte',
'index.feat.title': 'Tout ce dont un tireur sérieux a besoin',
'index.feat.sub': "Du premier tir à l'analyse de niveau compétition.",
'index.feat.gear.title': 'Inventaire équipement',
'index.feat.gear.desc': "Cataloguez chaque arme, lunette, suppresseur, bipied et chargeur que vous possédez. Créez des configurations personnalisées et partagez-les.",
'index.feat.sessions.title': 'Journal de sessions',
'index.feat.sessions.desc': 'Enregistrez chaque session de tir avec les données chrono, groupes de tirs, conditions météo et notes au même endroit.',
'index.feat.analysis.title': 'Analyse de performance',
'index.feat.analysis.desc': "Visualisez l'écart-type de vitesse, l'écart extrême, la taille des groupes dans le temps et identifiez vos tendances.",
'index.feat.reload.title': 'Développement de charges',
'index.feat.reload.desc': "Créez des recettes de charge, variez la quantité de poudre entre lots, liez chaque lot à des groupes de tirs et trouvez la charge la plus précise.",
'index.recent.title': 'Analyses récentes',
'index.recent.subtitle': 'Sessions chronographe partagées publiquement — aucun compte requis.',
'index.recent.upload': 'Importer la vôtre',
'index.recent.empty': 'Aucune analyse importée pour l\'instant. Soyez le premier !',
'dash.title': 'Tableau de bord',
'dash.welcome': 'Bon retour !',
'dash.welcome.name': 'Bon retour, {name} !',
'dash.stat.gear': 'Équipements',
'dash.stat.rigs': 'Configurations',
'dash.stat.recipes': 'Recettes de charge',
'dash.stat.batches': 'Lots de munitions',
'dash.quicklinks': 'Accès rapide',
'dash.quicklink.gears': 'Gérer équipements & configs',
'dash.quicklink.reloads': 'Développement de charges',
'dash.quicklink.sessions': 'Sessions de tir',
'dash.quicklink.chrono': 'Analyses chronographe',
'dash.quicklink.profile': 'Mon profil',
'profile.link.analyses': 'Mes analyses',
'profile.link.sessions': 'Mes sessions',
'profile.link.photos': 'Mes photos',
'profile.link.gears': 'Mes équipements',
'gear.title': 'Équipement & Configs',
'gear.tab.inv': 'Mon inventaire',
'gear.tab.rigs': 'Mes configs',
'gear.my.gear': 'Mon équipement',
'gear.add': 'Ajouter équipement',
'gear.my.rigs': 'Mes configurations',
'gear.new.rig': 'Nouvelle config',
'gear.empty.inv': "Vous n'avez pas encore ajouté d'équipement.",
'gear.empty.rigs': "Vous n'avez pas encore créé de configuration.",
'reload.title': 'Développement de charges',
'reload.recipes': 'Recettes',
'reload.no.sel': 'Sélectionnez une recette pour voir ses lots',
'profile.title': 'Mon profil',
'profile.tab.prof': 'Profil',
'profile.tab.pw': 'Changer le mot de passe',
'profile.avatar.upload': 'Charger un avatar',
'profile.personal.title': 'Informations personnelles',
'profile.form.first_name': 'Prénom',
'profile.form.last_name': 'Nom de famille',
'profile.form.email': 'E-mail',
'profile.form.email.ro': '(lecture seule)',
'profile.form.save': 'Enregistrer',
'profile.pw.title': 'Changer le mot de passe',
'profile.form.pw.current': 'Mot de passe actuel',
'profile.form.pw.new': 'Nouveau mot de passe',
'profile.form.pw.confirm': 'Confirmer le nouveau mot de passe',
'profile.form.pw.change': 'Changer le mot de passe',
'profile.rigs.title': 'Mes configurations',
'profile.rigs.empty': "Vous n'avez pas encore de configuration.",
'profile.table.name': 'Nom',
'profile.table.items': 'Éléments',
'profile.table.visibility': 'Visibilité',
'admin.title': 'Administration',
'admin.tab.users': 'Utilisateurs',
'admin.tab.catalog': 'Catalogue équipement',
'admin.tab.comps': 'Composants de rechargement',
'admin.tab.calibers': 'Calibres',
'admin.add.gear': 'Ajouter un équipement',
'admin.users.new': 'Nouvel utilisateur',
'admin.users.create.title': 'Créer un nouvel utilisateur',
'admin.users.username': "Nom d'utilisateur",
'admin.users.email': 'E-mail',
'admin.users.password': 'Mot de passe',
'admin.users.staff': 'Personnel / Admin',
'admin.users.first_name': 'Prénom',
'admin.users.last_name': 'Nom de famille',
'admin.users.create.btn': "Créer l'utilisateur",
'admin.table.username': 'Utilisateur',
'admin.table.email': 'E-mail',
'admin.table.name': 'Nom',
'admin.table.staff': 'Personnel',
'admin.table.active': 'Actif',
'admin.table.joined': 'Inscription',
'admin.filter.all_types': 'Tous types',
'admin.filter.firearms': 'Armes à feu',
'admin.filter.scopes': 'Lunettes',
'admin.filter.suppressors': 'Suppresseurs',
'admin.filter.bipods': 'Bipodes',
'admin.filter.magazines': 'Chargeurs',
'admin.filter.all_statuses': 'Tous statuts',
'admin.filter.pending': 'En attente',
'admin.filter.verified': 'Vérifié',
'admin.filter.rejected': 'Rejeté',
'admin.catalog.col.type': 'Type',
'admin.catalog.col.brand': 'Marque',
'admin.catalog.col.model': 'Modèle',
'admin.catalog.col.caliber': 'Calibre',
'admin.catalog.col.status': 'Statut',
'admin.add.btn': 'Ajouter au catalogue',
'admin.comp.primers': 'Amorces',
'admin.comp.brass': 'Douilles',
'admin.comp.bullets': 'Projectiles',
'admin.comp.powders': 'Poudres',
'tools.title': 'Outils',
'tools.subtitle': 'Outils balistiques et de rechargement — aucun compte requis.',
'tools.ballistics.title': 'Calculateur balistique',
'tools.ballistics.desc': 'Trajectoire point-masse — chute, dérive au vent, corrections MOA/MRAD. Sans compte.',
'tools.ballistics.soon': 'Bientôt — courbes de trajectoire, dérive au vent et compensations.',
'tools.chrono.title': 'Analyseur chronographe',
'tools.chrono.desc': 'Importez un CSV de votre chrono — détection de groupes, ET, ES, courbes de vitesse et rapport PDF.',
'tools.open': 'Ouvrir',
'tools.groupsize.title': 'Calculateur de groupe',
'tools.groupsize.desc': 'Annotez une photo de cible — mesurez l\'ES, le rayon moyen et la correction lunette en mm et MOA.',
'tools.oal.title': 'Calculateur OAL',
'tools.oal.soon': 'Bientôt — calculez la longueur totale optimale de la cartouche.',
'sessions.title': 'Sessions de tir',
'sessions.subtitle': 'Préparez et enregistrez vos sessions PRS, entraînement libre ou tir de vitesse.',
'sessions.soon': "Bientôt disponible — l'enregistrement et l'analyse des sessions seront disponibles ici.",
'sessions.milestone': "L'enregistrement des sessions fait partie du prochain jalon. Restez à l'écoute !",
'sessions.tab.prs': 'Match PRS',
'sessions.tab.fp': 'Entraînement libre',
'sessions.tab.speed': 'Tir de vitesse',
'sessions.new': 'Nouvelle session',
'sessions.none': 'Sélectionnez une session ou créez-en une nouvelle',
'sessions.empty': 'Aucune session.',
'sessions.delete.confirm': 'Supprimer cette session et toutes ses données ?',
'sessions.meta.competition': 'Compétition',
'sessions.meta.category': 'Catégorie',
'sessions.meta.date': 'Date',
'sessions.meta.location': 'Lieu',
'sessions.meta.rig': 'Configuration',
'sessions.meta.ammo': 'Munitions',
'sessions.meta.distance': 'Distance',
'sessions.meta.target': 'Cible',
'sessions.meta.rounds': 'Cartouches tirées',
'sessions.meta.format': 'Format',
'sessions.weather.title': 'Conditions météo',
'sessions.weather.temp': 'Temp (°C)',
'sessions.weather.wind': 'Vent (m/s)',
'sessions.weather.dir': 'Dir (°)',
'sessions.weather.humidity': 'Humidité (%)',
'sessions.weather.pressure': 'Pression (hPa)',
'sessions.weather.notes': 'Notes',
'sessions.weather.save': 'Enregistrer météo',
'sessions.analysis.title': 'Analyse liée',
'sessions.analysis.none': 'Aucune analyse liée.',
'sessions.analysis.link': 'Lier une analyse',
'sessions.analysis.unlink':'Délier',
'sessions.analysis.view': 'Voir dans le chronographe',
'sessions.analysis.photos':'Photos de cette analyse',
'sessions.stages.title': 'Stands',
'sessions.stages.add': 'Ajouter un stand',
'sessions.stages.none': 'Aucun stand. Ajoutez le premier pour commencer la préparation.',
'sessions.stage.prep': 'Préparation',
'sessions.stage.corrections': 'Corrections lunette',
'sessions.stage.results': 'Résultats',
'sessions.stage.target': 'Cible',
'sessions.stage.maxtime': 'Temps max',
'sessions.stage.shots': 'Tirs',
'sessions.stage.notes': 'Notes',
'sessions.stage.computed.elev': 'Élév. calculée',
'sessions.stage.computed.wind': 'Dérive calculée',
'sessions.stage.actual.elev': 'Élév. réelle',
'sessions.stage.actual.wind': 'Dérive réelle',
'sessions.stage.hits': 'Touches',
'sessions.stage.score': 'Score',
'sessions.stage.time': 'Temps (s)',
'sessions.stage.save.corrections': 'Enregistrer',
'sessions.stage.save.results': 'Enregistrer résultats',
'sessions.stage.badge.done': 'Résultats enregistrés',
'sessions.stage.badge.pending': 'En attente',
'sessions.create.name': 'Nom de la session',
'sessions.create.date': 'Date',
'sessions.create.location':'Lieu',
'sessions.create.competition': 'Nom de la compétition',
'sessions.create.category':'Catégorie',
'sessions.create.distance':'Distance (m)',
'sessions.create.target': 'Description de la cible',
'sessions.create.format': 'Format',
'sessions.create.rig': 'Configuration',
'sessions.create.ammo': 'Munitions',
'sessions.create.ammo.none':'Aucune',
'sessions.create.ammo.factory':'Industrielles',
'sessions.create.ammo.reload': 'Lot rechargé',
'sessions.create.ammo.suggest': 'Munitions introuvables ? Suggérez-les',
'sessions.create.ammo.suggest.note': 'Votre suggestion sera visible de tous après vérification par un administrateur.',
'sessions.stage.modal.title': 'Ajouter un stand',
'sessions.stage.modal.order': 'Stand n°',
'sessions.stage.modal.position': 'Position',
'sessions.stage.modal.distance': 'Distance (m)',
'sessions.stage.modal.maxtime': 'Temps max (s)',
'sessions.stage.modal.shots': 'Tirs',
'sessions.stage.modal.tw': 'Largeur cible (cm)',
'sessions.stage.modal.th': 'Hauteur cible (cm)',
'sessions.stage.modal.notes': 'Notes de préparation',
'btn.save': 'Enregistrer',
'btn.cancel': 'Annuler',
'btn.delete': 'Supprimer',
'btn.add': 'Ajouter',
'btn.edit': 'Modifier',
'btn.verify': 'Vérifier',
'btn.reject': 'Rejeter',
'btn.search': 'Rechercher',
'btn.submit': 'Soumettre',
},
de: {
'nav.dashboard': 'Übersicht',
'nav.gear': 'Mein Equipment',
'nav.reloads': 'Meine Ladungen',
'nav.tools': 'Werkzeuge',
'nav.photos': 'Fotos',
'nav.sessions': 'Sessions',
'nav.profile': 'Profil',
'nav.signout': 'Abmelden',
'nav.signin': 'Anmelden',
'nav.register': 'Registrieren',
'nav.admin': 'Administration',
'index.lead': 'Ihre Rundum-Plattform zur Verwaltung von Waffen und Ausrüstung, Protokollierung von Schießleistungen, Ladungsentwicklung und Teilen Ihrer Ergebnisse.',
'index.cta': 'Kostenlos starten',
'index.cta2': 'Tools entdecken',
'index.cta3': 'Bereit, Ihr Schießen zu verbessern?',
'index.cta4': 'Konto erstellen',
'index.feat.title': 'Alles, was ein ernsthafter Schütze braucht',
'index.feat.sub': 'Vom ersten Schuss bis zur Wettkampfanalyse.',
'index.feat.gear.title': 'Ausrüstungsinventar',
'index.feat.gear.desc': 'Katalogisieren Sie jede Waffe, jedes Zielfernrohr, jeden Schalldämpfer, jedes Zweibein und jedes Magazin. Erstellen und teilen Sie Konfigurationen.',
'index.feat.sessions.title': 'Session-Protokoll',
'index.feat.sessions.desc': 'Protokollieren Sie jede Schießsitzung mit Chronodaten, Gruppen, Wetterbedingungen und Notizen an einem Ort.',
'index.feat.analysis.title': 'Leistungsanalyse',
'index.feat.analysis.desc': 'Visualisieren Sie Geschwindigkeits-SD, ES, Gruppengrößen über Zeit und erkennen Sie Trends in Ihrer Schießleistung.',
'index.feat.reload.title': 'Ladungsentwicklung',
'index.feat.reload.desc': 'Erstellen Sie Ladungsrezepte, variieren Sie die Pulvermenge, verknüpfen Sie Chargen mit Schussgruppen und finden Sie die genaueste Ladung.',
'index.recent.title': 'Aktuelle Analysen',
'index.recent.subtitle': 'Öffentlich geteilte Chronographen-Sitzungen — kein Konto zum Ansehen erforderlich.',
'index.recent.upload': 'Eigene hochladen',
'index.recent.empty': 'Noch keine Analysen hochgeladen. Seien Sie der Erste!',
'dash.title': 'Übersicht',
'dash.welcome': 'Willkommen zurück!',
'dash.welcome.name': 'Willkommen zurück, {name}!',
'dash.stat.gear': 'Ausrüstung',
'dash.stat.rigs': 'Konfigurationen',
'dash.stat.recipes': 'Ladungsrezepte',
'dash.stat.batches': 'Munitionschargen',
'dash.quicklinks': 'Schnellzugriff',
'dash.quicklink.gears': 'Ausrüstung & Konfigurationen',
'dash.quicklink.reloads': 'Ladungsentwicklung',
'dash.quicklink.sessions': 'Schießsitzungen',
'dash.quicklink.chrono': 'Chronographenanalysen',
'dash.quicklink.profile': 'Mein Profil',
'profile.link.analyses': 'Meine Analysen',
'profile.link.sessions': 'Meine Sitzungen',
'profile.link.photos': 'Meine Fotos',
'profile.link.gears': 'Meine Ausrüstung',
'gear.title': 'Ausrüstung & Konfigurationen',
'gear.tab.inv': 'Mein Inventar',
'gear.tab.rigs': 'Meine Konfigs',
'gear.my.gear': 'Meine Ausrüstung',
'gear.add': 'Ausrüstung hinzufügen',
'gear.my.rigs': 'Meine Konfigurationen',
'gear.new.rig': 'Neue Konfiguration',
'gear.empty.inv': 'Sie haben noch keine Ausrüstung hinzugefügt.',
'gear.empty.rigs': 'Sie haben noch keine Konfiguration erstellt.',
'reload.title': 'Ladungsentwicklung',
'reload.recipes': 'Rezepte',
'reload.no.sel': 'Wählen Sie ein Rezept aus, um die Chargen anzuzeigen',
'profile.title': 'Mein Profil',
'profile.tab.prof': 'Profil',
'profile.tab.pw': 'Passwort ändern',
'profile.avatar.upload': 'Avatar hochladen',
'profile.personal.title': 'Persönliche Angaben',
'profile.form.first_name': 'Vorname',
'profile.form.last_name': 'Nachname',
'profile.form.email': 'E-Mail',
'profile.form.email.ro': '(schreibgeschützt)',
'profile.form.save': 'Speichern',
'profile.pw.title': 'Passwort ändern',
'profile.form.pw.current': 'Aktuelles Passwort',
'profile.form.pw.new': 'Neues Passwort',
'profile.form.pw.confirm': 'Neues Passwort bestätigen',
'profile.form.pw.change': 'Passwort ändern',
'profile.rigs.title': 'Meine Konfigurationen',
'profile.rigs.empty': 'Sie haben noch keine Konfigurationen.',
'profile.table.name': 'Name',
'profile.table.items': 'Elemente',
'profile.table.visibility': 'Sichtbarkeit',
'admin.title': 'Administration',
'admin.tab.users': 'Benutzer',
'admin.tab.catalog': 'Ausrüstungskatalog',
'admin.tab.comps': 'Ladekomponenten',
'admin.tab.calibers': 'Kaliber',
'admin.add.gear': 'Ausrüstung hinzufügen',
'admin.users.new': 'Neuer Benutzer',
'admin.users.create.title': 'Neuen Benutzer erstellen',
'admin.users.username': 'Benutzername',
'admin.users.email': 'E-Mail',
'admin.users.password': 'Passwort',
'admin.users.staff': 'Mitarbeiter / Admin',
'admin.users.first_name': 'Vorname',
'admin.users.last_name': 'Nachname',
'admin.users.create.btn': 'Benutzer erstellen',
'admin.table.username': 'Benutzername',
'admin.table.email': 'E-Mail',
'admin.table.name': 'Name',
'admin.table.staff': 'Mitarbeiter',
'admin.table.active': 'Aktiv',
'admin.table.joined': 'Beigetreten',
'admin.filter.all_types': 'Alle Typen',
'admin.filter.firearms': 'Schusswaffen',
'admin.filter.scopes': 'Zielfernrohre',
'admin.filter.suppressors': 'Schalldämpfer',
'admin.filter.bipods': 'Zweibeine',
'admin.filter.magazines': 'Magazine',
'admin.filter.all_statuses': 'Alle Status',
'admin.filter.pending': 'Ausstehend',
'admin.filter.verified': 'Bestätigt',
'admin.filter.rejected': 'Abgelehnt',
'admin.catalog.col.type': 'Typ',
'admin.catalog.col.brand': 'Marke',
'admin.catalog.col.model': 'Modell',
'admin.catalog.col.caliber': 'Kaliber',
'admin.catalog.col.status': 'Status',
'admin.add.btn': 'Zum Katalog hinzufügen',
'admin.comp.primers': 'Zündhütchen',
'admin.comp.brass': 'Hülsen',
'admin.comp.bullets': 'Geschosse',
'admin.comp.powders': 'Pulver',
'tools.title': 'Werkzeuge',
'tools.subtitle': 'Ballistische Werkzeuge und Ladewerkzeuge — kein Konto erforderlich.',
'tools.ballistics.title': 'Ballistikrechner',
'tools.ballistics.desc': 'Punktmasse-Trajektorie — Abfall, Windabdrift, Korrekturen in MOA/MRAD. Ohne Konto.',
'tools.ballistics.soon': 'Demnächst — Flugbahn, Windabdrift und Haltepunktdiagramme.',
'tools.chrono.title': 'Chronograph-Analysator',
'tools.chrono.desc': 'CSV von Ihrem Chrono hochladen — Gruppenerkennung, SD, ES, Geschwindigkeitskurven und PDF-Bericht.',
'tools.open': 'Öffnen',
'tools.groupsize.title': 'Gruppengrößenrechner',
'tools.groupsize.desc': 'Zielscheibenfoto annotieren — ES, mittleren Radius und Richtkorrekturen in mm und MOA messen.',
'tools.oal.title': 'OAL-Rechner',
'tools.oal.soon': 'Demnächst — optimale Gesamtlänge der Patrone berechnen.',
'sessions.title': 'Schießsitzungen',
'sessions.subtitle': 'PRS-, Freiübungs- oder Schnellschießsitzungen vorbereiten und aufzeichnen.',
'sessions.soon': 'Demnächst — Sitzungsprotokoll und Analyse werden hier verfügbar sein.',
'sessions.milestone': 'Die Sitzungsprotokollierung ist Teil des nächsten Meilensteins. Bleiben Sie dran!',
'sessions.tab.prs': 'PRS-Match',
'sessions.tab.fp': 'Freiübung',
'sessions.tab.speed': 'Schnellschießen',
'sessions.new': 'Neue Sitzung',
'sessions.none': 'Sitzung auswählen oder neue erstellen',
'sessions.empty': 'Keine Sitzungen vorhanden.',
'sessions.delete.confirm': 'Diese Sitzung und alle Daten löschen?',
'sessions.meta.competition': 'Wettkampf',
'sessions.meta.category': 'Kategorie',
'sessions.meta.date': 'Datum',
'sessions.meta.location': 'Ort',
'sessions.meta.rig': 'Konfiguration',
'sessions.meta.ammo': 'Munition',
'sessions.meta.distance': 'Entfernung',
'sessions.meta.target': 'Ziel',
'sessions.meta.rounds': 'Abgefeuerte Schüsse',
'sessions.meta.format': 'Format',
'sessions.weather.title': 'Wetterbedingungen',
'sessions.weather.temp': 'Temp (°C)',
'sessions.weather.wind': 'Wind (m/s)',
'sessions.weather.dir': 'Richtung (°)',
'sessions.weather.humidity': 'Luftfeuchtigkeit (%)',
'sessions.weather.pressure': 'Druck (hPa)',
'sessions.weather.notes': 'Notizen',
'sessions.weather.save': 'Wetter speichern',
'sessions.analysis.title': 'Verknüpfte Analyse',
'sessions.analysis.none': 'Keine Analyse verknüpft.',
'sessions.analysis.link': 'Analyse verknüpfen',
'sessions.analysis.unlink':'Verknüpfung lösen',
'sessions.analysis.view': 'Im Chronograph anzeigen',
'sessions.analysis.photos':'Fotos dieser Analyse',
'sessions.stages.title': 'Stände',
'sessions.stages.add': 'Stand hinzufügen',
'sessions.stages.none': 'Noch keine Stände. Ersten Stand hinzufügen.',
'sessions.stage.prep': 'Vorbereitung',
'sessions.stage.corrections': 'Absehenkorrekturen',
'sessions.stage.results': 'Ergebnisse',
'sessions.stage.target': 'Ziel',
'sessions.stage.maxtime': 'Max. Zeit',
'sessions.stage.shots': 'Schüsse',
'sessions.stage.notes': 'Notizen',
'sessions.stage.computed.elev': 'Ber. Höhe',
'sessions.stage.computed.wind': 'Ber. Wind',
'sessions.stage.actual.elev': 'Tats. Höhe',
'sessions.stage.actual.wind': 'Tats. Wind',
'sessions.stage.hits': 'Treffer',
'sessions.stage.score': 'Punkte',
'sessions.stage.time': 'Zeit (s)',
'sessions.stage.save.corrections': 'Speichern',
'sessions.stage.save.results': 'Ergebnisse speichern',
'sessions.stage.badge.done': 'Ergebnisse gespeichert',
'sessions.stage.badge.pending': 'Ausstehend',
'sessions.create.name': 'Sitzungsname',
'sessions.create.date': 'Datum',
'sessions.create.location':'Ort',
'sessions.create.competition': 'Wettkampfname',
'sessions.create.category':'Kategorie',
'sessions.create.distance':'Entfernung (m)',
'sessions.create.target': 'Zielbeschreibung',
'sessions.create.format': 'Format',
'sessions.create.rig': 'Konfiguration',
'sessions.create.ammo': 'Munition',
'sessions.create.ammo.none':'Keine',
'sessions.create.ammo.factory':'Fabrikmunition',
'sessions.create.ammo.reload': 'Wiederladung',
'sessions.create.ammo.suggest': 'Munition nicht gefunden? Vorschlagen',
'sessions.create.ammo.suggest.note': 'Ihr Vorschlag wird nach Admin-Prüfung für alle sichtbar.',
'sessions.stage.modal.title': 'Stand hinzufügen',
'sessions.stage.modal.order': 'Stand-Nr.',
'sessions.stage.modal.position': 'Position',
'sessions.stage.modal.distance': 'Entfernung (m)',
'sessions.stage.modal.maxtime': 'Max. Zeit (s)',
'sessions.stage.modal.shots': 'Schüsse',
'sessions.stage.modal.tw': 'Zielbreite (cm)',
'sessions.stage.modal.th': 'Zielhöhe (cm)',
'sessions.stage.modal.notes': 'Vorbereitungsnotizen',
'btn.save': 'Speichern',
'btn.cancel': 'Abbrechen',
'btn.delete': 'Löschen',
'btn.add': 'Hinzufügen',
'btn.edit': 'Bearbeiten',
'btn.verify': 'Bestätigen',
'btn.reject': 'Ablehnen',
'btn.search': 'Suchen',
'btn.submit': 'Absenden',
},
es: {
'nav.dashboard': 'Panel',
'nav.gear': 'Mi equipamiento',
'nav.reloads': 'Mis recargas',
'nav.tools': 'Herramientas',
'nav.photos': 'Fotos',
'nav.sessions': 'Sesiones',
'nav.profile': 'Perfil',
'nav.signout': 'Cerrar sesión',
'nav.signin': 'Iniciar sesión',
'nav.register': 'Registrarse',
'nav.admin': 'Administración',
'index.lead': 'Su plataforma integral para gestionar armas y equipamiento, registrar rendimiento, desarrollar recargas y compartir resultados con otros tiradores.',
'index.cta': 'Empezar gratis',
'index.cta2': 'Explorar herramientas',
'index.cta3': '¿Listo para mejorar su tiro?',
'index.cta4': 'Crear cuenta',
'index.feat.title': 'Todo lo que necesita un tirador serio',
'index.feat.sub': 'Desde el primer disparo hasta el análisis de competición.',
'index.feat.gear.title': 'Inventario de equipamiento',
'index.feat.gear.desc': 'Catalogue cada arma, mira, silenciador, bípode y cargador que posee. Cree configuraciones personalizadas y compártalas.',
'index.feat.sessions.title': 'Registro de sesiones',
'index.feat.sessions.desc': 'Registre cada sesión de tiro con datos de cronógrafo, grupos, condiciones meteorológicas y notas en un solo lugar.',
'index.feat.analysis.title': 'Análisis de rendimiento',
'index.feat.analysis.desc': 'Visualice la desviación típica, el extremo de la serie, tamaño de grupos a lo largo del tiempo e identifique tendencias.',
'index.feat.reload.title': 'Desarrollo de recargas',
'index.feat.reload.desc': 'Cree recetas de carga, varíe la carga de pólvora entre lotes, vincule cada lote a grupos de disparos y encuentre la carga más precisa.',
'index.recent.title': 'Análisis recientes',
'index.recent.subtitle': 'Sesiones de cronógrafo compartidas públicamente — no se requiere cuenta para ver.',
'index.recent.upload': 'Subir la suya',
'index.recent.empty': '¡Aún no hay análisis. Sea el primero!',
'dash.title': 'Panel',
'dash.welcome': '¡Bienvenido de nuevo!',
'dash.welcome.name': '¡Bienvenido de nuevo, {name}!',
'dash.stat.gear': 'Equipamiento',
'dash.stat.rigs': 'Configuraciones',
'dash.stat.recipes': 'Recetas de carga',
'dash.stat.batches': 'Lotes de munición',
'dash.quicklinks': 'Acceso rápido',
'dash.quicklink.gears': 'Gestionar equipamiento y configs',
'dash.quicklink.reloads': 'Desarrollo de recargas',
'dash.quicklink.sessions': 'Sesiones de tiro',
'dash.quicklink.chrono': 'Análisis de cronógrafo',
'dash.quicklink.profile': 'Mi perfil',
'profile.link.analyses': 'Mis análisis',
'profile.link.sessions': 'Mis sesiones',
'profile.link.photos': 'Mis fotos',
'profile.link.gears': 'Mi equipamiento',
'gear.title': 'Equipamiento y configs',
'gear.tab.inv': 'Mi inventario',
'gear.tab.rigs': 'Mis configs',
'gear.my.gear': 'Mi equipamiento',
'gear.add': 'Añadir equipamiento',
'gear.my.rigs': 'Mis configuraciones',
'gear.new.rig': 'Nueva config',
'gear.empty.inv': 'Aún no ha añadido equipamiento.',
'gear.empty.rigs': 'Aún no ha creado ninguna configuración.',
'reload.title': 'Desarrollo de cargas',
'reload.recipes': 'Recetas',
'reload.no.sel': 'Seleccione una receta para ver sus lotes',
'profile.title': 'Mi perfil',
'profile.tab.prof': 'Perfil',
'profile.tab.pw': 'Cambiar contraseña',
'profile.avatar.upload': 'Subir avatar',
'profile.personal.title': 'Información personal',
'profile.form.first_name': 'Nombre',
'profile.form.last_name': 'Apellido',
'profile.form.email': 'Correo electrónico',
'profile.form.email.ro': '(solo lectura)',
'profile.form.save': 'Guardar cambios',
'profile.pw.title': 'Cambiar contraseña',
'profile.form.pw.current': 'Contraseña actual',
'profile.form.pw.new': 'Nueva contraseña',
'profile.form.pw.confirm': 'Confirmar nueva contraseña',
'profile.form.pw.change': 'Cambiar contraseña',
'profile.rigs.title': 'Mis configuraciones',
'profile.rigs.empty': 'Aún no tiene configuraciones.',
'profile.table.name': 'Nombre',
'profile.table.items': 'Elementos',
'profile.table.visibility': 'Visibilidad',
'admin.title': 'Administración',
'admin.tab.users': 'Usuarios',
'admin.tab.catalog': 'Catálogo de equipamiento',
'admin.tab.comps': 'Componentes de recarga',
'admin.tab.calibers': 'Calibres',
'admin.add.gear': 'Añadir equipamiento',
'admin.users.new': 'Nuevo usuario',
'admin.users.create.title': 'Crear nuevo usuario',
'admin.users.username': 'Nombre de usuario',
'admin.users.email': 'Correo electrónico',
'admin.users.password': 'Contraseña',
'admin.users.staff': 'Personal / Admin',
'admin.users.first_name': 'Nombre',
'admin.users.last_name': 'Apellido',
'admin.users.create.btn': 'Crear usuario',
'admin.table.username': 'Usuario',
'admin.table.email': 'Correo',
'admin.table.name': 'Nombre',
'admin.table.staff': 'Personal',
'admin.table.active': 'Activo',
'admin.table.joined': 'Registro',
'admin.filter.all_types': 'Todos los tipos',
'admin.filter.firearms': 'Armas de fuego',
'admin.filter.scopes': 'Miras telescópicas',
'admin.filter.suppressors': 'Silenciadores',
'admin.filter.bipods': 'Bípodes',
'admin.filter.magazines': 'Cargadores',
'admin.filter.all_statuses': 'Todos los estados',
'admin.filter.pending': 'Pendiente',
'admin.filter.verified': 'Verificado',
'admin.filter.rejected': 'Rechazado',
'admin.catalog.col.type': 'Tipo',
'admin.catalog.col.brand': 'Marca',
'admin.catalog.col.model': 'Modelo',
'admin.catalog.col.caliber': 'Calibre',
'admin.catalog.col.status': 'Estado',
'admin.add.btn': 'Añadir al catálogo',
'admin.comp.primers': 'Cebadores',
'admin.comp.brass': 'Vainas',
'admin.comp.bullets': 'Proyectiles',
'admin.comp.powders': 'Pólvoras',
'tools.title': 'Herramientas',
'tools.subtitle': 'Herramientas balísticas y de recarga — no se requiere cuenta.',
'tools.ballistics.title': 'Calculadora balística',
'tools.ballistics.desc': 'Trayectoria de masa puntual — caída, deriva por viento, correcciones MOA/MRAD. Sin cuenta.',
'tools.ballistics.soon': 'Próximamente — trayectorias, deriva por viento y compensaciones.',
'tools.chrono.title': 'Analizador de cronógrafo',
'tools.chrono.desc': 'Suba un CSV de su cronógrafo — detección de grupos, DT, ES, gráficas de velocidad e informe PDF.',
'tools.open': 'Abrir',
'tools.groupsize.title': 'Calculadora de grupo',
'tools.groupsize.desc': 'Anote una foto de blanco — mida ES, radio medio y corrección de mira en mm y MOA.',
'tools.oal.title': 'Calculadora OAL',
'tools.oal.soon': 'Próximamente — calcule la longitud total óptima del cartucho.',
'sessions.title': 'Sesiones de tiro',
'sessions.subtitle': 'Prepare y registre sesiones de PRS, práctica libre o tiro de velocidad.',
'sessions.soon': 'Próximamente — el registro y análisis de sesiones estará disponible aquí.',
'sessions.milestone': 'El registro de sesiones forma parte del próximo hito. ¡Estén atentos!',
'sessions.tab.prs': 'Match PRS',
'sessions.tab.fp': 'Práctica libre',
'sessions.tab.speed': 'Tiro de velocidad',
'sessions.new': 'Nueva sesión',
'sessions.none': 'Seleccione una sesión o cree una nueva',
'sessions.empty': 'Sin sesiones.',
'sessions.delete.confirm': '¿Eliminar esta sesión y todos sus datos?',
'sessions.meta.competition': 'Competición',
'sessions.meta.category': 'Categoría',
'sessions.meta.date': 'Fecha',
'sessions.meta.location': 'Lugar',
'sessions.meta.rig': 'Configuración',
'sessions.meta.ammo': 'Munición',
'sessions.meta.distance': 'Distancia',
'sessions.meta.target': 'Blanco',
'sessions.meta.rounds': 'Disparos realizados',
'sessions.meta.format': 'Formato',
'sessions.weather.title': 'Condiciones meteorológicas',
'sessions.weather.temp': 'Temp (°C)',
'sessions.weather.wind': 'Viento (m/s)',
'sessions.weather.dir': 'Dir (°)',
'sessions.weather.humidity': 'Humedad (%)',
'sessions.weather.pressure': 'Presión (hPa)',
'sessions.weather.notes': 'Notas',
'sessions.weather.save': 'Guardar clima',
'sessions.analysis.title': 'Análisis vinculado',
'sessions.analysis.none': 'Ningún análisis vinculado.',
'sessions.analysis.link': 'Vincular análisis',
'sessions.analysis.unlink':'Desvincular',
'sessions.analysis.view': 'Ver en cronógrafo',
'sessions.analysis.photos':'Fotos de este análisis',
'sessions.stages.title': 'Puestos',
'sessions.stages.add': 'Añadir puesto',
'sessions.stages.none': 'Sin puestos. Añada el primero para empezar.',
'sessions.stage.prep': 'Preparación',
'sessions.stage.corrections': 'Correcciones de mira',
'sessions.stage.results': 'Resultados',
'sessions.stage.target': 'Blanco',
'sessions.stage.maxtime': 'Tiempo máx.',
'sessions.stage.shots': 'Disparos',
'sessions.stage.notes': 'Notas',
'sessions.stage.computed.elev': 'Elev. calculada',
'sessions.stage.computed.wind': 'Deriv. calculada',
'sessions.stage.actual.elev': 'Elev. real',
'sessions.stage.actual.wind': 'Deriv. real',
'sessions.stage.hits': 'Impactos',
'sessions.stage.score': 'Puntuación',
'sessions.stage.time': 'Tiempo (s)',
'sessions.stage.save.corrections': 'Guardar',
'sessions.stage.save.results': 'Guardar resultados',
'sessions.stage.badge.done': 'Resultados registrados',
'sessions.stage.badge.pending': 'Pendiente',
'sessions.create.name': 'Nombre de sesión',
'sessions.create.date': 'Fecha',
'sessions.create.location':'Lugar',
'sessions.create.competition': 'Nombre de competición',
'sessions.create.category':'Categoría',
'sessions.create.distance':'Distancia (m)',
'sessions.create.target': 'Descripción del blanco',
'sessions.create.format': 'Formato',
'sessions.create.rig': 'Configuración',
'sessions.create.ammo': 'Munición',
'sessions.create.ammo.none':'Ninguna',
'sessions.create.ammo.factory':'Comercial',
'sessions.create.ammo.reload': 'Lote recargado',
'sessions.create.ammo.suggest': '¿No encuentra su munición? Sugiérala',
'sessions.create.ammo.suggest.note': 'Su sugerencia será visible para todos tras verificación del administrador.',
'sessions.stage.modal.title': 'Añadir puesto',
'sessions.stage.modal.order': 'Puesto n°',
'sessions.stage.modal.position': 'Posición',
'sessions.stage.modal.distance': 'Distancia (m)',
'sessions.stage.modal.maxtime': 'Tiempo máx. (s)',
'sessions.stage.modal.shots': 'Disparos',
'sessions.stage.modal.tw': 'Ancho blanco (cm)',
'sessions.stage.modal.th': 'Alto blanco (cm)',
'sessions.stage.modal.notes': 'Notas de preparación',
'btn.save': 'Guardar',
'btn.cancel': 'Cancelar',
'btn.delete': 'Eliminar',
'btn.add': 'Añadir',
'btn.edit': 'Editar',
'btn.verify': 'Verificar',
'btn.reject': 'Rechazar',
'btn.search': 'Buscar',
'btn.submit': 'Enviar',
},
};
function t(key) {
const lang = getLang();
const dict = TRANSLATIONS[lang] || TRANSLATIONS['en'];
return dict[key] || TRANSLATIONS['en'][key] || key;
}
function applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = t(el.dataset.i18nPlaceholder);
});
}

272
frontend/js/messages.js Normal file
View File

@@ -0,0 +1,272 @@
// ── Messages page ─────────────────────────────────────────────────────────────
const composeModal = new bootstrap.Modal('#composeModal');
let _currentTab = 'inbox'; // 'inbox' | 'sent'
let _messages = []; // current list
let _openId = null; // currently open message id
let _recipientId = null; // chosen recipient in compose
// ── Tab switching ─────────────────────────────────────────────────────────────
document.querySelectorAll('#msgTabs .nav-link').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#msgTabs .nav-link').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_currentTab = btn.dataset.tab;
_openId = null;
showDetail(null);
loadMessages();
});
});
// ── Load messages ─────────────────────────────────────────────────────────────
async function loadMessages() {
document.getElementById('msgList').innerHTML =
'<div class="text-center py-4 text-muted small">Loading…</div>';
try {
const url = _currentTab === 'sent' ? '/social/messages/sent/' : '/social/messages/';
_messages = await apiGet(url);
renderList();
loadUnreadCount();
} catch (e) {
document.getElementById('msgList').innerHTML =
'<div class="text-center py-3 text-danger small">Failed to load messages.</div>';
}
}
function renderList() {
const el = document.getElementById('msgList');
if (!_messages.length) {
el.innerHTML = '<div class="text-center py-4 text-muted small">No messages here yet.</div>';
return;
}
el.innerHTML = _messages.map(m => {
const other = _currentTab === 'sent' ? m.recipient_detail : m.sender_detail;
const isUnread = _currentTab === 'inbox' && !m.read_at;
const date = m.sent_at ? new Date(m.sent_at).toLocaleDateString() : '';
return `
<div class="msg-row d-flex align-items-start gap-2 ${isUnread ? 'unread' : ''} ${m.id === _openId ? 'active' : ''}"
data-id="${m.id}" onclick="openMessage(${m.id})">
${isUnread ? '<span class="unread-dot mt-1 flex-shrink-0"></span>' : '<span style="width:8px;flex-shrink:0"></span>'}
<div class="overflow-hidden flex-grow-1">
<div class="d-flex justify-content-between">
<span class="msg-subject">${esc(m.subject)}</span>
<span class="msg-meta ms-2">${esc(date)}</span>
</div>
<div class="msg-meta">${esc(other?.display_name || other?.username || '—')}</div>
</div>
</div>`;
}).join('');
}
// ── Open a message ────────────────────────────────────────────────────────────
async function openMessage(id) {
_openId = id;
// Highlight
document.querySelectorAll('.msg-row').forEach(r => {
r.classList.toggle('active', Number(r.dataset.id) === id);
});
try {
const msg = await apiGet(`/social/messages/${id}/`);
showDetail(msg);
// Mark as read visually
const row = document.querySelector(`.msg-row[data-id="${id}"]`);
if (row) row.classList.remove('unread');
// Refresh unread badge
loadUnreadCount();
} catch (e) {
showToast('Failed to load message.', 'danger');
}
}
function showDetail(msg) {
const empty = document.getElementById('detailEmpty');
const content = document.getElementById('detailContent');
if (!msg) {
empty.classList.remove('d-none');
content.classList.add('d-none');
return;
}
empty.classList.add('d-none');
content.classList.remove('d-none');
document.getElementById('detailSubject').textContent = msg.subject;
document.getElementById('detailBody').textContent = msg.body;
const from = msg.sender_detail?.display_name || msg.sender_detail?.username || '—';
const to = msg.recipient_detail?.display_name || msg.recipient_detail?.username || '—';
const date = msg.sent_at ? new Date(msg.sent_at).toLocaleString() : '';
document.getElementById('detailMeta').innerHTML = `
<span><i class="bi bi-person me-1"></i>From: <strong>${esc(from)}</strong></span>
<span><i class="bi bi-person-fill-check me-1"></i>To: <strong>${esc(to)}</strong></span>
<span><i class="bi bi-clock me-1"></i>${esc(date)}</span>`;
// Reply button prefills compose
document.getElementById('detailReplyBtn').onclick = () => {
if (_currentTab === 'inbox' && msg.sender_detail) {
prefillCompose(msg.sender_detail, `Re: ${msg.subject}`);
} else {
prefillCompose(msg.recipient_detail, `Re: ${msg.subject}`);
}
};
}
// ── Delete ────────────────────────────────────────────────────────────────────
document.getElementById('detailDeleteBtn').addEventListener('click', async () => {
if (!_openId || !confirm('Delete this message?')) return;
try {
await apiDelete(`/social/messages/${_openId}/`);
_messages = _messages.filter(m => m.id !== _openId);
_openId = null;
renderList();
showDetail(null);
showToast('Message deleted.');
} catch (e) {
showToast('Delete failed.', 'danger');
}
});
// ── Compose ───────────────────────────────────────────────────────────────────
document.getElementById('composeBtn').addEventListener('click', () => openCompose());
function openCompose(recipientDetail = null, subject = '') {
clearCompose();
if (recipientDetail) prefillCompose(recipientDetail, subject);
composeModal.show();
}
function prefillCompose(recipientDetail, subject) {
clearCompose();
_recipientId = recipientDetail.id;
document.getElementById('recipientId').value = recipientDetail.id;
document.getElementById('recipientLabel').textContent = recipientDetail.display_name || recipientDetail.username;
document.getElementById('recipientChosen').classList.remove('d-none');
document.getElementById('recipientSearch').disabled = true;
document.getElementById('composeSubject').value = subject;
composeModal.show();
}
function clearCompose() {
_recipientId = null;
document.getElementById('recipientId').value = '';
document.getElementById('recipientSearch').value = '';
document.getElementById('recipientSearch').disabled = false;
document.getElementById('recipientResults').classList.add('d-none');
document.getElementById('recipientResults').innerHTML = '';
document.getElementById('recipientChosen').classList.add('d-none');
document.getElementById('recipientLabel').textContent = '';
document.getElementById('composeSubject').value = '';
document.getElementById('composeBody').value = '';
document.getElementById('composeAlert').classList.add('d-none');
}
document.getElementById('recipientClear').addEventListener('click', () => {
_recipientId = null;
document.getElementById('recipientId').value = '';
document.getElementById('recipientChosen').classList.add('d-none');
document.getElementById('recipientSearch').disabled = false;
document.getElementById('recipientSearch').value = '';
document.getElementById('recipientSearch').focus();
});
// Recipient search autocomplete
let _searchTimer = null;
document.getElementById('recipientSearch').addEventListener('input', function () {
clearTimeout(_searchTimer);
const q = this.value.trim();
if (q.length < 2) {
document.getElementById('recipientResults').classList.add('d-none');
return;
}
_searchTimer = setTimeout(async () => {
try {
const results = await apiGet(`/social/members/?q=${encodeURIComponent(q)}`);
renderRecipientResults(results);
} catch (e) { /* ignore */ }
}, 300);
});
function renderRecipientResults(results) {
const el = document.getElementById('recipientResults');
if (!results.length) { el.classList.add('d-none'); return; }
el.innerHTML = results.map(u =>
`<button type="button" class="list-group-item list-group-item-action py-1 px-2 small"
data-uid="${u.id}" data-name="${esc(u.display_name || u.username)}">
<strong>${esc(u.display_name || u.username)}</strong>
<span class="text-muted ms-1">@${esc(u.username)}</span>
</button>`
).join('');
el.classList.remove('d-none');
el.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
_recipientId = Number(btn.dataset.uid);
document.getElementById('recipientId').value = _recipientId;
document.getElementById('recipientLabel').textContent = btn.dataset.name;
document.getElementById('recipientChosen').classList.remove('d-none');
document.getElementById('recipientSearch').disabled = true;
document.getElementById('recipientSearch').value = '';
el.classList.add('d-none');
});
});
}
document.getElementById('composeSendBtn').addEventListener('click', async () => {
const alertEl = document.getElementById('composeAlert');
alertEl.classList.add('d-none');
if (!_recipientId) {
alertEl.textContent = 'Please select a recipient.';
alertEl.classList.remove('d-none');
return;
}
const subject = document.getElementById('composeSubject').value.trim();
const body = document.getElementById('composeBody').value.trim();
if (!subject) { alertEl.textContent = 'Subject is required.'; alertEl.classList.remove('d-none'); return; }
if (!body) { alertEl.textContent = 'Message body is required.'; alertEl.classList.remove('d-none'); return; }
const btn = document.getElementById('composeSendBtn');
const spin = document.getElementById('composeSpin');
btn.disabled = true;
spin.classList.remove('d-none');
try {
await apiPost('/social/messages/', { recipient: _recipientId, subject, body });
composeModal.hide();
showToast('Message sent.');
if (_currentTab === 'sent') loadMessages();
} catch (e) {
alertEl.textContent = 'Failed to send message.';
alertEl.classList.remove('d-none');
} finally {
btn.disabled = false;
spin.classList.add('d-none');
}
});
// ── Unread count ──────────────────────────────────────────────────────────────
async function loadUnreadCount() {
try {
const data = await apiGet('/social/messages/unread-count/');
const badge = document.getElementById('unreadBadge');
if (data.unread > 0) {
badge.textContent = data.unread;
badge.classList.remove('d-none');
} else {
badge.classList.add('d-none');
}
} catch (e) { /* ignore */ }
}
// ── Boot ──────────────────────────────────────────────────────────────────────
loadMessages();

187
frontend/js/nav.js Normal file
View File

@@ -0,0 +1,187 @@
// ── Navbar injection + auth guard ─────────────────────────────────────────────
(function () {
const access = getAccess();
const isAuth = !!access;
const path = window.location.pathname;
const PROTECTED = ['/dashboard.html', '/gears.html', '/reloads.html',
'/profile.html', '/sessions.html', '/admin.html',
'/messages.html', '/friends.html'];
const ADMIN_ONLY = ['/admin.html'];
const AUTH_ONLY = ['/login.html', '/register.html'];
if (PROTECTED.includes(path) && !isAuth) {
window.location.href = '/login.html';
return;
}
if (AUTH_ONLY.includes(path) && isAuth) {
window.location.href = '/dashboard.html';
return;
}
const LANGS = [
{ code: 'en', label: 'EN', name: 'English' },
{ code: 'fr', label: 'FR', name: 'Français' },
{ code: 'de', label: 'DE', name: 'Deutsch' },
{ code: 'es', label: 'ES', name: 'Español' },
];
const currentLang = getLang();
const langSelector = `
<li class="nav-item dropdown ms-2">
<a class="nav-link dropdown-toggle px-2" href="#" data-bs-toggle="dropdown"
id="langMenu" title="Language">
<span id="currentLangLabel">${currentLang.toUpperCase()}</span>
</a>
<ul class="dropdown-menu dropdown-menu-end">
${LANGS.map(l => `
<li>
<a class="dropdown-item ${l.code === currentLang ? 'active' : ''}"
href="#" data-lang="${l.code}">
${l.label}${l.name}
</a>
</li>`).join('')}
</ul>
</li>`;
// Built after profile load (need is_staff)
function buildNav(isStaff) {
const adminLink = isStaff
? `<li class="nav-item"><a class="nav-link text-warning" href="/admin.html"><i class="bi bi-shield-lock me-1"></i>${t('nav.admin')}</a></li>`
: '';
const mainLinks = isAuth ? `
${adminLink}
<li class="nav-item"><a class="nav-link" href="/dashboard.html">${t('nav.dashboard')}</a></li>
<li class="nav-item"><a class="nav-link" href="/sessions.html">${t('nav.sessions')}</a></li>
<li class="nav-item"><a class="nav-link" href="/gears.html">${t('nav.gear')}</a></li>
<li class="nav-item"><a class="nav-link" href="/reloads.html">${t('nav.reloads')}</a></li>
<li class="nav-item"><a class="nav-link" href="/photos.html">${t('nav.photos')}</a></li>
<li class="nav-item"><a class="nav-link" href="/tools.html">${t('nav.tools')}</a></li>
` : `
<li class="nav-item"><a class="nav-link" href="/tools.html">${t('nav.tools')}</a></li>
`;
const authNav = isAuth ? `
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center gap-2"
href="#" data-bs-toggle="dropdown">
<span class="avatar-circle" id="navInitials">?</span>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/profile.html"><i class="bi bi-person-circle me-2"></i>${t('nav.profile')}</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item d-flex align-items-center justify-content-between" href="/messages.html">
<span><i class="bi bi-envelope me-2"></i>Messages</span>
<span class="badge bg-danger rounded-pill d-none" id="navMsgBadge"></span>
</a>
</li>
<li>
<a class="dropdown-item d-flex align-items-center justify-content-between" href="/friends.html">
<span><i class="bi bi-people me-2"></i>Friends</span>
<span class="badge bg-danger rounded-pill d-none" id="navFriendsBadge"></span>
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" id="logoutBtn" href="#"><i class="bi bi-box-arrow-right me-2"></i>${t('nav.signout')}</a></li>
</ul>
</li>
` : `
<li class="nav-item"><a class="nav-link" href="/login.html">${t('nav.signin')}</a></li>
<li class="nav-item ms-2"><a class="btn btn-primary btn-sm" href="/register.html">${t('nav.register')}</a></li>
`;
document.getElementById('navbar').innerHTML = `
<nav class="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm">
<div class="container-fluid">
<a class="navbar-brand fw-bold" href="/index.html">
<i class="bi bi-crosshair2 me-2"></i>ShooterHub
</a>
<button class="navbar-toggler" type="button"
data-bs-toggle="collapse" data-bs-target="#mainNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav me-auto">${mainLinks}</ul>
<ul class="navbar-nav align-items-center">
${authNav}
${langSelector}
</ul>
</div>
</div>
</nav>`;
// Apply data-i18n translations to page elements
applyTranslations();
// Load notification badges for authenticated users
if (isAuth) {
apiFetch('/social/messages/unread-count/').then(r => r.json()).then(d => {
const b = document.getElementById('navMsgBadge');
if (b && d.unread > 0) { b.textContent = d.unread; b.classList.remove('d-none'); }
}).catch(() => {});
apiFetch('/social/friends/requests/').then(r => r.json()).then(d => {
const b = document.getElementById('navFriendsBadge');
const count = Array.isArray(d) ? d.length : 0;
if (b && count > 0) { b.textContent = count; b.classList.remove('d-none'); }
}).catch(() => {});
}
// Highlight active link
document.querySelectorAll('#navbar .nav-link[href]').forEach(a => {
if (a.getAttribute('href') === path) a.classList.add('active');
});
// Logout
document.addEventListener('click', e => {
if (e.target.closest('#logoutBtn')) {
e.preventDefault();
clearTokens();
window.location.href = '/index.html';
}
});
// Admin guard (page-level, after we know is_staff)
if (ADMIN_ONLY.includes(path) && !isStaff) {
window.location.href = '/dashboard.html';
}
}
// Language selector handler (attached after navbar renders via delegation)
document.addEventListener('click', e => {
const item = e.target.closest('[data-lang]');
if (!item) return;
e.preventDefault();
const lang = item.dataset.lang;
setLang(lang);
const label = document.getElementById('currentLangLabel');
if (label) label.textContent = lang.toUpperCase();
// Persist to profile if logged in
if (isAuth) {
apiPatch('/users/profile/', { language: lang }).catch(() => {});
}
// Refresh page so Django serves the right language
window.location.reload();
});
if (isAuth) {
apiFetch('/users/profile/')
.then(r => r.json())
.then(user => {
// Sync language from profile on first load
if (user.language && user.language !== getLang()) {
setLang(user.language);
}
buildNav(!!user.is_staff);
const initials = ((user.first_name?.[0] || '') + (user.last_name?.[0] || '') ||
user.username?.[0] || '?').toUpperCase();
const el = document.getElementById('navInitials');
if (el) el.textContent = initials;
})
.catch(() => buildNav(false));
} else {
buildNav(false);
}
})();

331
frontend/js/photos.js Normal file
View File

@@ -0,0 +1,331 @@
// ── Photos page ────────────────────────────────────────────────────────────────
let _nextUrl = null; // pagination cursor
let _allPhotos = []; // accumulated list
let _lbGpId = null; // currently open in lightbox
const lightboxModal = new bootstrap.Modal('#lightboxModal');
const uploadModal = new bootstrap.Modal('#uploadModal');
// esc, showToast, fDist, getDistUnit, getVelUnit → utils.js
// ── Load & render photos ───────────────────────────────────────────────────────
async function loadPhotos(reset = false) {
if (reset) {
_allPhotos = [];
_nextUrl = null;
document.getElementById('photoGrid').innerHTML = '';
}
const spinner = document.getElementById('gridSpinner');
const loadBtn = document.getElementById('loadMoreBtn');
spinner.classList.remove('d-none');
loadBtn.style.display = 'none';
try {
const filter = document.getElementById('filterMeasured').value;
const url = _nextUrl || buildUrl(filter);
const data = await apiGet(url);
const items = asList(data);
_nextUrl = data.next ? new URL(data.next).pathname + new URL(data.next).search : null;
_allPhotos = [..._allPhotos, ...items];
renderCards(items);
updateCount();
loadBtn.style.display = _nextUrl ? '' : 'none';
} catch(e) {
showToast('Failed to load photos.', 'danger');
} finally {
spinner.classList.add('d-none');
}
}
function buildUrl(filter) {
let url = '/photos/group-photos/?page_size=24';
// filtering by measured / unmeasured is done client-side after load
return url;
}
function updateCount() {
const filter = document.getElementById('filterMeasured').value;
let shown = _allPhotos;
if (filter === 'measured') shown = shown.filter(p => p.analysis?.group_size_mm != null);
if (filter === 'unmeasured') shown = shown.filter(p => !p.analysis?.group_size_mm);
document.getElementById('photoCount').textContent = `${shown.length} photo${shown.length !== 1 ? 's' : ''}`;
document.getElementById('emptyMsg').classList.toggle('d-none', shown.length > 0);
}
function renderCards(items) {
const filter = document.getElementById('filterMeasured').value;
const grid = document.getElementById('photoGrid');
const filtered = filter === 'measured' ? items.filter(p => p.analysis?.group_size_mm != null)
: filter === 'unmeasured' ? items.filter(p => !p.analysis?.group_size_mm)
: items;
if (!filtered.length) return;
filtered.forEach(gp => {
const col = document.createElement('div');
col.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
col.dataset.gpId = gp.id;
const an = gp.analysis;
const sg = gp.shot_group_detail;
const distM = sg?.distance_m ? parseFloat(sg.distance_m) : null;
const esMm = an?.group_size_mm != null ? parseFloat(an.group_size_mm) : null;
const esMoa = an?.group_size_moa;
const esBadge = esMm != null
? `<span class="badge bg-success photo-card badge-es">${fDist(esMm, esMoa, distM)}</span>`
: '';
const caption = gp.caption || (sg ? sg.label : '');
const poiCount = (gp.points_of_impact || []).length;
col.innerHTML = `
<div class="photo-card">
<img src="/api/photos/${gp.photo.id}/data/"
alt="${esc(caption)}"
onclick="openLightbox(${gp.id})">
<div class="overlay"></div>
${esBadge}
<div class="actions">
<a href="/group-size.html?gp=${gp.id}" class="btn btn-sm btn-light btn-icon" title="Measure">
<i class="bi bi-crosshair2"></i>
</a>
<button class="btn btn-sm btn-light btn-icon" title="Compute group size"
onclick="computeGroupSize(event, ${gp.id}, ${gp.id})">
<i class="bi bi-calculator"></i>
</button>
<button class="btn btn-sm btn-light btn-icon text-danger" title="Delete"
onclick="deletePhoto(event, ${gp.id})">
<i class="bi bi-trash"></i>
</button>
</div>
<div class="footer">
<div class="text-truncate" style="max-width:100%">${esc(caption)}</div>
${sg ? `<div class="opacity-75">${esc(sg.label)}${distM ? ` · ${distM} m` : ''}</div>` : ''}
${poiCount ? `<div class="opacity-75">${poiCount} POI${poiCount > 1 ? 's' : ''}</div>` : ''}
</div>
</div>`;
grid.appendChild(col);
});
}
// ── Lightbox ───────────────────────────────────────────────────────────────────
function openLightbox(gpId) {
const gp = _allPhotos.find(p => p.id === gpId);
if (!gp) return;
_lbGpId = gpId;
const sg = gp.shot_group_detail;
const an = gp.analysis;
const distM = sg?.distance_m ? parseFloat(sg.distance_m) : null;
const caption = gp.caption || (sg ? sg.label : '');
document.getElementById('lbTitle').textContent =
caption || `Photo #${gp.id}`;
const badge = document.getElementById('lbBadge');
if (sg) { badge.textContent = sg.label + (distM ? ` · ${distM} m` : ''); badge.classList.remove('d-none'); }
else badge.classList.add('d-none');
renderLbStats(gp);
applyDistUnitButtons(document.getElementById('lightboxModal'));
renderPublicToggle(gp.is_public, {
btnId: 'lbTogglePublicBtn', iconId: 'lbTogglePublicIcon',
labelId: 'lbTogglePublicLabel', privateClass: 'btn-outline-light',
});
document.getElementById('lbMeasureBtn').href = `/group-size.html?gp=${gp.id}`;
document.getElementById('lbOpenBtn').href = `/api/photos/${gp.photo.id}/data/`;
document.getElementById('lbImg').src = `/api/photos/${gp.photo.id}/data/`;
lightboxModal.show();
}
function renderLbStats(gp) {
const an = gp.analysis;
const sg = gp.shot_group_detail;
const distM = sg?.distance_m ? parseFloat(sg.distance_m) : null;
const parts = [];
const poiCount = (gp.points_of_impact || []).length;
if (poiCount) parts.push(`<span><span class="opacity-50">POIs:</span> <strong class="text-white">${poiCount}</strong></span>`);
if (an) {
if (an.group_size_mm != null)
parts.push(`<span><span class="opacity-50">Group ES:</span> <strong class="text-white">${fDist(an.group_size_mm, an.group_size_moa, distM)}</strong></span>`);
if (an.mean_radius_mm != null)
parts.push(`<span><span class="opacity-50">Mean radius:</span> <strong class="text-white">${fDist(an.mean_radius_mm, an.mean_radius_moa, distM)}</strong></span>`);
const wx = an.windage_offset_mm != null ? parseFloat(an.windage_offset_mm) : null;
const wy = an.elevation_offset_mm != null ? parseFloat(an.elevation_offset_mm) : null;
if (wx != null && wy != null && (Math.abs(wx) > 0.5 || Math.abs(wy) > 0.5)) {
parts.push(`<span><span class="opacity-50">Offset:</span> <strong class="text-white">${fDist(Math.abs(wx), an.windage_offset_moa, distM)} ${wx>0?'R':'L'} / ${fDist(Math.abs(wy), an.elevation_offset_moa, distM)} ${wy>0?'H':'Low'}</strong></span>`);
}
}
document.getElementById('lbStats').innerHTML =
parts.length ? parts.join('') : '<span class="opacity-50">Not yet measured — click Measure or Compute.</span>';
}
// ── Compute group size ─────────────────────────────────────────────────────────
async function computeGroupSize(e, gpId, colGpId) {
e.stopPropagation();
const gp = _allPhotos.find(p => p.id === gpId);
if (!gp || (gp.points_of_impact || []).length < 2) {
showToast('Need ≥ 2 annotated POIs to compute. Use "Measure" first.', 'warning');
return;
}
try {
const result = await apiPost(`/photos/group-photos/${gpId}/compute-group-size/`, {});
// Update cache
const idx = _allPhotos.findIndex(p => p.id === gpId);
if (idx !== -1) _allPhotos[idx] = { ..._allPhotos[idx], analysis: result.analysis ?? result };
// Refresh card
const old = document.querySelector(`[data-gp-id="${gpId}"]`);
if (old) { old.remove(); renderCards([_allPhotos[idx]]); }
// Refresh lightbox if open on this photo
if (_lbGpId === gpId) renderLbStats(_allPhotos[idx]);
showToast('Group size computed.');
} catch(err) {
showToast('Compute failed: ' + (err.message || 'unknown error'), 'danger');
}
}
// ── Delete ─────────────────────────────────────────────────────────────────────
async function deletePhoto(e, gpId) {
e.stopPropagation();
if (!confirm('Delete this photo?')) return;
try {
await apiDelete(`/photos/group-photos/${gpId}/`);
const col = document.querySelector(`[data-gp-id="${gpId}"]`);
col?.closest('.col-6, .col-sm-4, .col-md-3, .col-lg-2')?.remove();
_allPhotos = _allPhotos.filter(p => p.id !== gpId);
updateCount();
showToast('Photo deleted.');
if (_lbGpId === gpId) lightboxModal.hide();
} catch(err) {
showToast('Delete failed.', 'danger');
}
}
// ── Compute from lightbox ──────────────────────────────────────────────────────
document.getElementById('lbComputeBtn').addEventListener('click', () => {
if (_lbGpId != null) computeGroupSize({ stopPropagation: ()=>{} }, _lbGpId, _lbGpId);
});
// ── Public toggle from lightbox ────────────────────────────────────────────────
document.getElementById('lbTogglePublicBtn').addEventListener('click', async () => {
if (_lbGpId == null) return;
const idx = _allPhotos.findIndex(p => p.id === _lbGpId);
if (idx === -1) return;
const gp = _allPhotos[idx];
const newVal = !gp.is_public;
try {
await apiPatch(`/photos/group-photos/${gp.id}/`, { is_public: newVal });
_allPhotos[idx] = { ...gp, is_public: newVal };
renderPublicToggle(newVal, {
btnId: 'lbTogglePublicBtn', iconId: 'lbTogglePublicIcon',
labelId: 'lbTogglePublicLabel', privateClass: 'btn-outline-light',
});
showToast(newVal ? 'Photo is now public.' : 'Photo is now private.');
} catch(e) {
showToast('Failed to update visibility.', 'danger');
}
});
// ── Unit switch inside lightbox ────────────────────────────────────────────────
document.getElementById('lightboxModal').addEventListener('click', e => {
const btn = e.target.closest('[data-dist-unit]');
if (!btn) return;
setDistUnit(btn.dataset.distUnit);
applyDistUnitButtons(document.getElementById('lightboxModal'));
if (_lbGpId != null) {
const gp = _allPhotos.find(p => p.id === _lbGpId);
if (gp) renderLbStats(gp);
}
});
// ── Upload ─────────────────────────────────────────────────────────────────────
document.getElementById('uploadBtn').addEventListener('click', () => {
document.getElementById('upFile').value = '';
document.getElementById('upCaption').value = '';
document.getElementById('upAlert').classList.add('d-none');
uploadModal.show();
});
document.getElementById('upSubmitBtn').addEventListener('click', async () => {
const alertEl = document.getElementById('upAlert');
const spinner = document.getElementById('upSpinner');
const submitBtn = document.getElementById('upSubmitBtn');
alertEl.classList.add('d-none');
const file = document.getElementById('upFile').files[0];
if (!file) {
alertEl.textContent = 'Please select an image file.';
alertEl.classList.remove('d-none');
return;
}
submitBtn.disabled = true;
spinner.classList.remove('d-none');
try {
const formData = new FormData();
formData.append('file', file);
const photo = await apiFetch('/photos/upload/', { method: 'POST', body: formData })
.then(async r => {
if (!r.ok) { const e = await r.json().catch(() => ({})); throw Object.assign(new Error('Upload failed'), { data: e }); }
return r.json();
});
const caption = document.getElementById('upCaption').value.trim();
const gp = await apiPost('/photos/group-photos/', {
photo_id: photo.id,
...(caption ? { caption } : {}),
});
_allPhotos.unshift(gp);
renderCards([gp]);
// Move the new card to front
const grid = document.getElementById('photoGrid');
const newCol = grid.querySelector(`[data-gp-id="${gp.id}"]`)?.closest('[class^="col"]');
if (newCol) grid.prepend(newCol);
updateCount();
uploadModal.hide();
showToast('Photo uploaded.');
} catch(err) {
alertEl.textContent = (err.data && JSON.stringify(err.data)) || 'Upload failed.';
alertEl.classList.remove('d-none');
} finally {
submitBtn.disabled = false;
spinner.classList.add('d-none');
}
});
// ── Filter ─────────────────────────────────────────────────────────────────────
document.getElementById('filterMeasured').addEventListener('change', () => {
// Re-render current data without re-fetching
document.getElementById('photoGrid').innerHTML = '';
renderCards(_allPhotos);
updateCount();
});
// ── Load more ──────────────────────────────────────────────────────────────────
document.getElementById('loadMoreBtn').addEventListener('click', () => loadPhotos(false));
// ── Boot ───────────────────────────────────────────────────────────────────────
loadPhotos(true);

182
frontend/js/profile.js Normal file
View File

@@ -0,0 +1,182 @@
// ── Profile page logic ────────────────────────────────────────────────────────
let rigs = [];
// ── Toast ─────────────────────────────────────────────────────────────────────
function showToast(msg, type = 'success') {
const el = document.createElement('div');
el.className = `toast align-items-center text-bg-${type} border-0 show`;
el.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 3500);
}
function showAlert(id, msg, type = 'danger') {
const el = document.getElementById(id);
el.className = `alert alert-${type} mt-3`;
el.textContent = msg;
el.classList.remove('d-none');
}
function hideAlert(id) {
document.getElementById(id).classList.add('d-none');
}
// ── Load profile ─────────────────────────────────────────────────────────────
async function loadProfile() {
try {
const user = await apiGet('/users/profile/');
document.getElementById('firstName').value = user.first_name || '';
document.getElementById('lastName').value = user.last_name || '';
document.getElementById('email').value = user.email;
document.getElementById('profileUsername').textContent = '@' + user.username;
if (user.avatar_url) {
document.getElementById('avatarWrap').innerHTML =
`<img src="${user.avatar_url}" class="avatar-img mx-auto d-block" alt="avatar">`;
} else {
const initials = ((user.first_name?.[0] || '') + (user.last_name?.[0] || '') ||
user.username?.[0] || '?').toUpperCase();
document.getElementById('avatarInitials').textContent = initials;
}
} catch(e) {
showToast('Failed to load profile', 'danger');
}
}
// ── Save profile info ─────────────────────────────────────────────────────────
document.getElementById('profileForm').addEventListener('submit', async e => {
e.preventDefault();
hideAlert('profileAlert');
const btn = document.getElementById('saveProfileBtn');
btn.disabled = true;
try {
await apiPatch('/users/profile/', {
first_name: document.getElementById('firstName').value.trim(),
last_name: document.getElementById('lastName').value.trim(),
});
showAlert('profileAlert', 'Profile saved!', 'success');
showToast('Profile updated!');
} catch(e) {
showAlert('profileAlert', formatErrors(e.data));
} finally {
btn.disabled = false;
}
});
// ── Avatar upload ─────────────────────────────────────────────────────────────
document.getElementById('avatarInput').addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const alertEl = document.getElementById('avatarAlert');
alertEl.classList.add('d-none');
try {
// Step 1: upload image to /api/photos/upload/
const form = new FormData();
form.append('file', file);
const photo = await apiPost('/photos/upload/', form);
// Step 2: set it as the user's avatar
const updated = await apiPatch('/users/profile/', { avatar: photo.id });
if (updated.avatar_url) {
document.getElementById('avatarWrap').innerHTML =
`<img src="${updated.avatar_url}" class="avatar-img mx-auto d-block" alt="avatar">`;
}
showToast('Avatar updated!');
} catch(e) {
alertEl.textContent = (e.data && formatErrors(e.data)) || e.message || 'Upload failed. Please try a smaller image.';
alertEl.classList.remove('d-none');
}
});
// ── Change password ───────────────────────────────────────────────────────────
document.getElementById('passwordForm').addEventListener('submit', async e => {
e.preventDefault();
hideAlert('passwordAlert');
const p1 = document.getElementById('newPassword1').value;
const p2 = document.getElementById('newPassword2').value;
if (p1 !== p2) {
showAlert('passwordAlert', 'New passwords do not match.');
return;
}
try {
await apiPost('/auth/password/change/', {
old_password: document.getElementById('oldPassword').value,
new_password1: p1,
new_password2: p2,
});
showAlert('passwordAlert', 'Password changed successfully!', 'success');
document.getElementById('passwordForm').reset();
showToast('Password changed!');
} catch(e) {
showAlert('passwordAlert', formatErrors(e.data));
}
});
// ── Rigs table ────────────────────────────────────────────────────────────────
function renderRigs() {
const tbody = document.getElementById('rigsBody');
const wrap = document.getElementById('rigsTableWrap');
const empty = document.getElementById('rigsEmpty');
document.getElementById('rigsSpinner').classList.add('d-none');
if (!rigs.length) {
empty.classList.remove('d-none');
return;
}
wrap.classList.remove('d-none');
tbody.innerHTML = rigs.map(rig => `
<tr>
<td class="fw-semibold">${rig.name}</td>
<td>${rig.rig_items.length} item${rig.rig_items.length === 1 ? '' : 's'}</td>
<td>
<span class="badge ${rig.is_public ? 'bg-success' : 'bg-secondary'}">
${rig.is_public ? 'Public' : 'Private'}
</span>
</td>
<td>
<button class="btn btn-sm ${rig.is_public ? 'btn-outline-secondary' : 'btn-outline-success'}"
onclick="toggleRig(${rig.id})">
<i class="bi bi-${rig.is_public ? 'lock' : 'globe2'} me-1"></i>
${rig.is_public ? 'Make private' : 'Make public'}
</button>
</td>
</tr>
`).join('');
}
async function loadRigs() {
try {
rigs = await apiGet('/rigs/');
renderRigs();
} catch(e) {
document.getElementById('rigsSpinner').classList.add('d-none');
showToast('Failed to load rigs', 'danger');
}
}
async function toggleRig(id) {
const rig = rigs.find(r => r.id === id);
if (!rig) return;
try {
const updated = await apiPatch(`/rigs/${id}/`, { is_public: !rig.is_public });
const idx = rigs.findIndex(r => r.id === id);
if (idx >= 0) rigs[idx] = { ...rigs[idx], is_public: updated.is_public };
renderRigs();
showToast(`Rig is now ${updated.is_public ? 'public' : 'private'}.`);
} catch(e) {
showToast('Failed to update rig.', 'danger');
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
loadProfile();
loadRigs();

542
frontend/js/reloads.js Normal file
View File

@@ -0,0 +1,542 @@
// ── Reloads page logic ────────────────────────────────────────────────────────
let recipes = [];
let components = { primers: [], brass: [], bullets: [], powders: [] };
let currentRecipe = null;
// showToast, renderPublicToggle → utils.js
// ── Load components (for selects) ─────────────────────────────────────────────
async function loadComponents() {
const errOption = name => `<option value="">Failed to load ${name} — check console</option>`;
let primers = [], brass = [], bullets = [], powders = [];
try {
[primers, brass, bullets, powders] = await Promise.all([
apiGetAll('/gears/components/primers/?page_size=1000'),
apiGetAll('/gears/components/brass/?page_size=1000'),
apiGetAll('/gears/components/bullets/?page_size=1000'),
apiGetAll('/gears/components/powders/?page_size=1000'),
]);
} catch(e) {
console.error('loadComponents failed:', e);
['recipePrimer','recipeBrass','recipeBullet','batchPowder'].forEach(id => {
document.getElementById(id).innerHTML = errOption(id);
});
showToast('Failed to load components. Are the component tables populated?', 'danger');
return;
}
components = { primers, brass, bullets, powders };
// Populate recipe selects
document.getElementById('recipePrimer').innerHTML =
'<option value="">Select primer…</option>' +
(primers.length ? primers.map(p => `<option value="${p.id}">${p.brand} ${p.name} (${p.size})</option>`).join('')
: '<option disabled>No primers in database</option>');
document.getElementById('recipeBrass').innerHTML =
'<option value="">Select brass…</option>' +
(brass.length ? brass.map(b => `<option value="${b.id}">${b.brand} ${b.caliber_detail?.name || '?'}</option>`).join('')
: '<option disabled>No brass in database</option>');
document.getElementById('recipeBullet').innerHTML =
'<option value="">Select bullet…</option>' +
(bullets.length ? bullets.map(b => `<option value="${b.id}">${b.brand} ${b.model_name} ${b.weight_gr}gr (${b.bullet_type})</option>`).join('')
: '<option disabled>No bullets in database</option>');
// Populate batch powder select
document.getElementById('batchPowder').innerHTML =
'<option value="">Select powder…</option>' +
powders.map(p => `<option value="${p.id}">${p.brand} ${p.name}${p.powder_type ? ' ' + p.powder_type : ''}</option>`).join('');
}
// ── Recipes sidebar ───────────────────────────────────────────────────────────
function renderRecipesList() {
const list = document.getElementById('recipesList');
const empty = document.getElementById('recipesEmpty');
const spinner = document.getElementById('recipesSpinner');
spinner.classList.add('d-none');
if (!recipes.length) {
empty.classList.remove('d-none');
list.innerHTML = '';
return;
}
empty.classList.add('d-none');
list.innerHTML = recipes.map(r => `
<div class="recipe-item p-2 rounded mb-1 ${currentRecipe?.id === r.id ? 'active' : ''}"
onclick="selectRecipe(${r.id})">
<div class="fw-semibold small">${r.name}</div>
<div class="text-muted" style="font-size:.75rem;">
${r.caliber_detail?.name || '—'} · ${r.batches.length} batch${r.batches.length === 1 ? '' : 'es'}
</div>
</div>
`).join('');
}
async function loadRecipes() {
try {
recipes = await apiGetAll('/reloading/recipes/');
renderRecipesList();
} catch(e) {
document.getElementById('recipesSpinner').classList.add('d-none');
showToast('Failed to load recipes', 'danger');
}
}
function selectRecipe(id) {
currentRecipe = recipes.find(r => r.id === id);
if (!currentRecipe) return;
renderRecipesList();
renderRecipeDetail();
loadBatches(id);
}
function renderRecipeDetail() {
const r = currentRecipe;
document.getElementById('noRecipeMsg').classList.add('d-none');
document.getElementById('recipeDetail').classList.remove('d-none');
document.getElementById('detailName').textContent = r.name;
document.getElementById('detailCaliber').textContent = r.caliber_detail?.name || '—';
document.getElementById('detailPrimer').textContent =
`${r.primer_detail.brand} ${r.primer_detail.name} (${r.primer_detail.size})`;
document.getElementById('detailBrass').textContent =
`${r.brass_detail.brand} ${r.brass_detail.caliber_detail?.name || '?'}`;
document.getElementById('detailBullet').textContent =
`${r.bullet_detail.brand} ${r.bullet_detail.model_name} ${r.bullet_detail.weight_gr}gr`;
document.getElementById('detailNotes').textContent = r.notes || '—';
renderPublicToggle(r.is_public);
}
// renderPublicToggle → utils.js
document.getElementById('togglePublicBtn').addEventListener('click', async () => {
if (!currentRecipe) return;
const newVal = !currentRecipe.is_public;
try {
await apiPatch(`/reloading/recipes/${currentRecipe.id}/`, { is_public: newVal });
currentRecipe.is_public = newVal;
const idx = recipes.findIndex(r => r.id === currentRecipe.id);
if (idx >= 0) recipes[idx].is_public = newVal;
renderPublicToggle(newVal);
showToast(newVal ? 'Recipe is now public.' : 'Recipe is now private.');
} catch(e) {
showToast('Failed to update visibility.', 'danger');
}
});
// ── Edit / delete recipe ──────────────────────────────────────────────────────
const recipeModal = new bootstrap.Modal('#recipeModal');
document.getElementById('newRecipeBtn').addEventListener('click', async () => {
document.getElementById('recipeId').value = '';
document.getElementById('recipeName').value = '';
document.getElementById('recipeNotes').value = '';
document.getElementById('recipePrimer').value = '';
document.getElementById('recipeBrass').value = '';
document.getElementById('recipeBullet').value = '';
document.getElementById('recipeModalTitle').textContent = 'New recipe';
document.getElementById('recipeModalAlert').classList.add('d-none');
const calSel = document.getElementById('recipeCaliber');
if (calSel && (!calSel.options.length || calSel.options[0].text === 'Loading…')) {
await loadCalibersIntoSelect(calSel);
}
recipeModal.show();
});
document.getElementById('editRecipeBtn').addEventListener('click', async () => {
if (!currentRecipe) return;
const r = currentRecipe;
document.getElementById('recipeId').value = r.id;
document.getElementById('recipeName').value = r.name;
document.getElementById('recipeNotes').value = r.notes || '';
document.getElementById('recipePrimer').value = r.primer;
document.getElementById('recipeBrass').value = r.brass;
document.getElementById('recipeBullet').value = r.bullet;
document.getElementById('recipeModalTitle').textContent = 'Edit recipe';
document.getElementById('recipeModalAlert').classList.add('d-none');
const calSel = document.getElementById('recipeCaliber');
if (calSel && (!calSel.options.length || calSel.options[0].text === 'Loading…')) {
await loadCalibersIntoSelect(calSel);
}
// Set after options are loaded
calSel.value = r.caliber || '';
recipeModal.show();
});
document.getElementById('saveRecipeBtn').addEventListener('click', async () => {
const id = document.getElementById('recipeId').value;
const alertEl = document.getElementById('recipeModalAlert');
alertEl.classList.add('d-none');
const calVal = document.getElementById('recipeCaliber').value;
const payload = {
name: document.getElementById('recipeName').value.trim(),
caliber: calVal ? parseInt(calVal) : null,
primer: parseInt(document.getElementById('recipePrimer').value),
brass: parseInt(document.getElementById('recipeBrass').value),
bullet: parseInt(document.getElementById('recipeBullet').value),
notes: document.getElementById('recipeNotes').value.trim(),
};
if (!payload.name || !payload.caliber || !payload.primer || !payload.brass || !payload.bullet) {
alertEl.textContent = 'Name, caliber, primer, brass and bullet are required.';
alertEl.classList.remove('d-none');
return;
}
try {
if (id) {
const updated = await apiPatch(`/reloading/recipes/${id}/`, {
name: payload.name, caliber: payload.caliber || null, notes: payload.notes,
});
const idx = recipes.findIndex(r => r.id === parseInt(id));
if (idx >= 0) recipes[idx] = { ...recipes[idx], ...updated };
currentRecipe = recipes[idx];
renderRecipeDetail();
} else {
const created = await apiPost('/reloading/recipes/', payload);
recipes.push(created);
currentRecipe = created;
renderRecipeDetail();
document.getElementById('batchesBody').innerHTML = '';
document.getElementById('batchesEmpty').classList.remove('d-none');
document.getElementById('batchesTableWrap').classList.add('d-none');
document.getElementById('noRecipeMsg').classList.add('d-none');
document.getElementById('recipeDetail').classList.remove('d-none');
}
renderRecipesList();
recipeModal.hide();
showToast(id ? 'Recipe updated!' : 'Recipe created!');
} catch(e) {
alertEl.textContent = formatErrors(e.data);
alertEl.classList.remove('d-none');
}
});
document.getElementById('deleteRecipeBtn').addEventListener('click', async () => {
if (!currentRecipe) return;
if (!confirm(`Delete recipe "${currentRecipe.name}"? All its batches will be deleted too.`)) return;
try {
await apiDelete(`/reloading/recipes/${currentRecipe.id}/`);
recipes = recipes.filter(r => r.id !== currentRecipe.id);
currentRecipe = null;
renderRecipesList();
document.getElementById('recipeDetail').classList.add('d-none');
document.getElementById('noRecipeMsg').classList.remove('d-none');
showToast('Recipe deleted.');
} catch(e) {
showToast('Failed to delete recipe.', 'danger');
}
});
// ── Batches ───────────────────────────────────────────────────────────────────
let batches = [];
function renderBatches() {
const tbody = document.getElementById('batchesBody');
const wrap = document.getElementById('batchesTableWrap');
const empty = document.getElementById('batchesEmpty');
document.getElementById('batchesSpinner').classList.add('d-none');
if (!batches.length) {
empty.classList.remove('d-none');
wrap.classList.add('d-none');
return;
}
empty.classList.add('d-none');
wrap.classList.remove('d-none');
tbody.innerHTML = batches.map(b => `
<tr>
<td>${b.powder_detail.brand} ${b.powder_detail.name}</td>
<td><strong>${b.powder_charge_gr}</strong></td>
<td>${b.quantity ?? '—'}</td>
<td>${b.oal_mm ?? '—'}</td>
<td><span class="badge bg-secondary">${b.crimp}</span></td>
<td>${b.loaded_at ?? '—'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEditBatch(${b.id})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteBatch(${b.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
}
async function loadBatches(recipeId) {
const spinner = document.getElementById('batchesSpinner');
spinner.classList.remove('d-none');
document.getElementById('batchesEmpty').classList.add('d-none');
document.getElementById('batchesTableWrap').classList.add('d-none');
try {
batches = await apiGet(`/reloading/batches/?recipe=${recipeId}`);
renderBatches();
} catch(e) {
spinner.classList.add('d-none');
showToast('Failed to load batches', 'danger');
}
}
// Create / Edit Batch modal
const batchModal = new bootstrap.Modal('#batchModal');
document.getElementById('newBatchBtn').addEventListener('click', () => {
document.getElementById('batchId').value = '';
document.getElementById('batchCharge').value = '';
document.getElementById('batchQty').value = '';
document.getElementById('batchOal').value = '';
document.getElementById('batchCoal').value = '';
document.getElementById('batchCrimp').value = 'NONE';
document.getElementById('batchLoaded').value = '';
document.getElementById('batchCasePrep').value = '';
document.getElementById('batchNotes').value = '';
document.getElementById('batchPowder').value = '';
document.getElementById('batchModalTitle').textContent = 'New batch';
document.getElementById('batchModalAlert').classList.add('d-none');
batchModal.show();
});
function openEditBatch(id) {
const b = batches.find(x => x.id === id);
if (!b) return;
document.getElementById('batchId').value = id;
document.getElementById('batchPowder').value = b.powder;
document.getElementById('batchCharge').value = b.powder_charge_gr;
document.getElementById('batchQty').value = b.quantity ?? '';
document.getElementById('batchOal').value = b.oal_mm ?? '';
document.getElementById('batchCoal').value = b.coal_mm ?? '';
document.getElementById('batchCrimp').value = b.crimp || 'NONE';
document.getElementById('batchLoaded').value = b.loaded_at ?? '';
document.getElementById('batchCasePrep').value = b.case_prep_notes ?? '';
document.getElementById('batchNotes').value = b.notes ?? '';
document.getElementById('batchModalTitle').textContent = 'Edit batch';
document.getElementById('batchModalAlert').classList.add('d-none');
batchModal.show();
}
document.getElementById('saveBatchBtn').addEventListener('click', async () => {
if (!currentRecipe) return;
const id = document.getElementById('batchId').value;
const alertEl = document.getElementById('batchModalAlert');
alertEl.classList.add('d-none');
const payload = {
recipe: currentRecipe.id,
powder: parseInt(document.getElementById('batchPowder').value),
powder_charge_gr: document.getElementById('batchCharge').value,
crimp: document.getElementById('batchCrimp').value,
};
const qty = document.getElementById('batchQty').value;
const oal = document.getElementById('batchOal').value;
const coal = document.getElementById('batchCoal').value;
const loaded = document.getElementById('batchLoaded').value;
const casePrep = document.getElementById('batchCasePrep').value.trim();
const notes = document.getElementById('batchNotes').value.trim();
if (qty) payload.quantity = parseInt(qty);
if (oal) payload.oal_mm = oal;
if (coal) payload.coal_mm = coal;
if (loaded) payload.loaded_at = loaded;
if (casePrep) payload.case_prep_notes = casePrep;
if (notes) payload.notes = notes;
if (!payload.powder || !payload.powder_charge_gr) {
alertEl.textContent = 'Powder and charge are required.';
alertEl.classList.remove('d-none');
return;
}
try {
if (id) {
const updated = await apiPatch(`/reloading/batches/${id}/`, payload);
const idx = batches.findIndex(b => b.id === parseInt(id));
if (idx >= 0) batches[idx] = updated;
} else {
const created = await apiPost('/reloading/batches/', payload);
batches.push(created);
// Update recipe batch count
const ri = recipes.findIndex(r => r.id === currentRecipe.id);
if (ri >= 0) recipes[ri].batches = batches;
renderRecipesList();
}
renderBatches();
batchModal.hide();
showToast(id ? 'Batch updated!' : 'Batch created!');
} catch(e) {
alertEl.textContent = formatErrors(e.data);
alertEl.classList.remove('d-none');
}
});
async function deleteBatch(id) {
if (!confirm('Delete this batch?')) return;
try {
await apiDelete(`/reloading/batches/${id}/`);
batches = batches.filter(b => b.id !== id);
renderBatches();
// Update recipe batch count
const ri = recipes.findIndex(r => r.id === currentRecipe?.id);
if (ri >= 0) recipes[ri].batches = batches;
renderRecipesList();
showToast('Batch deleted.');
} catch(e) {
showToast('Failed to delete batch.', 'danger');
}
}
// ── Suggest component (user submission) ──────────────────────────────────────
const COMP_SUGGEST_FORMS = {
primers: {
endpoint: '/gears/components/primers/',
selectId: 'recipePrimer',
html: `
<div class="card bg-light border-0 p-2 small">
<p class="text-muted mb-1"><i class="bi bi-info-circle me-1"></i>Pending admin approval. Usable by you immediately.</p>
<input type="text" class="form-control form-control-sm mb-1" data-field="brand" placeholder="Brand *">
<input type="text" class="form-control form-control-sm mb-1" data-field="name" placeholder="Name *">
<select class="form-select form-select-sm mb-1" data-field="size">
<option value="SP">Small Pistol</option><option value="LP">Large Pistol</option>
<option value="SR">Small Rifle</option><option value="LR" selected>Large Rifle</option>
<option value="LRM">Large Rifle Magnum</option>
</select>
<div class="comp-suggest-alert alert alert-danger d-none py-1 mb-1"></div>
<button class="btn btn-xs btn-primary btn-sm w-100 comp-suggest-submit">Submit</button>
</div>`,
payload: (box) => ({
brand: box.querySelector('[data-field=brand]').value.trim(),
name: box.querySelector('[data-field=name]').value.trim(),
size: box.querySelector('[data-field=size]').value,
}),
label: (c) => `${c.brand} ${c.name} (${c.size}) [pending]`,
},
brass: {
endpoint: '/gears/components/brass/',
selectId: 'recipeBrass',
html: `
<div class="card bg-light border-0 p-2 small">
<p class="text-muted mb-1"><i class="bi bi-info-circle me-1"></i>Pending admin approval. Usable by you immediately.</p>
<input type="text" class="form-control form-control-sm mb-1" data-field="brand" placeholder="Brand *">
<select class="form-select form-select-sm mb-1" data-field="caliber"><option value="">Loading calibers…</option></select>
<div class="comp-suggest-alert alert alert-danger d-none py-1 mb-1"></div>
<button class="btn btn-sm btn-primary w-100 comp-suggest-submit">Submit</button>
</div>`,
payload: (box) => {
const cal = box.querySelector('[data-field=caliber]').value;
return { brand: box.querySelector('[data-field=brand]').value.trim(), caliber: cal ? parseInt(cal) : undefined };
},
label: (c) => `${c.brand} ${c.caliber_detail?.name || '?'} [pending]`,
},
bullets: {
endpoint: '/gears/components/bullets/',
selectId: 'recipeBullet',
html: `
<div class="card bg-light border-0 p-2 small">
<p class="text-muted mb-1"><i class="bi bi-info-circle me-1"></i>Pending admin approval. Usable by you immediately.</p>
<input type="text" class="form-control form-control-sm mb-1" data-field="brand" placeholder="Brand *">
<input type="text" class="form-control form-control-sm mb-1" data-field="model_name" placeholder="Model name *">
<input type="number" class="form-control form-control-sm mb-1" data-field="weight_gr" placeholder="Weight (gr) *" step="0.1">
<select class="form-select form-select-sm mb-1" data-field="bullet_type">
<option value="FMJ">FMJ</option><option value="HP">HP</option>
<option value="BTHP">BTHP</option><option value="SP">SP</option>
<option value="HPBT" selected>HPBT</option><option value="SMK">SMK</option>
<option value="A_TIP">A-Tip</option><option value="MONO">Monolithic</option>
</select>
<div class="comp-suggest-alert alert alert-danger d-none py-1 mb-1"></div>
<button class="btn btn-sm btn-primary w-100 comp-suggest-submit">Submit</button>
</div>`,
payload: (box) => ({
brand: box.querySelector('[data-field=brand]').value.trim(),
model_name: box.querySelector('[data-field=model_name]').value.trim(),
weight_gr: box.querySelector('[data-field=weight_gr]').value,
bullet_type: box.querySelector('[data-field=bullet_type]').value,
}),
label: (c) => `${c.brand} ${c.model_name} ${c.weight_gr}gr (${c.bullet_type}) [pending]`,
},
powders: {
endpoint: '/gears/components/powders/',
selectId: 'batchPowder',
html: `
<div class="card bg-light border-0 p-2 small">
<p class="text-muted mb-1"><i class="bi bi-info-circle me-1"></i>Pending admin approval. Usable by you immediately.</p>
<input type="text" class="form-control form-control-sm mb-1" data-field="brand" placeholder="Brand *">
<input type="text" class="form-control form-control-sm mb-1" data-field="name" placeholder="Name *">
<select class="form-select form-select-sm mb-1" data-field="powder_type">
<option value="">Type (optional)</option>
<option value="BALL">Ball/Spherical</option>
<option value="EXTRUDED">Extruded/Stick</option>
<option value="FLAKE">Flake</option>
</select>
<div class="comp-suggest-alert alert alert-danger d-none py-1 mb-1"></div>
<button class="btn btn-sm btn-primary w-100 comp-suggest-submit">Submit</button>
</div>`,
payload: (box) => {
const p = {
brand: box.querySelector('[data-field=brand]').value.trim(),
name: box.querySelector('[data-field=name]').value.trim(),
};
const t = box.querySelector('[data-field=powder_type]').value;
if (t) p.powder_type = t;
return p;
},
label: (c) => `${c.brand} ${c.name}${c.powder_type ? ' ' + c.powder_type : ''} [pending]`,
},
};
// Attach suggest-link handlers (works for both recipe modal and batch modal)
document.addEventListener('click', async e => {
const link = e.target.closest('.suggest-comp-link');
if (!link) return;
e.preventDefault();
const compType = link.dataset.comp;
const box = link.closest('.col-md-4, .col-md-6').querySelector('.suggest-comp-form');
const cfg = COMP_SUGGEST_FORMS[compType];
if (!box || !cfg) return;
if (box.innerHTML) { box.classList.toggle('d-none'); return; }
box.innerHTML = cfg.html;
box.classList.remove('d-none');
// If brass, load calibers into its select
if (compType === 'brass') {
const calSel = box.querySelector('[data-field=caliber]');
if (calSel) loadCalibersIntoSelect(calSel);
}
box.querySelector('.comp-suggest-submit').addEventListener('click', async () => {
const alertEl = box.querySelector('.comp-suggest-alert');
alertEl.classList.add('d-none');
const payload = cfg.payload(box);
if (!payload.brand || !payload.name && !payload.caliber && !payload.model_name) {
alertEl.textContent = 'Required fields are missing.';
alertEl.classList.remove('d-none');
return;
}
try {
const created = await apiPost(cfg.endpoint, payload);
// Add to the corresponding select and auto-select it
const sel = document.getElementById(cfg.selectId);
const opt = new Option(cfg.label(created), created.id, true, true);
sel.appendChild(opt);
// Add to components cache too
if (components[compType]) components[compType].push(created);
box.classList.add('d-none');
showToast('Component submitted! It is now available in the selector.');
} catch(err) {
alertEl.textContent = formatErrors(err.data) || 'Submission failed.';
alertEl.classList.remove('d-none');
}
});
});
// ── Init ──────────────────────────────────────────────────────────────────────
loadComponents();
loadRecipes();

1084
frontend/js/sessions.js Normal file

File diff suppressed because it is too large Load Diff

90
frontend/js/utils.js Normal file
View File

@@ -0,0 +1,90 @@
// ── Shared UI utilities ────────────────────────────────────────────────────────
// Loaded after api.js; available to all page scripts.
// ── HTML escaping ─────────────────────────────────────────────────────────────
function esc(s) {
const d = document.createElement('div');
d.textContent = String(s ?? '');
return d.innerHTML;
}
// ── Toast notifications ───────────────────────────────────────────────────────
function showToast(msg, type = 'success') {
const el = document.createElement('div');
el.className = `toast align-items-center text-bg-${type} border-0 show`;
el.innerHTML = `<div class="d-flex"><div class="toast-body">${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
// ── Public / private toggle ───────────────────────────────────────────────────
// Usage: renderPublicToggle(isPublic, { btnId, iconId, labelId, privateClass })
// privateClass defaults to 'btn-outline-secondary' (use 'btn-outline-light' for dark backgrounds)
function renderPublicToggle(isPublic, {
btnId = 'togglePublicBtn',
iconId = 'togglePublicIcon',
labelId = 'togglePublicLabel',
privateClass = 'btn-outline-secondary',
} = {}) {
const btn = document.getElementById(btnId);
const icon = document.getElementById(iconId);
const label = document.getElementById(labelId);
if (!btn || !icon || !label) return;
if (isPublic) {
icon.className = 'bi bi-globe2 me-1 text-success';
label.textContent = 'Public';
btn.className = 'btn btn-sm btn-outline-success';
} else {
icon.className = 'bi bi-lock me-1';
label.textContent = 'Private';
btn.className = `btn btn-sm ${privateClass}`;
}
}
// ── Unit preferences ──────────────────────────────────────────────────────────
function getDistUnit() { return localStorage.getItem('units.dist') || 'mm'; }
function getVelUnit() { return localStorage.getItem('units.vel') || 'fps'; }
function setDistUnit(u) { localStorage.setItem('units.dist', u); }
function setVelUnit(u) { localStorage.setItem('units.vel', u); }
// ── Distance unit button sync ─────────────────────────────────────────────────
// Marks the active [data-dist-unit] button inside container (or document).
function applyDistUnitButtons(container) {
const u = getDistUnit();
(container || document).querySelectorAll('[data-dist-unit]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.distUnit === u);
});
}
// ── Distance / group-size formatting ─────────────────────────────────────────
// mm: value in mm (float or string)
// moa: precomputed MOA from server (may be null)
// distM: shooting distance in metres (for MRAD computation)
function fDist(mm, moa, distM) {
const u = getDistUnit();
const mmV = parseFloat(mm);
if (u === 'moa') {
if (moa != null) return `${parseFloat(moa).toFixed(2)} MOA`;
if (distM) return `${(mmV / (distM * 0.29089)).toFixed(2)} MOA`;
return `${mmV.toFixed(1)} mm`;
}
if (u === 'mrad') {
if (distM) return `${(mmV / distM).toFixed(2)} MRAD`;
return `${mmV.toFixed(1)} mm`;
}
return `${mmV.toFixed(1)} mm`;
}
// ── Velocity formatting ───────────────────────────────────────────────────────
// fps / mps are the server values (strings or numbers)
function fVel(fps, mps) {
return getVelUnit() === 'mps' ? `${mps} m/s` : `${fps} fps`;
}