diff --git a/src/review.rs b/src/review.rs index 14e1448..c5990a8 100644 --- a/src/review.rs +++ b/src/review.rs @@ -1,15 +1,19 @@ use base64::Engine; use deduper::{DuplicateGroup, DuplicateKind}; +use image::imageops::FilterType; use std::fs; use tiny_http::{Header, Method, Response, Server}; +const THUMB_MAX: u32 = 300; + pub fn launch_review(groups: &[DuplicateGroup]) { let port = find_open_port(); let addr = format!("127.0.0.1:{port}"); let server = Server::http(&addr).expect("failed to start review server"); - println!("review server running at http://{addr}"); - println!("opening browser..."); + eprintln!("building review page ({} groups)...", groups.len()); + let html = build_review_html(groups); + eprintln!("review server running at http://{addr}"); let _ = open::that(format!("http://{addr}")); @@ -20,16 +24,12 @@ pub fn launch_review(groups: &[DuplicateGroup]) { }; let url = req.url().to_string(); - let method = req.method().clone(); + match (&method, url.as_str()) { (Method::Get, "/") => { - let html = build_review_html(groups); let header = Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap(); - let _ = req.respond(Response::from_string(html).with_header(header)); - } - (Method::Get, path) if path.starts_with("/image/") => { - serve_image(req, path); + let _ = req.respond(Response::from_string(&html).with_header(header)); } (Method::Post, "/delete") => { handle_delete(req); @@ -56,42 +56,12 @@ fn find_open_port() -> u16 { .port() } -fn serve_image(req: tiny_http::Request, path: &str) { - // path = /image/ - let encoded = &path["/image/".len()..]; - let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(encoded) - .unwrap_or_default(); - let filepath = String::from_utf8_lossy(&decoded); - - match fs::read(filepath.as_ref()) { - Ok(data) => { - let mime = guess_mime(&filepath); - let header = Header::from_bytes("Content-Type", mime).unwrap(); - let _ = req.respond(Response::from_data(data).with_header(header)); - } - Err(_) => { - let _ = req.respond(Response::from_string("not found").with_status_code(404)); - } - } -} - -fn guess_mime(path: &str) -> &'static str { - let lower = path.to_ascii_lowercase(); - if lower.ends_with(".png") { "image/png" } - else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { "image/jpeg" } - else if lower.ends_with(".gif") { "image/gif" } - else if lower.ends_with(".webp") { "image/webp" } - else if lower.ends_with(".bmp") { "image/bmp" } - else if lower.ends_with(".tif") || lower.ends_with(".tiff") { "image/tiff" } - else { "application/octet-stream" } -} - fn handle_delete(mut req: tiny_http::Request) -> usize { - let mut body = String::new(); - req.as_reader().read_to_string(&mut body).unwrap_or(0); + let mut body = Vec::new(); + let _ = std::io::Read::read_to_end(req.as_reader(), &mut body); + let body_str = String::from_utf8_lossy(&body); - let paths: Vec = serde_json::from_str(&body).unwrap_or_default(); + let paths: Vec = serde_json::from_str(&body_str).unwrap_or_default(); let mut deleted = 0; for path in &paths { match fs::remove_file(path) { @@ -111,10 +81,28 @@ fn handle_delete(mut req: tiny_http::Request) -> usize { deleted } -fn image_url(path: &std::path::Path) -> String { - let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(path.to_string_lossy().as_bytes()); - format!("/image/{encoded}") +fn make_thumbnail_data_uri(path: &std::path::Path) -> String { + let data = match fs::read(path) { + Ok(d) => d, + Err(_) => return String::from("data:image/png;base64,"), + }; + + let img = match image::ImageReader::new(std::io::Cursor::new(&data)) + .with_guessed_format() + .ok() + .and_then(|r| r.decode().ok()) + { + Some(i) => i, + None => return String::from("data:image/png;base64,"), + }; + + let thumb = img.resize(THUMB_MAX, THUMB_MAX, FilterType::Triangle); + let mut buf = std::io::Cursor::new(Vec::new()); + thumb + .write_to(&mut buf, image::ImageFormat::Jpeg) + .unwrap_or(()); + let b64 = base64::engine::general_purpose::STANDARD.encode(buf.into_inner()); + format!("data:image/jpeg;base64,{b64}") } fn build_review_html(groups: &[DuplicateGroup]) -> String { @@ -127,6 +115,7 @@ fn build_review_html(groups: &[DuplicateGroup]) -> String { * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #eee; padding: 20px; } h1 { text-align: center; margin-bottom: 20px; color: #e94560; } +.summary { text-align: center; margin-bottom: 20px; color: #aaa; } .group { background: #16213e; border-radius: 12px; padding: 20px; margin-bottom: 24px; border: 1px solid #0f3460; } .group-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .group-title { font-size: 1.2em; font-weight: bold; } @@ -134,26 +123,35 @@ h1 { text-align: center; margin-bottom: 20px; color: #e94560; } .badge-exact { background: #e94560; color: white; } .badge-similar { background: #0f3460; color: #eee; border: 1px solid #533483; } .images { display: flex; flex-wrap: wrap; gap: 16px; } -.image-card { position: relative; background: #0f3460; border-radius: 8px; padding: 12px; max-width: 300px; flex: 1; min-width: 200px; } -.image-card img { width: 100%; height: 200px; object-fit: contain; border-radius: 4px; background: #000; } +.image-card { position: relative; background: #0f3460; border-radius: 8px; padding: 12px; max-width: 320px; flex: 1; min-width: 200px; } +.image-card img { width: 100%; height: 200px; object-fit: contain; border-radius: 4px; background: #000; cursor: pointer; } .image-card .path { font-size: 0.75em; color: #aaa; margin-top: 8px; word-break: break-all; } .image-card .size { font-size: 0.8em; color: #888; margin-top: 4px; } .image-card label { display: flex; align-items: center; gap: 8px; margin-top: 8px; cursor: pointer; } .image-card input[type=checkbox] { width: 20px; height: 20px; accent-color: #e94560; } -.actions { text-align: center; margin-top: 30px; } +.actions { position: sticky; bottom: 0; background: #1a1a2e; padding: 16px; text-align: center; border-top: 2px solid #0f3460; } .btn { padding: 12px 32px; border: none; border-radius: 8px; font-size: 1em; cursor: pointer; margin: 0 8px; } .btn-delete { background: #e94560; color: white; } .btn-delete:hover { background: #c73650; } .btn-done { background: #533483; color: white; } .btn-done:hover { background: #3d2660; } +.btn-all { background: #0f3460; color: #eee; border: 1px solid #533483; } +.btn-all:hover { background: #16213e; } .status { text-align: center; margin-top: 16px; font-size: 1.1em; color: #e94560; } +.deleted { opacity: 0.3; pointer-events: none; }

🔍 deduper — Review Duplicates

-

Select files to delete, then click "Delete Selected"

"#); + let total_files: usize = groups.iter().map(|g| g.paths.len()).sum(); + html.push_str(&format!( + "

{} groups, {} files — select files to delete

\n", + groups.len(), + total_files + )); + for (idx, group) in groups.iter().enumerate() { let kind_str = match group.kind { DuplicateKind::Exact => "exact", @@ -165,29 +163,30 @@ h1 { text-align: center; margin-bottom: 20px; color: #e94560; } }; html.push_str(&format!( - r#"
+ r#"
Group {} {kind_str} +
"#, idx + 1 )); - for path in &group.paths { - let url = image_url(path); + for (pidx, path) in group.paths.iter().enumerate() { + let data_uri = make_thumbnail_data_uri(path); let display_path = path.display(); let size = fs::metadata(path) .map(|m| format_size(m.len())) .unwrap_or_else(|_| "?".to_string()); html.push_str(&format!( - r#"
-{display_path} + r#"
+{display_path}
{display_path}
{size}
- +
"# )); @@ -198,33 +197,49 @@ h1 { text-align: center; margin-bottom: 20px; color: #e94560; } html.push_str(r#"
- +