feat(data): redesign mobile data page with card layout and tab selector

- Add collapsible mobile tab selector with chevron icons
- Integrate search bar into tab component (below tabs)
- Replace Datastar search handler with vanilla JS for reliability
- Add mobile card layout: card-title, card-date, card-actions classes
- Add three-dot action menu for mobile with details/summary pattern
- Hide duplicate search inside data-content via CSS
- Pass search_value to DataTemplate for input preservation
This commit is contained in:
Jon Seager 2026-02-04 22:20:00 +00:00
parent dd08e84572
commit 8c521f0e05
No known key found for this signature in database
13 changed files with 434 additions and 62 deletions

View file

@ -63,6 +63,7 @@ pub(crate) async fn data_page(
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let entity_type = data_type.entity_type; let entity_type = data_type.entity_type;
let is_authenticated = super::is_authenticated(&state, &cookies).await; let is_authenticated = super::is_authenticated(&state, &cookies).await;
let search_value = list_query.search_value();
let content = render_entity_content(&state, &entity_type, list_query, is_authenticated) let content = render_entity_content(&state, &entity_type, list_query, is_authenticated)
.await .await
@ -97,6 +98,7 @@ pub(crate) async fn data_page(
active_type: entity_type, active_type: entity_type,
tabs, tabs,
content, content,
search_value,
}; };
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)

View file

@ -53,6 +53,10 @@ enum PageSizeParam {
} }
impl ListQuery { impl ListQuery {
pub fn search_value(&self) -> String {
self.q.clone().unwrap_or_default()
}
pub fn into_request_and_search<K: SortKey>(self) -> (ListRequest<K>, Option<String>) { pub fn into_request_and_search<K: SortKey>(self) -> (ListRequest<K>, Option<String>) {
self.into_request_and_search_with_default::<K>(DEFAULT_PAGE_SIZE) self.into_request_and_search_with_default::<K>(DEFAULT_PAGE_SIZE)
} }

View file

@ -132,6 +132,7 @@ pub struct DataTemplate {
pub active_type: String, pub active_type: String,
pub tabs: Vec<DataTab>, pub tabs: Vec<DataTab>,
pub content: String, pub content: String,
pub search_value: String,
} }
pub struct DataTab { pub struct DataTab {

View file

@ -16,24 +16,93 @@
{% endif %} {% endif %}
</header> </header>
<nav class="flex flex-wrap gap-1 rounded-lg border border-amber-300 bg-amber-100/80 p-1.5 shadow-sm" <div class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm"
data-signals:_active-tab="'{{ active_type }}'"> data-signals:_active-tab="'{{ active_type }}'" data-signals:_tabs-open="false">
{% for tab in tabs %} <!-- Desktop tabs -->
<a <nav class="hidden md:flex flex-wrap gap-1 p-1.5">
href="/data?type={{ tab.key }}" {% for tab in tabs %}
data-class:bg-amber-600="$_activeTab === '{{ tab.key }}'" <a
data-class:text-white="$_activeTab === '{{ tab.key }}'" href="/data?type={{ tab.key }}"
data-class:shadow-sm="$_activeTab === '{{ tab.key }}'" data-class:bg-amber-600="$_activeTab === '{{ tab.key }}'"
data-class:text-stone-600="$_activeTab !== '{{ tab.key }}'" data-class:text-white="$_activeTab === '{{ tab.key }}'"
class="rounded-md px-3 py-1.5 text-sm font-medium transition" data-class:shadow-sm="$_activeTab === '{{ tab.key }}'"
data-on:click__prevent="$_activeTab = '{{ tab.key }}'; history.pushState(null, '', '/data?type={{ tab.key }}'); @get('/data?type={{ tab.key }}', {responseOverrides: {selector: '#data-content', mode: 'inner'}})" data-class:text-stone-600="$_activeTab !== '{{ tab.key }}'"
>{{ tab.label }}</a> class="rounded-md px-3 py-1.5 text-sm font-medium transition"
{% endfor %} data-on:click__prevent="$_activeTab = '{{ tab.key }}'; history.pushState(null, '', '/data?type={{ tab.key }}'); @get('/data?type={{ tab.key }}', {responseOverrides: {selector: '#data-content', mode: 'inner'}})"
</nav> >{{ tab.label }}</a>
{% endfor %}
</nav>
<!-- Mobile tab selector -->
<div class="md:hidden">
<button
type="button"
class="flex w-full items-center justify-between p-3 text-sm font-medium text-amber-800"
data-on:click="$_tabsOpen = !$_tabsOpen"
>
<span>
{% for tab in tabs %}
<span data-show="$_activeTab === '{{ tab.key }}'">{{ tab.label }}</span>
{% endfor %}
</span>
<span data-show="!$_tabsOpen">{% call icons::chevron_down("h-5 w-5") %}</span>
<span data-show="$_tabsOpen" style="display:none">{% call icons::chevron_up("h-5 w-5") %}</span>
</button>
<div class="flex flex-col border-t border-amber-200" data-show="$_tabsOpen" style="display:none">
{% for tab in tabs %}
<a
href="/data?type={{ tab.key }}"
data-class:bg-amber-100="$_activeTab === '{{ tab.key }}'"
data-class:text-amber-800="$_activeTab === '{{ tab.key }}'"
data-class:font-semibold="$_activeTab === '{{ tab.key }}'"
data-class:text-stone-600="$_activeTab !== '{{ tab.key }}'"
class="px-3 py-2 text-sm transition hover:bg-amber-200/30"
data-on:click__prevent="$_activeTab = '{{ tab.key }}'; $_tabsOpen = false; history.pushState(null, '', '/data?type={{ tab.key }}'); @get('/data?type={{ tab.key }}', {responseOverrides: {selector: '#data-content', mode: 'inner'}})"
>{{ tab.label }}</a>
{% endfor %}
</div>
</div>
<!-- Search -->
<div class="border-t border-amber-200 px-3 py-2">
<input
type="search"
id="data-search"
placeholder="Search..."
value="{{ search_value }}"
class="input-field w-full text-sm"
{% if !search_value.is_empty() %}data-init="el.focus(); el.selectionStart = el.value.length"{% endif %}
/>
</div>
</div>
<div id="data-content"> <div id="data-content" class="data-page-content">
{{ content|safe }} {{ content|safe }}
</div> </div>
<div id="detail-panel"></div> <div id="detail-panel"></div>
<script>
(function() {
var searchInput = document.getElementById('data-search');
if (!searchInput) return;
var debounceTimer;
searchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
var params = new URLSearchParams(window.location.search);
var activeTab = params.get('type') || 'brews';
var query = searchInput.value;
var url = '/data?type=' + activeTab + '&q=' + encodeURIComponent(query);
history.pushState(null, '', url);
fetch(url, { headers: { 'datastar-request': 'true' } })
.then(function(res) { return res.text(); })
.then(function(html) {
document.getElementById('data-content').innerHTML = html;
});
}, 300);
});
})();
</script>
{% endblock %} {% endblock %}

