feat: loading spinner + serve full-res via endpoint

- Throbber spinner while lightbox image loads
- Full-res served via /image?path= endpoint (no more base64)
- Cleaner review.rs rewrite
This commit is contained in:
admin
2026-04-28 01:08:11 +00:00
parent 623e759e20
commit 5714c999bc

View File

@@ -16,7 +16,6 @@ pub fn launch_review(groups: &[DuplicateGroup], entries: &[ImageEntry]) {
eprintln!("review server running at http://{addr}"); eprintln!("review server running at http://{addr}");
let db = ignore_db::open_db().ok(); let db = ignore_db::open_db().ok();
let _ = open::that(format!("http://{addr}")); let _ = open::that(format!("http://{addr}"));
loop { loop {
@@ -73,13 +72,8 @@ fn handle_delete(mut req: tiny_http::Request) -> usize {
let mut deleted = 0; let mut deleted = 0;
for path in &paths { for path in &paths {
match fs::remove_file(path) { match fs::remove_file(path) {
Ok(_) => { Ok(_) => { eprintln!("deleted: {path}"); deleted += 1; }
eprintln!("deleted: {path}"); Err(e) => { eprintln!("failed to delete {path}: {e}"); }
deleted += 1;
}
Err(e) => {
eprintln!("failed to delete {path}: {e}");
}
} }
} }
@@ -164,7 +158,6 @@ fn handle_ignore(
let _ = std::io::Read::read_to_end(req.as_reader(), &mut body); let _ = std::io::Read::read_to_end(req.as_reader(), &mut body);
let body_str = String::from_utf8_lossy(&body); let body_str = String::from_utf8_lossy(&body);
// Body = group index as JSON number
let group_idx: usize = serde_json::from_str(&body_str).unwrap_or(usize::MAX); let group_idx: usize = serde_json::from_str(&body_str).unwrap_or(usize::MAX);
let (ok, msg) = if let Some(conn) = db { let (ok, msg) = if let Some(conn) = db {
@@ -176,21 +169,11 @@ fn handle_ignore(
.collect(); .collect();
let fp = ignore_db::group_fingerprint(&sha_list); let fp = ignore_db::group_fingerprint(&sha_list);
match ignore_db::ignore_group(conn, &fp) { match ignore_db::ignore_group(conn, &fp) {
Ok(_) => { Ok(_) => { eprintln!("ignored group {group_idx} (fingerprint: {fp})"); (true, "group ignored") }
eprintln!("ignored group {group_idx} (fingerprint: {fp})"); Err(e) => { eprintln!("failed to ignore group: {e}"); (false, "db error") }
(true, "group ignored")
}
Err(e) => {
eprintln!("failed to ignore group: {e}");
(false, "db error")
}
} }
} else { } else { (false, "invalid group index") }
(false, "invalid group index") } else { (false, "no database") };
}
} else {
(false, "no database")
};
let json = format!("{{\"ok\":{ok},\"message\":\"{msg}\"}}"); let json = format!("{{\"ok\":{ok},\"message\":\"{msg}\"}}");
let header = Header::from_bytes("Content-Type", "application/json").unwrap(); let header = Header::from_bytes("Content-Type", "application/json").unwrap();
@@ -214,14 +197,11 @@ fn make_thumbnail_data_uri(path: &std::path::Path) -> String {
let thumb = img.resize(THUMB_MAX, THUMB_MAX, FilterType::Triangle); let thumb = img.resize(THUMB_MAX, THUMB_MAX, FilterType::Triangle);
let mut buf = std::io::Cursor::new(Vec::new()); let mut buf = std::io::Cursor::new(Vec::new());
thumb thumb.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap_or(());
.write_to(&mut buf, image::ImageFormat::Jpeg)
.unwrap_or(());
let b64 = base64::engine::general_purpose::STANDARD.encode(buf.into_inner()); let b64 = base64::engine::general_purpose::STANDARD.encode(buf.into_inner());
format!("data:image/jpeg;base64,{b64}") format!("data:image/jpeg;base64,{b64}")
} }
fn build_review_html(groups: &[DuplicateGroup]) -> String { fn build_review_html(groups: &[DuplicateGroup]) -> String {
let mut html = String::from(r#"<!DOCTYPE html> let mut html = String::from(r#"<!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -262,17 +242,21 @@ h1 { text-align: center; margin-bottom: 20px; color: #e94560; }
.lightbox { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.92); z-index: 1000; justify-content: center; align-items: center; cursor: zoom-out; } .lightbox { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.92); z-index: 1000; justify-content: center; align-items: center; cursor: zoom-out; }
.lightbox.active { display: flex; } .lightbox.active { display: flex; }
.lightbox img { max-width: 95vw; max-height: 95vh; object-fit: contain; border-radius: 8px; } .lightbox img { max-width: 95vw; max-height: 95vh; object-fit: contain; border-radius: 8px; }
.lightbox img.loading { display: none; }
.lightbox .lb-path { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); color: #aaa; font-size: 0.85em; background: rgba(0,0,0,0.7); padding: 6px 16px; border-radius: 8px; max-width: 90vw; text-align: center; word-break: break-all; } .lightbox .lb-path { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); color: #aaa; font-size: 0.85em; background: rgba(0,0,0,0.7); padding: 6px 16px; border-radius: 8px; max-width: 90vw; text-align: center; word-break: break-all; }
.spinner { display: none; width: 48px; height: 48px; border: 5px solid rgba(255,255,255,0.2); border-top: 5px solid #e94560; border-radius: 50%; animation: spin 0.8s linear infinite; }
.lightbox.active .spinner.loading { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
</style> </style>
</head> </head>
<body> <body>
<div class="lightbox" id="lightbox" onclick="closeLightbox()"><img id="lb-img" src=""><div class="lb-path" id="lb-path"></div></div> <div class="lightbox" id="lightbox" onclick="closeLightbox()"><div class="spinner" id="lb-spinner"></div><img id="lb-img" src="" onload="document.getElementById('lb-spinner').classList.remove('loading');this.classList.remove('loading');"><div class="lb-path" id="lb-path"></div></div>
<h1>🔍 deduper Review Duplicates</h1> <h1>deduper Review Duplicates</h1>
"#); "#);
let total_files: usize = groups.iter().map(|g| g.paths.len()).sum(); let total_files: usize = groups.iter().map(|g| g.paths.len()).sum();
html.push_str(&format!( html.push_str(&format!(
"<p class=\"summary\">{} groups, {} files select files to delete or dismiss false positives</p>\n", "<p class=\"summary\">{} groups, {} files select files to delete or dismiss false positives</p>\n",
groups.len(), groups.len(),
total_files total_files
)); ));
@@ -293,7 +277,7 @@ h1 { text-align: center; margin-bottom: 20px; color: #e94560; }
<span class="group-title">Group {}</span> <span class="group-title">Group {}</span>
<span class="badge {badge_class}">{kind_str}</span> <span class="badge {badge_class}">{kind_str}</span>
<button class="btn btn-all" onclick="selectAllBut('{idx}')">Keep first, select rest</button> <button class="btn btn-all" onclick="selectAllBut('{idx}')">Keep first, select rest</button>
<button class="btn btn-ignore" onclick="ignoreGroup({idx})">👁️ Not a dupe</button> <button class="btn btn-ignore" onclick="ignoreGroup({idx})">Not a dupe</button>
</div> </div>
<div class="images"> <div class="images">
"#, "#,
@@ -324,8 +308,8 @@ h1 { text-align: center; margin-bottom: 20px; color: #e94560; }
html.push_str(r#" html.push_str(r#"
<div class="actions"> <div class="actions">
<button class="btn btn-delete" onclick="deleteSelected()">🗑️ Delete Selected (<span id="count">0</span>)</button> <button class="btn btn-delete" onclick="deleteSelected()">Delete Selected (<span id="count">0</span>)</button>
<button class="btn btn-done" onclick="shutdown()">Done</button> <button class="btn btn-done" onclick="shutdown()">Done</button>
</div> </div>
<div class="status" id="status"></div> <div class="status" id="status"></div>
@@ -353,7 +337,7 @@ async function ignoreGroup(groupIdx) {
if (data.ok) { if (data.ok) {
const el = document.getElementById('group-' + groupIdx); const el = document.getElementById('group-' + groupIdx);
if (el) el.classList.add('ignored'); if (el) el.classList.add('ignored');
document.getElementById('status').textContent = 'Group ' + (groupIdx+1) + ' dismissed — won\'t appear next run'; document.getElementById('status').textContent = 'Group ' + (groupIdx+1) + ' dismissed';
} else { } else {
document.getElementById('status').textContent = 'Error: ' + data.message; document.getElementById('status').textContent = 'Error: ' + data.message;
} }
@@ -391,12 +375,17 @@ async function shutdown() {
} }
function openLightbox(src, path) { function openLightbox(src, path) {
document.getElementById('lb-img').src = src; const img = document.getElementById('lb-img');
const spinner = document.getElementById('lb-spinner');
img.classList.add('loading');
spinner.classList.add('loading');
img.src = src;
document.getElementById('lb-path').textContent = path; document.getElementById('lb-path').textContent = path;
document.getElementById('lightbox').classList.add('active'); document.getElementById('lightbox').classList.add('active');
} }
function closeLightbox() { function closeLightbox() {
document.getElementById('lightbox').classList.remove('active'); document.getElementById('lightbox').classList.remove('active');
document.getElementById('lb-img').src = '';
} }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
</script> </script>