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).
216 lines
9.9 KiB
HTML
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…"
|
|
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…
|
|
</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 & Roast
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
{% endblock %}
|