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("/scan", get(scan::scan_page))
.route("/timeline", get(timeline::timeline_page)) .route("/timeline", get(timeline::timeline_page))
.route("/styles.css", get(styles)) .route("/styles.css", get(styles))
.route("/extract.js", get(extract_js))
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))
.nest("/api/v1", api_routes) .nest("/api/v1", api_routes)
.layer(ServiceBuilder::new().layer(CookieManagerLayer::new())) .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 { async fn favicon() -> impl IntoResponse {
( (
[("content-type", "image/x-icon")], [("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 %} {% block head %}
{% if is_authenticated && has_ai_extract %} {% if is_authenticated && has_ai_extract %}
<script src="/extract.js"></script>
<script> <script>
var _extracting = false; function fillRoasterForm(data) {
var form = document.getElementById('roaster-form');
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);
if (!form) return; if (!form) return;
if (data.name) form.querySelector('[name="name"]').value = data.name; if (data.name) form.querySelector('[name="name"]').value = data.name;
if (data.country) form.querySelector('[name="country"]').value = data.country; 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"> <div id="roaster-form-extract-controls" class="flex flex-wrap items-center gap-3">
<button <button
type="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" 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"> <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" id="roaster-form-extract-text"
class="input-field w-full text-sm" class="input-field w-full text-sm"
placeholder="Describe the roaster&hellip;" 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 <button
type="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" 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 Go

View file

@ -2,67 +2,10 @@
{% block head %} {% block head %}
{% if is_authenticated && has_ai_extract %} {% if is_authenticated && has_ai_extract %}
<script src="/extract.js"></script>
<script> <script>
var _extracting = false; function fillRoastForm(data) {
var form = document.getElementById('roast-form');
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);
if (!form) return; if (!form) return;
if (data.roaster_name) { if (data.roaster_name) {
@ -149,7 +92,7 @@
<div id="roast-form-extract-controls" class="flex flex-wrap items-center gap-3"> <div id="roast-form-extract-controls" class="flex flex-wrap items-center gap-3">
<button <button
type="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" 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"> <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" id="roast-form-extract-text"
class="input-field w-full text-sm" class="input-field w-full text-sm"
placeholder="Describe the coffee&hellip;" 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 <button
type="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" 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 Go

View file

@ -1,68 +1,10 @@
{% extends "base.html" %} {% block title %}Brewlog · Scan Bag{% endblock %} {% extends "base.html" %} {% block title %}Brewlog · Scan Bag{% endblock %}
{% block head %} {% block head %}
<script src="/extract.js"></script>
<script> <script>
var _extracting = false;
var _submitting = 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) { function fillScanForms(data) {
var form = document.getElementById('scan-form'); var form = document.getElementById('scan-form');
if (!form) return; if (!form) return;
@ -83,6 +25,8 @@
form.querySelector('[name="tasting_notes"]').value = data.roast.tasting_notes.join(', '); 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() { function resetScan() {
@ -142,7 +86,7 @@
<div id="scan-extract-controls" class="flex flex-wrap items-center gap-3"> <div id="scan-extract-controls" class="flex flex-wrap items-center gap-3">
<button <button
type="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" 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"> <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
@ -157,11 +101,11 @@
id="scan-extract-text" id="scan-extract-text"
class="input-field w-full text-sm" class="input-field w-full text-sm"
placeholder="Describe the coffee bag&hellip;" 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 <button
type="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" 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 Go