Files
ShooterHub/frontend/js/photos.js
2026-04-02 11:24:30 +02:00

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);