refactor: fix template review findings (security, macros, tokens)

Address findings from the templates code review:

- Fix XSS in admin.html onclick handlers via data attributes
- Fix XSS in 5 edit page signal initializations via JSON serialization
- Fix register.html token exposure by moving to data attribute
- Add entity_icon, quick_notes_toggles, add_form_submit macros
- Replace hardcoded colors with design tokens (warning, error, success)
- Add warning design tokens to CSS theme
- Scope MutationObserver to main element
- Add defer to webauthn.js script tags
- Refactor login/register JS to arrow functions
- Guard lightbox script behind image_url check
- Fix else-if to elif in 5 templates
This commit is contained in:
Jon Seager 2026-02-13 16:17:56 +00:00
parent ab4bcbaa0a
commit 920931ba17
No known key found for this signature in database
33 changed files with 354 additions and 358 deletions

View file

@ -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_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 { let template = BagEditTemplate {
nav_active: "", nav_active: "",
is_authenticated: true, is_authenticated: true,
@ -87,14 +103,11 @@ pub(crate) async fn bag_edit_page(
id: bag.bag.id.to_string(), id: bag.bag.id.to_string(),
roast_id: bag.bag.roast_id.to_string(), roast_id: bag.bag.roast_id.to_string(),
roast_label: format!("{} ({})", bag.roast_name, bag.roaster_name), roast_label: format!("{} ({})", bag.roast_name, bag.roaster_name),
roast_date: bag roast_date,
.bag
.roast_date
.map(|d| d.to_string())
.unwrap_or_default(),
amount: bag.bag.amount, amount: bag.bag.amount,
remaining: bag.bag.remaining, remaining: bag.bag.remaining,
roast_options, roast_options,
signals_json,
}; };
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)

View file

@ -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 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 { let template = CafeEditTemplate {
nav_active: "", nav_active: "",
is_authenticated: true, is_authenticated: true,
version_info: &crate::VERSION_INFO, version_info: &crate::VERSION_INFO,
id: cafe.id.to_string(), id: cafe.id.to_string(),
name: cafe.name, name,
city: cafe.city, city,
country: cafe.country, country,
latitude: cafe.latitude, latitude: cafe.latitude,
longitude: cafe.longitude, longitude: cafe.longitude,
website: cafe.website.unwrap_or_default(), website,
image_url, image_url,
signals_json,
}; };
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)

View file

@ -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 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 { let template = GearEditTemplate {
nav_active: "", nav_active: "",
is_authenticated: true, is_authenticated: true,
version_info: &crate::VERSION_INFO, version_info: &crate::VERSION_INFO,
id: gear.id.to_string(), id: gear.id.to_string(),
category: gear.category.display_label().to_string(), category: gear.category.display_label().to_string(),
make: gear.make, make,
model: gear.model, model,
image_url, image_url,
signals_json,
}; };
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)

View file

@ -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 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 { let template = RoasterEditTemplate {
nav_active: "", nav_active: "",
is_authenticated: true, is_authenticated: true,
version_info: &crate::VERSION_INFO, version_info: &crate::VERSION_INFO,
id: roaster.id.to_string(), id: roaster.id.to_string(),
name: roaster.name, name,
country: roaster.country, country,
city: roaster.city.unwrap_or_default(), city,
homepage: roaster.homepage.unwrap_or_default(), homepage,
image_url, image_url,
signals_json,
}; };
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)

View file

@ -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 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 { let template = RoastEditTemplate {
nav_active: "", nav_active: "",
is_authenticated: true, is_authenticated: true,
@ -82,14 +102,15 @@ pub(crate) async fn roast_edit_page(
id: roast.id.to_string(), id: roast.id.to_string(),
roaster_id: roast.roaster_id.to_string(), roaster_id: roast.roaster_id.to_string(),
roaster_name: roaster.name, roaster_name: roaster.name,
name: roast.name, name,
origin: roast.origin.unwrap_or_default(), origin,
region: roast.region.unwrap_or_default(), region,
producer: roast.producer.unwrap_or_default(), producer,
process: roast.process.unwrap_or_default(), process,
tasting_notes: roast.tasting_notes.join(", "), tasting_notes,
roaster_options, roaster_options,
image_url, image_url,
signals_json,
}; };
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)

View file

@ -317,6 +317,7 @@ pub struct RoasterEditTemplate {
pub city: String, pub city: String,
pub homepage: String, pub homepage: String,
pub image_url: Option<String>, pub image_url: Option<String>,
pub signals_json: String,
} }
#[derive(Template)] #[derive(Template)]
@ -336,6 +337,7 @@ pub struct RoastEditTemplate {
pub tasting_notes: String, pub tasting_notes: String,
pub roaster_options: Vec<RoasterOptionView>, pub roaster_options: Vec<RoasterOptionView>,
pub image_url: Option<String>, pub image_url: Option<String>,
pub signals_json: String,
} }
#[derive(Template)] #[derive(Template)]
@ -351,6 +353,7 @@ pub struct BagEditTemplate {
pub amount: f64, pub amount: f64,
pub remaining: f64, pub remaining: f64,
pub roast_options: Vec<RoastOptionView>, pub roast_options: Vec<RoastOptionView>,
pub signals_json: String,
} }
#[derive(Template)] #[derive(Template)]
@ -393,6 +396,7 @@ pub struct CafeEditTemplate {
pub longitude: f64, pub longitude: f64,
pub website: String, pub website: String,
pub image_url: Option<String>, pub image_url: Option<String>,
pub signals_json: String,
} }
#[derive(Template)] #[derive(Template)]
@ -422,6 +426,7 @@ pub struct GearEditTemplate {
pub make: String, pub make: String,
pub model: String, pub model: String,
pub image_url: Option<String>, pub image_url: Option<String>,
pub signals_json: String,
} }
#[derive(Template)] #[derive(Template)]

View file

@ -511,6 +511,36 @@ pub(crate) fn build_map_data(entries: &[(&str, u32)]) -> (String, u32) {
(parts.join(","), max) (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 `&quot;`, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -43,6 +43,10 @@
--success-bg: #ecfdf5; /* green-50 */ --success-bg: #ecfdf5; /* green-50 */
--success-border: rgba(5, 150, 105, 0.3); --success-border: rgba(5, 150, 105, 0.3);
--success-text: #065f46; /* green-800 */ --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"] { [data-theme="dark"] {
@ -74,6 +78,10 @@
--success-bg: rgba(5, 150, 105, 0.1); --success-bg: rgba(5, 150, 105, 0.1);
--success-border: rgba(5, 150, 105, 0.4); --success-border: rgba(5, 150, 105, 0.4);
--success-text: #6ee7b7; --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 ─────────────────── */ /* ── Theme: map raw vars to Tailwind utilities ─────────────────── */
@ -99,6 +107,10 @@
--color-success-bg: var(--success-bg); --color-success-bg: var(--success-bg);
--color-success-border: var(--success-border); --color-success-border: var(--success-border);
--color-success-text: var(--success-text); --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 ────────────────────────────────── */ /* ── Base: default border color ────────────────────────────────── */

View file

@ -179,10 +179,13 @@
setupInfiniteScroll(); setupInfiniteScroll();
const bodyObserver = new MutationObserver(() => { const mainEl = document.querySelector("main");
setupInfiniteScroll(); if (mainEl) {
}); const mainObserver = new MutationObserver(() => {
bodyObserver.observe(document.body, { childList: true, subtree: true }); setupInfiniteScroll();
});
mainObserver.observe(mainEl, { childList: true, subtree: true });
}
}); });
</script> </script>
</head> </head>

View file

@ -1,6 +1,8 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% import "partials/image_section.html" as img %} {% import "partials/image_section.html" as img %}
{% import "partials/location_search.html" as location %} {% 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 title %}Brewlog · Add{% endblock %}
{% block content %} {% block content %}
@ -190,21 +192,7 @@
</label> </label>
</div> </div>
{{ img::deferred_upload("roaster-image", "Add image (optional)") }} {{ img::deferred_upload("roaster-image", "Add image (optional)") }}
<div class="sticky-submit flex flex-col gap-2"> {{ detail_cards::add_form_submit("plus", "Save Roaster") }}
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::plus("h-4 w-4") }} Save Roaster
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
</form> </form>
</div> </div>
</div> </div>
@ -410,21 +398,7 @@
</label> </label>
</div> </div>
{{ img::deferred_upload("roast-image", "Add image (optional)") }} {{ img::deferred_upload("roast-image", "Add image (optional)") }}
<div class="sticky-submit flex flex-col gap-2"> {{ detail_cards::add_form_submit("plus", "Save Roast") }}
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::plus("h-4 w-4") }} Save Roast
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
</form> </form>
</div> </div>
{% endif %} {% endif %}
@ -512,21 +486,7 @@
/> />
</label> </label>
</div> </div>
<div class="sticky-submit flex flex-col gap-2"> {{ detail_cards::add_form_submit("plus", "Save Bag") }}
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::plus("h-4 w-4") }} Save Bag
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
</form> </form>
{% endif %} {% endif %}
</div> </div>
@ -552,7 +512,7 @@
to enable this form. to enable this form.
</p> </p>
</div> </div>
{% else if grinder_options.is_empty() || brewer_options.is_empty() %} {% elif grinder_options.is_empty() || brewer_options.is_empty() %}
<div class="text-sm text-text-secondary"> <div class="text-sm text-text-secondary">
<h3 class="text-lg font-semibold text-text">Add gear first</h3> <h3 class="text-lg font-semibold text-text">Add gear first</h3>
<p class="mt-2"> <p class="mt-2">
@ -915,74 +875,9 @@
</div> </div>
</div> </div>
<!-- Quick Notes --> <!-- Quick Notes -->
<div> {{ quick_notes::quick_notes_toggles() }}
<h4 class="text-sm font-semibold text-text mb-3">Quick Notes</h4>
<div class="flex flex-wrap gap-2">
<button
type="button"
data-on:click="$_qnGood = !$_qnGood"
data-attr:class="$_qnGood ? 'pill pill-success cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Good
</button>
<button
type="button"
data-on:click="$_qnTooFast = !$_qnTooFast"
data-attr:class="$_qnTooFast ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Fast
</button>
<button
type="button"
data-on:click="$_qnTooSlow = !$_qnTooSlow"
data-attr:class="$_qnTooSlow ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Slow
</button>
<button
type="button"
data-on:click="$_qnTooHot = !$_qnTooHot"
data-attr:class="$_qnTooHot ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Hot
</button>
<button
type="button"
data-on:click="$_qnUnderExtracted = !$_qnUnderExtracted"
data-attr:class="$_qnUnderExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Under Extracted
</button>
<button
type="button"
data-on:click="$_qnOverExtracted = !$_qnOverExtracted"
data-attr:class="$_qnOverExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Over Extracted
</button>
</div>
<input
type="hidden"
name="quick_notes"
data-attr:value="[$_qnGood && 'good', $_qnTooFast && 'too-fast', $_qnTooSlow && 'too-slow', $_qnTooHot && 'too-hot', $_qnUnderExtracted && 'under-extracted', $_qnOverExtracted && 'over-extracted'].filter(Boolean).join(',')"
/>
</div>
{{ img::deferred_upload("brew-image", "Add image (optional)") }} {{ img::deferred_upload("brew-image", "Add image (optional)") }}
<div class="sticky-submit flex flex-col gap-2"> {{ detail_cards::add_form_submit("beaker", "Save Brew") }}
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::beaker("h-4 w-4") }} Save Brew
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
</form> </form>
{% endif %} {% endif %}
</div> </div>
@ -1051,21 +946,7 @@
</label> </label>
</div> </div>
{{ img::deferred_upload("gear-image", "Add image (optional)") }} {{ img::deferred_upload("gear-image", "Add image (optional)") }}
<div class="sticky-submit flex flex-col gap-2"> {{ detail_cards::add_form_submit("plus", "Save Gear") }}
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::plus("h-4 w-4") }} Save Gear
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
</form> </form>
</div> </div>
@ -1223,21 +1104,7 @@
</label> </label>
</div> </div>
{{ img::deferred_upload("cafe-image", "Add image (optional)") }} {{ img::deferred_upload("cafe-image", "Add image (optional)") }}
<div class="sticky-submit flex flex-col gap-2"> {{ detail_cards::add_form_submit("plus", "Save Cafe") }}
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::plus("h-4 w-4") }} Save Cafe
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
</form> </form>
</div> </div>
@ -1339,21 +1206,7 @@
</searchable-select> </searchable-select>
</div> </div>
</div> </div>
<div class="sticky-submit flex flex-col gap-2"> {{ detail_cards::add_form_submit("plus", "Save Cup") }}
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{{ icons::plus("h-4 w-4") }} Save Cup
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
</form> </form>
{% endif %} {% endif %}
</div> </div>

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Admin{% endblock %} {% block title %}Brewlog · Admin{% endblock %}
{% block head %} {% block head %}
<script src="/static/js/webauthn.js"></script> <script defer src="/static/js/webauthn.js"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<header class="flex flex-col gap-2"> <header class="flex flex-col gap-2">
@ -49,7 +49,9 @@
<button <button
type="button" type="button"
class="shrink-0 inline-flex items-center justify-center rounded-md border text-accent transition hover:text-text hover:bg-surface-alt h-8 w-8 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm sm:font-medium" class="shrink-0 inline-flex items-center justify-center rounded-md border text-accent transition hover:text-text hover:bg-surface-alt h-8 w-8 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm sm:font-medium"
onclick="deletePasskey({{ passkey.id }}, '{{ passkey.name }}')" data-id="{{ passkey.id }}"
data-name="{{ passkey.name }}"
onclick="deletePasskey(this.dataset.id, this.dataset.name)"
aria-label="Delete passkey" aria-label="Delete passkey"
> >
{{ icons::delete("h-4 w-4") }} {{ icons::delete("h-4 w-4") }}
@ -175,7 +177,9 @@
<button <button
type="button" type="button"
class="shrink-0 inline-flex items-center justify-center rounded-md border text-accent transition hover:text-text hover:bg-surface-alt h-8 w-8 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm sm:font-medium" class="shrink-0 inline-flex items-center justify-center rounded-md border text-accent transition hover:text-text hover:bg-surface-alt h-8 w-8 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm sm:font-medium"
onclick="revokeToken({{ token.id }}, '{{ token.name }}')" data-id="{{ token.id }}"
data-name="{{ token.name }}"
onclick="revokeToken(this.dataset.id, this.dataset.name)"
aria-label="Revoke token" aria-label="Revoke token"
> >
{{ icons::delete("h-4 w-4") }} {{ icons::delete("h-4 w-4") }}

View file

