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 closed: bool,
pub finished_at: String,
pub created_at: String,
pub created_date: String,
pub created_time: String,
pub roast_name: String,
pub roaster_name: String,
pub roast_slug: String,
@ -29,7 +30,8 @@ impl BagView {
.bag
.finished_at
.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,
roaster_name: bag.roaster_name,
roast_slug: bag.roast_slug,

View file

@ -21,7 +21,8 @@ pub struct BrewView {
pub water_volume: String,
pub water_temp: String,
pub ratio: String,
pub created_at: String,
pub created_date: String,
pub created_time: String,
pub relative_date_label: String,
// Raw values for "brew again" feature
pub coffee_weight_raw: f64,
@ -62,7 +63,8 @@ impl BrewView {
water_volume: format!("{}ml", brew.brew.water_volume),
water_temp: format!("{:.1}\u{00B0}C", brew.brew.water_temp),
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),
coffee_weight_raw: brew.brew.coffee_weight,
grind_setting_raw: brew.brew.grind_setting,

View file

@ -13,7 +13,8 @@ pub struct CafeView {
pub has_website: bool,
pub website_url: String,
pub website_label: String,
pub created_at: String,
pub created_date: String,
pub created_time: String,
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 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 {
detail_path,
@ -52,7 +54,8 @@ impl From<Cafe> for CafeView {
has_website,
website_url: website.clone(),
website_label: website,
created_at: created_at_label,
created_date,
created_time,
created_at_sort_key,
}
}

View file

@ -9,7 +9,8 @@ pub struct CupView {
pub roaster_slug: String,
pub cafe_name: String,
pub cafe_slug: String,
pub created_at: String,
pub created_date: String,
pub created_time: String,
}
impl CupView {
@ -22,7 +23,8 @@ impl CupView {
roaster_slug: cup.roaster_slug,
cafe_name: cup.cafe_name,
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 model: String,
pub full_name: String,
pub created_at: String,
pub created_date: String,
pub created_time: String,
}
impl GearView {
@ -20,7 +21,8 @@ impl GearView {
make: gear.make.clone(),
model: gear.model.clone(),
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 homepage_url: String,
pub homepage_label: String,
pub created_at: String,
pub created_date: String,
pub created_time: String,
pub created_at_sort_key: i64,
}
@ -53,7 +54,8 @@ impl From<Roaster> for RoasterView {
let detail_path = format!("/roasters/{slug}");
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 {
detail_path,
@ -64,7 +66,8 @@ impl From<Roaster> for RoasterView {
has_homepage,
homepage_url: homepage.clone(),
homepage_label: homepage,
created_at: created_at_label,
created_date,
created_time,
created_at_sort_key,
}
}

View file

@ -10,7 +10,8 @@ pub struct RoastView {
pub region: String,
pub producer: String,
pub process: String,
pub created_at: String,
pub created_date: String,
pub created_time: String,
pub created_at_sort_key: i64,
pub tasting_notes: Vec<String>,
}
@ -64,7 +65,8 @@ impl RoastView {
.collect::<Vec<_>>()
})
.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}");
Self {
@ -77,7 +79,8 @@ impl RoastView {
region,
producer,
process,
created_at,
created_date,
created_time,
created_at_sort_key,
tasting_notes,
}

View file

@ -142,6 +142,28 @@ a {
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 ──────────────────────────────────────────────────────── */
.tab {
@ -367,8 +389,8 @@ a {
/* "Added" date: footer section with top divider */
.responsive-table td.card-date {
order: 99;
padding-top: 0.5rem;
margin-top: 0.5rem;
margin: 0.5rem -1rem -1rem !important;
padding: 0.75rem 1rem !important;
border-top: 1px solid var(--border-muted) !important;
font-size: 0.75rem;
color: var(--text-secondary);
@ -396,84 +418,29 @@ a {
display: none;
}
/* Three-dot dropdown menu */
.card-menu {
position: relative;
/* Hide detail row on mobile — inline card-detail is used instead */
.responsive-table .detail-row {
display: none !important;
}
.card-menu summary {
list-style: none;
cursor: pointer;
display: inline-flex;
height: 2rem;
width: 2rem;
align-items: center;
/* Inline detail content within cards */
.responsive-table .card-detail {
order: -1;
margin: 0 -1rem 0.25rem !important;
padding: 0.5rem 1rem !important;
border-bottom: 1px solid var(--border-muted) !important;
background-color: var(--surface-alt);
justify-content: center;
border-radius: 0.375rem;
color: var(--text-secondary);
transition: color 150ms ease, background-color 150ms ease;
}
.card-menu summary:hover {
color: var(--accent);
background-color: var(--surface-alt);
.responsive-table .card-detail.hidden {
display: none !important;
}
.card-menu summary::-webkit-details-marker {
.responsive-table .card-detail::before {
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 {
display: none;
}
@ -482,6 +449,11 @@ a {
/* ── Desktop timeline ──────────────────────────────────────────── */
@media (min-width: 768px) {
/* Hide inline card-detail on desktop — detail row is used instead */
.responsive-table .card-detail {
display: none !important;
}
.timeline-list {
display: flex;
flex-direction: column;

View file

@ -38,7 +38,8 @@
{% for bag in bags.items %}
<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">
{{ bag.created_at }}
<div>{{ bag.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ bag.created_time }}</div>
</td>
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap font-medium text-stone-600">
{{ bag.roaster_name }}
@ -58,47 +59,53 @@
{% endif %}
<td data-label="Finished" class="px-4 py-3 whitespace-nowrap">{{ bag.finished_at }}</td>
{% if is_authenticated %}
<td data-label="" class="card-actions px-4 py-3 text-right">
<div class="hidden md:inline-flex items-center gap-1">
<td data-label="" class="card-detail hidden">
<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 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 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 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>
<details class="card-menu md:hidden">
<summary>{% call icons::ellipsis_vertical("h-5 w-5") %}</summary>
<div class="card-menu-items">
{% if !bag.closed %}
<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'}})">
{% call icons::x_circle("h-4 w-4") %} Close bag
</button>
{% endif %}
<button type="button" class="menu-item-danger"
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'}})">
{% call icons::delete("h-4 w-4") %} Delete
</button>
</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 %}
</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 %}
</tbody>
</table>

View file

@ -38,7 +38,8 @@
{% for brew in brews.items %}
<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">
{{ brew.created_at }}
<div>{{ brew.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ brew.created_time }}</div>
</td>
<td data-label="Coffee" class="card-title px-4 py-3 whitespace-nowrap">
<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>
{% endif %}
{% if is_authenticated %}
<td data-label="" class="card-actions px-4 py-3 text-right">
<div class="hidden md:inline-flex items-center gap-1">
<td data-label="" class="card-detail hidden">
<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="coffee_weight" value="{{ brew.coffee_weight_raw }}" />
@ -79,52 +80,58 @@
{% endif %}
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
<button
type="submit"
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"
>
<span class="sr-only">Brew again</span>
{% call icons::refresh("h-4 w-4") %}
<button type="submit"
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>
</form>
<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 brew"
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 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 brew?') && @delete('/api/v1/brews/{{ brew.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#brew-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete
</button>
</div>
<details class="card-menu md:hidden">
<summary>{% call icons::ellipsis_vertical("h-5 w-5") %}</summary>
<div class="card-menu-items">
<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'}})">
<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="grinder_id" value="{{ brew.grinder_id }}" />
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting_raw }}" />
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
{% if let Some(fp_id) = brew.filter_paper_id %}
<input type="hidden" name="filter_paper_id" value="{{ fp_id }}" />
{% endif %}
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
<button type="submit">
{% call icons::refresh("h-4 w-4") %} Brew again
</button>
</form>
<button type="button" class="menu-item-danger"
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'}})">
{% call icons::delete("h-4 w-4") %} Delete
</button>
</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 %}
</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="coffee_weight" value="{{ brew.coffee_weight_raw }}" />
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting_raw }}" />
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
{% if let Some(fp_id) = brew.filter_paper_id %}
<input type="hidden" name="filter_paper_id" value="{{ fp_id }}" />
{% endif %}
<input type="hidden" name="water_volume" value="{{ brew.water_volume_raw }}" />
<input type="hidden" name="water_temp" value="{{ brew.water_temp_raw }}" />
<button type="submit"
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>
</form>
<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 brew?') && @delete('/api/v1/brews/{{ brew.id }}?{{ navigator.query() }}', {responseOverrides: {selector: '#brew-list', mode: 'replace'}})">
{% call icons::delete("h-4 w-4") %} Delete
</button>
</div>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View file

@ -40,7 +40,8 @@
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">
{{ cafe.created_at }}
<div>{{ cafe.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ cafe.created_time }}</div>
</td>
<td data-label="Name" class="card-title px-4 py-3 font-medium text-stone-800">
{{ cafe.name }}
@ -50,69 +51,58 @@
<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="" class="card-actions px-4 py-3 text-right">
<div class="hidden md: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-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"
title="Open in Google Maps"
>
<span class="sr-only">Open {{ cafe.name }} in Google Maps</span>
{% call icons::location("h-4 w-4") %}
<td data-label="" class="card-detail hidden">
<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 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"
title="Visit website"
>
<span class="sr-only">Visit {{ cafe.name }} website</span>
{% call icons::external_link("h-4 w-4") %}
<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>
{% 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 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
</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
</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>
{% endif %}
{% if is_authenticated %}
<button type="button" class="menu-item-danger"
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'}})">
{% call icons::delete("h-4 w-4") %} Delete
</button>
{% endif %}
</div>
</details>
</td>
</tr>
{% endfor %}

View file

@ -26,7 +26,9 @@
{% call table::sortable_header("Added", "created-at", navigator, "#cup-list") %}
{% call table::sortable_header("Coffee", "roast", 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>
{% endif %}
</tr>
</thead>
<tbody class="divide-y/70">
@ -36,7 +38,8 @@
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">
{{ cup.created_at }}
<div>{{ cup.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ cup.created_time }}</div>
</td>
<td data-label="Coffee" class="card-title px-4 py-3 whitespace-nowrap">
<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">
{{ cup.cafe_name }}
</td>
<td data-label="" class="card-actions px-4 py-3 text-right">
{% if is_authenticated %}
<div class="hidden md:inline-flex items-center gap-1">
<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 cup"
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") %}
{% if is_authenticated %}
<td data-label="" class="card-detail hidden">
<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 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 %}
</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>
<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
</button>
</div>
</details>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>

View file

@ -37,7 +37,8 @@
{% for item in gear.items %}
<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">
{{ item.created_at }}
<div>{{ item.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ item.created_time }}</div>
</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">
@ -50,28 +51,39 @@
{{ item.model }}
</td>
{% if is_authenticated %}
<td data-label="" class="card-detail hidden">
<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>
<td data-label="" class="card-actions px-4 py-3 text-right">
<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"
title="Delete gear"
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 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>
<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
</button>
</div>
</details>
</td>
{% endif %}
</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 %}
</tbody>
</table>

View file

@ -44,7 +44,8 @@
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">
{{ roast.created_at }}
<div>{{ roast.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ roast.created_time }}</div>
</td>
<td data-label="Roast" class="card-title px-4 py-3">
<div class="font-medium text-stone-800">{{ roast.name }}</div>
@ -60,36 +61,47 @@
{% if !roast.producer.is_empty() %}
<td data-label="Producer" class="px-4 py-3 whitespace-nowrap md:hidden">{{ roast.producer }}</td>
{% 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() %}
<span class="text-stone-500">No tasting notes yet.</span>
<span class="text-stone-400">&mdash;</span>
{% else %}
{{ roast.tasting_notes.join(", ") }}
{% endif %}
</td>
{% if is_authenticated %}
<td data-label="" class="card-detail hidden">
<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>
<td data-label="" class="card-actions px-4 py-3 text-right">
<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"
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'}})"
>
<span class="sr-only">Delete</span>
{% call icons::delete("h-4 w-4") %}
<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>
<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
</button>
</div>
</details>
</td>
{% endif %}
</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 %}
</tbody>
</table>

View file

@ -40,7 +40,8 @@
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">
{{ roaster.created_at }}
<div>{{ roaster.created_date }}</div>
<div class="hidden md:block font-normal text-stone-400">{{ roaster.created_time }}</div>
</td>
<td data-label="Name" class="card-title px-4 py-3 font-medium text-stone-800">
{{ roaster.name }}
@ -54,52 +55,58 @@
{% if !roaster.city.is_empty() %}
<td data-label="City" class="px-4 py-3 whitespace-nowrap md:hidden">{{ roaster.city }}</td>
{% 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 || is_authenticated %}
<td data-label="" class="card-detail hidden">
<div class="flex items-center gap-4">
{% 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 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 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 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>
{% endif %}
<td data-label="" class="card-actions px-4 py-3 text-right">
{% if roaster.has_homepage || is_authenticated %}
<details class="card-menu md:hidden">
<summary>{% call icons::ellipsis_vertical("h-5 w-5") %}</summary>
<div class="card-menu-items">
{% if roaster.has_homepage %}
<a href="{{ roaster.homepage_url }}" target="_blank" rel="noreferrer noopener">
{% call icons::external_link("h-4 w-4") %} Visit homepage
</a>
{% endif %}
{% if is_authenticated %}
<button type="button" class="menu-item-danger"
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'}})">
{% call icons::delete("h-4 w-4") %} Delete
</button>
{% endif %}
</div>
</details>
<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 %}
</td>
</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 %}
</tbody>
</table>