From 3746568dd7954bb7f5fa02dcb7dd6c40606d2029 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 19:31:00 +0000 Subject: [PATCH] refactor(web): extract shared AI extraction JS into standalone library Move duplicated photo capture, text extraction, and fetch/UI-toggle logic from roasters, roasts, and scan templates into a shared extract.js file served at /extract.js. Each page now provides only its form-filling callback. Uses ES6+ syntax (const, let, arrow functions, template literals). --- src/application/routes/mod.rs | 8 ++++ templates/extract.js | 57 +++++++++++++++++++++++++++++ templates/roasters.html | 69 +++-------------------------------- templates/roasts.html | 69 +++-------------------------------- templates/scan.html | 68 +++------------------------------- 5 files changed, 83 insertions(+), 188 deletions(-) create mode 100644 templates/extract.js diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index 6d5833f..7fbddba 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -113,6 +113,7 @@ pub fn app_router(state: AppState) -> axum::Router { .route("/scan", get(scan::scan_page)) .route("/timeline", get(timeline::timeline_page)) .route("/styles.css", get(styles)) + .route("/extract.js", get(extract_js)) .route("/favicon.ico", get(favicon)) .nest("/api/v1", api_routes) .layer(ServiceBuilder::new().layer(CookieManagerLayer::new())) @@ -130,6 +131,13 @@ async fn styles() -> impl IntoResponse { ) } +async fn extract_js() -> impl IntoResponse { + ( + [("content-type", "application/javascript; charset=utf-8")], + include_str!("../../../templates/extract.js"), + ) +} + async fn favicon() -> impl IntoResponse { ( [("content-type", "image/x-icon")], diff --git a/templates/extract.js b/templates/extract.js new file mode 100644 index 0000000..4941256 --- /dev/null +++ b/templates/extract.js @@ -0,0 +1,57 @@ +let _extracting = false; + +const triggerPhotoExtract = (formId, endpoint, onSuccess) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.capture = 'environment'; + input.onchange = () => { + if (input.files.length === 0) return; + const reader = new FileReader(); + reader.onload = () => { + doExtract(formId, endpoint, { image: reader.result }, onSuccess); + }; + reader.readAsDataURL(input.files[0]); + }; + input.click(); +}; + +const extractFromText = (formId, endpoint, onSuccess) => { + const input = document.getElementById(`${formId}-extract-text`); + const prompt = input.value.trim(); + if (prompt.length < 3) return; + doExtract(formId, endpoint, { prompt }, onSuccess); +}; + +const doExtract = async (formId, endpoint, body, onSuccess) => { + if (_extracting) return; + _extracting = true; + const errorEl = document.getElementById(`${formId}-extract-error`); + const controlsEl = document.getElementById(`${formId}-extract-controls`); + const waitingEl = document.getElementById(`${formId}-extract-waiting`); + errorEl.classList.add('hidden'); + controlsEl.classList.add('hidden'); + waitingEl.classList.remove('hidden'); + + try { + const resp = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(body), + }); + if (!resp.ok) { + const errData = await resp.json().catch(() => ({})); + throw new Error(errData.message || `Server returned ${resp.status}`); + } + const data = await resp.json(); + onSuccess(data); + } catch (e) { + errorEl.textContent = `Extraction failed: ${e.message}`; + errorEl.classList.remove('hidden'); + } finally { + waitingEl.classList.add('hidden'); + controlsEl.classList.remove('hidden'); + _extracting = false; + } +}; diff --git a/templates/roasters.html b/templates/roasters.html index 086dd80..857cb66 100644 --- a/templates/roasters.html +++ b/templates/roasters.html @@ -2,67 +2,10 @@ {% block head %} {% if is_authenticated && has_ai_extract %} +