feat(trip-cluster): master/detail review routes + templates + clusterReview JS
This commit is contained in:
@@ -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(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user