Files
vindn.de/frontend/templates/events/list.html.twig
T
2026-10-09 16:37:53 +02:00

439 lines
24 KiB
Twig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends 'base.html.twig' %}
{% block title %}{{ 'browse.title'|trans }}{% endblock %}
{% block head_extra %}
{% include '_datepicker_head.html.twig' %}
<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:14px; }
.search-input-bar input{ flex:1; min-width:0; 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;
}
/* summary of what's active, under the search bar */
.filter-bar{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:20px; }
.filter-bar .count{ display:inline-block; min-width:18px; padding:1px 6px; margin-left:4px; border-radius:var(--r-pill); background:var(--ink); color:var(--paper); font-family:var(--f-mono); font-size:11px; text-align:center; }
.filter-pill{ display:inline-flex; align-items:center; gap:8px; padding:6px 8px 6px 12px; border:1px solid var(--line-strong); border-radius:var(--r-pill); background:#fff; font-family:var(--f-mono); font-size:12px; color:var(--ink); }
.filter-pill:hover{ border-color:var(--ink); }
.filter-pill .x{ display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; border-radius:50%; background:var(--paper-dim); font-size:13px; line-height:1; }
.filter-clear-all{ font-family:var(--f-mono); font-size:12px; color:var(--accent); text-decoration:underline; margin-left:4px; }
/* the filter popup */
.filter-dialog{ width:min(640px, calc(100vw - 24px)); padding:0; overflow:hidden; border:1px solid var(--line-strong); border-radius:var(--r-card); background:var(--paper); color:var(--ink); }
.filter-dialog::backdrop{ background:rgba(20,20,20,.25); backdrop-filter:blur(5px) saturate(0); -webkit-backdrop-filter:blur(5px) saturate(0); }
/* Open/close transition. Opening uses @starting-style as the "from" state;
closing adds .is-closing first (see the script) and only calls close()
once the fade has finished, so it works without the newer
display/overlay transition features. */
.filter-dialog, .filter-dialog::backdrop{
transition: opacity .22s ease, transform .22s ease, background-color .22s ease,
backdrop-filter .22s ease, -webkit-backdrop-filter .22s ease;
}
.filter-dialog.is-closing{ opacity:0; transform:translateY(14px) scale(.98); }
.filter-dialog.is-closing::backdrop{ background-color:rgba(20,20,20,0); backdrop-filter:blur(0) saturate(1); -webkit-backdrop-filter:blur(0) saturate(1); }
@starting-style{
.filter-dialog[open]{ opacity:0; transform:translateY(14px) scale(.98); }
.filter-dialog[open]::backdrop{ background-color:rgba(20,20,20,0); backdrop-filter:blur(0) saturate(1); -webkit-backdrop-filter:blur(0) saturate(1); }
}
@media (prefers-reduced-motion: reduce){
.filter-dialog, .filter-dialog::backdrop{ transition-duration:.01s; }
}
body:has(dialog[open]){ overflow:hidden; }
.filter-dialog form{ display:flex; flex-direction:column; max-height:calc(100vh - 48px); max-height:calc(100dvh - 48px); }
.fd-head, .fd-foot{ display:flex; align-items:center; justify-content:space-between; gap:12px; padding:16px 24px; }
.fd-head{ border-bottom:1px solid var(--line); }
.fd-head h2{ font-size:20px; margin:0; }
.fd-close{ border:0; background:none; font-size:26px; line-height:1; cursor:pointer; color:var(--ink); padding:0 4px; }
.fd-body{ flex:1; overflow-y:auto; padding:20px 24px 8px; }
.fd-foot{ border-top:1px solid var(--line); }
.fd-section{ margin-bottom:24px; }
.fd-section > .filter-label{ margin-bottom:10px; }
.pick-row{ display:flex; flex-wrap:wrap; gap:8px; max-height:176px; overflow-y:auto; padding:2px; }
.pick{ position:relative; cursor:pointer; }
.pick input{ position:absolute; opacity:0; pointer-events:none; }
.pick span{ display:inline-block; font-family:var(--f-mono); font-size:12px; padding:7px 14px; border-radius:var(--r-pill); border:1px solid var(--line-strong); background:#fff; color:var(--ink-soft); white-space:nowrap; }
.pick input:checked + span{ background:var(--ink); color:var(--paper); border-color:var(--ink); }
.pick input:focus-visible + span{ outline:2px solid var(--accent); outline-offset:2px; }
.when-filter{ display:flex; align-items:center; gap:10px 20px; flex-wrap:wrap; }
.when-filter .chip-row-tight{ max-height:none; overflow:visible; }
.when-filter button.chip-sm{ cursor:pointer; }
.when-filter .date-range{ margin-top:0; }
.location-filter{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.location-filter input[type=text]{ min-width:0; flex:1 1 160px; 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; }
.locate-status{ max-width:none; }
.fd-link{ border:0; background:none; cursor:pointer; padding:0; color:var(--accent); text-decoration:underline; font-family:var(--f-mono); font-size:12px; }
@media (max-width:560px){
.fd-head, .fd-foot{ padding:14px 16px; }
.fd-body{ padding:16px 16px 8px; }
}
</style>
{% endblock %}
{% block body %}
<section class="section" style="padding-bottom:0;">
<div class="wrap">
<p class="eyebrow" style="margin-bottom:10px;">{{ 'browse.eyebrow'|trans }}</p>
<div class="toolbar">
<h1 style="font-size:clamp(28px,4vw,40px);">{{ q ? 'browse.heading_search'|trans({'%q%': q}) : 'browse.heading'|trans }}{{ activeCategory ? ' — ' ~ activeCategory : '' }}</h1>
<div style="display:flex; align-items:center; gap:12px;">
<p class="mono" style="font-size:13px; color:var(--muted); margin:0;">{{ 'browse.count'|trans({'%count%': total, '%n%': total|format_number}) }}</p>
<label for="sortSelect" style="display:none;">{{ 'browse.sort.label'|trans }}</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' : '' }}>{{ 'browse.sort.soonest'|trans }}</option>
{% if hasLocation %}
<option value="nearest" data-href="{{ path('event_list', filters|merge({sort: 'nearest'})) }}" {{ 'nearest' == sort ? 'selected' : '' }}>{{ 'browse.sort.nearest'|trans }}</option>
{% endif %}
<option value="az" data-href="{{ path('event_list', filters|merge({sort: 'az'})) }}" {{ 'az' == sort ? 'selected' : '' }}>{{ 'browse.sort.az'|trans }}</option>
<option value="za" data-href="{{ path('event_list', filters|merge({sort: 'za'})) }}" {{ 'za' == sort ? 'selected' : '' }}>{{ 'browse.sort.za'|trans }}</option>
</select>
</div>
</div>
<form class="search-input-bar" method="get" action="{{ path('event_list') }}">
{% if activeCategory %}<input type="hidden" name="category" value="{{ activeCategory }}">{% endif %}
{% if dateFrom %}<input type="hidden" name="from" value="{{ dateFrom }}">{% endif %}
{% if dateTo %}<input type="hidden" name="to" value="{{ dateTo }}">{% 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.label'|trans }}</label>
<input id="q" name="q" type="text" value="{{ q }}" placeholder="{{ 'browse.search.placeholder'|trans }}">
<button class="btn btn-primary" type="submit">{{ 'browse.search.submit'|trans }}</button>
</form>
{% set activeCount = (activeCategory ? 1 : 0) + (dateFrom or dateTo ? 1 : 0) + (hasLocation ? 1 : 0) %}
<div class="filter-bar">
<button type="button" class="btn btn-ghost btn-sm" id="openFilters" aria-haspopup="dialog">{{ 'browse.filters.button'|trans }}{% if activeCount %}<span class="count">{{ activeCount }}</span>{% endif %}</button>
{% if activeCategory %}
<a class="filter-pill" href="{{ path('event_list', filters|merge({category: null})) }}" aria-label="{{ 'browse.filters.remove_category'|trans({'%name%': activeCategory}) }}">{{ activeCategory }}<span class="x" aria-hidden="true">×</span></a>
{% endif %}
{% if dateFrom or dateTo %}
{% if dateFrom == today and dateTo == today %}{% set dateLabel = 'dates.today'|trans %}
{% elseif dateFrom == today and dateTo == weekEnd %}{% set dateLabel = 'dates.next7'|trans %}
{% elseif dateFrom and dateTo and dateFrom == dateTo %}{% set dateLabel = dateFrom|format_date %}
{% elseif dateFrom and dateTo %}{% set dateLabel = dateFrom|format_date ~ ' – ' ~ dateTo|format_date %}
{% elseif dateFrom %}{% set dateLabel = 'browse.filters.from'|trans({'%date%': dateFrom|format_date}) %}
{% else %}{% set dateLabel = 'browse.filters.until'|trans({'%date%': dateTo|format_date}) %}{% endif %}
<a class="filter-pill" data-forget="dates" href="{{ path('event_list', filters|merge({from: null, to: null})) }}" aria-label="{{ 'browse.filters.remove_dates'|trans({'%label%': dateLabel}) }}">{{ dateLabel }}<span class="x" aria-hidden="true">×</span></a>
{% endif %}
{% if hasLocation %}
<a class="filter-pill" id="locationPill" data-forget="location" data-lat="{{ lat }}" data-lon="{{ lon }}" href="{{ path('event_list', filters|merge({lat: null, lon: null, range: null})) }}" aria-label="{{ 'browse.filters.remove_location'|trans }}"><span class="label">{{ 'browse.filters.within_coords'|trans({'%km%': rangeKm, '%lat%': lat|format_number(3), '%lon%': lon|format_number(3)}) }}</span><span class="x" aria-hidden="true">×</span></a>
{% endif %}
{% if activeCount %}
<a class="filter-clear-all" data-forget="all" href="{{ path('event_list', {q: q ?: null, sort: filters.sort}) }}">{{ 'browse.filters.clear_all'|trans }}</a>
{% endif %}
</div>
</div>
</section>
<dialog class="filter-dialog" id="filterDialog" aria-labelledby="filterDialogTitle">
<form method="get" action="{{ path('event_list') }}" id="filterForm">
{% if q %}<input type="hidden" name="q" value="{{ q }}">{% endif %}
{% if filters.sort %}<input type="hidden" name="sort" value="{{ filters.sort }}">{% endif %}
<div class="fd-head">
<h2 id="filterDialogTitle">{{ 'browse.filters.dialog'|trans }}</h2>
<button type="button" class="fd-close" id="closeFilters" aria-label="{{ 'browse.filters.close'|trans }}">×</button>
</div>
<div class="fd-body">
<div class="fd-section">
<span class="filter-label">{{ 'browse.filters.category'|trans }}</span>
<div class="pick-row">
<label class="pick"><input type="radio" name="category" value="" {{ activeCategory is null ? 'checked' : '' }}><span>{{ 'map.all'|trans }}</span></label>
{% for category in categories %}
<label class="pick"><input type="radio" name="category" value="{{ category }}" {{ activeCategory == category ? 'checked' : '' }}><span>{{ category }}</span></label>
{% endfor %}
</div>
</div>
<div class="fd-section">
<span class="filter-label">{{ 'browse.filters.when'|trans }}</span>
<div class="when-filter">
<div class="chip-row-tight" id="datePresets">
<button type="button" class="chip-sm" data-from="" data-to="">{{ 'dates.any'|trans }}</button>
<button type="button" class="chip-sm" data-from="{{ today }}" data-to="{{ today }}">{{ 'dates.today'|trans }}</button>
<button type="button" class="chip-sm" data-from="{{ today }}" data-to="{{ weekEnd }}">{{ 'dates.next7'|trans }}</button>
</div>
<div class="date-range" role="group" aria-label="{{ 'dates.group'|trans }}">
<label for="dateFrom">{{ 'dates.from'|trans }}</label>
<input type="date" id="dateFrom" name="from" aria-label="{{ 'dates.from_aria'|trans }}" min="{{ today }}" value="{{ dateFrom }}">
<label for="dateTo">{{ 'dates.to'|trans }}</label>
<input type="date" id="dateTo" name="to" aria-label="{{ 'dates.to_aria'|trans }}" min="{{ today }}" value="{{ dateTo }}">
</div>
</div>
</div>
<div class="fd-section">
<span class="filter-label">{{ 'browse.filters.near'|trans }}</span>
<input type="hidden" name="lat" id="fLat" value="{{ hasLocation ? lat : '' }}">
<input type="hidden" name="lon" id="fLon" value="{{ hasLocation ? lon : '' }}">
<div class="location-filter">
<label for="locationSearchInput" style="display:none;">{{ 'location.search_label'|trans }}</label>
<input id="locationSearchInput" type="text" placeholder="{{ 'location.search_placeholder'|trans }}" autocomplete="off">
<button class="btn btn-ghost btn-sm" type="button" id="locationGo">{{ 'location.go'|trans }}</button>
<button type="button" class="btn btn-ghost btn-sm" id="useMyLocationBtn">{{ 'location.use_mine'|trans }}</button>
<label for="rangeSelect" style="display:none;">{{ 'location.radius'|trans }}</label>
<select id="rangeSelect" name="range">
{% for km in rangeOptions %}
<option value="{{ km }}" {{ km == rangeKm ? 'selected' : '' }}>{{ km }} km</option>
{% endfor %}
</select>
<button type="button" class="fd-link" id="clearLocation" {{ hasLocation ? '' : 'hidden' }}>{{ 'location.clear'|trans }}</button>
</div>
<p class="locate-status" id="locationStatus">{% if hasLocation %}{{ 'browse.filters.within_coords'|trans({'%km%': rangeKm, '%lat%': lat|format_number(3), '%lon%': lon|format_number(3)}) }}{% endif %}</p>
</div>
</div>
<div class="fd-foot">
<button type="button" class="fd-link" id="resetFilters">{{ 'browse.filters.reset'|trans }}</button>
<button type="submit" class="btn btn-primary btn-sm">{{ 'browse.filters.apply'|trans }}</button>
</div>
</form>
</dialog>
<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;">{{ 'browse.empty.title'|trans }}</p>
<p>{{ 'browse.empty.text'|trans }}</p>
</div>
{% endfor %}
</div>
{% include '_pager.html.twig' with {
currentPage: page,
totalPages: totalPages,
routeName: 'event_list',
routeParams: filters
} %}
</div>
</section>
{% endblock %}
{% block scripts %}
{% include '_datepicker_scripts.html.twig' %}
<script>
(function(){
// Storage/search/GPS lookups live in window.VindnLocation (location.js)
// and the range handling in window.VindnDates (dates.js), both loaded
// globally in base.html.twig and shared with the home hero map and /map,
// so a place or date range chosen on any of the three sticks when moving
// between them.
var dialog = document.getElementById('filterDialog');
var form = document.getElementById('filterForm');
var latEl = document.getElementById('fLat');
var lonEl = document.getElementById('fLon');
var placeInput = document.getElementById('locationSearchInput');
var rangeSelect = document.getElementById('rangeSelect');
var clearLocationBtn = document.getElementById('clearLocation');
var statusEl = document.getElementById('locationStatus');
var dateFrom = document.getElementById('dateFrom');
var dateTo = document.getElementById('dateTo');
var saved = window.VindnLocation.load();
var savedDates = window.VindnDates.load();
var search = window.location.search;
// ---- popup open/close ----
document.getElementById('openFilters').addEventListener('click', function () { dialog.showModal(); });
// Fade out first, then close — and do the same for Esc (the native
// "cancel") so every way of closing animates alike.
function closeDialog() {
if (!dialog.open || dialog.classList.contains('is-closing')) return;
dialog.classList.add('is-closing');
var finished = false;
function finish() {
if (finished) return;
finished = true;
dialog.classList.remove('is-closing');
dialog.close();
}
dialog.addEventListener('transitionend', function onEnd(e) {
if (e.target !== dialog) return;
dialog.removeEventListener('transitionend', onEnd);
finish();
});
setTimeout(finish, 400); // in case no transition runs (reduced motion, background tab)
}
document.getElementById('closeFilters').addEventListener('click', closeDialog);
dialog.addEventListener('cancel', function (e) { e.preventDefault(); closeDialog(); });
// a click on the backdrop lands on the <dialog> element itself
dialog.addEventListener('click', function (e) { if (e.target === dialog) closeDialog(); });
// ---- dates ----
var setDates = window.VindnDates.bindInputs(dateFrom, dateTo, markPresets);
var presets = document.querySelectorAll('#datePresets button');
function markPresets() {
presets.forEach(function (btn) {
var on = btn.getAttribute('data-from') === dateFrom.value && btn.getAttribute('data-to') === dateTo.value;
btn.classList.toggle('is-active', on);
});
}
presets.forEach(function (btn) {
btn.addEventListener('click', function () {
setDates(btn.getAttribute('data-from'), btn.getAttribute('data-to'));
markPresets();
});
});
markPresets();
// A range that arrived via the URL is remembered too, so the maps pick it
// up like they would one chosen here.
var urlDateFrom = {{ dateFrom|json_encode|raw }};
var urlDateTo = {{ dateTo|json_encode|raw }};
if (urlDateFrom || urlDateTo) window.VindnDates.save(urlDateFrom, urlDateTo);
// ---- location ----
var resolvedName = '';
if (saved && latEl.value && Math.abs(saved.lat - parseFloat(latEl.value)) < 1e-4 && Math.abs(saved.lon - parseFloat(lonEl.value)) < 1e-4 && saved.name) {
resolvedName = saved.name;
placeInput.value = saved.name;
// the summary pill can only show coordinates server-side
var pillLabel = document.querySelector('#locationPill .label');
if (pillLabel) pillLabel.textContent = window.VindnI18n.t('browse.within', { km: rangeSelect.value, place: saved.name.split(',').slice(0, 2).join(',') });
}
function setStatus(text, isError) {
statusEl.textContent = text;
statusEl.classList.toggle('is-error', !!isError);
}
function setLocation(lat, lon, name) {
latEl.value = lat.toFixed(5);
lonEl.value = lon.toFixed(5);
placeInput.value = name || '';
resolvedName = name || '';
clearLocationBtn.hidden = false;
setStatus(window.VindnI18n.t('location.found', { name: name || lat.toFixed(3) + ', ' + lon.toFixed(3) }), false);
}
function clearLocation() {
latEl.value = '';
lonEl.value = '';
placeInput.value = '';
resolvedName = '';
clearLocationBtn.hidden = true;
setStatus('', false);
}
function lookup(query, done) {
window.VindnLocation.searchPlace(query, {
onStart: function () { setStatus(window.VindnI18n.t('location.looking_up', { query: query }), false); },
onNotFound: function () { setStatus(window.VindnI18n.t('location.not_found', { query: query }), true); },
onFound: function (lat, lon, name) { setLocation(lat, lon, name); if (done) done(); },
onError: function () { setStatus(window.VindnI18n.t('location.search_failed'), true); }
});
}
document.getElementById('locationGo').addEventListener('click', function () {
var query = placeInput.value.trim();
if (query) lookup(query);
});
// Enter in the place field looks the place up rather than applying the
// whole popup half-finished.
placeInput.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
e.preventDefault();
var query = placeInput.value.trim();
if (query) lookup(query);
});
document.getElementById('useMyLocationBtn').addEventListener('click', function () {
window.VindnLocation.useMyLocation({
onUnsupported: function () { setStatus(window.VindnI18n.t('location.unsupported'), true); },
onStart: function () { setStatus(window.VindnI18n.t('location.finding'), false); },
onFound: setLocation,
onError: function () { setStatus(window.VindnI18n.t('location.failed'), true); }
});
});
clearLocationBtn.addEventListener('click', clearLocation);
document.getElementById('resetFilters').addEventListener('click', function () {
form.querySelector('input[name=category][value=""]').checked = true;
setDates('', '');
markPresets();
clearLocation();
});
// ---- applying ----
var submitting = false;
form.addEventListener('submit', function (e) {
if (submitting) return;
// A place typed but never looked up ("Go" skipped): resolve it first.
var typed = placeInput.value.trim();
if (typed && typed !== resolvedName) {
e.preventDefault();
lookup(typed, function () { form.requestSubmit(); });
return;
}
submitting = true;
var range = window.VindnDates.normalize(dateFrom.value, dateTo.value);
dateFrom.value = range.from || '';
dateTo.value = range.to || '';
window.VindnDates.save(range.from, range.to);
if (latEl.value && lonEl.value) {
window.VindnLocation.save(parseFloat(latEl.value), parseFloat(lonEl.value), resolvedName || null);
} else {
window.VindnLocation.clear();
rangeSelect.disabled = true;
}
// keep the URL to what's actually set
Array.prototype.forEach.call(form.elements, function (el) {
if (el.name && el.type !== 'radio' && el.value === '') el.disabled = true;
});
var cat = form.querySelector('input[name=category]:checked');
if (cat && cat.value === '') cat.disabled = true;
});
// Removing a filter via its pill must also forget it, otherwise the
// restore-on-load check below would bring it straight back.
document.querySelectorAll('[data-forget]').forEach(function (link) {
link.addEventListener('click', function () {
var what = link.getAttribute('data-forget');
if (what === 'dates' || what === 'all') window.VindnDates.clear();
if (what === 'location' || what === 'all') window.VindnLocation.clear();
});
});
var sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.addEventListener('change', function () {
var opt = sortSelect.options[sortSelect.selectedIndex];
window.location.href = opt.getAttribute('data-href');
});
}
// A remembered place and date range from the home map or /map take over
// here too — unless the URL already says something itself (an explicit
// ?lat=, or any of from/to/when), which always wins, e.g. a shared link.
// Both are restored in one redirect rather than two chained ones.
var restoreLocation = saved && !/[?&]lat=/.test(search);
var restoreDates = savedDates && !/[?&](from|to|when)=/.test(search);
if (restoreLocation || restoreDates) {
var restoreUrl = new URL(window.location.href);
if (restoreLocation) {
restoreUrl.searchParams.set('lat', saved.lat.toFixed(5));
restoreUrl.searchParams.set('lon', saved.lon.toFixed(5));
restoreUrl.searchParams.set('range', rangeSelect.value);
}
if (restoreDates) {
if (savedDates.from) restoreUrl.searchParams.set('from', savedDates.from);
if (savedDates.to) restoreUrl.searchParams.set('to', savedDates.to);
}
restoreUrl.searchParams.delete('page');
window.location.href = restoreUrl.toString();
}
})();
</script>
{% endblock %}