View file

@ -33,13 +33,13 @@
<tbody class="divide-y divide-amber-200/70"> <tbody class="divide-y divide-amber-200/70">
{% for bag in bags.items %} {% for bag in bags.items %}
<tr class="bg-amber-50/40 transition hover:bg-amber-50"> <tr 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"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ bag.created_at }} {{ bag.created_at }}
</td> </td>
<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 }}
</td> </td>
<td data-label="Roast" class="px-4 py-3 whitespace-nowrap">{{ bag.roast_name }}</td> <td data-label="Roast" class="card-title px-4 py-3 whitespace-nowrap">{{ bag.roast_name }}</td>
<td data-label="Weight" class="px-4 py-3 whitespace-nowrap">{{ bag.amount }}g</td> <td data-label="Weight" class="px-4 py-3 whitespace-nowrap">{{ bag.amount }}g</td>
<td data-label="Status" class="px-4 py-3 whitespace-nowrap"> <td data-label="Status" class="px-4 py-3 whitespace-nowrap">
{% if bag.closed %} {% if bag.closed %}
@ -54,8 +54,8 @@
{% 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="px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<div class="inline-flex items-center gap-1"> <div class="hidden md:inline-flex items-center gap-1">
{% if !bag.closed %} {% if !bag.closed %}
<button <button
type="button" type="button"
@ -77,6 +77,21 @@
{% call icons::delete("h-4 w-4") %} {% call icons::delete("h-4 w-4") %}
</button> </button>
</div> </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>
{% endif %} {% endif %}
</tr> </tr>

View file

@ -33,10 +33,10 @@
<tbody class="divide-y divide-amber-200/70"> <tbody class="divide-y divide-amber-200/70">
{% for brew in brews.items %} {% for brew in brews.items %}
<tr class="bg-amber-50/40 transition hover:bg-amber-50"> <tr 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"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ brew.created_at }} {{ brew.created_at }}
</td> </td>
<td data-label="Coffee" class="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>
<div class="hidden md:block text-xs text-stone-500">{{ brew.roaster_name }}</div> <div class="hidden md:block text-xs text-stone-500">{{ brew.roaster_name }}</div>
</td> </td>
@ -62,8 +62,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="px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<div class="inline-flex items-center gap-1"> <div class="hidden md:inline-flex items-center gap-1">
<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 }}" />
@ -94,6 +94,30 @@
{% call icons::delete("h-4 w-4") %} {% call icons::delete("h-4 w-4") %}
</button> </button>
</div> </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>
{% endif %} {% endif %}
</tr> </tr>

View file

@ -35,10 +35,10 @@
data-sort-city="{{ cafe.city }}" data-sort-city="{{ cafe.city }}"
class="bg-amber-50/40 transition hover:bg-amber-50" 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"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ cafe.created_at }} {{ cafe.created_at }}
</td> </td>
<td data-label="Name" class="px-4 py-3 font-semibold text-amber-800"> <td data-label="Name" class="card-title px-4 py-3 font-semibold text-amber-800">
{{ cafe.name }} {{ cafe.name }}
</td> </td>
<td data-label="Location" class="px-4 py-3 whitespace-nowrap"> <td data-label="Location" class="px-4 py-3 whitespace-nowrap">
@ -46,8 +46,8 @@
<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="px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<div class="inline-flex items-center gap-1"> <div class="hidden md:inline-flex items-center gap-1">
<a <a
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-amber-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-amber-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"
href="{{ cafe.map_url }}" href="{{ cafe.map_url }}"
@ -60,7 +60,7 @@
</a> </a>
{% if cafe.has_website %} {% if cafe.has_website %}
<a <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" 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.website_url }}" href="{{ cafe.website_url }}"
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
@ -71,7 +71,7 @@
</a> </a>
{% else %} {% else %}
<span <span
class="hidden md:inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-300 cursor-default" class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-300 cursor-default"
title="No website" title="No website"
aria-disabled="true" aria-disabled="true"
> >
@ -90,6 +90,25 @@
</button> </button>
{% endif %} {% endif %}
</div> </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> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View file

@ -32,10 +32,10 @@
data-star-key="{{ cup.id }}" data-star-key="{{ cup.id }}"
class="bg-amber-50/40 transition hover:bg-amber-50" 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"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ cup.created_at }} {{ cup.created_at }}
</td> </td>
<td data-label="Coffee" class="px-4 py-3 whitespace-nowrap"> <td data-label="Coffee" class="card-title px-4 py-3 whitespace-nowrap">
<span class="font-semibold text-amber-800">{{ cup.roast_name }}</span> <span class="font-semibold text-amber-800">{{ cup.roast_name }}</span>
<div class="hidden md:block text-xs text-stone-500">{{ cup.roaster_name }}</div> <div class="hidden md:block text-xs text-stone-500">{{ cup.roaster_name }}</div>
</td> </td>
@ -44,9 +44,9 @@
<span class="text-amber-800">{{ cup.cafe_name }}</span> <span class="text-amber-800">{{ cup.cafe_name }}</span>
</td> </td>
<td data-label="Rating" class="px-4 py-3 whitespace-nowrap">{{ cup.rating }}</td> <td data-label="Rating" class="px-4 py-3 whitespace-nowrap">{{ cup.rating }}</td>
<td data-label="" class="px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
{% if is_authenticated %} {% if is_authenticated %}
<div class="inline-flex items-center gap-1"> <div class="hidden md:inline-flex items-center gap-1">
<button <button
type="button" type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500" 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"
@ -57,6 +57,15 @@
{% call icons::delete("h-4 w-4") %} {% call icons::delete("h-4 w-4") %}
</button> </button>
</div> </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 %} {% endif %}
</td> </td>
</tr> </tr>

View file

