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:
parent
6d12b71482
commit
fb67f0659c
41 changed files with 933 additions and 911 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
10
src/lib.rs
10
src/lib.rs
|
|
@ -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"),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)} · ${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 — 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…"
|
|
||||||
/>
|
|
||||||
</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…
|
|
||||||
</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…
|
Saving…
|
||||||
</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 & Roast
|
Save Roaster & 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…" />
|
|
||||||
<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…
|
|
||||||
</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…" />
|
|
||||||
<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…
|
|
||||||
</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…">
|
||||||
<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…">
|
||||||
<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…">
|
||||||
<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 }} · {{ 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 (°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…" 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…">
|
||||||
<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…">
|
||||||
<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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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…"
|
|
||||||
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…"
|
|
||||||
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…"
|
||||||
>
|
oninput="filterList(this, 'cafe-options')"
|
||||||
<option value="">Select a cafe…</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…"
|
|
||||||
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 & saving…
|
|
||||||
</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…"
|
||||||
>
|
oninput="filterList(this, 'roast-options')"
|
||||||
<option value="">Select a roast…</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 & 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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
26
templates/components/photo-capture.js
Normal file
26
templates/components/photo-capture.js
Normal 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 = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
85
templates/components/searchable-select.js
Normal file
85
templates/components/searchable-select.js
Normal 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 }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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…"
|
|
||||||
/>
|
|
||||||
</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…
|
|
||||||
</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…
|
Saving…
|
||||||
</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 & Roast
|
Save Roaster & 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 →</a>
|
<a href="/data?type=brews" class="text-sm text-accent hover:text-accent-hover font-medium">View all brews →</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 →</a>
|
<a href="/data?type=bags" class="text-sm text-accent hover:text-accent-hover font-medium">View all bags →</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 →</a>
|
<a href="/timeline" class="text-sm text-accent hover:text-accent-hover font-medium">View full timeline →</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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'}})"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
63
templates/partials/location_search.html
Normal file
63
templates/partials/location_search.html
Normal 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…"
|
||||||
|
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 %}
|
||||||
|
|
@ -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 }} · {{ cafe.distance }}</span>
|
<span class="ml-2 text-xs text-stone-500">{{ cafe.location }} · {{ cafe.distance }}</span>
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
24
templates/partials/scan_input.html
Normal file
24
templates/partials/scan_input.html
Normal 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 %}
|
||||||
|
|
@ -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() }}–{{ items.end_index() }} of {{ items.total }}
|
Showing {{ items.start_index() }}–{{ 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>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue