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:
parent
ab4bcbaa0a
commit
920931ba17
33 changed files with 354 additions and 358 deletions
|
|
@ -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_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 {
|
||||
nav_active: "",
|
||||
is_authenticated: true,
|
||||
|
|
@ -87,14 +103,11 @@ pub(crate) async fn bag_edit_page(
|
|||
id: bag.bag.id.to_string(),
|
||||
roast_id: bag.bag.roast_id.to_string(),
|
||||
roast_label: format!("{} ({})", bag.roast_name, bag.roaster_name),
|
||||
roast_date: bag
|
||||
.bag
|
||||
.roast_date
|
||||
.map(|d| d.to_string())
|
||||
.unwrap_or_default(),
|
||||
roast_date,
|
||||
amount: bag.bag.amount,
|
||||
remaining: bag.bag.remaining,
|
||||
roast_options,
|
||||
signals_json,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
|
|
|
|||
|
|
@ -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 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 {
|
||||
nav_active: "",
|
||||
is_authenticated: true,
|
||||
version_info: &crate::VERSION_INFO,
|
||||
id: cafe.id.to_string(),
|
||||
name: cafe.name,
|
||||
city: cafe.city,
|
||||
country: cafe.country,
|
||||
name,
|
||||
city,
|
||||
country,
|
||||
latitude: cafe.latitude,
|
||||
longitude: cafe.longitude,
|
||||
website: cafe.website.unwrap_or_default(),
|
||||
website,
|
||||
image_url,
|
||||
signals_json,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
|
|
|
|||
|
|
@ -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 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 {
|
||||
nav_active: "",
|
||||
is_authenticated: true,
|
||||
version_info: &crate::VERSION_INFO,
|
||||
id: gear.id.to_string(),
|
||||
category: gear.category.display_label().to_string(),
|
||||
make: gear.make,
|
||||
model: gear.model,
|
||||
make,
|
||||
model,
|
||||
image_url,
|
||||
signals_json,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
|
|
|
|||
|
|
@ -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 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 {
|
||||
nav_active: "",
|
||||
is_authenticated: true,
|
||||
version_info: &crate::VERSION_INFO,
|
||||
id: roaster.id.to_string(),
|
||||
name: roaster.name,
|
||||
country: roaster.country,
|
||||
city: roaster.city.unwrap_or_default(),
|
||||
homepage: roaster.homepage.unwrap_or_default(),
|
||||
name,
|
||||
country,
|
||||
city,
|
||||
homepage,
|
||||
image_url,
|
||||
signals_json,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
|
|
|
|||
|
|
@ -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 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 {
|
||||
nav_active: "",
|
||||
is_authenticated: true,
|
||||
|
|
@ -82,14 +102,15 @@ pub(crate) async fn roast_edit_page(
|
|||
id: roast.id.to_string(),
|
||||
roaster_id: roast.roaster_id.to_string(),
|
||||
roaster_name: roaster.name,
|
||||
name: roast.name,
|
||||
origin: roast.origin.unwrap_or_default(),
|
||||
region: roast.region.unwrap_or_default(),
|
||||
producer: roast.producer.unwrap_or_default(),
|
||||
process: roast.process.unwrap_or_default(),
|
||||
tasting_notes: roast.tasting_notes.join(", "),
|
||||
name,
|
||||
origin,
|
||||
region,
|
||||
producer,
|
||||
process,
|
||||
tasting_notes,
|
||||
roaster_options,
|
||||
image_url,
|
||||
signals_json,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
|
|
|
|||
|
|
@ -317,6 +317,7 @@ pub struct RoasterEditTemplate {
|
|||
pub city: String,
|
||||
pub homepage: String,
|
||||
pub image_url: Option<String>,
|
||||
pub signals_json: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
|
|
@ -336,6 +337,7 @@ pub struct RoastEditTemplate {
|
|||
pub tasting_notes: String,
|
||||
pub roaster_options: Vec<RoasterOptionView>,
|
||||
pub image_url: Option<String>,
|
||||
pub signals_json: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
|
|
@ -351,6 +353,7 @@ pub struct BagEditTemplate {
|
|||
pub amount: f64,
|
||||
pub remaining: f64,
|
||||
pub roast_options: Vec<RoastOptionView>,
|
||||
pub signals_json: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
|
|
@ -393,6 +396,7 @@ pub struct CafeEditTemplate {
|
|||
pub longitude: f64,
|
||||
pub website: String,
|
||||
pub image_url: Option<String>,
|
||||
pub signals_json: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
|
|
@ -422,6 +426,7 @@ pub struct GearEditTemplate {
|
|||
pub make: String,
|
||||
pub model: String,
|
||||
pub image_url: Option<String>,
|
||||
pub signals_json: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
|
|
|
|||
|
|
@ -511,6 +511,36 @@ pub(crate) fn build_map_data(entries: &[(&str, u32)]) -> (String, u32) {
|
|||
(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 `"`, 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@
|
|||
--success-bg: #ecfdf5; /* green-50 */
|
||||
--success-border: rgba(5, 150, 105, 0.3);
|
||||
--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"] {
|
||||
|
|
@ -74,6 +78,10 @@
|
|||
--success-bg: rgba(5, 150, 105, 0.1);
|
||||
--success-border: rgba(5, 150, 105, 0.4);
|
||||
--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 ─────────────────── */
|
||||
|
|
@ -99,6 +107,10 @@
|
|||
--color-success-bg: var(--success-bg);
|
||||
--color-success-border: var(--success-border);
|
||||
--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 ────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -179,10 +179,13 @@
|
|||
|
||||
setupInfiniteScroll();
|
||||
|
||||
const bodyObserver = new MutationObserver(() => {
|
||||
setupInfiniteScroll();
|
||||
});
|
||||
bodyObserver.observe(document.body, { childList: true, subtree: true });
|
||||
const mainEl = document.querySelector("main");
|
||||
if (mainEl) {
|
||||
const mainObserver = new MutationObserver(() => {
|
||||
setupInfiniteScroll();
|
||||
});
|
||||
mainObserver.observe(mainEl, { childList: true, subtree: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||
{% import "partials/image_section.html" as img %}
|
||||
{% 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 content %}
|
||||
|
|
@ -190,21 +192,7 @@
|
|||
</label>
|
||||
</div>
|
||||
{{ img::deferred_upload("roaster-image", "Add image (optional)") }}
|
||||
<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"
|
||||
>
|
||||
{{ 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>
|
||||
{{ detail_cards::add_form_submit("plus", "Save Roaster") }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -410,21 +398,7 @@
|
|||
</label>
|
||||
</div>
|
||||
{{ img::deferred_upload("roast-image", "Add image (optional)") }}
|
||||
<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"
|
||||
>
|
||||
{{ 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>
|
||||
{{ detail_cards::add_form_submit("plus", "Save Roast") }}
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -512,21 +486,7 @@
|
|||
/>
|
||||
</label>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{{ 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>
|
||||
{{ detail_cards::add_form_submit("plus", "Save Bag") }}
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -552,7 +512,7 @@
|
|||
to enable this form.
|
||||
</p>
|
||||
</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">
|
||||
<h3 class="text-lg font-semibold text-text">Add gear first</h3>
|
||||
<p class="mt-2">
|
||||
|
|
@ -915,74 +875,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<!-- Quick Notes -->
|
||||
<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>
|
||||
{{ quick_notes::quick_notes_toggles() }}
|
||||
{{ img::deferred_upload("brew-image", "Add image (optional)") }}
|
||||
<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"
|
||||
>
|
||||
{{ 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>
|
||||
{{ detail_cards::add_form_submit("beaker", "Save Brew") }}
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -1051,21 +946,7 @@
|
|||
</label>
|
||||
</div>
|
||||
{{ img::deferred_upload("gear-image", "Add image (optional)") }}
|
||||
<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"
|
||||
>
|
||||
{{ 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>
|
||||
{{ detail_cards::add_form_submit("plus", "Save Gear") }}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
@ -1223,21 +1104,7 @@
|
|||
</label>
|
||||
</div>
|
||||
{{ img::deferred_upload("cafe-image", "Add image (optional)") }}
|
||||
<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"
|
||||
>
|
||||
{{ 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>
|
||||
{{ detail_cards::add_form_submit("plus", "Save Cafe") }}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
@ -1339,21 +1206,7 @@
|
|||
</searchable-select>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{{ 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>
|
||||
{{ detail_cards::add_form_submit("plus", "Save Cup") }}
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · Admin{% endblock %}
|
||||
{% block head %}
|
||||
<script src="/static/js/webauthn.js"></script>
|
||||
<script defer src="/static/js/webauthn.js"></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<header class="flex flex-col gap-2">
|
||||
|
|
@ -49,7 +49,9 @@
|
|||
<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"
|
||||
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"
|
||||
>
|
||||
{{ icons::delete("h-4 w-4") }}
|
||||
|
|
@ -175,7 +177,9 @@
|
|||
<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"
|
||||
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"
|
||||
>
|
||||
{{ icons::delete("h-4 w-4") }}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@
|
|||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{{ img::lightbox_script() }}
|
||||
{% if image_url.is_some() %}
|
||||
{{ img::lightbox_script() }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border bg-surface p-5">
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@
|
|||
<div class="rounded-lg border bg-surface p-6">
|
||||
{% if token.is_some() %}
|
||||
<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>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Your CLI has been authenticated successfully. You can close this
|
||||
window and return to the terminal.
|
||||
</p>
|
||||
</div>
|
||||
{% else if error.is_some() %}
|
||||
{% elif error.is_some() %}
|
||||
<div class="flex flex-col items-center text-center gap-3">
|
||||
{{ icons::x_circle("h-10 w-10 text-error") }}
|
||||
<h1 class="text-2xl font-semibold text-accent">
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@
|
|||
<section class="rounded-lg border bg-surface p-5">
|
||||
<form
|
||||
class="flex flex-col gap-4 pb-16 md:pb-0"
|
||||
data-signals:_submitting="false"
|
||||
data-signals:_submit-error="''"
|
||||
data-signals:_roast-date="'{{ roast_date }}'"
|
||||
data-signals:_amount="{{ amount }}"
|
||||
data-signals:_remaining="{{ remaining }}"
|
||||
data-signals="{{ signals_json }}"
|
||||
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/bags/{{ id }}', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_submitting) return;
|
||||
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Bag updated') }
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
{% import "partials/icons.html" as icons %}
|
||||
{% import "partials/image_section.html" as img %}
|
||||
{% import "partials/detail_cards.html" as detail_cards %}
|
||||
{% import "partials/forms/quick_notes.html" as quick_notes %}
|
||||
{% block title %}Brewlog · Edit Brew{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
|
@ -308,58 +309,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Quick Notes -->
|
||||
<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>
|
||||
{{ quick_notes::quick_notes_toggles() }}
|
||||
|
||||
{{ img::deferred_upload_with_preview("edit-brew-image", "Brew Image", "brew", id, image_url) }}
|
||||
{{ detail_cards::edit_form_actions() }}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,7 @@
|
|||
<section class="rounded-lg border bg-surface p-5">
|
||||
<form
|
||||
class="flex flex-col gap-4 pb-16 md:pb-0"
|
||||
data-signals:_submitting="false"
|
||||
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-signals="{{ signals_json }}"
|
||||
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/cafes/{{ id }}', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_submitting) return;
|
||||
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Cafe updated') }
|
||||
|
|
|
|||
|
|
@ -13,10 +13,7 @@
|
|||
<section class="rounded-lg border bg-surface p-5">
|
||||
<form
|
||||
class="flex flex-col gap-4 pb-16 md:pb-0"
|
||||
data-signals:_submitting="false"
|
||||
data-signals:_submit-error="''"
|
||||
data-signals:_make="'{{ make }}'"
|
||||
data-signals:_model="'{{ model }}'"
|
||||
data-signals="{{ signals_json }}"
|
||||
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/gear/{{ id }}', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_submitting) return;
|
||||
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Gear updated') }
|
||||
|
|
|
|||
|
|
@ -13,14 +13,7 @@
|
|||
<section class="rounded-lg border bg-surface p-5">
|
||||
<form
|
||||
class="flex flex-col gap-4 pb-16 md:pb-0"
|
||||
data-signals:_submitting="false"
|
||||
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-signals="{{ signals_json }}"
|
||||
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasts/{{ id }}', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_submitting) return;
|
||||
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roast updated') }
|
||||
|
|
|
|||
|
|
@ -13,12 +13,7 @@
|
|||
<section class="rounded-lg border bg-surface p-5">
|
||||
<form
|
||||
class="flex flex-col gap-4 pb-16 md:pb-0"
|
||||
data-signals:_submitting="false"
|
||||
data-signals:_submit-error="''"
|
||||
data-signals:_name="'{{ name }}'"
|
||||
data-signals:_country="'{{ country }}'"
|
||||
data-signals:_city="'{{ city }}'"
|
||||
data-signals:_homepage="'{{ homepage }}'"
|
||||
data-signals="{{ signals_json }}"
|
||||
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasters/{{ id }}', {contentType: 'form'})"
|
||||
data-on:datastar-fetch="if (!$_submitting) return;
|
||||
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roaster updated') }
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@
|
|||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{{ img::lightbox_script() }}
|
||||
{% if image_url.is_some() %}
|
||||
{{ img::lightbox_script() }}
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-lg border bg-surface p-5">
|
||||
<h2 class="text-lg font-semibold text-text mb-4">Details</h2>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"partials/brew_card.html" as brew_card
|
||||
%}
|
||||
{% import "partials/icons.html" as icons %}
|
||||
{% import "partials/entity_icon.html" as ei %}
|
||||
{% block title %}Brewlog{% endblock %}
|
||||
{% block head %}
|
||||
<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">
|
||||
<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>
|
||||
<p class="min-w-0 truncate text-sm">
|
||||
<span class="font-medium text-text">{{ event.title }}</span
|
||||
|
|
@ -337,7 +338,7 @@
|
|||
href="/stats"
|
||||
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"
|
||||
>{{ card.value }}</span
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · Login{% endblock %}
|
||||
{% block head %}
|
||||
<script src="/static/js/webauthn.js"></script>
|
||||
<script defer src="/static/js/webauthn.js"></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-md">
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
<div
|
||||
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
|
||||
(Chrome, Firefox, Safari, or Edge).
|
||||
|
|
@ -29,6 +29,7 @@
|
|||
<button
|
||||
id="login-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"
|
||||
>
|
||||
{{ icons::key("h-4 w-4") }} Sign in with Passkey
|
||||
|
|
@ -45,46 +46,35 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const handleLogin = async () => {
|
||||
const button = document.getElementById("login-button");
|
||||
const errorDiv = document.getElementById("login-error");
|
||||
const loadingDiv = document.getElementById("login-loading");
|
||||
const unsupportedDiv = document.getElementById("login-unsupported");
|
||||
|
||||
// Check WebAuthn support
|
||||
if (!window.PublicKeyCredential) {
|
||||
button.disabled = true;
|
||||
unsupportedDiv.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
errorDiv.classList.add("hidden");
|
||||
loadingDiv.classList.remove("hidden");
|
||||
button.disabled = true;
|
||||
|
||||
// Check for CLI callback query params
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let queryString = "";
|
||||
if (params.has("cli_callback")) {
|
||||
queryString = "?" + params.toString();
|
||||
queryString = `?${params.toString()}`;
|
||||
}
|
||||
|
||||
button.addEventListener("click", async function () {
|
||||
errorDiv.classList.add("hidden");
|
||||
loadingDiv.classList.remove("hidden");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await startPasskeyAuthentication(queryString);
|
||||
window.location.href = result.redirect || "/";
|
||||
} catch (err) {
|
||||
errorDiv.textContent = err.message;
|
||||
errorDiv.classList.remove("hidden");
|
||||
loadingDiv.classList.add("hidden");
|
||||
button.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await startPasskeyAuthentication(queryString);
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!window.PublicKeyCredential) {
|
||||
document.getElementById("login-button").disabled = true;
|
||||
document.getElementById("login-unsupported").classList.remove("hidden");
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||
{% block title %}Brewlog · Register{% endblock %}
|
||||
{% block head %}
|
||||
<script src="/static/js/webauthn.js"></script>
|
||||
<script defer src="/static/js/webauthn.js"></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-md">
|
||||
|
|
@ -20,13 +20,17 @@
|
|||
|
||||
<div
|
||||
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
|
||||
(Chrome, Firefox, Safari, or Edge).
|
||||
</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">
|
||||
<span class="text-text">Display Name</span>
|
||||
<input
|
||||
|
|
@ -55,6 +59,7 @@
|
|||
<button
|
||||
id="register-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"
|
||||
>
|
||||
{{ icons::key("h-4 w-4") }} Register Passkey
|
||||
|
|
@ -71,51 +76,46 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const token = "{{ token }}";
|
||||
const handleRegister = async () => {
|
||||
const token = document.getElementById("register-form").dataset.token;
|
||||
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 loadingDiv = document.getElementById("register-loading");
|
||||
const unsupportedDiv = document.getElementById("register-unsupported");
|
||||
|
||||
// Check WebAuthn support
|
||||
if (!window.PublicKeyCredential) {
|
||||
button.disabled = true;
|
||||
unsupportedDiv.classList.remove("hidden");
|
||||
const displayName = document.getElementById("display-name").value.trim();
|
||||
if (!displayName) {
|
||||
errorDiv.textContent = "Please enter a display name.";
|
||||
errorDiv.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
button.addEventListener("click", async function () {
|
||||
const displayName = displayNameInput.value.trim();
|
||||
if (!displayName) {
|
||||
errorDiv.textContent = "Please enter a display name.";
|
||||
errorDiv.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
const passkeyName = document.getElementById("passkey-name").value.trim();
|
||||
if (!passkeyName) {
|
||||
errorDiv.textContent = "Please enter a name for this passkey.";
|
||||
errorDiv.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const passkeyName = passkeyNameInput.value.trim();
|
||||
if (!passkeyName) {
|
||||
errorDiv.textContent = "Please enter a name for this passkey.";
|
||||
errorDiv.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
errorDiv.classList.add("hidden");
|
||||
loadingDiv.classList.remove("hidden");
|
||||
button.disabled = true;
|
||||
|
||||
errorDiv.classList.add("hidden");
|
||||
loadingDiv.classList.remove("hidden");
|
||||
button.disabled = true;
|
||||
try {
|
||||
await startPasskeyRegistration(token, displayName, passkeyName);
|
||||
window.location.href = "/";
|
||||
} catch (err) {
|
||||
errorDiv.textContent = err.message;
|
||||
errorDiv.classList.remove("hidden");
|
||||
loadingDiv.classList.add("hidden");
|
||||
button.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await startPasskeyRegistration(token, displayName, passkeyName);
|
||||
window.location.href = "/";
|
||||
} catch (err) {
|
||||
errorDiv.textContent = err.message;
|
||||
errorDiv.classList.remove("hidden");
|
||||
loadingDiv.classList.add("hidden");
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!window.PublicKeyCredential) {
|
||||
document.getElementById("register-button").disabled = true;
|
||||
document
|
||||
.getElementById("register-unsupported")
|
||||
.classList.remove("hidden");
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@
|
|||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{{ img::lightbox_script() }}
|
||||
{% if image_url.is_some() %}
|
||||
{{ img::lightbox_script() }}
|
||||
{% endif %}
|
||||
|
||||
<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, "") }}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@
|
|||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{{ img::lightbox_script() }}
|
||||
{% if image_url.is_some() %}
|
||||
{{ img::lightbox_script() }}
|
||||
{% endif %}
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border bg-surface p-5">
|
||||
|
|
|
|||
|
|
@ -146,6 +146,26 @@
|
|||
</div>
|
||||
{% 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() %}
|
||||
<p
|
||||
data-show="$_submitError"
|
||||
|
|
|
|||
4
templates/partials/entity_icon.html
Normal file
4
templates/partials/entity_icon.html
Normal 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 %}
|
||||
57
templates/partials/forms/quick_notes.html
Normal file
57
templates/partials/forms/quick_notes.html
Normal 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 %}
|
||||
|
|
@ -192,7 +192,7 @@
|
|||
type="checkbox"
|
||||
name="open_bag"
|
||||
value="true"
|
||||
class="accent-orange-700"
|
||||
class="accent-accent"
|
||||
data-bind:_open-bag
|
||||
/>
|
||||
<span class="font-semibold text-text">Open a bag of this coffee</span>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
style="box-shadow: inset 0 0 20px rgba(0,0,0,0.4)"
|
||||
></div>
|
||||
</div>
|
||||
{% else if is_authenticated %}
|
||||
{% elif is_authenticated %}
|
||||
<image-upload
|
||||
entity-type="{{ entity_type }}"
|
||||
entity-id="{{ entity_id }}"
|
||||
|
|
@ -144,7 +144,7 @@
|
|||
<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()})}"
|
||||
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
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -19,14 +19,14 @@
|
|||
</image-upload>
|
||||
<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})}"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else if is_authenticated %}
|
||||
{% elif is_authenticated %}
|
||||
<image-upload
|
||||
entity-type="{{ entity_type }}"
|
||||
entity-id="{{ entity_id }}"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{% import "partials/icons.html" as icons %}
|
||||
{% import "partials/entity_icon.html" as ei %}
|
||||
|
||||
<div
|
||||
data-signals:{{ tab_signal }}="'{{ active_type }}'"
|
||||
|
|
@ -18,7 +19,7 @@
|
|||
'{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}})
|
||||
{% 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 }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
|
@ -35,7 +36,7 @@
|
|||
<span
|
||||
data-show="{{ tab_signal_js }} === '{{ tab.key }}'"
|
||||
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
|
||||
>
|
||||
{% endfor %}
|
||||
|
|
@ -61,7 +62,7 @@
|
|||
{selector: '{{ tab_fetch_target }}', mode: '{{ tab_fetch_mode }}'}})
|
||||
{% 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 }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{% import "partials/icons.html" as icons %}
|
||||
{% import "partials/entity_icon.html" as ei %}
|
||||
<h2
|
||||
id="{{ month.anchor }}"
|
||||
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">
|
||||
{# Category — always visible #}
|
||||
<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>
|
||||
{# Relative date — shown when collapsed #}
|
||||
<span
|
||||
|
|
@ -131,7 +132,7 @@
|
|||
{{ icons::map("h-4 w-4") }}
|
||||
</a>
|
||||
</dd>
|
||||
{% else if let Some(url) = detail.link %}
|
||||
{% elif let Some(url) = detail.link %}
|
||||
<dd class="text-right">
|
||||
<a
|
||||
href="{{ url }}"
|
||||
|
|
|
|||
Loading…
Reference in a new issue