feat(ui): add entity links to detail page cards

Add roaster and roast slug parameters to coffee_card and roaster_card
macros, rendering entity names as links to their detail pages. Add cafe
link in the cup detail page. Pass slug fields through template structs
and route handlers.
This commit is contained in:
Jon Seager 2026-02-09 19:56:47 +00:00
parent 3c273feb18
commit da206c1938
No known key found for this signature in database
12 changed files with 78 additions and 15 deletions

View file

@ -44,6 +44,8 @@ pub(crate) async fn bag_detail_page(
version_info: &crate::VERSION_INFO, version_info: &crate::VERSION_INFO,
base_url: crate::base_url(), base_url: crate::base_url(),
bag: view, bag: view,
roaster_slug: roaster.slug.clone(),
roast_slug: roast.slug.clone(),
}; };
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)

View file

@ -50,6 +50,8 @@ pub(crate) async fn brew_detail_page(
version_info: &crate::VERSION_INFO, version_info: &crate::VERSION_INFO,
base_url: crate::base_url(), base_url: crate::base_url(),
brew: view, brew: view,
roaster_slug: roaster.slug.clone(),
roast_slug: roast.slug.clone(),
}; };
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)

View file

@ -55,6 +55,9 @@ pub(crate) async fn cup_detail_page(
version_info: &crate::VERSION_INFO, version_info: &crate::VERSION_INFO,
base_url: crate::base_url(), base_url: crate::base_url(),
cup: view, cup: view,
roaster_slug: roaster.slug.clone(),
roast_slug: roast.slug.clone(),
cafe_slug: cafe.slug.clone(),
}; };
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)

View file

@ -37,6 +37,7 @@ pub(crate) async fn roast_detail_page(
version_info: &crate::VERSION_INFO, version_info: &crate::VERSION_INFO,
base_url: crate::base_url(), base_url: crate::base_url(),
roast: view, roast: view,
roaster_slug,
}; };
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)

View file

@ -219,6 +219,8 @@ pub struct BagDetailTemplate {
pub version_info: &'static crate::VersionInfo, pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str, pub base_url: &'static str,
pub bag: BagDetailView, pub bag: BagDetailView,
pub roaster_slug: String,
pub roast_slug: String,
} }
#[derive(Template)] #[derive(Template)]
@ -229,6 +231,8 @@ pub struct BrewDetailTemplate {
pub version_info: &'static crate::VersionInfo, pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str, pub base_url: &'static str,
pub brew: BrewDetailView, pub brew: BrewDetailView,
pub roaster_slug: String,
pub roast_slug: String,
} }
#[derive(Template)] #[derive(Template)]
@ -239,6 +243,9 @@ pub struct CupDetailTemplate {
pub version_info: &'static crate::VersionInfo, pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str, pub base_url: &'static str,
pub cup: CupDetailView, pub cup: CupDetailView,
pub roaster_slug: String,
pub roast_slug: String,
pub cafe_slug: String,
} }
#[derive(Template)] #[derive(Template)]
@ -249,6 +256,7 @@ pub struct RoastDetailTemplate {
pub version_info: &'static crate::VersionInfo, pub version_info: &'static crate::VersionInfo,
pub base_url: &'static str, pub base_url: &'static str,
pub roast: RoastDetailView, pub roast: RoastDetailView,
pub roaster_slug: String,
} }
#[derive(Template)] #[derive(Template)]

View file

