diff --git a/src/application/routes/app/bags.rs b/src/application/routes/app/bags.rs index 816fc99..26927b5 100644 --- a/src/application/routes/app/bags.rs +++ b/src/application/routes/app/bags.rs @@ -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) diff --git a/src/application/routes/app/cafes.rs b/src/application/routes/app/cafes.rs index e20c3e9..15e6586 100644 --- a/src/application/routes/app/cafes.rs +++ b/src/application/routes/app/cafes.rs @@ -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) diff --git a/src/application/routes/app/gear.rs b/src/application/routes/app/gear.rs index 9953d41..b7f88a4 100644 --- a/src/application/routes/app/gear.rs +++ b/src/application/routes/app/gear.rs @@ -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) diff --git a/src/application/routes/app/roasters.rs b/src/application/routes/app/roasters.rs index 6ee573f..ff6315c 100644 --- a/src/application/routes/app/roasters.rs +++ b/src/application/routes/app/roasters.rs @@ -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) diff --git a/src/application/routes/app/roasts.rs b/src/application/routes/app/roasts.rs index c0438a5..983a32e 100644 --- a/src/application/routes/app/roasts.rs +++ b/src/application/routes/app/roasts.rs @@ -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) diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 406199a..448b64a 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -317,6 +317,7 @@ pub struct RoasterEditTemplate { pub city: String, pub homepage: String, pub image_url: Option, + pub signals_json: String, } #[derive(Template)] @@ -336,6 +337,7 @@ pub struct RoastEditTemplate { pub tasting_notes: String, pub roaster_options: Vec, pub image_url: Option, + pub signals_json: String, } #[derive(Template)] @@ -351,6 +353,7 @@ pub struct BagEditTemplate { pub amount: f64, pub remaining: f64, pub roast_options: Vec, + pub signals_json: String, } #[derive(Template)] @@ -393,6 +396,7 @@ pub struct CafeEditTemplate { pub longitude: f64, pub website: String, pub image_url: Option, + pub signals_json: String, } #[derive(Template)] @@ -422,6 +426,7 @@ pub struct GearEditTemplate { pub make: String, pub model: String, pub image_url: Option, + pub signals_json: String, } #[derive(Template)] diff --git a/src/presentation/web/views/mod.rs b/src/presentation/web/views/mod.rs index 0945f8c..f2eeb21 100644 --- a/src/presentation/web/views/mod.rs +++ b/src/presentation/web/views/mod.rs @@ -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 `"`, 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::*; diff --git a/static/css/input.css b/static/css/input.css index be0f711..816c05c 100644 --- a/static/css/input.css +++ b/static/css/input.css @@ -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 ────────────────────────────────── */ diff --git a/templates/base.html b/templates/base.html index 80de718..f5affc5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -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 }); + } }); diff --git a/templates/pages/add.html b/templates/pages/add.html index 656a24e..c0e1901 100644 --- a/templates/pages/add.html +++ b/templates/pages/add.html @@ -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 @@ {{ img::deferred_upload("roaster-image", "Add image (optional)") }} -
- - -
+ {{ detail_cards::add_form_submit("plus", "Save Roaster") }} @@ -410,21 +398,7 @@ {{ img::deferred_upload("roast-image", "Add image (optional)") }} -
- - -
+ {{ detail_cards::add_form_submit("plus", "Save Roast") }} {% endif %} @@ -512,21 +486,7 @@ /> -
- - -
+ {{ detail_cards::add_form_submit("plus", "Save Bag") }} {% endif %} @@ -552,7 +512,7 @@ to enable this form.

- {% else if grinder_options.is_empty() || brewer_options.is_empty() %} + {% elif grinder_options.is_empty() || brewer_options.is_empty() %}

Add gear first

@@ -915,74 +875,9 @@

-
-

Quick Notes

-
- - - - - - -
- -
+ {{ quick_notes::quick_notes_toggles() }} {{ img::deferred_upload("brew-image", "Add image (optional)") }} -
- - -
+ {{ detail_cards::add_form_submit("beaker", "Save Brew") }} {% endif %} @@ -1051,21 +946,7 @@ {{ img::deferred_upload("gear-image", "Add image (optional)") }} -
- - -
+ {{ detail_cards::add_form_submit("plus", "Save Gear") }} @@ -1223,21 +1104,7 @@ {{ img::deferred_upload("cafe-image", "Add image (optional)") }} -
- - -
+ {{ detail_cards::add_form_submit("plus", "Save Cafe") }} @@ -1339,21 +1206,7 @@ -
- - -
+ {{ detail_cards::add_form_submit("plus", "Save Cup") }} {% endif %} diff --git a/templates/pages/admin.html b/templates/pages/admin.html index 69f1785..e6a77f1 100644 --- a/templates/pages/admin.html +++ b/templates/pages/admin.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Admin{% endblock %} {% block head %} - + {% endblock %} {% block content %}
@@ -49,7 +49,9 @@
- {{ img::lightbox_script() }} + {% if image_url.is_some() %} + {{ img::lightbox_script() }} + {% endif %}
diff --git a/templates/pages/cli_callback.html b/templates/pages/cli_callback.html index ce30a04..6721eec 100644 --- a/templates/pages/cli_callback.html +++ b/templates/pages/cli_callback.html @@ -5,14 +5,14 @@
{% if token.is_some() %}
- {{ icons::check_circle("h-10 w-10 text-emerald-600 dark:text-emerald-400") }} + {{ icons::check_circle("h-10 w-10 text-success") }}

