refactor: fix template review findings (security, macros, tokens)

Address findings from the templates code review:

- Fix XSS in admin.html onclick handlers via data attributes
- Fix XSS in 5 edit page signal initializations via JSON serialization
- Fix register.html token exposure by moving to data attribute
- Add entity_icon, quick_notes_toggles, add_form_submit macros
- Replace hardcoded colors with design tokens (warning, error, success)
- Add warning design tokens to CSS theme
- Scope MutationObserver to main element
- Add defer to webauthn.js script tags
- Refactor login/register JS to arrow functions
- Guard lightbox script behind image_url check
- Fix else-if to elif in 5 templates
This commit is contained in:
Jon Seager 2026-02-13 16:17:56 +00:00
parent ab4bcbaa0a
commit 920931ba17
No known key found for this signature in database
33 changed files with 354 additions and 358 deletions

View file

@ -80,6 +80,22 @@ pub(crate) async fn bag_edit_page(
let roast_options = load_roast_options(&state).await.map_err(map_app_error)?;
let roast_date = bag
.bag
.roast_date
.map(|d| d.to_string())
.unwrap_or_default();
use crate::presentation::web::views::build_signals_json;
use serde_json::Value;
let signals_json = build_signals_json(&[
("_submitting", Value::Bool(false)),
("_submit-error", Value::String(String::new())),
("_roast-date", Value::String(roast_date.clone())),
("_amount", serde_json::json!(bag.bag.amount)),
("_remaining", serde_json::json!(bag.bag.remaining)),
]);
let template = BagEditTemplate {
nav_active: "",
is_authenticated: true,
@ -87,14 +103,11 @@ pub(crate) async fn bag_edit_page(
id: bag.bag.id.to_string(),
roast_id: bag.bag.roast_id.to_string(),
roast_label: format!("{} ({})", bag.roast_name, bag.roaster_name),
roast_date: bag
.bag
.roast_date
.map(|d| d.to_string())
.unwrap_or_default(),
roast_date,
amount: bag.bag.amount,
remaining: bag.bag.remaining,
roast_options,
signals_json,
};
render_html(template).map(IntoResponse::into_response)

View file

@ -59,18 +59,37 @@ pub(crate) async fn cafe_edit_page(
let image_url = resolve_image_url(&state, EntityType::Cafe, i64::from(id)).await;
let name = cafe.name;
let city = cafe.city;
let country = cafe.country;
let website = cafe.website.unwrap_or_default();
use crate::presentation::web::views::build_signals_json;
use serde_json::Value;
let signals_json = build_signals_json(&[
("_submitting", Value::Bool(false)),
("_submit-error", Value::String(String::new())),
("_name", Value::String(name.clone())),
("_city", Value::String(city.clone())),
("_country", Value::String(country.clone())),
("_latitude", serde_json::json!(cafe.latitude)),
("_longitude", serde_json::json!(cafe.longitude)),
("_website", Value::String(website.clone())),
]);
let template = CafeEditTemplate {
nav_active: "",
is_authenticated: true,
version_info: &crate::VERSION_INFO,
id: cafe.id.to_string(),
name: cafe.name,
city: cafe.city,
country: cafe.country,
name,
city,
country,
latitude: cafe.latitude,
longitude: cafe.longitude,
website: cafe.website.unwrap_or_default(),
website,
image_url,
signals_json,
};
render_html(template).map(IntoResponse::into_response)

View file

@ -58,15 +58,28 @@ pub(crate) async fn gear_edit_page(
let image_url = resolve_image_url(&state, EntityType::Gear, i64::from(id)).await;
let make = gear.make;
let model = gear.model;
use crate::presentation::web::views::build_signals_json;
use serde_json::Value;
let signals_json = build_signals_json(&[
("_submitting", Value::Bool(false)),
("_submit-error", Value::String(String::new())),
("_make", Value::String(make.clone())),
("_model", Value::String(model.clone())),
]);
let template = GearEditTemplate {
nav_active: "",
is_authenticated: true,
version_info: &crate::VERSION_INFO,
id: gear.id.to_string(),
category: gear.category.display_label().to_string(),
make: gear.make,
model: gear.model,
make,
model,
image_url,
signals_json,
};
render_html(template).map(IntoResponse::into_response)

View file

@ -59,16 +59,33 @@ pub(crate) async fn roaster_edit_page(
let image_url = resolve_image_url(&state, EntityType::Roaster, i64::from(id)).await;
let name = roaster.name;
let country = roaster.country;
let city = roaster.city.unwrap_or_default();
let homepage = roaster.homepage.unwrap_or_default();
use crate::presentation::web::views::build_signals_json;
use serde_json::Value;
let signals_json = build_signals_json(&[
("_submitting", Value::Bool(false)),
("_submit-error", Value::String(String::new())),
("_name", Value::String(name.clone())),
("_country", Value::String(country.clone())),
("_city", Value::String(city.clone())),
("_homepage", Value::String(homepage.clone())),
]);
let template = RoasterEditTemplate {
nav_active: "",
is_authenticated: true,
version_info: &crate::VERSION_INFO,
id: roaster.id.to_string(),
name: roaster.name,
country: roaster.country,
city: roaster.city.unwrap_or_default(),
homepage: roaster.homepage.unwrap_or_default(),
name,
country,
city,
homepage,
image_url,
signals_json,
};
render_html(template).map(IntoResponse::into_response)

View file

@ -75,6 +75,26 @@ pub(crate) async fn roast_edit_page(
let image_url = resolve_image_url(&state, EntityType::Roast, i64::from(id)).await;
let name = roast.name;
let origin = roast.origin.unwrap_or_default();
let region = roast.region.unwrap_or_default();
let producer = roast.producer.unwrap_or_default();
let process = roast.process.unwrap_or_default();
let tasting_notes = roast.tasting_notes.join(", ");
use crate::presentation::web::views::build_signals_json;
use serde_json::Value;
let signals_json = build_signals_json(&[
("_submitting", Value::Bool(false)),
("_submit-error", Value::String(String::new())),
("_name", Value::String(name.clone())),
("_origin", Value::String(origin.clone())),
("_region", Value::String(region.clone())),
("_producer", Value::String(producer.clone())),
("_process", Value::String(process.clone())),
("_tasting-notes", Value::String(tasting_notes.clone())),
]);
let template = RoastEditTemplate {
nav_active: "",
is_authenticated: true,
@ -82,14 +102,15 @@ pub(crate) async fn roast_edit_page(
id: roast.id.to_string(),
roaster_id: roast.roaster_id.to_string(),
roaster_name: roaster.name,
name: roast.name,
origin: roast.origin.unwrap_or_default(),
region: roast.region.unwrap_or_default(),
producer: roast.producer.unwrap_or_default(),
process: roast.process.unwrap_or_default(),
tasting_notes: roast.tasting_notes.join(", "),
name,
origin,
region,
producer,
process,
tasting_notes,
roaster_options,
image_url,
signals_json,
};
render_html(template).map(IntoResponse::into_response)

View file

@ -317,6 +317,7 @@ pub struct RoasterEditTemplate {
pub city: String,
pub homepage: String,
pub image_url: Option<String>,
pub signals_json: String,
}
#[derive(Template)]
@ -336,6 +337,7 @@ pub struct RoastEditTemplate {
pub tasting_notes: String,
pub roaster_options: Vec<RoasterOptionView>,
pub image_url: Option<String>,
pub signals_json: String,
}
#[derive(Template)]
@ -351,6 +353,7 @@ pub struct BagEditTemplate {
pub amount: f64,
pub remaining: f64,
pub roast_options: Vec<RoastOptionView>,
pub signals_json: String,
}
#[derive(Template)]
@ -393,6 +396,7 @@ pub struct CafeEditTemplate {
pub longitude: f64,
pub website: String,
pub image_url: Option<String>,
pub signals_json: String,
}
#[derive(Template)]
@ -422,6 +426,7 @@ pub struct GearEditTemplate {
pub make: String,
pub model: String,
pub image_url: Option<String>,
pub signals_json: String,
}
#[derive(Template)]

View file

@ -511,6 +511,36 @@ pub(crate) fn build_map_data(entries: &[(&str, u32)]) -> (String, u32) {
(parts.join(","), max)
}
/// Build a JSON string for Datastar `data-signals` attribute initialization.
///
/// Signal names may use kebab-case (`_roaster-name`); they are automatically
/// converted to camelCase (`_roasterName`) to match Datastar's internal store.
/// The returned string is a JSON object suitable for use in `data-signals="{{ signals_json }}"`.
/// Askama HTML-escapes `"` to `&quot;`, which the browser decodes before Datastar parses it.
pub fn build_signals_json(signals: &[(&str, serde_json::Value)]) -> String {
let mut map = serde_json::Map::new();
for (name, value) in signals {
map.insert(signals_kebab_to_camel(name), value.clone());
}
serde_json::Value::Object(map).to_string()
}
fn signals_kebab_to_camel(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut cap_next = false;
for c in s.chars() {
if c == '-' {
cap_next = true;
} else if cap_next {
result.push(c.to_ascii_uppercase());
cap_next = false;
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -43,6 +43,10 @@
--success-bg: #ecfdf5; /* green-50 */
--success-border: rgba(5, 150, 105, 0.3);
--success-text: #065f46; /* green-800 */
--warning: #d97706; /* amber-600 */
--warning-bg: #fffbeb; /* amber-50 */
--warning-border: rgba(217, 119, 6, 0.3);
--warning-text: #92400e; /* amber-800 */
}
[data-theme="dark"] {
@ -74,6 +78,10 @@
--success-bg: rgba(5, 150, 105, 0.1);
--success-border: rgba(5, 150, 105, 0.4);
--success-text: #6ee7b7;
--warning: #fbbf24; /* amber-400 */
--warning-bg: rgba(217, 119, 6, 0.1);
--warning-border: rgba(217, 119, 6, 0.4);
--warning-text: #fcd34d; /* amber-300 */
}
/* ── Theme: map raw vars to Tailwind utilities ─────────────────── */
@ -99,6 +107,10 @@
--color-success-bg: var(--success-bg);
--color-success-border: var(--success-border);
--color-success-text: var(--success-text);
--color-warning: var(--warning);
--color-warning-bg: var(--warning-bg);
--color-warning-border: var(--warning-border);
--color-warning-text: var(--warning-text);
}
/* ── Base: default border color ────────────────────────────────── */

View file

@ -179,10 +179,13 @@
setupInfiniteScroll();
const bodyObserver = new MutationObserver(() => {
setupInfiniteScroll();
});
bodyObserver.observe(document.body, { childList: true, subtree: true });
const mainEl = document.querySelector("main");
if (mainEl) {
const mainObserver = new MutationObserver(() => {
setupInfiniteScroll();
});
mainObserver.observe(mainEl, { childList: true, subtree: true });
}
});
</script>
</head>

View file

@ -1,6 +1,8 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% import "partials/image_section.html" as img %}
{% import "partials/location_search.html" as location %}
{% import "partials/detail_cards.html" as detail_cards %}
{% import "partials/forms/quick_notes.html" as quick_notes %}
{% block title %}Brewlog · Add{% endblock %}
{% block content %}
@ -190,21 +192,7 @@
</label>
</div>
{{ img::deferred_upload("roaster-image", "Add image (optional)") }}
<div class="sticky-submit flex flex-col gap-2">
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::plus("h-4 w-4") }} Save Roaster
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
{{ detail_cards::add_form_submit("plus", "Save Roaster") }}
</form>
</div>
</div>
@ -410,21 +398,7 @@
</label>
</div>
{{ img::deferred_upload("roast-image", "Add image (optional)") }}
<div class="sticky-submit flex flex-col gap-2">
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::plus("h-4 w-4") }} Save Roast
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
{{ detail_cards::add_form_submit("plus", "Save Roast") }}
</form>
</div>
{% endif %}
@ -512,21 +486,7 @@
/>
</label>
</div>
<div class="sticky-submit flex flex-col gap-2">
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::plus("h-4 w-4") }} Save Bag
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
{{ detail_cards::add_form_submit("plus", "Save Bag") }}
</form>
{% endif %}
</div>
@ -552,7 +512,7 @@
to enable this form.
</p>
</div>
{% else if grinder_options.is_empty() || brewer_options.is_empty() %}
{% elif grinder_options.is_empty() || brewer_options.is_empty() %}
<div class="text-sm text-text-secondary">
<h3 class="text-lg font-semibold text-text">Add gear first</h3>
<p class="mt-2">
@ -915,74 +875,9 @@
</div>
</div>
<!-- Quick Notes -->
<div>
<h4 class="text-sm font-semibold text-text mb-3">Quick Notes</h4>
<div class="flex flex-wrap gap-2">
<button
type="button"
data-on:click="$_qnGood = !$_qnGood"
data-attr:class="$_qnGood ? 'pill pill-success cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Good
</button>
<button
type="button"
data-on:click="$_qnTooFast = !$_qnTooFast"
data-attr:class="$_qnTooFast ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Fast
</button>
<button
type="button"
data-on:click="$_qnTooSlow = !$_qnTooSlow"
data-attr:class="$_qnTooSlow ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Slow
</button>
<button
type="button"
data-on:click="$_qnTooHot = !$_qnTooHot"
data-attr:class="$_qnTooHot ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Hot
</button>
<button
type="button"
data-on:click="$_qnUnderExtracted = !$_qnUnderExtracted"
data-attr:class="$_qnUnderExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Under Extracted
</button>
<button
type="button"
data-on:click="$_qnOverExtracted = !$_qnOverExtracted"
data-attr:class="$_qnOverExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Over Extracted
</button>
</div>
<input
type="hidden"
name="quick_notes"
data-attr:value="[$_qnGood && 'good', $_qnTooFast && 'too-fast', $_qnTooSlow && 'too-slow', $_qnTooHot && 'too-hot', $_qnUnderExtracted && 'under-extracted', $_qnOverExtracted && 'over-extracted'].filter(Boolean).join(',')"
/>
</div>
{{ quick_notes::quick_notes_toggles() }}
{{ img::deferred_upload("brew-image", "Add image (optional)") }}
<div class="sticky-submit flex flex-col gap-2">
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::beaker("h-4 w-4") }} Save Brew
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
{{ detail_cards::add_form_submit("beaker", "Save Brew") }}
</form>
{% endif %}
</div>
@ -1051,21 +946,7 @@
</label>
</div>
{{ img::deferred_upload("gear-image", "Add image (optional)") }}
<div class="sticky-submit flex flex-col gap-2">
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::plus("h-4 w-4") }} Save Gear
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
{{ detail_cards::add_form_submit("plus", "Save Gear") }}
</form>
</div>
@ -1223,21 +1104,7 @@
</label>
</div>
{{ img::deferred_upload("cafe-image", "Add image (optional)") }}
<div class="sticky-submit flex flex-col gap-2">
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::plus("h-4 w-4") }} Save Cafe
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
{{ detail_cards::add_form_submit("plus", "Save Cafe") }}
</form>
</div>
@ -1339,21 +1206,7 @@
</searchable-select>
</div>
</div>
<div class="sticky-submit flex flex-col gap-2">
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::plus("h-4 w-4") }} Save Cup
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
{{ detail_cards::add_form_submit("plus", "Save Cup") }}
</form>
{% endif %}
</div>

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Admin{% endblock %}
{% block head %}
<script src="/static/js/webauthn.js"></script>
<script defer src="/static/js/webauthn.js"></script>
{% endblock %}
{% block content %}
<header class="flex flex-col gap-2">
@ -49,7 +49,9 @@
<button
type="button"
class="shrink-0 inline-flex items-center justify-center rounded-md border text-accent transition hover:text-text hover:bg-surface-alt h-8 w-8 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm sm:font-medium"
onclick="deletePasskey({{ passkey.id }}, '{{ passkey.name }}')"
data-id="{{ passkey.id }}"
data-name="{{ passkey.name }}"
onclick="deletePasskey(this.dataset.id, this.dataset.name)"
aria-label="Delete passkey"
>
{{ icons::delete("h-4 w-4") }}
@ -175,7 +177,9 @@
<button
type="button"
class="shrink-0 inline-flex items-center justify-center rounded-md border text-accent transition hover:text-text hover:bg-surface-alt h-8 w-8 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm sm:font-medium"
onclick="revokeToken({{ token.id }}, '{{ token.name }}')"
data-id="{{ token.id }}"
data-name="{{ token.name }}"
onclick="revokeToken(this.dataset.id, this.dataset.name)"
aria-label="Revoke token"
>
{{ icons::delete("h-4 w-4") }}