@ -28,7 +28,9 @@
</div> </div>
</div> </div>
</header> </header>
{{ img::lightbox_script() }} {% if image_url.is_some() %}
{{ img::lightbox_script() }}
{% endif %}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">

View file

@ -5,14 +5,14 @@
<div class="rounded-lg border bg-surface p-6"> <div class="rounded-lg border bg-surface p-6">
{% if token.is_some() %} {% if token.is_some() %}
<div class="flex flex-col items-center text-center gap-3"> <div class="flex flex-col items-center text-center gap-3">
{{ icons::check_circle("h-10 w-10 text-emerald-600 dark:text-emerald-400") }} {{ icons::check_circle("h-10 w-10 text-success") }}
<h1 class="text-2xl font-semibold text-accent">CLI Authenticated</h1> <h1 class="text-2xl font-semibold text-accent">CLI Authenticated</h1>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
Your CLI has been authenticated successfully. You can close this Your CLI has been authenticated successfully. You can close this
window and return to the terminal. window and return to the terminal.
</p> </p>
</div> </div>
{% else if error.is_some() %} {% elif error.is_some() %}
<div class="flex flex-col items-center text-center gap-3"> <div class="flex flex-col items-center text-center gap-3">
{{ icons::x_circle("h-10 w-10 text-error") }} {{ icons::x_circle("h-10 w-10 text-error") }}
<h1 class="text-2xl font-semibold text-accent"> <h1 class="text-2xl font-semibold text-accent">

View file

@ -12,11 +12,7 @@
<section class="rounded-lg border bg-surface p-5"> <section class="rounded-lg border bg-surface p-5">
<form <form
class="flex flex-col gap-4 pb-16 md:pb-0" class="flex flex-col gap-4 pb-16 md:pb-0"
data-signals:_submitting="false" data-signals="{{ signals_json }}"
data-signals:_submit-error="''"
data-signals:_roast-date="'{{ roast_date }}'"
data-signals:_amount="{{ amount }}"
data-signals:_remaining="{{ remaining }}"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/bags/{{ id }}', {contentType: 'form'})" data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/bags/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return; data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Bag updated') } if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Bag updated') }

View file

@ -2,6 +2,7 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% import "partials/image_section.html" as img %} {% import "partials/image_section.html" as img %}
{% import "partials/detail_cards.html" as detail_cards %} {% import "partials/detail_cards.html" as detail_cards %}
{% import "partials/forms/quick_notes.html" as quick_notes %}
{% block title %}Brewlog · Edit Brew{% endblock %} {% block title %}Brewlog · Edit Brew{% endblock %}
{% block content %} {% block content %}
@ -308,58 +309,7 @@
</div> </div>
<!-- Quick Notes --> <!-- Quick Notes -->
<div> {{ quick_notes::quick_notes_toggles() }}
<h4 class="text-sm font-semibold text-text mb-3">Quick Notes</h4>
<div class="flex flex-wrap gap-2">
<button
type="button"
data-on:click="$_qnGood = !$_qnGood"
data-attr:class="$_qnGood ? 'pill pill-success cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Good
</button>
<button
type="button"
data-on:click="$_qnTooFast = !$_qnTooFast"
data-attr:class="$_qnTooFast ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Fast
</button>
<button
type="button"
data-on:click="$_qnTooSlow = !$_qnTooSlow"
data-attr:class="$_qnTooSlow ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Slow
</button>
<button
type="button"
data-on:click="$_qnTooHot = !$_qnTooHot"
data-attr:class="$_qnTooHot ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Hot
</button>
<button
type="button"
data-on:click="$_qnUnderExtracted = !$_qnUnderExtracted"
data-attr:class="$_qnUnderExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Under Extracted
</button>
<button
type="button"
data-on:click="$_qnOverExtracted = !$_qnOverExtracted"
data-attr:class="$_qnOverExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Over Extracted
</button>
</div>
<input
type="hidden"
name="quick_notes"
data-attr:value="[$_qnGood && 'good', $_qnTooFast && 'too-fast', $_qnTooSlow && 'too-slow', $_qnTooHot && 'too-hot', $_qnUnderExtracted && 'under-extracted', $_qnOverExtracted && 'over-extracted'].filter(Boolean).join(',')"
/>
</div>
{{ img::deferred_upload_with_preview("edit-brew-image", "Brew Image", "brew", id, image_url) }} {{ img::deferred_upload_with_preview("edit-brew-image", "Brew Image", "brew", id, image_url) }}
{{ detail_cards::edit_form_actions() }} {{ detail_cards::edit_form_actions() }}

View file

@ -13,14 +13,7 @@
<section class="rounded-lg border bg-surface p-5"> <section class="rounded-lg border bg-surface p-5">
<form <form
class="flex flex-col gap-4 pb-16 md:pb-0" class="flex flex-col gap-4 pb-16 md:pb-0"
data-signals:_submitting="false" data-signals="{{ signals_json }}"
data-signals:_submit-error="''"
data-signals:_name="'{{ name }}'"
data-signals:_city="'{{ city }}'"
data-signals:_country="'{{ country }}'"
data-signals:_latitude="{{ latitude }}"
data-signals:_longitude="{{ longitude }}"
data-signals:_website="'{{ website }}'"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/cafes/{{ id }}', {contentType: 'form'})" data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/cafes/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return; data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Cafe updated') } if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Cafe updated') }

View file

@ -13,10 +13,7 @@
<section class="rounded-lg border bg-surface p-5"> <section class="rounded-lg border bg-surface p-5">
<form <form
class="flex flex-col gap-4 pb-16 md:pb-0" class="flex flex-col gap-4 pb-16 md:pb-0"
data-signals:_submitting="false" data-signals="{{ signals_json }}"
data-signals:_submit-error="''"
data-signals:_make="'{{ make }}'"
data-signals:_model="'{{ model }}'"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/gear/{{ id }}', {contentType: 'form'})" data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/gear/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return; data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Gear updated') } if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Gear updated') }

View file

@ -13,14 +13,7 @@
<section class="rounded-lg border bg-surface p-5"> <section class="rounded-lg border bg-surface p-5">
<form <form
class="flex flex-col gap-4 pb-16 md:pb-0" class="flex flex-col gap-4 pb-16 md:pb-0"
data-signals:_submitting="false" data-signals="{{ signals_json }}"
data-signals:_submit-error="''"
data-signals:_name="'{{ name }}'"
data-signals:_origin="'{{ origin }}'"
data-signals:_region="'{{ region }}'"
data-signals:_producer="'{{ producer }}'"
data-signals:_process="'{{ process }}'"
data-signals:_tasting-notes="'{{ tasting_notes }}'"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasts/{{ id }}', {contentType: 'form'})" data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasts/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return; data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roast updated') } if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roast updated') }

View file

@ -13,12 +13,7 @@
<section class="rounded-lg border bg-surface p-5"> <section class="rounded-lg border bg-surface p-5">
<form <form
class="flex flex-col gap-4 pb-16 md:pb-0" class="flex flex-col gap-4 pb-16 md:pb-0"
data-signals:_submitting="false" data-signals="{{ signals_json }}"
data-signals:_submit-error="''"
data-signals:_name="'{{ name }}'"
data-signals:_country="'{{ country }}'"
data-signals:_city="'{{ city }}'"
data-signals:_homepage="'{{ homepage }}'"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasters/{{ id }}', {contentType: 'form'})" data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasters/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return; data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roaster updated') } if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roaster updated') }

View file

@ -31,7 +31,9 @@
</div> </div>
</div> </div>
</header> </header>
{{ img::lightbox_script() }} {% if image_url.is_some() %}
{{ img::lightbox_script() }}
{% endif %}
<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">Details</h2> <h2 class="text-lg font-semibold text-text mb-4">Details</h2>

View file

@ -4,6 +4,7 @@
"partials/brew_card.html" as brew_card "partials/brew_card.html" as brew_card
%} %}
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% import "partials/entity_icon.html" as ei %}
{% block title %}Brewlog{% endblock %} {% block title %}Brewlog{% endblock %}
{% block head %} {% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" /> <meta property="og:image" content="{{ base_url }}/static/og-image.png" />
@ -265,7 +266,7 @@
> >
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<span class="text-text-muted shrink-0"> <span class="text-text-muted shrink-0">
{% 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") }}
</span> </span>
<p class="min-w-0 truncate text-sm"> <p class="min-w-0 truncate text-sm">
<span class="font-medium text-text">{{ event.title }}</span <span class="font-medium text-text">{{ event.title }}</span
@ -337,7 +338,7 @@
href="/stats" href="/stats"
class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:border-accent/40"
> >
{% 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") }}
<span class="text-lg font-bold text-text whitespace-nowrap" <span class="text-lg font-bold text-text whitespace-nowrap"
>{{ card.value }}</span >{{ card.value }}</span
> >

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Login{% endblock %} {% block title %}Brewlog · Login{% endblock %}
{% block head %} {% block head %}
<script src="/static/js/webauthn.js"></script> <script defer src="/static/js/webauthn.js"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
@ -19,7 +19,7 @@
<div <div
id="login-unsupported" id="login-unsupported"
class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800" class="mt-4 hidden rounded-md bg-warning-bg border border-warning-border p-3 text-sm text-warning-text"
> >
This browser does not support passkeys. Please use a modern browser This browser does not support passkeys. Please use a modern browser
(Chrome, Firefox, Safari, or Edge). (Chrome, Firefox, Safari, or Edge).
@ -29,6 +29,7 @@
<button <button
id="login-button" id="login-button"
type="button" type="button"
onclick="handleLogin()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed" class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
> >
{{ icons::key("h-4 w-4") }} Sign in with Passkey {{ icons::key("h-4 w-4") }} Sign in with Passkey
@ -45,46 +46,35 @@
</div> </div>
<script> <script>
document.addEventListener("DOMContentLoaded", function () { const handleLogin = async () => {
const button = document.getElementById("login-button"); const button = document.getElementById("login-button");
const errorDiv = document.getElementById("login-error"); const errorDiv = document.getElementById("login-error");
const loadingDiv = document.getElementById("login-loading"); const loadingDiv = document.getElementById("login-loading");
const unsupportedDiv = document.getElementById("login-unsupported");
// Check WebAuthn support errorDiv.classList.add("hidden");
if (!window.PublicKeyCredential) { loadingDiv.classList.remove("hidden");
button.disabled = true; button.disabled = true;
unsupportedDiv.classList.remove("hidden");
return;
}
// Check for CLI callback query params
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
let queryString = ""; let queryString = "";
if (params.has("cli_callback")) { if (params.has("cli_callback")) {
queryString = "?" + params.toString(); queryString = `?${params.toString()}`;
} }
button.addEventListener("click", async function () { try {
errorDiv.classList.add("hidden"); const result = await startPasskeyAuthentication(queryString);
loadingDiv.classList.remove("hidden"); window.location.href = result.redirect || "/";
button.disabled = true; } catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.remove("hidden");
loadingDiv.classList.add("hidden");
button.disabled = false;
}
};
try { if (!window.PublicKeyCredential) {
const result = await startPasskeyAuthentication(queryString); document.getElementById("login-button").disabled = true;
document.getElementById("login-unsupported").classList.remove("hidden");
if (result.redirect) { }
window.location.href = result.redirect;
} else {
window.location.href = "/";
}
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.remove("hidden");
loadingDiv.classList.add("hidden");
button.disabled = false;
}
});
});
</script> </script>
{% endblock %} {% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Register{% endblock %} {% block title %}Brewlog · Register{% endblock %}
{% block head %} {% block head %}
<script src="/static/js/webauthn.js"></script> <script defer src="/static/js/webauthn.js"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
@ -20,13 +20,17 @@
<div <div
id="register-unsupported" id="register-unsupported"
class="mt-4 hidden rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800" class="mt-4 hidden rounded-md bg-warning-bg border border-warning-border p-3 text-sm text-warning-text"
> >
This browser does not support passkeys. Please use a modern browser This browser does not support passkeys. Please use a modern browser
(Chrome, Firefox, Safari, or Edge). (Chrome, Firefox, Safari, or Edge).
</div> </div>
<div id="register-form" class="mt-6 flex flex-col gap-4"> <div
id="register-form"
class="mt-6 flex flex-col gap-4"
data-token="{{ token }}"
>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-text">Display Name</span> <span class="text-text">Display Name</span>
<input <input
@ -55,6 +59,7 @@
<button <button
id="register-button" id="register-button"
type="button" type="button"
onclick="handleRegister()"
class="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed" class="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
> >
{{ icons::key("h-4 w-4") }} Register Passkey {{ icons::key("h-4 w-4") }} Register Passkey
@ -71,51 +76,46 @@
</div> </div>
<script> <script>
document.addEventListener("DOMContentLoaded", function () { const handleRegister = async () => {
const token = "{{ token }}"; const token = document.getElementById("register-form").dataset.token;
const button = document.getElementById("register-button"); const button = document.getElementById("register-button");
const displayNameInput = document.getElementById("display-name");
const passkeyNameInput = document.getElementById("passkey-name");
const errorDiv = document.getElementById("register-error"); const errorDiv = document.getElementById("register-error");
const loadingDiv = document.getElementById("register-loading"); const loadingDiv = document.getElementById("register-loading");
const unsupportedDiv = document.getElementById("register-unsupported");
// Check WebAuthn support const displayName = document.getElementById("display-name").value.trim();
if (!window.PublicKeyCredential) { if (!displayName) {
button.disabled = true; errorDiv.textContent = "Please enter a display name.";
unsupportedDiv.classList.remove("hidden"); errorDiv.classList.remove("hidden");
return; return;
} }
button.addEventListener("click", async function () { const passkeyName = document.getElementById("passkey-name").value.trim();
const displayName = displayNameInput.value.trim(); if (!passkeyName) {
if (!displayName) { errorDiv.textContent = "Please enter a name for this passkey.";
errorDiv.textContent = "Please enter a display name."; errorDiv.classList.remove("hidden");
errorDiv.classList.remove("hidden"); return;
return; }
}
const passkeyName = passkeyNameInput.value.trim(); errorDiv.classList.add("hidden");
if (!passkeyName) { loadingDiv.classList.remove("hidden");
errorDiv.textContent = "Please enter a name for this passkey."; button.disabled = true;
errorDiv.classList.remove("hidden");
return;
}
errorDiv.classList.add("hidden"); try {
loadingDiv.classList.remove("hidden"); await startPasskeyRegistration(token, displayName, passkeyName);
button.disabled = true; window.location.href = "/";
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.remove("hidden");
loadingDiv.classList.add("hidden");
button.disabled = false;
}
};
try { if (!window.PublicKeyCredential) {
await startPasskeyRegistration(token, displayName, passkeyName); document.getElementById("register-button").disabled = true;
window.location.href = "/"; document
} catch (err) { .getElementById("register-unsupported")
errorDiv.textContent = err.message; .classList.remove("hidden");
errorDiv.classList.remove("hidden"); }
loadingDiv.classList.add("hidden");
button.disabled = false;
}
});
});
</script> </script>
{% endblock %} {% endblock %}

View file

@ -28,7 +28,9 @@
</div> </div>
</div> </div>
</header> </header>
{{ img::lightbox_script() }} {% if image_url.is_some() %}
{{ img::lightbox_script() }}
{% endif %}
<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, roaster_slug, "") }} {{ detail::coffee_card(roast.name, roast.roaster_name, roast.origin, roast.origin_flag, roast.region, roast.producer, roast.process, roast.tasting_notes, roaster_slug, "") }}

View file

@ -29,7 +29,9 @@
</div> </div>
</div> </div>
</header> </header>
{{ img::lightbox_script() }} {% if image_url.is_some() %}
{{ img::lightbox_script() }}
{% endif %}
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div class="rounded-lg border bg-surface p-5"> <div class="rounded-lg border bg-surface p-5">

View file

@ -146,6 +146,26 @@
</div> </div>
{% endmacro %} {% endmacro %}
{# Submit/cancel buttons for add-entity forms. #}
{% macro add_form_submit(icon_name, label) %}
<div class="sticky-submit flex flex-col gap-2">
<button
type="submit"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
>
{% if icon_name == "beaker" %}{{ icons::beaker("h-4 w-4") }}{% else %}{{ icons::plus("h-4 w-4") }}{% endif %}
{{ label }}
</button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
{{ icons::x_mark("h-4 w-4") }} Cancel
</button>
</div>
{% endmacro %}
{% macro edit_form_actions() %} {% macro edit_form_actions() %}
<p <p
data-show="$_submitError" data-show="$_submitError"

View file

@ -0,0 +1,4 @@
{% import "partials/icons.html" as icons %}
{# Render the canonical icon for an entity type or icon key. #}
{% macro entity_icon(key, class) %}{% if key == "brew" || key == "brews" || key == "beaker" %}{{ icons::beaker(class) }}{% elif key == "roast" || key == "roasts" || key == "coffee_bean" %}{{ icons::coffee_bean(class) }}{% elif key == "roaster" || key == "roasters" || key == "fire" %}{{ icons::fire(class) }}{% elif key == "bag" || key == "bags" %}{{ icons::bag(class) }}{% elif key == "cup" || key == "cups" %}{{ icons::cup(class) }}{% elif key == "cafe" || key == "cafes" || key == "location" %}{{ icons::location(class) }}{% elif key == "gear" || key == "grinder" %}{{ icons::grinder(class) }}{% elif key == "map" %}{{ icons::map(class) }}{% endif %}{% endmacro %}

View file

@ -0,0 +1,57 @@
{# Quick note pill toggles with hidden form field.
Requires signals: _qn-good, _qn-too-fast, _qn-too-slow,
_qn-too-hot, _qn-under-extracted, _qn-over-extracted. #}
{% macro quick_notes_toggles() %}
<div>
<h4 class="text-sm font-semibold text-text mb-3">Quick Notes</h4>
<div class="flex flex-wrap gap-2">
<button
type="button"
data-on:click="$_qnGood = !$_qnGood"
data-attr:class="$_qnGood ? 'pill pill-success cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Good
</button>
<button
type="button"
data-on:click="$_qnTooFast = !$_qnTooFast"
data-attr:class="$_qnTooFast ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Fast
</button>
<button
type="button"
data-on:click="$_qnTooSlow = !$_qnTooSlow"
data-attr:class="$_qnTooSlow ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Slow
</button>
<button
type="button"
data-on:click="$_qnTooHot = !$_qnTooHot"
data-attr:class="$_qnTooHot ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Hot
</button>
<button
type="button"
data-on:click="$_qnUnderExtracted = !$_qnUnderExtracted"
data-attr:class="$_qnUnderExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Under Extracted
</button>
<button
type="button"
data-on:click="$_qnOverExtracted = !$_qnOverExtracted"
data-attr:class="$_qnOverExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Over Extracted
</button>
</div>
<input
type="hidden"
name="quick_notes"
data-attr:value="[$_qnGood && 'good', $_qnTooFast && 'too-fast', $_qnTooSlow && 'too-slow', $_qnTooHot && 'too-hot', $_qnUnderExtracted && 'under-extracted', $_qnOverExtracted && 'over-extracted'].filter(Boolean).join(',')"
/>
</div>
{% endmacro %}

View file

@ -192,7 +192,7 @@
type="checkbox" type="checkbox"
name="open_bag" name="open_bag"
value="true" value="true"
class="accent-orange-700" class="accent-accent"
data-bind:_open-bag data-bind:_open-bag
/> />
<span class="font-semibold text-text">Open a bag of this coffee</span> <span class="font-semibold text-text">Open a bag of this coffee</span>

View file

@ -49,7 +49,7 @@
style="box-shadow: inset 0 0 20px rgba(0,0,0,0.4)" style="box-shadow: inset 0 0 20px rgba(0,0,0,0.4)"
></div> ></div>
</div> </div>
{% else if is_authenticated %} {% elif is_authenticated %}
<image-upload <image-upload
entity-type="{{ entity_type }}" entity-type="{{ entity_type }}"
entity-id="{{ entity_id }}" entity-id="{{ entity_id }}"
@ -144,7 +144,7 @@
<button <button
type="button" type="button"
onclick="if(confirm('Remove this image?')){fetch('/api/v1/{{ entity_type }}/{{ entity_id }}/image',{method:'DELETE'}).then(()=>{this.closest('[id$=-existing]').remove()})}" onclick="if(confirm('Remove this image?')){fetch('/api/v1/{{ entity_type }}/{{ entity_id }}/image',{method:'DELETE'}).then(()=>{this.closest('[id$=-existing]').remove()})}"
class="inline-flex items-center gap-1 rounded border bg-surface/90 px-2 py-1 text-xs font-medium text-red-500 hover:bg-surface cursor-pointer backdrop-blur-sm" class="inline-flex items-center gap-1 rounded border bg-surface/90 px-2 py-1 text-xs font-medium text-error hover:bg-surface cursor-pointer backdrop-blur-sm"
> >
Remove Remove
</button> </button>

View file

@ -19,14 +19,14 @@
</image-upload> </image-upload>
<button <button
onclick="if(confirm('Remove this image?')){fetch('/api/v1/{{ entity_type }}/{{ entity_id }}/image',{method:'DELETE',headers:{'datastar-request':'true'}}).then(r=>r.text()).then(h=>{document.getElementById('entity-image').outerHTML=h})}" onclick="if(confirm('Remove this image?')){fetch('/api/v1/{{ entity_type }}/{{ entity_id }}/image',{method:'DELETE',headers:{'datastar-request':'true'}}).then(r=>r.text()).then(h=>{document.getElementById('entity-image').outerHTML=h})}"
class="inline-flex items-center gap-1 rounded border bg-surface/90 px-2 py-1 text-xs font-medium text-red-500 hover:bg-surface cursor-pointer backdrop-blur-sm" class="inline-flex items-center gap-1 rounded border bg-surface/90 px-2 py-1 text-xs font-medium text-error hover:bg-surface cursor-pointer backdrop-blur-sm"
> >
Remove Remove
</button> </button>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% else if is_authenticated %} {% elif is_authenticated %}
<image-upload <image-upload
entity-type="{{ entity_type }}" entity-type="{{ entity_type }}"
entity-id="{{ entity_id }}" entity-id="{{ entity_id }}"

View file

@ -1,4 +1,5 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% import "partials/entity_icon.html" as ei %}
<div <div
data-signals:{{ tab_signal }}="'{{ active_type }}'" data-signals:{{ tab_signal }}="'{{ active_type }}'"
@ -18,7 +19,7 @@
'{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}}) '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}})
{% endif %}" {% 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 }} {{ tab.label }}
</button> </button>
{% endfor %} {% endfor %}
@ -35,7 +36,7 @@
<span <span
data-show="{{ tab_signal_js }} === '{{ tab.key }}'" data-show="{{ tab_signal_js }} === '{{ tab.key }}'"
class="inline-flex items-center gap-1.5" class="inline-flex items-center gap-1.5"
>{% 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 }}</span {{ tab.label }}</span
> >
{% endfor %} {% endfor %}
@ -61,7 +62,7 @@
{selector: '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}}) {selector: '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}})
{% endif %}" {% 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 }} {{ tab.label }}
</button> </button>
{% endfor %} {% endfor %}

View file

@ -1,4 +1,5 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% import "partials/entity_icon.html" as ei %}
<h2 <h2
id="{{ month.anchor }}" id="{{ month.anchor }}"
class="timeline-heading scroll-mt-24 mb-6 text-2xl font-semibold text-text" class="timeline-heading scroll-mt-24 mb-6 text-2xl font-semibold text-text"
@ -42,7 +43,7 @@
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
{# Category — always visible #} {# Category — always visible #}
<span class="inline-flex items-center gap-1 text-xs text-text-muted"> <span class="inline-flex items-center gap-1 text-xs text-text-muted">
{% 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") }}
<span class="uppercase tracking-wide">{{ event.kind_label }}</span> <span class="uppercase tracking-wide">{{ event.kind_label }}</span>
{# Relative date — shown when collapsed #} {# Relative date — shown when collapsed #}
<span <span
@ -131,7 +132,7 @@
{{ icons::map("h-4 w-4") }} {{ icons::map("h-4 w-4") }}
</a> </a>
</dd> </dd>
{% else if let Some(url) = detail.link %} {% elif let Some(url) = detail.link %}
<dd class="text-right"> <dd class="text-right">
<a <a
href="{{ url }}" href="{{ url }}"