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("/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
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 %}
|
{% 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…"
|
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
|
<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
|
||||||
|
|
|
||||||
|
|
@ -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…"
|
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
|
<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
|
||||||
|
|
|
||||||
|
|
@ -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…"
|
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
|
<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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue