Clear all roaster/roast signals before each extraction request to prevent stale values from persisting across consecutive scans. Also reset the extract form on completion to clear the prompt input.
292 lines
15 KiB
HTML
292 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="''"
|
|
>
|
|
<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…"
|
|
/>
|
|
</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…
|
|
</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 = ''; 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>
|
|
<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…
|
|
</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 & 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 →</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 →</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 →</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 %}
|