* build(deps): bump askama from 0.12.1 to 0.15.4 Bumps [askama](https://github.com/askama-rs/askama) from 0.12.1 to 0.15.4. - [Release notes](https://github.com/askama-rs/askama/releases) - [Commits](https://github.com/askama-rs/askama/compare/0.12.1...v0.15.4) --- updated-dependencies: - dependency-name: askama dependency-version: 0.15.4 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> * fix(deps): adapt templates and tests for askama 0.15 - Migrate macro calls from {% call %} to {{ }} expression syntax (134 occurrences) - Update test assertion for askama 0.15's numeric HTML entity encoding (& vs &) --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jon Seager <jon@sgrs.uk>
221 lines
11 KiB
HTML
221 lines
11 KiB
HTML
{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import "partials/icons.html" as icons %} {% import "partials/scan_input.html" as scan %} {% 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"
|
|
data-signals:_matched-roaster-id="''"
|
|
data-signals:_matched-roast-id="''"
|
|
>
|
|
<!-- Input: photo or text (shown when not yet extracted) -->
|
|
<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 the coffee bag…", "Waiting for response…") }}
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Form: pre-filled roaster + roast (shown after extraction) -->
|
|
<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') { window.location.reload() } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
|
|
>
|
|
{% include "partials/forms/scan_result_form.html" %}
|
|
</form>
|
|
</div>
|
|
</section>
|
|
{% endif %}
|
|
|
|
<!-- Recent Brews -->
|
|
{% if !recent_brews.is_empty() %}
|
|
<section data-signals:_brewing="false">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h2 class="text-lg font-semibold text-text">Recent Brews</h2>
|
|
<a href="/data?type=brews" class="text-sm text-accent hover:text-accent-hover 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 bg-surface p-4 min-w-0">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="min-w-0">
|
|
<span class="block truncate font-semibold text-text">{{ brew.roast_name }}</span>
|
|
<p class="truncate text-sm text-text-muted">{{ brew.roaster_name }}</p>
|
|
</div>
|
|
<time class="text-xs text-text-muted whitespace-nowrap shrink-0">{{ brew.relative_date_label }}</time>
|
|
</div>
|
|
<div class="mt-2 space-y-0.5 text-sm">
|
|
<p class="truncate"><span class="font-medium text-text">{{ brew.coffee_weight }} · {{ brew.water_volume }} · {{ brew.water_temp }}</span> <span class="text-text-muted">· {{ brew.ratio }}</span></p>
|
|
<p class="truncate"><span class="font-medium text-text">{{ brew.grind_setting }}</span> <span class="text-text-muted">· {{ brew.grinder_name }}</span></p>
|
|
<p class="truncate"><span class="font-medium text-text">{{ brew.brewer_name }}</span>{% if let Some(fp) = brew.filter_paper_name %} <span class="text-text-muted">· {{ fp }}</span>{% endif %}</p>
|
|
</div>
|
|
<div class="mt-3 pt-3 border-t flex items-center justify-between gap-3">
|
|
<div class="flex flex-wrap gap-1">
|
|
{% if !brew.quick_notes.is_empty() %}
|
|
{% for qn in brew.quick_notes %}
|
|
<span class="{{ qn.pill_class }}">{{ qn.label }}</span>
|
|
{% endfor %}
|
|
{% else %}
|
|
<span class="pill pill-muted">No Notes</span>
|
|
{% endif %}
|
|
</div>
|
|
{% if is_authenticated %}
|
|
<form
|
|
class="inline shrink-0"
|
|
data-on:submit="$_brewing = true; @post('/api/v1/brews', {contentType: 'form'})"
|
|
data-on:datastar-fetch="if (!$_brewing) return; if (evt.detail.type === 'finished') { window.location.reload() } else if (evt.detail.type === 'error') { $_brewing = false }"
|
|
>
|
|
<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 }}" />
|
|
<input type="hidden" name="quick_notes" value="{{ brew.quick_notes_raw }}" />
|
|
<button
|
|
type="submit"
|
|
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-accent transition hover:text-accent-hover hover:bg-surface-alt"
|
|
title="Brew Again"
|
|
>
|
|
{{ icons::refresh("h-4 w-4") }}
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
</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-text">Open Bags</h2>
|
|
<a href="/data?type=bags" class="text-sm text-accent hover:text-accent-hover 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 %}
|
|
{{ 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-text">Recent Activity</h2>
|
|
<a href="/timeline" class="text-sm text-accent hover:text-accent-hover font-medium">View full timeline →</a>
|
|
</div>
|
|
<div class="space-y-2">
|
|
{% for event in recent_events %}
|
|
<div class="rounded-lg border bg-surface px-4 py-3 flex items-center justify-between gap-3">
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
<span class="pill pill-muted w-28 shrink-0 justify-center whitespace-nowrap">{{ event.kind_label }}</span>
|
|
<p class="min-w-0 truncate text-sm"><a href="{{ event.link }}" class="font-medium text-text hover:text-accent">{{ event.title }}</a>{% if let Some(sub) = event.subtitle %} <span class="text-xs text-text-muted italic">· {{ sub }}</span>{% endif %}</p>
|
|
</div>
|
|
<time class="text-xs text-text-muted whitespace-nowrap shrink-0">{{ event.relative_date_label }}</time>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</section>
|
|
{% endif %}
|
|
|
|
<!-- Stats -->
|
|
<section>
|
|
<div class="flex items-center justify-between mb-5">
|
|
<h2 class="text-lg font-semibold text-text">Stats</h2>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-7">
|
|
<a href="/data?type=brews" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
|
|
{{ icons::beaker("h-6 w-6 text-accent") }}
|
|
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.brews }}</span>
|
|
<span class="text-xs text-text-muted">Brews</span>
|
|
</a>
|
|
<a href="/data?type=roasts" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
|
|
{{ icons::fire("h-6 w-6 text-accent") }}
|
|
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.roasts }}</span>
|
|
<span class="text-xs text-text-muted">Roasts</span>
|
|
</a>
|
|
<a href="/data?type=roasters" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
|
|
{{ icons::building("h-6 w-6 text-accent") }}
|
|
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.roasters }}</span>
|
|
<span class="text-xs text-text-muted">Roasters</span>
|
|
</a>
|
|
<a href="/data?type=bags" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
|
|
{{ icons::bag("h-6 w-6 text-accent") }}
|
|
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.bags }}</span>
|
|
<span class="text-xs text-text-muted">Bags</span>
|
|
</a>
|
|
<a href="/data?type=cups" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
|
|
{{ icons::cup("h-6 w-6 text-accent") }}
|
|
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.cups }}</span>
|
|
<span class="text-xs text-text-muted">Cups</span>
|
|
</a>
|
|
<a href="/data?type=cafes" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
|
|
{{ icons::location("h-6 w-6 text-accent") }}
|
|
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.cafes }}</span>
|
|
<span class="text-xs text-text-muted">Cafes</span>
|
|
</a>
|
|
<a href="/data?type=gear" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
|
|
{{ icons::wrench("h-6 w-6 text-accent") }}
|
|
<span class="text-lg font-bold text-text group-hover:text-accent">{{ stats.gear }}</span>
|
|
<span class="text-xs text-text-muted">Gear</span>
|
|
</a>
|
|
</div>
|
|
</section>
|
|
{% endblock %}
|