diff --git a/Cargo.lock b/Cargo.lock index 694b41f..d661bbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,8 +62,7 @@ dependencies = [ "futures", "image", "lru", - "natord", - "pdfium-render", + "parsers", "rand 0.8.5", "reqwest", "serde", @@ -77,21 +76,10 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", - "unrar", "utoipa", "utoipa-swagger-ui", "uuid", "webp", - "zip 2.4.2", -] - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", ] [[package]] @@ -436,15 +424,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -509,17 +488,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "digest" version = "0.10.7" @@ -549,6 +517,15 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + [[package]] name = "either" version = "1.15.0" @@ -614,17 +591,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -639,6 +605,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1179,7 +1146,6 @@ dependencies = [ "notify", "num_cpus", "parsers", - "rand 0.8.5", "rayon", "reqwest", "serde", @@ -1209,11 +1175,11 @@ dependencies = [ [[package]] name = "inotify" -version = "0.9.6" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.0", "inotify-sys", "libc", ] @@ -1268,6 +1234,47 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys 0.61.2", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1400,25 +1407,33 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lopdf" -version = "0.35.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7c1d3350d071cb86987a6bcb205c7019a0eb70dcad92b454fec722cca8d68b" +checksum = "f560f57dfb9142a02d673e137622fd515d4231e51feb8b4af28d92647d83f35b" dependencies = [ "aes", + "bitflags 2.11.0", "cbc", "chrono", + "ecb", "encoding_rs", "flate2", + "getrandom 0.3.4", "indexmap", "itoa", + "jiff", "log", "md-5", "nom", "nom_locate", + "rand 0.9.2", "rangemap", "rayon", + "sha2", + "stringprep", "thiserror", "time", + "ttf-parser", "weezl", ] @@ -1490,12 +1505,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1506,18 +1515,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.48.0", -] - [[package]] name = "mio" version = "1.1.1" @@ -1525,6 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1547,19 +1545,18 @@ checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" [[package]] name = "nom" -version = "7.1.3" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" dependencies = [ "memchr", - "minimal-lexical", ] [[package]] name = "nom_locate" -version = "4.2.0" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" dependencies = [ "bytecount", "memchr", @@ -1568,21 +1565,29 @@ dependencies = [ [[package]] name = "notify" -version = "6.1.1" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ "bitflags 2.11.0", - "crossbeam-channel", - "filetime", "fsevent-sys", "inotify", "kqueue", "libc", "log", - "mio 0.8.11", + "mio", + "notify-types", "walkdir", - "windows-sys 0.48.0", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.0", ] [[package]] @@ -1696,13 +1701,14 @@ name = "parsers" version = "0.1.0" dependencies = [ "anyhow", + "flate2", "image", "lopdf", "natord", "pdfium-render", "regex", "unrar", - "zip 2.4.2", + "zip 8.2.0", ] [[package]] @@ -1821,6 +1827,21 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2824,7 +2845,7 @@ checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", - "mio 1.1.1", + "mio", "pin-project-lite", "signal-hook-registry", "socket2", @@ -2992,6 +3013,18 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.19.0" @@ -3907,21 +3940,24 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "b680f2a0cd479b4cff6e1233c483fdead418106eae419dc60200ae9850f6d004" dependencies = [ - "arbitrary", "crc32fast", - "crossbeam-utils", - "displaydoc", "flate2", "indexmap", "memchr", - "thiserror", + "typed-path", "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 410eeab..d7642ac 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -15,6 +15,7 @@ futures = "0.3" image.workspace = true lru.workspace = true stripstream-core = { path = "../../crates/core" } +parsers = { path = "../../crates/parsers" } rand.workspace = true tokio-stream = "0.1" reqwest.workspace = true @@ -28,10 +29,6 @@ tower-http = { version = "0.6", features = ["cors"] } tracing.workspace = true tracing-subscriber.workspace = true uuid.workspace = true -natord.workspace = true -pdfium-render.workspace = true -unrar.workspace = true -zip = { version = "8", default-features = false, features = ["deflate"] } utoipa.workspace = true utoipa-swagger-ui = { workspace = true, features = ["axum"] } webp.workspace = true diff --git a/apps/api/src/pages.rs b/apps/api/src/pages.rs index 206ce8a..96c3845 100644 --- a/apps/api/src/pages.rs +++ b/apps/api/src/pages.rs @@ -1,5 +1,5 @@ use std::{ - io::{Read, Write}, + io::Write, path::{Path, PathBuf}, sync::{atomic::Ordering, Arc}, time::Duration, @@ -351,241 +351,28 @@ fn render_page( width: u32, filter: image::imageops::FilterType, ) -> Result, ApiError> { - let page_bytes = match input_format { - "cbz" => extract_cbz_page(abs_path, page_number, true)?, - "cbr" => extract_cbr_page(abs_path, page_number, true)?, - "pdf" => render_pdf_page(abs_path, page_number, width)?, + let format = match input_format { + "cbz" => parsers::BookFormat::Cbz, + "cbr" => parsers::BookFormat::Cbr, + "pdf" => parsers::BookFormat::Pdf, _ => return Err(ApiError::bad_request("unsupported source format")), }; + let pdf_render_width = if width > 0 { width } else { 1200 }; + let page_bytes = parsers::extract_page( + std::path::Path::new(abs_path), + format, + page_number, + pdf_render_width, + ) + .map_err(|e| { + error!("Failed to extract page {} from {}: {}", page_number, abs_path, e); + ApiError::internal(format!("page extraction failed: {e}")) + })?; + transcode_image(&page_bytes, out_format, quality, width, filter) } -fn extract_cbz_page(abs_path: &str, page_number: u32, allow_fallback: bool) -> Result, ApiError> { - debug!("Opening CBZ archive: {}", abs_path); - let file = std::fs::File::open(abs_path).map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - ApiError::not_found("book file not accessible") - } else { - error!("Cannot open CBZ file {}: {}", abs_path, e); - ApiError::internal(format!("cannot open cbz: {e}")) - } - })?; - - let mut archive = match zip::ZipArchive::new(file) { - Ok(a) => a, - Err(zip_err) => { - if allow_fallback { - // Try RAR fallback (file might be a RAR with .cbz extension) - if let Ok(data) = extract_cbr_page(abs_path, page_number, false) { - return Ok(data); - } - // Streaming fallback: read local file headers without central directory - warn!("CBZ central dir failed for {}, trying streaming: {}", abs_path, zip_err); - return extract_cbz_page_streaming(abs_path, page_number); - } - error!("Invalid CBZ archive {}: {}", abs_path, zip_err); - return Err(ApiError::internal(format!("invalid cbz: {zip_err}"))); - } - }; - - let mut image_names: Vec = Vec::new(); - for i in 0..archive.len() { - let entry = match archive.by_index(i) { - Ok(e) => e, - Err(e) => { - warn!("Skipping corrupted CBZ entry {} in {}: {}", i, abs_path, e); - continue; - } - }; - let name = entry.name().to_ascii_lowercase(); - if is_image_name(&name) { - image_names.push(entry.name().to_string()); - } - } - image_names.sort_by(|a, b| natord::compare(a, b)); - debug!("Found {} images in CBZ {}", image_names.len(), abs_path); - - let index = page_number as usize - 1; - let selected = image_names.get(index).ok_or_else(|| { - error!("Page {} out of range in {} (total: {})", page_number, abs_path, image_names.len()); - ApiError::not_found("page out of range") - })?; - - debug!("Extracting page {} ({}) from {}", page_number, selected, abs_path); - let mut entry = archive.by_name(selected).map_err(|e| { - error!("Failed to read CBZ page {} from {}: {}", selected, abs_path, e); - ApiError::internal(format!("cbz page read failed: {e}")) - })?; - let mut buf = Vec::new(); - entry.read_to_end(&mut buf).map_err(|e| { - error!("Failed to load CBZ page {} from {}: {}", selected, abs_path, e); - ApiError::internal(format!("cbz page load failed: {e}")) - })?; - Ok(buf) -} - -fn extract_cbz_page_streaming(abs_path: &str, page_number: u32) -> Result, ApiError> { - let file = std::fs::File::open(abs_path).map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - ApiError::not_found("book file not accessible") - } else { - ApiError::internal(format!("cannot open cbz: {e}")) - } - })?; - let mut reader = std::io::BufReader::new(file); - let mut image_names: Vec = Vec::new(); - - loop { - match zip::read::read_zipfile_from_stream(&mut reader) { - Ok(Some(mut entry)) => { - let name = entry.name().to_string(); - if is_image_name(&name.to_ascii_lowercase()) { - image_names.push(name); - } - std::io::copy(&mut entry, &mut std::io::sink()) - .map_err(|e| ApiError::internal(format!("cbz stream skip: {e}")))?; - } - Ok(None) => break, - Err(_) => { - if !image_names.is_empty() { - break; - } - return Err(ApiError::internal("cbz streaming read failed".to_string())); - } - } - } - - image_names.sort_by(|a, b| natord::compare(a, b)); - let target = image_names - .get(page_number as usize - 1) - .ok_or_else(|| ApiError::not_found("page out of range"))? - .clone(); - - // Second pass: extract the target page - let file2 = std::fs::File::open(abs_path) - .map_err(|e| ApiError::internal(format!("cannot reopen cbz: {e}")))?; - let mut reader2 = std::io::BufReader::new(file2); - - loop { - match zip::read::read_zipfile_from_stream(&mut reader2) { - Ok(Some(mut entry)) => { - if entry.name() == target { - let mut buf = Vec::new(); - entry - .read_to_end(&mut buf) - .map_err(|e| ApiError::internal(format!("cbz stream read: {e}")))?; - return Ok(buf); - } - std::io::copy(&mut entry, &mut std::io::sink()) - .map_err(|e| ApiError::internal(format!("cbz stream skip: {e}")))?; - } - Ok(None) => break, - Err(_) => break, - } - } - - Err(ApiError::not_found("page not found in archive")) -} - -fn extract_cbr_page(abs_path: &str, page_number: u32, allow_fallback: bool) -> Result, ApiError> { - info!("Opening CBR archive: {}", abs_path); - let index = page_number as usize - 1; - - // Pass 1: list all image names (in-process, no subprocess) - let mut image_names: Vec = { - let archive = match unrar::Archive::new(abs_path).open_for_listing() { - Ok(a) => a, - Err(e) => { - if allow_fallback { - warn!("CBR open failed for {}, trying ZIP fallback: {}", abs_path, e); - return extract_cbz_page(abs_path, page_number, false); - } - return Err(ApiError::internal(format!("unrar listing failed: {}", e))); - } - }; - let mut names = Vec::new(); - for entry in archive { - let entry = entry.map_err(|e| ApiError::internal(format!("unrar entry error: {}", e)))?; - let name = entry.filename.to_string_lossy().to_string(); - if is_image_name(&name.to_ascii_lowercase()) { - names.push(name); - } - } - names - }; - - image_names.sort_by(|a, b| natord::compare(a, b)); - - let target = image_names - .get(index) - .ok_or_else(|| { - error!("Page {} out of range (total: {})", page_number, image_names.len()); - ApiError::not_found("page out of range") - })? - .clone(); - - // Pass 2: extract only the target page to memory - let mut archive = unrar::Archive::new(abs_path) - .open_for_processing() - .map_err(|e| ApiError::internal(format!("unrar processing failed: {}", e)))?; - - while let Some(header) = archive - .read_header() - .map_err(|e| ApiError::internal(format!("unrar read header: {}", e)))? - { - let entry_name = header.entry().filename.to_string_lossy().to_string(); - if entry_name == target { - let (data, _) = header - .read() - .map_err(|e| ApiError::internal(format!("unrar read: {}", e)))?; - info!("Extracted CBR page {} ({} bytes)", page_number, data.len()); - return Ok(data); - } - archive = header - .skip() - .map_err(|e| ApiError::internal(format!("unrar skip: {}", e)))?; - } - - Err(ApiError::not_found("page not found in archive")) -} - -fn render_pdf_page(abs_path: &str, page_number: u32, width: u32) -> Result, ApiError> { - use pdfium_render::prelude::*; - - debug!("Rendering PDF page {} of {} (width: {})", page_number, abs_path, width); - - let pdfium = Pdfium::new( - Pdfium::bind_to_system_library() - .map_err(|e| ApiError::internal(format!("pdfium not available: {:?}", e)))?, - ); - - let document = pdfium - .load_pdf_from_file(abs_path, None) - .map_err(|e| ApiError::internal(format!("pdf load failed: {:?}", e)))?; - - let page_index = (page_number - 1) as u16; - let page = document - .pages() - .get(page_index) - .map_err(|_| ApiError::not_found("page out of range"))?; - - let render_width = if width > 0 { width as i32 } else { 1200 }; - let config = PdfRenderConfig::new().set_target_width(render_width); - - let bitmap = page - .render_with_config(&config) - .map_err(|e| ApiError::internal(format!("pdf render failed: {:?}", e)))?; - - let image = bitmap.as_image(); - let mut buf = std::io::Cursor::new(Vec::new()); - image - .write_to(&mut buf, image::ImageFormat::Png) - .map_err(|e| ApiError::internal(format!("png encode failed: {}", e)))?; - - debug!("Rendered PDF page {} ({} bytes)", page_number, buf.get_ref().len()); - Ok(buf.into_inner()) -} fn transcode_image(input: &[u8], out_format: &OutputFormat, quality: u8, width: u32, filter: image::imageops::FilterType) -> Result, ApiError> { debug!("Transcoding image: {} bytes, format: {:?}, quality: {}, width: {}", input.len(), out_format, quality, width); @@ -650,20 +437,3 @@ fn format_matches(source: &ImageFormat, target: &OutputFormat) -> bool { ) } -fn is_image_name(name: &str) -> bool { - let lower = name.to_lowercase(); - lower.ends_with(".jpg") - || lower.ends_with(".jpeg") - || lower.ends_with(".png") - || lower.ends_with(".webp") - || lower.ends_with(".avif") - || lower.ends_with(".gif") - || lower.ends_with(".tif") - || lower.ends_with(".tiff") - || lower.ends_with(".bmp") -} - -#[allow(dead_code)] -fn _is_absolute_path(value: &str) -> bool { - Path::new(value).is_absolute() -} diff --git a/apps/backoffice/app/components/JobsIndicator.tsx b/apps/backoffice/app/components/JobsIndicator.tsx index cc6f07b..992bceb 100644 --- a/apps/backoffice/app/components/JobsIndicator.tsx +++ b/apps/backoffice/app/components/JobsIndicator.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; +import { createPortal } from "react-dom"; import Link from "next/link"; -import { Button } from "./ui/Button"; import { Badge } from "./ui/Badge"; import { ProgressBar } from "./ui/ProgressBar"; @@ -46,7 +46,9 @@ const ChevronIcon = ({ className }: { className?: string }) => ( export function JobsIndicator() { const [activeJobs, setActiveJobs] = useState([]); const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); + const buttonRef = useRef(null); + const popinRef = useRef(null); + const [popinStyle, setPopinStyle] = useState({}); useEffect(() => { const fetchActiveJobs = async () => { @@ -66,38 +68,87 @@ export function JobsIndicator() { return () => clearInterval(interval); }, []); - // Close dropdown when clicking outside + // Position the popin relative to the button + const updatePosition = useCallback(() => { + if (!buttonRef.current) return; + const rect = buttonRef.current.getBoundingClientRect(); + const isMobile = window.innerWidth < 640; + + if (isMobile) { + setPopinStyle({ + position: "fixed", + top: `${rect.bottom + 8}px`, + left: "12px", + right: "12px", + }); + } else { + // Align right edge of popin with right edge of button + const rightEdge = window.innerWidth - rect.right; + setPopinStyle({ + position: "fixed", + top: `${rect.bottom + 8}px`, + right: `${Math.max(rightEdge, 12)}px`, + width: "384px", // w-96 + }); + } + }, []); + useEffect(() => { + if (!isOpen) return; + updatePosition(); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [isOpen, updatePosition]); + + // Close when clicking outside + useEffect(() => { + if (!isOpen) return; const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + const target = event.target as Node; + if ( + buttonRef.current && !buttonRef.current.contains(target) && + popinRef.current && !popinRef.current.contains(target) + ) { setIsOpen(false); } }; - document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); + }, [isOpen]); + + // Close on Escape + useEffect(() => { + if (!isOpen) return; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsOpen(false); + }; + document.addEventListener("keydown", handleEsc); + return () => document.removeEventListener("keydown", handleEsc); + }, [isOpen]); const runningJobs = activeJobs.filter(j => j.status === "running" || j.status === "extracting_pages" || j.status === "generating_thumbnails"); const pendingJobs = activeJobs.filter(j => j.status === "pending"); const totalCount = activeJobs.length; - // Calculate overall progress const totalProgress = runningJobs.reduce((acc, job) => { return acc + (job.progress_percent || 0); }, 0) / (runningJobs.length || 1); if (totalCount === 0) { return ( - + {/* Mobile backdrop */} +
setIsOpen(false)} + aria-hidden="true" + /> + + {/* Popin */} +
+ {/* Header */} +
+
+ 📊 +
+