@ -32,31 +32,41 @@
<tbody class="divide-y divide-amber-200/70"> <tbody class="divide-y divide-amber-200/70">
{% for item in gear.items %} {% for item in gear.items %}
<tr class="bg-amber-50/40 transition hover:bg-amber-50"> <tr 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"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ item.created_at }} {{ item.created_at }}
</td> </td>
<td data-label="" class="card-title px-4 py-3 whitespace-nowrap md:hidden">{{ item.make }} {{ item.model }}</td>
<td data-label="Category" class="px-4 py-3 whitespace-nowrap"> <td data-label="Category" class="px-4 py-3 whitespace-nowrap">
<span class="inline-flex rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800"> <span class="inline-flex rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800">
{{ item.category_label }} {{ item.category_label }}
</span> </span>
</td> </td>
<td data-label="Make" class="px-4 py-3 whitespace-nowrap font-medium text-stone-600"> <td data-label="Make" class="mobile-hidden px-4 py-3 whitespace-nowrap font-medium text-stone-600">
{{ item.make }} {{ item.make }}
</td> </td>
<td data-label="Model" class="px-4 py-3 whitespace-nowrap"> <td data-label="Model" class="mobile-hidden px-4 py-3 whitespace-nowrap">
{{ item.model }} {{ item.model }}
</td> </td>
{% if is_authenticated %} {% if is_authenticated %}
<td data-label="" class="px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<button <button
type="button" type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500" 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" 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> <span class="sr-only">Delete</span>
{% call icons::delete("h-4 w-4") %} {% call icons::delete("h-4 w-4") %}
</button> </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> </td>
{% endif %} {% endif %}
</tr> </tr>

View file