@ -89,6 +89,9 @@ pub struct BagDetailView {
// Map // Map
pub map_countries: String, pub map_countries: String,
pub map_max: u32, pub map_max: u32,
// Slugs (for breadcrumbs)
pub roaster_slug: String,
pub roast_slug: String,
// Dates // Dates
pub created_date: String, pub created_date: String,
pub created_time: String, pub created_time: String,
@ -112,6 +115,8 @@ impl BagDetailView {
id: bag.bag.id.to_string(), id: bag.bag.id.to_string(),
roast_name: bag.roast_name, roast_name: bag.roast_name,
roaster_name: bag.roaster_name, roaster_name: bag.roaster_name,
roaster_slug: roaster.slug.clone(),
roast_slug: roast.slug.clone(),
origin: coffee.origin, origin: coffee.origin,
origin_flag: coffee.origin_flag, origin_flag: coffee.origin_flag,
region: coffee.region, region: coffee.region,

View file

@ -63,6 +63,10 @@ pub struct CupDetailView {
// Map // Map
pub map_countries: String, pub map_countries: String,
pub map_max: u32, pub map_max: u32,
// Slugs (for breadcrumbs)
pub roaster_slug: String,
pub roast_slug: String,
pub cafe_slug: String,
// Dates // Dates
pub created_date: String, pub created_date: String,
pub created_time: String, pub created_time: String,
@ -106,6 +110,9 @@ impl CupDetailView {
cafe_country: cafe.country.clone(), cafe_country: cafe.country.clone(),
cafe_country_flag, cafe_country_flag,
cafe_website: cafe.website.clone(), cafe_website: cafe.website.clone(),
roaster_slug: roaster.slug.clone(),
roast_slug: roast.slug.clone(),
cafe_slug: cafe.slug.clone(),
map_countries, map_countries,
map_max, map_max,
created_date: cup.cup.created_at.format("%Y-%m-%d").to_string(), created_date: cup.cup.created_at.format("%Y-%m-%d").to_string(),

View file

@ -27,13 +27,13 @@
{# ── Coffee + map ── #} {# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
{{ detail::coffee_card(bag.roast_name, bag.roaster_name, bag.origin, bag.origin_flag, bag.region, bag.producer, bag.process, bag.tasting_notes) }} {{ detail::coffee_card(bag.roast_name, bag.roaster_name, bag.origin, bag.origin_flag, bag.region, bag.producer, bag.process, bag.tasting_notes, roaster_slug, roast_slug) }}
{{ detail::map_with_legend_2(bag.map_countries, bag.map_max, "Origin", "", "Roaster", "opacity-50") }} {{ detail::map_with_legend_2(bag.map_countries, bag.map_max, "Origin", "", "Roaster", "opacity-50") }}
</div> </div>
{# ── Roaster & bag info ── #} {# ── Roaster & bag info ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
{{ detail::roaster_card(bag.roaster_name, bag.roaster_country, bag.roaster_country_flag, bag.roaster_city, bag.roaster_homepage) }} {{ detail::roaster_card(bag.roaster_name, bag.roaster_country, bag.roaster_country_flag, bag.roaster_city, bag.roaster_homepage, roaster_slug) }}
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Bag</h2> <h2 class="text-lg font-semibold text-text mb-4">Bag</h2>
@ -49,7 +49,14 @@
<span class="font-medium text-text">Closed</span> <span class="font-medium text-text">Closed</span>
{% else %} {% else %}
<div class="mt-1"> <div class="mt-1">
<div class="h-2 rounded-full bg-surface-alt overflow-hidden"> <div
class="h-2 rounded-full bg-surface-alt overflow-hidden"
role="progressbar"
aria-valuenow="{{ bag.used_percent }}"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Coffee used"
>
<div <div
class="h-full rounded-full bg-accent" class="h-full rounded-full bg-accent"
style="width: {{ bag.used_percent }}%" style="width: {{ bag.used_percent }}%"

View file

@ -29,13 +29,13 @@
{# ── Coffee + map ── #} {# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
{{ detail::coffee_card(brew.roast_name, brew.roaster_name, brew.origin, brew.origin_flag, brew.region, brew.producer, brew.process, brew.tasting_notes) }} {{ detail::coffee_card(brew.roast_name, brew.roaster_name, brew.origin, brew.origin_flag, brew.region, brew.producer, brew.process, brew.tasting_notes, roaster_slug, roast_slug) }}
{{ detail::map_with_legend_2(brew.map_countries, brew.map_max, "Origin", "", "Roaster", "opacity-50") }} {{ detail::map_with_legend_2(brew.map_countries, brew.map_max, "Origin", "", "Roaster", "opacity-50") }}
</div> </div>
{# ── Roaster & gear ── #} {# ── Roaster & gear ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
{{ detail::roaster_card(brew.roaster_name, brew.roaster_country, brew.roaster_country_flag, brew.roaster_city, brew.roaster_homepage) }} {{ detail::roaster_card(brew.roaster_name, brew.roaster_country, brew.roaster_country_flag, brew.roaster_city, brew.roaster_homepage, roaster_slug) }}
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Gear</h2> <h2 class="text-lg font-semibold text-text mb-4">Gear</h2>

View file

@ -27,20 +27,26 @@
{# ── Coffee + map ── #} {# ── Coffee + map ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
{{ detail::coffee_card(cup.roast_name, cup.roaster_name, cup.origin, cup.origin_flag, cup.region, cup.producer, cup.process, cup.tasting_notes) }} {{ detail::coffee_card(cup.roast_name, cup.roaster_name, cup.origin, cup.origin_flag, cup.region, cup.producer, cup.process, cup.tasting_notes, roaster_slug, roast_slug) }}
{{ detail::map_with_legend_3(cup.map_countries, cup.map_max, "Cafe", "", "Origin", "opacity-65", "Roaster", "opacity-35") }} {{ detail::map_with_legend_3(cup.map_countries, cup.map_max, "Cafe", "", "Origin", "opacity-65", "Roaster", "opacity-35") }}
</div> </div>
{# ── Roaster & cafe ── #} {# ── Roaster & cafe ── #}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
{{ detail::roaster_card(cup.roaster_name, cup.roaster_country, cup.roaster_country_flag, cup.roaster_city, cup.roaster_homepage) }} {{ detail::roaster_card(cup.roaster_name, cup.roaster_country, cup.roaster_country_flag, cup.roaster_city, cup.roaster_homepage, roaster_slug) }}
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Cafe</h2> <h2 class="text-lg font-semibold text-text mb-4">Cafe</h2>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm"> <dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div> <div>
<dt class="text-text-muted">Name</dt> <dt class="text-text-muted">Name</dt>
<dd class="font-medium text-text">{{ cup.cafe_name }}</dd> <dd class="font-medium">
<a
href="/cafes/{{ cafe_slug }}"
class="text-accent hover:text-accent-hover transition"
>{{ cup.cafe_name }}</a
>
</dd>
</div> </div>
<div> <div>
<dt class="text-text-muted">Country</dt> <dt class="text-text-muted">Country</dt>

View file

@ -22,12 +22,12 @@
</header> </header>
<div class="grid gap-6 md:grid-cols-2"> <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) }} {{ detail::coffee_card(roast.name, roast.roaster_name, roast.origin, roast.origin_flag, roast.region, roast.producer, roast.process, roast.tasting_notes, roaster_slug, "") }}
{{ detail::map_with_legend_2(roast.map_countries, roast.map_max, "Origin", "", "Roaster", "opacity-50") }} {{ detail::map_with_legend_2(roast.map_countries, roast.map_max, "Origin", "", "Roaster", "opacity-50") }}
</div> </div>
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
{{ detail::roaster_card(roast.roaster_name, roast.roaster_country, roast.roaster_country_flag, roast.roaster_city, roast.roaster_homepage) }} {{ detail::roaster_card(roast.roaster_name, roast.roaster_country, roast.roaster_country_flag, roast.roaster_city, roast.roaster_homepage, roaster_slug) }}
</div> </div>
{% if is_authenticated %} {% if is_authenticated %}

View file

@ -30,17 +30,33 @@
</script> </script>
{% endmacro %} {% endmacro %}
{% macro coffee_card(roast_name, roaster_name, origin, origin_flag, region, producer, process, tasting_notes) %} {% macro coffee_card(roast_name, roaster_name, origin, origin_flag, region, producer, process, tasting_notes, roaster_slug, roast_slug) %}
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Coffee</h2> <h2 class="text-lg font-semibold text-text mb-4">Coffee</h2>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm"> <dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div> <div>
<dt class="text-text-muted">Roast</dt> <dt class="text-text-muted">Roast</dt>
<dd class="font-medium text-text">{{ roast_name }}</dd> <dd class="font-medium">
{% if !roast_slug.is_empty() %}
<a
href="/roasters/{{ roaster_slug }}/roasts/{{ roast_slug }}"
class="text-accent hover:text-accent-hover transition"
>{{ roast_name }}</a
>
{% else %}
<span class="text-text">{{ roast_name }}</span>
{% endif %}
</dd>
</div> </div>
<div> <div>
<dt class="text-text-muted">Roaster</dt> <dt class="text-text-muted">Roaster</dt>
<dd class="font-medium text-text">{{ roaster_name }}</dd> <dd class="font-medium">
<a
href="/roasters/{{ roaster_slug }}"
class="text-accent hover:text-accent-hover transition"
>{{ roaster_name }}</a
>
</dd>
</div> </div>
{% if origin != "\u{2014}" %} {% if origin != "\u{2014}" %}
<div> <div>
@ -169,13 +185,19 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% macro roaster_card(name, country, country_flag, city, homepage) %} {% macro roaster_card(name, country, country_flag, city, homepage, roaster_slug) %}
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">
<h2 class="text-lg font-semibold text-text mb-4">Roaster</h2> <h2 class="text-lg font-semibold text-text mb-4">Roaster</h2>
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm"> <dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div> <div>
<dt class="text-text-muted">Name</dt> <dt class="text-text-muted">Name</dt>
<dd class="font-medium text-text">{{ name }}</dd> <dd class="font-medium">
<a
href="/roasters/{{ roaster_slug }}"
class="text-accent hover:text-accent-hover transition"
>{{ name }}</a
>
</dd>
</div> </div>
<div> <div>
<dt class="text-text-muted">Country</dt> <dt class="text-text-muted">Country</dt>