Replace duplicated inline SVGs across 16 templates with reusable Askama macros from a new icons.html partial, reducing repetition and making icon updates a single-point change.
333 lines
12 KiB
HTML
333 lines
12 KiB
HTML
{% extends "base.html" %} {% import "partials/icons.html" as icons %} {% block title %}Brewlog · Cafes{% endblock %}
|
|
|
|
{% block head %}
|
|
{% if is_authenticated %}
|
|
<script>
|
|
let _userLat = null;
|
|
let _userLng = null;
|
|
let _searchTimeout = null;
|
|
let _nearbyCafes = [];
|
|
|
|
function locateUser(btn) {
|
|
const errorEl = document.getElementById('nearby-error');
|
|
const searchWrap = document.getElementById('nearby-search-wrap');
|
|
errorEl.classList.add('hidden');
|
|
errorEl.textContent = '';
|
|
|
|
if (!navigator.geolocation) {
|
|
errorEl.textContent = 'Geolocation is not supported by your browser.';
|
|
errorEl.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
btn.textContent = 'Locating\u2026';
|
|
btn.disabled = true;
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
_userLat = pos.coords.latitude;
|
|
_userLng = pos.coords.longitude;
|
|
btn.classList.add('hidden');
|
|
searchWrap.classList.remove('hidden');
|
|
document.getElementById('nearby-search').focus();
|
|
},
|
|
(err) => {
|
|
btn.textContent = 'Find nearby';
|
|
btn.disabled = false;
|
|
if (err.code === 1) {
|
|
errorEl.textContent = 'Location access denied. Please allow location access and try again.';
|
|
} else if (err.code === 3) {
|
|
errorEl.textContent = 'Location request timed out. Please try again.';
|
|
} else {
|
|
errorEl.textContent = 'Could not determine your location. Please try again.';
|
|
}
|
|
errorEl.classList.remove('hidden');
|
|
},
|
|
{ enableHighAccuracy: true, timeout: 15000 }
|
|
);
|
|
}
|
|
|
|
function onSearchInput(input) {
|
|
clearTimeout(_searchTimeout);
|
|
const resultsEl = document.getElementById('nearby-results');
|
|
|
|
if (input.value.trim().length < 2) {
|
|
resultsEl.classList.add('hidden');
|
|
resultsEl.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
_searchTimeout = setTimeout(() => {
|
|
searchNearby(input.value.trim());
|
|
}, 350);
|
|
}
|
|
|
|
function toggleCitySearch(checkbox) {
|
|
const locateBtn = document.getElementById('locate-btn');
|
|
const cityWrap = document.getElementById('city-search-wrap');
|
|
const searchWrap = document.getElementById('nearby-search-wrap');
|
|
const resultsEl = document.getElementById('nearby-results');
|
|
|
|
if (checkbox.checked) {
|
|
locateBtn.classList.add('hidden');
|
|
cityWrap.classList.remove('hidden');
|
|
searchWrap.classList.remove('hidden');
|
|
document.getElementById('city-input').focus();
|
|
} else {
|
|
cityWrap.classList.add('hidden');
|
|
document.getElementById('city-input').value = '';
|
|
if (_userLat === null) {
|
|
searchWrap.classList.add('hidden');
|
|
locateBtn.classList.remove('hidden');
|
|
locateBtn.textContent = 'Find nearby';
|
|
locateBtn.disabled = false;
|
|
}
|
|
resultsEl.classList.add('hidden');
|
|
resultsEl.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
async function searchNearby(query) {
|
|
const resultsEl = document.getElementById('nearby-results');
|
|
const errorEl = document.getElementById('nearby-error');
|
|
const spinnerEl = document.getElementById('nearby-spinner');
|
|
errorEl.classList.add('hidden');
|
|
spinnerEl.classList.remove('hidden');
|
|
|
|
try {
|
|
const cityInput = document.getElementById('city-input');
|
|
const cityValue = cityInput ? cityInput.value.trim() : '';
|
|
let url;
|
|
if (cityValue.length >= 2) {
|
|
url = `/api/v1/nearby-cafes?near=${encodeURIComponent(cityValue)}&q=${encodeURIComponent(query)}`;
|
|
} else {
|
|
url = `/api/v1/nearby-cafes?lat=${_userLat}&lng=${_userLng}&q=${encodeURIComponent(query)}`;
|
|
}
|
|
const resp = await fetch(url, { credentials: 'same-origin' });
|
|
|
|
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
|
|
|
|
const cafes = await resp.json();
|
|
_nearbyCafes = cafes;
|
|
|
|
if (cafes.length === 0) {
|
|
resultsEl.innerHTML = '<div class="px-3 py-2 text-sm text-stone-500">No results found.</div>';
|
|
resultsEl.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
for (let i = 0; i < cafes.length; i++) {
|
|
const dist = cafes[i].distance_meters;
|
|
const distLabel = dist < 1000
|
|
? `${dist} m`
|
|
: `${(dist / 1000).toFixed(1)} km`;
|
|
const location = [cafes[i].city, cafes[i].country].filter(Boolean).join(', ');
|
|
html += `<button type="button" class="w-full px-3 py-2 text-left text-sm hover:bg-amber-100 transition" onclick="selectPlace(${i})">`
|
|
+ `<span class="font-medium text-amber-900">${escapeHtml(cafes[i].name)}</span>`
|
|
+ `<span class="ml-2 text-xs text-stone-500">${escapeHtml(location)} · ${distLabel}</span>`
|
|
+ `</button>`;
|
|
}
|
|
resultsEl.innerHTML = html;
|
|
resultsEl.classList.remove('hidden');
|
|
} catch (e) {
|
|
errorEl.textContent = 'Search failed. Please try again.';
|
|
errorEl.classList.remove('hidden');
|
|
} finally {
|
|
spinnerEl.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
function selectPlace(index) {
|
|
const cafe = _nearbyCafes[index];
|
|
if (!cafe) return;
|
|
|
|
const form = document.getElementById('nearby-search').closest('form');
|
|
form.querySelector('[name="name"]').value = cafe.name;
|
|
form.querySelector('[name="city"]').value = cafe.city || '';
|
|
form.querySelector('[name="country"]').value = cafe.country || '';
|
|
form.querySelector('[name="latitude"]').value = cafe.latitude;
|
|
form.querySelector('[name="longitude"]').value = cafe.longitude;
|
|
form.querySelector('[name="website"]').value = cafe.website || '';
|
|
|
|
document.getElementById('nearby-results').classList.add('hidden');
|
|
document.getElementById('nearby-search').value = '';
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const el = document.createElement('span');
|
|
el.textContent = text;
|
|
return el.innerHTML;
|
|
}
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<section data-signals:_show-form="false">
|
|
<header class="flex flex-wrap items-start justify-between gap-4">
|
|
<div class="flex flex-col gap-2">
|
|
<h1 class="text-3xl font-semibold">Cafes</h1>
|
|
<p class="max-w-2xl text-sm text-stone-600">Discover and track your favourite cafes.</p>
|
|
</div>
|
|
{% if is_authenticated %}
|
|
<button
|
|
type="button"
|
|
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
|
|
data-class:hidden="$_showForm"
|
|
data-on:click="$_showForm = true"
|
|
aria-label="Add new cafe"
|
|
>
|
|
<span aria-hidden="true">+</span>
|
|
</button>
|
|
{% endif %}
|
|
</header>
|
|
|
|
{% if is_authenticated %}
|
|
<div
|
|
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
|
|
data-show="$_showForm"
|
|
style="display: none"
|
|
>
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-amber-700">New Cafe</h2>
|
|
<p class="mt-1 text-sm text-stone-600">
|
|
Add a cafe you have visited or want to remember.
|
|
</p>
|
|
</div>
|
|
<form
|
|
method="post"
|
|
action="/api/v1/cafes"
|
|
class="mt-4 flex flex-col gap-4"
|
|
data-on:submit="@post('/api/v1/cafes?{{ navigator.query_for_page(1) }}', {contentType: 'form', responseOverrides: {selector: '#cafe-list', mode: 'replace'}})"
|
|
data-ref="_form"
|
|
data-on:datastar-fetch="evt.detail.type === 'finished' && ($_showForm = false, $_form && $_form.reset())"
|
|
>
|
|
<div class="relative border-b border-amber-200 pb-4">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<button
|
|
type="button"
|
|
id="locate-btn"
|
|
onclick="locateUser(this)"
|
|
class="inline-flex items-center gap-2 rounded-md border border-amber-500 px-3 py-2 text-sm font-medium text-amber-700 transition hover:bg-amber-50"
|
|
>
|
|
{% call icons::location("h-4 w-4") %}
|
|
Find nearby
|
|
</button>
|
|
<label class="inline-flex items-center gap-1.5 text-sm text-stone-600 cursor-pointer select-none">
|
|
<input type="checkbox" onchange="toggleCitySearch(this)" class="accent-amber-600" />
|
|
Search by city
|
|
</label>
|
|
<div id="city-search-wrap" class="hidden flex-1 min-w-[140px]">
|
|
<input
|
|
type="text"
|
|
id="city-input"
|
|
class="input-field w-full text-sm"
|
|
placeholder="e.g. Tokyo, London"
|
|
autocomplete="off"
|
|
/>
|
|
</div>
|
|
<div id="nearby-search-wrap" class="hidden relative flex-1 min-w-[200px]">
|
|
<input
|
|
type="text"
|
|
id="nearby-search"
|
|
oninput="onSearchInput(this)"
|
|
class="input-field w-full text-sm pr-8"
|
|
placeholder="Search for a cafe…"
|
|
autocomplete="off"
|
|
/>
|
|
<span id="nearby-spinner" class="hidden absolute right-2 top-1/2 -translate-y-1/2">
|
|
{% call icons::spinner("h-4 w-4") %}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p id="nearby-error" class="hidden mt-2 text-sm text-red-600"></p>
|
|
<div
|
|
id="nearby-results"
|
|
class="hidden absolute left-0 right-0 z-10 mt-1 max-h-60 overflow-y-auto rounded-md border border-amber-300 bg-white shadow-lg divide-y divide-amber-100"
|
|
></div>
|
|
</div>
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
<span class="text-stone-700">Name *</span>
|
|
<input
|
|
type="text"
|
|
name="name"
|
|
required
|
|
class="input-field"
|
|
placeholder="Blue Bottle Coffee"
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
<span class="text-stone-700">City *</span>
|
|
<input
|
|
type="text"
|
|
name="city"
|
|
required
|
|
class="input-field"
|
|
placeholder="San Francisco"
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
<span class="text-stone-700">Country *</span>
|
|
<input
|
|
type="text"
|
|
name="country"
|
|
required
|
|
class="input-field"
|
|
placeholder="United States"
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
<span class="text-stone-700">Website</span>
|
|
<input
|
|
type="url"
|
|
name="website"
|
|
class="input-field"
|
|
placeholder="https://bluebottlecoffee.com"
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
<span class="text-stone-700">Latitude *</span>
|
|
<input
|
|
type="number"
|
|
name="latitude"
|
|
step="any"
|
|
required
|
|
class="input-field"
|
|
placeholder="37.7749"
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
<span class="text-stone-700">Longitude *</span>
|
|
<input
|
|
type="number"
|
|
name="longitude"
|
|
step="any"
|
|
required
|
|
class="input-field"
|
|
placeholder="-122.4194"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div class="flex items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-stone-700 transition hover:border-amber-400 hover:text-stone-800"
|
|
data-on:click="($_showForm = false, $_form && $_form.reset())"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
|
>
|
|
Save Cafe
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
</section>
|
|
|
|
{% include "partials/cafe_list.html" %} {% endblock %}
|