feat(add): add extraction bars to roaster and roast forms

- Remove top-level scan section and scan_input import
- Add per-form extraction bars with camera + describe input
- Rename form signals from _add-* prefix to match extraction endpoints
- Add per-form extracting/error signals to guard datastar-fetch events
- Make Log Brew button full width
This commit is contained in:
Jon Seager 2026-02-07 20:58:50 +00:00
parent dd3714f0a1
commit 411d520cb3
No known key found for this signature in database

View file

@ -1,4 +1,4 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import "partials/scan_input.html" as scan %} {% import "partials/location_search.html" as location %} {% block title %}Brewlog · Add{% endblock %} {% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import "partials/location_search.html" as location %} {% block title %}Brewlog · Add{% endblock %}
{% block head %} {% block head %}
<script> <script>
@ -7,63 +7,22 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Scan Bag (Quick Add) -->
<section <section
data-signals:_extracting="false" data-signals:_add-submitting="false"
data-signals:_extract-error="''" data-signals:_roaster-extracting="false"
data-signals:_scan-extracted="false" data-signals:_roaster-extract-error="''"
data-signals:_scan-submitting="false"
data-signals:_scan-error="''"
data-signals:_roaster-name="''" data-signals:_roaster-name="''"
data-signals:_roaster-country="''" data-signals:_roaster-country="''"
data-signals:_roaster-city="''" data-signals:_roaster-city="''"
data-signals:_roaster-homepage="''" data-signals:_roaster-homepage="''"
data-signals:_roast-extracting="false"
data-signals:_roast-extract-error="''"
data-signals:_roast-name="''" data-signals:_roast-name="''"
data-signals:_origin="''" data-signals:_origin="''"
data-signals:_region="''" data-signals:_region="''"
data-signals:_producer="''" data-signals:_producer="''"
data-signals:_process="''" data-signals:_process="''"
data-signals:_tasting-notes="''" data-signals:_tasting-notes="''"
data-signals:_open-bag="true"
data-signals:_bag-amount="250"
data-signals:_matched-roaster-id="''"
data-signals:_matched-roast-id="''"
>
<!-- Input: photo or text -->
<div data-show="!$_scanExtracted" class="rounded-lg border bg-surface p-5">
<form id="scan-extract-form"
data-on:submit="$_extracting = true; $_extractError = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }"
>
{{ scan::scan_input("scan-extract-form", "scan-image", "$_extracting", "$_extractError", "Describe a coffee bag…", "Waiting for response…") }}
</form>
</div>
<!-- Scan result form -->
<div data-show="$_scanExtracted" style="display: none">
<form
class="rounded-lg border bg-surface p-5 flex flex-col gap-6"
data-on:submit="$_scanSubmitting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { $_scanSubmitting = false; window.location.href = '/data?type=bags' } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
>
{% include "partials/forms/scan_result_form.html" %}
</form>
</div>
</section>
<!-- Manual Add -->
<section
data-signals:_add-submitting="false"
data-signals:_add-roaster-name="''"
data-signals:_add-roaster-country="''"
data-signals:_add-roaster-city="''"
data-signals:_add-roaster-homepage="''"
data-signals:_add-roast-name="''"
data-signals:_add-origin="''"
data-signals:_add-region="''"
data-signals:_add-producer="''"
data-signals:_add-process="''"
data-signals:_add-tasting-notes="''"
data-signals:_brew-temp="{{ defaults.water_temp }}" data-signals:_brew-temp="{{ defaults.water_temp }}"
data-signals:_brew-grind="{{ defaults.grind_setting }}" data-signals:_brew-grind="{{ defaults.grind_setting }}"
data-signals:_brew-volume="{{ defaults.water_volume }}" data-signals:_brew-volume="{{ defaults.water_volume }}"
@ -96,10 +55,13 @@
data-signals:_qn-under-extracted="{% if defaults.quick_notes_raw.contains("under-extracted") %}true{% else %}false{% endif %}" data-signals:_qn-under-extracted="{% if defaults.quick_notes_raw.contains("under-extracted") %}true{% else %}false{% endif %}"
data-signals:_qn-over-extracted="{% if defaults.quick_notes_raw.contains("over-extracted") %}true{% else %}false{% endif %}" data-signals:_qn-over-extracted="{% if defaults.quick_notes_raw.contains("over-extracted") %}true{% else %}false{% endif %}"
> >
<h2 class="text-lg font-semibold text-text mb-3">Manual Add</h2> <header class="flex flex-col gap-2 mb-6">
<h1 class="text-3xl font-semibold">Add</h1>
<p class="max-w-2xl text-sm text-text-secondary">Add new coffee data manually or by scanning a bag.</p>
</header>
<!-- Entity type selector --> <!-- Entity type selector -->
<div class="rounded-lg border bg-surface mb-4"> <div class="mb-4">
{% include "partials/tab_bar.html" %} {% include "partials/tab_bar.html" %}
</div> </div>
@ -107,26 +69,52 @@
<div data-show="$_addType === 'roaster'" style="display:none" class="rounded-lg border bg-surface p-5"> <div data-show="$_addType === 'roaster'" style="display:none" class="rounded-lg border bg-surface p-5">
<div> <div>
<h3 class="text-lg font-semibold text-text">New Roaster</h3> <h3 class="text-lg font-semibold text-text">New Roaster</h3>
<p class="mt-1 text-sm text-text-secondary">Provide the core details and Brewlog will keep track of everything for you.</p> <p class="mt-1 text-sm text-text-secondary">Describe or take a photo of a coffee bag to extract roaster details.</p>
</div> </div>
<form method="post" action="/api/v1/roasters" class="mt-4 flex flex-col gap-4"> <!-- Extraction bar -->
<div class="mt-4">
<input type="hidden" name="image" id="roaster-extract-image" form="roaster-extract-form" />
<form id="roaster-extract-form"
data-on:submit="$_roasterExtracting = true; $_roasterExtractError = ''; @post('/api/v1/extract-roaster', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_roasterExtracting) return; if (evt.detail.type === 'finished') { $_roasterExtracting = false; document.getElementById('roaster-extract-form').reset() } else if (evt.detail.type === 'error') { $_roasterExtracting = false; $_roasterExtractError = 'Extraction failed. Please try again.' }"
class="hidden"></form>
<div data-show="!$_roasterExtracting" class="flex items-center gap-2">
<brew-photo-capture target-input="roaster-extract-image" target-form="roaster-extract-form"
class="shrink-0 inline-flex items-center justify-center rounded-md border p-2.5 text-accent transition hover:bg-surface-alt cursor-pointer"
aria-label="Take Photo"
>
{{ icons::camera("h-5 w-5") }}
</brew-photo-capture>
<span class="text-xs text-text-muted" style="font-variant: small-caps">or</span>
<input type="text" name="prompt" form="roaster-extract-form" class="input-field w-full" placeholder="Describe a roaster&hellip;" />
</div>
<div data-show="$_roasterExtracting" style="display:none" class="flex items-center gap-3 text-sm text-accent">
{{ icons::spinner("h-5 w-5") }}
Extracting&hellip;
</div>
<p data-show="$_roasterExtractError" data-text="$_roasterExtractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
</div>
<div class="mt-3 border-t pt-3">
<p class="mb-2 text-sm text-text-secondary">Or add manually:</p>
<form method="post" action="/api/v1/roasters" class="flex flex-col gap-4">
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Name*</span> <span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Name*</span>
<input type="text" name="name" required class="input-field" placeholder="Example Coffee Roasters" data-bind:_add-roaster-name /> <input type="text" name="name" required class="input-field" placeholder="Example Coffee Roasters" data-bind:_roaster-name />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Country*</span> <span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Country*</span>
<input type="text" name="country" required class="input-field" placeholder="United States" data-bind:_add-roaster-country /> <input type="text" name="country" required class="input-field" placeholder="United States" data-bind:_roaster-country />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-xs font-semibold text-text-muted uppercase tracking-wide">City</span> <span class="text-xs font-semibold text-text-muted uppercase tracking-wide">City</span>
<input type="text" name="city" class="input-field" placeholder="Portland" data-bind:_add-roaster-city /> <input type="text" name="city" class="input-field" placeholder="Portland" data-bind:_roaster-city />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Homepage</span> <span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Homepage</span>
<input type="url" name="homepage" class="input-field" placeholder="https://example.coffee" data-bind:_add-roaster-homepage /> <input type="url" name="homepage" class="input-field" placeholder="https://example.coffee" data-bind:_roaster-homepage />
</label> </label>
</div> </div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
@ -135,6 +123,7 @@
</button> </button>
</div> </div>
</form> </form>
</div>
</div> </div>
<!-- ========== ROAST FORM ========== --> <!-- ========== ROAST FORM ========== -->
@ -147,10 +136,36 @@
{% else %} {% else %}
<div> <div>
<h3 class="text-lg font-semibold text-text">New Roast</h3> <h3 class="text-lg font-semibold text-text">New Roast</h3>
<p class="mt-1 text-sm text-text-secondary">Select a roaster and fill in the details.</p> <p class="mt-1 text-sm text-text-secondary">Describe or take a photo of a coffee bag to extract roast details.</p>
</div> </div>
<form method="post" action="/api/v1/roasts" class="mt-4 flex flex-col gap-4"> <!-- Extraction bar -->
<div class="mt-4">
<input type="hidden" name="image" id="roast-extract-image" form="roast-extract-form" />
<form id="roast-extract-form"
data-on:submit="$_roastExtracting = true; $_roastExtractError = ''; @post('/api/v1/extract-roast', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_roastExtracting) return; if (evt.detail.type === 'finished') { $_roastExtracting = false; document.getElementById('roast-extract-form').reset() } else if (evt.detail.type === 'error') { $_roastExtracting = false; $_roastExtractError = 'Extraction failed. Please try again.' }"
class="hidden"></form>
<div data-show="!$_roastExtracting" class="flex items-center gap-2">
<brew-photo-capture target-input="roast-extract-image" target-form="roast-extract-form"
class="shrink-0 inline-flex items-center justify-center rounded-md border p-2.5 text-accent transition hover:bg-surface-alt cursor-pointer"
aria-label="Take Photo"
>
{{ icons::camera("h-5 w-5") }}
</brew-photo-capture>
<span class="text-xs text-text-muted" style="font-variant: small-caps">or</span>
<input type="text" name="prompt" form="roast-extract-form" class="input-field w-full" placeholder="Describe a coffee bag&hellip;" />
</div>
<div data-show="$_roastExtracting" style="display:none" class="flex items-center gap-3 text-sm text-accent">
{{ icons::spinner("h-5 w-5") }}
Extracting&hellip;
</div>
<p data-show="$_roastExtractError" data-text="$_roastExtractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
</div>
<div class="mt-3 border-t pt-3">
<p class="mb-2 text-sm text-text-secondary">Or add manually:</p>
<form method="post" action="/api/v1/roasts" class="flex flex-col gap-4">
<div class="flex flex-col gap-1 text-sm"> <div class="flex flex-col gap-1 text-sm">
<span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Roaster*</span> <span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Roaster*</span>
<searchable-select name="roaster_id" placeholder="Type to search roasters&hellip;"> <searchable-select name="roaster_id" placeholder="Type to search roasters&hellip;">
@ -165,27 +180,27 @@
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Roast Name*</span> <span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Roast Name*</span>
<input type="text" name="name" required class="input-field" placeholder="Ethiopia Yirgacheffe" data-bind:_add-roast-name /> <input type="text" name="name" required class="input-field" placeholder="Ethiopia Yirgacheffe" data-bind:_roast-name />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Origin*</span> <span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Origin*</span>
<input type="text" name="origin" required class="input-field" placeholder="Ethiopia" data-bind:_add-origin /> <input type="text" name="origin" required class="input-field" placeholder="Ethiopia" data-bind:_origin />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Region*</span> <span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Region*</span>
<input type="text" name="region" required class="input-field" placeholder="Guji" data-bind:_add-region /> <input type="text" name="region" required class="input-field" placeholder="Guji" data-bind:_region />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Producer*</span> <span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Producer*</span>
<input type="text" name="producer" required class="input-field" placeholder="Chelbesa Cooperative" data-bind:_add-producer /> <input type="text" name="producer" required class="input-field" placeholder="Chelbesa Cooperative" data-bind:_producer />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Process*</span> <span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Process*</span>
<input type="text" name="process" required class="input-field" placeholder="Washed" data-bind:_add-process /> <input type="text" name="process" required class="input-field" placeholder="Washed" data-bind:_process />
</label> </label>
<label class="sm:col-span-2 flex flex-col gap-1 text-sm"> <label class="sm:col-span-2 flex flex-col gap-1 text-sm">
<span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Tasting Notes* (comma or newline separated)</span> <span class="text-xs font-semibold text-text-muted uppercase tracking-wide">Tasting Notes* (comma or newline separated)</span>
<textarea name="tasting_notes" rows="2" required class="input-field" placeholder="Blueberry, Jasmine" data-bind:_add-tasting-notes></textarea> <textarea name="tasting_notes" rows="2" required class="input-field" placeholder="Blueberry, Jasmine" data-bind:_tasting-notes></textarea>
</label> </label>
</div> </div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
@ -194,6 +209,7 @@
</button> </button>
</div> </div>
</form> </form>
</div>
{% endif %} {% endif %}
</div> </div>
@ -421,7 +437,7 @@
data-attr:value="[$_qnGood && 'good', $_qnTooFast && 'too-fast', $_qnTooSlow && 'too-slow', $_qnTooHot && 'too-hot', $_qnUnderExtracted && 'under-extracted', $_qnOverExtracted && 'over-extracted'].filter(Boolean).join(',')" /> data-attr:value="[$_qnGood && 'good', $_qnTooFast && 'too-fast', $_qnTooSlow && 'too-slow', $_qnTooHot && 'too-hot', $_qnUnderExtracted && 'under-extracted', $_qnOverExtracted && 'over-extracted'].filter(Boolean).join(',')" />
</div> </div>
<div class="sticky-submit flex items-center justify-end"> <div class="sticky-submit flex items-center justify-end">
<button type="submit" class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover md:w-auto w-full"> <button type="submit" class="w-full rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover">
Log Brew Log Brew
</button> </button>
</div> </div>