diff --git a/CLAUDE.md b/CLAUDE.md index 3b518a9..a51ea40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,19 @@ When adding new pragmas, add them after the existing ones in `Database::connect( The middleware stack in `application/routes/mod.rs` applies layers in this order (outermost first): request tracing, cookie parsing, body size limit, security headers (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `CSP`, `HSTS`), and gzip compression. When adding new middleware, place it in the `ServiceBuilder` chain at the appropriate position. +### Open Graph Meta Tags + +Social media preview cards are powered by Open Graph and Twitter Card meta tags in `templates/base.html`. The base template defines default `og:title`, `og:description`, `og:type`, `og:site_name`, and `twitter:card` tags using Askama blocks (`{% block og_title %}`, `{% block og_description %}`). + +**Base URL** — `BREWLOG_RP_ORIGIN` is stored at startup via `set_base_url()` in `lib.rs` (a `OnceLock`). Templates access it through the `base_url` field on their template struct, set with `crate::base_url()` in the handler. + +**`og:image`** — a static 1200x630 PNG (`static/og-image.png`) served at `/og-image.png` via `include_bytes!()`. Only public shareable pages (home, brew detail, cup detail, stats) include it via `{% block head %}`, since those are the pages social crawlers can access. + +**Adding OG tags to a new page:** +1. Add `pub base_url: &'static str` to the template struct +2. Set `base_url: crate::base_url()` in the handler +3. Override `{% block og_title %}`, `{% block og_description %}`, and add `` in `{% block head %}` + ### Repository Pattern All data access goes through trait-based repositories defined in `domain/repositories.rs`. SQL implementations live in `infrastructure/repositories/`, each using a private `Record` struct with a `to_domain()` method to convert database rows to domain entities. Use typed ID wrappers from `domain/ids.rs` (e.g., `RoastId`, `BagId`) — never raw `i64`. diff --git a/src/application/routes/app/brews.rs b/src/application/routes/app/brews.rs index f69f18b..8d11ba7 100644 --- a/src/application/routes/app/brews.rs +++ b/src/application/routes/app/brews.rs @@ -48,6 +48,7 @@ pub(crate) async fn brew_detail_page( nav_active: "", is_authenticated, version_info: &crate::VERSION_INFO, + base_url: crate::base_url(), brew: view, }; diff --git a/src/application/routes/app/cups.rs b/src/application/routes/app/cups.rs index 7335831..e0a514e 100644 --- a/src/application/routes/app/cups.rs +++ b/src/application/routes/app/cups.rs @@ -53,6 +53,7 @@ pub(crate) async fn cup_detail_page( nav_active: "", is_authenticated, version_info: &crate::VERSION_INFO, + base_url: crate::base_url(), cup: view, }; diff --git a/src/application/routes/app/home.rs b/src/application/routes/app/home.rs index 3108ca8..8e0751f 100644 --- a/src/application/routes/app/home.rs +++ b/src/application/routes/app/home.rs @@ -43,6 +43,7 @@ pub(crate) async fn home_page( nav_active: "home", is_authenticated, version_info: &crate::VERSION_INFO, + base_url: crate::base_url(), recent_brews: content.recent_brews, open_bags: content.open_bags, recent_events: content.recent_events, diff --git a/src/application/routes/app/mod.rs b/src/application/routes/app/mod.rs index 8ed907a..bad1adb 100644 --- a/src/application/routes/app/mod.rs +++ b/src/application/routes/app/mod.rs @@ -43,6 +43,7 @@ pub(super) fn router() -> axum::Router { .route("/components/donut-chart.js", get(donut_chart_js)) .route("/favicon-light.svg", get(favicon_light)) .route("/favicon-dark.svg", get(favicon_dark)) + .route("/og-image.png", get(og_image)) .route("/health", get(health)) } @@ -140,6 +141,16 @@ async fn favicon_dark() -> impl IntoResponse { ) } +async fn og_image() -> impl IntoResponse { + ( + [ + ("content-type", "image/png"), + ("cache-control", "public, max-age=604800"), + ], + include_bytes!("../../../../static/og-image.png").as_slice(), + ) +} + async fn health() -> impl IntoResponse { ([("content-type", "application/json")], r#"{"status":"ok"}"#) } diff --git a/src/application/routes/app/stats.rs b/src/application/routes/app/stats.rs index 2f53af3..ace6526 100644 --- a/src/application/routes/app/stats.rs +++ b/src/application/routes/app/stats.rs @@ -125,6 +125,7 @@ pub(crate) async fn stats_page( nav_active: "stats", is_authenticated, version_info: &crate::VERSION_INFO, + base_url: crate::base_url(), active_type: entity_type, tabs, tab_signal: "_active-tab", diff --git a/src/lib.rs b/src/lib.rs index 6ac662d..e27a147 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +use std::sync::OnceLock; + pub mod application; pub mod domain; pub mod infrastructure; @@ -12,3 +14,13 @@ pub const VERSION_INFO: VersionInfo = VersionInfo { version: env!("CARGO_PKG_VERSION"), commit: env!("GIT_HASH"), }; + +static BASE_URL: OnceLock = OnceLock::new(); + +pub fn set_base_url(url: String) { + let _ = BASE_URL.set(url); +} + +pub fn base_url() -> &'static str { + BASE_URL.get().map_or("", std::string::String::as_str) +} diff --git a/src/main.rs b/src/main.rs index 64bcc8b..fab38e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -98,6 +98,8 @@ async fn run_server(command: ServeCommand) -> Result<()> { ) })?; + brewlog::set_base_url(rp_origin.clone()); + let config = ServerConfig { bind_address: command.bind_address, database_url: command.database_url, diff --git a/src/presentation/web/templates.rs b/src/presentation/web/templates.rs index 72bc0fb..da5d3be 100644 --- a/src/presentation/web/templates.rs +++ b/src/presentation/web/templates.rs @@ -105,6 +105,7 @@ pub struct HomeTemplate { pub nav_active: &'static str, pub is_authenticated: bool, pub version_info: &'static crate::VersionInfo, + pub base_url: &'static str, pub recent_brews: Vec, pub open_bags: Vec, @@ -183,6 +184,7 @@ pub struct StatsPageTemplate { pub nav_active: &'static str, pub is_authenticated: bool, pub version_info: &'static crate::VersionInfo, + pub base_url: &'static str, pub active_type: String, pub tabs: Vec, pub tab_signal: &'static str, @@ -214,6 +216,7 @@ pub struct BrewDetailTemplate { pub nav_active: &'static str, pub is_authenticated: bool, pub version_info: &'static crate::VersionInfo, + pub base_url: &'static str, pub brew: BrewDetailView, } @@ -223,6 +226,7 @@ pub struct CupDetailTemplate { pub nav_active: &'static str, pub is_authenticated: bool, pub version_info: &'static crate::VersionInfo, + pub base_url: &'static str, pub cup: CupDetailView, } diff --git a/static/og-image.png b/static/og-image.png new file mode 100644 index 0000000..f3f1b9f Binary files /dev/null and b/static/og-image.png differ diff --git a/templates/base.html b/templates/base.html index 4d4d899..7923232 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,6 +7,11 @@ name="description" content="{% block description %}Self-hosted coffee logging — track roasters, roasts, bags, brews, and gear.{% endblock %}" /> + + + + + {% block title %}Brewlog{% endblock %} diff --git a/templates/pages/brew.html b/templates/pages/brew.html index aefc0b6..84504c9 100644 --- a/templates/pages/brew.html +++ b/templates/pages/brew.html @@ -2,6 +2,9 @@ {% 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 og_title %}{{ brew.roast_name }} — Brewlog{% endblock %} +{% block og_description %}{{ brew.roast_name }} by {{ brew.roaster_name }} — {{ brew.coffee_weight }} coffee, {{ brew.water_volume }} water.{% endblock %} +{% block head %}{% endblock %} {% block content %}
diff --git a/templates/pages/cup.html b/templates/pages/cup.html index 9ee49f8..b147a03 100644 --- a/templates/pages/cup.html +++ b/templates/pages/cup.html @@ -2,6 +2,9 @@ {% 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 og_title %}{{ cup.roast_name }} at {{ cup.cafe_name }} — Brewlog{% endblock %} +{% block og_description %}{{ cup.roast_name }} by {{ cup.roaster_name }} at {{ cup.cafe_name }}, {{ cup.cafe_city }}.{% endblock %} +{% block head %}{% endblock %} {% block content %}
diff --git a/templates/pages/home.html b/templates/pages/home.html index 8c28881..ef51c67 100644 --- a/templates/pages/home.html +++ b/templates/pages/home.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import -"partials/brew_card.html" as brew_card %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog{% endblock %} {% block head %} {% if -is_authenticated %} +"partials/brew_card.html" as brew_card %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog{% endblock %} {% block head %} + +{% if is_authenticated %}