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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user