First commit of claude's rework in django + vanillajs fronted
This commit is contained in:
737
frontend/js/admin.js
Normal file
737
frontend/js/admin.js
Normal 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
132
frontend/js/api.js
Normal 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
295
frontend/js/ballistics.js
Normal 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
76
frontend/js/calibers.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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
448
frontend/js/chrono.js
Normal 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
280
frontend/js/friends.js
Normal 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
657
frontend/js/gears.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
|
||||
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
455
frontend/js/group-size.js
Normal 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
925
frontend/js/i18n.js
Normal 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
272
frontend/js/messages.js
Normal 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
187
frontend/js/nav.js
Normal 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
331
frontend/js/photos.js
Normal 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
182
frontend/js/profile.js
Normal 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
542
frontend/js/reloads.js
Normal 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
1084
frontend/js/sessions.js
Normal file
File diff suppressed because it is too large
Load Diff
90
frontend/js/utils.js
Normal file
90
frontend/js/utils.js
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user