Active Jobs

+

+ {runningJobs.length > 0 + ? `${runningJobs.length} running, ${pendingJobs.length} pending` + : `${pendingJobs.length} job${pendingJobs.length !== 1 ? 's' : ''} pending` + } +

+
+
+ setIsOpen(false)} + > + View All → + +
+ + {/* Overall progress bar if running */} + {runningJobs.length > 0 && ( +
+
+ Overall Progress + {Math.round(totalProgress)}% +
+ +
+ )} + + {/* Job List */} +
+ {activeJobs.length === 0 ? ( +
+ +

No active jobs

+
+ ) : ( +
    + {activeJobs.map(job => ( +
  • + setIsOpen(false)} + > +
    +
    + {(job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && } + {job.status === "pending" && } +
    + +
    +
    + {job.id.slice(0, 8)} + + {job.type === 'thumbnail_rebuild' ? 'Thumbnails' : job.type === 'thumbnail_regenerate' ? 'Regenerate' : job.type} + +
    + + {(job.status === "running" || job.status === "extracting_pages" || job.status === "generating_thumbnails") && job.progress_percent != null && ( +
    + + {job.progress_percent}% +
    + )} + + {job.current_file && ( +

    + 📄 {job.current_file} +

    + )} + + {job.stats_json && ( +
    + ✓ {job.stats_json.indexed_files} + {job.stats_json.errors > 0 && ( + ⚠ {job.stats_json.errors} + )} +
    + )} +
    +
    + +
  • + ))} +
+ )} +
+ + {/* Footer */} +
+

Auto-refreshing every 2s

+
+
+ + ); + return ( -
-
)} - + {/* Icon */} - + {/* Badge with count */} {totalCount > 99 ? "99+" : totalCount} - - {/* Chevron */} -