CLI Authenticated

Your CLI has been authenticated successfully. You can close this window and return to the terminal.

- {% else if error.is_some() %} + {% elif error.is_some() %}
{{ icons::x_circle("h-10 w-10 text-error") }}

diff --git a/templates/pages/edit_bag.html b/templates/pages/edit_bag.html index c2c1fe0..4eb1849 100644 --- a/templates/pages/edit_bag.html +++ b/templates/pages/edit_bag.html @@ -12,11 +12,7 @@
-
-

Quick Notes

-
- - - - - - -
- -
+ {{ quick_notes::quick_notes_toggles() }} {{ img::deferred_upload_with_preview("edit-brew-image", "Brew Image", "brew", id, image_url) }} {{ detail_cards::edit_form_actions() }} diff --git a/templates/pages/edit_cafe.html b/templates/pages/edit_cafe.html index fae198e..9b70d0b 100644 --- a/templates/pages/edit_cafe.html +++ b/templates/pages/edit_cafe.html @@ -13,14 +13,7 @@

Details

diff --git a/templates/pages/home.html b/templates/pages/home.html index 314bada..e55b551 100644 --- a/templates/pages/home.html +++ b/templates/pages/home.html @@ -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 %} @@ -265,7 +266,7 @@ >
- {% 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") }}

{{ event.title }} - {% 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") }} {{ card.value }} diff --git a/templates/pages/login.html b/templates/pages/login.html index 5f15771..39af7ce 100644 --- a/templates/pages/login.html +++ b/templates/pages/login.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Login{% endblock %} {% block head %} - + {% endblock %} {% block content %}

@@ -19,7 +19,7 @@ {% endblock %} diff --git a/templates/pages/register.html b/templates/pages/register.html index 6197a18..13173f9 100644 --- a/templates/pages/register.html +++ b/templates/pages/register.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Register{% endblock %} {% block head %} - + {% endblock %} {% block content %}
@@ -20,13 +20,17 @@ -
+
{% endblock %} diff --git a/templates/pages/roast.html b/templates/pages/roast.html index 328d9d0..3e805ed 100644 --- a/templates/pages/roast.html +++ b/templates/pages/roast.html @@ -28,7 +28,9 @@
- {{ img::lightbox_script() }} + {% if image_url.is_some() %} + {{ img::lightbox_script() }} + {% endif %}
{{ detail::coffee_card(roast.name, roast.roaster_name, roast.origin, roast.origin_flag, roast.region, roast.producer, roast.process, roast.tasting_notes, roaster_slug, "") }} diff --git a/templates/pages/roaster.html b/templates/pages/roaster.html index 2378509..64023a8 100644 --- a/templates/pages/roaster.html +++ b/templates/pages/roaster.html @@ -29,7 +29,9 @@
- {{ img::lightbox_script() }} + {% if image_url.is_some() %} + {{ img::lightbox_script() }} + {% endif %}
diff --git a/templates/partials/detail_cards.html b/templates/partials/detail_cards.html index dcca1ba..2afcd43 100644 --- a/templates/partials/detail_cards.html +++ b/templates/partials/detail_cards.html @@ -146,6 +146,26 @@
{% endmacro %} +{# Submit/cancel buttons for add-entity forms. #} +{% macro add_form_submit(icon_name, label) %} +
+ + +
+{% endmacro %} + {% macro edit_form_actions() %}

Quick Notes

+
+ + + + + + +
+ +
+{% endmacro %} diff --git a/templates/partials/forms/scan_result_form.html b/templates/partials/forms/scan_result_form.html index 06f0949..406047e 100644 --- a/templates/partials/forms/scan_result_form.html +++ b/templates/partials/forms/scan_result_form.html @@ -192,7 +192,7 @@ type="checkbox" name="open_bag" value="true" - class="accent-orange-700" + class="accent-accent" data-bind:_open-bag /> Open a bag of this coffee diff --git a/templates/partials/image_section.html b/templates/partials/image_section.html index 06543a9..1a0c2ed 100644 --- a/templates/partials/image_section.html +++ b/templates/partials/image_section.html @@ -49,7 +49,7 @@ style="box-shadow: inset 0 0 20px rgba(0,0,0,0.4)" >

- {% else if is_authenticated %} + {% elif is_authenticated %} Remove diff --git a/templates/partials/image_upload.html b/templates/partials/image_upload.html index 8ebe56a..fac1600 100644 --- a/templates/partials/image_upload.html +++ b/templates/partials/image_upload.html @@ -19,14 +19,14 @@
{% endif %}
- {% else if is_authenticated %} + {% elif is_authenticated %} - {% 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 }} {% endfor %} @@ -35,7 +36,7 @@ {% 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 }} {% 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 }} {% endfor %} diff --git a/templates/partials/timeline_month.html b/templates/partials/timeline_month.html index c3f8301..cf4f4db 100644 --- a/templates/partials/timeline_month.html +++ b/templates/partials/timeline_month.html @@ -1,4 +1,5 @@ {% import "partials/icons.html" as icons %} +{% import "partials/entity_icon.html" as ei %}

{# Category — always visible #} - {% 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") }} {{ event.kind_label }} {# Relative date — shown when collapsed #} - {% else if let Some(url) = detail.link %} + {% elif let Some(url) = detail.link %}