added frontend and made it usable

This commit is contained in:
Hendrik Behme committed 2026-08-15 14:50:42 +02:00
1 parent 96408f00d8
commit b9b15152bc
50 files changed
+7974 -1

No files matched your search

@@ -0,0 +1,10 @@
{# Leaflet + plugin stylesheets, shared by every page that renders a map. #}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.fullscreen/3.0.2/Control.FullScreen.min.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet-gesture-handling@1.2.2/dist/leaflet-gesture-handling.min.css">
{# OpenFreeMap serves vector tiles only — MapLibre GL renders them, bridged into our Leaflet map via maplibre-gl-leaflet. #}
<link href="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.Default.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet.awesome-markers@2.0.5/dist/leaflet.awesome-markers.css">
@@ -0,0 +1,11 @@
{# Leaflet + plugins + our map.js wrapper, shared by every page that renders a map. #}
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
{# Requires a two-finger drag / Ctrl+scroll to pan/zoom on touch/trackpad, with an on-map hint, so the map doesn't trap page scroll. #}
<script src="https://unpkg.com/leaflet-gesture-handling@1.2.2/dist/leaflet-gesture-handling.min.js"></script>
{# OpenFreeMap vector tiles: MapLibre GL does the rendering, maplibre-gl-leaflet bridges it into our Leaflet map as an ordinary layer. #}
<script src="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/@maplibre/maplibre-gl-leaflet/leaflet-maplibre-gl.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.fullscreen/3.0.2/Control.FullScreen.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/leaflet.markercluster.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet.awesome-markers@2.0.5/dist/leaflet.awesome-markers.min.js"></script>
<script src="{{ asset('js/map.js') }}"></script>
+69
View File
@@ -0,0 +1,69 @@
{#
Reusable interactive map stage: pin map, event search box, "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
that page's scripts block.
Pass `compact: true` for the lighter home-page hero variant: hides the
category chips/legend and the location-search/range controls, and uses
a contained card height instead of the full-viewport /map takeover.
#}
{% set compact = compact|default(false) %}
<section class="stage{{ compact ? ' stage-compact' : '' }}">
<div id="mapCanvas" role="application" aria-label="Map of events"></div>
<div class="map-overlay-top">
<div class="float-card search-card">
<form action="{{ path('search') }}">
<label for="mapSearch" style="display:none;">Search events</label>
<input id="mapSearch" name="q" type="text" placeholder="Search events…">
<button class="btn btn-primary btn-sm" type="submit">Search</button>
</form>
{% if not compact %}
<div class="chip-row-tight" id="catChips">
<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>
<div class="float-card locate-card">
{% if not compact %}
<form id="locationSearchForm">
<label for="locationSearch" style="display:none;">Search a place</label>
<input id="locationSearch" type="text" placeholder="Search a place…" autocomplete="off">
<button class="btn btn-ghost btn-sm" type="submit" aria-label="Center map on this place">Go</button>
</form>
{% endif %}
<button class="locate-btn" id="locateBtn" type="button">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/></svg>
Use my location
</button>
{% if not compact %}
<label class="range-label">
Range
<select id="rangeSelect">
{% for km in rangeOptions %}
<option value="{{ km }}" {{ km == rangeKm ? 'selected' : '' }}>{{ km }} km</option>
{% endfor %}
</select>
</label>
{% endif %}
<p class="locate-status" id="locateStatus">{{ compact ? 'Showing events near Nordrhein-Westfalen' : 'Showing events within ' ~ rangeKm ~ ' km' }}</p>
</div>
</div>
{% if not compact %}
<div class="legend float-card" id="legend"></div>
{% endif %}
<aside class="panel board" aria-label="Events">
<div class="panel-head">
<span class="eyebrow" style="color:var(--board-muted);">{{ compact ? 'Nearest to you' : 'Events' }}</span>
<span class="count" id="resultCount">— events</span>
</div>
<div class="panel-list" id="panelList"></div>
</aside>
</section>
+24
View File
@@ -0,0 +1,24 @@
{#
Compact pager: first / previous / current / next / last — deliberately
not a full list of page numbers, since result sets here can run into
the hundreds of pages.
Params:
currentPage, totalPages: integers
routeName: the route to link back to
routeParams: all params to preserve *except* `page` (e.g. category,
q, when, lat, lon, range)
#}
{% if totalPages > 1 %}
<nav class="pager" aria-label="Pagination">
{% if currentPage > 1 %}
<a href="{{ path(routeName, routeParams|merge({page: 1})) }}" aria-label="First page">« First</a>
<a href="{{ path(routeName, routeParams|merge({page: currentPage - 1})) }}" aria-label="Previous page">‹ Prev</a>
{% endif %}
<span class="is-active">Page {{ currentPage }} of {{ totalPages }}</span>
{% if currentPage < totalPages %}
<a href="{{ path(routeName, routeParams|merge({page: currentPage + 1})) }}" aria-label="Next page">Next ›</a>
<a href="{{ path(routeName, routeParams|merge({page: totalPages})) }}" aria-label="Last page">Last »</a>
{% endif %}
</nav>
{% endif %}
+67
View File
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}VINDN — Find what's on{% endblock %}</title>
<link rel="stylesheet" href="{{ asset('styles.css') }}">
{% block head_extra %}{% endblock %}
</head>
<body>
<a href="#main" class="skip-link">Skip to content</a>
<header class="site-header">
<div class="wrap">
<a href="{{ path('home') }}" class="brand" aria-label="VINDN home">
<img src="{{ asset('images/vindn_logo_adobe.svg') }}" alt="VINDN">
</a>
<nav class="main-nav" aria-label="Primary">
<a href="{{ path('home') }}" class="{{ activeNav == 'home' ? 'is-active' : '' }}">Home</a>
<a href="{{ path('event_list') }}" class="{{ activeNav == 'events' ? 'is-active' : '' }}">Browse events</a>
<a href="{{ path('map') }}" class="{{ activeNav == 'map' ? 'is-active' : '' }}">Map</a>
<a href="{{ path('search') }}" class="{{ activeNav == 'search' ? 'is-active' : '' }}">Search</a>
</nav>
<div class="nav-actions">
<form class="nav-search" action="{{ path('search') }}" role="search">
<button type="submit" aria-label="Search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
</button>
<label for="navSearchInput" style="display:none;">Search events</label>
<input id="navSearchInput" type="text" name="q" placeholder="Search events…" autocomplete="off" value="{{ app.request.query.get('q') }}">
</form>
<button class="nav-burger" aria-label="Open menu" aria-expanded="false" aria-controls="mobileMenu"><span></span><span></span><span></span></button>
</div>
</div>
<div class="mobile-menu" id="mobileMenu" hidden>
<nav aria-label="Primary">
<a href="{{ path('home') }}" class="{{ activeNav == 'home' ? 'is-active' : '' }}">Home</a>
<a href="{{ path('event_list') }}" class="{{ activeNav == 'events' ? 'is-active' : '' }}">Browse events</a>
<a href="{{ path('map') }}" class="{{ activeNav == 'map' ? 'is-active' : '' }}">Map</a>
<a href="{{ path('search') }}" class="{{ activeNav == 'search' ? 'is-active' : '' }}">Search</a>
</nav>
<form class="mobile-search" action="{{ path('search') }}" role="search">
<label for="mobileSearchInput" style="display:none;">Search events</label>
<input id="mobileSearchInput" type="text" name="q" placeholder="Search events…" autocomplete="off" value="{{ app.request.query.get('q') }}">
<button type="submit">Search</button>
</form>
</div>
</header>
<main id="main">
{% block body %}{% endblock %}
</main>
<footer class="site-footer">
<div class="wrap">
{% block footer %}
<div class="footer-bottom" style="border-top:none; margin-top:0; padding-top:0;">
<span>© 2026 VINDN</span>
</div>
{% endblock %}
</div>
</footer>
<script src="{{ asset('js/nav.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
{% set category = event.categoryNames|first %}
{% set image = event.primaryImage %}
<a class="event-card" href="{{ path('event_show', {id: event.id}) }}">
{% if image %}
<div class="glyph-tile" style="padding:0;">
<img src="{{ image.storagePath }}" alt="" loading="lazy" style="position:absolute; inset:0; width:100%; height:100%; object-fit:cover;">
</div>
{% else %}
<div class="glyph-tile" style="background:{{ category|category_bg }}; color:{{ category|category_color }};">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/></svg>
</div>
{% endif %}
<div class="body">
{% if category %}
<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="place">{{ [event.street, event.city]|filter(v => v)|join(', ') ?: event.country }}</p>
</div>
</a>
+55
View File
@@ -0,0 +1,55 @@
{% 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; }
</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);">What's on{{ activeCategory ? ' — ' ~ activeCategory : '' }}</h1>
<p class="mono" style="font-size:13px; color:var(--muted);">{{ total }} event{{ total == 1 ? '' : 's' }}</p>
</div>
<input type="checkbox" id="catsToggle" class="chip-toggle-checkbox">
<div class="chip-row chip-row-collapsible">
<a href="{{ path('event_list') }}" class="chip {{ activeCategory is null ? 'is-active' : '' }}">All</a>
{% for category in categories %}
<a href="{{ path('event_list', {category: category}) }}" class="chip {{ activeCategory == category ? 'is-active' : '' }}">{{ category }}</a>
{% endfor %}
</div>
<label for="catsToggle" class="chip-toggle-btn" style="margin-bottom:32px;">
<span class="show-more">Show all categories ↑</span>
<span class="show-less">Show fewer categories ↑</span>
</label>
</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 } %}
{% 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 this filter yet — try "All" 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: { category: activeCategory }
} %}
</div>
</section>
{% endblock %}
+113
View File
@@ -0,0 +1,113 @@
{% extends 'base.html.twig' %}
{% set category = event.categoryNames|first %}
{% set image = event.primaryImage %}
{% block title %}{{ event.title }} — VINDN{% endblock %}
{% block head_extra %}
{% include '_leaflet_head.html.twig' %}
<style>
.breadcrumb{ font-family:var(--f-mono); font-size:12px; color:var(--muted); margin-bottom:18px; }
.breadcrumb a:hover{ color:var(--accent); }
.hero-tile{
aspect-ratio: 16/6; border-radius:var(--r-card); position:relative; overflow:hidden;
display:flex; align-items:center; justify-content:center;
}
.hero-tile img{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
.hero-tile svg{ width:18%; opacity:.9; }
.detail-grid{ display:grid; grid-template-columns: 1fr 360px; gap:44px; margin-top:36px; align-items:start; }
.detail-grid h2{ font-size:22px; margin-bottom:14px; }
.prose p{ color:var(--ink-soft); margin:0 0 16px; font-size:15.5px; line-height:1.7; }
.info-board{ background:var(--board-bg); border-radius:var(--r-card); color:var(--board-text); padding:24px; position:sticky; top:96px; }
.info-board .row{ display:flex; justify-content:space-between; gap:12px; padding:13px 0; border-bottom:1px solid #2B2B22; font-size:13px; }
.info-board .row:first-child{ padding-top:0; }
.info-board .row:last-child{ border-bottom:none; }
.info-board .row .k{ font-family:var(--f-mono); color:var(--board-muted); flex:none; }
.info-board .row .v{ font-family:var(--f-mono); text-align:right; }
.info-board .row .v a{ color:inherit; }
#eventMapCanvas{ aspect-ratio:16/8; border-radius:var(--r-card); }
@media (max-width:900px){ .detail-grid{ grid-template-columns:1fr; } .info-board{ position:static; } }
</style>
{% endblock %}
{% block body %}
<section class="section">
<div class="wrap">
<p class="breadcrumb">
<a href="{{ path('event_list') }}">Browse events</a>
{% if category %} / <a href="{{ path('event_list', {category: category}) }}">{{ category }}</a>{% endif %}
/ {{ event.title }}
</p>
<div class="hero-tile" style="background:{{ category|category_bg }};">
{% if image %}
<img src="{{ image.storagePath }}" alt="">
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="{{ category|category_color }}" stroke-width="1.4"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/></svg>
{% endif %}
</div>
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:20px; margin-top:28px; flex-wrap:wrap;">
<div>
{% 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' }}
{% if event.city %} · {{ event.city|upper }}{% endif %}
</p>
</div>
</div>
<div class="detail-grid">
<div>
{% if event.description %}
<div class="prose">
<h2>About this event</h2>
<p>{{ event.description }}</p>
</div>
{% elseif event.teaser %}
<div class="prose">
<h2>About this event</h2>
<p>{{ event.teaser }}</p>
</div>
{% endif %}
{% if event.hasCoordinates %}
<h2 style="margin-top:36px;">Location</h2>
<div id="eventMapCanvas" role="application" aria-label="Map showing the event location"></div>
{% endif %}
<p style="margin-top:14px; font-size:14px; color:var(--muted);">
{{ [event.street, (event.zip ~ ' ' ~ event.city)|trim, event.country]|filter(v => v)|join(', ') }}
</p>
</div>
<!-- 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>
{% if event.endDateTime %}
<div class="row"><span class="k">Ends</span><span class="v">{{ event.endDateTime|date('D, M j · H:i') }}</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 %}
{% if event.phone %}<div class="row"><span class="k">Phone</span><span class="v"><a href="tel:{{ event.phone }}">{{ event.phone }}</a></span></div>{% endif %}
{% if event.email %}<div class="row"><span class="k">Email</span><span class="v"><a href="mailto:{{ event.email }}">{{ event.email }}</a></span></div>{% endif %}
{% if event.web %}
<div class="row" style="border-bottom:none;"><span class="k">Website</span><span class="v"><a href="{{ event.web }}" target="_blank" rel="noopener">Visit site →</a></span></div>
{% endif %}
</aside>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
{% if event.hasCoordinates %}
{% include '_leaflet_scripts.html.twig' %}
<script type="application/json" id="event-map-data">{{ mapEventJson|raw }}</script>
<script>
Vindn.renderPinMap('eventMapCanvas', Vindn.readEmbeddedJson('event-map-data'), { zoom: 14, zoomControl: true, attributionControl: true });
</script>
{% endif %}
{% endblock %}
+111
View File
@@ -0,0 +1,111 @@
{% extends 'base.html.twig' %}
{% block title %}VINDN — Find what's on{% endblock %}
{% block head_extra %}
{% include '_leaflet_head.html.twig' %}
{% endblock %}
{% block body %}
<!-- MAP HERO — light: nearest ~20 events, refines once you share your location -->
<section class="section" style="padding:0;">
<div class="wrap">
<p class="eyebrow" style="margin-bottom:8px;">Nordrhein-Westfalen — updated every hour</p>
<div class="section-head">
<h1 style="font-size:clamp(28px,4vw,40px);">What's on near you</h1>
<a href="{{ path('map') }}" class="btn btn-primary btn-sm">Open full map →</a>
</div>
</div>
</section>
<section class="section" style="padding:0;">
{% include '_map_stage.html.twig' with { compact: true } %}
</section>
<!-- BROWSE EVENTS -->
<section class="section">
<div class="wrap">
<div class="section-head">
<div>
<p class="eyebrow" style="margin-bottom:8px;">Browse</p>
<h2>Find events by category</h2>
</div>
<a href="{{ path('event_list') }}" class="section-link">Browse all events →</a>
</div>
<div class="chip-row">
{% for category in popularCategories %}
<a href="{{ path('event_list', {category: category}) }}" class="chip">{{ category }}</a>
{% endfor %}
</div>
</div>
</section>
<!-- THIS WEEK -->
<section class="section" style="background:var(--paper-dim);">
<div class="wrap">
<div class="section-head">
<div>
<p class="eyebrow" style="margin-bottom:8px;">Live from the importer</p>
<h2>Worth planning around</h2>
</div>
<a href="{{ path('event_list') }}" class="section-link">Browse all events →</a>
</div>
<div class="card-grid">
{% for event in upcomingEvents %}
{% include 'events/_card.html.twig' with { event: event } %}
{% else %}
<p style="color:var(--muted);">No events imported yet — check back once the backend's import job has run.</p>
{% endfor %}
</div>
</div>
</section>
{% endblock %}
{% block footer %}
<div class="footer-grid">
<div>
<img src="{{ asset('images/vindn_logo_adobe.svg') }}" alt="VINDN" style="height:24px; margin-bottom:14px;">
<p style="max-width:260px; font-size:14px; color:var(--muted);">The board for everything on, wherever you are.</p>
</div>
<div>
<h4>Explore</h4>
<ul>
<li><a href="{{ path('event_list') }}">Browse events</a></li>
<li><a href="{{ path('search') }}">Search</a></li>
<li><a href="{{ path('map') }}">Map</a></li>
</ul>
</div>
<div>
<h4>Organizers</h4>
<ul>
<li><a href="#">List an event</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Guidelines</a></li>
</ul>
</div>
<div>
<h4>Company</h4>
<ul>
<li><a href="#">About</a></li>
<li><a href="#">Contact</a></li>
<li><a href="#">Privacy</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<span>© 2026 VINDN</span>
</div>
{% endblock %}
{% block scripts %}
{% include '_leaflet_scripts.html.twig' %}
<script type="application/json" id="map-data">{{ mapEventsJson|raw }}</script>
<script>
Vindn.initMapStage({
events: Vindn.readEmbeddedJson('map-data'),
limit: 20,
defaultCenter: [{{ centerLat }}, {{ centerLon }}]
});
</script>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends 'base.html.twig' %}
{% block title %}Map — VINDN{% endblock %}
{% block head_extra %}
{% include '_leaflet_head.html.twig' %}
{% endblock %}
{% block body %}
{% include '_map_stage.html.twig' %}
{% endblock %}
{% block scripts %}
{% include '_leaflet_scripts.html.twig' %}
<script type="application/json" id="map-data">{{ mapEventsJson|raw }}</script>
<script>
Vindn.initRadiusMapStage({
events: Vindn.readEmbeddedJson('map-data'),
center: [{{ centerLat }}, {{ centerLon }}],
rangeKm: {{ rangeKm }},
maxRangeKm: {{ maxRangeKm }}
});
</script>
{% endblock %}
+230
View File
@@ -0,0 +1,230 @@
{% extends 'base.html.twig' %}
{% block title %}Search — VINDN{% endblock %}
{% block head_extra %}
<style>
.search-shell{ display:grid; grid-template-columns: 260px 1fr; gap:36px; align-items:start; }
.filter-group{ padding-bottom:22px; margin-bottom:22px; border-bottom:1px solid var(--line); }
.filter-group:last-child{ border-bottom:none; }
.filter-group h4{ font-family:var(--f-mono); font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin:0 0 14px; }
.filter-option{ display:flex; align-items:center; gap:10px; font-size:14px; padding:6px 0; cursor:pointer; }
.filter-option input{ accent-color: var(--accent); width:16px; height:16px; }
.results-toolbar{ display:flex; justify-content:space-between; align-items:center; margin-bottom:18px; flex-wrap:wrap; gap:12px; }
.search-input-bar{ display:flex; gap:10px; margin-bottom:28px; }
.search-input-bar input{ flex:1; padding:16px 20px; border:1px solid var(--line-strong); border-radius: var(--r-pill); font-size:15px; }
.no-results{ padding:60px 20px; text-align:center; color:var(--muted); border:1px dashed var(--line-strong); border-radius:var(--r-card); }
#locationSearchForm{ display:flex; gap:6px; margin-bottom:10px; }
#locationSearchForm input{ flex:1; min-width:0; padding:9px 10px; border:1px solid var(--line-strong); border-radius:6px; font-size:13px; }
.range-row{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:10px; font-size:13px; }
.range-row select{ padding:6px 8px; border:1px solid var(--line-strong); border-radius:6px; font-family:var(--f-mono); font-size:12px; background:#fff; }
#locationStatus{ font-family:var(--f-mono); font-size:11px; color:var(--muted); margin-top:8px; }
@media (max-width: 860px){ .search-shell{ grid-template-columns:1fr; } }
</style>
{% endblock %}
{% block body %}
<section class="section" style="padding-bottom:0;">
<div class="wrap">
<p class="eyebrow" style="margin-bottom:10px;">Search</p>
<h1 style="font-size:clamp(28px,4vw,40px); margin-bottom:24px;">
{{ q ? 'Searching: "' ~ q ~ '"' : 'Search events' }}
</h1>
<form class="search-input-bar" method="get" action="{{ path('search') }}">
{% if activeCategory %}<input type="hidden" name="category" value="{{ activeCategory }}">{% 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, venue or city…">
<button class="btn btn-primary" type="submit">Search</button>
</form>
</div>
</section>
<section class="section" style="padding-top:0;">
<div class="wrap">
<div class="search-shell">
<!-- FILTERS -->
<aside aria-label="Filters">
<div class="filter-group">
<h4>Near a location</h4>
<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" style="width:100%; justify-content:center;">Use my location</button>
{% if hasLocation %}
<div class="range-row">
<span>Within</span>
<select id="rangeSelect">
{% for km in rangeOptions %}
<option value="{{ km }}" {{ km == rangeKm ? 'selected' : '' }}>{{ km }} km</option>
{% endfor %}
</select>
</div>
<p id="locationStatus">
Near {{ lat|number_format(3) }}, {{ lon|number_format(3) }} —
<a href="{{ path('search', {q: q ?: null, category: activeCategory, when: when != 'any' ? when : null}) }}" style="color:var(--accent); text-decoration:underline;">clear</a>
</p>
{% else %}
<p id="locationStatus"></p>
{% endif %}
</div>
<form method="get" action="{{ path('search') }}" id="filterForm">
<input type="hidden" name="q" value="{{ q }}">
{% if hasLocation %}
<input type="hidden" name="lat" value="{{ lat }}">
<input type="hidden" name="lon" value="{{ lon }}">
<input type="hidden" name="range" value="{{ rangeKm }}">
{% endif %}
<div class="filter-group">
<h4>Category</h4>
<label class="filter-option">
<input type="radio" name="category" value="" {{ activeCategory ? '' : 'checked' }} onchange="this.form.submit()"> All
</label>
{% for category in categories %}
<label class="filter-option">
<input type="radio" name="category" value="{{ category }}" {{ activeCategory == category ? 'checked' : '' }} onchange="this.form.submit()"> {{ category }}
</label>
{% endfor %}
</div>
<div class="filter-group">
<h4>When</h4>
<label class="filter-option"><input type="radio" name="when" value="any" {{ when == 'any' ? 'checked' : '' }} onchange="this.form.submit()"> Any date</label>
<label class="filter-option"><input type="radio" name="when" value="today" {{ when == 'today' ? 'checked' : '' }} onchange="this.form.submit()"> Today</label>
<label class="filter-option"><input type="radio" name="when" value="week" {{ when == 'week' ? 'checked' : '' }} onchange="this.form.submit()"> Next 7 days</label>
</div>
<a href="{{ path('search') }}" class="btn btn-ghost btn-sm" style="width:100%; justify-content:center;">Clear filters</a>
</form>
</aside>
<!-- RESULTS -->
<div>
<div class="results-toolbar">
<p class="mono" style="font-size:13px; color:var(--muted);">
{{ total }} EVENT{{ total == 1 ? '' : 'S' }} MATCH{{ hasLocation ? ' · SORTED BY DISTANCE' : '' }}
</p>
</div>
{% if events|length %}
<div class="board" aria-label="Search results">
{% for event in events %}
<a class="board-row" href="{{ path('event_show', {id: event.id}) }}" aria-label="View {{ event.title }}">
<div class="board-date">
{{ event.startDateTime ? event.startDateTime|date('M j') : '—' }}
<span class="dow">{{ event.startDateTime ? event.startDateTime|date('D') : 'TBD' }}</span>
</div>
<div class="board-name">
{{ event.title }}
<span class="where">
{{ event.city }}{% if event.categoryNames|first %} · {{ event.categoryNames|first }}{% endif %}
{% if hasLocation and distances[event.id] is defined %}
{% set dist = distances[event.id] %}
· {{ dist < 1 ? (dist * 1000)|round ~ ' m' : (dist < 10 ? dist|round(1) : dist|round) ~ ' km' }} away
{% endif %}
</span>
</div>
<div class="board-status">{{ event.startDateTime ? event.startDateTime|date('H:i') : '' }}</div>
<div class="board-go">→</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="no-results">
<p class="eyebrow" style="margin-bottom:8px;">No matches</p>
<p>Nothing fits those filters yet. Try clearing a filter or a different search term.</p>
</div>
{% endif %}
{% include '_pager.html.twig' with {
currentPage: page,
totalPages: totalPages,
routeName: 'search',
routeParams: {
q: q ?: null,
category: activeCategory,
when: when != 'any' ? when : null,
lat: hasLocation ? lat : null,
lon: hasLocation ? lon : null,
range: hasLocation ? rangeKm : null
}
} %}
</div>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
(function(){
var currentLat = {{ hasLocation ? lat : 'null' }};
var currentLon = {{ hasLocation ? lon : 'null' }};
function navigateWithLocation(lat, lon, rangeKm) {
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();
}
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;
var statusEl = document.getElementById('locationStatus');
if (statusEl) statusEl.textContent = 'Looking up "' + query + '"…';
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) {
if (statusEl) statusEl.textContent = 'Could not find "' + query + '"';
return;
}
navigateWithLocation(parseFloat(results[0].lat), parseFloat(results[0].lon));
})
.catch(function () {
if (statusEl) statusEl.textContent = 'Location search failed';
});
});
}
var useMyLocationBtn = document.getElementById('useMyLocationBtn');
if (useMyLocationBtn) {
useMyLocationBtn.addEventListener('click', function () {
var statusEl = document.getElementById('locationStatus');
if (!navigator.geolocation) {
if (statusEl) statusEl.textContent = 'Location not supported by your browser';
return;
}
if (statusEl) statusEl.textContent = 'Finding you…';
navigator.geolocation.getCurrentPosition(function (pos) {
navigateWithLocation(pos.coords.latitude, pos.coords.longitude);
}, function () {
if (statusEl) statusEl.textContent = "Couldn't get your location";
}, { timeout: 8000 });
});
}
var rangeSelect = document.getElementById('rangeSelect');
if (rangeSelect) {
rangeSelect.addEventListener('change', function () {
if (currentLat === null || currentLon === null) return;
navigateWithLocation(currentLat, currentLon, parseInt(rangeSelect.value, 10));
});
}
})();
</script>
{% endblock %}