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).
This commit is contained in:
Jon Seager 2026-02-03 19:31:00 +00:00
parent e3db350609
commit 3746568dd7
No known key found for this signature in database
5 changed files with 83 additions and 188 deletions

View file

@ -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")],

57
templates/extract.js Normal file
View file

@ -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;
}
};

View file

@ -2,67 +2,10 @@
{% block head %}
{% if is_authenticated && has_ai_extract %}
<script src="/extract.js"></script>
<script>
var _extracting = false;
function triggerPhotoExtract(formId, endpoint) {
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.capture = 'environment';
input.onchange = function () {
if (input.files.length === 0) return;
var reader = new FileReader();
reader.onload = function () {
doExtract(formId, endpoint, { image: reader.result });
};
reader.readAsDataURL(input.files[0]);
};
input.click();
}
function extractFromText(formId, endpoint) {
var input = document.getElementById(formId + '-extract-text');
var prompt = input.value.trim();
if (prompt.length < 3) return;
doExtract(formId, endpoint, { prompt: prompt });
}
async function doExtract(formId, endpoint, body) {
if (_extracting) return;
_extracting = true;
var errorEl = document.getElementById(formId + '-extract-error');
var controlsEl = document.getElementById(formId + '-extract-controls');
var waitingEl = document.getElementById(formId + '-extract-waiting');
errorEl.classList.add('hidden');
controlsEl.classList.add('hidden');
waitingEl.classList.remove('hidden');
try {
var resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (!resp.ok) {
var errData = await resp.json().catch(function () { return {}; });
throw new Error(errData.message || 'Server returned ' + resp.status);
}
var data = await resp.json();
fillRoasterForm(formId, data);
} catch (e) {
errorEl.textContent = 'Extraction failed: ' + e.message;
errorEl.classList.remove('hidden');
} finally {
waitingEl.classList.add('hidden');
controlsEl.classList.remove('hidden');
_extracting = false;
}
}
function fillRoasterForm(formId, data) {
var form = document.getElementById(formId);
function fillRoasterForm(data) {
var form = document.getElementById('roaster-form');
if (!form) return;
if (data.name) form.querySelector('[name="name"]').value = data.name;
if (data.country) form.querySelector('[name="country"]').value = data.country;
@ -120,7 +63,7 @@
<div id="roaster-form-extract-controls" class="flex flex-wrap items-center gap-3">
<button
type="button"
onclick="triggerPhotoExtract('roaster-form', '/api/v1/extract-roaster')"
onclick="triggerPhotoExtract('roaster-form', '/api/v1/extract-roaster', fillRoasterForm)"
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
@ -135,11 +78,11 @@
id="roaster-form-extract-text"
class="input-field w-full text-sm"
placeholder="Describe the roaster&hellip;"
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('roaster-form','/api/v1/extract-roaster')}"
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('roaster-form','/api/v1/extract-roaster',fillRoasterForm)}"
/>
<button
type="button"
onclick="extractFromText('roaster-form', '/api/v1/extract-roaster')"
onclick="extractFromText('roaster-form', '/api/v1/extract-roaster', fillRoasterForm)"
class="rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
>
Go

View file

