function clusterReview() { return { ...photoGrid(), selected: null, clusterIds: [], failures: [], init() { this.clusterIds = [...document.querySelectorAll('.cluster-row')] .map(el => parseInt(el.dataset.clusterId, 10)); this.selected = this.clusterIds.length ? this.clusterIds[0] : null; document.body.addEventListener('htmx:afterSwap', (e) => { if (e.target.id === 'detail') { this.hideIndicator(); this.selectFirst(); } }); }, // --- F16: loading indicator for programmatic swaps ----------------- showIndicator() { const el = document.getElementById('detail-indicator'); if (el) el.classList.add('htmx-request'); }, hideIndicator() { const el = document.getElementById('detail-indicator'); if (el) el.classList.remove('htmx-request'); }, selectCluster(id) { this.selected = id; this.showIndicator(); htmx.ajax('GET', `/cluster/${id}`, { target: '#detail' }); }, moveCluster(dir) { const n = this.clusterIds.length; if (!n) return; const i = this.clusterIds.indexOf(this.selected); const j = ((i + dir) % n + n) % n; // wrap around (mirrors nextPendingId) if (this.clusterIds[j] != null) this.selectCluster(this.clusterIds[j]); }, // --- F3: in-place rail status + advance to next pending ------------ statusClass(status) { return ({ pending: 'badge-ghost', approved: 'badge-success', non_trip: 'badge-neutral', skipped: 'badge-warning', merged: 'badge-info', split: 'badge-info', })[status] || 'badge-ghost'; }, railStatusEl(id) { return document.querySelector(`.cluster-status[data-cluster-id="${id}"]`); }, railStatusText(id) { const el = this.railStatusEl(id); return el ? el.textContent.trim() : ''; }, updateRailStatus(id, status) { const el = this.railStatusEl(id); if (el) el.innerHTML = `${status}`; }, nextPendingId(fromId) { const n = this.clusterIds.length; const start = Math.max(0, this.clusterIds.indexOf(fromId)); for (let k = 1; k <= n; k++) { const id = this.clusterIds[(start + k) % n]; if (id !== fromId && this.railStatusText(id) === 'pending') return id; } return null; }, // Update the acted cluster's rail badge in place (keeping the new status // text visible) and advance the detail pane to the next pending cluster // instead of bouncing back to the attention-queue head. afterDecision(id, status) { this.updateRailStatus(id, status); const next = this.nextPendingId(id); if (next != null) this.selectCluster(next); else this.selectCluster(id); // no pending left: refresh current detail }, nameValue() { const el = document.getElementById('cluster-name'); return el ? el.value : ''; }, async post(path, body) { const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), }); return res.json(); }, async approve() { const acted = this.selected; const r = await this.post(`/cluster/${acted}/approve`, { name: this.nameValue() }); this.afterDecision(acted, r.status || 'approved'); }, async nonTrip() { const acted = this.selected; const r = await this.post(`/cluster/${acted}/non-trip`); this.afterDecision(acted, r.status || 'non_trip'); }, async skip() { const acted = this.selected; const r = await this.post(`/cluster/${acted}/skip`); this.afterDecision(acted, r.status || 'skipped'); }, async split() { // F7: require a focused boundary photo before splitting. if (!this.focused) { alert('Focus the first photo of the second trip, then Split.'); return; } await this.post(`/cluster/${this.selected}/split`, { boundary_asset_id: this.focused.dataset.assetId }); location.reload(); // split restructures the rail: full refresh is correct }, async merge(otherId) { // F17: merge terminally marks both clusters merged with no UI un-merge. if (!confirm('Merge these two clusters? This cannot be undone in the UI.')) return; await this.post(`/cluster/${this.selected}/merge`, { other_id: otherId }); location.reload(); // merge restructures the rail: full refresh is correct }, async setMember(assetId, included) { await this.post(`/cluster/${this.selected}/member`, { asset_id: assetId, included }); this.selectCluster(this.selected); }, async approveHighConfidence() { // F19: show a pre-action count and confirm. const threshold = 0.75; const n = this.clusterIds.filter(id => { const row = document.querySelector(`.cluster-row[data-cluster-id="${id}"]`); return row && row.querySelector('.badge-success[title]'); // confidence "high" }).length; if (!confirm(`Approve ${n} high-confidence cluster(s) (>= ${threshold})?`)) return; const r = await this.post('/approve-high-confidence', { threshold }); alert(`Approved ${r.approved} cluster(s).`); location.reload(); }, async apply() { // Write-back needs explicit confirmation (mirrors applyAll/merge). if (!confirm('Apply this cluster’s decision to Immich?')) return; const r = await this.post(`/cluster/${this.selected}/apply`, {}); alert(`Applied ${r.succeeded.length}, failed ${r.failed.length}.`); }, async applyAll() { if (!confirm('Apply all approved decisions to Immich?')) return; const r = await this.post('/apply-all', {}); const results = r.results || []; let ok = 0, bad = 0; this.failures = []; for (const x of results) { const succeeded = x.succeeded || []; const failed = x.failed || []; ok += succeeded.length; bad += failed.length; if (failed.length) { const id = x.cluster_id != null ? x.cluster_id : x.id; const row = document.querySelector(`.cluster-row[data-cluster-id="${id}"]`); if (row) row.classList.add('badge-error', 'ring-1', 'ring-error'); this.failures.push({ id, name: row ? row.dataset.clusterName : `cluster ${id}`, failed: failed.length, }); } } alert(`Applied ${ok} write(s), ${bad} failure(s).`); }, onKey(e) { if (e.target.tagName === 'INPUT') return; const k = e.key.toLowerCase(); if (e.key === '[') { e.preventDefault(); this.moveCluster(-1); } else if (e.key === ']') { e.preventDefault(); this.moveCluster(1); } else if (e.key === 'ArrowLeft') { e.preventDefault(); this.navigate(-1); } else if (e.key === 'ArrowRight') { e.preventDefault(); this.navigate(1); } else if (e.key === 'Enter') { if (this.focused) this.openLightbox(this.focused); } else if (e.key === 'Escape') { this.closeLightbox(); } else if (k === 'a') { this.approve(); } else if (k === 'n') { this.nonTrip(); } else if (k === 's') { if (this.focused) this.split(); } // F7: suppress until focused else if (k === 'x') { this.skip(); } }, }; }