View file

@ -28,7 +28,9 @@
</div>
</div>
</header>
{{ img::lightbox_script() }}
{% if image_url.is_some() %}
{{ img::lightbox_script() }}
{% endif %}
<div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5">

View file

@ -5,14 +5,14 @@
<div class="rounded-lg border bg-surface p-6">
{% if token.is_some() %}
<div class="flex flex-col items-center text-center gap-3">
{{ icons::check_circle("h-10 w-10 text-emerald-600 dark:text-emerald-400") }}
{{ icons::check_circle("h-10 w-10 text-success") }}
<h1 class="text-2xl font-semibold text-accent">CLI Authenticated</h1>
<p class="text-sm text-text-secondary">
Your CLI has been authenticated successfully. You can close this
window and return to the terminal.
</p>
</div>
{% else if error.is_some() %}
{% elif error.is_some() %}
<div class="flex flex-col items-center text-center gap-3">
{{ icons::x_circle("h-10 w-10 text-error") }}
<h1 class="text-2xl font-semibold text-accent">

View file

@ -12,11 +12,7 @@
<section class="rounded-lg border bg-surface p-5">
<form
class="flex flex-col gap-4 pb-16 md:pb-0"
data-signals:_submitting="false"
data-signals:_submit-error="''"
data-signals:_roast-date="'{{ roast_date }}'"
data-signals:_amount="{{ amount }}"
data-signals:_remaining="{{ remaining }}"
data-signals="{{ signals_json }}"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/bags/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Bag updated') }

