feat(detail): add shareable brew and cup detail pages
Add /brews/:id and /cups/:id routes with full coffee, roaster, gear, and recipe information. Each page includes a world map highlighting relevant countries, tasting note pills, and a share button that copies the URL to the clipboard.
This commit is contained in:
parent
9237a386e0
commit
b17aaab5f4
9 changed files with 791 additions and 9 deletions
55
src/application/routes/app/brews.rs
Normal file
55
src/application/routes/app/brews.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use tower_cookies::Cookies;
|
||||||
|
|
||||||
|
use crate::application::errors::map_app_error;
|
||||||
|
use crate::application::routes::render_html;
|
||||||
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::ids::BrewId;
|
||||||
|
use crate::presentation::web::templates::BrewDetailTemplate;
|
||||||
|
use crate::presentation::web::views::BrewDetailView;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, cookies))]
|
||||||
|
pub(crate) async fn brew_detail_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
cookies: Cookies,
|
||||||
|
Path(id): Path<BrewId>,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await;
|
||||||
|
|
||||||
|
let brew_details = state
|
||||||
|
.brew_repo
|
||||||
|
.get_with_details(id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
|
let bag = state
|
||||||
|
.bag_repo
|
||||||
|
.get(brew_details.brew.bag_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
|
let roast = state
|
||||||
|
.roast_repo
|
||||||
|
.get(bag.roast_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
|
let roaster = state
|
||||||
|
.roaster_repo
|
||||||
|
.get(roast.roaster_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
|
let view = BrewDetailView::from_parts(brew_details, &roast, &roaster);
|
||||||
|
|
||||||
|
let template = BrewDetailTemplate {
|
||||||
|
nav_active: "",
|
||||||
|
is_authenticated,
|
||||||
|
version_info: &crate::VERSION_INFO,
|
||||||
|
brew: view,
|
||||||
|
};
|
||||||
|
|
||||||
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
}
|
||||||
60
src/application/routes/app/cups.rs
Normal file
60
src/application/routes/app/cups.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use tower_cookies::Cookies;
|
||||||
|
|
||||||
|
use crate::application::errors::map_app_error;
|
||||||
|
use crate::application::routes::render_html;
|
||||||
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::ids::CupId;
|
||||||
|
use crate::presentation::web::templates::CupDetailTemplate;
|
||||||
|
use crate::presentation::web::views::CupDetailView;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, cookies))]
|
||||||
|
pub(crate) async fn cup_detail_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
cookies: Cookies,
|
||||||
|
Path(id): Path<CupId>,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await;
|
||||||
|
|
||||||
|
let cup_details = state
|
||||||
|
.cup_repo
|
||||||
|
.get_with_details(id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
|
let (roast, cafe) = tokio::try_join!(
|
||||||
|
async {
|
||||||
|
state
|
||||||
|
.roast_repo
|
||||||
|
.get(cup_details.cup.roast_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_app_error(e.into()))
|
||||||
|
},
|
||||||
|
async {
|
||||||
|
state
|
||||||
|
.cafe_repo
|
||||||
|
.get(cup_details.cup.cafe_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_app_error(e.into()))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let roaster = state
|
||||||
|
.roaster_repo
|
||||||
|
.get(roast.roaster_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| map_app_error(e.into()))?;
|
||||||
|
|
||||||
|
let view = CupDetailView::from_parts(cup_details, &roast, &roaster, &cafe);
|
||||||
|
|
||||||
|
let template = CupDetailTemplate {
|
||||||
|
nav_active: "",
|
||||||
|
is_authenticated,
|
||||||
|
version_info: &crate::VERSION_INFO,
|
||||||
|
cup: view,
|
||||||
|
};
|
||||||
|
|
||||||
|
render_html(template).map(IntoResponse::into_response)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
mod add;
|
mod add;
|
||||||
mod admin;
|
mod admin;
|
||||||
pub(super) mod auth;
|
pub(super) mod auth;
|
||||||
|
mod brews;
|
||||||
mod checkin;
|
mod checkin;
|
||||||
|
mod cups;
|
||||||
mod data;
|
mod data;
|
||||||
mod home;
|
mod home;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
|
@ -27,6 +29,8 @@ pub(super) fn router() -> axum::Router<AppState> {
|
||||||
.route("/check-in", get(checkin::checkin_page))
|
.route("/check-in", get(checkin::checkin_page))
|
||||||
.route("/timeline", get(timeline::timeline_page))
|
.route("/timeline", get(timeline::timeline_page))
|
||||||
.route("/stats", get(stats::stats_page))
|
.route("/stats", get(stats::stats_page))
|
||||||
|
.route("/brews/{id}", get(brews::brew_detail_page))
|
||||||
|
.route("/cups/{id}", get(cups::cup_detail_page))
|
||||||
.route("/styles.css", get(styles))
|
.route("/styles.css", get(styles))
|
||||||
.route("/webauthn.js", get(webauthn_js))
|
.route("/webauthn.js", get(webauthn_js))
|
||||||
.route("/components/photo-capture.js", get(photo_capture_js))
|
.route("/components/photo-capture.js", get(photo_capture_js))
|
||||||
|
|
@ -137,8 +141,5 @@ async fn favicon_dark() -> impl IntoResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health() -> impl IntoResponse {
|
async fn health() -> impl IntoResponse {
|
||||||
(
|
([("content-type", "application/json")], r#"{"status":"ok"}"#)
|
||||||
[("content-type", "application/json")],
|
|
||||||
r#"{"status":"ok"}"#,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
|
||||||
use super::views::{
|
use super::views::{
|
||||||
BagOptionView, BagView, BrewDefaultsView, BrewView, CafeOptionView, CafeView, CupView,
|
BagOptionView, BagView, BrewDefaultsView, BrewDetailView, BrewView, CafeOptionView, CafeView,
|
||||||
GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated, QuickNoteView,
|
CupDetailView, CupView, GearOptionView, GearView, ListNavigator, NearbyCafeView, Paginated,
|
||||||
RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatCard, StatsView,
|
QuickNoteView, RoastOptionView, RoastView, RoasterOptionView, RoasterView, StatCard, StatsView,
|
||||||
TimelineEventView, TimelineMonthView,
|
TimelineEventView, TimelineMonthView,
|
||||||
};
|
};
|
||||||
use crate::domain::bags::BagSortKey;
|
use crate::domain::bags::BagSortKey;
|
||||||
|
|
@ -199,6 +199,7 @@ pub struct StatsPageTemplate {
|
||||||
pub consumption_30d_weight: String,
|
pub consumption_30d_weight: String,
|
||||||
pub consumption_all_time_weight: String,
|
pub consumption_all_time_weight: String,
|
||||||
pub cache_age: String,
|
pub cache_age: String,
|
||||||
|
pub has_data: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -207,6 +208,24 @@ pub struct StatsMapFragment<'a> {
|
||||||
pub geo_stats: &'a crate::domain::country_stats::GeoStats,
|
pub geo_stats: &'a crate::domain::country_stats::GeoStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/brew.html")]
|
||||||
|
pub struct BrewDetailTemplate {
|
||||||
|
pub nav_active: &'static str,
|
||||||
|
pub is_authenticated: bool,
|
||||||
|
pub version_info: &'static crate::VersionInfo,
|
||||||
|
pub brew: BrewDetailView,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/cup.html")]
|
||||||
|
pub struct CupDetailTemplate {
|
||||||
|
pub nav_active: &'static str,
|
||||||
|
pub is_authenticated: bool,
|
||||||
|
pub version_info: &'static crate::VersionInfo,
|
||||||
|
pub cup: CupDetailView,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
|
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
|
||||||
template.render()
|
template.render()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
use crate::domain::brews::{BrewWithDetails, QuickNote, format_brew_time};
|
use crate::domain::brews::{BrewWithDetails, QuickNote, format_brew_time};
|
||||||
|
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
|
||||||
use crate::domain::formatting::format_weight;
|
use crate::domain::formatting::format_weight;
|
||||||
|
use crate::domain::roasters::Roaster;
|
||||||
|
use crate::domain::roasts::Roast;
|
||||||
|
|
||||||
|
use super::build_map_data;
|
||||||
use super::relative_date;
|
use super::relative_date;
|
||||||
|
use super::tasting_notes::{self, TastingNoteView};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct QuickNoteView {
|
pub struct QuickNoteView {
|
||||||
|
|
@ -192,6 +197,128 @@ impl Default for BrewDefaultsView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct BrewDetailView {
|
||||||
|
// Coffee info
|
||||||
|
pub roast_name: String,
|
||||||
|
pub roaster_name: String,
|
||||||
|
pub origin: String,
|
||||||
|
pub origin_flag: String,
|
||||||
|
pub region: String,
|
||||||
|
pub producer: String,
|
||||||
|
pub process: String,
|
||||||
|
pub tasting_notes: Vec<TastingNoteView>,
|
||||||
|
// Roaster info
|
||||||
|
pub roaster_country: String,
|
||||||
|
pub roaster_country_flag: String,
|
||||||
|
pub roaster_city: Option<String>,
|
||||||
|
pub roaster_homepage: Option<String>,
|
||||||
|
// Recipe
|
||||||
|
pub coffee_weight: String,
|
||||||
|
pub water_volume: String,
|
||||||
|
pub water_temp: String,
|
||||||
|
pub grind_setting: String,
|
||||||
|
pub brew_time: Option<String>,
|
||||||
|
pub quick_notes_label: String,
|
||||||
|
// Gear
|
||||||
|
pub grinder_name: String,
|
||||||
|
pub brewer_name: String,
|
||||||
|
pub filter_paper_name: Option<String>,
|
||||||
|
// Map
|
||||||
|
pub map_countries: String,
|
||||||
|
pub map_max: u32,
|
||||||
|
// Dates
|
||||||
|
pub created_date: String,
|
||||||
|
pub created_time: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrewDetailView {
|
||||||
|
pub fn from_parts(brew: BrewWithDetails, roast: &Roast, roaster: &Roaster) -> Self {
|
||||||
|
let em_dash = "\u{2014}".to_string();
|
||||||
|
|
||||||
|
let origin = roast.origin.clone().unwrap_or_default();
|
||||||
|
let origin_flag = country_to_iso(&origin)
|
||||||
|
.map(iso_to_flag_emoji)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let roaster_country_flag = country_to_iso(&roaster.country)
|
||||||
|
.map(iso_to_flag_emoji)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut map_entries: Vec<(&str, u32)> = Vec::new();
|
||||||
|
if let Some(ref o) = roast.origin
|
||||||
|
&& !o.is_empty()
|
||||||
|
{
|
||||||
|
map_entries.push((o.as_str(), 2));
|
||||||
|
}
|
||||||
|
map_entries.push((roaster.country.as_str(), 1));
|
||||||
|
let (map_countries, map_max) = build_map_data(&map_entries);
|
||||||
|
|
||||||
|
let tasting_notes = roast
|
||||||
|
.tasting_notes
|
||||||
|
.iter()
|
||||||
|
.flat_map(|note| {
|
||||||
|
note.split([',', '\n'])
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.map(|n| tasting_notes::categorize(&n))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let quick_notes_label = brew
|
||||||
|
.brew
|
||||||
|
.quick_notes
|
||||||
|
.iter()
|
||||||
|
.map(|n| n.label())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
roast_name: brew.roast_name,
|
||||||
|
roaster_name: brew.roaster_name,
|
||||||
|
origin: if origin.is_empty() {
|
||||||
|
em_dash.clone()
|
||||||
|
} else {
|
||||||
|
origin
|
||||||
|
},
|
||||||
|
origin_flag,
|
||||||
|
region: roast
|
||||||
|
.region
|
||||||
|
.clone()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(em_dash.clone()),
|
||||||
|
producer: roast
|
||||||
|
.producer
|
||||||
|
.clone()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(em_dash.clone()),
|
||||||
|
process: roast
|
||||||
|
.process
|
||||||
|
.clone()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(em_dash),
|
||||||
|
tasting_notes,
|
||||||
|
roaster_country: roaster.country.clone(),
|
||||||
|
roaster_country_flag,
|
||||||
|
roaster_city: roaster.city.clone(),
|
||||||
|
roaster_homepage: roaster.homepage.clone(),
|
||||||
|
coffee_weight: format_weight(brew.brew.coffee_weight),
|
||||||
|
water_volume: format!("{}ml", brew.brew.water_volume),
|
||||||
|
water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp),
|
||||||
|
grind_setting: format!("{:.1}", brew.brew.grind_setting),
|
||||||
|
brew_time: brew.brew.brew_time.map(format_brew_time),
|
||||||
|
quick_notes_label,
|
||||||
|
grinder_name: brew.grinder_name,
|
||||||
|
brewer_name: brew.brewer_name,
|
||||||
|
filter_paper_name: brew.filter_paper_name,
|
||||||
|
map_countries,
|
||||||
|
map_max,
|
||||||
|
created_date: brew.brew.created_at.format("%Y-%m-%d").to_string(),
|
||||||
|
created_time: brew.brew.created_at.format("%H:%M").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<BrewWithDetails> for BrewDefaultsView {
|
impl From<BrewWithDetails> for BrewDefaultsView {
|
||||||
fn from(brew: BrewWithDetails) -> Self {
|
fn from(brew: BrewWithDetails) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
|
use crate::domain::cafes::Cafe;
|
||||||
|
use crate::domain::countries::{country_to_iso, iso_to_flag_emoji};
|
||||||
use crate::domain::cups::CupWithDetails;
|
use crate::domain::cups::CupWithDetails;
|
||||||
|
use crate::domain::roasters::Roaster;
|
||||||
|
use crate::domain::roasts::Roast;
|
||||||
|
|
||||||
|
use super::build_map_data;
|
||||||
|
use super::tasting_notes::{self, TastingNoteView};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CupView {
|
pub struct CupView {
|
||||||
|
|
@ -30,3 +37,113 @@ impl CupView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct CupDetailView {
|
||||||
|
// Coffee info
|
||||||
|
pub roast_name: String,
|
||||||
|
pub roaster_name: String,
|
||||||
|
pub origin: String,
|
||||||
|
pub origin_flag: String,
|
||||||
|
pub region: String,
|
||||||
|
pub producer: String,
|
||||||
|
pub process: String,
|
||||||
|
pub tasting_notes: Vec<TastingNoteView>,
|
||||||
|
// Roaster info
|
||||||
|
pub roaster_country: String,
|
||||||
|
pub roaster_country_flag: String,
|
||||||
|
pub roaster_city: Option<String>,
|
||||||
|
pub roaster_homepage: Option<String>,
|
||||||
|
// Cafe info
|
||||||
|
pub cafe_name: String,
|
||||||
|
pub cafe_city: String,
|
||||||
|
pub cafe_country: String,
|
||||||
|
pub cafe_country_flag: String,
|
||||||
|
pub cafe_website: Option<String>,
|
||||||
|
// Map
|
||||||
|
pub map_countries: String,
|
||||||
|
pub map_max: u32,
|
||||||
|
// Dates
|
||||||
|
pub created_date: String,
|
||||||
|
pub created_time: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CupDetailView {
|
||||||
|
pub fn from_parts(cup: CupWithDetails, roast: &Roast, roaster: &Roaster, cafe: &Cafe) -> Self {
|
||||||
|
let em_dash = "\u{2014}".to_string();
|
||||||
|
|
||||||
|
let origin = roast.origin.clone().unwrap_or_default();
|
||||||
|
let origin_flag = country_to_iso(&origin)
|
||||||
|
.map(iso_to_flag_emoji)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let roaster_country_flag = country_to_iso(&roaster.country)
|
||||||
|
.map(iso_to_flag_emoji)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let cafe_country_flag = country_to_iso(&cafe.country)
|
||||||
|
.map(iso_to_flag_emoji)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut map_entries: Vec<(&str, u32)> = Vec::new();
|
||||||
|
map_entries.push((cafe.country.as_str(), 3));
|
||||||
|
if let Some(ref o) = roast.origin
|
||||||
|
&& !o.is_empty()
|
||||||
|
{
|
||||||
|
map_entries.push((o.as_str(), 2));
|
||||||
|
}
|
||||||
|
map_entries.push((roaster.country.as_str(), 1));
|
||||||
|
let (map_countries, map_max) = build_map_data(&map_entries);
|
||||||
|
|
||||||
|
let tasting_notes = roast
|
||||||
|
.tasting_notes
|
||||||
|
.iter()
|
||||||
|
.flat_map(|note| {
|
||||||
|
note.split([',', '\n'])
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.map(|n| tasting_notes::categorize(&n))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
roast_name: cup.roast_name,
|
||||||
|
roaster_name: cup.roaster_name,
|
||||||
|
origin: if origin.is_empty() {
|
||||||
|
em_dash.clone()
|
||||||
|
} else {
|
||||||
|
origin
|
||||||
|
},
|
||||||
|
origin_flag,
|
||||||
|
region: roast
|
||||||
|
.region
|
||||||
|
.clone()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(em_dash.clone()),
|
||||||
|
producer: roast
|
||||||
|
.producer
|
||||||
|
.clone()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(em_dash.clone()),
|
||||||
|
process: roast
|
||||||
|
.process
|
||||||
|
.clone()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(em_dash),
|
||||||
|
tasting_notes,
|
||||||
|
roaster_country: roaster.country.clone(),
|
||||||
|
roaster_country_flag,
|
||||||
|
roaster_city: roaster.city.clone(),
|
||||||
|
roaster_homepage: roaster.homepage.clone(),
|
||||||
|
cafe_name: cafe.name.clone(),
|
||||||
|
cafe_city: cafe.city.clone(),
|
||||||
|
cafe_country: cafe.country.clone(),
|
||||||
|
cafe_country_flag,
|
||||||
|
cafe_website: cafe.website.clone(),
|
||||||
|
map_countries,
|
||||||
|
map_max,
|
||||||
|
created_date: cup.cup.created_at.format("%Y-%m-%d").to_string(),
|
||||||
|
created_time: cup.cup.created_at.format("%H:%M").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ pub mod tasting_notes;
|
||||||
mod timeline;
|
mod timeline;
|
||||||
|
|
||||||
pub use bags::{BagOptionView, BagView};
|
pub use bags::{BagOptionView, BagView};
|
||||||
pub use brews::{BrewDefaultsView, BrewView, QuickNoteView};
|
pub use brews::{BrewDefaultsView, BrewDetailView, BrewView, QuickNoteView};
|
||||||
pub use cafes::{CafeOptionView, CafeView, NearbyCafeView};
|
pub use cafes::{CafeOptionView, CafeView, NearbyCafeView};
|
||||||
pub use cups::CupView;
|
pub use cups::{CupDetailView, CupView};
|
||||||
pub use gear::{GearOptionView, GearView};
|
pub use gear::{GearOptionView, GearView};
|
||||||
pub use roasters::{RoasterOptionView, RoasterView};
|
pub use roasters::{RoasterOptionView, RoasterView};
|
||||||
pub use roasts::{RoastOptionView, RoastView};
|
pub use roasts::{RoastOptionView, RoastView};
|
||||||
|
|
@ -29,6 +29,17 @@ pub struct StatsView {
|
||||||
pub bags: u64,
|
pub bags: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl StatsView {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.brews == 0
|
||||||
|
&& self.roasts == 0
|
||||||
|
&& self.roasters == 0
|
||||||
|
&& self.cups == 0
|
||||||
|
&& self.cafes == 0
|
||||||
|
&& self.bags == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct StatCard {
|
pub struct StatCard {
|
||||||
pub icon: &'static str,
|
pub icon: &'static str,
|
||||||
pub value: String,
|
pub value: String,
|
||||||
|
|
@ -349,3 +360,37 @@ fn page_size_from_text(value: &str) -> PageSize {
|
||||||
PageSize::limited(DEFAULT_PAGE_SIZE)
|
PageSize::limited(DEFAULT_PAGE_SIZE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build `data-countries` and `data-max` values for the world-map component.
|
||||||
|
///
|
||||||
|
/// Accepts `(country_name, weight)` pairs where higher weights render darker.
|
||||||
|
/// Resolves country names to ISO codes, deduplicates by keeping the highest weight
|
||||||
|
/// per ISO code, and returns the attribute string and max value.
|
||||||
|
pub(crate) fn build_map_data(entries: &[(&str, u32)]) -> (String, u32) {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::domain::countries::country_to_iso;
|
||||||
|
|
||||||
|
let mut iso_weights: HashMap<&str, u32> = HashMap::new();
|
||||||
|
|
||||||
|
for &(country_name, weight) in entries {
|
||||||
|
if country_name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(iso) = country_to_iso(country_name) {
|
||||||
|
let entry = iso_weights.entry(iso).or_insert(0);
|
||||||
|
*entry = (*entry).max(weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut max = 0u32;
|
||||||
|
let parts: Vec<String> = iso_weights
|
||||||
|
.iter()
|
||||||
|
.map(|(iso, &w)| {
|
||||||
|
max = max.max(w);
|
||||||
|
format!("{iso}:{w}")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(parts.join(","), max)
|
||||||
|
}
|
||||||
|
|
|
||||||
192
templates/pages/brew.html
Normal file
192
templates/pages/brew.html
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "partials/icons.html" as icons %}
|
||||||
|
{% block title %}Brewlog · {{ brew.roast_name }}{% endblock %}
|
||||||
|
{% block description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<header class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h1 class="text-3xl font-semibold">{{ brew.roast_name }}</h1>
|
||||||
|
<p class="text-sm text-text-secondary">
|
||||||
|
{{ brew.roaster_name }} · Brewed {{ brew.created_date }} at {{ brew.created_time }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="shareLink(this)" class="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium text-text-muted transition hover:bg-surface-alt hover:text-text shrink-0">
|
||||||
|
<span class="share-icon">{{ icons::clipboard("h-4 w-4 shrink-0") }}</span>
|
||||||
|
<span class="share-icon-check hidden">{{ icons::check("h-4 w-4 shrink-0") }}</span>
|
||||||
|
<span class="share-label">Share</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const shareLink = (btn) => {
|
||||||
|
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||||
|
btn.querySelector('.share-icon').classList.add('hidden');
|
||||||
|
btn.querySelector('.share-icon-check').classList.remove('hidden');
|
||||||
|
btn.querySelector('.share-label').textContent = 'Copied';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.querySelector('.share-icon').classList.remove('hidden');
|
||||||
|
btn.querySelector('.share-icon-check').classList.add('hidden');
|
||||||
|
btn.querySelector('.share-label').textContent = 'Share';
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{# ── Coffee + map ── #}
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Roast</dt>
|
||||||
|
<dd class="font-medium text-text">{{ brew.roast_name }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Roaster</dt>
|
||||||
|
<dd class="font-medium text-text">{{ brew.roaster_name }}</dd>
|
||||||
|
</div>
|
||||||
|
{% if brew.origin != "\u{2014}" %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Origin</dt>
|
||||||
|
<dd class="font-medium text-text">
|
||||||
|
{% if !brew.origin_flag.is_empty() %}{{ brew.origin_flag }} {% endif %}{{ brew.origin }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if brew.region != "\u{2014}" %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Region</dt>
|
||||||
|
<dd class="font-medium text-text">{{ brew.region }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if brew.producer != "\u{2014}" %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Producer</dt>
|
||||||
|
<dd class="font-medium text-text">{{ brew.producer }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if brew.process != "\u{2014}" %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Process</dt>
|
||||||
|
<dd class="font-medium text-text">{{ brew.process }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
{% if !brew.tasting_notes.is_empty() %}
|
||||||
|
<div class="mt-4 flex flex-wrap gap-1.5">
|
||||||
|
{% for note in brew.tasting_notes %}
|
||||||
|
<span class="{{ note.pill_class }}">{{ note.label }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if !brew.map_countries.is_empty() %}
|
||||||
|
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center">
|
||||||
|
<world-map
|
||||||
|
class="block w-full"
|
||||||
|
data-countries="{{ brew.map_countries }}"
|
||||||
|
data-max="{{ brew.map_max }}"
|
||||||
|
></world-map>
|
||||||
|
<div class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted">
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent"></span> Origin
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent opacity-50"></span> Roaster
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Roaster & gear ── #}
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Name</dt>
|
||||||
|
<dd class="font-medium text-text">{{ brew.roaster_name }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Country</dt>
|
||||||
|
<dd class="font-medium text-text">
|
||||||
|
{% if !brew.roaster_country_flag.is_empty() %}{{ brew.roaster_country_flag }} {% endif %}{{ brew.roaster_country }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% if let Some(city) = brew.roaster_city %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">City</dt>
|
||||||
|
<dd class="font-medium text-text">{{ city }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if let Some(url) = brew.roaster_homepage %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Website</dt>
|
||||||
|
<dd class="font-medium">
|
||||||
|
<a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover transition">Visit Website</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
|
<h2 class="text-lg font-semibold text-text mb-4">Gear</h2>
|
||||||
|
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Grinder</dt>
|
||||||
|
<dd class="font-medium text-text">{{ brew.grinder_name }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Brewer</dt>
|
||||||
|
<dd class="font-medium text-text">{{ brew.brewer_name }}</dd>
|
||||||
|
</div>
|
||||||
|
{% if let Some(fp) = brew.filter_paper_name %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Filter Paper</dt>
|
||||||
|
<dd class="font-medium text-text">{{ fp }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Recipe ── #}
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
|
<h2 class="text-lg font-semibold text-text mb-4">Recipe</h2>
|
||||||
|
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Coffee</dt>
|
||||||
|
<dd class="font-medium text-text">{{ brew.coffee_weight }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Water</dt>
|
||||||
|
<dd class="font-medium text-text">{{ brew.water_volume }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Temperature</dt>
|
||||||
|
<dd class="font-medium text-text">{{ brew.water_temp }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Grind Setting</dt>
|
||||||
|
<dd class="font-medium text-text">{{ brew.grind_setting }}</dd>
|
||||||
|
</div>
|
||||||
|
{% if let Some(time) = brew.brew_time %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Brew Time</dt>
|
||||||
|
<dd class="font-medium text-text">{{ time }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if !brew.quick_notes_label.is_empty() %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Notes</dt>
|
||||||
|
<dd class="font-medium text-text">{{ brew.quick_notes_label }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
166
templates/pages/cup.html
Normal file
166
templates/pages/cup.html
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "partials/icons.html" as icons %}
|
||||||
|
{% block title %}Brewlog · {{ cup.roast_name }} at {{ cup.cafe_name }}{% endblock %}
|
||||||
|
{% block description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<header class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h1 class="text-3xl font-semibold">{{ cup.roast_name }}</h1>
|
||||||
|
<p class="text-sm text-text-secondary">
|
||||||
|
{{ cup.roaster_name }} · {{ cup.cafe_name }}, {{ cup.cafe_city }} · {{ cup.created_date }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="shareLink(this)" class="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium text-text-muted transition hover:bg-surface-alt hover:text-text shrink-0">
|
||||||
|
<span class="share-icon">{{ icons::clipboard("h-4 w-4 shrink-0") }}</span>
|
||||||
|
<span class="share-icon-check hidden">{{ icons::check("h-4 w-4 shrink-0") }}</span>
|
||||||
|
<span class="share-label">Share</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const shareLink = (btn) => {
|
||||||
|
navigator.clipboard.writeText(window.location.href).then(() => {
|
||||||
|
btn.querySelector('.share-icon').classList.add('hidden');
|
||||||
|
btn.querySelector('.share-icon-check').classList.remove('hidden');
|
||||||
|
btn.querySelector('.share-label').textContent = 'Copied';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.querySelector('.share-icon').classList.remove('hidden');
|
||||||
|
btn.querySelector('.share-icon-check').classList.add('hidden');
|
||||||
|
btn.querySelector('.share-label').textContent = 'Share';
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{# ── Coffee + map ── #}
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Roast</dt>
|
||||||
|
<dd class="font-medium text-text">{{ cup.roast_name }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Roaster</dt>
|
||||||
|
<dd class="font-medium text-text">{{ cup.roaster_name }}</dd>
|
||||||
|
</div>
|
||||||
|
{% if cup.origin != "\u{2014}" %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Origin</dt>
|
||||||
|
<dd class="font-medium text-text">
|
||||||
|
{% if !cup.origin_flag.is_empty() %}{{ cup.origin_flag }} {% endif %}{{ cup.origin }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if cup.region != "\u{2014}" %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Region</dt>
|
||||||
|
<dd class="font-medium text-text">{{ cup.region }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if cup.producer != "\u{2014}" %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Producer</dt>
|
||||||
|
<dd class="font-medium text-text">{{ cup.producer }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if cup.process != "\u{2014}" %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Process</dt>
|
||||||
|
<dd class="font-medium text-text">{{ cup.process }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
{% if !cup.tasting_notes.is_empty() %}
|
||||||
|
<div class="mt-4 flex flex-wrap gap-1.5">
|
||||||
|
{% for note in cup.tasting_notes %}
|
||||||
|
<span class="{{ note.pill_class }}">{{ note.label }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if !cup.map_countries.is_empty() %}
|
||||||
|
<div class="relative rounded-lg border bg-surface overflow-hidden flex items-center">
|
||||||
|
<world-map
|
||||||
|
class="block w-full"
|
||||||
|
data-countries="{{ cup.map_countries }}"
|
||||||
|
data-max="{{ cup.map_max }}"
|
||||||
|
></world-map>
|
||||||
|
<div class="absolute top-2 right-2 flex flex-col gap-1 text-2xs text-text-muted">
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent"></span> Cafe
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent opacity-65"></span> Origin
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<span class="inline-block h-2.5 w-2.5 rounded-sm bg-accent opacity-35"></span> Roaster
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Roaster & cafe ── #}
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Name</dt>
|
||||||
|
<dd class="font-medium text-text">{{ cup.roaster_name }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Country</dt>
|
||||||
|
<dd class="font-medium text-text">
|
||||||
|
{% if !cup.roaster_country_flag.is_empty() %}{{ cup.roaster_country_flag }} {% endif %}{{ cup.roaster_country }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% if let Some(city) = cup.roaster_city %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">City</dt>
|
||||||
|
<dd class="font-medium text-text">{{ city }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if let Some(url) = cup.roaster_homepage %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Website</dt>
|
||||||
|
<dd class="font-medium">
|
||||||
|
<a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover transition">Visit Website</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border bg-surface p-5">
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Name</dt>
|
||||||
|
<dd class="font-medium text-text">{{ cup.cafe_name }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">City</dt>
|
||||||
|
<dd class="font-medium text-text">{{ cup.cafe_city }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Country</dt>
|
||||||
|
<dd class="font-medium text-text">
|
||||||
|
{% if !cup.cafe_country_flag.is_empty() %}{{ cup.cafe_country_flag }} {% endif %}{{ cup.cafe_country }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% if let Some(url) = cup.cafe_website %}
|
||||||
|
<div>
|
||||||
|
<dt class="text-text-muted">Website</dt>
|
||||||
|
<dd class="font-medium">
|
||||||
|
<a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover transition">Visit Website</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in a new issue