brewlog/templates/scan.html
Jon Seager 3746568dd7
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).
2026-02-03 19:31:00 +00:00

216 lines
9.9 KiB
HTML

{% extends "base.html" %} {% block title %}Brewlog · Scan Bag{% endblock %}
{% block head %}
<script src="/extract.js"></script>
<script>
var _submitting = false;
function fillScanForms(data) {
var form = document.getElementById('scan-form');
if (!form) return;
if (data.roaster) {
if (data.roaster.name) form.querySelector('[name="roaster_name"]').value = data.roaster.name;
if (data.roaster.country) form.querySelector('[name="roaster_country"]').value = data.roaster.country;
if (data.roaster.city) form.querySelector('[name="roaster_city"]').value = data.roaster.city;
if (data.roaster.homepage) form.querySelector('[name="roaster_homepage"]').value = data.roaster.homepage;
if (data.roaster.notes) form.querySelector('[name="roaster_notes"]').value = data.roaster.notes;
}
if (data.roast) {
if (data.roast.name) form.querySelector('[name="roast_name"]').value = data.roast.name;
if (data.roast.origin) form.querySelector('[name="origin"]').value = data.roast.origin;
if (data.roast.region) form.querySelector('[name="region"]').value = data.roast.region;
if (data.roast.producer) form.querySelector('[name="producer"]').value = data.roast.producer;
if (data.roast.process) form.querySelector('[name="process"]').value = data.roast.process;
if (data.roast.tasting_notes && data.roast.tasting_notes.length > 0) {
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() {
document.getElementById('scan-input-section').style.display = 'block';
document.getElementById('scan-form-section').style.display = 'none';
document.getElementById('scan-form').reset();
document.getElementById('scan-submit-error').classList.add('hidden');
}
async function submitScan(event) {
event.preventDefault();
if (_submitting) return;
_submitting = true;
var errorEl = document.getElementById('scan-submit-error');
errorEl.classList.add('hidden');
var form = document.getElementById('scan-form');
var formData = new FormData(form);
var body = {};
formData.forEach(function (value, key) { body[key] = value; });
try {
var resp = await fetch('/api/v1/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();
window.location.href = data.redirect || '/roasts';
} catch (e) {
errorEl.textContent = 'Save failed: ' + e.message;
errorEl.classList.remove('hidden');
} finally {
_submitting = false;
}
return false;
}
</script>
{% endblock %}
{% block content %}
<section>
<header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Scan Bag</h1>
<p class="max-w-2xl text-sm text-stone-600">
Take a photo of a coffee bag or describe it, and Brewlog will extract the roaster and roast details for you.
</p>
</header>
<!-- Input section: photo or text -->
<div id="scan-input-section" class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<div id="scan-extract-controls" class="flex flex-wrap items-center gap-3">
<button
type="button"
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">
<path fill-rule="evenodd" d="M1 8a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 018.07 3h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0016.07 6H17a2 2 0 012 2v7a2 2 0 01-2 2H3a2 2 0 01-2-2V8zm13.5 3a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM10 14a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
</svg>
Take Photo
</button>
<span class="text-xs text-stone-400">or</span>
<div class="flex flex-1 min-w-[200px] gap-2">
<input
type="text"
id="scan-extract-text"
class="input-field w-full text-sm"
placeholder="Describe the coffee bag&hellip;"
onkeydown="if(event.key==='Enter'){event.preventDefault();extractFromText('scan','/api/v1/extract-bag-scan',fillScanForms)}"
/>
<button
type="button"
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
</button>
</div>
</div>
<div id="scan-extract-waiting" class="hidden flex items-center gap-3 text-sm text-amber-700">
<svg class="h-5 w-5 animate-spin text-amber-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Waiting for response&hellip;
</div>
<p id="scan-extract-error" class="hidden mt-2 text-sm text-red-600"></p>
</div>
<!-- Form section: pre-filled roaster + roast forms -->
<div id="scan-form-section" style="display: none">
<form
id="scan-form"
class="mt-6 flex flex-col gap-6"
onsubmit="return submitScan(event)"
>
<!-- Roaster section -->
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<div>
<h2 class="text-lg font-semibold text-amber-700">Roaster</h2>
<p class="mt-1 text-sm text-stone-600">If this roaster already exists, it will be matched automatically.</p>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Name *</span>
<input type="text" name="roaster_name" required class="input-field" placeholder="Example Coffee Roasters" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Country *</span>
<input type="text" name="roaster_country" required class="input-field" placeholder="United States" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">City</span>
<input type="text" name="roaster_city" class="input-field" placeholder="Portland" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Homepage</span>
<input type="url" name="roaster_homepage" class="input-field" placeholder="https://example.coffee" />
</label>
<label class="sm:col-span-2 flex flex-col gap-1 text-sm">
<span class="text-stone-700">Notes</span>
<textarea name="roaster_notes" rows="2" class="input-field" placeholder="Short description of the roaster"></textarea>
</label>
</div>
</div>
<!-- Roast section -->
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<div>
<h2 class="text-lg font-semibold text-amber-700">Roast</h2>
<p class="mt-1 text-sm text-stone-600">Details about this specific coffee.</p>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Roast Name *</span>
<input type="text" name="roast_name" required class="input-field" placeholder="Ethiopia Yirgacheffe" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Origin *</span>
<input type="text" name="origin" required class="input-field" placeholder="Ethiopia" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Region *</span>
<input type="text" name="region" required class="input-field" placeholder="Guji" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Producer *</span>
<input type="text" name="producer" required class="input-field" placeholder="Chelbesa Cooperative" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Process *</span>
<input type="text" name="process" required class="input-field" placeholder="Washed" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Tasting Notes * (comma separated)</span>
<textarea name="tasting_notes" rows="2" required class="input-field" placeholder="Blueberry, Jasmine"></textarea>
</label>
</div>
</div>
<p id="scan-submit-error" class="hidden text-sm text-red-600"></p>
<div class="flex items-center justify-end gap-2">
<button
type="button"
onclick="resetScan()"
class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-amber-400 hover:text-stone-800"
>
Start Over
</button>
<button
type="submit"
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
>
Save Roaster &amp; Roast
</button>
</div>
</form>
</div>
</section>
{% endblock %}