feat(cafes): add CLI commands, web views, and templates

Add CLI subcommands (add, list, get, update, delete) with negative
number support for coordinates. Add CafeView with map URL generation,
Askama templates for list/detail pages, and nav link between Brews
and Gear.
This commit is contained in:
Jon Seager 2026-02-03 14:53:00 +00:00
parent 1781bff48d
commit 3f83d74e20
No known key found for this signature in database
9 changed files with 564 additions and 3 deletions

View file

@ -4,7 +4,7 @@ use brewlog::infrastructure::backup::{BackupData, BackupService};
use brewlog::infrastructure::client::BrewlogClient; use brewlog::infrastructure::client::BrewlogClient;
use brewlog::infrastructure::database::Database; use brewlog::infrastructure::database::Database;
use brewlog::presentation::cli::{ use brewlog::presentation::cli::{
Cli, Commands, ServeCommand, bags, brews, gear, roasters, roasts, tokens, Cli, Commands, ServeCommand, bags, brews, cafes, gear, roasters, roasts, tokens,
}; };
use clap::Parser; use clap::Parser;
@ -43,6 +43,10 @@ async fn main() -> Result<()> {
let client = BrewlogClient::from_base_url(&cli.api_url)?; let client = BrewlogClient::from_base_url(&cli.api_url)?;
brews::run(&client, command).await brews::run(&client, command).await
} }
Commands::Cafe { command } => {
let client = BrewlogClient::from_base_url(&cli.api_url)?;
cafes::run(&client, command).await
}
Commands::Token { command } => { Commands::Token { command } => {
let client = BrewlogClient::from_base_url(&cli.api_url)?; let client = BrewlogClient::from_base_url(&cli.api_url)?;
tokens::run(&client, command).await tokens::run(&client, command).await

View file

@ -0,0 +1,112 @@
use anyhow::Result;
use clap::{Args, Subcommand};
use super::macros::{define_delete_command, define_get_command};
use super::print_json;
use crate::domain::cafes::{NewCafe, UpdateCafe};
use crate::domain::ids::CafeId;
use crate::infrastructure::client::BrewlogClient;
#[derive(Debug, Subcommand)]
pub enum CafeCommands {
/// Add a new cafe
Add(AddCafeCommand),
/// List all cafes
List,
/// Get a cafe by ID
Get(GetCafeCommand),
/// Update a cafe
Update(UpdateCafeCommand),
/// Delete a cafe
Delete(DeleteCafeCommand),
}
pub async fn run(client: &BrewlogClient, cmd: CafeCommands) -> Result<()> {
match cmd {
CafeCommands::Add(c) => add_cafe(client, c).await,
CafeCommands::List => list_cafes(client).await,
CafeCommands::Get(c) => get_cafe(client, c).await,
CafeCommands::Update(c) => update_cafe(client, c).await,
CafeCommands::Delete(c) => delete_cafe(client, c).await,
}
}
#[derive(Debug, Args)]
pub struct AddCafeCommand {
#[arg(long)]
pub name: String,
#[arg(long)]
pub city: String,
#[arg(long)]
pub country: String,
#[arg(long, allow_negative_numbers = true)]
pub latitude: f64,
#[arg(long, allow_negative_numbers = true)]
pub longitude: f64,
#[arg(long)]
pub website: Option<String>,
#[arg(long)]
pub notes: Option<String>,
}
pub async fn add_cafe(client: &BrewlogClient, command: AddCafeCommand) -> Result<()> {
let payload = NewCafe {
name: command.name,
city: command.city,
country: command.country,
latitude: command.latitude,
longitude: command.longitude,
website: command.website,
notes: command.notes,
};
let cafe = client.cafes().create(&payload).await?;
print_json(&cafe)
}
pub async fn list_cafes(client: &BrewlogClient) -> Result<()> {
let cafes = client.cafes().list().await?;
print_json(&cafes)
}
define_get_command!(GetCafeCommand, get_cafe, CafeId, cafes);
#[derive(Debug, Args)]
pub struct UpdateCafeCommand {
#[arg(long)]
pub id: i64,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub city: Option<String>,
#[arg(long)]
pub country: Option<String>,
#[arg(long, allow_negative_numbers = true)]
pub latitude: Option<f64>,
#[arg(long, allow_negative_numbers = true)]
pub longitude: Option<f64>,
#[arg(long)]
pub website: Option<String>,
#[arg(long)]
pub notes: Option<String>,
}
pub async fn update_cafe(client: &BrewlogClient, command: UpdateCafeCommand) -> Result<()> {
let payload = UpdateCafe {
name: command.name,
city: command.city,
country: command.country,
latitude: command.latitude,
longitude: command.longitude,
website: command.website,
notes: command.notes,
};
let cafe = client
.cafes()
.update(CafeId::new(command.id), &payload)
.await?;
print_json(&cafe)
}
define_delete_command!(DeleteCafeCommand, delete_cafe, CafeId, cafes, "cafe");

View file

@ -1,6 +1,7 @@
pub mod backup; pub mod backup;
pub mod bags; pub mod bags;
pub mod brews; pub mod brews;
pub mod cafes;
pub mod gear; pub mod gear;
mod macros; mod macros;
pub mod roasters; pub mod roasters;
@ -12,6 +13,7 @@ use std::net::SocketAddr;
use backup::{BackupCommand, RestoreCommand}; use backup::{BackupCommand, RestoreCommand};
use bags::BagCommands; use bags::BagCommands;
use brews::BrewCommands; use brews::BrewCommands;
use cafes::CafeCommands;
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use gear::GearCommands; use gear::GearCommands;
use roasters::RoasterCommands; use roasters::RoasterCommands;
@ -68,6 +70,12 @@ pub enum Commands {
command: BrewCommands, command: BrewCommands,
}, },
/// Manage cafes
Cafe {
#[command(subcommand)]
command: CafeCommands,
},
/// Manage API tokens /// Manage API tokens
Token { Token {
#[command(subcommand)] #[command(subcommand)]

View file

@ -1,11 +1,13 @@
use askama::Template; use askama::Template;
use super::views::{ use super::views::{
BagOptionView, BagView, BrewDefaultsView, BrewView, GearOptionView, GearView, ListNavigator, BagOptionView, BagView, BrewDefaultsView, BrewView, CafeView, GearOptionView, GearView,
Paginated, RoastView, RoasterOptionView, RoasterView, TimelineEventView, TimelineMonthView, ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineEventView,
TimelineMonthView,
}; };
use crate::domain::bags::BagSortKey; use crate::domain::bags::BagSortKey;
use crate::domain::brews::BrewSortKey; use crate::domain::brews::BrewSortKey;
use crate::domain::cafes::CafeSortKey;
use crate::domain::gear::GearSortKey; use crate::domain::gear::GearSortKey;
use crate::domain::roasters::RoasterSortKey; use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasts::{RoastSortKey, RoastWithRoaster}; use crate::domain::roasts::{RoastSortKey, RoastWithRoaster};
@ -148,6 +150,31 @@ pub struct BrewListTemplate {
pub navigator: ListNavigator<BrewSortKey>, pub navigator: ListNavigator<BrewSortKey>,
} }
#[derive(Template)]
#[template(path = "cafes.html")]
pub struct CafesTemplate {
pub nav_active: &'static str,
pub is_authenticated: bool,
pub cafes: Paginated<CafeView>,
pub navigator: ListNavigator<CafeSortKey>,
}
#[derive(Template)]
#[template(path = "partials/cafe_list.html")]
pub struct CafeListTemplate {
pub is_authenticated: bool,
pub cafes: Paginated<CafeView>,
pub navigator: ListNavigator<CafeSortKey>,
}
#[derive(Template)]
#[template(path = "cafe_detail.html")]
pub struct CafeDetailTemplate {
pub nav_active: &'static str,
pub is_authenticated: bool,
pub cafe: CafeView,
}
pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> { pub fn render_template<T: Template>(template: T) -> Result<String, askama::Error> {
template.render() template.render()
} }

View file

@ -1,5 +1,6 @@
use crate::domain::bags::BagWithRoast; use crate::domain::bags::BagWithRoast;
use crate::domain::brews::{Brew, BrewWithDetails}; use crate::domain::brews::{Brew, BrewWithDetails};
use crate::domain::cafes::Cafe;
use crate::domain::gear::Gear; use crate::domain::gear::Gear;
use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey};
use crate::domain::roasters::Roaster; use crate::domain::roasters::Roaster;
@ -519,6 +520,7 @@ impl TimelineEventView {
("bag", "finished") => "Bag Finished", ("bag", "finished") => "Bag Finished",
("gear", "added") => "Gear Added", ("gear", "added") => "Gear Added",
("brew", "brewed") => "Brewed", ("brew", "brewed") => "Brewed",
("cafe", "added") => "Cafe Added",
_ => "Event", _ => "Event",
}; };
@ -528,6 +530,7 @@ impl TimelineEventView {
("roast" | "bag" | "brew", Some(slug), Some(roaster_slug)) => { ("roast" | "bag" | "brew", Some(slug), Some(roaster_slug)) => {
format!("/roasters/{roaster_slug}/roasts/{slug}") format!("/roasters/{roaster_slug}/roasts/{slug}")
} }
("cafe", Some(slug), _) => format!("/cafes/{slug}"),
("gear", _, _) => "/gear".to_string(), ("gear", _, _) => "/gear".to_string(),
("brew", _, _) => "/brews".to_string(), ("brew", _, _) => "/brews".to_string(),
("roaster", None, _) => format!("/roasters/{entity_id}"), ("roaster", None, _) => format!("/roasters/{entity_id}"),
@ -800,3 +803,63 @@ impl From<Brew> for BrewDefaultsView {
} }
} }
} }
pub struct CafeView {
pub id: String,
pub detail_path: String,
pub name: String,
pub city: String,
pub country: String,
pub latitude: f64,
pub longitude: f64,
pub map_url: String,
pub has_website: bool,
pub website_url: String,
pub website_label: String,
pub notes: String,
pub created_at: String,
pub created_at_sort_key: i64,
}
impl From<Cafe> for CafeView {
fn from(cafe: Cafe) -> Self {
let Cafe {
id,
slug,
name,
city,
country,
latitude,
longitude,
website,
notes,
created_at,
updated_at: _,
} = cafe;
let website = website.unwrap_or_default();
let has_website = !website.is_empty();
let detail_path = format!("/cafes/{slug}");
let map_url = format!("https://www.google.com/maps?q={latitude},{longitude}");
let created_at_sort_key = created_at.timestamp();
let created_at_label = created_at.format("%Y-%m-%d").to_string();
Self {
detail_path,
id: id.to_string(),
name,
city,
country,
latitude,
longitude,
map_url,
has_website,
website_url: website.clone(),
website_label: website,
notes: notes.unwrap_or_else(|| "This cafe has no notes yet.".to_string()),
created_at: created_at_label,
created_at_sort_key,
}
}
}

View file

@ -0,0 +1,73 @@
{% extends "base.html" %} {% block title %}Brewlog · Cafe · {{ cafe.name }}{% endblock %} {%
block content %}
<header class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">{{ cafe.name }}</h1>
<p class="max-w-2xl text-sm text-stone-600">Detailed view for {{ cafe.name }}.</p>
</header>
<section class="grid gap-4 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
<dl class="grid gap-2 text-sm text-stone-700">
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">City</dt>
<dd class="text-right">{{ cafe.city }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">Country</dt>
<dd class="text-right">{{ cafe.country }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">Coordinates</dt>
<dd class="text-right">{{ cafe.latitude }}, {{ cafe.longitude }}</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="font-medium text-stone-500">Map</dt>
<dd class="flex justify-end">
<a
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-200/60 px-3 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-500 hover:bg-amber-200 hover:text-amber-900"
href="{{ cafe.map_url }}"
target="_blank"
rel="noreferrer noopener"
aria-label="Open {{ cafe.name }} in Google Maps"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M9.69 18.933l.003.001C9.89 19.02 10 19 10 19s.11.02.308-.066l.002-.001.006-.003.018-.008a5.741 5.741 0 00.281-.14c.186-.096.446-.24.757-.433.62-.384 1.445-.966 2.274-1.765C15.302 14.988 17 12.493 17 9A7 7 0 103 9c0 3.492 1.698 5.988 3.355 7.584a13.731 13.731 0 002.273 1.765 11.842 11.842 0 00.976.544l.062.029.018.008.006.003zM10 11.25a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5z" clip-rule="evenodd" />
</svg>
<span>Open in Maps</span>
</a>
</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="font-medium text-stone-500">Website</dt>
<dd class="flex justify-end">
{% if cafe.has_website %}
<a
class="inline-flex items-center gap-2 rounded-md border border-amber-400 bg-amber-200/60 px-3 py-1 text-xs font-semibold text-amber-800 transition hover:border-amber-500 hover:bg-amber-200 hover:text-amber-900"
href="{{ cafe.website_url }}"
target="_blank"
rel="noreferrer noopener"
aria-label="Visit {{ cafe.name }} website"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
clip-rule="evenodd"
/>
<path
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
/>
</svg>
<span>Visit</span>
</a>
{% else %}
<span>&mdash;</span>
{% endif %}
</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">Created</dt>
<dd class="text-right">{{ cafe.created_at }}</dd>
</div>
</dl>
<p class="text-sm text-stone-600">{{ cafe.notes }}</p>
</section>
{% endblock %}

133
templates/cafes.html Normal file
View file

@ -0,0 +1,133 @@
{% extends "base.html" %} {% block title %}Brewlog · Cafes{% endblock %} {% block content %}
<section data-signals:_show-form="false">
<header class="flex flex-wrap items-start justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-semibold">Cafes</h1>
<p class="max-w-2xl text-sm text-stone-600">Discover and track your favourite cafes.</p>
</div>
{% if is_authenticated %}
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
data-class:hidden="$_showForm"
data-on:click="$_showForm = true"
aria-label="Add new cafe"
>
<span aria-hidden="true">+</span>
</button>
{% endif %}
</header>
{% if is_authenticated %}
<div
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
data-show="$_showForm"
style="display: none"
>
<div>
<h2 class="text-lg font-semibold text-amber-700">New Cafe</h2>
<p class="mt-1 text-sm text-stone-600">
Add a cafe you have visited or want to remember.
</p>
</div>
<form
method="post"
action="/api/v1/cafes"
class="mt-4 flex flex-col gap-4"
data-on:submit="@post('/api/v1/cafes?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#cafe-list', mode: 'replace'}})"
data-ref="_form"
data-on:datastar-fetch="evt.detail.type === 'finished' && ($_showForm = false, $_form && $_form.reset())"
>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Name *</span>
<input
type="text"
name="name"
required
class="input-field"
placeholder="Blue Bottle Coffee"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">City *</span>
<input
type="text"
name="city"
required
class="input-field"
placeholder="San Francisco"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Country *</span>
<input
type="text"
name="country"
required
class="input-field"
placeholder="United States"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Website</span>
<input
type="url"
name="website"
class="input-field"
placeholder="https://bluebottlecoffee.com"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Latitude *</span>
<input
type="number"
name="latitude"
step="any"
required
class="input-field"
placeholder="37.7749"
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-stone-700">Longitude *</span>
<input
type="number"
name="longitude"
step="any"
required
class="input-field"
placeholder="-122.4194"
/>
</label>
<label class="sm:col-span-2 flex flex-col gap-1 text-sm">
<span class="text-stone-700">Notes</span>
<textarea
name="notes"
rows="3"
class="input-field"
placeholder="Atmosphere, speciality drinks, opening hours..."
></textarea>
</label>
</div>
<div class="flex items-center justify-end gap-2">
<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"
data-on:click="($_showForm = false, $_form && $_form.reset())"
>
Cancel
</button>
<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"
>
Save Cafe
</button>
</div>
</form>
</div>
{% endif %}
</section>
{% include "partials/cafe_list.html" %} {% endblock %}

View file

@ -7,6 +7,7 @@
<a class="border-b-2 pb-1 transition {% if nav_active == "roasts" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasts">Roasts</a> <a class="border-b-2 pb-1 transition {% if nav_active == "roasts" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasts">Roasts</a>
<a class="border-b-2 pb-1 transition {% if nav_active == "bags" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/bags">Bags</a> <a class="border-b-2 pb-1 transition {% if nav_active == "bags" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/bags">Bags</a>
<a class="border-b-2 pb-1 transition {% if nav_active == "brews" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/brews">Brews</a> <a class="border-b-2 pb-1 transition {% if nav_active == "brews" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/brews">Brews</a>
<a class="border-b-2 pb-1 transition {% if nav_active == "cafes" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/cafes">Cafes</a>
<a class="border-b-2 pb-1 transition {% if nav_active == "gear" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/gear">Gear</a> <a class="border-b-2 pb-1 transition {% if nav_active == "gear" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/gear">Gear</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> <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 %} {% if is_authenticated %}
@ -33,6 +34,7 @@
<a class="py-1 transition {% if nav_active == "roasts" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/roasts">Roasts</a> <a class="py-1 transition {% if nav_active == "roasts" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/roasts">Roasts</a>
<a class="py-1 transition {% if nav_active == "bags" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/bags">Bags</a> <a class="py-1 transition {% if nav_active == "bags" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/bags">Bags</a>
<a class="py-1 transition {% if nav_active == "brews" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/brews">Brews</a> <a class="py-1 transition {% if nav_active == "brews" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/brews">Brews</a>
<a class="py-1 transition {% if nav_active == "cafes" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/cafes">Cafes</a>
<a class="py-1 transition {% if nav_active == "gear" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/gear">Gear</a> <a class="py-1 transition {% if nav_active == "gear" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/gear">Gear</a>
<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> <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>
{% if is_authenticated %} {% if is_authenticated %}

View file

@ -0,0 +1,139 @@
{% import "partials/table.html" as table %}
<div id="cafe-list" class="mt-6" data-star-scope="cafes">
{% if cafes.items.is_empty() && !navigator.has_search() %}
<div
class="rounded-lg border border-dashed border-amber-300 bg-amber-100/40 px-4 py-6 text-sm text-stone-600"
>
<p class="text-center">
No cafes recorded yet. Use the form above to add your first cafe.
</p>
</div>
{% else %}
<section class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm"
{% 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") %}
<div class="overflow-x-auto">
<table class="responsive-table min-w-full divide-y divide-amber-200 text-left text-sm text-stone-700">
<thead class="bg-amber-200/60 text-xs font-semibold tracking-wide text-amber-900">
<tr>
{% call table::sortable_header("Added", "created-at", navigator, "#cafe-list") %}
{% call table::sortable_header("Name", "name", navigator, "#cafe-list") %}
{% call table::sortable_header("Location", "country", navigator, "#cafe-list") %}
<th scope="col" class="mobile-hidden px-4 py-3">Notes</th>
<th scope="col" class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-amber-200/70">
{% for cafe in cafes.items %}
<tr
data-star-key="{{ cafe.id }}"
data-sort-created-at="{{ cafe.created_at_sort_key }}"
data-sort-name="{{ cafe.name }}"
data-sort-country="{{ cafe.country }}"
data-sort-city="{{ cafe.city }}"
class="bg-amber-50/40 transition hover:bg-amber-50"
>
<td data-label="Added" class="whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ cafe.created_at }}
</td>
<td data-label="Name" class="px-4 py-3">
<a
href="{{ cafe.detail_path }}"
class="font-semibold text-amber-800 hover:text-amber-600"
>{{ cafe.name }}</a
>
</td>
<td data-label="Location" class="px-4 py-3 whitespace-nowrap">
{{ cafe.country }}
<div class="hidden md:block text-xs text-stone-500">{{ cafe.city }}</div>
</td>
<td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cafe.city }}</td>
<td data-label="Notes" class="mobile-hidden px-4 py-3 text-sm text-stone-600">{{ cafe.notes }}</td>
<td data-label="" class="px-4 py-3 text-right">
<div class="inline-flex items-center gap-1">
<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"
href="{{ cafe.map_url }}"
target="_blank"
rel="noreferrer noopener"
title="Open in Google Maps"
>
<span class="sr-only">Open {{ cafe.name }} in Google Maps</span>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M9.69 18.933l.003.001C9.89 19.02 10 19 10 19s.11.02.308-.066l.002-.001.006-.003.018-.008a5.741 5.741 0 00.281-.14c.186-.096.446-.24.757-.433.62-.384 1.445-.966 2.274-1.765C15.302 14.988 17 12.493 17 9A7 7 0 103 9c0 3.492 1.698 5.988 3.355 7.584a13.731 13.731 0 002.273 1.765 11.842 11.842 0 00.976.544l.062.029.018.008.006.003zM10 11.25a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5z" clip-rule="evenodd" />
</svg>
</a>
{% if cafe.has_website %}
<a
class="hidden md: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"
href="{{ cafe.website_url }}"
target="_blank"
rel="noreferrer noopener"
title="Visit website"
>
<span class="sr-only">Visit {{ cafe.name }} website</span>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
clip-rule="evenodd"
/>
<path
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
/>
</svg>
</a>
{% else %}
<span
class="hidden md:inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-300 cursor-default"
title="No website"
aria-disabled="true"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
clip-rule="evenodd"
/>
<path
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
/>
</svg>
</span>
{% endif %}
{% if is_authenticated %}
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
title="Delete cafe"
data-on:click="confirm('Delete this cafe?') && @delete('/api/v1/cafes/{{ cafe.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#cafe-list', mode: 'replace'}})"
>
<span class="sr-only">Delete</span>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M7.5 3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1H15a1 1 0 1 1 0 2h-.4l-.74 10.36A2 2 0 0 1 11.87 17H8.13a2 2 0 0 1-1.99-1.64L5.4 5H5a1 1 0 1 1 0-2h2.5Zm.9 4.5a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Zm3.4 0a.75.75 0 0 1 .75.75v6a.75.75 0 1 1-1.5 0v-6a.75.75 0 0 1 .75-.75Z"
clip-rule="evenodd"
/>
</svg>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if cafes.items.is_empty() %}
<div class="p-8 text-center text-stone-500">No cafes match your search.</div>
{% endif %}
{% call table::pagination_header(cafes, navigator, "#cafe-list") %}
{% if cafes.has_next() %}
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
{% endif %}
</section>
{% endif %}
</div>