brewlog/templates/home.html
Jon Seager 91a9900c24
feat(scan): optionally open a bag when scanning coffee
Add "Open a bag of this coffee" checkbox (default: checked) to the
scan form with a configurable amount field (default: 250g). When
enabled, creates a bag and timeline event alongside the roaster/roast.
2026-02-04 17:41:53 +00:00

306 lines
15 KiB
HTML

{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog{% endblock %}
{% block head %}
{% if is_authenticated %}
<script>
const closeBag = async (bagId, cardEl) => {
if (!confirm('Close this bag? This will mark it as finished.')) return;
try {
const today = new Date().toISOString().split('T')[0];
const resp = await fetch(`/api/v1/bags/${bagId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ closed: true, remaining: 0.0, finished_at: today }),
});
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
cardEl.remove();
const grid = document.getElementById('open-bags-grid');
if (grid && grid.children.length === 0) {
document.getElementById('open-bags-section')?.remove();
}
} catch (e) {
alert(`Failed to close bag: ${e.message}`);
}
};
</script>
{% endif %}
{% endblock %}
{% block content %}
<!-- Scan Bag -->
{% if is_authenticated %}
<section
data-signals:_extracting="false"
data-signals:_extract-error="''"
data-signals:_scan-extracted="false"
data-signals:_scan-submitting="false"
data-signals:_scan-error="''"
data-signals:_roaster-name="''"
data-signals:_roaster-country="''"
data-signals:_roaster-city="''"
data-signals:_roaster-homepage="''"
data-signals:_roast-name="''"
data-signals:_origin="''"
data-signals:_region="''"
data-signals:_producer="''"
data-signals:_process="''"
data-signals:_tasting-notes="''"
data-signals:_open-bag="true"
data-signals:_bag-amount="250"
>
<h2 class="text-lg font-semibold text-amber-700 mb-3">Scan Bag</h2>
<!-- Hidden file input — minimal JS for FileReader API -->
<input type="file" id="scan-photo" accept="image/*" capture="environment" class="hidden"
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('scan-image').value=r.result;document.getElementById('scan-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
<!-- Input: photo or text (shown when not yet extracted) -->
<div data-show="!$_scanExtracted" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<form id="scan-extract-form"
data-on:submit="$_extracting = true; $_extractError = ''; $_roasterName = ''; $_roasterCountry = ''; $_roasterCity = ''; $_roasterHomepage = ''; $_roastName = ''; $_origin = ''; $_region = ''; $_producer = ''; $_process = ''; $_tastingNotes = ''; @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.' }"
>
<input type="hidden" name="image" id="scan-image" />
<div data-show="!$_extracting" class="flex items-center gap-2">
<button
type="button"
onclick="document.getElementById('scan-photo').click()"
class="shrink-0 inline-flex items-center justify-center rounded-md border border-amber-500 p-2.5 text-amber-700 transition hover:bg-amber-50"
aria-label="Take Photo"
>
{% call icons::camera("h-5 w-5") %}
</button>
<input
type="text"
name="prompt"
class="input-field w-full text-sm"
placeholder="Describe the coffee bag&hellip;"
/>
</div>
<div data-show="$_extracting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
{% call icons::spinner("h-5 w-5") %}
Waiting for response&hellip;
</div>
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
</form>
</div>
<!-- Form: pre-filled roaster + roast (shown after extraction) -->
<div data-show="$_scanExtracted" style="display: none">
<form
class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm 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; $_scanExtracted = false; $_roasterName = ''; $_roasterCountry = ''; $_roasterCity = ''; $_roasterHomepage = ''; $_roastName = ''; $_origin = ''; $_region = ''; $_producer = ''; $_process = ''; $_tastingNotes = ''; $_openBag = true; $_bagAmount = 250; window.location.reload() } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
>
<div>
<h3 class="text-base font-semibold text-amber-700">Roaster</h3>
<p class="mt-1 text-sm text-stone-600">If this roaster already exists, it will be matched automatically.</p>
<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" data-bind:_roaster-name />
</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" data-bind:_roaster-country />
</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" data-bind:_roaster-city />
</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" data-bind:_roaster-homepage />
</label>
</div>
</div>
<div>
<h3 class="text-base font-semibold text-amber-700">Roast</h3>
<p class="mt-1 text-sm text-stone-600">Details about this specific coffee.</p>
<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" data-bind:_roast-name />
</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" data-bind:_origin />
</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" data-bind:_region />
</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" data-bind:_producer />
</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" data-bind:_process />
</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" data-bind:_tasting-notes></textarea>
</label>
</div>
</div>
<div>
<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" name="open_bag" value="true" class="accent-amber-600" data-bind:_open-bag />
<span class="font-semibold text-amber-700">Open a bag of this coffee</span>
</label>
<div data-show="$_openBag" class="mt-3 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Amount (grams)</span>
<input type="number" name="bag_amount" min="1" step="any" class="input-field" placeholder="250" data-bind:_bag-amount />
</label>
</div>
</div>
<p data-show="$_scanError" data-text="$_scanError" style="display:none" class="text-sm text-red-600"></p>
<div data-show="$_scanSubmitting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
{% call icons::spinner("h-5 w-5") %}
Saving&hellip;
</div>
<div data-show="!$_scanSubmitting" class="flex items-center justify-end gap-2">
<button
type="button"
data-on:click="$_scanExtracted = false"
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"
>
Cancel
</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>
{% endif %}
<!-- Recent Brews -->
{% if !recent_brews.is_empty() %}
<section>
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-amber-700">Recent Brews</h2>
<a href="/brews" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View all brews &rarr;</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 items-start gap-4">
{% for brew in recent_brews %}
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm">
<div>
<a href="/roasters/{{ brew.roaster_slug }}/roasts/{{ brew.roast_slug }}" class="font-semibold text-amber-800 hover:text-amber-600">{{ brew.roast_name }}</a>
<p class="text-sm text-stone-500">{{ brew.roaster_name }}</p>
</div>
<div class="mt-2 space-y-0.5 text-sm">
<p><span class="font-medium text-stone-800">{{ brew.coffee_weight }} / {{ brew.water_volume }}</span> <span class="text-stone-500">· {{ brew.ratio }} · {{ brew.water_temp }}</span></p>
<p><span class="font-medium text-stone-800">{{ brew.grind_setting }}</span> <span class="text-stone-500">· {{ brew.grinder_name }}</span></p>
<p><span class="font-medium text-stone-800">{{ brew.brewer_name }}</span>{% if let Some(fp) = brew.filter_paper_name %} <span class="text-stone-500">· {{ fp }}</span>{% endif %}</p>
</div>
{% if is_authenticated %}
<div class="mt-3 pt-3 border-t border-amber-100 flex gap-3">
<form
class="inline"
data-on:submit="@post('/api/v1/brews', {contentType: 'form'})"
>
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight_raw }}" />
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting_raw }}" />
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
{% if let Some(fp_id) = brew.filter_paper_id %}
<input type="hidden" name="filter_paper_id" value="{{ fp_id }}" />
{% endif %}
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
<button
type="submit"
class="inline-flex items-center gap-1 text-sm font-medium text-amber-700 hover:text-amber-500"
>
{% call icons::plus_circle("h-4 w-4") %}
Brew Again
</button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endif %}
<!-- Open Bags -->
{% if !open_bags.is_empty() %}
<section id="open-bags-section">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-amber-700">Open Bags</h2>
<a href="/bags" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View all bags &rarr;</a>
</div>
<div id="open-bags-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for bag in open_bags %}
{% call bag_card::card(bag, is_authenticated) %}
{% endfor %}
</div>
</section>
{% endif %}
<!-- Recent Activity -->
{% if !recent_events.is_empty() %}
<section>
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-amber-700">Recent Activity</h2>
<a href="/timeline" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View full timeline &rarr;</a>
</div>
<div class="space-y-2">
{% for event in recent_events %}
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 shadow-sm flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<span class="inline-flex w-28 shrink-0 items-center justify-center whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800">{{ event.kind_label }}</span>
<a href="{{ event.link }}" class="truncate font-medium text-stone-700 hover:text-amber-600 text-sm">{{ event.title }}</a>
</div>
<time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ event.date_label }}</time>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<!-- Stats -->
<section class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<h2 class="text-lg font-semibold text-amber-700 mb-4">Your Coffee Journey</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-center">
<a href="/brews" class="group">
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.brews }}</div>
<div class="text-xs text-stone-500">Brews</div>
</a>
<a href="/roasts" class="group">
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.roasts }}</div>
<div class="text-xs text-stone-500">Roasts</div>
</a>
<a href="/roasters" class="group">
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.roasters }}</div>
<div class="text-xs text-stone-500">Roasters</div>
</a>
<a href="/bags" class="group">
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.bags }}</div>
<div class="text-xs text-stone-500">Bags</div>
</a>
<a href="/cups" class="group">
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.cups }}</div>
<div class="text-xs text-stone-500">Cups</div>
</a>
<a href="/cafes" class="group">
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.cafes }}</div>
<div class="text-xs text-stone-500">Cafes</div>
</a>
<a href="/gear" class="group">
<div class="text-2xl font-bold text-amber-800 group-hover:text-amber-600">{{ stats.gear }}</div>
<div class="text-xs text-stone-500">Gear</div>
</a>
</div>
</section>
{% endblock %}