View file

@ -2,6 +2,7 @@
{% import "partials/icons.html" as icons %}
{% import "partials/image_section.html" as img %}
{% import "partials/detail_cards.html" as detail_cards %}
{% import "partials/forms/quick_notes.html" as quick_notes %}
{% block title %}Brewlog · Edit Brew{% endblock %}
{% block content %}
@ -308,58 +309,7 @@
</div>
<!-- Quick Notes -->
<div>
<h4 class="text-sm font-semibold text-text mb-3">Quick Notes</h4>
<div class="flex flex-wrap gap-2">
<button
type="button"
data-on:click="$_qnGood = !$_qnGood"
data-attr:class="$_qnGood ? 'pill pill-success cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Good
</button>
<button
type="button"
data-on:click="$_qnTooFast = !$_qnTooFast"
data-attr:class="$_qnTooFast ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Fast
</button>
<button
type="button"
data-on:click="$_qnTooSlow = !$_qnTooSlow"
data-attr:class="$_qnTooSlow ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Slow
</button>
<button
type="button"
data-on:click="$_qnTooHot = !$_qnTooHot"
data-attr:class="$_qnTooHot ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Hot
</button>
<button
type="button"
data-on:click="$_qnUnderExtracted = !$_qnUnderExtracted"
data-attr:class="$_qnUnderExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Under Extracted
</button>
<button
type="button"
data-on:click="$_qnOverExtracted = !$_qnOverExtracted"
data-attr:class="$_qnOverExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Over Extracted
</button>
</div>
<input
type="hidden"
name="quick_notes"
data-attr:value="[$_qnGood && 'good', $_qnTooFast && 'too-fast', $_qnTooSlow && 'too-slow', $_qnTooHot && 'too-hot', $_qnUnderExtracted && 'under-extracted', $_qnOverExtracted && 'over-extracted'].filter(Boolean).join(',')"
/>
</div>
{{ quick_notes::quick_notes_toggles() }}
{{ img::deferred_upload_with_preview("edit-brew-image", "Brew Image", "brew", id, image_url) }}
{{ detail_cards::edit_form_actions() }}

