feat(trip-cluster): master/detail review routes + templates + clusterReview JS

This commit is contained in:
2026-06-27 17:35:58 +02:00
parent 44c05444d3
commit edf0735098
6 changed files with 508 additions and 3 deletions
+182
View File
@@ -0,0 +1,182 @@
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 =
`<span class="badge badge-sm ${this.statusClass(status)}">${status}</span>`;
},
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() {
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(); }
},
};
}