332 lines
14 KiB
JavaScript
332 lines
14 KiB
JavaScript
// ── 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);
|