// ── 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 ? `${fDist(esMm, esMoa, distM)}` : ''; const caption = gp.caption || (sg ? sg.label : ''); const poiCount = (gp.points_of_impact || []).length; col.innerHTML = `
`; 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(`POIs: ${poiCount}`); if (an) { if (an.group_size_mm != null) parts.push(`Group ES: ${fDist(an.group_size_mm, an.group_size_moa, distM)}`); if (an.mean_radius_mm != null) parts.push(`Mean radius: ${fDist(an.mean_radius_mm, an.mean_radius_moa, distM)}`); 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(`Offset: ${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'}`); } } document.getElementById('lbStats').innerHTML = parts.length ? parts.join('') : 'Not yet measured — click Measure or Compute.'; } // ── 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);