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:
parent
e3db350609
commit
3746568dd7
5 changed files with 83 additions and 188 deletions
|
|
@ -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
57
templates/extract.js
Normal 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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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…"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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…"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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…"
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue