refactor(ui): improve data table detail rows and mobile cards

- Split created_at into created_date + created_time across all view models
- Add inline card-detail pattern for mobile action buttons
- Add desktop detail-row with ellipsis toggle for table actions
- Add pill-warning and pill-success CSS variants with dark mode
- Center mobile card-detail buttons, fix card-date vertical spacing
- Rename action labels: "View on Map" → "View Map", "Visit website/homepage" → "Homepage"
This commit is contained in:
Jon Seager 2026-02-05 20:40:38 +00:00
parent 35ea246ba2
commit 3bc6fae656
No known key found for this signature in database
15 changed files with 351 additions and 315 deletions

View file

@ -9,7 +9,8 @@ pub struct BagView {
pub remaining: String, pub remaining: String,
pub closed: bool, pub closed: bool,
pub finished_at: String, pub finished_at: String,
pub created_at: String, pub created_date: String,
pub created_time: String,
pub roast_name: String, pub roast_name: String,
pub roaster_name: String, pub roaster_name: String,
pub roast_slug: String, pub roast_slug: String,
@ -29,7 +30,8 @@ impl BagView {
.bag .bag
.finished_at .finished_at
.map_or_else(|| "".to_string(), |d| d.to_string()), .map_or_else(|| "".to_string(), |d| d.to_string()),
created_at: bag.bag.created_at.format("%Y-%m-%d").to_string(), created_date: bag.bag.created_at.format("%Y-%m-%d").to_string(),
created_time: bag.bag.created_at.format("%H:%M").to_string(),
roast_name: bag.roast_name, roast_name: bag.roast_name,
roaster_name: bag.roaster_name, roaster_name: bag.roaster_name,
roast_slug: bag.roast_slug, roast_slug: bag.roast_slug,

View file

@ -21,7 +21,8 @@ pub struct BrewView {
pub water_volume: String, pub water_volume: String,
pub water_temp: String, pub water_temp: String,
pub ratio: String, pub ratio: String,
pub created_at: String, pub created_date: String,
pub created_time: String,
pub relative_date_label: String, pub relative_date_label: String,
// Raw values for "brew again" feature // Raw values for "brew again" feature
pub coffee_weight_raw: f64, pub coffee_weight_raw: f64,
@ -62,7 +63,8 @@ impl BrewView {
water_volume: format!("{}ml", brew.brew.water_volume), water_volume: format!("{}ml", brew.brew.water_volume),
water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp), water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp),
ratio, ratio,
created_at: brew.brew.created_at.format("%Y-%m-%d %H:%M").to_string(), created_date: brew.brew.created_at.format("%Y-%m-%d").to_string(),
created_time: brew.brew.created_at.format("%H:%M").to_string(),
relative_date_label: relative_date(brew.brew.created_at), relative_date_label: relative_date(brew.brew.created_at),
coffee_weight_raw: brew.brew.coffee_weight, coffee_weight_raw: brew.brew.coffee_weight,
grind_setting_raw: brew.brew.grind_setting, grind_setting_raw: brew.brew.grind_setting,

View file

@ -13,7 +13,8 @@ pub struct CafeView {
pub has_website: bool, pub has_website: bool,
pub website_url: String, pub website_url: String,
pub website_label: String, pub website_label: String,
pub created_at: String, pub created_date: String,
pub created_time: String,
pub created_at_sort_key: i64, pub created_at_sort_key: i64,
} }
@ -38,7 +39,8 @@ impl From<Cafe> for CafeView {
let map_url = format!("https://www.google.com/maps?q={latitude},{longitude}"); let map_url = format!("https://www.google.com/maps?q={latitude},{longitude}");
let created_at_sort_key = created_at.timestamp(); let created_at_sort_key = created_at.timestamp();
let created_at_label = created_at.format("%Y-%m-%d").to_string(); let created_date = created_at.format("%Y-%m-%d").to_string();
let created_time = created_at.format("%H:%M").to_string();
Self { Self {
detail_path, detail_path,
@ -52,7 +54,8 @@ impl From<Cafe> for CafeView {
has_website, has_website,
website_url: website.clone(), website_url: website.clone(),
website_label: website, website_label: website,
created_at: created_at_label, created_date,
created_time,
created_at_sort_key, created_at_sort_key,
} }
} }

View file

@ -9,7 +9,8 @@ pub struct CupView {
pub roaster_slug: String, pub roaster_slug: String,
pub cafe_name: String, pub cafe_name: String,
pub cafe_slug: String, pub cafe_slug: String,
pub created_at: String, pub created_date: String,
pub created_time: String,
} }
impl CupView { impl CupView {
@ -22,7 +23,8 @@ impl CupView {
roaster_slug: cup.roaster_slug, roaster_slug: cup.roaster_slug,
cafe_name: cup.cafe_name, cafe_name: cup.cafe_name,
cafe_slug: cup.cafe_slug, cafe_slug: cup.cafe_slug,
created_at: cup.cup.created_at.format("%Y-%m-%d %H:%M").to_string(), created_date: cup.cup.created_at.format("%Y-%m-%d").to_string(),
created_time: cup.cup.created_at.format("%H:%M").to_string(),
} }
} }
} }

View file

@ -8,7 +8,8 @@ pub struct GearView {
pub make: String, pub make: String,
pub model: String, pub model: String,
pub full_name: String, pub full_name: String,
pub created_at: String, pub created_date: String,
pub created_time: String,
} }
impl GearView { impl GearView {
@ -20,7 +21,8 @@ impl GearView {
make: gear.make.clone(), make: gear.make.clone(),
model: gear.model.clone(), model: gear.model.clone(),
full_name: format!("{} {}", gear.make, gear.model), full_name: format!("{} {}", gear.make, gear.model),
created_at: gear.created_at.format("%Y-%m-%d").to_string(), created_date: gear.created_at.format("%Y-%m-%d").to_string(),
created_time: gear.created_at.format("%H:%M").to_string(),
} }
} }
} }

View file

@ -32,7 +32,8 @@ pub struct RoasterView {
pub has_homepage: bool, pub has_homepage: bool,
pub homepage_url: String, pub homepage_url: String,
pub homepage_label: String, pub homepage_label: String,
pub created_at: String, pub created_date: String,
pub created_time: String,
pub created_at_sort_key: i64, pub created_at_sort_key: i64,
} }
@ -53,7 +54,8 @@ impl From<Roaster> for RoasterView {
let detail_path = format!("/roasters/{slug}"); let detail_path = format!("/roasters/{slug}");
let created_at_sort_key = created_at.timestamp(); let created_at_sort_key = created_at.timestamp();
let created_at_label = created_at.format("%Y-%m-%d").to_string(); let created_date = created_at.format("%Y-%m-%d").to_string();
let created_time = created_at.format("%H:%M").to_string();
Self { Self {
detail_path, detail_path,
@ -64,7 +66,8 @@ impl From<Roaster> for RoasterView {
has_homepage, has_homepage,
homepage_url: homepage.clone(), homepage_url: homepage.clone(),
homepage_label: homepage, homepage_label: homepage,
created_at: created_at_label, created_date,
created_time,
created_at_sort_key, created_at_sort_key,
} }
} }

View file

@ -10,7 +10,8 @@ pub struct RoastView {
pub region: String, pub region: String,
pub producer: String, pub producer: String,
pub process: String, pub process: String,
pub created_at: String, pub created_date: String,
pub created_time: String,
pub created_at_sort_key: i64, pub created_at_sort_key: i64,
pub tasting_notes: Vec<String>, pub tasting_notes: Vec<String>,
} }
@ -64,7 +65,8 @@ impl RoastView {
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
.collect(); .collect();
let created_at = created_at.format("%Y-%m-%d").to_string(); let created_date = created_at.format("%Y-%m-%d").to_string();
let created_time = created_at.format("%H:%M").to_string();
let detail_path = format!("/roasters/{roaster_slug}/roasts/{slug}"); let detail_path = format!("/roasters/{roaster_slug}/roasts/{slug}");
Self { Self {
@ -77,7 +79,8 @@ impl RoastView {
region, region,
producer, producer,
process, process,
created_at, created_date,
created_time,
created_at_sort_key, created_at_sort_key,
tasting_notes, tasting_notes,
} }

View file

@ -142,6 +142,28 @@ a {
background-color: var(--surface-alt); background-color: var(--surface-alt);
} }
.pill-warning {
border-color: rgba(217, 119, 6, 0.4);
color: #92400e;
background-color: #fffbeb;
}
[data-theme="dark"] .pill-warning {
color: #fbbf24;
background-color: rgba(217, 119, 6, 0.1);
}
.pill-success {
border-color: rgba(5, 150, 105, 0.4);
color: #065f46;
background-color: #ecfdf5;
}
[data-theme="dark"] .pill-success {
color: #34d399;
background-color: rgba(5, 150, 105, 0.1);
}
/* ── Tabs ──────────────────────────────────────────────────────── */ /* ── Tabs ──────────────────────────────────────────────────────── */
.tab { .tab {
@ -367,8 +389,8 @@ a {
/* "Added" date: footer section with top divider */ /* "Added" date: footer section with top divider */
.responsive-table td.card-date { .responsive-table td.card-date {
order: 99; order: 99;
padding-top: 0.5rem; margin: 0.5rem -1rem -1rem !important;
margin-top: 0.5rem; padding: 0.75rem 1rem !important;
border-top: 1px solid var(--border-muted) !important; border-top: 1px solid var(--border-muted) !important;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-secondary); color: var(--text-secondary);
@ -396,84 +418,29 @@ a {
display: none; display: none;
} }
/* Three-dot dropdown menu */ /* Hide detail row on mobile — inline card-detail is used instead */
.card-menu { .responsive-table .detail-row {
position: relative; display: none !important;
} }
.card-menu summary { /* Inline detail content within cards */
list-style: none; .responsive-table .card-detail {
cursor: pointer; order: -1;
display: inline-flex; margin: 0 -1rem 0.25rem !important;
height: 2rem; padding: 0.5rem 1rem !important;
width: 2rem; border-bottom: 1px solid var(--border-muted) !important;
align-items: center; background-color: var(--surface-alt);
justify-content: center; justify-content: center;
border-radius: 0.375rem;
color: var(--text-secondary);
transition: color 150ms ease, background-color 150ms ease;
} }
.card-menu summary:hover { .responsive-table .card-detail.hidden {
color: var(--accent); display: none !important;
background-color: var(--surface-alt);
} }
.card-menu summary::-webkit-details-marker { .responsive-table .card-detail::before {
display: none; display: none;
} }
.card-menu[open] summary {
color: var(--accent);
background-color: var(--surface-alt);
}
.card-menu .card-menu-items {
position: absolute;
right: 0;
top: 100%;
z-index: 20;
min-width: 10rem;
margin-top: 0.25rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
background-color: var(--surface);
padding: 0.25rem 0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.card-menu .card-menu-items a,
.card-menu .card-menu-items button,
.card-menu .card-menu-items form button {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: var(--text);
text-align: left;
background: none;
border: none;
cursor: pointer;
transition: background-color 150ms ease;
text-decoration: none;
}
.card-menu .card-menu-items a:hover,
.card-menu .card-menu-items button:hover,
.card-menu .card-menu-items form button:hover {
background-color: var(--surface-alt);
}
.card-menu .card-menu-items .menu-item-danger {
color: #dc2626;
}
.card-menu .card-menu-items .menu-item-danger:hover {
background-color: rgba(254, 202, 202, 0.3);
}
.pagination-controls { .pagination-controls {
display: none; display: none;
} }
@ -482,6 +449,11 @@ a {
/* ── Desktop timeline ──────────────────────────────────────────── */ /* ── Desktop timeline ──────────────────────────────────────────── */
@media (min-width: 768px) { @media (min-width: 768px) {
/* Hide inline card-detail on desktop — detail row is used instead */
.responsive-table .card-detail {
display: none !important;
}
.timeline-list { .timeline-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -38,7 +38,8 @@
{% for bag in bags.items %} {% for bag in bags.items %}
<tr class="transition hover:bg-surface-alt"> <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 }} <div>{{ bag.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ bag.created_time }}</div>
</td> </td>
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap font-medium text-stone-600"> <td data-label="Roaster" class="px-4 py-3 whitespace-nowrap font-medium text-stone-600">
{{ bag.roaster_name }} {{ bag.roaster_name }}
@ -58,47 +59,53 @@
{% endif %} {% endif %}
<td data-label="Finished" class="px-4 py-3 whitespace-nowrap">{{ bag.finished_at }}</td> <td data-label="Finished" class="px-4 py-3 whitespace-nowrap">{{ bag.finished_at }}</td>
{% if is_authenticated %} {% if is_authenticated %}
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-detail hidden">
<div class="hidden md:inline-flex items-center gap-1"> <div class="flex items-center gap-4">
{% if !bag.closed %}
<button
type="button"
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"
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'}})"
>
<span class="sr-only">Close bag</span>
{% call icons::x_circle("h-4 w-4") %}
</button>
{% endif %}
<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 bag"
data-on:click="confirm('Delete this bag?') && @delete('/api/v1/bags/{{ bag.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#bag-list', mode: 'replace'}})"
>
<span class="sr-only">Delete</span>
{% call icons::delete("h-4 w-4") %}
</button>
</div>
<details class="card-menu md:hidden">
<summary>{% call icons::ellipsis_vertical("h-5 w-5") %}</summary>
<div class="card-menu-items">
{% if !bag.closed %} {% if !bag.closed %}
<button type="button" <button type="button"
data-on:click="this.closest('details').open = false; 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'}})"> class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
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'}})">
{% call icons::x_circle("h-4 w-4") %} Close bag {% call icons::x_circle("h-4 w-4") %} Close bag
</button> </button>
{% endif %} {% endif %}
<button type="button" class="menu-item-danger" <button type="button"
data-on:click="this.closest('details').open = false; confirm('Delete this bag?') && @delete('/api/v1/bags/{{ bag.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#bag-list', mode: 'replace'}})"> class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
data-on:click="confirm('Delete this bag?') && @delete('/api/v1/bags/{{ bag.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#bag-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete {% call icons::delete("h-4 w-4") %} Delete
</button> </button>
</div> </div>
</details> </td>
<td data-label="" class="card-actions px-4 py-3 text-right">
<button type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-400 transition hover:text-accent hover:bg-surface-alt"
onclick="const tr=this.closest('tr');const d=tr.querySelector('.card-detail');if(d)d.classList.toggle('hidden');const r=tr.nextElementSibling;if(r&&r.classList.contains('detail-row'))r.classList.toggle('hidden');this.querySelector('.icon-expand').classList.toggle('hidden');this.querySelector('.icon-collapse').classList.toggle('hidden');"
title="Actions">
<span class="icon-expand">{% call icons::ellipsis_vertical("h-5 w-5") %}</span>
<span class="icon-collapse hidden">{% call icons::chevron_up("h-5 w-5") %}</span>
</button>
</td> </td>
{% endif %} {% endif %}
</tr> </tr>
{% if is_authenticated %}
<tr class="hidden detail-row">
<td colspan="7" class="bg-surface-alt px-4 py-2">
<div class="flex items-center gap-4">
{% if !bag.closed %}
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
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'}})">
{% call icons::x_circle("h-4 w-4") %} Close bag
</button>
{% endif %}
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
data-on:click="confirm('Delete this bag?') && @delete('/api/v1/bags/{{ bag.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#bag-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete
</button>
</div>
</td>
</tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -38,7 +38,8 @@
{% for brew in brews.items %} {% for brew in brews.items %}
<tr class="transition hover:bg-surface-alt"> <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 }} <div>{{ brew.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ brew.created_time }}</div>
</td> </td>
<td data-label="Coffee" class="card-title px-4 py-3 whitespace-nowrap"> <td data-label="Coffee" class="card-title px-4 py-3 whitespace-nowrap">
<div class="font-medium text-stone-800">{{ brew.roast_name }}</div> <div class="font-medium text-stone-800">{{ brew.roast_name }}</div>
@ -66,8 +67,8 @@
<td data-label="Filter" class="px-4 py-3 whitespace-nowrap md:hidden">{{ fp_name }}</td> <td data-label="Filter" class="px-4 py-3 whitespace-nowrap md:hidden">{{ fp_name }}</td>
{% endif %} {% endif %}
{% if is_authenticated %} {% if is_authenticated %}
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-detail hidden">
<div class="hidden md:inline-flex items-center gap-1"> <div class="flex items-center gap-4">
<form class="inline" data-on:submit="@post('/api/v1/brews?{{ navigator.query() }}', {contentType: 'form', responseOverrides: {selector: '#brew-list', mode: 'replace'}})"> <form class="inline" data-on:submit="@post('/api/v1/brews?{{ navigator.query() }}', {contentType: 'form', responseOverrides: {selector: '#brew-list', mode: 'replace'}})">
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" /> <input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight_raw }}" /> <input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight_raw }}" />
@ -79,29 +80,34 @@
{% endif %} {% endif %}
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" /> <input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
<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-accent hover:text-accent-hover">
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" {% call icons::plus_circle("h-4 w-4") %} Brew Again
title="Brew again"
>
<span class="sr-only">Brew again</span>
{% call icons::refresh("h-4 w-4") %}
</button> </button>
</form> </form>
<button <button type="button"
type="button" class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
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" data-on:click="confirm('Delete this brew?') && @delete('/api/v1/brews/{{ brew.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#brew-list', mode: 'replace'}})">
title="Delete brew" {% call icons::delete("h-4 w-4") %} Delete
data-on:click="confirm('Delete this brew?') && @delete('/api/v1/brews/{{ brew.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#brew-list', mode: 'replace'}})"
>
<span class="sr-only">Delete</span>
{% call icons::delete("h-4 w-4") %}
</button> </button>
</div> </div>
<details class="card-menu md:hidden"> </td>
<summary>{% call icons::ellipsis_vertical("h-5 w-5") %}</summary> <td data-label="" class="card-actions px-4 py-3 text-right">
<div class="card-menu-items"> <button type="button"
<form class="inline" data-on:submit="this.closest('details').open = false; @post('/api/v1/brews?{{ navigator.query() }}', {contentType: 'form', responseOverrides: {selector: '#brew-list', mode: 'replace'}})"> class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-400 transition hover:text-accent hover:bg-surface-alt"
onclick="const tr=this.closest('tr');const d=tr.querySelector('.card-detail');if(d)d.classList.toggle('hidden');const r=tr.nextElementSibling;if(r&&r.classList.contains('detail-row'))r.classList.toggle('hidden');this.querySelector('.icon-expand').classList.toggle('hidden');this.querySelector('.icon-collapse').classList.toggle('hidden');"
title="Actions">
<span class="icon-expand">{% call icons::ellipsis_vertical("h-5 w-5") %}</span>
<span class="icon-collapse hidden">{% call icons::chevron_up("h-5 w-5") %}</span>
</button>
</td>
{% endif %}
</tr>
{% if is_authenticated %}
<tr class="hidden detail-row">
<td colspan="7" class="bg-surface-alt px-4 py-2">
<div class="flex items-center gap-4">
<form class="inline" data-on:submit="@post('/api/v1/brews?{{ navigator.query() }}', {contentType: 'form', responseOverrides: {selector: '#brew-list', mode: 'replace'}})">
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" /> <input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight_raw }}" /> <input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight_raw }}" />
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" /> <input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
@ -112,19 +118,20 @@
{% endif %} {% endif %}
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" /> <input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" /> <input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
<button type="submit"> <button type="submit"
{% call icons::refresh("h-4 w-4") %} Brew again 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") %} Brew Again
</button> </button>
</form> </form>
<button type="button" class="menu-item-danger" <button type="button"
data-on:click="this.closest('details').open = false; confirm('Delete this brew?') && @delete('/api/v1/brews/{{ brew.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#brew-list', mode: 'replace'}})"> class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
data-on:click="confirm('Delete this brew?') && @delete('/api/v1/brews/{{ brew.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#brew-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete {% call icons::delete("h-4 w-4") %} Delete
</button> </button>
</div> </div>
</details>
</td> </td>
{% endif %}
</tr> </tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -40,7 +40,8 @@
class="transition hover:bg-surface-alt" 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 }} <div>{{ cafe.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ cafe.created_time }}</div>
</td> </td>
<td data-label="Name" class="card-title px-4 py-3 font-medium text-stone-800"> <td data-label="Name" class="card-title px-4 py-3 font-medium text-stone-800">
{{ cafe.name }} {{ cafe.name }}
@ -50,69 +51,58 @@
<div class="hidden md:block text-xs text-stone-500">{{ cafe.city }}</div> <div class="hidden md:block text-xs text-stone-500">{{ cafe.city }}</div>
</td> </td>
<td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cafe.city }}</td> <td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ cafe.city }}</td>
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-detail hidden">
<div class="hidden md:inline-flex items-center gap-1"> <div class="flex items-center gap-4">
<a <a class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
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 }}" target="_blank" rel="noreferrer noopener">
href="{{ cafe.map_url }}" {% call icons::location("h-4 w-4") %} View Map
target="_blank"
rel="noreferrer noopener"
title="Open in Google Maps"
>
<span class="sr-only">Open {{ cafe.name }} in Google Maps</span>
{% call icons::location("h-4 w-4") %}
</a> </a>
{% if cafe.has_website %} {% if cafe.has_website %}
<a <a class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
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 }}" target="_blank" rel="noreferrer noopener">
href="{{ cafe.website_url }}" {% call icons::external_link("h-4 w-4") %} Homepage
target="_blank"
rel="noreferrer noopener"
title="Visit website"
>
<span class="sr-only">Visit {{ cafe.name }} website</span>
{% call icons::external_link("h-4 w-4") %}
</a>
{% else %}
<span
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-300 cursor-default"
title="No website"
aria-disabled="true"
>
{% call icons::external_link("h-4 w-4") %}
</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>
{% call icons::delete("h-4 w-4") %}
</button>
{% endif %}
</div>
<details class="card-menu md:hidden">
<summary>{% call icons::ellipsis_vertical("h-5 w-5") %}</summary>
<div class="card-menu-items">
<a href="{{ cafe.map_url }}" target="_blank" rel="noreferrer noopener">
{% call icons::location("h-4 w-4") %} View on map
</a>
{% if cafe.has_website %}
<a href="{{ cafe.website_url }}" target="_blank" rel="noreferrer noopener">
{% call icons::external_link("h-4 w-4") %} Visit website
</a> </a>
{% endif %} {% endif %}
{% if is_authenticated %} {% if is_authenticated %}
<button type="button" class="menu-item-danger" <button type="button"
data-on:click="this.closest('details').open = false; confirm('Delete this cafe?') && @delete('/api/v1/cafes/{{ cafe.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#cafe-list', mode: 'replace'}})"> class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
data-on:click="confirm('Delete this cafe?') && @delete('/api/v1/cafes/{{ cafe.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#cafe-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete
</button>
{% endif %}
</div>
</td>
<td data-label="" class="card-actions px-4 py-3 text-right">
<button type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-400 transition hover:text-accent hover:bg-surface-alt"
onclick="const tr=this.closest('tr');const d=tr.querySelector('.card-detail');if(d)d.classList.toggle('hidden');const r=tr.nextElementSibling;if(r&&r.classList.contains('detail-row'))r.classList.toggle('hidden');this.querySelector('.icon-expand').classList.toggle('hidden');this.querySelector('.icon-collapse').classList.toggle('hidden');"
title="Actions">
<span class="icon-expand">{% call icons::ellipsis_vertical("h-5 w-5") %}</span>
<span class="icon-collapse hidden">{% call icons::chevron_up("h-5 w-5") %}</span>
</button>
</td>
</tr>
<tr class="hidden detail-row">
<td colspan="4" class="bg-surface-alt px-4 py-2">
<div class="flex items-center gap-4">
<a class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
href="{{ cafe.map_url }}" target="_blank" rel="noreferrer noopener">
{% call icons::location("h-4 w-4") %} View Map
</a>
{% if cafe.has_website %}
<a class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
href="{{ cafe.website_url }}" target="_blank" rel="noreferrer noopener">
{% call icons::external_link("h-4 w-4") %} Homepage
</a>
{% endif %}
{% if is_authenticated %}
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
data-on:click="confirm('Delete this cafe?') && @delete('/api/v1/cafes/{{ cafe.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#cafe-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete {% call icons::delete("h-4 w-4") %} Delete
</button> </button>
{% endif %} {% endif %}
</div> </div>
</details>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View file

@ -26,7 +26,9 @@
{% 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") %}
{% if is_authenticated %}
<th scope="col" class="px-4 py-3 text-right">Actions</th> <th scope="col" class="px-4 py-3 text-right">Actions</th>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y/70"> <tbody class="divide-y/70">
@ -36,7 +38,8 @@
class="transition hover:bg-surface-alt" 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 }} <div>{{ cup.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ cup.created_time }}</div>
</td> </td>
<td data-label="Coffee" class="card-title px-4 py-3 whitespace-nowrap"> <td data-label="Coffee" class="card-title px-4 py-3 whitespace-nowrap">
<div class="font-medium text-stone-800">{{ cup.roast_name }}</div> <div class="font-medium text-stone-800">{{ cup.roast_name }}</div>
@ -46,31 +49,40 @@
<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="" 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"> <td data-label="" class="card-detail hidden">
<button <div class="flex items-center gap-4">
type="button" <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" class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
title="Delete cup" data-on:click="confirm('Delete this cup?') && @delete('/api/v1/cups/{{ cup.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#cup-list', mode: 'replace'}})">
data-on:click="confirm('Delete this cup?') && @delete('/api/v1/cups/{{ cup.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#cup-list', mode: 'replace'}})"
>
<span class="sr-only">Delete</span>
{% call icons::delete("h-4 w-4") %}
</button>
</div>
<details class="card-menu md:hidden">
<summary>{% call icons::ellipsis_vertical("h-5 w-5") %}</summary>
<div class="card-menu-items">
<button type="button" class="menu-item-danger"
data-on:click="this.closest('details').open = false; confirm('Delete this cup?') && @delete('/api/v1/cups/{{ cup.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#cup-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete {% call icons::delete("h-4 w-4") %} Delete
</button> </button>
</div> </div>
</details> </td>
<td data-label="" class="card-actions px-4 py-3 text-right">
<button type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-400 transition hover:text-accent hover:bg-surface-alt"
onclick="const tr=this.closest('tr');const d=tr.querySelector('.card-detail');if(d)d.classList.toggle('hidden');const r=tr.nextElementSibling;if(r&&r.classList.contains('detail-row'))r.classList.toggle('hidden');this.querySelector('.icon-expand').classList.toggle('hidden');this.querySelector('.icon-collapse').classList.toggle('hidden');"
title="Actions">
<span class="icon-expand">{% call icons::ellipsis_vertical("h-5 w-5") %}</span>
<span class="icon-collapse hidden">{% call icons::chevron_up("h-5 w-5") %}</span>
</button>
</td>
{% endif %} {% endif %}
</tr>
{% if is_authenticated %}
<tr class="hidden detail-row">
<td colspan="4" class="bg-surface-alt px-4 py-2">
<div class="flex items-center gap-4">
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
data-on:click="confirm('Delete this cup?') && @delete('/api/v1/cups/{{ cup.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#cup-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete
</button>
</div>
</td> </td>
</tr> </tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -37,7 +37,8 @@
{% for item in gear.items %} {% for item in gear.items %}
<tr class="transition hover:bg-surface-alt"> <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 }} <div>{{ item.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ item.created_time }}</div>
</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">
@ -50,28 +51,39 @@
{{ item.model }} {{ item.model }}
</td> </td>
{% if is_authenticated %} {% if is_authenticated %}
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-detail hidden">
<button <div class="flex items-center gap-4">
type="button" <button type="button"
class="hidden md: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" class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
title="Delete gear" data-on:click="confirm('Delete this gear?') && @delete('/api/v1/gear/{{ item.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#gear-list', mode: 'replace'}})">
data-on:click="confirm('Delete this gear?') && @delete('/api/v1/gear/{{ item.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#gear-list', mode: 'replace'}})"
>
<span class="sr-only">Delete</span>
{% call icons::delete("h-4 w-4") %}
</button>
<details class="card-menu md:hidden">
<summary>{% call icons::ellipsis_vertical("h-5 w-5") %}</summary>
<div class="card-menu-items">
<button type="button" class="menu-item-danger"
data-on:click="this.closest('details').open = false; confirm('Delete this gear?') && @delete('/api/v1/gear/{{ item.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#gear-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete {% call icons::delete("h-4 w-4") %} Delete
</button> </button>
</div> </div>
</details> </td>
<td data-label="" class="card-actions px-4 py-3 text-right">
<button type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-400 transition hover:text-accent hover:bg-surface-alt"
onclick="const tr=this.closest('tr');const d=tr.querySelector('.card-detail');if(d)d.classList.toggle('hidden');const r=tr.nextElementSibling;if(r&&r.classList.contains('detail-row'))r.classList.toggle('hidden');this.querySelector('.icon-expand').classList.toggle('hidden');this.querySelector('.icon-collapse').classList.toggle('hidden');"
title="Actions">
<span class="icon-expand">{% call icons::ellipsis_vertical("h-5 w-5") %}</span>
<span class="icon-collapse hidden">{% call icons::chevron_up("h-5 w-5") %}</span>
</button>
</td> </td>
{% endif %} {% endif %}
</tr> </tr>
{% if is_authenticated %}
<tr class="hidden detail-row">
<td colspan="5" class="bg-surface-alt px-4 py-2">
<div class="flex items-center gap-4">
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
data-on:click="confirm('Delete this gear?') && @delete('/api/v1/gear/{{ item.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#gear-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete
</button>
</div>
</td>
</tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -44,7 +44,8 @@
class="transition hover:bg-surface-alt" 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 }} <div>{{ roast.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ roast.created_time }}</div>
</td> </td>
<td data-label="Roast" class="card-title px-4 py-3"> <td data-label="Roast" class="card-title px-4 py-3">
<div class="font-medium text-stone-800">{{ roast.name }}</div> <div class="font-medium text-stone-800">{{ roast.name }}</div>
@ -60,36 +61,47 @@
{% if !roast.producer.is_empty() %} {% if !roast.producer.is_empty() %}
<td data-label="Producer" class="px-4 py-3 whitespace-nowrap md:hidden">{{ roast.producer }}</td> <td data-label="Producer" class="px-4 py-3 whitespace-nowrap md:hidden">{{ roast.producer }}</td>
{% endif %} {% endif %}
<td data-label="Tasting Notes" class="px-4 py-3 text-stone-600 text-right md:text-left"> <td data-label="Tasting Notes" class="px-4 py-3 text-xs text-stone-500 text-right md:text-left">
{% if roast.tasting_notes.is_empty() %} {% if roast.tasting_notes.is_empty() %}
<span class="text-stone-500">No tasting notes yet.</span> <span class="text-stone-400">&mdash;</span>
{% else %} {% else %}
{{ roast.tasting_notes.join(", ") }} {{ roast.tasting_notes.join(", ") }}
{% endif %} {% endif %}
</td> </td>
{% if is_authenticated %} {% if is_authenticated %}
<td data-label="" class="card-actions px-4 py-3 text-right"> <td data-label="" class="card-detail hidden">
<button <div class="flex items-center gap-4">
type="button" <button type="button"
class="hidden md: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" class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
title="Delete roast" data-on:click="confirm('Delete this roast?') && @delete('/api/v1/roasts/{{ roast.full_id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})">
data-on:click="confirm('Delete this roast?') && @delete('/api/v1/roasts/{{ roast.full_id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})"
>
<span class="sr-only">Delete</span>
{% call icons::delete("h-4 w-4") %}
</button>
<details class="card-menu md:hidden">
<summary>{% call icons::ellipsis_vertical("h-5 w-5") %}</summary>
<div class="card-menu-items">
<button type="button" class="menu-item-danger"
data-on:click="this.closest('details').open = false; confirm('Delete this roast?') && @delete('/api/v1/roasts/{{ roast.full_id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete {% call icons::delete("h-4 w-4") %} Delete
</button> </button>
</div> </div>
</details> </td>
<td data-label="" class="card-actions px-4 py-3 text-right">
<button type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-400 transition hover:text-accent hover:bg-surface-alt"
onclick="const tr=this.closest('tr');const d=tr.querySelector('.card-detail');if(d)d.classList.toggle('hidden');const r=tr.nextElementSibling;if(r&&r.classList.contains('detail-row'))r.classList.toggle('hidden');this.querySelector('.icon-expand').classList.toggle('hidden');this.querySelector('.icon-collapse').classList.toggle('hidden');"
title="Actions">
<span class="icon-expand">{% call icons::ellipsis_vertical("h-5 w-5") %}</span>
<span class="icon-collapse hidden">{% call icons::chevron_up("h-5 w-5") %}</span>
</button>
</td> </td>
{% endif %} {% endif %}
</tr> </tr>
{% if is_authenticated %}
<tr class="hidden detail-row">
<td colspan="5" class="bg-surface-alt px-4 py-2">
<div class="flex items-center gap-4">
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
data-on:click="confirm('Delete this roast?') && @delete('/api/v1/roasts/{{ roast.full_id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roast-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete
</button>
</div>
</td>
</tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -40,7 +40,8 @@
class="transition hover:bg-surface-alt" 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 }} <div>{{ roaster.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ roaster.created_time }}</div>
</td> </td>
<td data-label="Name" class="card-title px-4 py-3 font-medium text-stone-800"> <td data-label="Name" class="card-title px-4 py-3 font-medium text-stone-800">
{{ roaster.name }} {{ roaster.name }}
@ -54,52 +55,58 @@
{% if !roaster.city.is_empty() %} {% if !roaster.city.is_empty() %}
<td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ roaster.city }}</td> <td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ roaster.city }}</td>
{% endif %} {% endif %}
<td data-label="" class="card-actions px-4 py-3 text-right">
<div class="hidden md:inline-flex items-center gap-1">
{% if roaster.has_homepage %}
<a
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 }}"
target="_blank"
rel="noreferrer noopener"
title="Visit homepage"
>
<span class="sr-only">Visit {{ roaster.name }} homepage</span>
{% call icons::external_link("h-4 w-4") %}
</a>
{% 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 roaster"
data-on:click="confirm('Delete this roaster?') && @delete('/api/v1/roasters/{{ roaster.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"
>
<span class="sr-only">Delete</span>
{% call icons::delete("h-4 w-4") %}
</button>
{% endif %}
</div>
{% if roaster.has_homepage || is_authenticated %} {% if roaster.has_homepage || is_authenticated %}
<details class="card-menu md:hidden"> <td data-label="" class="card-detail hidden">
<summary>{% call icons::ellipsis_vertical("h-5 w-5") %}</summary> <div class="flex items-center gap-4">
<div class="card-menu-items">
{% if roaster.has_homepage %} {% if roaster.has_homepage %}
<a href="{{ roaster.homepage_url }}" target="_blank" rel="noreferrer noopener"> <a class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
{% call icons::external_link("h-4 w-4") %} Visit homepage href="{{ roaster.homepage_url }}" target="_blank" rel="noreferrer noopener">
{% call icons::external_link("h-4 w-4") %} Homepage
</a> </a>
{% endif %} {% endif %}
{% if is_authenticated %} {% if is_authenticated %}
<button type="button" class="menu-item-danger" <button type="button"
data-on:click="this.closest('details').open = false; confirm('Delete this roaster?') && @delete('/api/v1/roasters/{{ roaster.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})"> class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
data-on:click="confirm('Delete this roaster?') && @delete('/api/v1/roasters/{{ roaster.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete {% call icons::delete("h-4 w-4") %} Delete
</button> </button>
{% endif %} {% endif %}
</div> </div>
</details> </td>
{% endif %}
<td data-label="" class="card-actions px-4 py-3 text-right">
{% if roaster.has_homepage || is_authenticated %}
<button type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-400 transition hover:text-accent hover:bg-surface-alt"
onclick="const tr=this.closest('tr');const d=tr.querySelector('.card-detail');if(d)d.classList.toggle('hidden');const r=tr.nextElementSibling;if(r&&r.classList.contains('detail-row'))r.classList.toggle('hidden');this.querySelector('.icon-expand').classList.toggle('hidden');this.querySelector('.icon-collapse').classList.toggle('hidden');"
title="Actions">
<span class="icon-expand">{% call icons::ellipsis_vertical("h-5 w-5") %}</span>
<span class="icon-collapse hidden">{% call icons::chevron_up("h-5 w-5") %}</span>
</button>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% if roaster.has_homepage || is_authenticated %}
<tr class="hidden detail-row">
<td colspan="4" class="bg-surface-alt px-4 py-2">
<div class="flex items-center gap-4">
{% if roaster.has_homepage %}
<a class="inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover"
href="{{ roaster.homepage_url }}" target="_blank" rel="noreferrer noopener">
{% call icons::external_link("h-4 w-4") %} Homepage
</a>
{% endif %}
{% if is_authenticated %}
<button type="button"
class="inline-flex items-center gap-1 text-sm font-medium text-stone-500 hover:text-red-600"
data-on:click="confirm('Delete this roaster?') && @delete('/api/v1/roasters/{{ roaster.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#roaster-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete
</button>
{% endif %}
</div>
</td>
</tr>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>