275 lines
13 KiB
Twig
275 lines
13 KiB
Twig
{% extends 'base.html.twig' %}
|
||
|
||
{% block title %}Browse events — VINDN{% endblock %}
|
||
|
||
{% block head_extra %}
|
||
<style>
|
||
.toolbar{ display:flex; justify-content:space-between; align-items:center; gap:16px; flex-wrap:wrap; margin-bottom:20px; }
|
||
.search-input-bar{ display:flex; gap:10px; margin-bottom:20px; }
|
||
.search-input-bar input{ flex:1; padding:16px 20px; border:1px solid var(--line-strong); border-radius: var(--r-pill); font-size:15px; }
|
||
.filter-label{
|
||
display:block; font-family:var(--f-mono); font-size:11px; text-transform:uppercase;
|
||
letter-spacing:.06em; color:var(--muted); margin:0 0 8px;
|
||
}
|
||
.filter-row{ display:flex; align-items:flex-start; gap:24px; flex-wrap:wrap; margin-bottom:18px; }
|
||
.filter-group-inline{ display:flex; flex-direction:column; }
|
||
.location-filter{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||
.location-filter form{ display:flex; gap:6px; }
|
||
.location-filter input[type=text]{ min-width:0; width:180px; padding:9px 12px; border:1px solid var(--line-strong); border-radius:var(--r-pill); font-size:13px; }
|
||
.location-filter select{ padding:7px 8px; border:1px solid var(--line-strong); border-radius:6px; font-family:var(--f-mono); font-size:12px; background:#fff; }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block body %}
|
||
<section class="section" style="padding-bottom:0;">
|
||
<div class="wrap">
|
||
<p class="eyebrow" style="margin-bottom:10px;">All events</p>
|
||
<div class="toolbar">
|
||
<h1 style="font-size:clamp(28px,4vw,40px);">{{ q ? 'Searching: "' ~ q ~ '"' : "What's on" }}{{ activeCategory ? ' — ' ~ activeCategory : '' }}</h1>
|
||
<div style="display:flex; align-items:center; gap:12px;">
|
||
<p class="mono" style="font-size:13px; color:var(--muted); margin:0;">{{ total }} event{{ total == 1 ? '' : 's' }}</p>
|
||
<label for="sortSelect" style="display:none;">Sort by</label>
|
||
<select id="sortSelect" style="padding:7px 8px; border:1px solid var(--line-strong); border-radius:6px; font-family:var(--f-mono); font-size:12px; background:#fff;">
|
||
<option value="soonest" data-href="{{ path('event_list', filters|merge({sort: 'soonest'})) }}" {{ 'soonest' == sort ? 'selected' : '' }}>Sort: Soonest</option>
|
||
{% if hasLocation %}
|
||
<option value="nearest" data-href="{{ path('event_list', filters|merge({sort: 'nearest'})) }}" {{ 'nearest' == sort ? 'selected' : '' }}>Sort: Nearest</option>
|
||
{% endif %}
|
||
<option value="az" data-href="{{ path('event_list', filters|merge({sort: 'az'})) }}" {{ 'az' == sort ? 'selected' : '' }}>Sort: A–Z</option>
|
||
<option value="za" data-href="{{ path('event_list', filters|merge({sort: 'za'})) }}" {{ 'za' == sort ? 'selected' : '' }}>Sort: Z–A</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<span class="filter-label">Search</span>
|
||
<form class="search-input-bar" method="get" action="{{ path('event_list') }}">
|
||
{% if activeCategory %}<input type="hidden" name="category" value="{{ activeCategory }}">{% endif %}
|
||
{% if 'any' != when %}<input type="hidden" name="when" value="{{ when }}">{% endif %}
|
||
{% if hasLocation %}
|
||
<input type="hidden" name="lat" value="{{ lat }}">
|
||
<input type="hidden" name="lon" value="{{ lon }}">
|
||
<input type="hidden" name="range" value="{{ rangeKm }}">
|
||
{% endif %}
|
||
<label for="q" style="display:none;">Search events</label>
|
||
<input id="q" name="q" type="text" value="{{ q }}" placeholder="Search by event, artist or venue…">
|
||
<button class="btn btn-primary" type="submit">Search</button>
|
||
</form>
|
||
|
||
<span class="filter-label">Category</span>
|
||
<input type="checkbox" id="catsToggle" class="chip-toggle-checkbox">
|
||
<div class="chip-row chip-row-collapsible">
|
||
<a href="{{ path('event_list', filters|merge({category: null})) }}" class="chip {{ activeCategory is null ? 'is-active' : '' }}">All</a>
|
||
{% for category in categories %}
|
||
<a href="{{ path('event_list', filters|merge({category: category})) }}" class="chip {{ activeCategory == category ? 'is-active' : '' }}">{{ category }}</a>
|
||
{% endfor %}
|
||
</div>
|
||
<label for="catsToggle" class="chip-toggle-btn" style="margin-bottom:20px;">
|
||
<span class="show-more">Show all categories ↑</span>
|
||
<span class="show-less">Show fewer categories ↑</span>
|
||
</label>
|
||
|
||
<div class="filter-row">
|
||
<div class="filter-group-inline">
|
||
<span class="filter-label">When</span>
|
||
<div class="chip-row-tight">
|
||
<a href="{{ path('event_list', filters|merge({when: null})) }}" class="chip-sm {{ 'any' == when ? 'is-active' : '' }}">Any date</a>
|
||
<a href="{{ path('event_list', filters|merge({when: 'today'})) }}" class="chip-sm {{ 'today' == when ? 'is-active' : '' }}">Today</a>
|
||
<a href="{{ path('event_list', filters|merge({when: 'week'})) }}" class="chip-sm {{ 'week' == when ? 'is-active' : '' }}">Next 7 days</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filter-group-inline">
|
||
<span class="filter-label">Near a location</span>
|
||
<div class="location-filter">
|
||
<form id="locationSearchForm">
|
||
<label for="locationSearchInput" style="display:none;">Search a place</label>
|
||
<input id="locationSearchInput" type="text" placeholder="Search a place…" autocomplete="off">
|
||
<button class="btn btn-ghost btn-sm" type="submit" aria-label="Search near this place">Go</button>
|
||
</form>
|
||
<button type="button" class="btn btn-ghost btn-sm" id="useMyLocationBtn">Use my location</button>
|
||
<select id="rangeSelect">
|
||
{% for km in rangeOptions %}
|
||
<option value="{{ km }}" {{ km == rangeKm ? 'selected' : '' }}>{{ km }} km</option>
|
||
{% endfor %}
|
||
</select>
|
||
{% if hasLocation %}
|
||
<a href="{{ path('event_list', filters|merge({lat: null, lon: null, range: null})) }}" id="clearLocationLink" style="color:var(--accent); text-decoration:underline; font-family:var(--f-mono); font-size:12px;">clear</a>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="locate-status" id="locationStatus">
|
||
{% if hasLocation %}Showing events within {{ rangeKm }} km of {{ lat|number_format(3) }}, {{ lon|number_format(3) }}{% endif %}
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="section" style="padding-top:0;">
|
||
<div class="wrap">
|
||
<div class="card-grid">
|
||
{% for event in events %}
|
||
{% include 'events/_card.html.twig' with { event: event, dist: hasLocation ? distances[event.id]|default(null) : null } %}
|
||
{% else %}
|
||
<div class="no-results" style="grid-column:1/-1; padding:60px 20px; text-align:center; color:var(--muted); border:1px dashed var(--line-strong); border-radius:var(--r-card);">
|
||
<p class="eyebrow" style="margin-bottom:8px;">No events</p>
|
||
<p>Nothing matches those filters yet — try clearing one, or check back once the importer has run.</p>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
{% include '_pager.html.twig' with {
|
||
currentPage: page,
|
||
totalPages: totalPages,
|
||
routeName: 'event_list',
|
||
routeParams: filters
|
||
} %}
|
||
</div>
|
||
</section>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
(function(){
|
||
var currentLat = {{ hasLocation ? lat : 'null' }};
|
||
var currentLon = {{ hasLocation ? lon : 'null' }};
|
||
var rangeSelect = document.getElementById('rangeSelect');
|
||
|
||
// Shared with the home hero map and /map (see map.js) so a search/
|
||
// geolocation made on any of the three sticks when moving between them.
|
||
var LAST_LOCATION_KEY = 'vindn:lastLocation';
|
||
|
||
function loadLastLocation() {
|
||
try {
|
||
var raw = window.localStorage && localStorage.getItem(LAST_LOCATION_KEY);
|
||
if (!raw) return null;
|
||
var parsed = JSON.parse(raw);
|
||
if (typeof parsed.lat === 'number' && typeof parsed.lon === 'number' && isFinite(parsed.lat) && isFinite(parsed.lon)) {
|
||
return parsed;
|
||
}
|
||
} catch (e) { /* localStorage disabled/unavailable, or corrupt value — just skip it */ }
|
||
return null;
|
||
}
|
||
|
||
function saveLastLocation(lat, lon, name) {
|
||
try {
|
||
if (window.localStorage) localStorage.setItem(LAST_LOCATION_KEY, JSON.stringify({ lat: lat, lon: lon, name: name || null }));
|
||
} catch (e) { /* localStorage disabled/unavailable — nothing to persist to */ }
|
||
}
|
||
|
||
function currentRangeKm() {
|
||
return rangeSelect ? parseInt(rangeSelect.value, 10) : {{ rangeKm }};
|
||
}
|
||
|
||
// Full reloads happen after every location change here, so there's no
|
||
// point setting the input's value client-side before navigating — the
|
||
// restore-on-load block below re-reads it from localStorage instead,
|
||
// right after this same call has just written it there.
|
||
function navigateWithLocation(lat, lon, rangeKm, name) {
|
||
// Preserve whatever's currently shown (e.g. a range-only change)
|
||
// unless a new place/geolocation explicitly overrides it.
|
||
if (name === undefined) {
|
||
var locInput = document.getElementById('locationSearchInput');
|
||
name = locInput ? locInput.value : null;
|
||
}
|
||
saveLastLocation(lat, lon, name);
|
||
var url = new URL(window.location.href);
|
||
url.searchParams.set('lat', lat.toFixed(5));
|
||
url.searchParams.set('lon', lon.toFixed(5));
|
||
url.searchParams.set('range', rangeKm || url.searchParams.get('range') || 25);
|
||
url.searchParams.delete('page'); // the result set changed, page N may no longer exist
|
||
window.location.href = url.toString();
|
||
}
|
||
|
||
function setStatus(text, isError) {
|
||
var el = document.getElementById('locationStatus');
|
||
if (!el) return;
|
||
el.textContent = text;
|
||
el.classList.toggle('is-error', !!isError);
|
||
}
|
||
|
||
// Nominatim: explicit submit only, never as-you-type — required by its
|
||
// usage policy (max ~1 req/s, no autocomplete-style querying).
|
||
var locationForm = document.getElementById('locationSearchForm');
|
||
if (locationForm) {
|
||
locationForm.addEventListener('submit', function (e) {
|
||
e.preventDefault();
|
||
var input = document.getElementById('locationSearchInput');
|
||
var query = (input.value || '').trim();
|
||
if (!query) return;
|
||
setStatus('Looking up "' + query + '"…', false);
|
||
fetch('https://nominatim.openstreetmap.org/search?format=json&limit=1&q=' + encodeURIComponent(query))
|
||
.then(function (res) { return res.json(); })
|
||
.then(function (results) {
|
||
if (!results || !results.length) {
|
||
setStatus('Could not find "' + query + '"', true);
|
||
return;
|
||
}
|
||
navigateWithLocation(parseFloat(results[0].lat), parseFloat(results[0].lon), currentRangeKm(), results[0].display_name);
|
||
})
|
||
.catch(function () {
|
||
setStatus('Location search failed', true);
|
||
});
|
||
});
|
||
}
|
||
|
||
var useMyLocationBtn = document.getElementById('useMyLocationBtn');
|
||
if (useMyLocationBtn) {
|
||
useMyLocationBtn.addEventListener('click', function () {
|
||
if (!navigator.geolocation) {
|
||
setStatus('Location not supported by your browser', true);
|
||
return;
|
||
}
|
||
setStatus('Finding you…', false);
|
||
navigator.geolocation.getCurrentPosition(function (pos) {
|
||
navigateWithLocation(pos.coords.latitude, pos.coords.longitude, currentRangeKm(), 'Your location');
|
||
}, function () {
|
||
setStatus("Couldn't get your location", true);
|
||
}, { timeout: 8000 });
|
||
});
|
||
}
|
||
|
||
if (rangeSelect) {
|
||
rangeSelect.addEventListener('change', function () {
|
||
// No location chosen yet — just remember the preference for
|
||
// whichever search/geolocation happens next.
|
||
if (currentLat === null || currentLon === null) return;
|
||
navigateWithLocation(currentLat, currentLon, currentRangeKm());
|
||
});
|
||
}
|
||
|
||
var clearLocationLink = document.getElementById('clearLocationLink');
|
||
if (clearLocationLink) {
|
||
// Otherwise the restore-on-load check below would immediately undo
|
||
// the clear on the very next visit.
|
||
clearLocationLink.addEventListener('click', function () {
|
||
try { window.localStorage && localStorage.removeItem(LAST_LOCATION_KEY); } catch (e) {}
|
||
});
|
||
}
|
||
|
||
var sortSelect = document.getElementById('sortSelect');
|
||
if (sortSelect) {
|
||
sortSelect.addEventListener('change', function () {
|
||
var opt = sortSelect.options[sortSelect.selectedIndex];
|
||
window.location.href = opt.getAttribute('data-href');
|
||
});
|
||
}
|
||
|
||
var saved = loadLastLocation();
|
||
|
||
// Every full reload here (search, geolocation, range change) already
|
||
// lands with an explicit ?lat=, so this doesn't overwrite it — it only
|
||
// fills in the name text a fresh navigation can't carry in the URL.
|
||
if (saved && saved.name) {
|
||
var locInput = document.getElementById('locationSearchInput');
|
||
if (locInput && !locInput.value) locInput.value = saved.name;
|
||
}
|
||
|
||
// A remembered search/geolocation from the home map or /map takes over
|
||
// here too — unless this URL already has its own explicit ?lat=, which
|
||
// always wins (e.g. a link shared with a location baked in).
|
||
if (!/[?&]lat=/.test(window.location.search) && saved) {
|
||
navigateWithLocation(saved.lat, saved.lon, currentRangeKm(), saved.name);
|
||
}
|
||
})();
|
||
</script>
|
||
{% endblock %}
|