View file

@ -13,14 +13,7 @@
<section class="rounded-lg border bg-surface p-5">
<form
class="flex flex-col gap-4 pb-16 md:pb-0"
data-signals:_submitting="false"
data-signals:_submit-error="''"
data-signals:_name="'{{ name }}'"
data-signals:_city="'{{ city }}'"
data-signals:_country="'{{ country }}'"
data-signals:_latitude="{{ latitude }}"
data-signals:_longitude="{{ longitude }}"
data-signals:_website="'{{ website }}'"
data-signals="{{ signals_json }}"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/cafes/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Cafe updated') }

View file

@ -13,10 +13,7 @@
<section class="rounded-lg border bg-surface p-5">
<form
class="flex flex-col gap-4 pb-16 md:pb-0"
data-signals:_submitting="false"
data-signals:_submit-error="''"
data-signals:_make="'{{ make }}'"
data-signals:_model="'{{ model }}'"
data-signals="{{ signals_json }}"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/gear/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Gear updated') }

View file

@ -13,14 +13,7 @@
<section class="rounded-lg border bg-surface p-5">
<form
class="flex flex-col gap-4 pb-16 md:pb-0"
data-signals:_submitting="false"
data-signals:_submit-error="''"
data-signals:_name="'{{ name }}'"
data-signals:_origin="'{{ origin }}'"
data-signals:_region="'{{ region }}'"
data-signals:_producer="'{{ producer }}'"
data-signals:_process="'{{ process }}'"
data-signals:_tasting-notes="'{{ tasting_notes }}'"
data-signals="{{ signals_json }}"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasts/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roast updated') }

View file

@ -13,12 +13,7 @@
<section class="rounded-lg border bg-surface p-5">
<form
class="flex flex-col gap-4 pb-16 md:pb-0"
data-signals:_submitting="false"
data-signals:_submit-error="''"
data-signals:_name="'{{ name }}'"
data-signals:_country="'{{ country }}'"
data-signals:_city="'{{ city }}'"
data-signals:_homepage="'{{ homepage }}'"
data-signals="{{ signals_json }}"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasters/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roaster updated') }

View file

@ -31,7 +31,9 @@
</div>
</div>
</header>
{{ img::lightbox_script() }}
{% if image_url.is_some() %}
{{ img::lightbox_script() }}
{% endif %}
<div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Details</h2>

View file

@ -4,6 +4,7 @@
"partials/brew_card.html" as brew_card
%}
{% import "partials/icons.html" as icons %}
{% import "partials/entity_icon.html" as ei %}
{% block title %}Brewlog{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
@ -265,7 +266,7 @@
>
<div class="flex items-center gap-3 min-w-0">
<span class="text-text-muted shrink-0">
{% if event.entity_type == "brew" %}{{ icons::beaker("h-5 w-5") }}{% elif event.entity_type == "roast" %}{{ icons::coffee_bean("h-5 w-5") }}{% elif event.entity_type == "roaster" %}{{ icons::fire("h-5 w-5") }}{% elif event.entity_type == "bag" %}{{ icons::bag("h-5 w-5") }}{% elif event.entity_type == "cup" %}{{ icons::cup("h-5 w-5") }}{% elif event.entity_type == "cafe" %}{{ icons::location("h-5 w-5") }}{% elif event.entity_type == "gear" %}{{ icons::grinder("h-5 w-5") }}{% else %}{{ icons::beaker("h-5 w-5") }}{% endif %}
{{ ei::entity_icon(event.entity_type, "h-5 w-5") }}
</span>
<p class="min-w-0 truncate text-sm">
<span class="font-medium text-text">{{ event.title }}</span
@ -337,7 +338,7 @@
href="/stats"
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
>
{% if card.icon == "coffee_bean" %}{{ icons::coffee_bean("h-6 w-6 text-accent") }}{% else if card.icon == "beaker" %}{{ icons::beaker("h-6 w-6 text-accent") }}{% else if card.icon == "map" %}{{ icons::map("h-6 w-6 text-accent") }}{% else if card.icon == "location" %}{{ icons::location("h-6 w-6 text-accent") }}{% else if card.icon == "fire" %}{{ icons::fire("h-6 w-6 text-accent") }}{% endif %}
{{ ei::entity_icon(card.icon, "h-6 w-6 text-accent") }}
<span class="text-lg font-bold text-text whitespace-nowrap"
>{{ card.value }}</span
>

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Login{% endblock %}
{% block head %}
<script src="/static/js/webauthn.js"></script>
<script defer src="/static/js/webauthn.js"></script>
{% endblock %}
{% block content %}
<div class="mx-auto max-w-md">
@ -19,7 +19,7 @@
<div
id="login-unsupported"
class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800"
class="mt-4 hidden rounded-md bg-warning-bg border border-warning-border p-3 text-sm text-warning-text"
>
This browser does not support passkeys. Please use a modern browser
(Chrome, Firefox, Safari, or Edge).
@ -29,6 +29,7 @@
<button
id="login-button"
type="button"
onclick="handleLogin()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ icons::key("h-4 w-4") }} Sign in with Passkey
@ -45,46 +46,35 @@
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
const handleLogin = async () => {
const button = document.getElementById("login-button");
const errorDiv = document.getElementById("login-error");
const loadingDiv = document.getElementById("login-loading");
const unsupportedDiv = document.getElementById("login-unsupported");
// Check WebAuthn support
if (!window.PublicKeyCredential) {
button.disabled = true;
unsupportedDiv.classList.remove("hidden");
return;
}
errorDiv.classList.add("hidden");
loadingDiv.classList.remove("hidden");
button.disabled = true;
// Check for CLI callback query params
const params = new URLSearchParams(window.location.search);
let queryString = "";
if (params.has("cli_callback")) {
queryString = "?" + params.toString();
queryString = `?${params.toString()}`;
}
button.addEventListener("click", async function () {
errorDiv.classList.add("hidden");
loadingDiv.classList.remove("hidden");
button.disabled = true;
try {
const result = await startPasskeyAuthentication(queryString);
window.location.href = result.redirect || "/";
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.remove("hidden");
loadingDiv.classList.add("hidden");
button.disabled = false;
}
};
try {
const result = await startPasskeyAuthentication(queryString);
if (result.redirect) {
window.location.href = result.redirect;
} else {
window.location.href = "/";
}
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.remove("hidden");
loadingDiv.classList.add("hidden");
button.disabled = false;
}
});
});
if (!window.PublicKeyCredential) {
document.getElementById("login-button").disabled = true;
document.getElementById("login-unsupported").classList.remove("hidden");
}
</script>
{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Register{% endblock %}
{% block head %}
<script src="/static/js/webauthn.js"></script>
<script defer src="/static/js/webauthn.js"></script>
{% endblock %}
{% block content %}
<div class="mx-auto max-w-md">
@ -20,13 +20,17 @@
<div
id="register-unsupported"
class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800"
class="mt-4 hidden rounded-md bg-warning-bg border border-warning-border p-3 text-sm text-warning-text"
>
This browser does not support passkeys. Please use a modern browser
(Chrome, Firefox, Safari, or Edge).
</div>
<div id="register-form" class="mt-6 flex flex-col gap-4">
<div
id="register-form"
class="mt-6 flex flex-col gap-4"
data-token="{{ token }}"
>
<label class="flex flex-col gap-1 text-sm">
<span class="text-text">Display Name</span>
<input
@ -55,6 +59,7 @@
<button
id="register-button"
type="button"
onclick="handleRegister()"
class="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ icons::key("h-4 w-4") }} Register Passkey
@ -71,51 +76,46 @@
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
const token = "{{ token }}";
const handleRegister = async () => {
const token = document.getElementById("register-form").dataset.token;
const button = document.getElementById("register-button");
const displayNameInput = document.getElementById("display-name");
const passkeyNameInput = document.getElementById("passkey-name");
const errorDiv = document.getElementById("register-error");
const loadingDiv = document.getElementById("register-loading");
const unsupportedDiv = document.getElementById("register-unsupported");
// Check WebAuthn support
if (!window.PublicKeyCredential) {
button.disabled = true;
unsupportedDiv.classList.remove("hidden");
const displayName = document.getElementById("display-name").value.trim();
if (!displayName) {
errorDiv.textContent = "Please enter a display name.";
errorDiv.classList.remove("hidden");
return;
}
button.addEventListener("click", async function () {
const displayName = displayNameInput.value.trim();
if (!displayName) {
errorDiv.textContent = "Please enter a display name.";
errorDiv.classList.remove("hidden");
return;
}
const passkeyName = document.getElementById("passkey-name").value.trim();
if (!passkeyName) {
errorDiv.textContent = "Please enter a name for this passkey.";
errorDiv.classList.remove("hidden");
return;
}
const passkeyName = passkeyNameInput.value.trim();
if (!passkeyName) {
errorDiv.textContent = "Please enter a name for this passkey.";
errorDiv.classList.remove("hidden");
return;
}
errorDiv.classList.add("hidden");
loadingDiv.classList.remove("hidden");
button.disabled = true;
errorDiv.classList.add("hidden");
loadingDiv.classList.remove("hidden");
button.disabled = true;
try {
await startPasskeyRegistration(token, displayName, passkeyName);
window.location.href = "/";
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.remove("hidden");
loadingDiv.classList.add("hidden");
button.disabled = false;
}
};
try {
await startPasskeyRegistration(token, displayName, passkeyName);
window.location.href = "/";
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.remove("hidden");
loadingDiv.classList.add("hidden");
button.disabled = false;
}
});
});
if (!window.PublicKeyCredential) {
document.getElementById("register-button").disabled = true;
document
.getElementById("register-unsupported")
.classList.remove("hidden");
}
</script>
{% endblock %}

View file

@ -28,7 +28,9 @@
</div>
</div>
</header>
{{ img::lightbox_script() }}
{% if image_url.is_some() %}
{{ img::lightbox_script() }}
{% endif %}
<div class="grid gap-6 md:grid-cols-2">
{{ detail::coffee_card(roast.name, roast.roaster_name, roast.origin, roast.origin_flag, roast.region, roast.producer, roast.process, roast.tasting_notes, roaster_slug, "") }}

View file

@ -29,7 +29,9 @@
</div>
</div>
</header>
{{ img::lightbox_script() }}
{% if image_url.is_some() %}
{{ img::lightbox_script() }}
{% endif %}
<div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5">

View file

@ -146,6 +146,26 @@
</div>
{% endmacro %}
{# Submit/cancel buttons for add-entity forms. #}
{% macro add_form_submit(icon_name, label) %}
<div class="sticky-submit flex flex-col gap-2">
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{% if icon_name == "beaker" %}{{ icons::beaker("h-4 w-4") }}{% else %}{{ icons::plus("h-4 w-4") }}{% endif %}
{{ label }}
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
{% endmacro %}
{% macro edit_form_actions() %}
<p
data-show="$_submitError"

View file

@ -0,0 +1,4 @@
{% import "partials/icons.html" as icons %}
{# Render the canonical icon for an entity type or icon key. #}
{% macro entity_icon(key, class) %}{% if key == "brew" || key == "brews" || key == "beaker" %}{{ icons::beaker(class) }}{% elif key == "roast" || key == "roasts" || key == "coffee_bean" %}{{ icons::coffee_bean(class) }}{% elif key == "roaster" || key == "roasters" || key == "fire" %}{{ icons::fire(class) }}{% elif key == "bag" || key == "bags" %}{{ icons::bag(class) }}{% elif key == "cup" || key == "cups" %}{{ icons::cup(class) }}{% elif key == "cafe" || key == "cafes" || key == "location" %}{{ icons::location(class) }}{% elif key == "gear" || key == "grinder" %}{{ icons::grinder(class) }}{% elif key == "map" %}{{ icons::map(class) }}{% endif %}{% endmacro %}

View file

@ -0,0 +1,57 @@
{# Quick note pill toggles with hidden form field.
Requires signals: _qn-good, _qn-too-fast, _qn-too-slow,
_qn-too-hot, _qn-under-extracted, _qn-over-extracted. #}
{% macro quick_notes_toggles() %}
<div>
<h4 class="text-sm font-semibold text-text mb-3">Quick Notes</h4>
<div class="flex flex-wrap gap-2">
<button
type="button"
data-on:click="$_qnGood = !$_qnGood"
data-attr:class="$_qnGood ? 'pill pill-success cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Good
</button>
<button
type="button"
data-on:click="$_qnTooFast = !$_qnTooFast"
data-attr:class="$_qnTooFast ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Fast
</button>
<button
type="button"
data-on:click="$_qnTooSlow = !$_qnTooSlow"
data-attr:class="$_qnTooSlow ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Slow
</button>
<button
type="button"
data-on:click="$_qnTooHot = !$_qnTooHot"
data-attr:class="$_qnTooHot ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Hot
</button>
<button
type="button"
data-on:click="$_qnUnderExtracted = !$_qnUnderExtracted"
data-attr:class="$_qnUnderExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Under Extracted
</button>
<button
type="button"
data-on:click="$_qnOverExtracted = !$_qnOverExtracted"
data-attr:class="$_qnOverExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Over Extracted
</button>
</div>
<input
type="hidden"
name="quick_notes"
data-attr:value="[$_qnGood && 'good', $_qnTooFast && 'too-fast', $_qnTooSlow && 'too-slow', $_qnTooHot && 'too-hot', $_qnUnderExtracted && 'under-extracted', $_qnOverExtracted && 'over-extracted'].filter(Boolean).join(',')"
/>
</div>
{% endmacro %}

View file

@ -192,7 +192,7 @@
type="checkbox"
name="open_bag"
value="true"
class="accent-orange-700"
class="accent-accent"
data-bind:_open-bag
/>
<span class="font-semibold text-text">Open a bag of this coffee</span>

View file

@ -49,7 +49,7 @@
style="box-shadow: inset 0 0 20px rgba(0,0,0,0.4)"
></div>
</div>
{% else if is_authenticated %}
{% elif is_authenticated %}
<image-upload
entity-type="{{ entity_type }}"
entity-id="{{ entity_id }}"
@ -144,7 +144,7 @@
<button
type="button"
onclick="if(confirm('Remove this image?')){fetch('/api/v1/{{ entity_type }}/{{ entity_id }}/image',{method:'DELETE'}).then(()=>{this.closest('[id$=-existing]').remove()})}"
class="inline-flex items-center gap-1 rounded border bg-surface/90 px-2 py-1 text-xs font-medium text-red-500 hover:bg-surface cursor-pointer backdrop-blur-sm"
class="inline-flex items-center gap-1 rounded border bg-surface/90 px-2 py-1 text-xs font-medium text-error hover:bg-surface cursor-pointer backdrop-blur-sm"
>
Remove
</button>

View file

@ -19,14 +19,14 @@
</image-upload>
<button
onclick="if(confirm('Remove this image?')){fetch('/api/v1/{{ entity_type }}/{{ entity_id }}/image',{method:'DELETE',headers:{'datastar-request':'true'}}).then(r=>r.text()).then(h=>{document.getElementById('entity-image').outerHTML=h})}"
class="inline-flex items-center gap-1 rounded border bg-surface/90 px-2 py-1 text-xs font-medium text-red-500 hover:bg-surface cursor-pointer backdrop-blur-sm"
class="inline-flex items-center gap-1 rounded border bg-surface/90 px-2 py-1 text-xs font-medium text-error hover:bg-surface cursor-pointer backdrop-blur-sm"
>
Remove
</button>
</div>
{% endif %}
</div>
{% else if is_authenticated %}
{% elif is_authenticated %}
<image-upload
entity-type="{{ entity_type }}"
entity-id="{{ entity_id }}"

View file

@ -1,4 +1,5 @@
{% import "partials/icons.html" as icons %}
{% import "partials/entity_icon.html" as ei %}
<div
data-signals:{{ tab_signal }}="'{{ active_type }}'"
@ -18,7 +19,7 @@
'{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}})
{% endif %}"
>
{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %}
{{ ei::entity_icon(tab.key, "h-4 w-4 shrink-0") }}
{{ tab.label }}
</button>
{% endfor %}
@ -35,7 +36,7 @@
<span
data-show="{{ tab_signal_js }} === '{{ tab.key }}'"
class="inline-flex items-center gap-1.5"
>{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %}
>{{ ei::entity_icon(tab.key, "h-4 w-4 shrink-0") }}
{{ tab.label }}</span
>
{% endfor %}
@ -61,7 +62,7 @@
{selector: '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}})
{% endif %}"
>
{% if tab.key == "brew" || tab.key == "brews" %}{{ icons::beaker("h-4 w-4 shrink-0") }}{% elif tab.key == "roast" || tab.key == "roasts" %}{{ icons::coffee_bean("h-4 w-4 shrink-0") }}{% elif tab.key == "roaster" || tab.key == "roasters" %}{{ icons::fire("h-4 w-4 shrink-0") }}{% elif tab.key == "bag" || tab.key == "bags" %}{{ icons::bag("h-4 w-4 shrink-0") }}{% elif tab.key == "cup" || tab.key == "cups" %}{{ icons::cup("h-4 w-4 shrink-0") }}{% elif tab.key == "cafe" || tab.key == "cafes" %}{{ icons::location("h-4 w-4 shrink-0") }}{% elif tab.key == "gear" %}{{ icons::grinder("h-4 w-4 shrink-0") }}{% endif %}
{{ ei::entity_icon(tab.key, "h-4 w-4 shrink-0") }}
{{ tab.label }}
</button>
{% endfor %}

View file

@ -1,4 +1,5 @@
{% import "partials/icons.html" as icons %}
{% import "partials/entity_icon.html" as ei %}
<h2
id="{{ month.anchor }}"
class="timeline-heading scroll-mt-24 mb-6 text-2xl font-semibold text-text"
@ -42,7 +43,7 @@
<div class="flex flex-wrap items-center justify-between gap-2">
{# Category — always visible #}
<span class="inline-flex items-center gap-1 text-xs text-text-muted">
{% if event.entity_type == "brew" %}{{ icons::beaker("h-3 w-3 shrink-0") }}{% elif event.entity_type == "roast" %}{{ icons::coffee_bean("h-3 w-3 shrink-0") }}{% elif event.entity_type == "roaster" %}{{ icons::fire("h-3 w-3 shrink-0") }}{% elif event.entity_type == "bag" %}{{ icons::bag("h-3 w-3 shrink-0") }}{% elif event.entity_type == "cup" %}{{ icons::cup("h-3 w-3 shrink-0") }}{% elif event.entity_type == "cafe" %}{{ icons::location("h-3 w-3 shrink-0") }}{% elif event.entity_type == "gear" %}{{ icons::grinder("h-3 w-3 shrink-0") }}{% endif %}
{{ ei::entity_icon(event.entity_type, "h-3 w-3 shrink-0") }}
<span class="uppercase tracking-wide">{{ event.kind_label }}</span>
{# Relative date — shown when collapsed #}
<span
@ -131,7 +132,7 @@
{{ icons::map("h-4 w-4") }}
</a>
</dd>
{% else if let Some(url) = detail.link %}
{% elif let Some(url) = detail.link %}
<dd class="text-right">
<a
href="{{ url }}"