@ -142,8 +142,26 @@
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro ellipsis_vertical(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 3a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM10 8.5a1.5 1.5 0 110 3 1.5 1.5 0 010-3zM11.5 15.5a1.5 1.5 0 10-3 0 1.5 1.5 0 003 0z" />
</svg>
{% endmacro %}
{% macro x_circle(class) %} {% macro x_circle(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro chevron_down(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
{% endmacro %}
{% macro chevron_up(class) %}
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M14.78 11.78a.75.75 0 0 1-1.06 0L10 8.06l-3.72 3.72a.75.75 0 1 1-1.06-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06Z" clip-rule="evenodd" />
</svg>
{% endmacro %}

View file

@ -37,10 +37,10 @@
data-sort-producer="{{ roast.producer }}" data-sort-producer="{{ roast.producer }}"
class="bg-amber-50/40 transition hover:bg-amber-50" 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"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ roast.created_at }} {{ roast.created_at }}
</td> </td>
<td data-label="Roast" class="px-4 py-3"> <td data-label="Roast" class="card-title px-4 py-3">
<span class="font-semibold text-amber-800">{{ roast.name }}</span> <span class="font-semibold text-amber-800">{{ roast.name }}</span>
<div class="hidden md:block text-xs text-stone-500">{{ roast.roaster_label }}</div> <div class="hidden md:block text-xs text-stone-500">{{ roast.roaster_label }}</div>
</td> </td>
@ -62,16 +62,25 @@
{% endif %} {% endif %}
</td> </td>
{% if is_authenticated %} {% if is_authenticated %}
<td data-label="" class="px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<button <button
type="button" type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500" 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" 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> <span class="sr-only">Delete</span>
{% call icons::delete("h-4 w-4") %} {% call icons::delete("h-4 w-4") %}
</button> </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> </td>
{% endif %} {% endif %}
</tr> </tr>

View file

@ -35,10 +35,10 @@
data-sort-city="{{ roaster.city }}" data-sort-city="{{ roaster.city }}"
class="bg-amber-50/40 transition hover:bg-amber-50" 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"> <td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
{{ roaster.created_at }} {{ roaster.created_at }}
</td> </td>
<td data-label="Name" class="px-4 py-3 font-semibold text-amber-800"> <td data-label="Name" class="card-title px-4 py-3 font-semibold text-amber-800">
{{ roaster.name }} {{ roaster.name }}
</td> </td>
<td data-label="Location" class="px-4 py-3 whitespace-nowrap"> <td data-label="Location" class="px-4 py-3 whitespace-nowrap">
@ -50,11 +50,11 @@
{% 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="px-4 py-3 text-right"> <td data-label="" class="card-actions px-4 py-3 text-right">
<div class="inline-flex items-center gap-1"> <div class="hidden md:inline-flex items-center gap-1">
{% if roaster.has_homepage %} {% if roaster.has_homepage %}
<a <a
class="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" 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="{{ roaster.homepage_url }}" href="{{ roaster.homepage_url }}"
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
@ -76,6 +76,24 @@
</button> </button>
{% endif %} {% endif %}
</div> </div>
{% 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>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View file

@ -100,24 +100,72 @@ a {
display: block; display: block;
} }
/* Hide duplicate search inside data page - search is in the tab bar */
.data-page-content section:has(.responsive-table) > div:first-child:has(input[type="search"]) {
display: none;
}
/* Remove extra margin from list containers inside data page */
.data-page-content > div[id$="-list"] {
margin-top: 0;
}
/* Responsive tables: convert rows to cards on mobile */ /* Responsive tables: convert rows to cards on mobile */
@media (max-width: 767px) { @media (max-width: 767px) {
/* Remove outer section border/background on mobile - cards are standalone */
section:has(.responsive-table) {
border: none !important;
background: transparent !important;
box-shadow: none !important;
padding: 0 !important;
}
/* Search header: remove wrapper styling, let input fill width */
section:has(.responsive-table) > div:first-child:has(input[type="search"]) {
border: none !important;
background: transparent !important;
padding: 0 !important;
margin-bottom: 1rem;
}
/* Remove overflow wrapper styling */
section:has(.responsive-table) > .overflow-x-auto {
overflow: visible;
}
.responsive-table {
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
/* Remove divide-y borders from table */
.responsive-table,
.responsive-table tbody {
border-color: transparent !important;
}
.responsive-table thead { .responsive-table thead {
display: none; display: none;
} }
.responsive-table tbody { .responsive-table tbody {
display: block; display: flex;
flex-direction: column;
gap: 1rem;
padding: 0;
} }
/* Match home page brew card style */
.responsive-table tr { .responsive-table tr {
display: block; display: flex;
flex-direction: column;
position: relative;
padding: 1rem; padding: 1rem;
border-bottom: 1px solid rgba(217, 169, 55, 0.35); border-radius: 0.5rem;
} border: 1px solid #fde68a !important; /* amber-200 */
background-color: #fffbeb; /* amber-50 */
.responsive-table tr:last-child { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); /* shadow-sm */
border-bottom: none;
} }
.responsive-table td { .responsive-table td {
@ -126,7 +174,8 @@ a {
align-items: baseline; align-items: baseline;
padding: 0.25rem 0; padding: 0.25rem 0;
white-space: normal; white-space: normal;
font-size: 0.875rem; /* normalize all values to text-sm */ font-size: 0.875rem;
border: none !important;
} }
.responsive-table td::before { .responsive-table td::before {
@ -134,8 +183,8 @@ a {
flex-shrink: 0; flex-shrink: 0;
min-width: 5.5rem; min-width: 5.5rem;
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 600; font-weight: 500;
color: #78350f; /* amber-900 */ color: #78716c; /* stone-500 - more muted like brew card */
margin-right: 0.75rem; margin-right: 0.75rem;
} }
@ -143,15 +192,140 @@ a {
display: none; display: none;
} }
.responsive-table td[data-label=""] {
justify-content: flex-end;
padding-top: 0.25rem;
}
.responsive-table td.mobile-hidden { .responsive-table td.mobile-hidden {
display: none; display: none;
} }
/* Card title: header section with bottom divider */
.responsive-table td.card-title {
order: -2;
padding: 0 0 0.5rem 0;
padding-right: 2.5rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid #fef3c7 !important; /* amber-100 */
}
.responsive-table td.card-title,
.responsive-table td.card-title > :first-child {
font-size: 1rem;
font-weight: 600;
color: #92400e; /* amber-800 */
}
.responsive-table td.card-title::before {
display: none;
}
/* "Added" date: footer section with top divider */
.responsive-table td.card-date {
order: 99;
padding-top: 0.5rem;
margin-top: 0.5rem;
border-top: 1px solid #fef3c7 !important; /* amber-100 */
font-size: 0.75rem;
color: #78716c;
}
.responsive-table td.card-date::before {
font-size: 0.75rem;
font-weight: 500;
color: #78716c;
min-width: auto;
margin-right: 0.5rem;
}
/* Actions: absolute top-right in header area */
.responsive-table td.card-actions {
order: -1;
position: absolute;
top: 0.75rem;
right: 0.75rem;
padding: 0;
justify-content: flex-end;
}
.responsive-table td.card-actions::before {
display: none;
}
/* Three-dot dropdown menu */
.card-menu {
position: relative;
}
.card-menu summary {
list-style: none;
cursor: pointer;
display: inline-flex;
height: 2rem;
width: 2rem;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
color: #78716c;
transition: color 150ms ease, background-color 150ms ease;
}
.card-menu summary:hover {
color: #b45309;
background-color: rgba(254, 243, 199, 0.8);
}
.card-menu summary::-webkit-details-marker {
display: none;
}
.card-menu[open] summary {
color: #b45309;
background-color: rgba(254, 243, 199, 0.8);
}
.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 rgba(217, 169, 55, 0.4);
background-color: #fffbeb;
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: #44403c;
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: rgba(254, 243, 199, 0.6);
}
.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;
} }