added date selection and filter popup
This commit is contained in:
1 parent
739a1eee28
commit
36ed3880e8
16 files changed
+741
-180
No files matched your search
@@ -0,0 +1,136 @@
|
||||
// Shared date-range handling for Browse Events and both maps — remembered
|
||||
// across pages the way location.js remembers the place, validated in one
|
||||
// spot, and bound to German-format (dd.mm.yyyy) date pickers. Pages still
|
||||
// own what a range *does* (a server-side filter on Browse, a client-side
|
||||
// one on the maps); this only owns the range itself.
|
||||
(function (window) {
|
||||
var KEY = 'vindn:dateRange';
|
||||
var DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// "Y-m-d" for today in Germany — the site's audience and data are
|
||||
// German-local, so "today" must not flip at UTC midnight. sv-SE happens
|
||||
// to format dates as ISO, which is the only reason it's used here.
|
||||
function today() {
|
||||
return new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' });
|
||||
}
|
||||
|
||||
// Valid "Y-m-d" strings or null for each end, and reversed ends swapped.
|
||||
function normalize(from, to) {
|
||||
from = DAY_RE.test(from || '') ? from : null;
|
||||
to = DAY_RE.test(to || '') ? to : null;
|
||||
if (from && to && from > to) {
|
||||
var swap = from;
|
||||
from = to;
|
||||
to = swap;
|
||||
}
|
||||
return { from: from, to: to };
|
||||
}
|
||||
|
||||
// The remembered range, or null. A range that already ended is dropped
|
||||
// (nothing it could match is shown anymore), and a start in the past is
|
||||
// moved up to today so the pickers never show a date they can't select.
|
||||
function load() {
|
||||
try {
|
||||
var raw = window.localStorage && localStorage.getItem(KEY);
|
||||
if (!raw) return null;
|
||||
var parsed = JSON.parse(raw);
|
||||
var range = normalize(parsed.from, parsed.to);
|
||||
if (!range.from && !range.to) return null;
|
||||
var now = today();
|
||||
if (range.to && range.to < now) {
|
||||
clear();
|
||||
return null;
|
||||
}
|
||||
if (range.from && range.from < now) range.from = now;
|
||||
return range;
|
||||
} catch (e) { /* localStorage disabled/unavailable, or corrupt value — just skip it */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function save(from, to) {
|
||||
var range = normalize(from, to);
|
||||
if (!range.from && !range.to) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (window.localStorage) localStorage.setItem(KEY, JSON.stringify(range));
|
||||
} catch (e) { /* localStorage disabled/unavailable — nothing to persist to */ }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
try {
|
||||
if (window.localStorage) localStorage.removeItem(KEY);
|
||||
} catch (e) { /* localStorage disabled/unavailable */ }
|
||||
}
|
||||
|
||||
// Turns a pair of <input type="date"> into German-format pickers when
|
||||
// flatpickr is loaded. A native date input displays in the *browser's*
|
||||
// locale and can't be forced to dd.mm.yyyy, so the visible field is
|
||||
// flatpickr's alt input (d.m.Y, German calendar) while the original input
|
||||
// keeps holding the machine-readable "Y-m-d" value everything else reads.
|
||||
// Without flatpickr (e.g. its CDN is unreachable) the native inputs keep
|
||||
// working as they are.
|
||||
//
|
||||
// onChange(from, to) fires when the *user* edits either field — never for
|
||||
// updates made through the returned setter, so applying a remembered
|
||||
// range doesn't trigger the page's own change handling.
|
||||
function bindInputs(fromEl, toEl, onChange) {
|
||||
var pickers = [];
|
||||
if (window.flatpickr) {
|
||||
var options = {
|
||||
locale: 'de',
|
||||
dateFormat: 'Y-m-d',
|
||||
altInput: true,
|
||||
altFormat: 'd.m.Y',
|
||||
allowInput: true,
|
||||
disableMobile: true, // the native mobile picker would bring the device locale back
|
||||
static: true, // rendered inside the card, so it also shows in a fullscreen map
|
||||
minDate: 'today',
|
||||
// The popup renders inside its card/dialog; in a scrolling container
|
||||
// (the Browse filter popup) it can open partly out of view.
|
||||
onOpen: function (selected, str, instance) {
|
||||
setTimeout(function () {
|
||||
instance.calendarContainer.scrollIntoView({ block: 'nearest' });
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
pickers = [fromEl, toEl].map(function (el) {
|
||||
var picker = window.flatpickr(el, options);
|
||||
// The original input turns into a hidden one, so the visible alt
|
||||
// input has to take over its id and label — otherwise the <label for>
|
||||
// points at something that can't be focused or clicked.
|
||||
var alt = picker.altInput;
|
||||
var label = el.id && document.querySelector('label[for="' + el.id + '"]');
|
||||
alt.id = el.id + 'Display';
|
||||
if (label) label.setAttribute('for', alt.id);
|
||||
alt.setAttribute('aria-label', el.getAttribute('aria-label') || '');
|
||||
alt.setAttribute('placeholder', 'TT.MM.JJJJ');
|
||||
return picker;
|
||||
});
|
||||
}
|
||||
|
||||
function handle() { onChange(fromEl.value, toEl.value); }
|
||||
fromEl.addEventListener('change', handle);
|
||||
toEl.addEventListener('change', handle);
|
||||
|
||||
return function set(from, to) {
|
||||
if (pickers.length) {
|
||||
pickers[0].setDate(from || null, false);
|
||||
pickers[1].setDate(to || null, false);
|
||||
} else {
|
||||
fromEl.value = from || '';
|
||||
toEl.value = to || '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.VindnDates = {
|
||||
today: today,
|
||||
normalize: normalize,
|
||||
load: load,
|
||||
save: save,
|
||||
clear: clear,
|
||||
bindInputs: bindInputs
|
||||
};
|
||||
})(window);
|
||||
@@ -30,6 +30,63 @@
|
||||
statusEl.classList.toggle('is-error', !!isError);
|
||||
}
|
||||
|
||||
// German number formatting for what the panel shows (decimal comma, dot
|
||||
// as thousands separator) — mirrors GermanFormatExtension::distance() on
|
||||
// the server so a distance reads the same on a card and in the panel.
|
||||
var DE_INT = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 });
|
||||
var DE_ONE_DECIMAL = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 1 });
|
||||
|
||||
function formatKm(km) {
|
||||
return km < 10 ? DE_ONE_DECIMAL.format(km) : DE_INT.format(km);
|
||||
}
|
||||
|
||||
// Day-range filtering for the maps — the same "event overlaps [from, to]"
|
||||
// rule as EventRepository::filterByDateRange on the server, applied to the
|
||||
// Y-m-d prefixes the serializer ships, so it's a plain string comparison.
|
||||
function eventInDateRange(ev, range) {
|
||||
if (!range.from && !range.to) return true;
|
||||
if (!ev.start) return false; // undated events can't match a date window
|
||||
var end = ev.end || ev.start;
|
||||
return (!range.to || ev.start <= range.to) && (!range.from || end >= range.from);
|
||||
}
|
||||
|
||||
// Binds the shared #dateFrom/#dateTo/#dateClear controls (German-format
|
||||
// pickers, remembered range — see dates.js) and returns the live range
|
||||
// object ({from, to}, either may be null) the caller filters by; onChange
|
||||
// runs after every user edit. `initial` seeds it (a range from the URL or
|
||||
// from storage) without firing onChange.
|
||||
function initDateRange(initial, onChange) {
|
||||
var fromEl = document.getElementById('dateFrom');
|
||||
var toEl = document.getElementById('dateTo');
|
||||
var clearEl = document.getElementById('dateClear');
|
||||
var range = { from: null, to: null };
|
||||
var setInputs = function () {};
|
||||
|
||||
function apply(from, to) {
|
||||
var normalized = window.VindnDates.normalize(from, to);
|
||||
range.from = normalized.from;
|
||||
range.to = normalized.to;
|
||||
setInputs(range.from, range.to);
|
||||
if (clearEl) clearEl.hidden = !(range.from || range.to);
|
||||
}
|
||||
|
||||
function edited(from, to) {
|
||||
apply(from, to);
|
||||
window.VindnDates.save(range.from, range.to);
|
||||
onChange();
|
||||
}
|
||||
|
||||
if (fromEl && toEl) setInputs = window.VindnDates.bindInputs(fromEl, toEl, edited);
|
||||
if (clearEl) clearEl.addEventListener('click', function () { edited(null, null); });
|
||||
|
||||
apply(initial && initial.from, initial && initial.to);
|
||||
// A range that arrived via the URL should be remembered too, so the
|
||||
// other pages pick it up like they would one picked here.
|
||||
if (range.from || range.to) window.VindnDates.save(range.from, range.to);
|
||||
|
||||
return range;
|
||||
}
|
||||
|
||||
// Leaflet.awesome-markers only ships a fixed palette of named colors (no
|
||||
// arbitrary hex/hsl) — curated to the ones that read well against the
|
||||
// grayscale-filtered basemap. Approximate hex values below are only used
|
||||
@@ -350,6 +407,23 @@
|
||||
|
||||
if (limit && map) fitToMarkers(map, markers);
|
||||
|
||||
var dates = initDateRange(window.VindnDates.load(), onDatesChange);
|
||||
|
||||
// Candidate pool for the date window; in limit mode ("nearest N") the
|
||||
// N nearest are picked *from this*, so a narrow window still shows up
|
||||
// to N events instead of N nearest-overall minus the ones outside it.
|
||||
function poolEvents() {
|
||||
return allEvents.filter(function (ev) { return eventInDateRange(ev, dates); });
|
||||
}
|
||||
|
||||
function onDatesChange() {
|
||||
if (limit) {
|
||||
rebuildMarkers(nearest(state.userLoc || defaultCenter, poolEvents(), limit));
|
||||
if (map) fitToMarkers(map, markers);
|
||||
}
|
||||
renderList();
|
||||
}
|
||||
|
||||
var legendEl = document.getElementById('legend');
|
||||
if (legendEl) {
|
||||
var seenCats = [];
|
||||
@@ -398,7 +472,7 @@
|
||||
return list;
|
||||
}
|
||||
|
||||
var visible = allEvents.filter(function (ev) { return state.cat === 'all' || ev.category === state.cat; });
|
||||
var visible = allEvents.filter(function (ev) { return (state.cat === 'all' || ev.category === state.cat) && eventInDateRange(ev, dates); });
|
||||
if (state.userLoc) {
|
||||
visible = visible.map(function (ev) { ev._dist = distanceKm(state.userLoc[0], state.userLoc[1], ev.lat, ev.lon); return ev; })
|
||||
.sort(function (a, b) { return a._dist - b._dist; });
|
||||
@@ -419,7 +493,7 @@
|
||||
var list = currentList();
|
||||
|
||||
var countEl = document.getElementById('resultCount');
|
||||
if (countEl) countEl.textContent = list.length + (list.length === 1 ? ' event' : ' events');
|
||||
if (countEl) countEl.textContent = DE_INT.format(list.length) + (list.length === 1 ? ' event' : ' events');
|
||||
|
||||
var panelEl = document.getElementById('panelList');
|
||||
if (!panelEl) return;
|
||||
@@ -427,7 +501,7 @@
|
||||
panelEl.innerHTML = list.map(function (ev) {
|
||||
var distHtml = '';
|
||||
if (ev._dist != null) {
|
||||
var distLabel = ev._dist < 1 ? Math.round(ev._dist * 1000) : ev._dist.toFixed(1);
|
||||
var distLabel = ev._dist < 1 ? Math.round(ev._dist * 1000) : formatKm(ev._dist);
|
||||
var distUnit = ev._dist < 1 ? 'm' : 'km';
|
||||
distHtml = '<div class="dist">' + distLabel + '<span class="u">' + distUnit + '</span></div>';
|
||||
}
|
||||
@@ -480,7 +554,7 @@
|
||||
window.VindnLocation.save(lat, lon, placeName);
|
||||
setLocationInputValue(placeName);
|
||||
|
||||
if (limit) rebuildMarkers(nearest([lat, lon], allEvents.slice(), limit));
|
||||
if (limit) rebuildMarkers(nearest([lat, lon], poolEvents(), limit));
|
||||
|
||||
var statusEl = document.getElementById('locateStatus');
|
||||
setLocateStatus(statusEl, statusText, false);
|
||||
@@ -548,7 +622,10 @@
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
renderList();
|
||||
// The first pins/list were built before the date range was known — a
|
||||
// remembered range has to reshape them (onDatesChange also renders).
|
||||
if (dates.from || dates.to) onDatesChange();
|
||||
else renderList();
|
||||
|
||||
return { map: map, markers: markers };
|
||||
}
|
||||
@@ -576,6 +653,14 @@
|
||||
var containerId = opts.containerId || 'mapCanvas';
|
||||
|
||||
var state = { source: originalCenter.slice(), rangeKm: opts.rangeKm || 25, cat: 'all' };
|
||||
// A from/to in the URL (shared link, or this page's own reload) wins
|
||||
// over the remembered range, the same way an explicit lat/lon does.
|
||||
var urlParams = new URLSearchParams(window.location.search);
|
||||
var urlHasDates = urlParams.has('from') || urlParams.has('to');
|
||||
var dates = initDateRange(
|
||||
urlHasDates ? { from: urlParams.get('from'), to: urlParams.get('to') } : window.VindnDates.load(),
|
||||
function () { render(); }
|
||||
);
|
||||
// getCurrentPosition() can take a few seconds; if the user searches or
|
||||
// clicks the map before it resolves, its result must not overwrite
|
||||
// whatever they deliberately chose in the meantime.
|
||||
@@ -625,6 +710,7 @@
|
||||
function inRange() {
|
||||
return allEvents
|
||||
.filter(function (ev) { return state.cat === 'all' || ev.category === state.cat; })
|
||||
.filter(function (ev) { return eventInDateRange(ev, dates); })
|
||||
.map(function (ev) { ev._dist = distanceKm(state.source[0], state.source[1], ev.lat, ev.lon); return ev; })
|
||||
.filter(function (ev) { return ev._dist <= state.rangeKm; })
|
||||
.sort(function (a, b) { return a._dist - b._dist; });
|
||||
@@ -636,6 +722,10 @@
|
||||
url.searchParams.set('lat', state.source[0].toFixed(5));
|
||||
url.searchParams.set('lon', state.source[1].toFixed(5));
|
||||
url.searchParams.set('range', state.rangeKm);
|
||||
['from', 'to'].forEach(function (key) {
|
||||
if (dates[key]) url.searchParams.set(key, dates[key]);
|
||||
else url.searchParams.delete(key);
|
||||
});
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
|
||||
@@ -658,12 +748,12 @@
|
||||
setLocateStatus(statusEl, 'Showing events within ' + state.rangeKm + ' km', false);
|
||||
|
||||
var countEl = document.getElementById('resultCount');
|
||||
if (countEl) countEl.textContent = visible.length + (visible.length === 1 ? ' event' : ' events');
|
||||
if (countEl) countEl.textContent = DE_INT.format(visible.length) + (visible.length === 1 ? ' event' : ' events');
|
||||
|
||||
var panelEl = document.getElementById('panelList');
|
||||
if (panelEl) {
|
||||
panelEl.innerHTML = visible.map(function (ev) {
|
||||
var distLabel = ev._dist < 1 ? Math.round(ev._dist * 1000) : ev._dist.toFixed(1);
|
||||
var distLabel = ev._dist < 1 ? Math.round(ev._dist * 1000) : formatKm(ev._dist);
|
||||
var distUnit = ev._dist < 1 ? 'm' : 'km';
|
||||
return (
|
||||
'<div class="map-row" data-id="' + ev.id + '" tabindex="0" role="button" aria-label="' + ev.title + '">' +
|
||||
|
||||
@@ -352,6 +352,26 @@ h1,h2,h3{ font-family: var(--f-display); font-weight:700; letter-spacing:-0.01em
|
||||
pointer-events:auto;
|
||||
}
|
||||
.search-card{ padding:14px 16px; flex:1; max-width:560px; }
|
||||
/* the compact card holds just the date row — size it to that, not to 560px */
|
||||
.stage-compact .search-card{ flex:none; max-width:100%; }
|
||||
.date-range{ display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin-top:8px; font-family:var(--f-mono); font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.06em; }
|
||||
.date-range:first-child{ margin-top:0; }
|
||||
.date-range input{ padding:5px 8px; border:1px solid var(--line-strong); border-radius:6px; font-family:var(--f-mono); font-size:12px; background:#fff; color:var(--ink); }
|
||||
.date-range input{ width:112px; }
|
||||
.date-range .flatpickr-wrapper{ display:inline-block; position:static; }
|
||||
/* Anchor the popup to the row (Browse) or the whole card (maps) instead of
|
||||
the input it belongs to — otherwise the "From" calendar, ~308px wide,
|
||||
starts at the input and runs off the right edge of a phone screen. */
|
||||
.date-range{ position:relative; }
|
||||
.search-card{ position:relative; }
|
||||
.search-card .date-range{ position:static; }
|
||||
.date-range .flatpickr-calendar.static{ left:0; right:auto; }
|
||||
.date-range .flatpickr-calendar:before, .date-range .flatpickr-calendar:after{ display:none; }
|
||||
/* the calendar renders inside .date-range (static), so undo its uppercase mono label styling */
|
||||
.date-range .flatpickr-calendar{ text-transform:none; letter-spacing:normal; font-family:var(--f-body); font-size:14px; color:var(--ink); }
|
||||
.flatpickr-day.selected, .flatpickr-day.selected:hover{ background:var(--ink); border-color:var(--ink); }
|
||||
.flatpickr-day.today:not(.selected){ border-color:var(--accent); }
|
||||
.date-clear{ background:none; border:none; padding:0 4px; font-family:var(--f-mono); font-size:12px; text-transform:none; letter-spacing:0; color:var(--accent); text-decoration:underline; }
|
||||
.chip-row-tight{ display:flex; gap:8px; flex-wrap:wrap; max-height:64px; overflow-y:auto; }
|
||||
.chip-sm{ font-family:var(--f-mono); font-size:11px; padding:6px 12px; border-radius:var(--r-pill); border:1px solid var(--line-strong); background:#fff; color:var(--ink-soft); }
|
||||
.chip-sm.is-active{ background:var(--ink); color:var(--paper); border-color:var(--ink); }
|
||||
|
||||
@@ -24,9 +24,30 @@ class EventController extends AbstractController
|
||||
{
|
||||
$q = trim((string) $request->query->get('q', ''));
|
||||
$category = $request->query->get('category') ?: null;
|
||||
$when = $request->query->get('when') ?: 'any';
|
||||
$page = max(1, $request->query->getInt('page', 1));
|
||||
|
||||
// A from/to range (inclusive days, "Y-m-d"); either end may be left
|
||||
// open. The page's "Today"/"Next 7 days" chips are just links to
|
||||
// such a range.
|
||||
$today = new \DateTimeImmutable('today', new \DateTimeZone('Europe/Berlin'));
|
||||
$dateFrom = $this->parseDay($request->query->get('from'));
|
||||
$dateTo = $this->parseDay($request->query->get('to'));
|
||||
if ($dateFrom && $dateTo && $dateFrom > $dateTo) {
|
||||
[$dateFrom, $dateTo] = [$dateTo, $dateFrom];
|
||||
}
|
||||
|
||||
// Legacy ?when= links still work: they just become a from/to range,
|
||||
// so the page only ever deals with one kind of date filter.
|
||||
if (null === $dateFrom && null === $dateTo) {
|
||||
$when = $request->query->get('when');
|
||||
if ('today' === $when) {
|
||||
$dateFrom = $dateTo = $today->format('Y-m-d');
|
||||
} elseif ('week' === $when) {
|
||||
$dateFrom = $today->format('Y-m-d');
|
||||
$dateTo = $today->modify('+6 days')->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
// Location filtering is opt-in — plain text/category browsing
|
||||
// behaves exactly as before unless a place/"use my location"
|
||||
// actually set lat/lon.
|
||||
@@ -58,8 +79,8 @@ class EventController extends AbstractController
|
||||
}
|
||||
|
||||
$offset = ($page - 1) * self::PER_PAGE;
|
||||
$items = $events->search($q ?: null, $category, $when, self::PER_PAGE, $offset, $locationIds, $sort);
|
||||
$total = $events->countSearch($q ?: null, $category, $when, $locationIds);
|
||||
$items = $events->search($q ?: null, $category, $dateFrom, $dateTo, self::PER_PAGE, $offset, $locationIds, $sort);
|
||||
$total = $events->countSearch($q ?: null, $category, $dateFrom, $dateTo, $locationIds);
|
||||
|
||||
return $this->render('events/list.html.twig', [
|
||||
'activeNav' => 'events',
|
||||
@@ -67,7 +88,10 @@ class EventController extends AbstractController
|
||||
'categories' => $categories->findUsedNames(),
|
||||
'activeCategory' => $category,
|
||||
'q' => $q,
|
||||
'when' => $when,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'today' => $today->format('Y-m-d'),
|
||||
'weekEnd' => $today->modify('+6 days')->format('Y-m-d'),
|
||||
'hasLocation' => $hasLocation,
|
||||
'lat' => $lat,
|
||||
'lon' => $lon,
|
||||
@@ -81,7 +105,8 @@ class EventController extends AbstractController
|
||||
'filters' => [
|
||||
'q' => $q ?: null,
|
||||
'category' => $category,
|
||||
'when' => 'any' !== $when ? $when : null,
|
||||
'from' => $dateFrom,
|
||||
'to' => $dateTo,
|
||||
'lat' => $hasLocation ? $lat : null,
|
||||
'lon' => $hasLocation ? $lon : null,
|
||||
'range' => $hasLocation ? $rangeKm : null,
|
||||
@@ -93,6 +118,17 @@ class EventController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/** Valid "Y-m-d" or null — garbage in the query string just means "no bound". */
|
||||
private function parseDay(mixed $value): ?string
|
||||
{
|
||||
if (!\is_string($value) || '' === $value) {
|
||||
return null;
|
||||
}
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
|
||||
return $date && $date->format('Y-m-d') === $value ? $value : null;
|
||||
}
|
||||
|
||||
#[Route('/events/{id}', name: 'event_show', requirements: ['id' => '\d+'])]
|
||||
public function show(int $id, EventRepository $events, EventMapSerializer $mapSerializer): Response
|
||||
{
|
||||
|
||||
@@ -56,7 +56,7 @@ class EventRepository extends ServiceEntityRepository
|
||||
*/
|
||||
public function distancesWithinRadius(float $lat, float $lon, float $radiusKm, int $limit = 500): array
|
||||
{
|
||||
// end_date comparison mirrors notEnded()/filterByWhen(): day-level
|
||||
// end_date comparison mirrors notEnded()/filterByDateRange(): day-level
|
||||
// lexicographic comparison against the raw ISO-ish string column,
|
||||
// in Europe/Berlin — this is raw SQL (DBAL, not the QueryBuilder),
|
||||
// so it can't just call that helper directly.
|
||||
@@ -124,7 +124,7 @@ class EventRepository extends ServiceEntityRepository
|
||||
*
|
||||
* @return Event[]
|
||||
*/
|
||||
public function search(?string $q, ?string $category, string $when, int $limit, int $offset, ?array $locationIds = null, string $sort = 'soonest'): array
|
||||
public function search(?string $q, ?string $category, ?string $dateFrom, ?string $dateTo, int $limit, int $offset, ?array $locationIds = null, string $sort = 'soonest'): array
|
||||
{
|
||||
// "Nearest" only means anything once a location narrowed the pool.
|
||||
if ('nearest' === $sort && null === $locationIds) {
|
||||
@@ -137,7 +137,7 @@ class EventRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
$matchingIds = array_map('intval', array_column(
|
||||
$this->baseSearchQb($q, $category, $when)
|
||||
$this->baseSearchQb($q, $category, $dateFrom, $dateTo)
|
||||
->select('e.id')
|
||||
->andWhere('e.id IN (:locationIds)')
|
||||
->setParameter('locationIds', $locationIds)
|
||||
@@ -156,7 +156,7 @@ class EventRepository extends ServiceEntityRepository
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->baseSearchQb($q, $category, $when);
|
||||
$qb = $this->baseSearchQb($q, $category, $dateFrom, $dateTo);
|
||||
if (null !== $locationIds) {
|
||||
$qb->andWhere('e.id IN (:locationIds)')->setParameter('locationIds', $locationIds);
|
||||
}
|
||||
@@ -178,13 +178,13 @@ class EventRepository extends ServiceEntityRepository
|
||||
*
|
||||
* @param int[]|null $locationIds
|
||||
*/
|
||||
public function countSearch(?string $q, ?string $category, string $when, ?array $locationIds = null): int
|
||||
public function countSearch(?string $q, ?string $category, ?string $dateFrom, ?string $dateTo, ?array $locationIds = null): int
|
||||
{
|
||||
if (null !== $locationIds && !$locationIds) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$qb = $this->baseSearchQb($q, $category, $when)->select('COUNT(DISTINCT e.id)');
|
||||
$qb = $this->baseSearchQb($q, $category, $dateFrom, $dateTo)->select('COUNT(DISTINCT e.id)');
|
||||
|
||||
if (null !== $locationIds) {
|
||||
$qb->andWhere('e.id IN (:locationIds)')->setParameter('locationIds', $locationIds);
|
||||
@@ -193,7 +193,7 @@ class EventRepository extends ServiceEntityRepository
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
private function baseSearchQb(?string $q, ?string $category, string $when): QueryBuilder
|
||||
private function baseSearchQb(?string $q, ?string $category, ?string $dateFrom, ?string $dateTo): QueryBuilder
|
||||
{
|
||||
$qb = $this->notEnded($this->notDeleted());
|
||||
|
||||
@@ -203,45 +203,47 @@ class EventRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
$this->filterByCategory($qb, $category);
|
||||
$this->filterByWhen($qb, $when);
|
||||
$this->filterByDateRange($qb, $dateFrom, $dateTo);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* startDate is a raw ISO-8601-ish string ("2026-09-12T18:00:00+02:00"),
|
||||
* consistently formatted across the imported dataset, so a plain
|
||||
* lexicographic string comparison against a "Y-m-d" boundary sorts
|
||||
* correctly without needing to parse it as a real date/time column.
|
||||
* Keeps events that are happening at some point within the inclusive
|
||||
* day range [$dateFrom, $dateTo] ("Y-m-d", either end optional) — i.e.
|
||||
* the event overlaps the window, so a multi-day exhibition shows up
|
||||
* for any day it's running, not just the day it opened.
|
||||
*
|
||||
* start_date/end_date are raw ISO-8601-ish strings
|
||||
* ("2026-09-12T18:00:00+02:00"), consistently formatted across the
|
||||
* imported dataset, so a plain lexicographic comparison against a
|
||||
* "Y-m-d" boundary sorts correctly without parsing them as real
|
||||
* date/time columns. The upper bound compares against the *next* day
|
||||
* with "<", since "2026-09-12T18:00" > "2026-09-12" as strings.
|
||||
* Applied in SQL (not PHP, post-fetch) so it composes correctly with
|
||||
* pagination and COUNT — filtering after LIMIT/OFFSET would make the
|
||||
* page size and total count lie.
|
||||
* page size and total count lie. Undated events (blank start_date)
|
||||
* are excluded once a range is active; a blank end_date falls back to
|
||||
* the start alone.
|
||||
*/
|
||||
private function filterByWhen(QueryBuilder $qb, string $when): void
|
||||
private function filterByDateRange(QueryBuilder $qb, ?string $dateFrom, ?string $dateTo): void
|
||||
{
|
||||
if ('any' === $when) {
|
||||
if (!$dateFrom && !$dateTo) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Explicit timezone, not the container's (UTC): the imported dates
|
||||
// and the site's audience are both German-local, so "today" needs
|
||||
// to mean the German calendar day, not whatever day it happens to
|
||||
// be in UTC — those disagree for two hours after every UTC
|
||||
// midnight.
|
||||
$start = new \DateTimeImmutable('today', new \DateTimeZone('Europe/Berlin'));
|
||||
$end = match ($when) {
|
||||
'today' => $start->modify('+1 day'),
|
||||
'week' => $start->modify('+7 days'),
|
||||
default => null,
|
||||
};
|
||||
$qb->andWhere("e.startDate <> ''");
|
||||
|
||||
if (!$end) {
|
||||
return;
|
||||
if ($dateTo) {
|
||||
$dayAfterTo = (new \DateTimeImmutable($dateTo, new \DateTimeZone('Europe/Berlin')))->modify('+1 day');
|
||||
$qb->andWhere('e.startDate < :rangeToExclusive')
|
||||
->setParameter('rangeToExclusive', $dayAfterTo->format('Y-m-d'));
|
||||
}
|
||||
|
||||
$qb->andWhere('e.startDate >= :whenStart AND e.startDate < :whenEnd')
|
||||
->setParameter('whenStart', $start->format('Y-m-d'))
|
||||
->setParameter('whenEnd', $end->format('Y-m-d'));
|
||||
if ($dateFrom) {
|
||||
$qb->andWhere("e.endDate >= :rangeFrom OR (e.endDate = '' AND e.startDate >= :rangeFrom)")
|
||||
->setParameter('rangeFrom', $dateFrom);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,8 +291,8 @@ class EventRepository extends ServiceEntityRepository
|
||||
|
||||
/**
|
||||
* Hides events that have already ended — compared by calendar day
|
||||
* (Europe/Berlin), the same precision filterByWhen() uses for
|
||||
* "today"/"week", not exact time (end_date is a raw ISO-ish string,
|
||||
* (Europe/Berlin), the same precision filterByDateRange() uses,
|
||||
* not exact time (end_date is a raw ISO-ish string,
|
||||
* not a real date/time column). Blank end_date is treated as "still
|
||||
* current" rather than excluded, matching orderByStartDate()'s
|
||||
* defensive handling of blank date strings.
|
||||
|
||||
@@ -34,6 +34,12 @@ class EventMapSerializer
|
||||
'category' => $event->getCategoryNames()[0] ?? null,
|
||||
'lat' => $event->getLatitude(),
|
||||
'lon' => $event->getLongitude(),
|
||||
// "Y-m-d" prefixes of the raw ISO start/end strings — enough for
|
||||
// map.js's day-range filter (same overlap rule as
|
||||
// EventRepository::filterByDateRange), without shipping full
|
||||
// timestamps for thousands of pins.
|
||||
'start' => substr((string) $event->getStartDate(), 0, 10) ?: null,
|
||||
'end' => substr((string) $event->getEndDate(), 0, 10) ?: null,
|
||||
'url' => $this->urlGenerator->generate('event_show', ['id' => $event->getId()]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFilter;
|
||||
|
||||
/**
|
||||
* German presentation of dates and numbers (dd.mm.yyyy, decimal comma, dot
|
||||
* as thousands separator). Done by hand instead of via the intl extension —
|
||||
* it isn't installed in either container image, and since dates are shown
|
||||
* numerically only the weekday abbreviation needs translating.
|
||||
*/
|
||||
class GermanFormatExtension extends AbstractExtension
|
||||
{
|
||||
private const WEEKDAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
|
||||
public function getFilters(): array
|
||||
{
|
||||
return [
|
||||
new TwigFilter('de_datetime', $this->datetime(...)),
|
||||
new TwigFilter('de_number', $this->number(...)),
|
||||
new TwigFilter('de_distance', $this->distance(...)),
|
||||
];
|
||||
}
|
||||
|
||||
/** "Do, 16.10.2026 · 18:00" — always in German local time. */
|
||||
public function datetime(?\DateTimeInterface $date): string
|
||||
{
|
||||
if (!$date) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$local = \DateTimeImmutable::createFromInterface($date)->setTimezone(new \DateTimeZone('Europe/Berlin'));
|
||||
|
||||
return self::WEEKDAYS[(int) $local->format('w')].', '.$local->format('d.m.Y · H:i');
|
||||
}
|
||||
|
||||
public function number(int|float|null $value, int $decimals = 0): string
|
||||
{
|
||||
return number_format((float) $value, $decimals, ',', '.');
|
||||
}
|
||||
|
||||
/** "250 m", "1,7 km", "24 km". */
|
||||
public function distance(float $km): string
|
||||
{
|
||||
if ($km < 1) {
|
||||
return $this->number(round($km * 1000)).' m';
|
||||
}
|
||||
if ($km < 10) {
|
||||
$rounded = round($km, 1);
|
||||
|
||||
return $this->number($rounded, floor($rounded) === $rounded ? 0 : 1).' km';
|
||||
}
|
||||
|
||||
return $this->number(round($km)).' km';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{# German-format date pickers (see public/js/dates.js) — only the pages that have a date range include this. #}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.css">
|
||||
@@ -0,0 +1,3 @@
|
||||
{# Must load before the page's own init script — VindnDates.bindInputs() looks for window.flatpickr at call time. #}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/l10n/de.js"></script>
|
||||
@@ -1,5 +1,5 @@
|
||||
{#
|
||||
Reusable interactive map stage: pin map, category filter, "use my
|
||||
Reusable interactive map stage: pin map, category and date filters, "use my
|
||||
location" button, and a nearby-events panel. Include on any page that
|
||||
has defined `mapEventsJson` and `categories` in scope, then call
|
||||
Vindn.initMapStage() (home) or Vindn.initRadiusMapStage() (/map) from
|
||||
@@ -14,16 +14,23 @@
|
||||
<div id="mapCanvas" role="application" aria-label="Map of events"></div>
|
||||
|
||||
<div class="map-overlay-top">
|
||||
{% if not compact %}
|
||||
<div class="float-card search-card">
|
||||
<div class="chip-row-tight" id="catChips">
|
||||
<div class="float-card search-card">
|
||||
{% if not compact %}
|
||||
<div class="chip-row-tight" id="catChips" role="group" aria-label="Categories">
|
||||
<button class="chip-sm is-active" data-cat="all">All</button>
|
||||
{% for category in categories %}
|
||||
<button class="chip-sm" data-cat="{{ category }}">{{ category }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="date-range" role="group" aria-label="Date range">
|
||||
<label for="dateFrom">From</label>
|
||||
<input type="date" id="dateFrom" aria-label="From date" min="{{ 'now'|date('Y-m-d', 'Europe/Berlin') }}">
|
||||
<label for="dateTo">to</label>
|
||||
<input type="date" id="dateTo" aria-label="To date" min="{{ 'now'|date('Y-m-d', 'Europe/Berlin') }}">
|
||||
<button type="button" id="dateClear" class="date-clear" hidden>clear</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="float-card locate-card">
|
||||
<form id="locationSearchForm">
|
||||
<label for="locationSearch" style="display:none;">Search a place</label>
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
</footer>
|
||||
|
||||
<script src="{{ asset('js/location.js') }}"></script>
|
||||
<script src="{{ asset('js/dates.js') }}"></script>
|
||||
<script src="{{ asset('js/nav.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
<span class="tag" style="background:{{ category|category_bg }}; color:{{ category|category_color }};">{{ category }}</span>
|
||||
{% endif %}
|
||||
<h3>{{ event.title }}</h3>
|
||||
<p class="when">{{ event.startDateTime ? event.startDateTime|date('D, M j · H:i') : 'Date TBD' }}</p>
|
||||
<p class="when">{{ event.startDateTime ? event.startDateTime|de_datetime : 'Date TBD' }}</p>
|
||||
<p class="place">
|
||||
{{ [event.street, event.city]|filter(v => v)|join(', ') ?: event.country }}
|
||||
{% if dist is defined and dist is not null %}
|
||||
· {{ dist < 1 ? (dist * 1000)|round ~ ' m' : (dist < 10 ? dist|round(1) : dist|round) ~ ' km' }} away
|
||||
· {{ dist|de_distance }} away
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3,20 +3,74 @@
|
||||
{% block title %}Browse events — VINDN{% 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:20px; }
|
||||
.search-input-bar input{ flex:1; padding:16px 20px; border:1px solid var(--line-strong); border-radius: var(--r-pill); font-size:15px; }
|
||||
.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;
|
||||
}
|
||||
.filter-row{ display:flex; align-items:flex-start; gap:24px; flex-wrap:wrap; margin-bottom:18px; }
|
||||
.filter-group-inline{ display:flex; flex-direction:column; }
|
||||
|
||||
/* 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 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 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 %}
|
||||
|
||||
@@ -27,7 +81,7 @@
|
||||
<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>
|
||||
<p class="mono" style="font-size:13px; color:var(--muted); margin:0;">{{ total|de_number }} 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>
|
||||
@@ -40,10 +94,10 @@
|
||||
</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 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 }}">
|
||||
@@ -54,54 +108,95 @@
|
||||
<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 %}
|
||||
{% 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">Filters{% 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="Remove category filter {{ activeCategory }}">{{ activeCategory }}<span class="x" aria-hidden="true">×</span></a>
|
||||
{% endif %}
|
||||
{% if dateFrom or dateTo %}
|
||||
{% if dateFrom == today and dateTo == today %}{% set dateLabel = 'Today' %}
|
||||
{% elseif dateFrom == today and dateTo == weekEnd %}{% set dateLabel = 'Next 7 days' %}
|
||||
{% elseif dateFrom and dateTo and dateFrom == dateTo %}{% set dateLabel = dateFrom|date('d.m.Y') %}
|
||||
{% elseif dateFrom and dateTo %}{% set dateLabel = dateFrom|date('d.m.Y') ~ ' – ' ~ dateTo|date('d.m.Y') %}
|
||||
{% elseif dateFrom %}{% set dateLabel = 'From ' ~ dateFrom|date('d.m.Y') %}
|
||||
{% else %}{% set dateLabel = 'Until ' ~ dateTo|date('d.m.Y') %}{% endif %}
|
||||
<a class="filter-pill" data-forget="dates" href="{{ path('event_list', filters|merge({from: null, to: null})) }}" aria-label="Remove date filter {{ 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="Remove location filter"><span class="label">Within {{ rangeKm }} km of {{ lat|de_number(3) }}, {{ lon|de_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}) }}">Clear all</a>
|
||||
{% endif %}
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<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">Filters</h2>
|
||||
<button type="button" class="fd-close" id="closeFilters" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="fd-body">
|
||||
<div class="fd-section">
|
||||
<span class="filter-label">Category</span>
|
||||
<div class="pick-row">
|
||||
<label class="pick"><input type="radio" name="category" value="" {{ activeCategory is null ? 'checked' : '' }}><span>All</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="filter-group-inline">
|
||||
<div class="fd-section">
|
||||
<span class="filter-label">When</span>
|
||||
<div class="when-filter">
|
||||
<div class="chip-row-tight" id="datePresets">
|
||||
<button type="button" class="chip-sm" data-from="" data-to="">Any date</button>
|
||||
<button type="button" class="chip-sm" data-from="{{ today }}" data-to="{{ today }}">Today</button>
|
||||
<button type="button" class="chip-sm" data-from="{{ today }}" data-to="{{ weekEnd }}">Next 7 days</button>
|
||||
</div>
|
||||
<div class="date-range" role="group" aria-label="Date range">
|
||||
<label for="dateFrom">From</label>
|
||||
<input type="date" id="dateFrom" name="from" aria-label="From date" min="{{ today }}" value="{{ dateFrom }}">
|
||||
<label for="dateTo">to</label>
|
||||
<input type="date" id="dateTo" name="to" aria-label="To date" min="{{ today }}" value="{{ dateTo }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fd-section">
|
||||
<span class="filter-label">Near a location</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">
|
||||
<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>
|
||||
<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="button" id="locationGo">Go</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="useMyLocationBtn">Use my location</button>
|
||||
<select id="rangeSelect">
|
||||
<label for="rangeSelect" style="display:none;">Radius</label>
|
||||
<select id="rangeSelect" name="range">
|
||||
{% 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 %}
|
||||
<button type="button" class="fd-link" id="clearLocation" {{ hasLocation ? '' : 'hidden' }}>clear</button>
|
||||
</div>
|
||||
<p class="locate-status" id="locationStatus">{% if hasLocation %}Within {{ rangeKm }} km of {{ lat|de_number(3) }}, {{ lon|de_number(3) }}{% endif %}</p>
|
||||
</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>
|
||||
|
||||
<div class="fd-foot">
|
||||
<button type="button" class="fd-link" id="resetFilters">Reset all</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Show events</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<section class="section" style="padding-top:0;">
|
||||
<div class="wrap">
|
||||
@@ -127,91 +222,188 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% include '_datepicker_scripts.html.twig' %}
|
||||
<script>
|
||||
(function(){
|
||||
var currentLat = {{ hasLocation ? lat : 'null' }};
|
||||
var currentLon = {{ hasLocation ? lon : 'null' }};
|
||||
// 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');
|
||||
|
||||
// Storage/search/GPS lookups live in window.VindnLocation (location.js,
|
||||
// loaded globally in base.html.twig) — shared with the home hero map and
|
||||
// /map, so a search/geolocation made on any of the three sticks when
|
||||
// moving between them.
|
||||
var saved = window.VindnLocation.load();
|
||||
var savedDates = window.VindnDates.load();
|
||||
var search = window.location.search;
|
||||
|
||||
function currentRangeKm() {
|
||||
return rangeSelect ? parseInt(rangeSelect.value, 10) : {{ rangeKm }};
|
||||
}
|
||||
// ---- popup open/close ----
|
||||
document.getElementById('openFilters').addEventListener('click', function () { dialog.showModal(); });
|
||||
|
||||
// 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;
|
||||
// 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();
|
||||
}
|
||||
window.VindnLocation.save(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();
|
||||
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 = 'Within ' + rangeSelect.value + ' km of ' + saved.name.split(',').slice(0, 2).join(',');
|
||||
}
|
||||
|
||||
function setStatus(text, isError) {
|
||||
var el = document.getElementById('locationStatus');
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.toggle('is-error', !!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('Found: ' + (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('Looking up "' + query + '"…', false); },
|
||||
onNotFound: function () { setStatus('Could not find "' + query + '"', true); },
|
||||
onFound: function (lat, lon, name) { setLocation(lat, lon, name); if (done) done(); },
|
||||
onError: function () { setStatus('Location search failed', true); }
|
||||
});
|
||||
}
|
||||
|
||||
var locationForm = document.getElementById('locationSearchForm');
|
||||
if (locationForm) {
|
||||
locationForm.addEventListener('submit', function (e) {
|
||||
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('Location not supported by your browser', true); },
|
||||
onStart: function () { setStatus('Finding you…', false); },
|
||||
onFound: setLocation,
|
||||
onError: function () { setStatus("Couldn't get your location", 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();
|
||||
var input = document.getElementById('locationSearchInput');
|
||||
var query = (input.value || '').trim();
|
||||
if (!query) return;
|
||||
window.VindnLocation.searchPlace(query, {
|
||||
onStart: function () { setStatus('Looking up "' + query + '"…', false); },
|
||||
onNotFound: function () { setStatus('Could not find "' + query + '"', true); },
|
||||
onFound: function (lat, lon, name) { navigateWithLocation(lat, lon, currentRangeKm(), name); },
|
||||
onError: function () { setStatus('Location search failed', true); }
|
||||
});
|
||||
});
|
||||
}
|
||||
lookup(typed, function () { form.requestSubmit(); });
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
|
||||
var useMyLocationBtn = document.getElementById('useMyLocationBtn');
|
||||
if (useMyLocationBtn) {
|
||||
useMyLocationBtn.addEventListener('click', function () {
|
||||
window.VindnLocation.useMyLocation({
|
||||
onUnsupported: function () { setStatus('Location not supported by your browser', true); },
|
||||
onStart: function () { setStatus('Finding you…', false); },
|
||||
onFound: function (lat, lon, name) { navigateWithLocation(lat, lon, currentRangeKm(), name); },
|
||||
onError: function () { setStatus("Couldn't get your location", 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 (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());
|
||||
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;
|
||||
});
|
||||
|
||||
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 () { window.VindnLocation.clear(); });
|
||||
}
|
||||
// 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) {
|
||||
@@ -221,21 +413,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
var saved = window.VindnLocation.load();
|
||||
|
||||
// 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);
|
||||
// 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>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
{% if category %}<span class="tag" style="background:{{ category|category_bg }}; color:{{ category|category_color }};">{{ category }}</span>{% endif %}
|
||||
<h1 style="font-size:clamp(28px,4vw,42px); margin-top:14px; max-width:640px;">{{ event.title }}</h1>
|
||||
<p class="mono" style="color:var(--muted); margin-top:10px; font-size:13px;">
|
||||
{{ event.startDateTime ? event.startDateTime|date('D, M j · H:i')|upper : 'DATE TBD' }}
|
||||
{{ event.startDateTime ? event.startDateTime|de_datetime|upper : 'DATE TBD' }}
|
||||
{% if event.city %} · {{ event.city|upper }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
@@ -85,9 +85,9 @@
|
||||
|
||||
<!-- sticky info board -->
|
||||
<aside class="info-board" aria-label="Event details">
|
||||
<div class="row"><span class="k">Starts</span><span class="v">{{ event.startDateTime ? event.startDateTime|date('D, M j · H:i') : 'TBD' }}</span></div>
|
||||
<div class="row"><span class="k">Starts</span><span class="v">{{ event.startDateTime ? event.startDateTime|de_datetime : 'TBD' }}</span></div>
|
||||
{% if event.endDateTime %}
|
||||
<div class="row"><span class="k">Ends</span><span class="v">{{ event.endDateTime|date('D, M j · H:i') }}</span></div>
|
||||
<div class="row"><span class="k">Ends</span><span class="v">{{ event.endDateTime|de_datetime }}</span></div>
|
||||
{% endif %}
|
||||
{% if event.city %}<div class="row"><span class="k">City</span><span class="v">{{ event.city }}</span></div>{% endif %}
|
||||
{% if event.type %}<div class="row"><span class="k">Type</span><span class="v">{{ event.type }}</span></div>{% endif %}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
{% block head_extra %}
|
||||
{% include '_leaflet_head.html.twig' %}
|
||||
{% include '_datepicker_head.html.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
@@ -99,6 +100,7 @@
|
||||
|
||||
{% block scripts %}
|
||||
{% include '_leaflet_scripts.html.twig' %}
|
||||
{% include '_datepicker_scripts.html.twig' %}
|
||||
<script type="application/json" id="map-data">{{ mapEventsJson|raw }}</script>
|
||||
<script>
|
||||
Vindn.initMapStage({
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
{% block head_extra %}
|
||||
{% include '_leaflet_head.html.twig' %}
|
||||
{% include '_datepicker_head.html.twig' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
@@ -12,6 +13,7 @@
|
||||
|
||||
{% block scripts %}
|
||||
{% include '_leaflet_scripts.html.twig' %}
|
||||
{% include '_datepicker_scripts.html.twig' %}
|
||||
<script type="application/json" id="map-data">{{ mapEventsJson|raw }}</script>
|
||||
<script>
|
||||
Vindn.initRadiusMapStage({
|
||||
|
||||
Reference in new issue
Block a user