@ -2,67 +2,10 @@
{% block head %}
{% if is_authenticated && has_ai_extract %}
<script src="/extract.js"></script>
<script>
var _extracting = false;
function triggerPhotoExtract(formId, endpoint) {
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.capture = 'environment';
input.onchange = function () {
if (input.files.length === 0) return;
var reader = new FileReader();
reader.onload = function () {
doExtract(formId, endpoint, { image: reader.result });
};
reader.readAsDataURL(input.files[0]);
};
input.click();
}
function extractFromText(formId, endpoint) {
var input = document.getElementById(formId + '-extract-text');
var prompt = input.value.trim();
if (prompt.length < 3) return;
doExtract(formId, endpoint, { prompt: prompt });
}
async function doExtract(formId, endpoint, body) {
if (_extracting) return;
_extracting = true;
var errorEl = document.getElementById(formId + '-extract-error');
var controlsEl = document.getElementById(formId + '-extract-controls');
var waitingEl = document.getElementById(formId + '-extract-waiting');
errorEl.classList.add('hidden');
controlsEl.classList.add('hidden');
waitingEl.classList.remove('hidden');
try {
var resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (!resp.ok) {
var errData = await resp.json().catch(function () { return {}; });
throw new Error(errData.message || 'Server returned ' + resp.status);
}
var data = await resp.json();
fillRoastForm(formId, data);
} catch (e) {
errorEl.textContent = 'Extraction failed: ' + e.message;
errorEl.classList.remove('hidden');
} finally {
waitingEl.classList.add('hidden');
controlsEl.classList.remove('hidden');
_extracting = false;
}
}
function fillRoastForm(formId, data) {
var form = document.getElementById(formId);
function fillRoastForm(data) {
var form = document.getElementById('roast-form');
if (!form) return;
if (data.roaster_name) {
@ -149,7 +92,7 @@
<div id="roast-form-extract-controls" class="flex flex-wrap items-center gap-3">
<button
type="button"
onclick="triggerPhotoExtract('roast-form', '/api/v1/extract-roast')"
onclick="triggerPhotoExtract('roast-form', '/api/v1/extract-roast', fillRoastForm)"
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
@ -164,11 +107,11 @@
id="roast-form-extract-text"
class="input-field w-full text-sm"
placeholder="Describe the coffee&hellip;"
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('roast-form','/api/v1/extract-roast')}"
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('roast-form','/api/v1/extract-roast',fillRoastForm)}"
/>
<button
type="button"
onclick="extractFromText('roast-form', '/api/v1/extract-roast')"
onclick="extractFromText('roast-form', '/api/v1/extract-roast', fillRoastForm)"
class="rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
>
Go

View file

@ -1,68 +1,10 @@
{% extends "base.html" %} {% block title %}Brewlog · Scan Bag{% endblock %}
{% block head %}
<script src="/extract.js"></script>
<script>
var _extracting = false;
var _submitting = false;
function triggerScanPhoto() {
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.capture = 'environment';
input.onchange = function () {
if (input.files.length === 0) return;
var reader = new FileReader();
reader.onload = function () {
doScanExtract({ image: reader.result });
};
reader.readAsDataURL(input.files[0]);
};
input.click();
}
function scanFromText() {
var input = document.getElementById('scan-extract-text');
var prompt = input.value.trim();
if (prompt.length < 3) return;
doScanExtract({ prompt: prompt });
}
async function doScanExtract(body) {
if (_extracting) return;
_extracting = true;
var errorEl = document.getElementById('scan-extract-error');
var controlsEl = document.getElementById('scan-extract-controls');
var waitingEl = document.getElementById('scan-extract-waiting');
errorEl.classList.add('hidden');
controlsEl.classList.add('hidden');
waitingEl.classList.remove('hidden');
try {
var resp = await fetch('/api/v1/extract-bag-scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (!resp.ok) {
var errData = await resp.json().catch(function () { return {}; });
throw new Error(errData.message || 'Server returned ' + resp.status);
}
var data = await resp.json();
fillScanForms(data);
document.getElementById('scan-input-section').style.display = 'none';
document.getElementById('scan-form-section').style.display = 'block';
} catch (e) {
errorEl.textContent = 'Extraction failed: ' + e.message;
errorEl.classList.remove('hidden');
} finally {
waitingEl.classList.add('hidden');
controlsEl.classList.remove('hidden');
_extracting = false;
}
}
function fillScanForms(data) {
var form = document.getElementById('scan-form');
if (!form) return;
@ -83,6 +25,8 @@
form.querySelector('[name="tasting_notes"]').value = data.roast.tasting_notes.join(', ');
}
}
document.getElementById('scan-input-section').style.display = 'none';
document.getElementById('scan-form-section').style.display = 'block';
}
function resetScan() {
@ -142,7 +86,7 @@
<div id="scan-extract-controls" class="flex flex-wrap items-center gap-3">
<button
type="button"
onclick="triggerScanPhoto()"
onclick="triggerPhotoExtract('scan', '/api/v1/extract-bag-scan', fillScanForms)"
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
@ -157,11 +101,11 @@
id="scan-extract-text"
class="input-field w-full text-sm"
placeholder="Describe the coffee bag&hellip;"
onkeydown="if(event.key==='Enter'){event.preventDefault();scanFromText()}"
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('scan','/api/v1/extract-bag-scan',fillScanForms)}"
/>
<button
type="button"
onclick="scanFromText()"
onclick="extractFromText('scan', '/api/v1/extract-bag-scan', fillScanForms)"
class="rounded-md border border-amber-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
>
Go