- Add "Find nearby" button that requests browser geolocation - Show search input with debounced type-ahead after locating user - Render clickable results with name, location, and distance - Auto-fill form fields (name, city, country, lat, lng, website) when a result is selected - Show loading spinner in search input during API requests
300 lines
11 KiB
HTML
300 lines
11 KiB
HTML
{% extends "base.html" %} {% block title %}Brewlog · Cafes{% endblock %}
|
|
|
|
{% block head %}
|
|
{% if is_authenticated %}
|
|
<script>
|
|
var _userLat = null;
|
|
var _userLng = null;
|
|
var _searchTimeout = null;
|
|
var _nearbyCafes = [];
|
|
|
|
function locateUser(btn) {
|
|
var errorEl = document.getElementById('nearby-error');
|
|
var 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(
|
|
function (pos) {
|
|
_userLat = pos.coords.latitude;
|
|
_userLng = pos.coords.longitude;
|
|
btn.classList.add('hidden');
|
|
searchWrap.classList.remove('hidden');
|
|
document.getElementById('nearby-search').focus();
|
|
},
|
|
function (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);
|
|
var resultsEl = document.getElementById('nearby-results');
|
|
|
|
if (input.value.trim().length < 2) {
|
|
resultsEl.classList.add('hidden');
|
|
resultsEl.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
_searchTimeout = setTimeout(function () {
|
|
searchNearby(input.value.trim());
|
|
}, 350);
|
|
}
|
|
|
|
async function searchNearby(query) {
|
|
var resultsEl = document.getElementById('nearby-results');
|
|
var errorEl = document.getElementById('nearby-error');
|
|
var spinnerEl = document.getElementById('nearby-spinner');
|
|
errorEl.classList.add('hidden');
|
|
spinnerEl.classList.remove('hidden');
|
|
|
|
try {
|
|
var url = '/api/v1/nearby-cafes?lat=' + _userLat + '&lng=' + _userLng + '&q=' + encodeURIComponent(query);
|
|
var resp = await fetch(url, { credentials: 'same-origin' });
|
|
|
|
if (!resp.ok) throw new Error('Server returned ' + resp.status);
|
|
|
|
var 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;
|
|
}
|
|
|
|
var html = '';
|
|
for (var i = 0; i < cafes.length; i++) {
|
|
var dist = cafes[i].distance_meters;
|
|
var distLabel = dist < 1000
|
|
? dist + ' m'
|
|
: (dist / 1000).toFixed(1) + ' km';
|
|
var 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) {
|
|
var cafe = _nearbyCafes[index];
|
|
if (!cafe) return;
|
|
|
|
var 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) {
|
|
var 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-400 bg-amber-50 px-3 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
|
>
|
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
<path fill-rule="evenodd" d="M9.69 18.933l.003.001C9.89 19.02 10 19 10 19s.11.02.308-.066l.002-.001.006-.003.018-.008a5.741 5.741 0 00.281-.14c.186-.096.446-.24.757-.433.62-.384 1.445-.966 2.274-1.765C15.302 14.988 17 12.493 17 9A7 7 0 103 9c0 3.492 1.698 5.988 3.355 7.584a13.731 13.731 0 002.273 1.765 11.842 11.842 0 00.976.544l.062.029.018.008.006.003zM10 11.25a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5z" clip-rule="evenodd" />
|
|
</svg>
|
|
Find nearby
|
|
</button>
|
|
<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"
|
|
/>
|
|
<svg id="nearby-spinner" class="hidden absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-amber-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
</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>
|
|
<label class="sm:col-span-2 flex flex-col gap-1 text-sm">
|
|
<span class="text-stone-700">Notes</span>
|
|
<textarea
|
|
name="notes"
|
|
rows="3"
|
|
class="input-field"
|
|
placeholder="Atmosphere, speciality drinks, opening hours..."
|
|
></textarea>
|
|
</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 %}
|