feat(web): add Open Graph and Twitter Card meta tags
- Add og:title, og:description, og:image, og:type, og:site_name and twitter:card meta tags to base template with Askama block overrides - Create static 1200x630 OG image served at /og-image.png - Override OG blocks with entity-specific content on brew, cup, and stats detail pages - Reuse BREWLOG_RP_ORIGIN as base URL for absolute og:image URLs via OnceLock initialized at server startup
This commit is contained in:
parent
f5e319ce03
commit
9e5dff7f5f
15 changed files with 63 additions and 2 deletions
13
CLAUDE.md
13
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<String>`). 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 `<meta property="og:image" content="{{ base_url }}/og-image.png" />` 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`.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ pub(super) fn router() -> axum::Router<AppState> {
|
|||
.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"}"#)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
12
src/lib.rs
12
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<String> = 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<BrewView>,
|
||||
pub open_bags: Vec<BagView>,
|
||||
|
|
@ -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<Tab>,
|
||||
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,
|
||||
}
|
||||
|
||||
|
|
|
|||
BIN
static/og-image.png
Normal file
BIN
static/og-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
|
|
@ -7,6 +7,11 @@
|
|||
name="description"
|
||||
content="{% block description %}Self-hosted coffee logging — track roasters, roasts, bags, brews, and gear.{% endblock %}"
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Brewlog" />
|
||||
<meta property="og:title" content="{% block og_title %}Brewlog{% endblock %}" />
|
||||
<meta property="og:description" content="{% block og_description %}Self-hosted coffee logging — track roasters, roasts, bags, brews, and gear.{% endblock %}" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<title>{% block title %}Brewlog{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<link rel="icon" id="favicon" type="image/svg+xml" href="/favicon-light.svg" />
|
||||
|
|
|
|||
|
|
@ -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 %}<meta property="og:image" content="{{ base_url }}/og-image.png" />{% endblock %}
|
||||
{% block content %}
|
||||
<header class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -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 %}<meta property="og:image" content="{{ base_url }}/og-image.png" />{% endblock %}
|
||||
{% block content %}
|
||||
<header class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
<meta property="og:image" content="{{ base_url }}/og-image.png" />
|
||||
{% if is_authenticated %}
|
||||
<script>
|
||||
const closeBag = async (bagId, cardEl) => {
|
||||
if (!confirm("Close this bag? This will mark it as finished.")) return
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import "partials/histogram.html" as histogram %} {% block title %}Brewlog · Stats{% endblock %}
|
||||
{% block og_title %}Stats — Brewlog{% endblock %}
|
||||
{% block og_description %}Aggregated coffee data across origins, consumption, and brewing.{% endblock %}
|
||||
{% block head %}<meta property="og:image" content="{{ base_url }}/og-image.png" />{% endblock %}
|
||||
{% block content %}
|
||||
<header class="flex flex-col gap-2">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
|
|
|
|||
Loading…
Reference in a new issue