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:
parent
dd08e84572
commit
8c521f0e05
13 changed files with 434 additions and 62 deletions
|
|
@ -63,6 +63,7 @@ pub(crate) async fn data_page(
|
|||
) -> Result<Response, StatusCode> {
|
||||
let entity_type = data_type.entity_type;
|
||||
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)
|
||||
.await
|
||||
|
|
@ -97,6 +98,7 @@ pub(crate) async fn data_page(
|
|||
active_type: entity_type,
|
||||
tabs,
|
||||
content,
|
||||
search_value,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ enum PageSizeParam {
|
|||
}
|
||||
|
||||
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>) {
|
||||
self.into_request_and_search_with_default::<K>(DEFAULT_PAGE_SIZE)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ pub struct DataTemplate {
|
|||
pub active_type: String,
|
||||
pub tabs: Vec<DataTab>,
|
||||
pub content: String,
|
||||
pub search_value: String,
|
||||
}
|
||||
|
||||
pub struct DataTab {
|
||||
|
|
|
|||
|
|
@ -16,24 +16,93 @@
|
|||
{% endif %}
|
||||
</header>
|
||||
|
||||
<nav class="flex flex-wrap gap-1 rounded-lg border border-amber-300 bg-amber-100/80 p-1.5 shadow-sm"
|
||||
data-signals:_active-tab="'{{ active_type }}'">
|
||||
{% for tab in tabs %}
|
||||
<a
|
||||
href="/data?type={{ tab.key }}"
|
||||
data-class:bg-amber-600="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:text-white="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:shadow-sm="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:text-stone-600="$_activeTab !== '{{ tab.key }}'"
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition"
|
||||
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'}})"
|
||||
>{{ tab.label }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<div class="rounded-lg border border-amber-300 bg-amber-100/80 shadow-sm"
|
||||
data-signals:_active-tab="'{{ active_type }}'" data-signals:_tabs-open="false">
|
||||
<!-- Desktop tabs -->
|
||||
<nav class="hidden md:flex flex-wrap gap-1 p-1.5">
|
||||
{% for tab in tabs %}
|
||||
<a
|
||||
href="/data?type={{ tab.key }}"
|
||||
data-class:bg-amber-600="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:text-white="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:shadow-sm="$_activeTab === '{{ tab.key }}'"
|
||||
data-class:text-stone-600="$_activeTab !== '{{ tab.key }}'"
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition"
|
||||
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'}})"
|
||||
>{{ 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 }}
|
||||
</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 %}
|
||||
|
|
|
|||
|
|
@ -33,13 +33,13 @@
|
|||
<tbody class="divide-y divide-amber-200/70">
|
||||
{% for bag in bags.items %}
|
||||
<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 }}
|
||||
</td>
|
||||
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap font-medium text-stone-600">
|
||||
{{ bag.roaster_name }}
|
||||
</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="Status" class="px-4 py-3 whitespace-nowrap">
|
||||
{% if bag.closed %}
|
||||
|
|
@ -54,8 +54,8 @@
|
|||
{% endif %}
|
||||
<td data-label="Finished" class="px-4 py-3 whitespace-nowrap">{{ bag.finished_at }}</td>
|
||||
{% if is_authenticated %}
|
||||
<td data-label="" class="px-4 py-3 text-right">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<td data-label="" class="card-actions px-4 py-3 text-right">
|
||||
<div class="hidden md:inline-flex items-center gap-1">
|
||||
{% if !bag.closed %}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -77,6 +77,21 @@
|
|||
{% 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 %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -33,10 +33,10 @@
|
|||
<tbody class="divide-y divide-amber-200/70">
|
||||
{% for brew in brews.items %}
|
||||
<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 }}
|
||||
</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="hidden md:block text-xs text-stone-500">{{ brew.roaster_name }}</div>
|
||||
</td>
|
||||
|
|
@ -62,8 +62,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="px-4 py-3 text-right">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<td data-label="" class="card-actions px-4 py-3 text-right">
|
||||
<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'}})">
|
||||
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
|
||||
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight_raw }}" />
|
||||
|
|
@ -94,6 +94,30 @@
|
|||
{% 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">
|
||||
<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>
|
||||
{% endif %}
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@
|
|||
data-sort-city="{{ cafe.city }}"
|
||||
class="bg-amber-50/40 transition hover:bg-amber-50"
|
||||
>
|
||||
<td data-label="Added" class="whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
|
||||
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
|
||||
{{ cafe.created_at }}
|
||||
</td>
|
||||
<td data-label="Name" class="px-4 py-3 font-semibold text-amber-800">
|
||||
<td data-label="Name" class="card-title px-4 py-3 font-semibold text-amber-800">
|
||||
{{ cafe.name }}
|
||||
</td>
|
||||
<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>
|
||||
</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">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<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-amber-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"
|
||||
href="{{ cafe.map_url }}"
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
</a>
|
||||
{% if cafe.has_website %}
|
||||
<a
|
||||
class="hidden md:inline-flex h-8 w-8 items-center justify-center rounded-md text-stone-500 transition hover:text-amber-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"
|
||||
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 }}"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
|
|
@ -71,7 +71,7 @@
|
|||
</a>
|
||||
{% else %}
|
||||
<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"
|
||||
aria-disabled="true"
|
||||
>
|
||||
|
|
@ -90,6 +90,25 @@
|
|||
</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 %}
|
||||
|
|
|
|||
|
|
@ -32,10 +32,10 @@
|
|||
data-star-key="{{ cup.id }}"
|
||||
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 }}
|
||||
</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>
|
||||
<div class="hidden md:block text-xs text-stone-500">{{ cup.roaster_name }}</div>
|
||||
</td>
|
||||
|
|
@ -44,9 +44,9 @@
|
|||
<span class="text-amber-800">{{ cup.cafe_name }}</span>
|
||||
</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 %}
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<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"
|
||||
|
|
@ -57,6 +57,15 @@
|
|||
{% 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
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -32,31 +32,41 @@
|
|||
<tbody class="divide-y divide-amber-200/70">
|
||||
{% for item in gear.items %}
|
||||
<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 }}
|
||||
</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">
|
||||
<span class="inline-flex rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800">
|
||||
{{ item.category_label }}
|
||||
</span>
|
||||
</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 }}
|
||||
</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 }}
|
||||
</td>
|
||||
{% 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
|
||||
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"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -142,8 +142,26 @@
|
|||
</svg>
|
||||
{% 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) %}
|
||||
<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" />
|
||||
</svg>
|
||||
{% 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 %}
|
||||
|
|
|
|||
|
|
@ -37,10 +37,10 @@
|
|||
data-sort-producer="{{ roast.producer }}"
|
||||
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 }}
|
||||
</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>
|
||||
<div class="hidden md:block text-xs text-stone-500">{{ roast.roaster_label }}</div>
|
||||
</td>
|
||||
|
|
@ -62,16 +62,25 @@
|
|||
{% endif %}
|
||||
</td>
|
||||
{% 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
|
||||
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"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@
|
|||
data-sort-city="{{ roaster.city }}"
|
||||
class="bg-amber-50/40 transition hover:bg-amber-50"
|
||||
>
|
||||
<td data-label="Added" class="whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
|
||||
<td data-label="Added" class="card-date whitespace-nowrap px-4 py-3 text-xs font-medium text-stone-600">
|
||||
{{ roaster.created_at }}
|
||||
</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 }}
|
||||
</td>
|
||||
<td data-label="Location" class="px-4 py-3 whitespace-nowrap">
|
||||
|
|
@ -50,11 +50,11 @@
|
|||
{% 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="px-4 py-3 text-right">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<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="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 }}"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
|
|
@ -76,6 +76,24 @@
|
|||
</button>
|
||||
{% endif %}
|
||||
</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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -100,24 +100,72 @@ a {
|
|||
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 */
|
||||
@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 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.responsive-table tbody {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Match home page brew card style */
|
||||
.responsive-table tr {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(217, 169, 55, 0.35);
|
||||
}
|
||||
|
||||
.responsive-table tr:last-child {
|
||||
border-bottom: none;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #fde68a !important; /* amber-200 */
|
||||
background-color: #fffbeb; /* amber-50 */
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); /* shadow-sm */
|
||||
}
|
||||
|
||||
.responsive-table td {
|
||||
|
|
@ -126,7 +174,8 @@ a {
|
|||
align-items: baseline;
|
||||
padding: 0.25rem 0;
|
||||
white-space: normal;
|
||||
font-size: 0.875rem; /* normalize all values to text-sm */
|
||||
font-size: 0.875rem;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.responsive-table td::before {
|
||||
|
|
@ -134,8 +183,8 @@ a {
|
|||
flex-shrink: 0;
|
||||
min-width: 5.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #78350f; /* amber-900 */
|
||||
font-weight: 500;
|
||||
color: #78716c; /* stone-500 - more muted like brew card */
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
|
|
@ -143,15 +192,140 @@ a {
|
|||
display: none;
|
||||
}
|
||||
|
||||
.responsive-table td[data-label=""] {
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.responsive-table td.mobile-hidden {
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue