feat(ui): redesign with theme tokens, dark mode, and reusable components

Overhaul the web UI with CSS custom property-based theming and
dark mode support. Extract reusable template partials (location
search, scan input) and web components (photo-capture,
searchable-select). Add version and commit info to page footer.

- Replace hardcoded amber palette with semantic theme tokens
- Add dark mode with localStorage persistence and system preference detection
- Extract <brew-photo-capture> and <searchable-select> web components
- Extract location_search and scan_input Askama macros
- Add sun/moon, timeline, database, map icons
- Display version and git commit in footer
- Improve timeline coordinate rounding and map link formatting
- Add roast_name, roaster_name, remaining to BagOptionView
This commit is contained in:
Jon Seager 2026-02-05 17:03:09 +00:00
parent 6d12b71482
commit fb67f0659c
No known key found for this signature in database
41 changed files with 933 additions and 911 deletions

View file

@ -73,6 +73,7 @@ fn format_date(dt: DateTime<Utc>) -> String {
struct AccountTemplate { struct AccountTemplate {
nav_active: &'static str, nav_active: &'static str,
is_authenticated: bool, is_authenticated: bool,
version_info: &'static crate::VersionInfo,
ai_usage: Option<AiUsageView>, ai_usage: Option<AiUsageView>,
passkeys: Vec<PasskeyView>, passkeys: Vec<PasskeyView>,
tokens: Vec<TokenView>, tokens: Vec<TokenView>,
@ -137,6 +138,7 @@ pub(crate) async fn account_page(
let template = AccountTemplate { let template = AccountTemplate {
nav_active: "account", nav_active: "account",
is_authenticated: true, is_authenticated: true,
version_info: &crate::VERSION_INFO,
ai_usage, ai_usage,
passkeys, passkeys,
tokens, tokens,

View file

@ -46,6 +46,7 @@ pub(crate) async fn add_page(
let template = AddTemplate { let template = AddTemplate {
nav_active: "data", nav_active: "data",
is_authenticated, is_authenticated,
version_info: &crate::VERSION_INFO,
active_type: query.entity_type, active_type: query.entity_type,
roaster_options, roaster_options,
roast_options, roast_options,

View file

@ -21,6 +21,7 @@ pub struct LoginQuery {
struct LoginTemplate { struct LoginTemplate {
nav_active: &'static str, nav_active: &'static str,
is_authenticated: bool, is_authenticated: bool,
version_info: &'static crate::VersionInfo,
} }
#[tracing::instrument(skip(state, cookies))] #[tracing::instrument(skip(state, cookies))]
@ -38,6 +39,7 @@ pub(crate) async fn login_page(
let template = LoginTemplate { let template = LoginTemplate {
nav_active: "login", nav_active: "login",
is_authenticated: false, is_authenticated: false,
version_info: &crate::VERSION_INFO,
}; };
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)

View file

@ -32,6 +32,7 @@ pub(crate) async fn checkin_page(
let template = CheckInTemplate { let template = CheckInTemplate {
nav_active: "checkin", nav_active: "checkin",
is_authenticated: true, is_authenticated: true,
version_info: &crate::VERSION_INFO,
roast_options, roast_options,
cafe_options, cafe_options,
}; };

View file

@ -95,6 +95,7 @@ pub(crate) async fn data_page(
let template = DataTemplate { let template = DataTemplate {
nav_active: "data", nav_active: "data",
is_authenticated, is_authenticated,
version_info: &crate::VERSION_INFO,
active_type: entity_type, active_type: entity_type,
tabs, tabs,
content, content,

View file

@ -31,6 +31,7 @@ pub(crate) async fn home_page(
let template = HomeTemplate { let template = HomeTemplate {
nav_active: "home", nav_active: "home",
is_authenticated, is_authenticated,
version_info: &crate::VERSION_INFO,
recent_brews: content.recent_brews, recent_brews: content.recent_brews,
open_bags: content.open_bags, open_bags: content.open_bags,
recent_events: content.recent_events, recent_events: content.recent_events,

View file

@ -130,6 +130,11 @@ pub fn app_router(state: AppState) -> axum::Router {
.route("/timeline", get(timeline::timeline_page)) .route("/timeline", get(timeline::timeline_page))
.route("/styles.css", get(styles)) .route("/styles.css", get(styles))
.route("/webauthn.js", get(webauthn_js)) .route("/webauthn.js", get(webauthn_js))
.route("/components/photo-capture.js", get(photo_capture_js))
.route(
"/components/searchable-select.js",
get(searchable_select_js),
)
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))
.nest("/api/v1", api_routes) .nest("/api/v1", api_routes)
.nest("/api/v1/webauthn", webauthn_routes) .nest("/api/v1/webauthn", webauthn_routes)
@ -155,6 +160,20 @@ async fn webauthn_js() -> impl IntoResponse {
) )
} }
async fn photo_capture_js() -> impl IntoResponse {
(
[("content-type", "application/javascript; charset=utf-8")],
include_str!("../../../templates/components/photo-capture.js"),
)
}
async fn searchable_select_js() -> impl IntoResponse {
(
[("content-type", "application/javascript; charset=utf-8")],
include_str!("../../../templates/components/searchable-select.js"),
)
}
async fn favicon() -> impl IntoResponse { async fn favicon() -> impl IntoResponse {
( (
[("content-type", "image/x-icon")], [("content-type", "image/x-icon")],

View file

@ -93,6 +93,7 @@ pub(crate) async fn timeline_page(
let template = TimelineTemplate { let template = TimelineTemplate {
nav_active: "timeline", nav_active: "timeline",
is_authenticated, is_authenticated,
version_info: &crate::VERSION_INFO,
events: data.events, events: data.events,
navigator: data.navigator, navigator: data.navigator,
months: data.months, months: data.months,

View file

@ -29,6 +29,7 @@ const SESSION_COOKIE_NAME: &str = "brewlog_session";
struct RegisterTemplate { struct RegisterTemplate {
nav_active: &'static str, nav_active: &'static str,
is_authenticated: bool, is_authenticated: bool,
version_info: &'static crate::VersionInfo,
token: String, token: String,
} }
@ -37,6 +38,7 @@ struct RegisterTemplate {
struct CliCallbackTemplate { struct CliCallbackTemplate {
nav_active: &'static str, nav_active: &'static str,
is_authenticated: bool, is_authenticated: bool,
version_info: &'static crate::VersionInfo,
token: Option<String>, token: Option<String>,
error: Option<String>, error: Option<String>,
} }
@ -116,6 +118,7 @@ pub(crate) async fn register_page(
let template = RegisterTemplate { let template = RegisterTemplate {
nav_active: "", nav_active: "",
is_authenticated: false, is_authenticated: false,
version_info: &crate::VERSION_INFO,
token, token,
}; };
@ -505,6 +508,7 @@ pub(crate) async fn cli_callback_page() -> Result<Response, StatusCode> {
let template = CliCallbackTemplate { let template = CliCallbackTemplate {
nav_active: "", nav_active: "",
is_authenticated: false, is_authenticated: false,
version_info: &crate::VERSION_INFO,
token: None, token: None,
error: None, error: None,
}; };

View file

@ -2,3 +2,13 @@ pub mod application;
pub mod domain; pub mod domain;
pub mod infrastructure; pub mod infrastructure;
pub mod presentation; pub mod presentation;
pub struct VersionInfo {
pub version: &'static str,
pub commit: &'static str,
}
pub const VERSION_INFO: VersionInfo = VersionInfo {
version: env!("CARGO_PKG_VERSION"),
commit: env!("GIT_HASH"),
};

View file

@ -35,6 +35,7 @@ pub struct RoastListTemplate {
pub struct TimelineTemplate { pub struct TimelineTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub version_info: &'static crate::VersionInfo,
pub events: Paginated<TimelineEventView>, pub events: Paginated<TimelineEventView>,
pub navigator: ListNavigator<TimelineSortKey>, pub navigator: ListNavigator<TimelineSortKey>,
@ -101,6 +102,7 @@ pub struct CupListTemplate {
pub struct HomeTemplate { pub struct HomeTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub version_info: &'static crate::VersionInfo,
pub recent_brews: Vec<BrewView>, pub recent_brews: Vec<BrewView>,
pub open_bags: Vec<BagView>, pub open_bags: Vec<BagView>,
@ -113,6 +115,7 @@ pub struct HomeTemplate {
pub struct CheckInTemplate { pub struct CheckInTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub version_info: &'static crate::VersionInfo,
pub roast_options: Vec<RoastOptionView>, pub roast_options: Vec<RoastOptionView>,
pub cafe_options: Vec<CafeOptionView>, pub cafe_options: Vec<CafeOptionView>,
@ -129,6 +132,7 @@ pub struct NearbyCafesFragment {
pub struct DataTemplate { pub struct DataTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub version_info: &'static crate::VersionInfo,
pub active_type: String, pub active_type: String,
pub tabs: Vec<DataTab>, pub tabs: Vec<DataTab>,
pub content: String, pub content: String,
@ -145,6 +149,7 @@ pub struct DataTab {
pub struct AddTemplate { pub struct AddTemplate {
pub nav_active: &'static str, pub nav_active: &'static str,
pub is_authenticated: bool, pub is_authenticated: bool,
pub version_info: &'static crate::VersionInfo,
pub active_type: String, pub active_type: String,
pub roaster_options: Vec<RoasterOptionView>, pub roaster_options: Vec<RoasterOptionView>,
pub roast_options: Vec<RoastOptionView>, pub roast_options: Vec<RoastOptionView>,

View file

@ -42,16 +42,23 @@ impl BagView {
pub struct BagOptionView { pub struct BagOptionView {
pub id: String, pub id: String,
pub label: String, pub label: String,
pub roast_name: String,
pub roaster_name: String,
pub remaining: String,
} }
impl From<BagWithRoast> for BagOptionView { impl From<BagWithRoast> for BagOptionView {
fn from(bag: BagWithRoast) -> Self { fn from(bag: BagWithRoast) -> Self {
let remaining = format!("{:.0}g", bag.bag.remaining);
Self { Self {
id: bag.bag.id.to_string(), id: bag.bag.id.to_string(),
label: format!( label: format!(
"{} - {} ({:.0}g remaining)", "{} - {} ({} remaining)",
bag.roaster_name, bag.roast_name, bag.bag.remaining bag.roaster_name, bag.roast_name, remaining
), ),
roast_name: bag.roast_name,
roaster_name: bag.roaster_name,
remaining,
} }
} }
} }

View file

@ -87,6 +87,7 @@ impl Default for BrewDefaultsView {
fn default() -> Self { fn default() -> Self {
Self { Self {
bag_id: String::new(), bag_id: String::new(),
grinder_id: String::new(), grinder_id: String::new(),
brewer_id: String::new(), brewer_id: String::new(),
filter_paper_id: String::new(), filter_paper_id: String::new(),
@ -102,6 +103,7 @@ impl From<Brew> for BrewDefaultsView {
fn from(brew: Brew) -> Self { fn from(brew: Brew) -> Self {
Self { Self {
bag_id: brew.bag_id.to_string(), bag_id: brew.bag_id.to_string(),
grinder_id: brew.grinder_id.to_string(), grinder_id: brew.grinder_id.to_string(),
brewer_id: brew.brewer_id.to_string(), brewer_id: brew.brewer_id.to_string(),
filter_paper_id: brew filter_paper_id: brew

View file

@ -7,6 +7,20 @@ fn is_blank(value: &str) -> bool {
value.is_empty() || value == "\u{2014}" value.is_empty() || value == "\u{2014}"
} }
/// Rounds "lat,lon" coordinates to 3 decimal places for display.
fn round_coords(coords: &str) -> String {
let parts: Vec<&str> = coords.split(',').collect();
if parts.len() == 2
&& let (Ok(lat), Ok(lon)) = (
parts[0].trim().parse::<f64>(),
parts[1].trim().parse::<f64>(),
)
{
return format!("{lat:.3}, {lon:.3}");
}
coords.to_string()
}
#[derive(Clone)] #[derive(Clone)]
pub struct TimelineEventDetailView { pub struct TimelineEventDetailView {
pub label: String, pub label: String,
@ -189,13 +203,19 @@ impl TimelineEventView {
"position" => { "position" => {
let trimmed = detail.value.trim(); let trimmed = detail.value.trim();
if !trimmed.is_empty() { if !trimmed.is_empty() {
let display = trimmed let coords = trimmed
.strip_prefix("https://www.google.com/maps?q=") .strip_prefix("https://www.google.com/maps?q=")
.unwrap_or(trimmed); .unwrap_or(trimmed);
let display = round_coords(coords);
let link = if trimmed.starts_with("http") {
trimmed.to_string()
} else {
format!("https://www.google.com/maps?q={coords}")
};
mapped.push(TimelineEventDetailView { mapped.push(TimelineEventDetailView {
label: detail.label, label: detail.label,
value: display.to_string(), value: display,
link: Some(trimmed.to_string()), link: Some(link),
}); });
} }
} }

View file

@ -1,19 +1,19 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% block title %}Brewlog · Account{% endblock %} {% block title %}Brewlog · Admin{% endblock %}
{% block head %} {% block head %}
<script src="/webauthn.js"></script> <script src="/webauthn.js"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Passkeys --> <!-- Passkeys -->
<section> <section>
<h2 class="text-lg font-semibold text-amber-700 mb-3">Passkeys</h2> <h2 class="text-lg font-semibold text-accent mb-3">Passkeys</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{% for passkey in passkeys %} {% for passkey in passkeys %}
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm flex flex-col justify-between"> <div class="rounded-lg border bg-surface p-4 flex flex-col justify-between">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<span class="block font-semibold text-amber-800">{% call icons::key("inline h-4 w-4 mr-1 text-amber-600") %}{{ passkey.name }}</span> <span class="block font-semibold text-stone-800">{% call icons::key("inline h-4 w-4 mr-1 text-accent") %}{{ passkey.name }}</span>
</div> </div>
{% if passkeys.len() > 1 %} {% if passkeys.len() > 1 %}
<button <button
@ -43,7 +43,7 @@
{% endif %} {% endif %}
<!-- Add passkey form (hidden by default) --> <!-- Add passkey form (hidden by default) -->
<div id="add-passkey-form" class="mt-3 hidden rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div id="add-passkey-form" class="mt-3 hidden rounded-lg border bg-surface p-5">
<div id="add-passkey-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800"></div> <div id="add-passkey-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800"></div>
<div class="flex items-end gap-3"> <div class="flex items-end gap-3">
<label class="flex-1 flex flex-col gap-1 text-sm"> <label class="flex-1 flex flex-col gap-1 text-sm">
@ -60,7 +60,7 @@
id="add-passkey-btn" id="add-passkey-btn"
type="button" type="button"
onclick="registerPasskey()" onclick="registerPasskey()"
class="shrink-0 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed" class="shrink-0 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
> >
Register Register
</button> </button>
@ -72,7 +72,7 @@
Cancel Cancel
</button> </button>
</div> </div>
<div id="add-passkey-loading" class="mt-3 hidden flex items-center gap-3 text-sm text-amber-700"> <div id="add-passkey-loading" class="mt-3 hidden flex items-center gap-3 text-sm text-accent">
{% call icons::spinner("h-5 w-5") %} {% call icons::spinner("h-5 w-5") %}
Follow the prompts from your browser or device... Follow the prompts from your browser or device...
</div> </div>
@ -82,7 +82,7 @@
id="show-add-passkey" id="show-add-passkey"
type="button" type="button"
onclick="showPasskeyForm()" onclick="showPasskeyForm()"
class="mt-3 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500" class="mt-3 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover"
> >
Add Passkey Add Passkey
</button> </button>
@ -90,17 +90,17 @@
<!-- API Tokens --> <!-- API Tokens -->
<section> <section>
<h2 class="text-lg font-semibold text-amber-700 mb-3">API Tokens</h2> <h2 class="text-lg font-semibold text-accent mb-3">API Tokens</h2>
{% if tokens.is_empty() %} {% if tokens.is_empty() %}
<p class="text-sm text-stone-500">No active tokens. Create one to use the CLI or API.</p> <p class="text-sm text-stone-500">No active tokens. Create one to use the CLI or API.</p>
{% else %} {% else %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{% for token in tokens %} {% for token in tokens %}
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm flex flex-col justify-between"> <div class="rounded-lg border bg-surface p-4 flex flex-col justify-between">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<span class="block font-semibold text-amber-800">{{ token.name }}</span> <span class="block font-semibold text-stone-800">{{ token.name }}</span>
</div> </div>
<button <button
type="button" type="button"
@ -125,7 +125,7 @@
{% endif %} {% endif %}
<!-- Create token form (hidden by default) --> <!-- Create token form (hidden by default) -->
<div id="create-token-form" class="mt-3 hidden rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div id="create-token-form" class="mt-3 hidden rounded-lg border bg-surface p-5">
<div id="create-token-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800"></div> <div id="create-token-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800"></div>
<div class="flex items-end gap-3"> <div class="flex items-end gap-3">
<label class="flex-1 flex flex-col gap-1 text-sm"> <label class="flex-1 flex flex-col gap-1 text-sm">
@ -142,7 +142,7 @@
id="create-token-btn" id="create-token-btn"
type="button" type="button"
onclick="createToken()" onclick="createToken()"
class="shrink-0 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed" class="shrink-0 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
> >
Create Create
</button> </button>
@ -157,10 +157,10 @@
</div> </div>
<!-- One-time token display (hidden by default) --> <!-- One-time token display (hidden by default) -->
<div id="token-created" class="mt-3 hidden rounded-lg border border-green-300 bg-green-50 p-5 shadow-sm"> <div id="token-created" class="mt-3 hidden rounded-lg border border-green-300 bg-green-50 p-5">
<p class="text-sm font-medium text-green-800">Token created! Copy it now — you won't see it again.</p> <p class="text-sm font-medium text-green-800">Token created! Copy it now — you won't see it again.</p>
<div class="mt-3 flex items-center gap-2"> <div class="mt-3 flex items-center gap-2">
<code id="token-value" class="flex-1 rounded bg-white px-3 py-2 text-sm font-mono text-stone-800 border border-green-200 break-all select-all"></code> <code id="token-value" class="flex-1 rounded bg-surface px-3 py-2 text-sm font-mono text-stone-800 border border-green-200 break-all select-all"></code>
<button <button
type="button" type="button"
onclick="copyToken(this)" onclick="copyToken(this)"
@ -182,7 +182,7 @@
id="show-create-token" id="show-create-token"
type="button" type="button"
onclick="showTokenForm()" onclick="showTokenForm()"
class="mt-3 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500" class="mt-3 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover"
> >
Create Token Create Token
</button> </button>
@ -190,20 +190,20 @@
<!-- Data --> <!-- Data -->
<section> <section>
<h2 class="text-lg font-semibold text-amber-700 mb-3">Data</h2> <h2 class="text-lg font-semibold text-accent mb-3">Data</h2>
<p class="text-sm text-stone-500 mb-3">Export all coffee data as JSON, or restore from a previous backup.</p> <p class="text-sm text-stone-500 mb-3">Export all coffee data as JSON, or restore from a previous backup.</p>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<a <a
href="/api/v1/backup" href="/api/v1/backup"
download download
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500 inline-block" class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover inline-block"
> >
Download Backup Download Backup
</a> </a>
<button <button
type="button" type="button"
class="rounded-md border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-50 hover:text-stone-800" class="rounded-md border bg-surface px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-surface-alt hover:text-stone-800"
onclick="document.getElementById('restore-file-input').click()" onclick="document.getElementById('restore-file-input').click()"
> >
Restore from Backup Restore from Backup
@ -220,36 +220,24 @@
<!-- OpenRouter Usage --> <!-- OpenRouter Usage -->
{% if let Some(usage) = ai_usage %} {% if let Some(usage) = ai_usage %}
<section> <section>
<h2 class="text-lg font-semibold text-amber-700 mb-3">OpenRouter Usage</h2> <h2 class="text-lg font-semibold text-accent mb-3">OpenRouter Usage</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3"> <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm"> <div class="rounded-lg border bg-surface p-4">
<span class="block text-sm text-stone-500">Total Cost</span> <span class="block text-sm text-stone-500">Total Cost</span>
<span class="mt-1 block text-lg font-semibold text-amber-800">{{ usage.total_cost }}</span> <span class="mt-1 block text-lg font-semibold text-stone-800">{{ usage.total_cost }}</span>
</div> </div>
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm"> <div class="rounded-lg border bg-surface p-4">
<span class="block text-sm text-stone-500">API Calls</span> <span class="block text-sm text-stone-500">API Calls</span>
<span class="mt-1 block text-lg font-semibold text-amber-800">{{ usage.total_calls }}</span> <span class="mt-1 block text-lg font-semibold text-stone-800">{{ usage.total_calls }}</span>
</div> </div>
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm"> <div class="rounded-lg border bg-surface p-4">
<span class="block text-sm text-stone-500">Total Tokens</span> <span class="block text-sm text-stone-500">Total Tokens</span>
<span class="mt-1 block text-lg font-semibold text-amber-800">{{ usage.total_tokens }}</span> <span class="mt-1 block text-lg font-semibold text-stone-800">{{ usage.total_tokens }}</span>
</div> </div>
</div> </div>
</section> </section>
{% endif %} {% endif %}
<!-- Sign Out -->
<section>
<form method="post" action="/logout">
<button
type="submit"
class="rounded-md border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-50 hover:text-stone-800"
>
Sign Out
</button>
</form>
</section>
<script> <script>
// --- Passkey form --- // --- Passkey form ---

View file

@ -1,163 +1,8 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Add{% endblock %} {% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import "partials/scan_input.html" as scan %} {% import "partials/location_search.html" as location %} {% block title %}Brewlog · Add{% endblock %}
{% block head %} {% block head %}
<script> <script>
let _userLat = null; {% call location::location_search_js() %}
let _userLng = null;
let _searchTimeout = null;
let _nearbyCafes = [];
const locateUser = (btn) => {
const errorEl = document.getElementById('nearby-error');
const searchWrap = document.getElementById('nearby-search-wrap');
errorEl.classList.add('hidden');
errorEl.textContent = '';
if (!navigator.geolocation) {
errorEl.textContent = 'Geolocation is not supported by your browser.';
errorEl.classList.remove('hidden');
return;
}
btn.textContent = 'Locating\u2026';
btn.disabled = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
_userLat = pos.coords.latitude;
_userLng = pos.coords.longitude;
btn.classList.add('hidden');
searchWrap.classList.remove('hidden');
document.getElementById('nearby-search').focus();
},
(err) => {
btn.textContent = 'Find nearby';
btn.disabled = false;
if (err.code === 1) {
errorEl.textContent = 'Location access denied. Please allow location access and try again.';
} else if (err.code === 3) {
errorEl.textContent = 'Location request timed out. Please try again.';
} else {
errorEl.textContent = 'Could not determine your location. Please try again.';
}
errorEl.classList.remove('hidden');
},
{ enableHighAccuracy: true, timeout: 15000 }
);
};
const onSearchInput = (input) => {
clearTimeout(_searchTimeout);
const resultsEl = document.getElementById('nearby-results');
if (input.value.trim().length < 2) {
resultsEl.classList.add('hidden');
resultsEl.innerHTML = '';
return;
}
_searchTimeout = setTimeout(() => {
searchNearby(input.value.trim());
}, 350);
};
const toggleCitySearch = (checkbox) => {
const locateBtn = document.getElementById('locate-btn');
const cityWrap = document.getElementById('city-search-wrap');
const searchWrap = document.getElementById('nearby-search-wrap');
const resultsEl = document.getElementById('nearby-results');
if (checkbox.checked) {
locateBtn.classList.add('hidden');
cityWrap.classList.remove('hidden');
searchWrap.classList.remove('hidden');
document.getElementById('city-input').focus();
} else {
cityWrap.classList.add('hidden');
document.getElementById('city-input').value = '';
if (_userLat === null) {
searchWrap.classList.add('hidden');
locateBtn.classList.remove('hidden');
locateBtn.textContent = 'Find nearby';
locateBtn.disabled = false;
}
resultsEl.classList.add('hidden');
resultsEl.innerHTML = '';
}
};
const searchNearby = async (query) => {
const resultsEl = document.getElementById('nearby-results');
const errorEl = document.getElementById('nearby-error');
const spinnerEl = document.getElementById('nearby-spinner');
errorEl.classList.add('hidden');
spinnerEl.classList.remove('hidden');
try {
const cityInput = document.getElementById('city-input');
const cityValue = cityInput ? cityInput.value.trim() : '';
let url;
if (cityValue.length >= 2) {
url = `/api/v1/nearby-cafes?near=${encodeURIComponent(cityValue)}&q=${encodeURIComponent(query)}`;
} else {
url = `/api/v1/nearby-cafes?lat=${_userLat}&lng=${_userLng}&q=${encodeURIComponent(query)}`;
}
const resp = await fetch(url, { credentials: 'same-origin' });
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
const cafes = await resp.json();
_nearbyCafes = cafes;
if (cafes.length === 0) {
resultsEl.innerHTML = '<div class="px-3 py-2 text-sm text-stone-500">No results found.</div>';
resultsEl.classList.remove('hidden');
return;
}
let html = '';
for (let i = 0; i < cafes.length; i++) {
const dist = cafes[i].distance_meters;
const distLabel = dist < 1000
? `${dist} m`
: `${(dist / 1000).toFixed(1)} km`;
const location = [cafes[i].city, cafes[i].country].filter(Boolean).join(', ');
html += `<button type="button" class="w-full px-3 py-2 text-left text-sm hover:bg-amber-100 transition" onclick="selectPlace(${i})">`
+ `<span class="font-medium text-amber-900">${escapeHtml(cafes[i].name)}</span>`
+ `<span class="ml-2 text-xs text-stone-500">${escapeHtml(location)} &middot; ${distLabel}</span>`
+ `</button>`;
}
resultsEl.innerHTML = html;
resultsEl.classList.remove('hidden');
} catch (e) {
errorEl.textContent = 'Search failed. Please try again.';
errorEl.classList.remove('hidden');
} finally {
spinnerEl.classList.add('hidden');
}
};
const selectPlace = (index) => {
const cafe = _nearbyCafes[index];
if (!cafe) return;
const form = document.getElementById('cafe-form');
form.querySelector('[name="name"]').value = cafe.name;
form.querySelector('[name="city"]').value = cafe.city || '';
form.querySelector('[name="country"]').value = cafe.country || '';
form.querySelector('[name="latitude"]').value = cafe.latitude;
form.querySelector('[name="longitude"]').value = cafe.longitude;
form.querySelector('[name="website"]').value = cafe.website || '';
document.getElementById('nearby-results').classList.add('hidden');
document.getElementById('nearby-search').value = '';
};
const escapeHtml = (text) => {
const el = document.createElement('span');
el.textContent = text;
return el.innerHTML;
};
</script> </script>
{% endblock %} {% endblock %}
@ -182,52 +27,25 @@
data-signals:_open-bag="true" data-signals:_open-bag="true"
data-signals:_bag-amount="250" data-signals:_bag-amount="250"
> >
<h2 class="text-lg font-semibold text-amber-700 mb-3">Quick Add &mdash; Scan Bag</h2>
<!-- Hidden file input -->
<input type="file" id="scan-photo" accept="image/*" capture="environment" class="hidden"
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('scan-image').value=r.result;document.getElementById('scan-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
<!-- Input: photo or text --> <!-- Input: photo or text -->
<div data-show="!$_scanExtracted" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div data-show="!$_scanExtracted" class="rounded-lg border bg-surface p-5">
<form id="scan-extract-form" <form id="scan-extract-form"
data-on:submit="$_extracting = true; $_extractError = ''; $_roasterName = ''; $_roasterCountry = ''; $_roasterCity = ''; $_roasterHomepage = ''; $_roastName = ''; $_origin = ''; $_region = ''; $_producer = ''; $_process = ''; $_tastingNotes = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})" data-on:submit="$_extracting = true; $_extractError = ''; $_roasterName = ''; $_roasterCountry = ''; $_roasterCity = ''; $_roasterHomepage = ''; $_roastName = ''; $_origin = ''; $_region = ''; $_producer = ''; $_process = ''; $_tastingNotes = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }" data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }"
> >
<input type="hidden" name="image" id="scan-image" /> {% call scan::scan_input("scan-extract-form", "scan-image", "$_extracting", "$_extractError", "Describe the coffee bag…", "Waiting for response…") %}
<div data-show="!$_extracting" class="flex items-center gap-2">
<button
type="button"
onclick="document.getElementById('scan-photo').click()"
class="shrink-0 inline-flex items-center justify-center rounded-md border border-amber-500 p-2.5 text-amber-700 transition hover:bg-amber-50"
aria-label="Take Photo"
>
{% call icons::camera("h-5 w-5") %}
</button>
<input
type="text"
name="prompt"
class="input-field w-full text-sm"
placeholder="Describe the coffee bag&hellip;"
/>
</div>
<div data-show="$_extracting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
{% call icons::spinner("h-5 w-5") %}
Waiting for response&hellip;
</div>
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
</form> </form>
</div> </div>
<!-- Scan result form --> <!-- Scan result form -->
<div data-show="$_scanExtracted" style="display: none"> <div data-show="$_scanExtracted" style="display: none">
<form <form
class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm flex flex-col gap-6" class="rounded-lg border bg-surface p-5 flex flex-col gap-6"
data-on:submit="$_scanSubmitting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})" data-on:submit="$_scanSubmitting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { $_scanSubmitting = false; window.location.href = '/data?type=bags' } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }" data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { $_scanSubmitting = false; window.location.href = '/data?type=bags' } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
> >
<div> <div>
<h3 class="text-base font-semibold text-amber-700">Roaster</h3> <h3 class="text-base font-semibold text-stone-800">Roaster</h3>
<p class="mt-1 text-sm text-stone-600">If this roaster already exists, it will be matched automatically.</p> <p class="mt-1 text-sm text-stone-600">If this roaster already exists, it will be matched automatically.</p>
<div class="mt-4 grid gap-4 sm:grid-cols-2"> <div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
@ -249,7 +67,7 @@
</div> </div>
</div> </div>
<div> <div>
<h3 class="text-base font-semibold text-amber-700">Roast</h3> <h3 class="text-base font-semibold text-stone-800">Roast</h3>
<p class="mt-1 text-sm text-stone-600">Details about this specific coffee.</p> <p class="mt-1 text-sm text-stone-600">Details about this specific coffee.</p>
<div class="mt-4 grid gap-4 sm:grid-cols-2"> <div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
@ -280,8 +98,8 @@
</div> </div>
<div> <div>
<label class="inline-flex items-center gap-2 text-sm cursor-pointer"> <label class="inline-flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" name="open_bag" value="true" class="accent-amber-600" data-bind:_open-bag /> <input type="checkbox" name="open_bag" value="true" class="accent-orange-700" data-bind:_open-bag />
<span class="font-semibold text-amber-700">Open a bag of this coffee</span> <span class="font-semibold text-stone-800">Open a bag of this coffee</span>
</label> </label>
<div data-show="$_openBag" class="mt-3 grid gap-4 sm:grid-cols-2"> <div data-show="$_openBag" class="mt-3 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
@ -291,7 +109,7 @@
</div> </div>
</div> </div>
<p data-show="$_scanError" data-text="$_scanError" style="display:none" class="text-sm text-red-600"></p> <p data-show="$_scanError" data-text="$_scanError" style="display:none" class="text-sm text-red-600"></p>
<div data-show="$_scanSubmitting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700"> <div data-show="$_scanSubmitting" style="display:none" class="flex items-center gap-3 text-sm text-stone-800">
{% call icons::spinner("h-5 w-5") %} {% call icons::spinner("h-5 w-5") %}
Saving&hellip; Saving&hellip;
</div> </div>
@ -299,13 +117,13 @@
<button <button
type="button" type="button"
data-on:click="$_scanExtracted = false" data-on:click="$_scanExtracted = false"
class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-amber-400 hover:text-stone-800" class="rounded-md border px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-accent hover:text-stone-800"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500" class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
> >
Save Roaster &amp; Roast Save Roaster &amp; Roast
</button> </button>
@ -317,8 +135,6 @@
<!-- Manual Add --> <!-- Manual Add -->
<section <section
data-signals:_add-type="'{{ active_type }}'" data-signals:_add-type="'{{ active_type }}'"
data-signals:_add-extracting="false"
data-signals:_add-extract-error="''"
data-signals:_add-submitting="false" data-signals:_add-submitting="false"
data-signals:_add-roaster-name="''" data-signals:_add-roaster-name="''"
data-signals:_add-roaster-country="''" data-signals:_add-roaster-country="''"
@ -330,13 +146,27 @@
data-signals:_add-producer="''" data-signals:_add-producer="''"
data-signals:_add-process="''" data-signals:_add-process="''"
data-signals:_add-tasting-notes="''" data-signals:_add-tasting-notes="''"
data-signals:_add-roaster-id="''"
data-signals:_brew-temp="{{ defaults.water_temp }}" data-signals:_brew-temp="{{ defaults.water_temp }}"
data-signals:_brew-grind="{{ defaults.grind_setting }}" data-signals:_brew-grind="{{ defaults.grind_setting }}"
data-signals:_brew-volume="{{ defaults.water_volume }}" data-signals:_brew-volume="{{ defaults.water_volume }}"
data-signals:_brew-weight="{{ defaults.coffee_weight }}" data-signals:_brew-weight="{{ defaults.coffee_weight }}"
data-signals:_cafe-id="''"
data-signals:_cafe-name="''"
data-signals:_cafe-city="''"
data-signals:_cafe-country="''"
data-signals:_cafe-lat="''"
data-signals:_cafe-lng="''"
data-signals:_cafe-website="''"
data-signals:_cafe-search="''"
data-signals:_cafe-error="''"
data-signals:_city-name="''"
data-signals:_locating="false"
data-signals:_location-found="false"
data-signals:_user-lat="0"
data-signals:_user-lng="0"
data-signals:_reviewing-cafe="false"
> >
<h2 class="text-lg font-semibold text-amber-700 mb-3">Manual Add</h2> <h2 class="text-lg font-semibold text-stone-800 mb-3">Manual Add</h2>
<!-- Entity type selector pills --> <!-- Entity type selector pills -->
<div class="flex flex-wrap gap-2 mb-4"> <div class="flex flex-wrap gap-2 mb-4">
@ -345,13 +175,13 @@
type="button" type="button"
data-on:click="$_addType = '{{ key }}'" data-on:click="$_addType = '{{ key }}'"
class="rounded-full px-3 py-1.5 text-sm font-medium transition border" class="rounded-full px-3 py-1.5 text-sm font-medium transition border"
data-class:bg-amber-600="$_addType === '{{ key }}'" data-class:bg-accent="$_addType === '{{ key }}'"
data-class:text-amber-50="$_addType === '{{ key }}'" data-class:text-accent-text="$_addType === '{{ key }}'"
data-class:border-amber-600="$_addType === '{{ key }}'" data-class:border-accent="$_addType === '{{ key }}'"
data-class:bg-transparent="$_addType !== '{{ key }}'" data-class:bg-transparent="$_addType !== '{{ key }}'"
data-class:text-stone-600="$_addType !== '{{ key }}'" data-class:text-stone-600="$_addType !== '{{ key }}'"
data-class:border-amber-300="$_addType !== '{{ key }}'" data-class:border="$_addType !== '{{ key }}'"
data-class:hover--bg-amber-50="$_addType !== '{{ key }}'" data-class:hover--bg-surface-alt="$_addType !== '{{ key }}'"
> >
{{ label }} {{ label }}
</button> </button>
@ -359,39 +189,12 @@
</div> </div>
<!-- ========== ROASTER FORM ========== --> <!-- ========== ROASTER FORM ========== -->
<div data-show="$_addType === 'roaster'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div data-show="$_addType === 'roaster'" style="display:none" class="rounded-lg border bg-surface p-5">
<div> <div>
<h3 class="text-lg font-semibold text-amber-700">New Roaster</h3> <h3 class="text-lg font-semibold text-stone-800">New Roaster</h3>
<p class="mt-1 text-sm text-stone-600">Provide the core details and Brewlog will keep track of everything for you.</p> <p class="mt-1 text-sm text-stone-600">Provide the core details and Brewlog will keep track of everything for you.</p>
</div> </div>
<input type="file" id="add-roaster-photo" accept="image/*" capture="environment" class="hidden"
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('add-roaster-image').value=r.result;document.getElementById('add-roaster-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
<form id="add-roaster-extract-form" class="mt-4 border-b border-amber-200 pb-4"
data-on:submit="$_addExtracting = true; $_addExtractError = ''; @post('/api/v1/extract-roaster', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_addExtracting) return; if (evt.detail.type === 'finished') { $_addExtracting = false } else if (evt.detail.type === 'error') { $_addExtracting = false; $_addExtractError = 'Extraction failed. Please try again.' }"
>
<input type="hidden" name="image" id="add-roaster-image" />
<div data-show="!$_addExtracting" class="flex flex-wrap items-center gap-3">
<button type="button" onclick="document.getElementById('add-roaster-photo').click()"
class="inline-flex items-center gap-2 rounded-md border border-amber-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50">
{% call icons::camera("h-4 w-4") %}
Extract from photo
</button>
<span class="text-xs text-stone-400">or</span>
<div class="flex flex-1 min-w-[200px] gap-2">
<input type="text" name="prompt" class="input-field w-full text-sm" placeholder="Describe the roaster&hellip;" />
<button type="submit" class="rounded-md border border-amber-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50">Go</button>
</div>
</div>
<div data-show="$_addExtracting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
{% call icons::spinner("h-4 w-4") %}
Waiting for response&hellip;
</div>
<p data-show="$_addExtractError" data-text="$_addExtractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
</form>
<form method="post" action="/api/v1/roasters" class="mt-4 flex flex-col gap-4"> <form method="post" action="/api/v1/roasters" class="mt-4 flex flex-col gap-4">
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
@ -412,7 +215,7 @@
</label> </label>
</div> </div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"> <button type="submit" class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover">
Save Roaster Save Roaster
</button> </button>
</div> </div>
@ -420,56 +223,31 @@
</div> </div>
<!-- ========== ROAST FORM ========== --> <!-- ========== ROAST FORM ========== -->
<div data-show="$_addType === 'roast'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div data-show="$_addType === 'roast'" style="display:none" class="rounded-lg border bg-surface p-5">
{% if roaster_options.is_empty() %} {% if roaster_options.is_empty() %}
<div class="text-sm text-stone-600"> <div class="text-sm text-stone-600">
<h3 class="text-lg font-semibold text-amber-700">Add a roaster first</h3> <h3 class="text-lg font-semibold text-stone-800">Add a roaster first</h3>
<p class="mt-2">Roasts need a roaster. Add a roaster above to enable this form.</p> <p class="mt-2">Roasts need a roaster. Add a roaster above to enable this form.</p>
</div> </div>
{% else %} {% else %}
<div> <div>
<h3 class="text-lg font-semibold text-amber-700">New Roast</h3> <h3 class="text-lg font-semibold text-stone-800">New Roast</h3>
<p class="mt-1 text-sm text-stone-600">Select a roaster, describe the roast, and Brewlog will take care of the rest.</p> <p class="mt-1 text-sm text-stone-600">Select a roaster and fill in the details.</p>
</div> </div>
<input type="file" id="add-roast-photo" accept="image/*" capture="environment" class="hidden"
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('add-roast-image').value=r.result;document.getElementById('add-roast-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
<form id="add-roast-extract-form" class="mt-4 border-b border-amber-200 pb-4"
data-on:submit="$_addExtracting = true; $_addExtractError = ''; @post('/api/v1/extract-roast', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_addExtracting) return; if (evt.detail.type === 'finished') { $_addExtracting = false } else if (evt.detail.type === 'error') { $_addExtracting = false; $_addExtractError = 'Extraction failed. Please try again.' }"
>
<input type="hidden" name="image" id="add-roast-image" />
<div data-show="!$_addExtracting" class="flex flex-wrap items-center gap-3">
<button type="button" onclick="document.getElementById('add-roast-photo').click()"
class="inline-flex items-center gap-2 rounded-md border border-amber-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50">
{% call icons::camera("h-4 w-4") %}
Extract from photo
</button>
<span class="text-xs text-stone-400">or</span>
<div class="flex flex-1 min-w-[200px] gap-2">
<input type="text" name="prompt" class="input-field w-full text-sm" placeholder="Describe the coffee&hellip;" />
<button type="submit" class="rounded-md border border-amber-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50">Go</button>
</div>
</div>
<div data-show="$_addExtracting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
{% call icons::spinner("h-4 w-4") %}
Waiting for response&hellip;
</div>
<p data-show="$_addExtractError" data-text="$_addExtractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
</form>
<form method="post" action="/api/v1/roasts" class="mt-4 flex flex-col gap-4"> <form method="post" action="/api/v1/roasts" class="mt-4 flex flex-col gap-4">
<div class="grid gap-4 sm:grid-cols-2"> <div class="flex flex-col gap-1 text-sm">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Roaster *</span> <span class="text-stone-700">Roaster *</span>
<select name="roaster_id" required class="input-field" data-bind:_add-roaster-id> <searchable-select name="roaster_id" placeholder="Type to search roasters&hellip;">
<option value="">Select a roaster</option>
{% for roaster in roaster_options %} {% for roaster in roaster_options %}
<option value="{{ roaster.id }}">{{ roaster.name }}</option> <button type="button" value="{{ roaster.id }}" data-display="{{ roaster.name }}"
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition">
<span class="font-medium text-stone-800">{{ roaster.name }}</span>
</button>
{% endfor %} {% endfor %}
</select> </searchable-select>
</label> </div>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Roast Name *</span> <span class="text-stone-700">Roast Name *</span>
<input type="text" name="name" required class="input-field" placeholder="Ethiopia Yirgacheffe" data-bind:_add-roast-name /> <input type="text" name="name" required class="input-field" placeholder="Ethiopia Yirgacheffe" data-bind:_add-roast-name />
@ -496,7 +274,7 @@
</label> </label>
</div> </div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"> <button type="submit" class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover">
Save Roast Save Roast
</button> </button>
</div> </div>
@ -505,36 +283,31 @@
</div> </div>
<!-- ========== BAG FORM ========== --> <!-- ========== BAG FORM ========== -->
<div data-show="$_addType === 'bag'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div data-show="$_addType === 'bag'" style="display:none" class="rounded-lg border bg-surface p-5">
{% if roaster_options.is_empty() %} {% if roast_options.is_empty() %}
<div class="text-sm text-stone-600"> <div class="text-sm text-stone-600">
<h3 class="text-lg font-semibold text-amber-700">Add a roaster first</h3> <h3 class="text-lg font-semibold text-stone-800">Add a roast first</h3>
<p class="mt-2">Bags need a roaster and a roast. Add a roaster to enable this form.</p> <p class="mt-2">Bags need a roast. Add a roaster and roast to enable this form.</p>
</div> </div>
{% else %} {% else %}
<div> <div>
<h3 class="text-lg font-semibold text-amber-700">New Bag</h3> <h3 class="text-lg font-semibold text-stone-800">New Bag</h3>
<p class="mt-1 text-sm text-stone-600">Select a roaster, then a roast, and enter the details.</p> <p class="mt-1 text-sm text-stone-600">Select a roast and enter the details.</p>
</div> </div>
<form method="post" action="/api/v1/bags" class="mt-4 flex flex-col gap-4"> <form method="post" action="/api/v1/bags" class="mt-4 flex flex-col gap-4">
<div class="grid gap-4 sm:grid-cols-2"> <div class="flex flex-col gap-1 text-sm">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Roaster *</span>
<select name="roaster_id" required class="input-field"
data-on:change="@get('/api/v1/roasts?roaster_id=' + evt.target.value, {responseOverrides: {selector: '#add-roast-select-options', mode: 'replace'}})">
<option value="">Select a roaster</option>
{% for roaster in roaster_options %}
<option value="{{ roaster.id }}">{{ roaster.name }}</option>
{% endfor %}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Roast *</span> <span class="text-stone-700">Roast *</span>
<select name="roast_id" required class="input-field" id="add-roast-select"> <searchable-select name="roast_id" placeholder="Type to search roasts&hellip;">
<option value="">Select a roast</option> {% for roast in roast_options %}
<optgroup id="add-roast-select-options"></optgroup> <button type="button" value="{{ roast.id }}" data-display="{{ roast.label }}"
</select> class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition">
</label> <span class="font-medium text-stone-800">{{ roast.name }}</span>
<span class="ml-2 text-xs text-stone-500">{{ roast.roaster_name }}</span>
</button>
{% endfor %}
</searchable-select>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Roast Date</span> <span class="text-stone-700">Roast Date</span>
<input type="date" name="roast_date" class="input-field" /> <input type="date" name="roast_date" class="input-field" />
@ -545,7 +318,7 @@
</label> </label>
</div> </div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"> <button type="submit" class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover">
Save Bag Save Bag
</button> </button>
</div> </div>
@ -554,33 +327,54 @@
</div> </div>
<!-- ========== BREW FORM ========== --> <!-- ========== BREW FORM ========== -->
<div data-show="$_addType === 'brew'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div data-show="$_addType === 'brew'" style="display:none" class="rounded-lg border bg-surface p-5">
{% if bag_options.is_empty() %} {% if bag_options.is_empty() %}
<div class="text-sm text-stone-600"> <div class="text-sm text-stone-600">
<h3 class="text-lg font-semibold text-amber-700">Open a bag first</h3> <h3 class="text-lg font-semibold text-stone-800">Open a bag first</h3>
<p class="mt-2">Brews need an open bag of coffee. Add a bag to enable this form.</p> <p class="mt-2">Brews need an open bag of coffee. Add a bag to enable this form.</p>
</div> </div>
{% else if grinder_options.is_empty() || brewer_options.is_empty() %} {% else if grinder_options.is_empty() || brewer_options.is_empty() %}
<div class="text-sm text-stone-600"> <div class="text-sm text-stone-600">
<h3 class="text-lg font-semibold text-amber-700">Add your gear first</h3> <h3 class="text-lg font-semibold text-stone-800">Add your gear first</h3>
<p class="mt-2">Brews need a grinder and brewer. Add your gear to enable this form.</p> <p class="mt-2">Brews need a grinder and brewer. Add your gear to enable this form.</p>
</div> </div>
{% else %} {% else %}
<div> <div>
<h3 class="text-lg font-semibold text-amber-700">New Brew</h3> <h3 class="text-lg font-semibold text-stone-800">New Brew</h3>
<p class="mt-1 text-sm text-stone-600">Log a cup of coffee.</p> <p class="mt-1 text-sm text-stone-600">Log a cup of coffee.</p>
</div> </div>
<form method="post" action="/api/v1/brews" class="mt-4 flex flex-col gap-4 overflow-hidden"> <form method="post" action="/api/v1/brews" class="mt-4 flex flex-col gap-6 overflow-hidden">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <!-- Coffee -->
<label class="flex flex-col gap-1 text-sm"> <div>
<h4 class="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-3">Coffee</h4>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Bag *</span> <span class="text-stone-700">Bag *</span>
<select name="bag_id" required class="input-field"> <searchable-select name="bag_id" placeholder="Type to search bags&hellip;">
<option value="">Select a bag</option>
{% for bag in bag_options %} {% for bag in bag_options %}
<option value="{{ bag.id }}"{% if bag.id == defaults.bag_id %} selected{% endif %}>{{ bag.label }}</option> <button type="button" value="{{ bag.id }}" data-display="{{ bag.roast_name }}"
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition">
<span class="font-medium text-stone-800">{{ bag.roast_name }}</span>
<span class="ml-2 text-xs text-stone-500">{{ bag.roaster_name }} &middot; {{ bag.remaining }}</span>
</button>
{% endfor %} {% endfor %}
</select> </searchable-select>
</label> </div>
<div class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Weight (g) *</span>
<div class="flex items-center gap-2">
<button type="button" class="btn-adjust" data-on:click="$_brewWeight = Math.max(1, $_brewWeight - 0.5)">-</button>
<input type="number" name="coffee_weight" step="any" min="1" required class="input-field flex-1 text-center" data-attr:value="$_brewWeight" data-on:input="$_brewWeight = this.valueAsNumber" />
<button type="button" class="btn-adjust" data-on:click="$_brewWeight = $_brewWeight + 0.5">+</button>
</div>
</div>
</div>
</div>
<!-- Grinder -->
<div>
<h4 class="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-3">Grinder</h4>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Grinder *</span> <span class="text-stone-700">Grinder *</span>
<select name="grinder_id" required class="input-field"> <select name="grinder_id" required class="input-field">
@ -589,6 +383,21 @@
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<div class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Grind Setting *</span>
<div class="flex items-center gap-2">
<button type="button" class="btn-adjust" data-on:click="$_brewGrind = Math.max(0, $_brewGrind - 0.5)">-</button>
<input type="number" name="grind_setting" step="any" min="0" required class="input-field flex-1 text-center" data-attr:value="$_brewGrind" data-on:input="$_brewGrind = this.valueAsNumber" />
<button type="button" class="btn-adjust" data-on:click="$_brewGrind = $_brewGrind + 0.5">+</button>
</div>
</div>
</div>
</div>
<!-- Brewer -->
<div>
<h4 class="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-3">Brewer</h4>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Brewer *</span> <span class="text-stone-700">Brewer *</span>
<select name="brewer_id" required class="input-field"> <select name="brewer_id" required class="input-field">
@ -606,24 +415,15 @@
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
</div>
</div>
<!-- Water -->
<div>
<h4 class="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-3">Water</h4>
<div class="grid gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1 text-sm"> <div class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Coffee (g) *</span> <span class="text-stone-700">Volume (ml) *</span>
<div class="flex items-center gap-2">
<button type="button" class="btn-adjust" data-on:click="$_brewWeight = Math.max(1, $_brewWeight - 0.5)">-</button>
<input type="number" name="coffee_weight" step="any" min="1" required class="input-field flex-1 text-center" data-attr:value="$_brewWeight" data-on:input="$_brewWeight = this.valueAsNumber" />
<button type="button" class="btn-adjust" data-on:click="$_brewWeight = $_brewWeight + 0.5">+</button>
</div>
</div>
<div class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Grind Setting *</span>
<div class="flex items-center gap-2">
<button type="button" class="btn-adjust" data-on:click="$_brewGrind = Math.max(0, $_brewGrind - 0.5)">-</button>
<input type="number" name="grind_setting" step="any" min="0" required class="input-field flex-1 text-center" data-attr:value="$_brewGrind" data-on:input="$_brewGrind = this.valueAsNumber" />
<button type="button" class="btn-adjust" data-on:click="$_brewGrind = $_brewGrind + 0.5">+</button>
</div>
</div>
<div class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Water (ml) *</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button type="button" class="btn-adjust" data-on:click="$_brewVolume = Math.max(10, $_brewVolume - 10)">-</button> <button type="button" class="btn-adjust" data-on:click="$_brewVolume = Math.max(10, $_brewVolume - 10)">-</button>
<input type="number" name="water_volume" step="any" min="10" required class="input-field flex-1 text-center" data-attr:value="$_brewVolume" data-on:input="$_brewVolume = this.valueAsNumber" /> <input type="number" name="water_volume" step="any" min="10" required class="input-field flex-1 text-center" data-attr:value="$_brewVolume" data-on:input="$_brewVolume = this.valueAsNumber" />
@ -631,7 +431,7 @@
</div> </div>
</div> </div>
<div class="flex flex-col gap-1 text-sm"> <div class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Temp (C) *</span> <span class="text-stone-700">Temp (&deg;C) *</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button type="button" class="btn-adjust" data-on:click="$_brewTemp = Math.max(0, $_brewTemp - 0.5)">-</button> <button type="button" class="btn-adjust" data-on:click="$_brewTemp = Math.max(0, $_brewTemp - 0.5)">-</button>
<input type="number" name="water_temp" step="any" min="0" max="100" required class="input-field flex-1 text-center" data-attr:value="$_brewTemp" data-on:input="$_brewTemp = this.valueAsNumber" /> <input type="number" name="water_temp" step="any" min="0" max="100" required class="input-field flex-1 text-center" data-attr:value="$_brewTemp" data-on:input="$_brewTemp = this.valueAsNumber" />
@ -639,8 +439,9 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"> <button type="submit" class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover">
Log Brew Log Brew
</button> </button>
</div> </div>
@ -649,9 +450,9 @@
</div> </div>
<!-- ========== GEAR FORM ========== --> <!-- ========== GEAR FORM ========== -->
<div data-show="$_addType === 'gear'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div data-show="$_addType === 'gear'" style="display:none" class="rounded-lg border bg-surface p-5">
<div> <div>
<h3 class="text-lg font-semibold text-amber-700">New Gear</h3> <h3 class="text-lg font-semibold text-stone-800">New Gear</h3>
<p class="mt-1 text-sm text-stone-600">Add brewing equipment to your collection.</p> <p class="mt-1 text-sm text-stone-600">Add brewing equipment to your collection.</p>
</div> </div>
<form method="post" action="/api/v1/gear" class="mt-4 flex flex-col gap-4"> <form method="post" action="/api/v1/gear" class="mt-4 flex flex-col gap-4">
@ -675,7 +476,7 @@
</label> </label>
</div> </div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"> <button type="submit" class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover">
Save Gear Save Gear
</button> </button>
</div> </div>
@ -683,65 +484,60 @@
</div> </div>
<!-- ========== CAFE FORM ========== --> <!-- ========== CAFE FORM ========== -->
<div data-show="$_addType === 'cafe'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div data-show="$_addType === 'cafe'" style="display:none" class="rounded-lg border bg-surface p-5">
<div> <div>
<h3 class="text-lg font-semibold text-amber-700">New Cafe</h3> <h3 class="text-lg font-semibold text-stone-800">New Cafe</h3>
<p class="mt-1 text-sm text-stone-600">Add a cafe you have visited or want to remember.</p> <p class="mt-1 text-sm text-stone-600">Search for a nearby cafe or enter details manually.</p>
</div> </div>
<!-- Location search -->
<div class="mt-4 border-b pb-4" data-show="!$_reviewingCafe">
{% call location::location_search("$_cafeError") %}
<p data-show="$_cafeError" data-text="$_cafeError" style="display:none" class="mt-2 text-sm text-red-600"></p>
</div>
<!-- Selected cafe indicator -->
<div class="mt-4 border-b pb-4 flex items-center justify-between" data-show="$_reviewingCafe" style="display:none">
<div class="flex items-center gap-2">
{% call icons::location("h-4 w-4 text-accent shrink-0") %}
<span class="text-sm font-medium text-stone-800" data-text="$_cafeName"></span>
<span class="text-xs text-stone-500" data-show="$_cafeCity" data-text="$_cafeCity" style="display:none"></span>
</div>
<button type="button"
class="text-xs text-stone-500 hover:text-stone-700"
data-on:click="$_reviewingCafe = false; $_cafeName = ''; $_cafeCity = ''; $_cafeCountry = ''; $_cafeLat = ''; $_cafeLng = ''; $_cafeWebsite = ''"
>Back</button>
</div>
<form id="cafe-form" method="post" action="/api/v1/cafes" class="mt-4 flex flex-col gap-4"> <form id="cafe-form" method="post" action="/api/v1/cafes" class="mt-4 flex flex-col gap-4">
<div class="relative border-b border-amber-200 pb-4">
<div class="flex flex-wrap items-center gap-3">
<button type="button" id="locate-btn" onclick="locateUser(this)"
class="inline-flex items-center gap-2 rounded-md border border-amber-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50">
{% call icons::location("h-4 w-4") %}
Find nearby
</button>
<label class="inline-flex items-center gap-1.5 text-sm text-stone-600 cursor-pointer select-none">
<input type="checkbox" onchange="toggleCitySearch(this)" class="accent-amber-600" />
Search by city
</label>
<div id="city-search-wrap" class="hidden flex-1 min-w-[140px]">
<input type="text" id="city-input" class="input-field w-full text-sm" placeholder="e.g. Tokyo, London" autocomplete="off" />
</div>
<div id="nearby-search-wrap" class="hidden relative flex-1 min-w-[200px]">
<input type="text" id="nearby-search" oninput="onSearchInput(this)" class="input-field w-full text-sm pr-8" placeholder="Search for a cafe&hellip;" autocomplete="off" />
<span id="nearby-spinner" class="hidden absolute right-2 top-1/2 -translate-y-1/2">
{% call icons::spinner("h-4 w-4") %}
</span>
</div>
</div>
<p id="nearby-error" class="hidden mt-2 text-sm text-red-600"></p>
<div id="nearby-results"
class="hidden absolute left-0 right-0 z-10 mt-1 max-h-60 overflow-y-auto rounded-md border border-amber-300 bg-white shadow-lg divide-y divide-amber-100"></div>
</div>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Name *</span> <span class="text-stone-700">Name *</span>
<input type="text" name="name" required class="input-field" placeholder="Blue Bottle Coffee" /> <input type="text" name="name" required class="input-field" placeholder="Blue Bottle Coffee" data-bind:_cafe-name />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">City *</span> <span class="text-stone-700">City *</span>
<input type="text" name="city" required class="input-field" placeholder="San Francisco" /> <input type="text" name="city" required class="input-field" placeholder="San Francisco" data-bind:_cafe-city />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Country *</span> <span class="text-stone-700">Country *</span>
<input type="text" name="country" required class="input-field" placeholder="United States" /> <input type="text" name="country" required class="input-field" placeholder="United States" data-bind:_cafe-country />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Website</span> <span class="text-stone-700">Website</span>
<input type="url" name="website" class="input-field" placeholder="https://bluebottlecoffee.com" /> <input type="url" name="website" class="input-field" placeholder="https://bluebottlecoffee.com" data-bind:_cafe-website />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Latitude *</span> <span class="text-stone-700">Latitude *</span>
<input type="number" name="latitude" step="any" required class="input-field" placeholder="37.7749" /> <input type="number" name="latitude" step="any" required class="input-field" placeholder="37.7749" data-bind:_cafe-lat />
</label> </label>
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Longitude *</span> <span class="text-stone-700">Longitude *</span>
<input type="number" name="longitude" step="any" required class="input-field" placeholder="-122.4194" /> <input type="number" name="longitude" step="any" required class="input-field" placeholder="-122.4194" data-bind:_cafe-lng />
</label> </label>
</div> </div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"> <button type="submit" class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover">
Save Cafe Save Cafe
</button> </button>
</div> </div>
@ -749,49 +545,51 @@
</div> </div>
<!-- ========== CUP FORM ========== --> <!-- ========== CUP FORM ========== -->
<div data-show="$_addType === 'cup'" style="display:none" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div data-show="$_addType === 'cup'" style="display:none" class="rounded-lg border bg-surface p-5">
{% if roast_options.is_empty() || cafe_options.is_empty() %}
<div class="text-sm text-stone-600">
<h3 class="text-lg font-semibold text-stone-800">Add a roast and cafe first</h3>
<p class="mt-2">Cups need both a roast and a cafe. Add them above to enable this form.</p>
</div>
{% else %}
<div> <div>
<h3 class="text-lg font-semibold text-amber-700">New Cup</h3> <h3 class="text-lg font-semibold text-stone-800">New Cup</h3>
<p class="mt-1 text-sm text-stone-600">Record a coffee you had at a cafe.</p> <p class="mt-1 text-sm text-stone-600">Record a coffee you had at a cafe.</p>
</div> </div>
<form method="post" action="/api/v1/cups" class="mt-4 flex flex-col gap-4"> <form method="post" action="/api/v1/cups" class="mt-4 flex flex-col gap-4">
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <div class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Coffee *</span> <span class="text-stone-700">Coffee *</span>
<select name="roast_id" required class="input-field"> <searchable-select name="roast_id" placeholder="Type to search roasts&hellip;">
<option value="">Select a roast...</option>
{% for roast in roast_options %} {% for roast in roast_options %}
<option value="{{ roast.id }}">{{ roast.label }}</option> <button type="button" value="{{ roast.id }}" data-display="{{ roast.name }}"
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition">
<span class="font-medium text-stone-800">{{ roast.name }}</span>
<span class="ml-2 text-xs text-stone-500">{{ roast.roaster_name }}</span>
</button>
{% endfor %} {% endfor %}
</select> </searchable-select>
</label> </div>
<label class="flex flex-col gap-1 text-sm"> <div class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Cafe *</span> <span class="text-stone-700">Cafe *</span>
<select name="cafe_id" required class="input-field"> <searchable-select name="cafe_id" placeholder="Type to search cafes&hellip;">
<option value="">Select a cafe...</option>
{% for cafe in cafe_options %} {% for cafe in cafe_options %}
<option value="{{ cafe.id }}">{{ cafe.label }}</option> <button type="button" value="{{ cafe.id }}" data-display="{{ cafe.name }}"
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition">
<span class="font-medium text-stone-800">{{ cafe.name }}</span>
<span class="ml-2 text-xs text-stone-500">{{ cafe.city }}</span>
</button>
{% endfor %} {% endfor %}
</select> </searchable-select>
</label> </div>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Rating</span>
<select name="rating" class="input-field">
<option value="">No rating</option>
<option value="5">5 - Exceptional</option>
<option value="4">4 - Great</option>
<option value="3">3 - Good</option>
<option value="2">2 - Fair</option>
<option value="1">1 - Poor</option>
</select>
</label>
</div> </div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<button type="submit" class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"> <button type="submit" class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover">
Save Cup Save Cup
</button> </button>
</div> </div>
</form> </form>
{% endif %}
</div> </div>
</section> </section>
{% endblock %} {% endblock %}

View file

@ -5,11 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Brewlog{% endblock %}</title> <title>{% block title %}Brewlog{% endblock %}</title>
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
<script src="https://cdn.tailwindcss.com"></script> <script>
(() => {
const stored = localStorage.getItem("theme");
if (stored === "dark" || (!stored && matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.setAttribute("data-theme", "dark");
}
})();
</script>
<script <script
type="module" type="module"
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"
></script> ></script>
<script src="/components/photo-capture.js"></script>
<script src="/components/searchable-select.js"></script>
{% block head %}{% endblock %} {% block head %}{% endblock %}
<script> <script>
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
@ -88,11 +97,11 @@
}); });
</script> </script>
</head> </head>
<body class="min-h-screen bg-amber-50 text-stone-900"> <body class="min-h-screen bg-page text-stone-900">
<main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-10"> <main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-10">
{% include "nav.html" %} {% block content %}{% endblock %} {% include "nav.html" %} {% block content %}{% endblock %}
<footer class="mt-8 text-xs text-stone-500"> <footer class="mt-8 text-center text-xs tracking-widest text-stone-400" style="font-variant: small-caps">
<p>Built with Axum, Tailwind CSS, and Datastar.</p> <p>B{rew}log by <a href="https://jnsgr.uk" class="text-stone-500 hover:text-accent transition" target="_blank" rel="noreferrer noopener">jnsgruk</a> · {{ version_info.version }} · <a href="https://github.com/jnsgruk/brewlog/commit/{{ version_info.commit }}" class="text-stone-500 hover:text-accent transition" target="_blank" rel="noreferrer noopener">{{ version_info.commit }}</a></p>
</footer> </footer>
</main> </main>
</body> </body>

View file

@ -1,33 +1,17 @@
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Check In{% endblock %} {% extends "base.html" %} {% import "partials/icons.html" as icons %} {% import "partials/scan_input.html" as scan %} {% import "partials/location_search.html" as location %} {% block title %}Brewlog · Check In{% endblock %}
{% block head %} {% block head %}
<script> <script>
const locateUser = () => { const filterList = (input, listId) => {
const root = document.getElementById('checkin-root'); const q = input.value.toLowerCase();
const emit = (name, detail) => const list = document.getElementById(listId);
root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true })); list.classList.toggle('hidden', !q);
list.querySelectorAll('button').forEach((b) => {
if (!navigator.geolocation) { b.style.display = b.textContent.toLowerCase().includes(q) ? '' : 'none';
emit('location-error', { message: 'Geolocation not supported by your browser.' }); });
return;
}
emit('location-start');
navigator.geolocation.getCurrentPosition(
(pos) => {
emit('location-found', { lat: pos.coords.latitude, lng: pos.coords.longitude });
},
(err) => {
if (err.code === 1) {
emit('location-error', { message: 'Location access denied. Search by name instead.' });
} else {
emit('location-error', { message: 'Could not determine location. Search by name instead.' });
}
},
{ enableHighAccuracy: true, timeout: 15000 },
);
}; };
{% call location::location_search_js() %}
</script> </script>
{% endblock %} {% endblock %}
@ -46,21 +30,17 @@ const locateUser = () => {
data-signals:_roast-id="''" data-signals:_roast-id="''"
data-signals:_roast-name="''" data-signals:_roast-name="''"
data-signals:_roaster-name="''" data-signals:_roaster-name="''"
data-signals:_rating="0"
data-signals:_error="''" data-signals:_error="''"
data-signals:_submitting="false" data-signals:_submitting="false"
data-signals:_locating="false" data-signals:_locating="false"
data-signals:_location-found="false" data-signals:_location-found="false"
data-signals:_user-lat="0" data-signals:_user-lat="0"
data-signals:_user-lng="0" data-signals:_user-lng="0"
data-signals:_search-by-city="false"
data-signals:_city-name="''" data-signals:_city-name="''"
data-signals:_reviewing-cafe="false" data-signals:_reviewing-cafe="false"
data-signals:_scan-waiting="false" data-signals:_scan-waiting="false"
data-signals:_scan-error="''"
data-signals:_scan-success="''" data-signals:_scan-success="''"
data-on:location-found="$_locating = false; $_locationFound = true; $_userLat = evt.detail.lat; $_userLng = evt.detail.lng; @get('/api/v1/nearby-cafes?lat=' + evt.detail.lat + '&lng=' + evt.detail.lng + '&q=coffee', {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})"
data-on:location-error="$_locating = false; $_error = evt.detail.message"
data-on:location-start="$_locating = true"
> >
<header class="flex flex-col gap-2"> <header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Check In</h1> <h1 class="text-3xl font-semibold">Check In</h1>
@ -69,15 +49,56 @@ const locateUser = () => {
</p> </p>
</header> </header>
<!-- Step progress -->
<div class="relative mx-auto mt-6" style="width: 80%">
<!-- Background track (continuous line through dot centers) -->
<div class="absolute bg-stone-300" style="height: 3px; top: 8px; left: 10px; right: 10px"></div>
<!-- Accent overlay: first segment -->
<div class="absolute bg-accent" style="height: 3px; top: 8px; left: 10px; right: 50%; display: none"
data-show="$_step > 1"
></div>
<!-- Accent overlay: second segment -->
<div class="absolute bg-accent" style="height: 3px; top: 8px; left: 50%; right: 10px; display: none"
data-show="$_step > 2"
></div>
<div class="relative flex justify-between">
<div class="flex flex-col items-center">
<div class="h-5 w-5 rounded-full border-[3px] bg-page transition-all"
data-class="{'border-accent': $_step >= 1, 'border-stone-300': $_step < 1}"
></div>
<span class="mt-2 text-xs font-medium transition-colors"
data-class="{'text-accent': $_step === 1, 'text-stone-800': $_step > 1}"
>Cafe</span>
</div>
<div class="flex flex-col items-center">
<div class="h-5 w-5 rounded-full border-[3px] bg-page transition-all"
data-class="{'border-accent': $_step >= 2, 'border-stone-300': $_step < 2}"
></div>
<span class="mt-2 text-xs font-medium transition-colors"
data-class="{'text-accent': $_step === 2, 'text-stone-800': $_step > 2, 'text-stone-400': $_step < 2}"
>Coffee</span>
</div>
<div class="flex flex-col items-center">
<div class="h-5 w-5 rounded-full border-[3px] bg-page transition-all"
data-class="{'border-accent': $_step >= 3, 'border-stone-300': $_step < 3}"
></div>
<span class="mt-2 text-xs font-medium transition-colors"
data-class="{'text-accent': $_step === 3, 'text-stone-400': $_step < 3}"
>Submit</span>
</div>
</div>
</div>
<!-- Selected cafe summary (shown after selection) --> <!-- Selected cafe summary (shown after selection) -->
<div <div
class="mt-4 rounded-lg border border-amber-300 bg-amber-100/80 px-4 py-3 shadow-sm flex items-center justify-between" class="mt-4 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between"
data-show="$_cafeName && !$_reviewingCafe" data-show="$_step > 1 && $_cafeName && !$_reviewingCafe"
style="display: none" style="display: none"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{% call icons::location("h-4 w-4 text-amber-600 shrink-0") %} {% call icons::location("h-4 w-4 text-accent shrink-0") %}
<span class="font-medium text-amber-800" data-text="$_cafeName"></span> <span class="font-medium text-stone-800" data-text="$_cafeName"></span>
<span class="text-xs text-stone-500" data-show="$_cafeCity" data-text="$_cafeCity" style="display: none"></span> <span class="text-xs text-stone-500" data-show="$_cafeCity" data-text="$_cafeCity" style="display: none"></span>
</div> </div>
<button <button
@ -89,13 +110,13 @@ const locateUser = () => {
<!-- Selected roast summary (shown after selection) --> <!-- Selected roast summary (shown after selection) -->
<div <div
class="mt-2 rounded-lg border border-amber-300 bg-amber-100/80 px-4 py-3 shadow-sm flex items-center justify-between" class="mt-2 rounded-lg border bg-surface px-4 py-3 flex items-center justify-between"
data-show="$_roastName && $_step > 2" data-show="$_roastName && $_step > 2"
style="display: none" style="display: none"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{% call icons::bag("h-4 w-4 text-amber-600 shrink-0") %} {% call icons::bag("h-4 w-4 text-accent shrink-0") %}
<span class="font-medium text-amber-800" data-text="$_roastName"></span> <span class="font-medium text-stone-800" data-text="$_roastName"></span>
<span class="text-xs text-stone-500" data-show="$_roasterName" data-text="$_roasterName" style="display: none"></span> <span class="text-xs text-stone-500" data-show="$_roasterName" data-text="$_roasterName" style="display: none"></span>
</div> </div>
<button <button
@ -105,33 +126,6 @@ const locateUser = () => {
>Change</button> >Change</button>
</div> </div>
<!-- Step indicators -->
<div class="mt-4 flex gap-2 text-sm">
<span
class="rounded-full px-3 py-1 font-medium"
data-class:bg-amber-600="$_step === 1"
data-class:text-white="$_step === 1"
data-class:bg-amber-200="$_step > 1"
data-class:text-amber-800="$_step > 1"
>1. Cafe</span>
<span
class="rounded-full px-3 py-1 font-medium"
data-class:bg-amber-600="$_step === 2"
data-class:text-white="$_step === 2"
data-class:bg-amber-200="$_step > 2"
data-class:text-amber-800="$_step > 2"
data-class:bg-stone-200="$_step < 2"
data-class:text-stone-500="$_step < 2"
>2. Coffee</span>
<span
class="rounded-full px-3 py-1 font-medium"
data-class:bg-amber-600="$_step === 3"
data-class:text-white="$_step === 3"
data-class:bg-stone-200="$_step < 3"
data-class:text-stone-500="$_step < 3"
>3. Rate</span>
</div>
<!-- Error display --> <!-- Error display -->
<p <p
class="mt-3 text-sm text-red-600" class="mt-3 text-sm text-red-600"
@ -142,55 +136,17 @@ const locateUser = () => {
<!-- Step 1: Cafe selection --> <!-- Step 1: Cafe selection -->
<div class="mt-4" data-show="$_step === 1" style="display: none"> <div class="mt-4" data-show="$_step === 1" style="display: none">
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div class="rounded-lg border bg-surface p-5">
<div class="flex flex-wrap items-center gap-3 mb-4" data-show="!$_reviewingCafe"> <div data-show="!$_reviewingCafe">
<button {% call location::location_search("$_error") %}
type="button"
data-on:click="locateUser()"
data-show="!$_searchByCity"
class="inline-flex items-center gap-2 rounded-md border border-amber-500 px-4 py-2.5 text-sm font-medium text-amber-700 transition hover:bg-amber-50"
data-attr:disabled="$_locating || $_locationFound"
>
{% call icons::location("h-4 w-4") %}
<span data-text="$_locating ? 'Locating\u2026' : $_locationFound ? 'Location found' : 'Use My Location'">Use My Location</span>
</button>
<div data-show="!$_searchByCity && $_locating" style="display: none">
{% call icons::spinner("h-5 w-5") %}
</div> </div>
<input
type="text"
data-bind:_city-name
data-show="$_searchByCity"
style="display: none"
class="input-field flex-1 min-w-[200px] text-sm"
placeholder="City name&hellip;"
data-on:input__debounce.350ms="$_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})"
/>
<label class="flex items-center gap-1.5 text-xs text-stone-500 cursor-pointer select-none">
<input type="checkbox" data-bind:_search-by-city class="accent-amber-600" />
Search by city
</label>
</div>
<input
type="text"
data-bind:_cafe-search
data-show="!$_reviewingCafe"
class="input-field w-full text-sm"
placeholder="Search for a cafe&hellip;"
data-on:input__debounce.350ms="if ($_searchByCity) { $_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) } else { $_locationFound && @get('/api/v1/nearby-cafes?lat=' + $_userLat + '&lng=' + $_userLng + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) }"
/>
<!-- Nearby results (replaced by server fragment) -->
<div data-show="!$_reviewingCafe" id="nearby-results" class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border border-amber-200 bg-white"></div>
<!-- Review form for new cafe from Foursquare --> <!-- Review form for new cafe from Foursquare -->
<div <div
class="mt-3 border-t border-amber-200 pt-4"
data-show="$_reviewingCafe" data-show="$_reviewingCafe"
style="display: none" style="display: none"
> >
<h3 class="text-base font-semibold text-amber-700 mb-1">Confirm cafe details</h3> <h3 class="text-base font-semibold text-stone-800 mb-1">Confirm cafe details</h3>
<p class="text-sm text-stone-600 mb-3">Review and edit the details before saving.</p> <p class="text-sm text-stone-600 mb-3">Review and edit the details before saving.</p>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
@ -213,34 +169,45 @@ const locateUser = () => {
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<button <button
type="button" type="button"
class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-amber-400 hover:text-stone-800" class="rounded-md border px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-accent hover:text-stone-800"
data-on:click="$_reviewingCafe = false; $_cafeName = ''; $_cafeCity = ''; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''" data-on:click="$_reviewingCafe = false; $_cafeName = ''; $_cafeCity = ''; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''"
> >
Cancel Back
</button> </button>
<button <button
type="button" type="button"
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500" class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
data-on:click="$_reviewingCafe = false; $_step = 2" data-on:click="$_reviewingCafe = false; $_step = 2"
> >
Confirm Next
</button> </button>
</div> </div>
</div> </div>
<!-- Saved cafes --> <!-- Saved cafes -->
{% if !cafe_options.is_empty() %} {% if !cafe_options.is_empty() %}
<div class="mt-3 border-t border-amber-200 pt-3" data-show="!$_reviewingCafe"> <div class="mt-3 border-t pt-3" data-show="!$_reviewingCafe">
<p class="mb-2 text-xs text-stone-500">Or choose a saved cafe:</p> <p class="mb-2 text-xs text-stone-500">Or choose a saved cafe:</p>
<select <input
type="text"
class="input-field w-full text-sm" class="input-field w-full text-sm"
data-on:change="if (el.value) { const opt = el.options[el.selectedIndex]; $_cafeId = el.value; $_cafeName = opt.dataset.name || ''; $_cafeCity = opt.dataset.city || ''; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''; $_step = 2 }" placeholder="Type to search saved cafes&hellip;"
> oninput="filterList(this, 'cafe-options')"
<option value="">Select a cafe&hellip;</option> />
<div id="cafe-options" class="hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface">
{% for cafe in cafe_options %} {% for cafe in cafe_options %}
<option value="{{ cafe.id }}" data-name="{{ cafe.name }}" data-city="{{ cafe.city }}">{{ cafe.label }}</option> <button
type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition"
data-on:click="$_cafeId = '{{ cafe.id }}'; $_cafeName = el.dataset.name; $_cafeCity = el.dataset.city; $_cafeCountry = ''; $_cafeLat = 0; $_cafeLng = 0; $_cafeWebsite = ''; $_step = 2"
data-name="{{ cafe.name }}"
data-city="{{ cafe.city }}"
>
<span class="font-medium text-stone-800">{{ cafe.name }}</span>
<span class="ml-2 text-xs text-stone-500">{{ cafe.city }}</span>
</button>
{% endfor %} {% endfor %}
</select> </div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@ -248,63 +215,20 @@ const locateUser = () => {
<!-- Step 2: Roast identification --> <!-- Step 2: Roast identification -->
<div class="mt-4" data-show="$_step === 2" style="display: none"> <div class="mt-4" data-show="$_step === 2" style="display: none">
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div class="rounded-lg border bg-surface p-5">
<h3 class="text-base font-semibold text-amber-700 mb-3">What are you drinking?</h3> <h3 class="text-base font-semibold text-stone-800 mb-3">What are you drinking?</h3>
<div data-show="!$_scanWaiting && !$_scanSuccess" style="display: none" class="mb-4"> <div data-show="!$_scanSuccess" class="mb-4">
<p class="text-sm text-stone-600 mb-3">Scan a bag to identify the coffee, or select from your existing roasts below.</p> <p class="text-sm text-stone-600 mb-3" data-show="!$_scanWaiting">Scan a bag to identify the coffee, or select from your existing roasts below.</p>
<input type="file" id="checkin-photo" accept="image/*" capture="environment" class="hidden"
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('checkin-image').value=r.result;document.getElementById('checkin-scan-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
<div id="scan-submit-result"></div>
<form id="checkin-scan-form" <form id="checkin-scan-form"
data-on:submit="$_scanWaiting = true; $_error = ''; @post('/api/v1/scan', {contentType: 'form'})" data-on:submit="$_scanWaiting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_scanWaiting) return; if (evt.detail.type === 'finished') { $_scanWaiting = false; $_roastName = $_scanSuccess; $_step = 3 } else if (evt.detail.type === 'error') { $_scanWaiting = false; $_error = 'Scan failed. Please try again.' }" data-on:datastar-fetch="if (!$_scanWaiting) return; if (evt.detail.type === 'finished') { $_scanWaiting = false; $_roastName = $_scanSuccess; $_step = 3 } else if (evt.detail.type === 'error') { $_scanWaiting = false; $_scanError = 'Scan failed. Please try again.' }"
> >
<input type="hidden" name="image" id="checkin-image" /> {% call scan::scan_input("checkin-scan-form", "checkin-image", "$_scanWaiting", "$_scanError", "Describe the coffee…", "Scanning…") %}
<input type="hidden" name="prompt" id="checkin-prompt-hidden" />
<div class="flex flex-wrap items-center gap-3">
<button
type="button"
onclick="document.getElementById('checkin-photo').click()"
class="inline-flex items-center gap-2 rounded-md border border-amber-500 px-4 py-2.5 text-sm font-medium text-amber-700 transition hover:bg-amber-50"
>
{% call icons::camera("h-5 w-5") %}
Scan Bag
</button>
<span class="text-xs text-stone-400">or</span>
<div class="flex flex-1 min-w-[200px] gap-2">
<input
type="text"
id="checkin-prompt-text"
class="input-field w-full text-sm"
placeholder="Describe the coffee&hellip;"
onkeydown="if(event.key==='Enter'){event.preventDefault();document.getElementById('checkin-prompt-hidden').value=this.value;document.getElementById('checkin-scan-form').requestSubmit()}"
/>
<button
type="button"
onclick="document.getElementById('checkin-prompt-hidden').value=document.getElementById('checkin-prompt-text').value;document.getElementById('checkin-scan-form').requestSubmit()"
class="rounded-md border border-amber-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50"
>
Go
</button>
</div>
</div>
</form> </form>
</div> </div>
<div
class="flex items-center gap-3 text-sm text-amber-700 mb-4"
data-show="$_scanWaiting"
style="display: none"
>
{% call icons::spinner("h-5 w-5") %}
Scanning &amp; saving&hellip;
</div>
<div <div
class="mb-4 rounded-md bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-800" class="mb-4 rounded-md bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-800"
data-show="$_scanSuccess" data-show="$_scanSuccess"
@ -313,39 +237,48 @@ const locateUser = () => {
Scanned: <span class="font-medium" data-text="$_scanSuccess"></span> Scanned: <span class="font-medium" data-text="$_scanSuccess"></span>
</div> </div>
<div class="border-t border-amber-200 pt-3"> {% if !roast_options.is_empty() %}
<div class="border-t pt-3">
<p class="text-xs text-stone-500 mb-2">Or select an existing roast:</p> <p class="text-xs text-stone-500 mb-2">Or select an existing roast:</p>
<select <input
type="text"
class="input-field w-full text-sm" class="input-field w-full text-sm"
data-on:change="$_roastId = el.value; $_roastName = el.options[el.selectedIndex].dataset.name || ''; $_roasterName = el.options[el.selectedIndex].dataset.roaster || ''; el.value && ($_step = 3)" placeholder="Type to search existing roasts&hellip;"
> oninput="filterList(this, 'roast-options')"
<option value="">Select a roast&hellip;</option> />
<div id="roast-options" class="hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface">
{% for roast in roast_options %} {% for roast in roast_options %}
<option value="{{ roast.id }}" data-name="{{ roast.name }}" data-roaster="{{ roast.roaster_name }}">{{ roast.label }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Step 3: Rating + submit -->
<div class="mt-4" data-show="$_step === 3" style="display: none">
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<h3 class="text-base font-semibold text-amber-700 mb-3">How was it? (optional)</h3>
<div class="flex gap-1 mb-6">
{% for i in 1..=5 %}
<button <button
type="button" type="button"
class="transition hover:text-amber-400" class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition"
data-on:click="$_rating = {{ i }}" data-on:click="$_roastId = '{{ roast.id }}'; $_roastName = el.dataset.name; $_roasterName = el.dataset.roaster; $_step = 3"
data-class:text-amber-500="$_rating >= {{ i }}" data-name="{{ roast.name }}"
data-class:text-stone-300="$_rating < {{ i }}" data-roaster="{{ roast.roaster_name }}"
aria-label="Rate {{ i }} stars"
> >
{% call icons::star("h-8 w-8") %} <span class="font-medium text-stone-800">{{ roast.name }}</span>
<span class="ml-2 text-xs text-stone-500">{{ roast.roaster_name }}</span>
</button> </button>
{% endfor %} {% endfor %}
</div> </div>
</div>
{% endif %}
</div>
</div>
<!-- Step 3: Review + submit -->
<div class="mt-4" data-show="$_step === 3" style="display: none">
<div class="rounded-lg border bg-surface p-5">
<h3 class="text-base font-semibold text-stone-800 mb-4">Review &amp; Submit</h3>
<dl class="flex flex-col gap-3 text-sm mb-6">
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">Cafe</dt>
<dd class="text-right text-stone-800" data-text="$_cafeCity ? $_cafeName + ', ' + $_cafeCity : $_cafeName"></dd>
</div>
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">Coffee</dt>
<dd class="text-right text-stone-800" data-text="$_roasterName ? $_roastName + ' (' + $_roasterName + ')' : $_roastName"></dd>
</div>
</dl>
<form <form
data-on:submit="$_submitting = true; $_error = ''; @post('/api/v1/check-in', {contentType: 'form'})" data-on:submit="$_submitting = true; $_error = ''; @post('/api/v1/check-in', {contentType: 'form'})"
@ -359,10 +292,9 @@ const locateUser = () => {
<input type="hidden" name="cafe_lng" data-attr:value="$_cafeLng" /> <input type="hidden" name="cafe_lng" data-attr:value="$_cafeLng" />
<input type="hidden" name="cafe_website" data-attr:value="$_cafeWebsite" /> <input type="hidden" name="cafe_website" data-attr:value="$_cafeWebsite" />
<input type="hidden" name="roast_id" data-attr:value="$_roastId" /> <input type="hidden" name="roast_id" data-attr:value="$_roastId" />
<input type="hidden" name="rating" data-attr:value="$_rating" />
<button <button
type="submit" type="submit"
class="w-full rounded-md bg-amber-600 px-4 py-3 text-sm font-semibold text-amber-50 transition hover:bg-amber-500 disabled:opacity-50" class="w-full rounded-md bg-accent px-4 py-3 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
data-attr:disabled="$_submitting" data-attr:disabled="$_submitting"
> >
Check In Check In

View file

@ -2,7 +2,7 @@
{% block title %}Brewlog · CLI Authentication{% endblock %} {% block title %}Brewlog · CLI Authentication{% endblock %}
{% block content %} {% block content %}
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-6 shadow-sm"> <div class="rounded-lg border bg-surface p-6">
{% if token.is_some() %} {% if token.is_some() %}
<h1 class="text-2xl font-semibold text-green-700">Authenticated</h1> <h1 class="text-2xl font-semibold text-green-700">Authenticated</h1>
<p class="mt-2 text-sm text-stone-600"> <p class="mt-2 text-sm text-stone-600">
@ -14,7 +14,7 @@
{{ error.as_ref().unwrap() }} {{ error.as_ref().unwrap() }}
</div> </div>
{% else %} {% else %}
<h1 class="text-2xl font-semibold text-amber-700">CLI Authentication</h1> <h1 class="text-2xl font-semibold text-accent">CLI Authentication</h1>
<p class="mt-2 text-sm text-stone-600"> <p class="mt-2 text-sm text-stone-600">
Processing authentication... Processing authentication...
</p> </p>

View file

@ -0,0 +1,26 @@
customElements.define("brew-photo-capture", class extends HTMLElement {
connectedCallback() {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.capture = "environment";
input.hidden = true;
this.appendChild(input);
this.addEventListener("click", (e) => {
if (e.target !== input) input.click();
});
input.addEventListener("change", () => {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
document.getElementById(this.getAttribute("target-input")).value = reader.result;
document.getElementById(this.getAttribute("target-form")).requestSubmit();
};
reader.readAsDataURL(file);
input.value = "";
});
}
});

View file

@ -0,0 +1,85 @@
customElements.define("searchable-select", class extends HTMLElement {
connectedCallback() {
requestAnimationFrame(() => this._setup());
}
_setup() {
if (this._initialized) return;
this._initialized = true;
const name = this.getAttribute("name");
const placeholder = this.getAttribute("placeholder") || "Type to search\u2026";
const buttons = [...this.querySelectorAll("button")];
const hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = name;
const search = document.createElement("input");
search.type = "text";
search.className = "input-field w-full";
search.placeholder = placeholder;
const options = document.createElement("div");
options.className = "hidden mt-2 max-h-48 overflow-y-auto rounded-lg border bg-surface";
buttons.forEach((btn) => options.appendChild(btn));
const searchWrap = document.createElement("div");
searchWrap.appendChild(search);
searchWrap.appendChild(options);
const display = document.createElement("span");
display.className = "input-field w-full block pr-8";
const clear = document.createElement("button");
clear.type = "button";
clear.className = "absolute right-2 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600 transition";
clear.innerHTML = '<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" /></svg>';
const selectedWrap = document.createElement("div");
selectedWrap.className = "relative hidden";
selectedWrap.appendChild(display);
selectedWrap.appendChild(clear);
this.textContent = "";
this.style.display = "block";
this.appendChild(hidden);
this.appendChild(searchWrap);
this.appendChild(selectedWrap);
search.addEventListener("input", () => {
const q = search.value.toLowerCase();
options.classList.toggle("hidden", !q);
buttons.forEach((btn) => {
btn.style.display = btn.textContent.toLowerCase().includes(q) ? "" : "none";
});
});
options.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn || !options.contains(btn)) return;
hidden.value = btn.value;
display.textContent = btn.dataset.display;
searchWrap.classList.add("hidden");
selectedWrap.classList.remove("hidden");
this.dispatchEvent(new CustomEvent("change", {
detail: { value: btn.value, display: btn.dataset.display, data: { ...btn.dataset } },
bubbles: true,
}));
});
clear.addEventListener("click", () => {
hidden.value = "";
display.textContent = "";
search.value = "";
searchWrap.classList.remove("hidden");
selectedWrap.classList.add("hidden");
options.classList.add("hidden");
setTimeout(() => search.focus(), 0);
this.dispatchEvent(new CustomEvent("clear", { bubbles: true }));
});
}
});

View file

@ -8,7 +8,7 @@
{% if is_authenticated %} {% if is_authenticated %}
<a <a
href="/add?type={{ active_type }}" href="/add?type={{ active_type }}"
class="inline-flex items-center gap-2 rounded-md bg-amber-600 px-4 py-2.5 text-sm font-semibold text-amber-50 transition hover:bg-amber-500" class="inline-flex items-center gap-2 rounded-md bg-accent px-4 py-2.5 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
> >
{% call icons::plus_circle("h-4 w-4") %} {% call icons::plus_circle("h-4 w-4") %}
Add Add
@ -16,14 +16,14 @@
{% endif %} {% endif %}
</header> </header>
<div class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm" <div class="rounded-lg border bg-surface"
data-signals:_active-tab="'{{ active_type }}'" data-signals:_tabs-open="false"> data-signals:_active-tab="'{{ active_type }}'" data-signals:_tabs-open="false">
<!-- Desktop tabs --> <!-- Desktop tabs -->
<nav class="hidden md:flex flex-wrap gap-1 p-1.5"> <nav class="hidden md:flex flex-wrap gap-1 p-1.5">
{% for tab in tabs %} {% for tab in tabs %}
<a <a
href="/data?type={{ tab.key }}" href="/data?type={{ tab.key }}"
data-class:bg-amber-600="$_activeTab === '{{ tab.key }}'" data-class:bg-accent="$_activeTab === '{{ tab.key }}'"
data-class:text-white="$_activeTab === '{{ tab.key }}'" data-class:text-white="$_activeTab === '{{ tab.key }}'"
data-class:shadow-sm="$_activeTab === '{{ tab.key }}'" data-class:shadow-sm="$_activeTab === '{{ tab.key }}'"
data-class:text-stone-600="$_activeTab !== '{{ tab.key }}'" data-class:text-stone-600="$_activeTab !== '{{ tab.key }}'"
@ -36,7 +36,7 @@
<div class="md:hidden"> <div class="md:hidden">
<button <button
type="button" type="button"
class="flex w-full items-center justify-between p-3 text-sm font-medium text-amber-800" class="flex w-full items-center justify-between p-3 text-sm font-medium text-stone-800"
data-on:click="$_tabsOpen = !$_tabsOpen" data-on:click="$_tabsOpen = !$_tabsOpen"
> >
<span> <span>
@ -47,22 +47,22 @@
<span data-show="!$_tabsOpen">{% call icons::chevron_down("h-5 w-5") %}</span> <span data-show="!$_tabsOpen">{% call icons::chevron_down("h-5 w-5") %}</span>
<span data-show="$_tabsOpen" style="display:none">{% call icons::chevron_up("h-5 w-5") %}</span> <span data-show="$_tabsOpen" style="display:none">{% call icons::chevron_up("h-5 w-5") %}</span>
</button> </button>
<div class="flex flex-col border-t border-amber-200" data-show="$_tabsOpen" style="display:none"> <div class="flex flex-col border-t" data-show="$_tabsOpen" style="display:none">
{% for tab in tabs %} {% for tab in tabs %}
<a <a
href="/data?type={{ tab.key }}" href="/data?type={{ tab.key }}"
data-class:bg-amber-100="$_activeTab === '{{ tab.key }}'" data-class:bg-accent-subtle="$_activeTab === '{{ tab.key }}'"
data-class:text-amber-800="$_activeTab === '{{ tab.key }}'" data-class:text-stone-800="$_activeTab === '{{ tab.key }}'"
data-class:font-semibold="$_activeTab === '{{ tab.key }}'" data-class:font-semibold="$_activeTab === '{{ tab.key }}'"
data-class:text-stone-600="$_activeTab !== '{{ tab.key }}'" data-class:text-stone-600="$_activeTab !== '{{ tab.key }}'"
class="px-3 py-2 text-sm transition hover:bg-amber-200/30" class="px-3 py-2 text-sm transition hover:bg-surface-alt"
data-on:click__prevent="$_activeTab = '{{ tab.key }}'; $_tabsOpen = false; history.pushState(null, '', '/data?type={{ tab.key }}'); @get('/data?type={{ tab.key }}', {responseOverrides: {selector: '#data-content', mode: 'inner'}})" data-on:click__prevent="$_activeTab = '{{ tab.key }}'; $_tabsOpen = false; history.pushState(null, '', '/data?type={{ tab.key }}'); @get('/data?type={{ tab.key }}', {responseOverrides: {selector: '#data-content', mode: 'inner'}})"
>{{ tab.label }}</a> >{{ tab.label }}</a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<!-- Search --> <!-- Search -->
<div class="border-t border-amber-200 px-3 py-2"> <div class="border-t px-3 py-2">
<input <input
type="search" type="search"
id="data-search" id="data-search"

View file

@ -1,4 +1,4 @@
{% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog{% endblock %} {% extends "base.html" %} {% import "partials/bag_card.html" as bag_card %} {% import "partials/icons.html" as icons %} {% import "partials/scan_input.html" as scan %} {% block title %}Brewlog{% endblock %}
{% block head %} {% block head %}
{% if is_authenticated %} {% if is_authenticated %}
@ -49,52 +49,25 @@
data-signals:_open-bag="true" data-signals:_open-bag="true"
data-signals:_bag-amount="250" data-signals:_bag-amount="250"
> >
<h2 class="text-lg font-semibold text-amber-700 mb-3">Scan Bag</h2>
<!-- Hidden file input — minimal JS for FileReader API -->
<input type="file" id="scan-photo" accept="image/*" capture="environment" class="hidden"
onchange="if(this.files[0]){const r=new FileReader();r.onload=()=>{document.getElementById('scan-image').value=r.result;document.getElementById('scan-extract-form').requestSubmit()};r.readAsDataURL(this.files[0]);this.value=''}" />
<!-- Input: photo or text (shown when not yet extracted) --> <!-- Input: photo or text (shown when not yet extracted) -->
<div data-show="!$_scanExtracted" class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"> <div data-show="!$_scanExtracted" class="rounded-lg border bg-surface p-5">
<form id="scan-extract-form" <form id="scan-extract-form"
data-on:submit="$_extracting = true; $_extractError = ''; $_roasterName = ''; $_roasterCountry = ''; $_roasterCity = ''; $_roasterHomepage = ''; $_roastName = ''; $_origin = ''; $_region = ''; $_producer = ''; $_process = ''; $_tastingNotes = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})" data-on:submit="$_extracting = true; $_extractError = ''; $_roasterName = ''; $_roasterCountry = ''; $_roasterCity = ''; $_roasterHomepage = ''; $_roastName = ''; $_origin = ''; $_region = ''; $_producer = ''; $_process = ''; $_tastingNotes = ''; @post('/api/v1/extract-bag-scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }" data-on:datastar-fetch="if (!$_extracting) return; if (evt.detail.type === 'finished') { $_extracting = false; $_scanExtracted = true; document.getElementById('scan-extract-form').reset() } else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed. Please try again.' }"
> >
<input type="hidden" name="image" id="scan-image" /> {% call scan::scan_input("scan-extract-form", "scan-image", "$_extracting", "$_extractError", "Describe the coffee bag…", "Waiting for response…") %}
<div data-show="!$_extracting" class="flex items-center gap-2">
<button
type="button"
onclick="document.getElementById('scan-photo').click()"
class="shrink-0 inline-flex items-center justify-center rounded-md border border-amber-500 p-2.5 text-amber-700 transition hover:bg-amber-50"
aria-label="Take Photo"
>
{% call icons::camera("h-5 w-5") %}
</button>
<input
type="text"
name="prompt"
class="input-field w-full text-sm"
placeholder="Describe the coffee bag&hellip;"
/>
</div>
<div data-show="$_extracting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700">
{% call icons::spinner("h-5 w-5") %}
Waiting for response&hellip;
</div>
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="mt-2 text-sm text-red-600"></p>
</form> </form>
</div> </div>
<!-- Form: pre-filled roaster + roast (shown after extraction) --> <!-- Form: pre-filled roaster + roast (shown after extraction) -->
<div data-show="$_scanExtracted" style="display: none"> <div data-show="$_scanExtracted" style="display: none">
<form <form
class="rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm flex flex-col gap-6" class="rounded-lg border bg-surface p-5 flex flex-col gap-6"
data-on:submit="$_scanSubmitting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})" data-on:submit="$_scanSubmitting = true; $_scanError = ''; @post('/api/v1/scan', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { $_scanSubmitting = false; $_scanExtracted = false; $_roasterName = ''; $_roasterCountry = ''; $_roasterCity = ''; $_roasterHomepage = ''; $_roastName = ''; $_origin = ''; $_region = ''; $_producer = ''; $_process = ''; $_tastingNotes = ''; $_openBag = true; $_bagAmount = 250; window.location.reload() } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }" data-on:datastar-fetch="if (!$_scanSubmitting) return; if (evt.detail.type === 'finished') { $_scanSubmitting = false; $_scanExtracted = false; $_roasterName = ''; $_roasterCountry = ''; $_roasterCity = ''; $_roasterHomepage = ''; $_roastName = ''; $_origin = ''; $_region = ''; $_producer = ''; $_process = ''; $_tastingNotes = ''; $_openBag = true; $_bagAmount = 250; window.location.reload() } else if (evt.detail.type === 'error') { $_scanSubmitting = false; $_scanError = 'Save failed. Please try again.' }"
> >
<div> <div>
<h3 class="text-base font-semibold text-amber-700">Roaster</h3> <h3 class="text-base font-semibold text-stone-800">Roaster</h3>
<p class="mt-1 text-sm text-stone-600">If this roaster already exists, it will be matched automatically.</p> <p class="mt-1 text-sm text-stone-600">If this roaster already exists, it will be matched automatically.</p>
<div class="mt-4 grid gap-4 sm:grid-cols-2"> <div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
@ -116,7 +89,7 @@
</div> </div>
</div> </div>
<div> <div>
<h3 class="text-base font-semibold text-amber-700">Roast</h3> <h3 class="text-base font-semibold text-stone-800">Roast</h3>
<p class="mt-1 text-sm text-stone-600">Details about this specific coffee.</p> <p class="mt-1 text-sm text-stone-600">Details about this specific coffee.</p>
<div class="mt-4 grid gap-4 sm:grid-cols-2"> <div class="mt-4 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
@ -147,8 +120,8 @@
</div> </div>
<div> <div>
<label class="inline-flex items-center gap-2 text-sm cursor-pointer"> <label class="inline-flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" name="open_bag" value="true" class="accent-amber-600" data-bind:_open-bag /> <input type="checkbox" name="open_bag" value="true" class="accent-orange-700" data-bind:_open-bag />
<span class="font-semibold text-amber-700">Open a bag of this coffee</span> <span class="font-semibold text-stone-800">Open a bag of this coffee</span>
</label> </label>
<div data-show="$_openBag" class="mt-3 grid gap-4 sm:grid-cols-2"> <div data-show="$_openBag" class="mt-3 grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
@ -158,7 +131,7 @@
</div> </div>
</div> </div>
<p data-show="$_scanError" data-text="$_scanError" style="display:none" class="text-sm text-red-600"></p> <p data-show="$_scanError" data-text="$_scanError" style="display:none" class="text-sm text-red-600"></p>
<div data-show="$_scanSubmitting" style="display:none" class="flex items-center gap-3 text-sm text-amber-700"> <div data-show="$_scanSubmitting" style="display:none" class="flex items-center gap-3 text-sm text-accent">
{% call icons::spinner("h-5 w-5") %} {% call icons::spinner("h-5 w-5") %}
Saving&hellip; Saving&hellip;
</div> </div>
@ -166,13 +139,13 @@
<button <button
type="button" type="button"
data-on:click="$_scanExtracted = false" data-on:click="$_scanExtracted = false"
class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-amber-400 hover:text-stone-800" class="rounded-md border px-4 py-2 text-sm font-semibold text-stone-700 transition hover:bg-surface-alt"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500" class="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
> >
Save Roaster &amp; Roast Save Roaster &amp; Roast
</button> </button>
@ -186,15 +159,15 @@
{% if !recent_brews.is_empty() %} {% if !recent_brews.is_empty() %}
<section> <section>
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-amber-700">Recent Brews</h2> <h2 class="text-lg font-semibold text-stone-800">Recent Brews</h2>
<a href="/data?type=brews" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View all brews &rarr;</a> <a href="/data?type=brews" class="text-sm text-accent hover:text-accent-hover font-medium">View all brews &rarr;</a>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 items-start gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 items-start gap-4">
{% for brew in recent_brews %} {% for brew in recent_brews %}
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm min-w-0"> <div class="rounded-lg border bg-surface p-4 min-w-0">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<span class="block truncate font-semibold text-amber-800">{{ brew.roast_name }}</span> <span class="block truncate font-semibold text-stone-800">{{ brew.roast_name }}</span>
<p class="truncate text-sm text-stone-500">{{ brew.roaster_name }}</p> <p class="truncate text-sm text-stone-500">{{ brew.roaster_name }}</p>
</div> </div>
<time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ brew.relative_date_label }}</time> <time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ brew.relative_date_label }}</time>
@ -205,7 +178,7 @@
<p class="truncate"><span class="font-medium text-stone-800">{{ brew.brewer_name }}</span>{% if let Some(fp) = brew.filter_paper_name %} <span class="text-stone-500">· {{ fp }}</span>{% endif %}</p> <p class="truncate"><span class="font-medium text-stone-800">{{ brew.brewer_name }}</span>{% if let Some(fp) = brew.filter_paper_name %} <span class="text-stone-500">· {{ fp }}</span>{% endif %}</p>
</div> </div>
{% if is_authenticated %} {% if is_authenticated %}
<div class="mt-3 pt-3 border-t border-amber-100 flex gap-3"> <div class="mt-3 pt-3 border-t flex gap-3">
<form <form
class="inline" class="inline"
data-on:submit="@post('/api/v1/brews', {contentType: 'form'})" data-on:submit="@post('/api/v1/brews', {contentType: 'form'})"
@ -222,7 +195,7 @@
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" /> <input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
<button <button
type="submit" type="submit"
class="inline-flex items-center gap-1 text-sm font-medium text-amber-700 hover:text-amber-500" class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
> >
{% call icons::plus_circle("h-4 w-4") %} {% call icons::plus_circle("h-4 w-4") %}
Brew Again Brew Again
@ -240,8 +213,8 @@
{% if !open_bags.is_empty() %} {% if !open_bags.is_empty() %}
<section id="open-bags-section"> <section id="open-bags-section">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-amber-700">Open Bags</h2> <h2 class="text-lg font-semibold text-stone-800">Open Bags</h2>
<a href="/data?type=bags" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View all bags &rarr;</a> <a href="/data?type=bags" class="text-sm text-accent hover:text-accent-hover font-medium">View all bags &rarr;</a>
</div> </div>
<div id="open-bags-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div id="open-bags-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for bag in open_bags %} {% for bag in open_bags %}
@ -255,15 +228,15 @@
{% if !recent_events.is_empty() %} {% if !recent_events.is_empty() %}
<section> <section>
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-amber-700">Recent Activity</h2> <h2 class="text-lg font-semibold text-stone-800">Recent Activity</h2>
<a href="/timeline" class="text-sm text-amber-600 hover:text-amber-500 font-medium">View full timeline &rarr;</a> <a href="/timeline" class="text-sm text-accent hover:text-accent-hover font-medium">View full timeline &rarr;</a>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
{% for event in recent_events %} {% for event in recent_events %}
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 shadow-sm flex items-center justify-between gap-3"> <div class="rounded-lg border bg-surface px-4 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">
<span class="inline-flex w-28 shrink-0 items-center justify-center whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800">{{ event.kind_label }}</span> <span class="pill pill-muted w-28 shrink-0 justify-center whitespace-nowrap">{{ event.kind_label }}</span>
<p class="min-w-0 truncate text-sm"><a href="{{ event.link }}" class="font-medium text-stone-700 hover:text-amber-600">{{ event.title }}</a>{% if let Some(sub) = event.subtitle %} <span class="text-xs text-stone-400 italic">· {{ sub }}</span>{% endif %}</p> <p class="min-w-0 truncate text-sm"><a href="{{ event.link }}" class="font-medium text-stone-700 hover:text-accent">{{ event.title }}</a>{% if let Some(sub) = event.subtitle %} <span class="text-xs text-stone-400 italic">· {{ sub }}</span>{% endif %}</p>
</div> </div>
<time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ event.relative_date_label }}</time> <time class="text-xs text-stone-500 whitespace-nowrap shrink-0">{{ event.relative_date_label }}</time>
</div> </div>
@ -275,42 +248,42 @@
<!-- Stats --> <!-- Stats -->
<section> <section>
<div class="flex items-center justify-between mb-5"> <div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-amber-700">Stats</h2> <h2 class="text-lg font-semibold text-stone-800">Stats</h2>
</div> </div>
<div class="grid grid-cols-2 gap-3 md:flex md:flex-wrap md:justify-center"> <div class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-7">
<a href="/data?type=brews" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80"> <a href="/data?type=brews" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::beaker("h-4 w-4 text-amber-600") %} {% call icons::beaker("h-6 w-6 text-accent") %}
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.brews }}</span> <span class="text-lg font-bold text-stone-800 group-hover:text-accent">{{ stats.brews }}</span>
<span class="text-xs text-stone-500">Brews</span> <span class="text-xs text-stone-500">Brews</span>
</a> </a>
<a href="/data?type=roasts" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80"> <a href="/data?type=roasts" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::fire("h-4 w-4 text-amber-600") %} {% call icons::fire("h-6 w-6 text-accent") %}
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.roasts }}</span> <span class="text-lg font-bold text-stone-800 group-hover:text-accent">{{ stats.roasts }}</span>
<span class="text-xs text-stone-500">Roasts</span> <span class="text-xs text-stone-500">Roasts</span>
</a> </a>
<a href="/data?type=roasters" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80"> <a href="/data?type=roasters" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::building("h-4 w-4 text-amber-600") %} {% call icons::building("h-6 w-6 text-accent") %}
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.roasters }}</span> <span class="text-lg font-bold text-stone-800 group-hover:text-accent">{{ stats.roasters }}</span>
<span class="text-xs text-stone-500">Roasters</span> <span class="text-xs text-stone-500">Roasters</span>
</a> </a>
<a href="/data?type=bags" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80"> <a href="/data?type=bags" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::bag("h-4 w-4 text-amber-600") %} {% call icons::bag("h-6 w-6 text-accent") %}
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.bags }}</span> <span class="text-lg font-bold text-stone-800 group-hover:text-accent">{{ stats.bags }}</span>
<span class="text-xs text-stone-500">Bags</span> <span class="text-xs text-stone-500">Bags</span>
</a> </a>
<a href="/data?type=cups" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80"> <a href="/data?type=cups" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::cup("h-4 w-4 text-amber-600") %} {% call icons::cup("h-6 w-6 text-accent") %}
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.cups }}</span> <span class="text-lg font-bold text-stone-800 group-hover:text-accent">{{ stats.cups }}</span>
<span class="text-xs text-stone-500">Cups</span> <span class="text-xs text-stone-500">Cups</span>
</a> </a>
<a href="/data?type=cafes" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80"> <a href="/data?type=cafes" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::location("h-4 w-4 text-amber-600") %} {% call icons::location("h-6 w-6 text-accent") %}
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.cafes }}</span> <span class="text-lg font-bold text-stone-800 group-hover:text-accent">{{ stats.cafes }}</span>
<span class="text-xs text-stone-500">Cafes</span> <span class="text-xs text-stone-500">Cafes</span>
</a> </a>
<a href="/data?type=gear" class="group inline-flex items-center gap-2 rounded-full border-l-4 border-l-amber-400 bg-amber-50/60 py-2 pl-3 pr-4 transition hover:bg-amber-100/80"> <a href="/data?type=gear" class="group flex flex-col items-center gap-1 rounded-lg border bg-surface p-4 transition hover:bg-surface-alt">
{% call icons::wrench("h-4 w-4 text-amber-600") %} {% call icons::wrench("h-6 w-6 text-accent") %}
<span class="min-w-[2ch] text-base font-bold text-amber-800 group-hover:text-amber-600">{{ stats.gear }}</span> <span class="text-lg font-bold text-stone-800 group-hover:text-accent">{{ stats.gear }}</span>
<span class="text-xs text-stone-500">Gear</span> <span class="text-xs text-stone-500">Gear</span>
</a> </a>
</div> </div>

View file

@ -5,8 +5,8 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-6 shadow-sm"> <div class="rounded-lg border bg-surface p-6">
<h1 class="text-2xl font-semibold text-amber-700">Login</h1> <h1 class="text-2xl font-semibold text-accent">Login</h1>
<p class="mt-2 text-sm text-stone-600"> <p class="mt-2 text-sm text-stone-600">
Sign in with your passkey to manage your coffee log. Sign in with your passkey to manage your coffee log.
</p> </p>
@ -21,7 +21,7 @@
<button <button
id="login-button" id="login-button"
type="button" type="button"
class="w-full rounded-md bg-amber-600 px-4 py-3 text-sm font-semibold text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed" class="w-full 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"
> >
Sign in with Passkey Sign in with Passkey
</button> </button>

View file

@ -1,50 +1,72 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
<nav class="relative rounded-lg border border-amber-300 bg-amber-100/80 p-3 text-sm text-stone-600" data-signals:_nav-open="false"> <nav class="relative border-b pb-4 text-sm" data-signals:_nav-open="false">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="font-semibold uppercase tracking-[0.25em] text-amber-700"><a href="/">B{rew}log</a></div> <div class="font-semibold uppercase tracking-[0.25em] text-accent"><a href="/">B{rew}log</a></div>
<!-- Desktop links --> <div class="flex items-center gap-1">
<div class="hidden md:flex items-center gap-3"> <a class="rounded-md p-1.5 transition {% if nav_active == "home" %}text-accent{% else %}text-stone-400 hover:text-stone-600 dark:text-stone-500 dark:hover:text-stone-300{% endif %}" href="/" title="Home" aria-label="Home">
<a class="border-b-2 pb-1 transition {% if nav_active == "home" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/">Home</a> {% call icons::home("h-5 w-5") %}
<a class="border-b-2 pb-1 transition {% if nav_active == "data" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/data">Data</a>
<a class="border-b-2 pb-1 transition {% if nav_active == "timeline" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/timeline">Timeline</a>
{% if is_authenticated %}
<a class="border-b-2 pb-1 transition {% if nav_active == "checkin" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/check-in" aria-label="Check In">
{% call icons::checkin("inline h-4 w-4") %}
</a> </a>
<a class="border-b-2 pb-1 transition {% if nav_active == "account" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/account" aria-label="Account"> <button type="button" class="rounded-md p-1.5 text-stone-400 transition hover:text-stone-600 dark:text-stone-500 dark:hover:text-stone-300" title="Toggle theme" aria-label="Toggle theme" onclick="toggleTheme()">
{% call icons::user("inline h-4 w-4") %} <span class="theme-icon-light">{% call icons::sun("h-5 w-5") %}</span>
</a> <span class="theme-icon-dark hidden">{% call icons::moon("h-5 w-5") %}</span>
{% else %} </button>
<a class="border-b-2 pb-1 transition text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400" href="/login" aria-label="Login"> <button class="rounded-md p-1.5 text-stone-400 transition hover:text-stone-600 dark:text-stone-500 dark:hover:text-stone-300" data-on:click="$_navOpen = !$_navOpen" title="Menu" aria-label="Toggle menu">
{% call icons::user("inline h-4 w-4") %} <span data-show="!$_navOpen">{% call icons::hamburger("h-5 w-5") %}</span>
</a> <span data-show="$_navOpen" style="display:none">{% call icons::close("h-5 w-5") %}</span>
{% endif %}
</div>
<!-- Hamburger button (mobile only) -->
<button class="md:hidden p-1 text-amber-700" data-on:click="$_navOpen = !$_navOpen" aria-label="Toggle menu">
<span data-show="!$_navOpen">{% call icons::hamburger("h-6 w-6") %}</span>
<span data-show="$_navOpen" style="display:none">{% call icons::close("h-6 w-6") %}</span>
</button> </button>
</div> </div>
<!-- Mobile menu --> </div>
<div class="md:hidden flex flex-col gap-2 pt-3 mt-3 border-t border-amber-300" data-show="$_navOpen" style="display:none"> <!-- Menu -->
<a class="py-1 transition {% if nav_active == "home" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/">Home</a> <div class="flex flex-col gap-2 pt-3 mt-3 border-t md:items-end" data-show="$_navOpen" style="display:none">
<a class="py-1 transition {% if nav_active == "data" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/data">Data</a> <a class="py-1 transition inline-flex items-center gap-1.5 {% if nav_active == "data" %}text-accent font-medium{% else %}text-stone-500 hover:text-stone-800 dark:hover:text-stone-300{% endif %}" href="/data">
<a class="py-1 transition {% if nav_active == "timeline" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/timeline">Timeline</a> Data {% call icons::database("h-4 w-4") %}
</a>
<a class="py-1 transition inline-flex items-center gap-1.5 {% if nav_active == "timeline" %}text-accent font-medium{% else %}text-stone-500 hover:text-stone-800 dark:hover:text-stone-300{% endif %}" href="/timeline">
Timeline {% call icons::timeline("h-4 w-4") %}
</a>
{% if is_authenticated %} {% if is_authenticated %}
<a class="py-1 transition inline-flex items-center gap-1 {% if nav_active == "checkin" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/check-in"> <a class="py-1 transition inline-flex items-center gap-1.5 {% if nav_active == "checkin" %}text-accent font-medium{% else %}text-stone-500 hover:text-stone-800 dark:hover:text-stone-300{% endif %}" href="/check-in">
{% call icons::checkin("inline h-4 w-4") %} Check In {% call icons::checkin("h-4 w-4") %}
Check In
</a> </a>
<a class="py-1 transition inline-flex items-center gap-1 {% if nav_active == "account" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/account"> <a class="py-1 transition inline-flex items-center gap-1.5 {% if nav_active == "account" %}text-accent font-medium{% else %}text-stone-500 hover:text-stone-800 dark:hover:text-stone-300{% endif %}" href="/account">
{% call icons::user("inline h-4 w-4") %} Admin {% call icons::cog("h-4 w-4") %}
Account
</a> </a>
<form method="post" action="/logout" class="flex md:justify-end">
<button type="submit" class="py-1 transition inline-flex items-center gap-1.5 text-stone-500 hover:text-stone-800 dark:hover:text-stone-300">
Sign Out {% call icons::logout("h-4 w-4") %}
</button>
</form>
{% else %} {% else %}
<a class="py-1 transition inline-flex items-center gap-1 text-stone-500 hover:text-amber-600" href="/login"> <a class="py-1 transition inline-flex items-center gap-1.5 text-stone-500 hover:text-stone-800 dark:hover:text-stone-300" href="/login">
{% call icons::user("inline h-4 w-4") %} Login {% call icons::user("h-4 w-4") %}
Login
</a> </a>
{% endif %} {% endif %}
</div> </div>
</nav> </nav>
<script>
const toggleTheme = () => {
const html = document.documentElement;
const isDark = html.getAttribute("data-theme") === "dark";
if (isDark) {
html.removeAttribute("data-theme");
localStorage.setItem("theme", "light");
} else {
html.setAttribute("data-theme", "dark");
localStorage.setItem("theme", "dark");
}
updateThemeIcons();
};
const updateThemeIcons = () => {
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
document.querySelectorAll(".theme-icon-light").forEach((el) => {
el.classList.toggle("hidden", isDark);
});
document.querySelectorAll(".theme-icon-dark").forEach((el) => {
el.classList.toggle("hidden", !isDark);
});
};
// Set correct icon state on load
updateThemeIcons();
</script>

View file

@ -1,8 +1,8 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
{% macro card(bag, is_authenticated) %} {% macro card(bag, is_authenticated) %}
<div id="bag-card-{{ bag.id }}" class="rounded-lg border border-amber-200 bg-amber-50 p-4 shadow-sm"> <div id="bag-card-{{ bag.id }}" class="rounded-lg border bg-surface p-4">
<div class="min-w-0"> <div class="min-w-0">
<span class="block truncate font-semibold text-amber-800">{{ bag.roast_name }}</span> <span class="block truncate font-semibold text-stone-800">{{ bag.roast_name }}</span>
<p class="truncate text-sm text-stone-500">{{ bag.roaster_name }}</p> <p class="truncate text-sm text-stone-500">{{ bag.roaster_name }}</p>
</div> </div>
<p class="mt-2 truncate text-sm text-stone-600"> <p class="mt-2 truncate text-sm text-stone-600">
@ -10,8 +10,8 @@
<span class="text-stone-400">of {{ bag.amount }}g remaining</span> <span class="text-stone-400">of {{ bag.amount }}g remaining</span>
</p> </p>
{% if is_authenticated %} {% if is_authenticated %}
<div class="mt-3 pt-3 border-t border-amber-100 flex gap-3"> <div class="mt-3 pt-3 border-t flex gap-3">
<a href="/data?type=brews" class="inline-flex items-center gap-1 text-sm font-medium text-amber-700 hover:text-amber-500"> <a href="/data?type=brews" class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover">
{% call icons::plus_circle("h-4 w-4") %} {% call icons::plus_circle("h-4 w-4") %}
Brew Brew
</a> </a>

View file

@ -4,7 +4,7 @@
<div id="bag-list" class="mt-6" data-star-scope="bags"> <div id="bag-list" class="mt-6" data-star-scope="bags">
{% if bags.items.is_empty() && !navigator.has_search() %} {% if bags.items.is_empty() && !navigator.has_search() %}
<div <div
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600" class="rounded-lg border border-dashed px-4 py-6 text-sm text-stone-600"
> >
<p class="text-center"> <p class="text-center">
{% if is_authenticated %} {% if is_authenticated %}
@ -15,13 +15,13 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm" <section class="rounded-lg border bg-surface"
{% if bags.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(bags.next_page().unwrap())|safe }}" data-target="#bag-list"{% endif %} {% if bags.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(bags.next_page().unwrap())|safe }}" data-target="#bag-list"{% endif %}
> >
{% call table::search_header(navigator, "#bag-list") %} {% call table::search_header(navigator, "#bag-list") %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700"> <table class="responsive-table min-w-full divide-y text-left text-sm text-stone-700">
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900"> <thead class="bg-surface-alt text-xs font-semibold tracking-wide text-stone-700">
<tr> <tr>
{% call table::sortable_header("Added", "created-at", navigator, "#bag-list") %} {% call table::sortable_header("Added", "created-at", navigator, "#bag-list") %}
{% call table::sortable_header("Roaster", "roaster", navigator, "#bag-list") %} {% call table::sortable_header("Roaster", "roaster", navigator, "#bag-list") %}
@ -34,9 +34,9 @@
{% endif %} {% endif %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-amber-200/70"> <tbody class="divide-y/70">
{% for bag in bags.items %} {% for bag in bags.items %}
<tr class="bg-amber-50/40 transition hover:bg-amber-50"> <tr class="transition hover:bg-surface-alt">
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ bag.created_at }} {{ bag.created_at }}
</td> </td>
@ -47,9 +47,9 @@
<td data-label="Weight" class="px-4 py-3 whitespace-nowrap">{{ bag.amount }}g</td> <td data-label="Weight" class="px-4 py-3 whitespace-nowrap">{{ bag.amount }}g</td>
<td data-label="Status" class="px-4 py-3 whitespace-nowrap"> <td data-label="Status" class="px-4 py-3 whitespace-nowrap">
{% if bag.closed %} {% if bag.closed %}
<span class="inline-flex rounded-full bg-stone-100 px-2 py-1 text-xs font-medium text-stone-600">Closed</span> Closed
{% else %} {% else %}
<span class="inline-flex rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">Open</span> <span class="font-medium text-emerald-600">Open</span>
<div class="hidden md:block text-xs text-stone-500">{{ bag.remaining }}g left</div> <div class="hidden md:block text-xs text-stone-500">{{ bag.remaining }}g left</div>
{% endif %} {% endif %}
</td> </td>
@ -63,7 +63,7 @@
{% if !bag.closed %} {% if !bag.closed %}
<button <button
type="button" type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-amber-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
title="Close bag" title="Close bag"
data-on:click="confirm('Close this bag? This will mark it as finished.') && @put('/api/v1/bags/{{ bag.id }}?closed=true&{{ navigator.query() }}', {responseOverrides: {selector: '#bag-list', mode: 'replace'}})" data-on:click="confirm('Close this bag? This will mark it as finished.') && @put('/api/v1/bags/{{ bag.id }}?closed=true&{{ navigator.query() }}', {responseOverrides: {selector: '#bag-list', mode: 'replace'}})"
> >

View file

@ -4,7 +4,7 @@
<div id="brew-list" class="mt-6" data-star-scope="brews"> <div id="brew-list" class="mt-6" data-star-scope="brews">
{% if brews.items.is_empty() && !navigator.has_search() %} {% if brews.items.is_empty() && !navigator.has_search() %}
<div <div
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600" class="rounded-lg border border-dashed px-4 py-6 text-sm text-stone-600"
> >
<p class="text-center"> <p class="text-center">
{% if is_authenticated %} {% if is_authenticated %}
@ -15,14 +15,14 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm" <section class="rounded-lg border bg-surface"
{% if brews.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(brews.next_page().unwrap())|safe }}" data-target="#brew-list"{% endif %} {% if brews.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(brews.next_page().unwrap())|safe }}" data-target="#brew-list"{% endif %}
> >
{% call table::search_header(navigator, "#brew-list") %} {% call table::search_header(navigator, "#brew-list") %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700"> <table class="responsive-table min-w-full divide-y text-left text-sm text-stone-700">
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900"> <thead class="bg-surface-alt text-xs font-semibold tracking-wide text-stone-700">
<tr> <tr>
{% call table::sortable_header("Added", "created-at", navigator, "#brew-list") %} {% call table::sortable_header("Added", "created-at", navigator, "#brew-list") %}
<th scope="col" class="px-4 py-3">Coffee</th> <th scope="col" class="px-4 py-3">Coffee</th>
@ -34,9 +34,9 @@
{% endif %} {% endif %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-amber-200/70"> <tbody class="divide-y/70">
{% for brew in brews.items %} {% for brew in brews.items %}
<tr class="bg-amber-50/40 transition hover:bg-amber-50"> <tr class="transition hover:bg-surface-alt">
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ brew.created_at }} {{ brew.created_at }}
</td> </td>
@ -81,7 +81,7 @@
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" /> <input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
<button <button
type="submit" type="submit"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-amber-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
title="Brew again" title="Brew again"
> >
<span class="sr-only">Brew again</span> <span class="sr-only">Brew again</span>

View file

@ -4,7 +4,7 @@
<div id="cafe-list" class="mt-6" data-star-scope="cafes"> <div id="cafe-list" class="mt-6" data-star-scope="cafes">
{% if cafes.items.is_empty() && !navigator.has_search() %} {% if cafes.items.is_empty() && !navigator.has_search() %}
<div <div
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600" class="rounded-lg border border-dashed px-4 py-6 text-sm text-stone-600"
> >
<p class="text-center"> <p class="text-center">
{% if is_authenticated %} {% if is_authenticated %}
@ -15,13 +15,13 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm" <section class="rounded-lg border bg-surface"
{% if cafes.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(cafes.next_page().unwrap())|safe }}" data-target="#cafe-list"{% endif %} {% if cafes.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(cafes.next_page().unwrap())|safe }}" data-target="#cafe-list"{% endif %}
> >
{% call table::search_header(navigator, "#cafe-list") %} {% call table::search_header(navigator, "#cafe-list") %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700"> <table class="responsive-table min-w-full divide-y text-left text-sm text-stone-700">
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900"> <thead class="bg-surface-alt text-xs font-semibold tracking-wide text-stone-700">
<tr> <tr>
{% call table::sortable_header("Added", "created-at", navigator, "#cafe-list") %} {% call table::sortable_header("Added", "created-at", navigator, "#cafe-list") %}
{% call table::sortable_header("Name", "name", navigator, "#cafe-list") %} {% call table::sortable_header("Name", "name", navigator, "#cafe-list") %}
@ -29,7 +29,7 @@
<th scope="col" class="px-4 py-3 text-right">Actions</th> <th scope="col" class="px-4 py-3 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-amber-200/70"> <tbody class="divide-y/70">
{% for cafe in cafes.items %} {% for cafe in cafes.items %}
<tr <tr
data-star-key="{{ cafe.id }}" data-star-key="{{ cafe.id }}"
@ -37,7 +37,7 @@
data-sort-name="{{ cafe.name }}" data-sort-name="{{ cafe.name }}"
data-sort-country="{{ cafe.country }}" data-sort-country="{{ cafe.country }}"
data-sort-city="{{ cafe.city }}" data-sort-city="{{ cafe.city }}"
class="bg-amber-50/40 transition hover:bg-amber-50" class="transition hover:bg-surface-alt"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ cafe.created_at }} {{ cafe.created_at }}
@ -53,7 +53,7 @@
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<div class="hidden md:inline-flex items-center gap-1"> <div class="hidden md:inline-flex items-center gap-1">
<a <a
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-amber-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
href="{{ cafe.map_url }}" href="{{ cafe.map_url }}"
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
@ -64,7 +64,7 @@
</a> </a>
{% if cafe.has_website %} {% if cafe.has_website %}
<a <a
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-amber-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
href="{{ cafe.website_url }}" href="{{ cafe.website_url }}"
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"

View file

@ -4,7 +4,7 @@
<div id="cup-list" class="mt-6" data-star-scope="cups"> <div id="cup-list" class="mt-6" data-star-scope="cups">
{% if cups.items.is_empty() && !navigator.has_search() %} {% if cups.items.is_empty() && !navigator.has_search() %}
<div <div
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600" class="rounded-lg border border-dashed px-4 py-6 text-sm text-stone-600"
> >
<p class="text-center"> <p class="text-center">
{% if is_authenticated %} {% if is_authenticated %}
@ -15,26 +15,25 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm" <section class="rounded-lg border bg-surface"
{% if cups.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(cups.next_page().unwrap())|safe }}" data-target="#cup-list"{% endif %} {% if cups.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(cups.next_page().unwrap())|safe }}" data-target="#cup-list"{% endif %}
> >
{% call table::search_header(navigator, "#cup-list") %} {% call table::search_header(navigator, "#cup-list") %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700"> <table class="responsive-table min-w-full divide-y text-left text-sm text-stone-700">
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900"> <thead class="bg-surface-alt text-xs font-semibold tracking-wide text-stone-700">
<tr> <tr>
{% call table::sortable_header("Added", "created-at", navigator, "#cup-list") %} {% call table::sortable_header("Added", "created-at", navigator, "#cup-list") %}
{% call table::sortable_header("Coffee", "roast", navigator, "#cup-list") %} {% call table::sortable_header("Coffee", "roast", navigator, "#cup-list") %}
{% call table::sortable_header("Cafe", "cafe", navigator, "#cup-list") %} {% call table::sortable_header("Cafe", "cafe", navigator, "#cup-list") %}
{% call table::sortable_header("Rating", "rating", navigator, "#cup-list") %}
<th scope="col" class="px-4 py-3 text-right">Actions</th> <th scope="col" class="px-4 py-3 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-amber-200/70"> <tbody class="divide-y/70">
{% for cup in cups.items %} {% for cup in cups.items %}
<tr <tr
data-star-key="{{ cup.id }}" data-star-key="{{ cup.id }}"
class="bg-amber-50/40 transition hover:bg-amber-50" class="transition hover:bg-surface-alt"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ cup.created_at }} {{ cup.created_at }}
@ -47,7 +46,6 @@
<td data-label="Cafe" class="px-4 py-3 whitespace-nowrap"> <td data-label="Cafe" class="px-4 py-3 whitespace-nowrap">
{{ cup.cafe_name }} {{ cup.cafe_name }}
</td> </td>
<td data-label="Rating" class="px-4 py-3 whitespace-nowrap">{{ cup.rating }}</td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
{% if is_authenticated %} {% if is_authenticated %}
<div class="hidden md:inline-flex items-center gap-1"> <div class="hidden md:inline-flex items-center gap-1">

View file

@ -4,7 +4,7 @@
<div id="gear-list" class="mt-6" data-star-scope="gear"> <div id="gear-list" class="mt-6" data-star-scope="gear">
{% if gear.items.is_empty() && !navigator.has_search() %} {% if gear.items.is_empty() && !navigator.has_search() %}
<div <div
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600" class="rounded-lg border border-dashed px-4 py-6 text-sm text-stone-600"
> >
<p class="text-center"> <p class="text-center">
{% if is_authenticated %} {% if is_authenticated %}
@ -15,14 +15,14 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm" <section class="rounded-lg border bg-surface"
{% if gear.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(gear.next_page().unwrap())|safe }}" data-target="#gear-list"{% endif %} {% if gear.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(gear.next_page().unwrap())|safe }}" data-target="#gear-list"{% endif %}
> >
{% call table::search_header(navigator, "#gear-list") %} {% call table::search_header(navigator, "#gear-list") %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700"> <table class="responsive-table min-w-full divide-y text-left text-sm text-stone-700">
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900"> <thead class="bg-surface-alt text-xs font-semibold tracking-wide text-stone-700">
<tr> <tr>
{% call table::sortable_header("Added", "created-at", navigator, "#gear-list") %} {% call table::sortable_header("Added", "created-at", navigator, "#gear-list") %}
{% call table::sortable_header("Category", "category", navigator, "#gear-list") %} {% call table::sortable_header("Category", "category", navigator, "#gear-list") %}
@ -33,17 +33,15 @@
{% endif %} {% endif %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-amber-200/70"> <tbody class="divide-y/70">
{% for item in gear.items %} {% for item in gear.items %}
<tr class="bg-amber-50/40 transition hover:bg-amber-50"> <tr class="transition hover:bg-surface-alt">
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ item.created_at }} {{ item.created_at }}
</td> </td>
<td data-label="" class="card-title px-4 py-3 whitespace-nowrap md:hidden">{{ item.make }} {{ item.model }}</td> <td data-label="" class="card-title px-4 py-3 whitespace-nowrap md:hidden">{{ item.make }} {{ item.model }}</td>
<td data-label="Category" class="px-4 py-3 whitespace-nowrap"> <td data-label="Category" class="px-4 py-3 whitespace-nowrap">
<span class="inline-flex rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800">
{{ item.category_label }} {{ item.category_label }}
</span>
</td> </td>
<td data-label="Make" class="mobile-hidden px-4 py-3 whitespace-nowrap font-medium text-stone-600"> <td data-label="Make" class="mobile-hidden px-4 py-3 whitespace-nowrap font-medium text-stone-600">
{{ item.make }} {{ item.make }}

View file

@ -5,7 +5,7 @@
{% endmacro %} {% endmacro %}
{% macro spinner(class) %} {% macro spinner(class) %}
<svg class="{{ class }} animate-spin text-amber-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true"> <svg class="{{ class }} animate-spin text-accent" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg> </svg>
@ -30,6 +30,12 @@
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro map(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8.157 2.176a1.5 1.5 0 0 0-1.147 0l-4.084 1.69A1.5 1.5 0 0 0 2 5.251v10.877a1.5 1.5 0 0 0 2.074 1.386l3.51-1.453 4.26 1.763a1.5 1.5 0 0 0 1.146 0l4.083-1.69A1.5 1.5 0 0 0 18 14.748V3.873a1.5 1.5 0 0 0-2.073-1.386l-3.51 1.452-4.26-1.763ZM7.58 5a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0v-6.5A.75.75 0 0 1 7.58 5Zm5.59 2.75a.75.75 0 0 0-1.5 0v6.5a.75.75 0 0 0 1.5 0v-6.5Z" clip-rule="evenodd" />
</svg>
{% endmacro %}
{% macro refresh(class) %} {% macro refresh(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0v2.43l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389 5.5 5.5 0 0 1 9.2-2.466l.312.311h-2.433a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.22Z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0v2.43l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389 5.5 5.5 0 0 1 9.2-2.466l.312.311h-2.433a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.22Z" clip-rule="evenodd" />
@ -179,6 +185,44 @@
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro sun(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 10 2ZM10 15a5 5 0 1 0 0-10 5 5 0 0 0 0 10ZM15.657 4.343a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 0 1-1.06-1.06l1.06-1.06a.75.75 0 0 1 1.06 0ZM18 10a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 18 10ZM15.657 15.657a.75.75 0 0 1-1.06 0l-1.06-1.06a.75.75 0 1 1 1.06-1.06l1.06 1.06a.75.75 0 0 1 0 1.06ZM10 18a.75.75 0 0 1-.75-.75v-1.5a.75.75 0 0 1 1.5 0v1.5A.75.75 0 0 1 10 18ZM4.343 15.657a.75.75 0 0 1 0-1.06l1.06-1.06a.75.75 0 1 1 1.061 1.06l-1.06 1.06a.75.75 0 0 1-1.061 0ZM2 10a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5A.75.75 0 0 1 2 10ZM4.343 4.343a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.06-1.06a.75.75 0 0 1 0-1.061Z" />
</svg>
{% endmacro %}
{% macro moon(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M7.455 2.004a.75.75 0 0 1 .26.77 7 7 0 0 0 9.958 7.967.75.75 0 0 1 1.067.853A8.5 8.5 0 1 1 6.647 1.921a.75.75 0 0 1 .808.083Z" clip-rule="evenodd" />
</svg>
{% endmacro %}
{% macro timeline(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<rect x="3.25" y="1" width="1.5" height="18" rx="0.75"/>
<circle cx="4" cy="4" r="2"/>
<circle cx="4" cy="10" r="2"/>
<circle cx="4" cy="16" r="2"/>
<rect x="9" y="3" width="8" height="2" rx="1"/>
<rect x="9" y="9" width="6" height="2" rx="1"/>
<rect x="9" y="15" width="8" height="2" rx="1"/>
</svg>
{% endmacro %}
{% macro database(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 2C6.13 2 3 2.9 3 4s3.13 2 7 2 7-.9 7-2-3.13-2-7-2Z"/>
<path d="M3 4v6c0 1.1 3.13 2 7 2s7-.9 7-2V4c0 1.1-3.13 2-7 2S3 5.1 3 4Z"/>
<path d="M3 10v6c0 1.1 3.13 2 7 2s7-.9 7-2v-6c0 1.1-3.13 2-7 2S3 11.1 3 10Z"/>
</svg>
{% endmacro %}
{% macro cog(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M7.84 1.804A1 1 0 0 1 8.82 1h2.36a1 1 0 0 1 .98.804l.331 1.652a6.993 6.993 0 0 1 1.929 1.115l1.598-.54a1 1 0 0 1 1.186.447l1.18 2.044a1 1 0 0 1-.205 1.251l-1.267 1.113a7.047 7.047 0 0 1 0 2.228l1.267 1.113a1 1 0 0 1 .206 1.25l-1.18 2.045a1 1 0 0 1-1.187.447l-1.598-.54a6.993 6.993 0 0 1-1.929 1.115l-.33 1.652a1 1 0 0 1-.98.804H8.82a1 1 0 0 1-.98-.804l-.331-1.652a6.993 6.993 0 0 1-1.929-1.115l-1.598.54a1 1 0 0 1-1.186-.447l-1.18-2.044a1 1 0 0 1 .205-1.251l1.267-1.114a7.05 7.05 0 0 1 0-2.227L1.821 7.773a1 1 0 0 1-.206-1.25l1.18-2.045a1 1 0 0 1 1.187-.447l1.598.54A6.993 6.993 0 0 1 7.51 3.456l.33-1.652ZM10 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" clip-rule="evenodd" />
</svg>
{% endmacro %}
{% macro key(class) %} {% macro key(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z" clip-rule="evenodd" />

View file

@ -0,0 +1,63 @@
{% import "partials/icons.html" as icons %}
{% macro location_search_js() %}
const locateUser = (triggerEl) => {
const root = triggerEl.closest('[data-location-root]');
const emit = (name, detail) =>
root.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
if (!navigator.geolocation) {
emit('location-error', { message: 'Geolocation not supported by your browser.' });
return;
}
emit('location-start');
navigator.geolocation.getCurrentPosition(
(pos) => {
emit('location-found', { lat: pos.coords.latitude, lng: pos.coords.longitude });
},
(err) => {
if (err.code === 1) {
emit('location-error', { message: 'Location access denied. Search by name instead.' });
} else {
emit('location-error', { message: 'Could not determine location. Search by name instead.' });
}
},
{ enableHighAccuracy: true, timeout: 15000 },
);
};
{% endmacro %}
{% macro location_search(error_signal) %}
<div data-location-root
data-on:location-found="$_locating = false; $_locationFound = true; $_userLat = evt.detail.lat; $_userLng = evt.detail.lng; @get('/api/v1/nearby-cafes?lat=' + evt.detail.lat + '&lng=' + evt.detail.lng + '&q=coffee', {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})"
data-on:location-error="$_locating = false; {{ error_signal }} = evt.detail.message"
data-on:location-start="$_locating = true"
>
<div class="flex items-center gap-2 mb-3">
<button type="button"
data-on:click="locateUser(el)"
class="shrink-0 inline-flex items-center justify-center rounded-md border p-2.5 transition hover:bg-surface-alt"
data-class="{'text-accent': $_locationFound, 'text-stone-800': !$_locationFound}"
data-attr:disabled="$_locating || $_locationFound"
aria-label="Use my location"
>
<span data-show="!$_locating">{% call icons::location("h-5 w-5") %}</span>
<span data-show="$_locating" style="display: none">{% call icons::spinner("h-5 w-5") %}</span>
</button>
<input type="text" data-bind:_city-name
class="input-field w-full text-sm"
data-attr:placeholder="$_locationFound ? 'Using your location (' + $_userLat.toFixed(2) + ', ' + $_userLng.toFixed(2) + ')' : 'Name of city or area'"
data-attr:disabled="$_locationFound"
data-on:input__debounce.350ms="$_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}})"
/>
</div>
<input type="text" data-bind:_cafe-search
class="input-field w-full text-sm"
placeholder="Search for a cafe&hellip;"
data-on:input__debounce.350ms="if ($_locationFound) { @get('/api/v1/nearby-cafes?lat=' + $_userLat + '&lng=' + $_userLng + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) } else { $_cityName.length >= 2 && $_cafeSearch.length >= 2 && @get('/api/v1/nearby-cafes?near=' + encodeURIComponent($_cityName) + '&q=' + encodeURIComponent($_cafeSearch), {responseOverrides: {selector: '#nearby-results', mode: 'replace'}}) }"
/>
<div id="nearby-results" class="hidden mt-3 max-h-60 overflow-y-auto rounded-lg border bg-surface"></div>
</div>
{% endmacro %}

View file

@ -1,4 +1,4 @@
<div id="nearby-results" class="mt-3 max-h-60 overflow-y-auto rounded-lg border border-amber-200 bg-white"> <div id="nearby-results" class="mt-3 max-h-60 overflow-y-auto rounded-lg border bg-surface">
{% if cafes.is_empty() %} {% if cafes.is_empty() %}
<p class="px-3 py-2 text-sm text-stone-500">No nearby cafes found.</p> <p class="px-3 py-2 text-sm text-stone-500">No nearby cafes found.</p>
{% else %} {% else %}
@ -6,7 +6,7 @@
{% for cafe in cafes %} {% for cafe in cafes %}
<button <button
type="button" type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-amber-100 transition" class="w-full px-3 py-2 text-left text-sm hover:bg-surface-alt transition"
data-cafe-name="{{ cafe.name }}" data-cafe-name="{{ cafe.name }}"
data-cafe-city="{{ cafe.city }}" data-cafe-city="{{ cafe.city }}"
data-cafe-country="{{ cafe.country }}" data-cafe-country="{{ cafe.country }}"
@ -15,7 +15,7 @@
data-cafe-website="{{ cafe.website }}" data-cafe-website="{{ cafe.website }}"
data-on:click="$_cafeId = ''; $_cafeName = el.dataset.cafeName; $_cafeCity = el.dataset.cafeCity; $_cafeCountry = el.dataset.cafeCountry; $_cafeLat = parseFloat(el.dataset.cafeLat); $_cafeLng = parseFloat(el.dataset.cafeLng); $_cafeWebsite = el.dataset.cafeWebsite; $_reviewingCafe = true" data-on:click="$_cafeId = ''; $_cafeName = el.dataset.cafeName; $_cafeCity = el.dataset.cafeCity; $_cafeCountry = el.dataset.cafeCountry; $_cafeLat = parseFloat(el.dataset.cafeLat); $_cafeLng = parseFloat(el.dataset.cafeLng); $_cafeWebsite = el.dataset.cafeWebsite; $_reviewingCafe = true"
> >
<span class="font-medium text-amber-900">{{ cafe.name }}</span> <span class="font-medium text-stone-800">{{ cafe.name }}</span>
<span class="ml-2 text-xs text-stone-500">{{ cafe.location }} &middot; {{ cafe.distance }}</span> <span class="ml-2 text-xs text-stone-500">{{ cafe.location }} &middot; {{ cafe.distance }}</span>
</button> </button>
{% endfor %} {% endfor %}

View file

@ -4,7 +4,7 @@
<div id="roast-list" class="mt-6" data-star-scope="roasts"> <div id="roast-list" class="mt-6" data-star-scope="roasts">
{% if roasts.items.is_empty() && !navigator.has_search() %} {% if roasts.items.is_empty() && !navigator.has_search() %}
<div <div
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600" class="rounded-lg border border-dashed px-4 py-6 text-sm text-stone-600"
> >
<p class="text-center"> <p class="text-center">
{% if is_authenticated %} {% if is_authenticated %}
@ -15,13 +15,13 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm" <section class="rounded-lg border bg-surface"
{% if roasts.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(roasts.next_page().unwrap())|safe }}" data-target="#roast-list"{% endif %} {% if roasts.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(roasts.next_page().unwrap())|safe }}" data-target="#roast-list"{% endif %}
> >
{% call table::search_header(navigator, "#roast-list") %} {% call table::search_header(navigator, "#roast-list") %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700"> <table class="responsive-table min-w-full divide-y text-left text-sm text-stone-700">
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900"> <thead class="bg-surface-alt text-xs font-semibold tracking-wide text-stone-700">
<tr> <tr>
{% call table::sortable_header("Added", "created-at", navigator, "#roast-list") %} {% call table::sortable_header("Added", "created-at", navigator, "#roast-list") %}
{% call table::sortable_header("Roast", "name", navigator, "#roast-list") %} {% call table::sortable_header("Roast", "name", navigator, "#roast-list") %}
@ -32,7 +32,7 @@
{% endif %} {% endif %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-amber-200/70"> <tbody class="divide-y/70">
{% for roast in roasts.items %} {% for roast in roasts.items %}
<tr <tr
data-star-key="{{ roast.full_id }}" data-star-key="{{ roast.full_id }}"
@ -41,7 +41,7 @@
data-sort-roaster="{{ roast.roaster_label }}" data-sort-roaster="{{ roast.roaster_label }}"
data-sort-origin="{{ roast.origin }}" data-sort-origin="{{ roast.origin }}"
data-sort-producer="{{ roast.producer }}" data-sort-producer="{{ roast.producer }}"
class="bg-amber-50/40 transition hover:bg-amber-50" class="transition hover:bg-surface-alt"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ roast.created_at }} {{ roast.created_at }}

View file

@ -4,7 +4,7 @@
<div id="roaster-list" class="mt-6" data-star-scope="roasters"> <div id="roaster-list" class="mt-6" data-star-scope="roasters">
{% if roasters.items.is_empty() && !navigator.has_search() %} {% if roasters.items.is_empty() && !navigator.has_search() %}
<div <div
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600" class="rounded-lg border border-dashed px-4 py-6 text-sm text-stone-600"
> >
<p class="text-center"> <p class="text-center">
{% if is_authenticated %} {% if is_authenticated %}
@ -15,13 +15,13 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm" <section class="rounded-lg border bg-surface"
{% if roasters.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(roasters.next_page().unwrap())|safe }}" data-target="#roaster-list"{% endif %} {% if roasters.has_next() %}data-infinite-scroll data-next-url="{{ navigator.fragment_page_href(roasters.next_page().unwrap())|safe }}" data-target="#roaster-list"{% endif %}
> >
{% call table::search_header(navigator, "#roaster-list") %} {% call table::search_header(navigator, "#roaster-list") %}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700"> <table class="responsive-table min-w-full divide-y text-left text-sm text-stone-700">
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900"> <thead class="bg-surface-alt text-xs font-semibold tracking-wide text-stone-700">
<tr> <tr>
{% call table::sortable_header("Added", "created-at", navigator, "#roaster-list") %} {% call table::sortable_header("Added", "created-at", navigator, "#roaster-list") %}
{% call table::sortable_header("Name", "name", navigator, "#roaster-list") %} {% call table::sortable_header("Name", "name", navigator, "#roaster-list") %}
@ -29,7 +29,7 @@
<th scope="col" class="px-4 py-3 text-right">Actions</th> <th scope="col" class="px-4 py-3 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-amber-200/70"> <tbody class="divide-y/70">
{% for roaster in roasters.items %} {% for roaster in roasters.items %}
<tr <tr
data-star-key="{{ roaster.id }}" data-star-key="{{ roaster.id }}"
@ -37,7 +37,7 @@
data-sort-name="{{ roaster.name }}" data-sort-name="{{ roaster.name }}"
data-sort-country="{{ roaster.country }}" data-sort-country="{{ roaster.country }}"
data-sort-city="{{ roaster.city }}" data-sort-city="{{ roaster.city }}"
class="bg-amber-50/40 transition hover:bg-amber-50" class="transition hover:bg-surface-alt"
> >
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ roaster.created_at }} {{ roaster.created_at }}
@ -58,7 +58,7 @@
<div class="hidden md:inline-flex items-center gap-1"> <div class="hidden md:inline-flex items-center gap-1">
{% if roaster.has_homepage %} {% if roaster.has_homepage %}
<a <a
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-amber-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
href="{{ roaster.homepage_url }}" href="{{ roaster.homepage_url }}"
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"

View file

@ -0,0 +1,24 @@
{% import "partials/icons.html" as icons %}
{% macro scan_input(form_id, image_input_id, extracting_signal, error_signal, placeholder, spinner_text) %}
<input type="hidden" name="image" id="{{ image_input_id }}" />
<div data-show="!{{ extracting_signal }}" class="flex items-center gap-2">
<brew-photo-capture target-input="{{ image_input_id }}" target-form="{{ form_id }}"
class="shrink-0 inline-flex items-center justify-center rounded-md border p-2.5 text-accent transition hover:bg-surface-alt cursor-pointer"
aria-label="Take Photo"
>
{% call icons::camera("h-5 w-5") %}
</brew-photo-capture>
<input
type="text"
name="prompt"
class="input-field w-full text-sm"
placeholder="{{ placeholder }}"
/>
</div>
<div data-show="{{ extracting_signal }}" style="display:none" class="flex items-center gap-3 text-sm text-accent">
{% call icons::spinner("h-5 w-5") %}
{{ spinner_text }}
</div>
<p data-show="{{ error_signal }}" data-text="{{ error_signal }}" style="display:none" class="mt-2 text-sm text-red-600"></p>
{% endmacro %}

View file

@ -1,5 +1,5 @@
{% macro search_header(navigator, target_selector) %} {% macro search_header(navigator, target_selector) %}
<div class="border-b border-amber-200/70 px-4 py-3"> <div class="border-b px-4 py-3">
<input <input
type="search" type="search"
placeholder="Search..." placeholder="Search..."
@ -14,7 +14,7 @@
{% macro pagination_header(items, navigator, target_selector) %} {% macro pagination_header(items, navigator, target_selector) %}
<div <div
class="pagination-controls hidden md:flex flex-wrap items-center justify-between gap-x-3 border-t border-amber-200/70 px-4 py-1.5 text-xs leading-none text-stone-600" class="pagination-controls hidden md:flex flex-wrap items-center justify-between gap-x-3 border-t px-4 py-1.5 text-xs leading-none text-stone-600"
> >
<span> <span>
Showing {{ items.start_index() }}&ndash;{{ items.end_index() }} of {{ items.total }} Showing {{ items.start_index() }}&ndash;{{ items.end_index() }} of {{ items.total }}
@ -24,7 +24,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="button" type="button"
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300" class="inline-flex items-center gap-1 rounded-full border px-3 py-1 font-semibold transition hover:text-accent hover:border-accent disabled:cursor-not-allowed disabled:opacity-40"
{% if items.has_previous() %} {% if items.has_previous() %}
data-on:click="history.pushState(null, '', '{{ navigator.page_href(items.previous_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(items.previous_page().unwrap())|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})" data-on:click="history.pushState(null, '', '{{ navigator.page_href(items.previous_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(items.previous_page().unwrap())|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
{% else %} {% else %}
@ -35,15 +35,15 @@
<span>Previous</span> <span>Previous</span>
</button> </button>
{% if items.is_showing_all() %} {% if items.is_showing_all() %}
<span class="font-semibold text-amber-800">Showing all results</span> <span class="font-semibold text-stone-700">Showing all results</span>
{% else %} {% else %}
<span class="font-semibold text-amber-800" <span class="font-semibold text-stone-700"
>Page {{ items.page }} of {{ items.total_pages() }}</span >Page {{ items.page }} of {{ items.total_pages() }}</span
> >
{% endif %} {% endif %}
<button <button
type="button" type="button"
class="inline-flex items-center gap-1 rounded-full border border-amber-400 px-3 py-1 font-semibold transition hover:border-amber-500 hover:text-amber-800 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300" class="inline-flex items-center gap-1 rounded-full border px-3 py-1 font-semibold transition hover:text-accent hover:border-accent disabled:cursor-not-allowed disabled:opacity-40"
{% if items.has_next() %} {% if items.has_next() %}
data-on:click="history.pushState(null, '', '{{ navigator.page_href(items.next_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(items.next_page().unwrap())|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})" data-on:click="history.pushState(null, '', '{{ navigator.page_href(items.next_page().unwrap())|safe }}'); @get('{{ navigator.fragment_page_href(items.next_page().unwrap())|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
{% else %} {% else %}
@ -57,7 +57,7 @@
<label class="flex items-center gap-2 text-xs font-semibold text-stone-600"> <label class="flex items-center gap-2 text-xs font-semibold text-stone-600">
<span>Rows</span> <span>Rows</span>
<select <select
class="rounded-md border border-amber-300 bg-white px-2 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-400" class="rounded-md border bg-surface px-2 py-1 text-xs font-semibold text-stone-700 transition hover:border-accent"
data-on:change="history.pushState(null, '', evt.target.selectedOptions[0].dataset.url); @get(evt.target.selectedOptions[0].dataset.href, {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})" data-on:change="history.pushState(null, '', evt.target.selectedOptions[0].dataset.url); @get(evt.target.selectedOptions[0].dataset.href, {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
> >
<option value="5" data-href="{{ navigator.fragment_rows_href("5")|safe }}" data-url="{{ navigator.rows_href("5")|safe }}" {% if items.is_page_size(5) %}selected{% endif %}>5</option> <option value="5" data-href="{{ navigator.fragment_rows_href("5")|safe }}" data-url="{{ navigator.rows_href("5")|safe }}" {% if items.is_page_size(5) %}selected{% endif %}>5</option>
@ -80,7 +80,7 @@
> >
<button <button
type="button" type="button"
class="flex items-center gap-1 text-amber-900 hover:text-amber-700" class="flex items-center gap-1 text-stone-800 hover:text-accent"
data-on:click="history.pushState(null, '', '{{ navigator.sort_href(key)|safe }}'); @get('{{ navigator.fragment_sort_href(key)|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})" data-on:click="history.pushState(null, '', '{{ navigator.sort_href(key)|safe }}'); @get('{{ navigator.fragment_sort_href(key)|safe }}', {responseOverrides: {selector: '{{ target_selector }}', mode: 'replace'}})"
> >
<span>{{ label }}</span> <span>{{ label }}</span>

View file

@ -1,5 +1,5 @@
{% import "partials/icons.html" as icons %} {% import "partials/icons.html" as icons %}
<h2 id="{{ month.anchor }}" class="timeline-heading scroll-mt-24 mb-6 text-2xl font-semibold text-amber-800" data-timeline-month> <h2 id="{{ month.anchor }}" class="timeline-heading scroll-mt-24 mb-6 text-2xl font-semibold text-stone-800" data-timeline-month>
{{ month.heading }} {{ month.heading }}
<button type="button" class="timeline-top-btn" onclick="window.scrollTo({top: 0, behavior: 'smooth'})" aria-label="Back to top"> <button type="button" class="timeline-top-btn" onclick="window.scrollTo({top: 0, behavior: 'smooth'})" aria-label="Back to top">
{% call icons::arrow_up("h-5 w-5") %} {% call icons::arrow_up("h-5 w-5") %}
@ -9,15 +9,12 @@
<div class="timeline-item relative mb-8" data-timeline-event> <div class="timeline-item relative mb-8" data-timeline-event>
{# Timeline node/bullet #} {# Timeline node/bullet #}
<span <span
class="timeline-node absolute h-5 w-5 rounded-full border-4 border-amber-50 bg-amber-600" class="timeline-node absolute h-5 w-5 rounded-full border-[3px] border-accent bg-page"
aria-hidden="true" aria-hidden="true"
></span> ></span>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-5 shadow-sm text-left"> <div class="rounded-lg border bg-surface p-5 text-left">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<span <span class="pill pill-muted">{{ event.kind_label }}</span>
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800"
>{{ event.kind_label }}</span
>
<time <time
datetime="{{ event.iso_timestamp }}" datetime="{{ event.iso_timestamp }}"
class="text-xs uppercase tracking-wide text-stone-500" class="text-xs uppercase tracking-wide text-stone-500"
@ -26,17 +23,17 @@
%} %}
</time> </time>
</div> </div>
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold text-amber-800"> <h3 class="mt-3 flex items-center gap-2 text-lg font-semibold text-stone-800">
<a href="{{ event.link }}" class="hover:text-amber-600">{{ event.title }}</a> <a href="{{ event.link }}" class="hover:text-accent">{{ event.title }}</a>
{% if let Some(url) = event.external_link %} {% if let Some(url) = event.external_link %}
<a <a
href="{{ url }}" href="{{ url }}"
class="inline-flex h-7 w-7 items-center justify-center text-amber-700 transition hover:text-amber-500" class="ml-auto text-accent transition hover:text-accent-hover"
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
aria-label="Open external link" aria-label="Open external link"
> >
{% call icons::external_link("h-3.5 w-3.5") %} {% call icons::external_link("h-4 w-4") %}
<span class="sr-only">Open external link</span> <span class="sr-only">Open external link</span>
</a> </a>
{% endif %} {% endif %}
@ -58,7 +55,7 @@
<input type="hidden" name="water_temp" value="{{ brew.water_temp }}" /> <input type="hidden" name="water_temp" value="{{ brew.water_temp }}" />
<button <button
type="submit" type="submit"
class="inline-flex h-7 w-7 items-center justify-center rounded-md text-amber-700 transition hover:text-amber-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500" class="text-accent transition hover:text-accent-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
title="Brew again" title="Brew again"
> >
<span class="sr-only">Brew again</span> <span class="sr-only">Brew again</span>
@ -74,33 +71,29 @@
<div class="flex justify-between gap-2"> <div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">{{ detail.label }}</dt> <dt class="font-medium text-stone-500">{{ detail.label }}</dt>
{% if detail.label.eq_ignore_ascii_case("position") && detail.link.is_some() %} {% if detail.label.eq_ignore_ascii_case("position") && detail.link.is_some() %}
<dd class="text-right"> <dd class="text-right flex items-center justify-end gap-1.5">
<a href="{{ detail.link.as_ref().unwrap() }}" target="_blank" rel="noreferrer noopener" class="text-amber-700 hover:text-amber-500">View on Map</a> {{ detail.value }}
<span class="text-stone-400">/</span> <a href="{{ detail.link.as_ref().unwrap() }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover" aria-label="View on map">
<button type="button" onclick="navigator.clipboard.writeText('{{ detail.value }}')" class="text-amber-700 hover:text-amber-500 transition" title="Copy coordinates" aria-label="Copy coordinates">Copy</button> {% call icons::map("h-4 w-4") %}
</a>
</dd> </dd>
{% else if let Some(url) = detail.link %} {% else if let Some(url) = detail.link %}
<dd class="text-right"><a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-amber-700 hover:text-amber-500">{{ detail.value }}</a></dd> <dd class="text-right"><a href="{{ url }}" target="_blank" rel="noreferrer noopener" class="text-accent hover:text-accent-hover">{{ detail.value }}</a></dd>
{% else %} {% else %}
<dd class="text-right">{{ detail.value }}</dd> <dd class="text-right">{{ detail.value }}</dd>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
{% if let Some(notes) = event.tasting_notes %}
{% if !notes.is_empty() %}
<div class="flex justify-between gap-2">
<dt class="shrink-0 font-medium text-stone-500">Tasting Notes</dt>
<dd class="text-right">{{ notes.join(", ") }}</dd>
</div>
{% endif %}
{% endif %}
</dl> </dl>
{% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %} {% endif %}
<p class="mt-4 text-sm text-stone-600">No tasting notes yet.</p>
{% else %}
<ul class="mt-4 flex flex-wrap gap-2">
{% for note in notes %}
<li>
<span
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
>{{ note }}</span
>
</li>
{% endfor %}
</ul>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View file

@ -5,8 +5,8 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-6 shadow-sm"> <div class="rounded-lg border bg-surface p-6">
<h1 class="text-2xl font-semibold text-amber-700">Register</h1> <h1 class="text-2xl font-semibold text-accent">Register</h1>
<p class="mt-2 text-sm text-stone-600"> <p class="mt-2 text-sm text-stone-600">
Create your account by registering a passkey. This will be used to sign in going forward. Create your account by registering a passkey. This will be used to sign in going forward.
</p> </p>
@ -44,7 +44,7 @@
<button <button
id="register-button" id="register-button"
type="button" type="button"
class="mt-2 w-full rounded-md bg-amber-600 px-4 py-3 text-sm font-semibold text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed" class="mt-2 w-full 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"
> >
Register Passkey Register Passkey
</button> </button>
@ -61,7 +61,7 @@
</div> </div>
<a <a
href="/" href="/"
class="mt-4 block w-full rounded-md bg-amber-600 px-4 py-3 text-center text-sm font-semibold text-amber-50 transition hover:bg-amber-500" class="mt-4 block w-full rounded-md bg-accent px-4 py-3 text-center text-sm font-semibold text-accent-text transition hover:bg-accent-hover"
> >
Go to Brewlog Go to Brewlog
</a> </a>

View file

@ -1,19 +1,12 @@
{% extends "base.html" %} {% block title %}Brewlog · Timeline{% endblock %} {% block content %} {% extends "base.html" %} {% block title %}Brewlog · Timeline{% endblock %} {% block content %}
<header class="flex flex-col gap-2"> <div>
<h1 class="text-3xl font-semibold">Timeline</h1>
<p class="max-w-2xl text-sm text-stone-600">
Follow the history of roasters and roasts in Brewlog.
</p>
</header>
<div class="mt-8">
<section <section
id="timeline-events" id="timeline-events"
data-empty="{{ months.is_empty() }}" data-empty="{{ months.is_empty() }}"
> >
{% if months.is_empty() %} {% if months.is_empty() %}
<p <p
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-6 text-sm text-stone-600" class="rounded-lg border border-dashed p-6 text-sm text-stone-600"
data-role="timeline-empty-state" data-role="timeline-empty-state"
> >
No events yet. No events yet.
@ -21,7 +14,7 @@
{% else %} {% else %}
<div class="timeline-list relative" id="timeline-items"> <div class="timeline-list relative" id="timeline-items">
{# Single central timeline line for all months #} {# Single central timeline line for all months #}
<div class="timeline-line absolute top-0 bottom-0 w-0.5 bg-amber-200" aria-hidden="true"></div> <div class="timeline-line absolute top-0 bottom-0 w-1 bg-stone-300 dark:bg-stone-600" aria-hidden="true"></div>
{% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %} {% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
@ -36,7 +29,7 @@
<button <button
id="timeline-load-more" id="timeline-load-more"
type="button" type="button"
class="inline-flex items-center gap-2 rounded-full border border-amber-500 px-4 py-2 text-sm font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600 disabled:cursor-not-allowed disabled:border-amber-200 disabled:text-amber-300" class="inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold text-accent transition hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-40"
style="{% if months.is_empty() || !events.has_next() %}display: none{% endif %}" style="{% if months.is_empty() || !events.has_next() %}display: none{% endif %}"
> >
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
@ -95,7 +88,7 @@
container.className = "timeline-list relative" container.className = "timeline-list relative"
container.id = "timeline-items" container.id = "timeline-items"
const line = document.createElement("div") const line = document.createElement("div")
line.className = "timeline-line absolute top-0 bottom-0 w-0.5 bg-amber-200" line.className = "timeline-line absolute top-0 bottom-0 w-1 bg-stone-300 dark:bg-stone-600"
line.setAttribute("aria-hidden", "true") line.setAttribute("aria-hidden", "true")
container.appendChild(line) container.appendChild(line)
section.insertBefore(container, loader) section.insertBefore(container, loader)