added translations
This commit is contained in:
1 parent
36ed3880e8
commit
71635eb574
33 files changed
+1025
-241
No files matched your search
@@ -17,6 +17,7 @@
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/translation": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/yaml": "7.4.*",
|
||||
"twig/twig": "^3.28"
|
||||
|
||||
Generated
+102
-2
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "e35ae73cbb88bee1d3d0f8aeab3bbf4e",
|
||||
"content-hash": "dbf99499391dc3d20f909c071c1cb298",
|
||||
"packages": [
|
||||
{
|
||||
"name": "doctrine/collections",
|
||||
@@ -3494,6 +3494,106 @@
|
||||
],
|
||||
"time": "2026-07-28T07:33:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/translation",
|
||||
"version": "v7.4.17",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/translation.git",
|
||||
"reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b",
|
||||
"reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/polyfill-mbstring": "~1.0",
|
||||
"symfony/translation-contracts": "^2.5.3|^3.3"
|
||||
},
|
||||
"conflict": {
|
||||
"nikic/php-parser": "<5.0",
|
||||
"symfony/config": "<6.4",
|
||||
"symfony/console": "<6.4",
|
||||
"symfony/dependency-injection": "<6.4",
|
||||
"symfony/http-client-contracts": "<2.5",
|
||||
"symfony/http-kernel": "<6.4",
|
||||
"symfony/service-contracts": "<2.5",
|
||||
"symfony/twig-bundle": "<6.4",
|
||||
"symfony/yaml": "<6.4"
|
||||
},
|
||||
"provide": {
|
||||
"symfony/translation-implementation": "2.3|3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"nikic/php-parser": "^5.0",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/config": "^6.4|^7.0|^8.0",
|
||||
"symfony/console": "^6.4|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/finder": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-client-contracts": "^2.5|^3.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0|^8.0",
|
||||
"symfony/intl": "^6.4|^7.0|^8.0",
|
||||
"symfony/polyfill-intl-icu": "^1.21",
|
||||
"symfony/routing": "^6.4|^7.0|^8.0",
|
||||
"symfony/service-contracts": "^2.5|^3",
|
||||
"symfony/yaml": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"Resources/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Translation\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides tools to internationalize your application",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/translation/tree/v7.4.17"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-21T17:40:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/translation-contracts",
|
||||
"version": "v3.7.1",
|
||||
@@ -4118,5 +4218,5 @@
|
||||
"ext-iconv": "*"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
framework:
|
||||
default_locale: de
|
||||
# Languages the site is offered in. To add one: list its code here, add
|
||||
# translations/{messages,js,formats}.<code>.yaml (copy the "de" files and
|
||||
# translate), and — if flatpickr ships it — nothing else; the language
|
||||
# switcher, <html lang> and the date picker locale follow from this list.
|
||||
enabled_locales: ['de', 'en']
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
# A key missing from a language falls back to German instead of
|
||||
# showing the raw key.
|
||||
fallbacks: ['de']
|
||||
providers:
|
||||
@@ -1,5 +1,7 @@
|
||||
twig:
|
||||
default_path: '%kernel.project_dir%/templates'
|
||||
globals:
|
||||
enabled_locales: '%kernel.enabled_locales%'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
|
||||
@@ -302,7 +302,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* },
|
||||
* translator?: bool|array{ // Translator configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* fallbacks?: Param|string|list<scalar|Param|null>,
|
||||
* logging?: bool|Param, // Default: false
|
||||
* formatter?: scalar|Param|null, // Default: "translator.formatter.default"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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
|
||||
// spot, and bound to date pickers shown in the page language (format and
|
||||
// calendar names from i18n.js / the flatpickr locale). 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) {
|
||||
@@ -64,11 +65,12 @@
|
||||
} 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.
|
||||
// Turns a pair of <input type="date"> into pickers in the page language
|
||||
// when flatpickr is loaded. A native date input displays in the *browser's*
|
||||
// locale and can't be forced to the site's format, so the visible field is
|
||||
// flatpickr's alt input (e.g. d.m.Y with a 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.
|
||||
//
|
||||
@@ -79,10 +81,12 @@
|
||||
var pickers = [];
|
||||
if (window.flatpickr) {
|
||||
var options = {
|
||||
locale: 'de',
|
||||
// flatpickr ships a file per language (loaded by
|
||||
// _datepicker_scripts.html.twig); English is built in.
|
||||
locale: (window.flatpickr.l10ns && window.flatpickr.l10ns[window.VindnI18n.locale]) ? window.VindnI18n.locale : 'default',
|
||||
dateFormat: 'Y-m-d',
|
||||
altInput: true,
|
||||
altFormat: 'd.m.Y',
|
||||
altFormat: window.VindnI18n.dateFormat,
|
||||
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
|
||||
@@ -105,7 +109,7 @@
|
||||
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');
|
||||
alt.setAttribute('placeholder', window.VindnI18n.t('dates.placeholder'));
|
||||
return picker;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Browser-side translations. The server prints the catalog for the current
|
||||
// language (translations/js.<locale>.yaml, via the vindn_i18n_json Twig
|
||||
// function in base.html.twig) into window.VINDN_I18N; this module only looks
|
||||
// things up. All visible text in public/js goes through t()/count() so a new
|
||||
// language is a translations/ change, not a code change.
|
||||
(function (window) {
|
||||
var data = window.VINDN_I18N || {};
|
||||
var locale = data.locale || 'de';
|
||||
var messages = data.messages || {};
|
||||
|
||||
var numberFormats = {};
|
||||
var pluralRules;
|
||||
try {
|
||||
pluralRules = new Intl.PluralRules(locale);
|
||||
} catch (e) { /* unknown locale tag — fall back to "other" below */ }
|
||||
|
||||
// "Hello %name%" + {name: 'x'} — same %placeholder% syntax as Symfony's
|
||||
// catalogs. An unknown key shows itself, which is visible but harmless.
|
||||
function t(key, params) {
|
||||
var text = Object.prototype.hasOwnProperty.call(messages, key) ? messages[key] : key;
|
||||
if (params) {
|
||||
Object.keys(params).forEach(function (name) {
|
||||
text = text.split('%' + name + '%').join(params[name]);
|
||||
});
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Number in the page language, at most `decimals` fraction digits.
|
||||
function number(value, decimals) {
|
||||
decimals = decimals || 0;
|
||||
if (!numberFormats[decimals]) {
|
||||
numberFormats[decimals] = new Intl.NumberFormat(locale, { maximumFractionDigits: decimals });
|
||||
}
|
||||
return numberFormats[decimals].format(value);
|
||||
}
|
||||
|
||||
// Plural-aware message: looks up "<key>.<rule>" (one, other, few, …) for
|
||||
// the language's own plural rules, falling back to "<key>.other". The
|
||||
// count itself is available as %n%, already formatted.
|
||||
function count(key, n, params) {
|
||||
var rule = pluralRules ? pluralRules.select(n) : 'other';
|
||||
var full = key + '.' + rule;
|
||||
if (!Object.prototype.hasOwnProperty.call(messages, full)) full = key + '.other';
|
||||
return t(full, Object.assign({ n: number(n) }, params || {}));
|
||||
}
|
||||
|
||||
window.VindnI18n = {
|
||||
locale: locale,
|
||||
dateFormat: data.dateFormat || 'd.m.Y',
|
||||
t: t,
|
||||
number: number,
|
||||
count: count
|
||||
};
|
||||
})(window);
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
if (callbacks.onStart) callbacks.onStart();
|
||||
navigator.geolocation.getCurrentPosition(function (pos) {
|
||||
if (callbacks.onFound) callbacks.onFound(pos.coords.latitude, pos.coords.longitude, 'Your location');
|
||||
if (callbacks.onFound) callbacks.onFound(pos.coords.latitude, pos.coords.longitude, window.VindnI18n.t('location.yours'));
|
||||
}, function () {
|
||||
if (callbacks.onError) callbacks.onError();
|
||||
}, { timeout: 8000 });
|
||||
|
||||
+39
-28
@@ -30,14 +30,11 @@
|
||||
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 });
|
||||
|
||||
// Number formatting for what the panel shows (separators of the page
|
||||
// language) — mirrors LocaleFormatExtension::distance() on the server so a
|
||||
// distance reads the same on a card and in the panel.
|
||||
function formatKm(km) {
|
||||
return km < 10 ? DE_ONE_DECIMAL.format(km) : DE_INT.format(km);
|
||||
return window.VindnI18n.number(km, km < 10 ? 1 : 0);
|
||||
}
|
||||
|
||||
// Day-range filtering for the maps — the same "event overlaps [from, to]"
|
||||
@@ -260,7 +257,7 @@
|
||||
(ev.category ? '<span class="cat" style="color:' + hex + ';">' + ev.category + '</span>' : '') +
|
||||
'<h4>' + ev.title + '</h4>' +
|
||||
(ev.venue ? '<p>' + ev.venue + '</p>' : '') +
|
||||
(ev.url ? '<a href="' + ev.url + '">View event →</a>' : '') +
|
||||
(ev.url ? '<a href="' + ev.url + '">' + window.VindnI18n.t('map.view_event') + '</a>' : '') +
|
||||
'</div>';
|
||||
marker.bindPopup(popupHtml);
|
||||
if (onMarkerClick) marker.on('click', function () { onMarkerClick(ev, marker); });
|
||||
@@ -285,7 +282,7 @@
|
||||
|
||||
var center = opts.center || (events.length ? [events[0].lat, events[0].lon] : DEFAULT_CENTER);
|
||||
var map = L.map(containerId, {
|
||||
zoomControl: !!opts.zoomControl,
|
||||
zoomControl: false, // added below, so its tooltips can be translated
|
||||
attributionControl: !!opts.attributionControl,
|
||||
scrollWheelZoom: !!opts.scrollWheelZoom,
|
||||
// A raster L.tileLayer used to supply maxZoom implicitly; the
|
||||
@@ -299,9 +296,23 @@
|
||||
// Requires two-finger drag / Ctrl+scroll to pan/zoom, with an
|
||||
// on-map hint, so an embedded map (home hero, mid-page /map) doesn't
|
||||
// trap page scroll/pinch-zoom on touch devices.
|
||||
gestureHandling: opts.gestureHandling !== false
|
||||
gestureHandling: opts.gestureHandling !== false,
|
||||
gestureHandlingOptions: {
|
||||
text: {
|
||||
touch: window.VindnI18n.t('map.gesture.touch'),
|
||||
scroll: window.VindnI18n.t('map.gesture.scroll'),
|
||||
scrollMac: window.VindnI18n.t('map.gesture.scroll_mac')
|
||||
}
|
||||
}
|
||||
}).setView(center, opts.zoom || 13);
|
||||
|
||||
if (opts.zoomControl) {
|
||||
L.control.zoom({
|
||||
zoomInTitle: window.VindnI18n.t('map.zoom_in'),
|
||||
zoomOutTitle: window.VindnI18n.t('map.zoom_out')
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
if (L.maplibreGL) {
|
||||
// OpenFreeMap serves vector tiles only, rendered by MapLibre GL and
|
||||
// bridged into this Leaflet map as an ordinary layer — everything
|
||||
@@ -331,8 +342,8 @@
|
||||
// just the bare map div — those overlay controls are DOM siblings of
|
||||
// #mapCanvas, not descendants, so they'd vanish in fullscreen otherwise.
|
||||
L.control.fullscreen({
|
||||
title: 'View fullscreen',
|
||||
titleCancel: 'Exit fullscreen',
|
||||
title: window.VindnI18n.t('map.fullscreen'),
|
||||
titleCancel: window.VindnI18n.t('map.fullscreen_exit'),
|
||||
fullscreenElement: el.closest('.stage') || el
|
||||
}).addTo(map);
|
||||
|
||||
@@ -493,7 +504,7 @@
|
||||
var list = currentList();
|
||||
|
||||
var countEl = document.getElementById('resultCount');
|
||||
if (countEl) countEl.textContent = DE_INT.format(list.length) + (list.length === 1 ? ' event' : ' events');
|
||||
if (countEl) countEl.textContent = window.VindnI18n.count('map.count', list.length);
|
||||
|
||||
var panelEl = document.getElementById('panelList');
|
||||
if (!panelEl) return;
|
||||
@@ -546,7 +557,7 @@
|
||||
}
|
||||
|
||||
function statusForLimit() {
|
||||
return limit ? ('Showing the ' + limit + ' nearest events') : 'Showing events near you';
|
||||
return limit ? window.VindnI18n.t('map.status_nearest', { limit: limit }) : window.VindnI18n.t('map.status_near_you');
|
||||
}
|
||||
|
||||
function setUserLocation(lat, lon, statusText, placeName) {
|
||||
@@ -572,15 +583,15 @@
|
||||
function requestLocation(isAutoCheck) {
|
||||
var statusEl = document.getElementById('locateStatus');
|
||||
window.VindnLocation.useMyLocation({
|
||||
onUnsupported: function () { setLocateStatus(statusEl, 'Location not supported by your browser', true); },
|
||||
onStart: function () { setLocateStatus(statusEl, 'Finding you…', false); },
|
||||
onUnsupported: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.unsupported'), true); },
|
||||
onStart: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.finding'), false); },
|
||||
onFound: function (lat, lon, name) {
|
||||
// A manual search/click may have already happened while this
|
||||
// (silent, permission-check-triggered) lookup was still resolving.
|
||||
if (isAutoCheck && manualLocationSet) return;
|
||||
setUserLocation(lat, lon, statusForLimit(), name);
|
||||
},
|
||||
onError: function () { setLocateStatus(statusEl, "Couldn't get your location", true); }
|
||||
onError: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.failed'), true); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -597,10 +608,10 @@
|
||||
manualLocationSet = true;
|
||||
var statusEl = document.getElementById('locateStatus');
|
||||
window.VindnLocation.searchPlace(query, {
|
||||
onStart: function () { setLocateStatus(statusEl, 'Looking up "' + query + '"…', false); },
|
||||
onNotFound: function () { setLocateStatus(statusEl, 'Could not find "' + query + '"', true); },
|
||||
onStart: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.looking_up', { query: query }), false); },
|
||||
onNotFound: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.not_found', { query: query }), true); },
|
||||
onFound: function (lat, lon, name) { setUserLocation(lat, lon, statusForLimit(), name); },
|
||||
onError: function () { setLocateStatus(statusEl, 'Location search failed', true); }
|
||||
onError: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.search_failed'), true); }
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -745,10 +756,10 @@
|
||||
}
|
||||
|
||||
var statusEl = document.getElementById('locateStatus');
|
||||
setLocateStatus(statusEl, 'Showing events within ' + state.rangeKm + ' km', false);
|
||||
setLocateStatus(statusEl, window.VindnI18n.t('map.status_within', { km: state.rangeKm }), false);
|
||||
|
||||
var countEl = document.getElementById('resultCount');
|
||||
if (countEl) countEl.textContent = DE_INT.format(visible.length) + (visible.length === 1 ? ' event' : ' events');
|
||||
if (countEl) countEl.textContent = window.VindnI18n.count('map.count', visible.length);
|
||||
|
||||
var panelEl = document.getElementById('panelList');
|
||||
if (panelEl) {
|
||||
@@ -855,10 +866,10 @@
|
||||
manualLocationSet = true;
|
||||
var statusEl = document.getElementById('locateStatus');
|
||||
window.VindnLocation.searchPlace(query, {
|
||||
onStart: function () { setLocateStatus(statusEl, 'Looking up "' + query + '"…', false); },
|
||||
onNotFound: function () { setLocateStatus(statusEl, 'Could not find "' + query + '"', true); },
|
||||
onStart: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.looking_up', { query: query }), false); },
|
||||
onNotFound: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.not_found', { query: query }), true); },
|
||||
onFound: function (lat, lon, name) { setSource(lat, lon, name); },
|
||||
onError: function () { setLocateStatus(statusEl, 'Location search failed', true); }
|
||||
onError: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.search_failed'), true); }
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -866,15 +877,15 @@
|
||||
function requestLocation(isAutoCheck) {
|
||||
var statusEl = document.getElementById('locateStatus');
|
||||
window.VindnLocation.useMyLocation({
|
||||
onUnsupported: function () { setLocateStatus(statusEl, 'Location not supported by your browser', true); },
|
||||
onStart: function () { setLocateStatus(statusEl, 'Finding you…', false); },
|
||||
onUnsupported: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.unsupported'), true); },
|
||||
onStart: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.finding'), false); },
|
||||
onFound: function (lat, lon, name) {
|
||||
// A manual search/click/drag may have already happened while this
|
||||
// (silent, permission-check-triggered) lookup was still resolving.
|
||||
if (isAutoCheck && manualLocationSet) return;
|
||||
setSource(lat, lon, name);
|
||||
},
|
||||
onError: function () { setLocateStatus(statusEl, "Couldn't get your location", true); }
|
||||
onError: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.failed'), true); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,10 @@ h1,h2,h3{ font-family: var(--f-display); font-weight:700; letter-spacing:-0.01em
|
||||
border:none;
|
||||
}
|
||||
.nav-actions{ display:flex; align-items:center; gap:16px; }
|
||||
.lang-switch{ display:flex; align-items:center; gap:2px; font-family:var(--f-mono); font-size:12px; }
|
||||
.lang-switch a, .lang-switch span{ padding:5px 7px; border-radius:var(--r-pill); color:var(--muted); }
|
||||
.lang-switch a:hover{ color:var(--ink); }
|
||||
.lang-switch .is-active{ background:var(--ink); color:var(--paper); }
|
||||
.nav-burger{ display:none; background:none; border:none; padding:6px; }
|
||||
.nav-burger span{ display:block; width:22px; height:2px; background:var(--ink); margin:5px 0; transition:transform .2s ease, opacity .2s ease; }
|
||||
.nav-burger.is-open span:nth-child(1){ transform:translateY(7px) rotate(45deg); }
|
||||
|
||||
@@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class EventController extends AbstractController
|
||||
{
|
||||
@@ -130,11 +131,11 @@ class EventController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/events/{id}', name: 'event_show', requirements: ['id' => '\d+'])]
|
||||
public function show(int $id, EventRepository $events, EventMapSerializer $mapSerializer): Response
|
||||
public function show(int $id, EventRepository $events, EventMapSerializer $mapSerializer, TranslatorInterface $translator): Response
|
||||
{
|
||||
$event = $events->findOneNotDeleted($id);
|
||||
if (!$event) {
|
||||
throw $this->createNotFoundException('Event nicht gefunden.');
|
||||
throw $this->createNotFoundException($translator->trans('event.not_found'));
|
||||
}
|
||||
|
||||
return $this->render('events/show.html.twig', [
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\EventSubscriber\LocaleSubscriber;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class LanguageController extends AbstractController
|
||||
{
|
||||
/** @param list<string> $enabledLocales */
|
||||
public function __construct(
|
||||
#[Autowire('%kernel.enabled_locales%')]
|
||||
private readonly array $enabledLocales,
|
||||
) {
|
||||
}
|
||||
|
||||
/** Remembers the chosen language in a cookie and returns to the page it was chosen on. */
|
||||
#[Route('/language/{locale}', name: 'language_switch', requirements: ['locale' => '[a-z]{2}'])]
|
||||
public function switch(string $locale, Request $request): RedirectResponse
|
||||
{
|
||||
if (!\in_array($locale, $this->enabledLocales, true)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
// Only ever back to a path on this site — never an open redirect.
|
||||
$back = (string) $request->query->get('back', '/');
|
||||
if (!str_starts_with($back, '/') || str_starts_with($back, '//') || str_contains($back, '\\')) {
|
||||
$back = '/';
|
||||
}
|
||||
|
||||
$response = $this->redirect($back);
|
||||
$response->headers->setCookie(Cookie::create(LocaleSubscriber::COOKIE, $locale, strtotime('+1 year'), '/', null, $request->isSecure(), false, false, Cookie::SAMESITE_LAX));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Picks the display language for a request, without putting it into the URL
|
||||
* (links and the saved-filter/location handling stay language-neutral):
|
||||
*
|
||||
* 1. the `vindn_lang` cookie, set by the language switcher
|
||||
* 2. the browser's Accept-Language, matched against the enabled locales
|
||||
* 3. the default locale
|
||||
*
|
||||
* Has to run before the framework's LocaleListener (16) and LocaleAwareListener
|
||||
* (15, which hands the request locale to the translator) — hence priority 17.
|
||||
*/
|
||||
class LocaleSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public const COOKIE = 'vindn_lang';
|
||||
|
||||
/** @param list<string> $enabledLocales */
|
||||
public function __construct(
|
||||
#[Autowire('%kernel.enabled_locales%')]
|
||||
private readonly array $enabledLocales,
|
||||
#[Autowire('%kernel.default_locale%')]
|
||||
private readonly string $defaultLocale,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::REQUEST => [['onKernelRequest', 17]]];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = $event->getRequest();
|
||||
$locales = $this->enabledLocales ?: [$this->defaultLocale];
|
||||
|
||||
$cookie = $request->cookies->get(self::COOKIE);
|
||||
if (\is_string($cookie) && \in_array($cookie, $locales, true)) {
|
||||
$request->setLocale($cookie);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$request->setLocale($request->getPreferredLanguage($locales) ?? $this->defaultLocale);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?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,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Translation\MessageCatalogueInterface;
|
||||
use Symfony\Component\Translation\TranslatorBagInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFilter;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* Locale-aware dates, numbers and distances, plus the catalog hand-off to the
|
||||
* browser. Everything language-specific (weekday names, date pattern,
|
||||
* separators) lives in translations/formats.<locale>.yaml, so a new language
|
||||
* needs no code — and it's done by hand instead of via the intl extension,
|
||||
* which isn't installed in either container image.
|
||||
*
|
||||
* Times are always shown in German local time: that's where the events are,
|
||||
* whatever language the page is in.
|
||||
*/
|
||||
class LocaleFormatExtension extends AbstractExtension
|
||||
{
|
||||
private const DOMAIN = 'formats';
|
||||
private const TIMEZONE = 'Europe/Berlin';
|
||||
|
||||
public function __construct(
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly RequestStack $requests,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getFilters(): array
|
||||
{
|
||||
return [
|
||||
new TwigFilter('format_datetime', $this->datetime(...)),
|
||||
new TwigFilter('format_date', $this->date(...)),
|
||||
new TwigFilter('format_number', $this->number(...)),
|
||||
new TwigFilter('format_distance', $this->distance(...)),
|
||||
];
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('vindn_i18n_json', $this->browserCatalog(...), ['is_safe' => ['html']]),
|
||||
];
|
||||
}
|
||||
|
||||
/** "Do, 16.10.2026 · 18:00" (de) — in German local time. */
|
||||
public function datetime(?\DateTimeInterface $date): string
|
||||
{
|
||||
if (!$date) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$local = \DateTimeImmutable::createFromInterface($date)->setTimezone(new \DateTimeZone(self::TIMEZONE));
|
||||
|
||||
return $this->format('datetime', [
|
||||
'%weekday%' => $this->format('weekday.'.$local->format('w')),
|
||||
'%date%' => $local->format($this->format('date')),
|
||||
'%time%' => $local->format($this->format('time')),
|
||||
]);
|
||||
}
|
||||
|
||||
/** A calendar day, given as a DateTimeInterface or a "Y-m-d" string. */
|
||||
public function date(\DateTimeInterface|string|null $date): string
|
||||
{
|
||||
if (null === $date || '' === $date) {
|
||||
return '';
|
||||
}
|
||||
if (\is_string($date)) {
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
||||
if (!$date) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return $date->format($this->format('date'));
|
||||
}
|
||||
|
||||
public function number(int|float|null $value, int $decimals = 0): string
|
||||
{
|
||||
return number_format((float) $value, $decimals, $this->format('decimal_separator'), $this->format('thousands_separator'));
|
||||
}
|
||||
|
||||
/** "250 m", "1,7 km", "24 km" (de). */
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* What public/js/i18n.js needs: the locale, its number/date conventions
|
||||
* and every string of the `js` domain (own language, with the fallback
|
||||
* language filling any gaps). Safe to print inside a <script>.
|
||||
*/
|
||||
public function browserCatalog(): string
|
||||
{
|
||||
$locale = $this->translator->getLocale();
|
||||
|
||||
$messages = [];
|
||||
if ($this->translator instanceof TranslatorBagInterface) {
|
||||
$catalogue = $this->translator->getCatalogue($locale);
|
||||
$chain = [];
|
||||
for ($c = $catalogue; $c instanceof MessageCatalogueInterface; $c = method_exists($c, 'getFallbackCatalogue') ? $c->getFallbackCatalogue() : null) {
|
||||
array_unshift($chain, $c);
|
||||
}
|
||||
foreach ($chain as $c) {
|
||||
$messages = array_replace($messages, $c->all('js'));
|
||||
}
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
'locale' => $locale,
|
||||
'dateFormat' => $this->format('date'),
|
||||
'timezone' => self::TIMEZONE,
|
||||
'messages' => $messages,
|
||||
], \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES | \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_APOS | \JSON_HEX_QUOT | \JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/** @param array<string, string> $params */
|
||||
private function format(string $id, array $params = []): string
|
||||
{
|
||||
return $this->translator->trans($id, $params, self::DOMAIN);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,19 @@
|
||||
"version": "7.4"
|
||||
}
|
||||
},
|
||||
"symfony/translation": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.3",
|
||||
"ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/translation.yaml",
|
||||
"translations/.gitignore"
|
||||
]
|
||||
},
|
||||
"symfony/twig-bundle": {
|
||||
"version": "v7.4.15"
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
{# German-format date pickers (see public/js/dates.js) — only the pages that have a date range include this. #}
|
||||
{# Date pickers in the page language (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">
|
||||
@@ -1,3 +1,4 @@
|
||||
{# 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>
|
||||
{# Calendar names/first weekday for the page language; English is built in. #}
|
||||
{% if app.request.locale != 'en' %}<script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/l10n/{{ app.request.locale }}.js"></script>{% endif %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{# Language links for every enabled locale (config/packages/translation.yaml). The current one is marked; others go through /language/<code>, which remembers the choice in a cookie and returns here. #}
|
||||
<nav class="lang-switch" aria-label="{{ 'language.label'|trans }}">
|
||||
{% for code in enabled_locales %}
|
||||
{% if code == app.request.locale %}
|
||||
<span class="is-active" lang="{{ code }}" aria-current="true" title="{{ ('language.name.' ~ code)|trans }}">{{ code|upper }}</span>
|
||||
{% else %}
|
||||
<a href="{{ path('language_switch', {locale: code, back: app.request.requestUri}) }}" lang="{{ code }}" hreflang="{{ code }}" rel="nofollow" title="{{ ('language.name.' ~ code)|trans }}">{{ code|upper }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
@@ -11,39 +11,39 @@
|
||||
#}
|
||||
{% set compact = compact|default(false) %}
|
||||
<section class="stage{{ compact ? ' stage-compact' : '' }}">
|
||||
<div id="mapCanvas" role="application" aria-label="Map of events"></div>
|
||||
<div id="mapCanvas" role="application" aria-label="{{ 'map.canvas_aria'|trans }}"></div>
|
||||
|
||||
<div class="map-overlay-top">
|
||||
<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>
|
||||
<div class="chip-row-tight" id="catChips" role="group" aria-label="{{ 'map.categories'|trans }}">
|
||||
<button class="chip-sm is-active" data-cat="all">{{ 'map.all'|trans }}</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 class="date-range" role="group" aria-label="{{ 'dates.group'|trans }}">
|
||||
<label for="dateFrom">{{ 'dates.from'|trans }}</label>
|
||||
<input type="date" id="dateFrom" aria-label="{{ 'dates.from_aria'|trans }}" min="{{ 'now'|date('Y-m-d', 'Europe/Berlin') }}">
|
||||
<label for="dateTo">{{ 'dates.to'|trans }}</label>
|
||||
<input type="date" id="dateTo" aria-label="{{ 'dates.to_aria'|trans }}" min="{{ 'now'|date('Y-m-d', 'Europe/Berlin') }}">
|
||||
<button type="button" id="dateClear" class="date-clear" hidden>{{ 'dates.clear'|trans }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="float-card locate-card">
|
||||
<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>
|
||||
<label for="locationSearch" style="display:none;">{{ 'location.search_label'|trans }}</label>
|
||||
<input id="locationSearch" type="text" placeholder="{{ 'location.search_placeholder'|trans }}" autocomplete="off">
|
||||
<button class="btn btn-ghost btn-sm" type="submit" aria-label="{{ 'map.go_aria'|trans }}">{{ 'location.go'|trans }}</button>
|
||||
</form>
|
||||
<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
|
||||
{{ 'location.use_mine'|trans }}
|
||||
</button>
|
||||
{% if not compact %}
|
||||
<label class="range-label">
|
||||
Range
|
||||
{{ 'location.radius'|trans }}
|
||||
<select id="rangeSelect">
|
||||
{% for km in rangeOptions %}
|
||||
<option value="{{ km }}" {{ km == rangeKm ? 'selected' : '' }}>{{ km }} km</option>
|
||||
@@ -51,7 +51,7 @@
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
<p class="locate-status" id="locateStatus">{{ compact ? 'Showing events near Nordrhein-Westfalen' : 'Showing events within ' ~ rangeKm ~ ' km' }}</p>
|
||||
<p class="locate-status" id="locateStatus">{{ compact ? 'map.status_region'|trans : 'map.status_within'|trans({'%km%': rangeKm}) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,10 +59,10 @@
|
||||
<div class="legend float-card" id="legend"></div>
|
||||
{% endif %}
|
||||
|
||||
<aside class="panel board" aria-label="Events">
|
||||
<aside class="panel board" aria-label="{{ 'map.panel_aria'|trans }}">
|
||||
<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>
|
||||
<span class="eyebrow" style="color:var(--board-muted);">{{ compact ? 'map.panel_nearest'|trans : 'map.panel_events'|trans }}</span>
|
||||
<span class="count" id="resultCount">{{ 'map.count_placeholder'|trans }}</span>
|
||||
</div>
|
||||
<div class="panel-list" id="panelList"></div>
|
||||
</aside>
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
q, when, lat, lon, range)
|
||||
#}
|
||||
{% if totalPages > 1 %}
|
||||
<nav class="pager" aria-label="Pagination">
|
||||
<nav class="pager" aria-label="{{ 'pager.nav'|trans }}">
|
||||
{% 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>
|
||||
<a href="{{ path(routeName, routeParams|merge({page: 1})) }}" aria-label="{{ 'pager.first_aria'|trans }}">{{ 'pager.first'|trans }}</a>
|
||||
<a href="{{ path(routeName, routeParams|merge({page: currentPage - 1})) }}" aria-label="{{ 'pager.prev_aria'|trans }}">{{ 'pager.prev'|trans }}</a>
|
||||
{% endif %}
|
||||
<span class="is-active">Page {{ currentPage }} of {{ totalPages }}</span>
|
||||
<span class="is-active">{{ 'pager.page'|trans({'%current%': currentPage, '%total%': 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>
|
||||
<a href="{{ path(routeName, routeParams|merge({page: currentPage + 1})) }}" aria-label="{{ 'pager.next_aria'|trans }}">{{ 'pager.next'|trans }}</a>
|
||||
<a href="{{ path(routeName, routeParams|merge({page: totalPages})) }}" aria-label="{{ 'pager.last_aria'|trans }}">{{ 'pager.last'|trans }}</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
@@ -1,46 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{ app.request.locale }}">
|
||||
<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>
|
||||
<title>{% block title %}{{ 'home.title'|trans }}{% 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>
|
||||
<a href="#main" class="skip-link">{{ 'skip_link'|trans }}</a>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="wrap">
|
||||
<a href="{{ path('home') }}" class="brand" aria-label="VINDN home">
|
||||
<a href="{{ path('home') }}" class="brand" aria-label="{{ 'nav.home_aria'|trans }}">
|
||||
<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>
|
||||
<nav class="main-nav" aria-label="{{ 'nav.primary'|trans }}">
|
||||
<a href="{{ path('home') }}" class="{{ activeNav == 'home' ? 'is-active' : '' }}">{{ 'nav.home'|trans }}</a>
|
||||
<a href="{{ path('event_list') }}" class="{{ activeNav == 'events' ? 'is-active' : '' }}">{{ 'nav.browse'|trans }}</a>
|
||||
<a href="{{ path('map') }}" class="{{ activeNav == 'map' ? 'is-active' : '' }}">{{ 'nav.map'|trans }}</a>
|
||||
</nav>
|
||||
<div class="nav-actions">
|
||||
<form class="nav-search" action="{{ path('event_list') }}" role="search">
|
||||
<button type="submit" aria-label="Search">
|
||||
<button type="submit" aria-label="{{ 'search.submit'|trans }}">
|
||||
<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') }}">
|
||||
<label for="navSearchInput" style="display:none;">{{ 'search.label'|trans }}</label>
|
||||
<input id="navSearchInput" type="text" name="q" placeholder="{{ 'search.placeholder'|trans }}" 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>
|
||||
{% include '_language_switcher.html.twig' %}
|
||||
<button class="nav-burger" aria-label="{{ 'nav.menu_open'|trans }}" 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>
|
||||
<nav aria-label="{{ 'nav.primary'|trans }}">
|
||||
<a href="{{ path('home') }}" class="{{ activeNav == 'home' ? 'is-active' : '' }}">{{ 'nav.home'|trans }}</a>
|
||||
<a href="{{ path('event_list') }}" class="{{ activeNav == 'events' ? 'is-active' : '' }}">{{ 'nav.browse'|trans }}</a>
|
||||
<a href="{{ path('map') }}" class="{{ activeNav == 'map' ? 'is-active' : '' }}">{{ 'nav.map'|trans }}</a>
|
||||
</nav>
|
||||
<form class="mobile-search" action="{{ path('event_list') }}" 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>
|
||||
<label for="mobileSearchInput" style="display:none;">{{ 'search.label'|trans }}</label>
|
||||
<input id="mobileSearchInput" type="text" name="q" placeholder="{{ 'search.placeholder'|trans }}" autocomplete="off" value="{{ app.request.query.get('q') }}">
|
||||
<button type="submit">{{ 'search.submit'|trans }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
@@ -59,6 +60,8 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>window.VINDN_I18N = {{ vindn_i18n_json() }};</script>
|
||||
<script src="{{ asset('js/i18n.js') }}"></script>
|
||||
<script src="{{ asset('js/location.js') }}"></script>
|
||||
<script src="{{ asset('js/dates.js') }}"></script>
|
||||
<script src="{{ asset('js/nav.js') }}"></script>
|
||||
|
||||
@@ -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|de_datetime : 'Date TBD' }}</p>
|
||||
<p class="when">{{ event.startDateTime ? event.startDateTime|format_datetime : 'card.tbd'|trans }}</p>
|
||||
<p class="place">
|
||||
{{ [event.street, event.city]|filter(v => v)|join(', ') ?: event.country }}
|
||||
{% if dist is defined and dist is not null %}
|
||||
· {{ dist|de_distance }} away
|
||||
· {{ 'card.away'|trans({'%distance%': dist|format_distance}) }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Browse events — VINDN{% endblock %}
|
||||
{% block title %}{{ 'browse.title'|trans }}{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
{% include '_datepicker_head.html.twig' %}
|
||||
@@ -77,19 +77,19 @@
|
||||
{% block body %}
|
||||
<section class="section" style="padding-bottom:0;">
|
||||
<div class="wrap">
|
||||
<p class="eyebrow" style="margin-bottom:10px;">All events</p>
|
||||
<p class="eyebrow" style="margin-bottom:10px;">{{ 'browse.eyebrow'|trans }}</p>
|
||||
<div class="toolbar">
|
||||
<h1 style="font-size:clamp(28px,4vw,40px);">{{ q ? 'Searching: "' ~ q ~ '"' : "What's on" }}{{ activeCategory ? ' — ' ~ activeCategory : '' }}</h1>
|
||||
<h1 style="font-size:clamp(28px,4vw,40px);">{{ q ? 'browse.heading_search'|trans({'%q%': q}) : 'browse.heading'|trans }}{{ activeCategory ? ' — ' ~ activeCategory : '' }}</h1>
|
||||
<div style="display:flex; align-items:center; gap:12px;">
|
||||
<p class="mono" style="font-size:13px; color:var(--muted); margin:0;">{{ total|de_number }} event{{ total == 1 ? '' : 's' }}</p>
|
||||
<label for="sortSelect" style="display:none;">Sort by</label>
|
||||
<p class="mono" style="font-size:13px; color:var(--muted); margin:0;">{{ 'browse.count'|trans({'%count%': total, '%n%': total|format_number}) }}</p>
|
||||
<label for="sortSelect" style="display:none;">{{ 'browse.sort.label'|trans }}</label>
|
||||
<select id="sortSelect" style="padding:7px 8px; border:1px solid var(--line-strong); border-radius:6px; font-family:var(--f-mono); font-size:12px; background:#fff;">
|
||||
<option value="soonest" data-href="{{ path('event_list', filters|merge({sort: 'soonest'})) }}" {{ 'soonest' == sort ? 'selected' : '' }}>Sort: Soonest</option>
|
||||
<option value="soonest" data-href="{{ path('event_list', filters|merge({sort: 'soonest'})) }}" {{ 'soonest' == sort ? 'selected' : '' }}>{{ 'browse.sort.soonest'|trans }}</option>
|
||||
{% if hasLocation %}
|
||||
<option value="nearest" data-href="{{ path('event_list', filters|merge({sort: 'nearest'})) }}" {{ 'nearest' == sort ? 'selected' : '' }}>Sort: Nearest</option>
|
||||
<option value="nearest" data-href="{{ path('event_list', filters|merge({sort: 'nearest'})) }}" {{ 'nearest' == sort ? 'selected' : '' }}>{{ 'browse.sort.nearest'|trans }}</option>
|
||||
{% endif %}
|
||||
<option value="az" data-href="{{ path('event_list', filters|merge({sort: 'az'})) }}" {{ 'az' == sort ? 'selected' : '' }}>Sort: A–Z</option>
|
||||
<option value="za" data-href="{{ path('event_list', filters|merge({sort: 'za'})) }}" {{ 'za' == sort ? 'selected' : '' }}>Sort: Z–A</option>
|
||||
<option value="az" data-href="{{ path('event_list', filters|merge({sort: 'az'})) }}" {{ 'az' == sort ? 'selected' : '' }}>{{ 'browse.sort.az'|trans }}</option>
|
||||
<option value="za" data-href="{{ path('event_list', filters|merge({sort: 'za'})) }}" {{ 'za' == sort ? 'selected' : '' }}>{{ 'browse.sort.za'|trans }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,31 +103,31 @@
|
||||
<input type="hidden" name="lon" value="{{ lon }}">
|
||||
<input type="hidden" name="range" value="{{ rangeKm }}">
|
||||
{% endif %}
|
||||
<label for="q" style="display:none;">Search events</label>
|
||||
<input id="q" name="q" type="text" value="{{ q }}" placeholder="Search by event, artist or venue…">
|
||||
<button class="btn btn-primary" type="submit">Search</button>
|
||||
<label for="q" style="display:none;">{{ 'search.label'|trans }}</label>
|
||||
<input id="q" name="q" type="text" value="{{ q }}" placeholder="{{ 'browse.search.placeholder'|trans }}">
|
||||
<button class="btn btn-primary" type="submit">{{ 'browse.search.submit'|trans }}</button>
|
||||
</form>
|
||||
|
||||
{% set activeCount = (activeCategory ? 1 : 0) + (dateFrom or dateTo ? 1 : 0) + (hasLocation ? 1 : 0) %}
|
||||
<div class="filter-bar">
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="openFilters" aria-haspopup="dialog">Filters{% if activeCount %}<span class="count">{{ activeCount }}</span>{% endif %}</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="openFilters" aria-haspopup="dialog">{{ 'browse.filters.button'|trans }}{% if activeCount %}<span class="count">{{ activeCount }}</span>{% endif %}</button>
|
||||
{% if activeCategory %}
|
||||
<a class="filter-pill" href="{{ path('event_list', filters|merge({category: null})) }}" aria-label="Remove category filter {{ activeCategory }}">{{ activeCategory }}<span class="x" aria-hidden="true">×</span></a>
|
||||
<a class="filter-pill" href="{{ path('event_list', filters|merge({category: null})) }}" aria-label="{{ 'browse.filters.remove_category'|trans({'%name%': activeCategory}) }}">{{ activeCategory }}<span class="x" aria-hidden="true">×</span></a>
|
||||
{% endif %}
|
||||
{% if dateFrom or dateTo %}
|
||||
{% if dateFrom == today and dateTo == today %}{% set dateLabel = '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>
|
||||
{% if dateFrom == today and dateTo == today %}{% set dateLabel = 'dates.today'|trans %}
|
||||
{% elseif dateFrom == today and dateTo == weekEnd %}{% set dateLabel = 'dates.next7'|trans %}
|
||||
{% elseif dateFrom and dateTo and dateFrom == dateTo %}{% set dateLabel = dateFrom|format_date %}
|
||||
{% elseif dateFrom and dateTo %}{% set dateLabel = dateFrom|format_date ~ ' – ' ~ dateTo|format_date %}
|
||||
{% elseif dateFrom %}{% set dateLabel = 'browse.filters.from'|trans({'%date%': dateFrom|format_date}) %}
|
||||
{% else %}{% set dateLabel = 'browse.filters.until'|trans({'%date%': dateTo|format_date}) %}{% endif %}
|
||||
<a class="filter-pill" data-forget="dates" href="{{ path('event_list', filters|merge({from: null, to: null})) }}" aria-label="{{ 'browse.filters.remove_dates'|trans({'%label%': dateLabel}) }}">{{ dateLabel }}<span class="x" aria-hidden="true">×</span></a>
|
||||
{% endif %}
|
||||
{% if hasLocation %}
|
||||
<a class="filter-pill" id="locationPill" data-forget="location" data-lat="{{ lat }}" data-lon="{{ lon }}" href="{{ path('event_list', filters|merge({lat: null, lon: null, range: null})) }}" aria-label="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>
|
||||
<a class="filter-pill" id="locationPill" data-forget="location" data-lat="{{ lat }}" data-lon="{{ lon }}" href="{{ path('event_list', filters|merge({lat: null, lon: null, range: null})) }}" aria-label="{{ 'browse.filters.remove_location'|trans }}"><span class="label">{{ 'browse.filters.within_coords'|trans({'%km%': rangeKm, '%lat%': lat|format_number(3), '%lon%': lon|format_number(3)}) }}</span><span class="x" aria-hidden="true">×</span></a>
|
||||
{% endif %}
|
||||
{% if activeCount %}
|
||||
<a class="filter-clear-all" data-forget="all" href="{{ path('event_list', {q: q ?: null, sort: filters.sort}) }}">Clear all</a>
|
||||
<a class="filter-clear-all" data-forget="all" href="{{ path('event_list', {q: q ?: null, sort: filters.sort}) }}">{{ 'browse.filters.clear_all'|trans }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,15 +138,15 @@
|
||||
{% 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>
|
||||
<h2 id="filterDialogTitle">{{ 'browse.filters.dialog'|trans }}</h2>
|
||||
<button type="button" class="fd-close" id="closeFilters" aria-label="{{ 'browse.filters.close'|trans }}">×</button>
|
||||
</div>
|
||||
|
||||
<div class="fd-body">
|
||||
<div class="fd-section">
|
||||
<span class="filter-label">Category</span>
|
||||
<span class="filter-label">{{ 'browse.filters.category'|trans }}</span>
|
||||
<div class="pick-row">
|
||||
<label class="pick"><input type="radio" name="category" value="" {{ activeCategory is null ? 'checked' : '' }}><span>All</span></label>
|
||||
<label class="pick"><input type="radio" name="category" value="" {{ activeCategory is null ? 'checked' : '' }}><span>{{ 'map.all'|trans }}</span></label>
|
||||
{% for category in categories %}
|
||||
<label class="pick"><input type="radio" name="category" value="{{ category }}" {{ activeCategory == category ? 'checked' : '' }}><span>{{ category }}</span></label>
|
||||
{% endfor %}
|
||||
@@ -154,46 +154,46 @@
|
||||
</div>
|
||||
|
||||
<div class="fd-section">
|
||||
<span class="filter-label">When</span>
|
||||
<span class="filter-label">{{ 'browse.filters.when'|trans }}</span>
|
||||
<div class="when-filter">
|
||||
<div class="chip-row-tight" id="datePresets">
|
||||
<button type="button" class="chip-sm" data-from="" data-to="">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>
|
||||
<button type="button" class="chip-sm" data-from="" data-to="">{{ 'dates.any'|trans }}</button>
|
||||
<button type="button" class="chip-sm" data-from="{{ today }}" data-to="{{ today }}">{{ 'dates.today'|trans }}</button>
|
||||
<button type="button" class="chip-sm" data-from="{{ today }}" data-to="{{ weekEnd }}">{{ 'dates.next7'|trans }}</button>
|
||||
</div>
|
||||
<div class="date-range" role="group" aria-label="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 class="date-range" role="group" aria-label="{{ 'dates.group'|trans }}">
|
||||
<label for="dateFrom">{{ 'dates.from'|trans }}</label>
|
||||
<input type="date" id="dateFrom" name="from" aria-label="{{ 'dates.from_aria'|trans }}" min="{{ today }}" value="{{ dateFrom }}">
|
||||
<label for="dateTo">{{ 'dates.to'|trans }}</label>
|
||||
<input type="date" id="dateTo" name="to" aria-label="{{ 'dates.to_aria'|trans }}" min="{{ today }}" value="{{ dateTo }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fd-section">
|
||||
<span class="filter-label">Near a location</span>
|
||||
<span class="filter-label">{{ 'browse.filters.near'|trans }}</span>
|
||||
<input type="hidden" name="lat" id="fLat" value="{{ hasLocation ? lat : '' }}">
|
||||
<input type="hidden" name="lon" id="fLon" value="{{ hasLocation ? lon : '' }}">
|
||||
<div class="location-filter">
|
||||
<label for="locationSearchInput" style="display:none;">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>
|
||||
<label for="rangeSelect" style="display:none;">Radius</label>
|
||||
<label for="locationSearchInput" style="display:none;">{{ 'location.search_label'|trans }}</label>
|
||||
<input id="locationSearchInput" type="text" placeholder="{{ 'location.search_placeholder'|trans }}" autocomplete="off">
|
||||
<button class="btn btn-ghost btn-sm" type="button" id="locationGo">{{ 'location.go'|trans }}</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="useMyLocationBtn">{{ 'location.use_mine'|trans }}</button>
|
||||
<label for="rangeSelect" style="display:none;">{{ 'location.radius'|trans }}</label>
|
||||
<select id="rangeSelect" name="range">
|
||||
{% for km in rangeOptions %}
|
||||
<option value="{{ km }}" {{ km == rangeKm ? 'selected' : '' }}>{{ km }} km</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" class="fd-link" id="clearLocation" {{ hasLocation ? '' : 'hidden' }}>clear</button>
|
||||
<button type="button" class="fd-link" id="clearLocation" {{ hasLocation ? '' : 'hidden' }}>{{ 'location.clear'|trans }}</button>
|
||||
</div>
|
||||
<p class="locate-status" id="locationStatus">{% if hasLocation %}Within {{ rangeKm }} km of {{ lat|de_number(3) }}, {{ lon|de_number(3) }}{% endif %}</p>
|
||||
<p class="locate-status" id="locationStatus">{% if hasLocation %}{{ 'browse.filters.within_coords'|trans({'%km%': rangeKm, '%lat%': lat|format_number(3), '%lon%': lon|format_number(3)}) }}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fd-foot">
|
||||
<button type="button" class="fd-link" id="resetFilters">Reset all</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Show events</button>
|
||||
<button type="button" class="fd-link" id="resetFilters">{{ 'browse.filters.reset'|trans }}</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">{{ 'browse.filters.apply'|trans }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
@@ -205,8 +205,8 @@
|
||||
{% include 'events/_card.html.twig' with { event: event, dist: hasLocation ? distances[event.id]|default(null) : null } %}
|
||||
{% else %}
|
||||
<div class="no-results" style="grid-column:1/-1; padding:60px 20px; text-align:center; color:var(--muted); border:1px dashed var(--line-strong); border-radius:var(--r-card);">
|
||||
<p class="eyebrow" style="margin-bottom:8px;">No events</p>
|
||||
<p>Nothing matches those filters yet — try clearing one, or check back once the importer has run.</p>
|
||||
<p class="eyebrow" style="margin-bottom:8px;">{{ 'browse.empty.title'|trans }}</p>
|
||||
<p>{{ 'browse.empty.text'|trans }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -302,7 +302,7 @@
|
||||
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(',');
|
||||
if (pillLabel) pillLabel.textContent = window.VindnI18n.t('browse.within', { km: rangeSelect.value, place: saved.name.split(',').slice(0, 2).join(',') });
|
||||
}
|
||||
|
||||
function setStatus(text, isError) {
|
||||
@@ -315,7 +315,7 @@
|
||||
placeInput.value = name || '';
|
||||
resolvedName = name || '';
|
||||
clearLocationBtn.hidden = false;
|
||||
setStatus('Found: ' + (name || lat.toFixed(3) + ', ' + lon.toFixed(3)), false);
|
||||
setStatus(window.VindnI18n.t('location.found', { name: name || lat.toFixed(3) + ', ' + lon.toFixed(3) }), false);
|
||||
}
|
||||
function clearLocation() {
|
||||
latEl.value = '';
|
||||
@@ -327,10 +327,10 @@
|
||||
}
|
||||
function lookup(query, done) {
|
||||
window.VindnLocation.searchPlace(query, {
|
||||
onStart: function () { setStatus('Looking up "' + query + '"…', false); },
|
||||
onNotFound: function () { setStatus('Could not find "' + query + '"', true); },
|
||||
onStart: function () { setStatus(window.VindnI18n.t('location.looking_up', { query: query }), false); },
|
||||
onNotFound: function () { setStatus(window.VindnI18n.t('location.not_found', { query: query }), true); },
|
||||
onFound: function (lat, lon, name) { setLocation(lat, lon, name); if (done) done(); },
|
||||
onError: function () { setStatus('Location search failed', true); }
|
||||
onError: function () { setStatus(window.VindnI18n.t('location.search_failed'), true); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -348,10 +348,10 @@
|
||||
});
|
||||
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); },
|
||||
onUnsupported: function () { setStatus(window.VindnI18n.t('location.unsupported'), true); },
|
||||
onStart: function () { setStatus(window.VindnI18n.t('location.finding'), false); },
|
||||
onFound: setLocation,
|
||||
onError: function () { setStatus("Couldn't get your location", true); }
|
||||
onError: function () { setStatus(window.VindnI18n.t('location.failed'), true); }
|
||||
});
|
||||
});
|
||||
clearLocationBtn.addEventListener('click', clearLocation);
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="wrap">
|
||||
|
||||
<p class="breadcrumb">
|
||||
<a href="{{ path('event_list') }}">Browse events</a>
|
||||
<a href="{{ path('event_list') }}">{{ 'nav.browse'|trans }}</a>
|
||||
{% if category %} / <a href="{{ path('event_list', {category: category}) }}">{{ category }}</a>{% endif %}
|
||||
/ {{ event.title }}
|
||||
</p>
|
||||
@@ -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|de_datetime|upper : 'DATE TBD' }}
|
||||
{{ event.startDateTime ? event.startDateTime|format_datetime|upper : 'event.date_tbd'|trans|upper }}
|
||||
{% if event.city %} · {{ event.city|upper }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
@@ -64,19 +64,19 @@
|
||||
<div>
|
||||
{% if event.description %}
|
||||
<div class="prose">
|
||||
<h2>About this event</h2>
|
||||
<h2>{{ 'event.about'|trans }}</h2>
|
||||
<p>{{ event.description }}</p>
|
||||
</div>
|
||||
{% elseif event.teaser %}
|
||||
<div class="prose">
|
||||
<h2>About this event</h2>
|
||||
<h2>{{ 'event.about'|trans }}</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>
|
||||
<h2 style="margin-top:36px;">{{ 'event.location'|trans }}</h2>
|
||||
<div id="eventMapCanvas" role="application" aria-label="{{ 'event.map_aria'|trans }}"></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(', ') }}
|
||||
@@ -84,17 +84,17 @@
|
||||
</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|de_datetime : 'TBD' }}</span></div>
|
||||
<aside class="info-board" aria-label="{{ 'event.details_aria'|trans }}">
|
||||
<div class="row"><span class="k">{{ 'event.starts'|trans }}</span><span class="v">{{ event.startDateTime ? event.startDateTime|format_datetime : 'event.tbd'|trans }}</span></div>
|
||||
{% if event.endDateTime %}
|
||||
<div class="row"><span class="k">Ends</span><span class="v">{{ event.endDateTime|de_datetime }}</span></div>
|
||||
<div class="row"><span class="k">{{ 'event.ends'|trans }}</span><span class="v">{{ event.endDateTime|format_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 %}
|
||||
{% 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.city %}<div class="row"><span class="k">{{ 'event.city'|trans }}</span><span class="v">{{ event.city }}</span></div>{% endif %}
|
||||
{% if event.type %}<div class="row"><span class="k">{{ 'event.type'|trans }}</span><span class="v">{{ event.type }}</span></div>{% endif %}
|
||||
{% if event.phone %}<div class="row"><span class="k">{{ 'event.phone'|trans }}</span><span class="v"><a href="tel:{{ event.phone }}">{{ event.phone }}</a></span></div>{% endif %}
|
||||
{% if event.email %}<div class="row"><span class="k">{{ 'event.email'|trans }}</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>
|
||||
<div class="row" style="border-bottom:none;"><span class="k">{{ 'event.website'|trans }}</span><span class="v"><a href="{{ event.web }}" target="_blank" rel="noopener">{{ 'event.visit'|trans }}</a></span></div>
|
||||
{% endif %}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}VINDN — Find what's on{% endblock %}
|
||||
{% block title %}{{ 'home.title'|trans }}{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
{% include '_leaflet_head.html.twig' %}
|
||||
@@ -14,8 +14,8 @@
|
||||
<div class="wrap">
|
||||
<p class="eyebrow" style="margin-bottom:8px;"></p>
|
||||
<div class="section-head">
|
||||
<h1 style="font-size:clamp(28px,4vw,40px);">Was in deiner Nähe los ist</h1>
|
||||
<a href="{{ path('map') }}" class="btn btn-primary btn-sm">Open full map →</a>
|
||||
<h1 style="font-size:clamp(28px,4vw,40px);">{{ 'home.hero.title'|trans }}</h1>
|
||||
<a href="{{ path('map') }}" class="btn btn-primary btn-sm">{{ 'home.hero.map_link'|trans }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -28,10 +28,10 @@
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow" style="margin-bottom:8px;">Browse</p>
|
||||
<h2>Find events by category</h2>
|
||||
<p class="eyebrow" style="margin-bottom:8px;">{{ 'home.browse.eyebrow'|trans }}</p>
|
||||
<h2>{{ 'home.browse.title'|trans }}</h2>
|
||||
</div>
|
||||
<a href="{{ path('event_list') }}" class="section-link">Browse all events →</a>
|
||||
<a href="{{ path('event_list') }}" class="section-link">{{ 'home.browse.all'|trans }}</a>
|
||||
</div>
|
||||
<div class="chip-row">
|
||||
{% for category in popularCategories %}
|
||||
@@ -46,16 +46,16 @@
|
||||
<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>
|
||||
<p class="eyebrow" style="margin-bottom:8px;">{{ 'home.recent.eyebrow'|trans }}</p>
|
||||
<h2>{{ 'home.recent.title'|trans }}</h2>
|
||||
</div>
|
||||
<a href="{{ path('event_list') }}" class="section-link">Browse all events →</a>
|
||||
<a href="{{ path('event_list') }}" class="section-link">{{ 'home.browse.all'|trans }}</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>
|
||||
<p style="color:var(--muted);">{{ 'home.recent.empty'|trans }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,29 +67,29 @@
|
||||
<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>
|
||||
<p style="max-width:260px; font-size:14px; color:var(--muted);">{{ 'home.footer.tagline'|trans }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Explore</h4>
|
||||
<h4>{{ 'home.footer.explore'|trans }}</h4>
|
||||
<ul>
|
||||
<li><a href="{{ path('event_list') }}">Browse events</a></li>
|
||||
<li><a href="{{ path('map') }}">Map</a></li>
|
||||
<li><a href="{{ path('event_list') }}">{{ 'nav.browse'|trans }}</a></li>
|
||||
<li><a href="{{ path('map') }}">{{ 'nav.map'|trans }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Organizers</h4>
|
||||
<h4>{{ 'home.footer.organizers'|trans }}</h4>
|
||||
<ul>
|
||||
<li><a href="#">List an event</a></li>
|
||||
<li><a href="#">Pricing</a></li>
|
||||
<li><a href="#">Guidelines</a></li>
|
||||
<li><a href="#">{{ 'home.footer.list_event'|trans }}</a></li>
|
||||
<li><a href="#">{{ 'home.footer.pricing'|trans }}</a></li>
|
||||
<li><a href="#">{{ 'home.footer.guidelines'|trans }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Company</h4>
|
||||
<h4>{{ 'home.footer.company'|trans }}</h4>
|
||||
<ul>
|
||||
<li><a href="#">About</a></li>
|
||||
<li><a href="#">Contact</a></li>
|
||||
<li><a href="#">Privacy</a></li>
|
||||
<li><a href="#">{{ 'home.footer.about'|trans }}</a></li>
|
||||
<li><a href="#">{{ 'home.footer.contact'|trans }}</a></li>
|
||||
<li><a href="#">{{ 'home.footer.privacy'|trans }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Map — VINDN{% endblock %}
|
||||
{% block title %}{{ 'map.title'|trans }}{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
{% include '_leaflet_head.html.twig' %}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Datums-/Zahlenkonventionen (siehe src/Twig/LocaleFormatExtension.php).
|
||||
# `date` ist ein PHP-Datumsformat und zugleich das flatpickr-Anzeigeformat.
|
||||
date: 'd.m.Y'
|
||||
time: 'H:i'
|
||||
datetime: '%weekday%, %date% · %time%'
|
||||
decimal_separator: ','
|
||||
thousands_separator: '.'
|
||||
weekday:
|
||||
0: 'So'
|
||||
1: 'Mo'
|
||||
2: 'Di'
|
||||
3: 'Mi'
|
||||
4: 'Do'
|
||||
5: 'Fr'
|
||||
6: 'Sa'
|
||||
@@ -0,0 +1,13 @@
|
||||
date: 'Y-m-d'
|
||||
time: 'H:i'
|
||||
datetime: '%weekday%, %date% · %time%'
|
||||
decimal_separator: '.'
|
||||
thousands_separator: ','
|
||||
weekday:
|
||||
0: 'Sun'
|
||||
1: 'Mon'
|
||||
2: 'Tue'
|
||||
3: 'Wed'
|
||||
4: 'Thu'
|
||||
5: 'Fri'
|
||||
6: 'Sat'
|
||||
@@ -0,0 +1,34 @@
|
||||
# Texte, die das Browser-JavaScript braucht (public/js/*.js über VindnI18n.t()).
|
||||
# Plurale: <schlüssel>.one / .other (Regeln der Sprache über Intl.PluralRules).
|
||||
dates:
|
||||
placeholder: 'TT.MM.JJJJ'
|
||||
|
||||
location:
|
||||
unsupported: 'Standort wird von deinem Browser nicht unterstützt'
|
||||
finding: 'Suche dich …'
|
||||
failed: 'Dein Standort konnte nicht ermittelt werden'
|
||||
looking_up: 'Suche „%query%“ …'
|
||||
not_found: '„%query%“ wurde nicht gefunden'
|
||||
search_failed: 'Ortssuche fehlgeschlagen'
|
||||
yours: 'Dein Standort'
|
||||
found: 'Gefunden: %name%'
|
||||
|
||||
map:
|
||||
status_nearest: 'Zeige die %limit% nächsten Veranstaltungen'
|
||||
status_near_you: 'Zeige Veranstaltungen in deiner Nähe'
|
||||
status_within: 'Zeige Veranstaltungen im Umkreis von %km% km'
|
||||
count:
|
||||
one: '%n% Veranstaltung'
|
||||
other: '%n% Veranstaltungen'
|
||||
view_event: 'Zur Veranstaltung →'
|
||||
zoom_in: 'Vergrößern'
|
||||
zoom_out: 'Verkleinern'
|
||||
fullscreen: 'Vollbild'
|
||||
fullscreen_exit: 'Vollbild beenden'
|
||||
gesture:
|
||||
touch: 'Bewege die Karte mit zwei Fingern'
|
||||
scroll: 'Halte Strg gedrückt und scrolle, um die Karte zu zoomen'
|
||||
scroll_mac: 'Halte ⌘ gedrückt und scrolle, um die Karte zu zoomen'
|
||||
|
||||
browse:
|
||||
within: 'Im Umkreis von %km% km um %place%'
|
||||
@@ -0,0 +1,32 @@
|
||||
dates:
|
||||
placeholder: 'YYYY-MM-DD'
|
||||
|
||||
location:
|
||||
unsupported: 'Location not supported by your browser'
|
||||
finding: 'Finding you…'
|
||||
failed: "Couldn't get your location"
|
||||
looking_up: 'Looking up "%query%"…'
|
||||
not_found: 'Could not find "%query%"'
|
||||
search_failed: 'Location search failed'
|
||||
yours: 'Your location'
|
||||
found: 'Found: %name%'
|
||||
|
||||
map:
|
||||
status_nearest: 'Showing the %limit% nearest events'
|
||||
status_near_you: 'Showing events near you'
|
||||
status_within: 'Showing events within %km% km'
|
||||
count:
|
||||
one: '%n% event'
|
||||
other: '%n% events'
|
||||
view_event: 'View event →'
|
||||
zoom_in: 'Zoom in'
|
||||
zoom_out: 'Zoom out'
|
||||
fullscreen: 'View fullscreen'
|
||||
fullscreen_exit: 'Exit fullscreen'
|
||||
gesture:
|
||||
touch: 'Use two fingers to move the map'
|
||||
scroll: 'Use Ctrl + scroll to zoom the map'
|
||||
scroll_mac: 'Use ⌘ + scroll to zoom the map'
|
||||
|
||||
browse:
|
||||
within: 'Within %km% km of %place%'
|
||||
@@ -0,0 +1,150 @@
|
||||
# Seitentexte (Twig). Deutsch ist die Standardsprache — neue Sprachen:
|
||||
# diese Datei als messages.<code>.yaml kopieren und übersetzen, dazu
|
||||
# js.<code>.yaml und formats.<code>.yaml (siehe config/packages/translation.yaml).
|
||||
skip_link: 'Zum Inhalt springen'
|
||||
|
||||
nav:
|
||||
home: 'Start'
|
||||
browse: 'Events entdecken'
|
||||
map: 'Karte'
|
||||
primary: 'Hauptnavigation'
|
||||
home_aria: 'VINDN Startseite'
|
||||
menu_open: 'Menü öffnen'
|
||||
|
||||
search:
|
||||
label: 'Events suchen'
|
||||
placeholder: 'Events suchen …'
|
||||
submit: 'Suchen'
|
||||
|
||||
language:
|
||||
label: 'Sprache'
|
||||
name:
|
||||
de: 'Deutsch'
|
||||
en: 'English'
|
||||
|
||||
# Zeitraum-Auswahl (Events, Startseiten-Karte, /map)
|
||||
dates:
|
||||
from: 'Von'
|
||||
to: 'bis'
|
||||
from_aria: 'Datum von'
|
||||
to_aria: 'Datum bis'
|
||||
group: 'Zeitraum'
|
||||
any: 'Beliebiges Datum'
|
||||
today: 'Heute'
|
||||
next7: 'Nächste 7 Tage'
|
||||
clear: 'zurücksetzen'
|
||||
|
||||
# Orts-Auswahl (Events, Startseiten-Karte, /map)
|
||||
location:
|
||||
search_label: 'Ort suchen'
|
||||
search_placeholder: 'Ort suchen …'
|
||||
go: 'Los'
|
||||
use_mine: 'Meinen Standort verwenden'
|
||||
radius: 'Umkreis'
|
||||
clear: 'entfernen'
|
||||
|
||||
home:
|
||||
title: 'VINDN — Finde, was los ist'
|
||||
hero:
|
||||
title: 'Was in deiner Nähe los ist'
|
||||
map_link: 'Gesamte Karte öffnen →'
|
||||
browse:
|
||||
eyebrow: 'Entdecken'
|
||||
title: 'Events nach Kategorie finden'
|
||||
all: 'Alle Events ansehen →'
|
||||
recent:
|
||||
eyebrow: 'Frisch importiert'
|
||||
title: 'Zum Vormerken'
|
||||
empty: 'Noch keine Events importiert — schau wieder vorbei, sobald der Import gelaufen ist.'
|
||||
footer:
|
||||
tagline: 'Das Schwarze Brett für alles, was los ist — wo auch immer du bist.'
|
||||
explore: 'Entdecken'
|
||||
organizers: 'Veranstalter'
|
||||
list_event: 'Event eintragen'
|
||||
pricing: 'Preise'
|
||||
guidelines: 'Richtlinien'
|
||||
company: 'Unternehmen'
|
||||
about: 'Über uns'
|
||||
contact: 'Kontakt'
|
||||
privacy: 'Datenschutz'
|
||||
|
||||
card:
|
||||
tbd: 'Datum folgt'
|
||||
away: '%distance% entfernt'
|
||||
|
||||
event:
|
||||
about: 'Über diese Event'
|
||||
location: 'Ort'
|
||||
date_tbd: 'Datum folgt'
|
||||
tbd: 'folgt'
|
||||
map_aria: 'Karte mit dem Eventsort'
|
||||
details_aria: 'Eventsdetails'
|
||||
starts: 'Beginn'
|
||||
ends: 'Ende'
|
||||
city: 'Stadt'
|
||||
type: 'Art'
|
||||
phone: 'Telefon'
|
||||
email: 'E-Mail'
|
||||
website: 'Website'
|
||||
visit: 'Zur Website →'
|
||||
not_found: 'Event nicht gefunden.'
|
||||
|
||||
map:
|
||||
title: 'Karte — VINDN'
|
||||
canvas_aria: 'Karte der Events'
|
||||
categories: 'Kategorien'
|
||||
all: 'Alle'
|
||||
go_aria: 'Karte auf diesen Ort zentrieren'
|
||||
status_region: 'Zeige Events in der Nähe von Nordrhein-Westfalen'
|
||||
status_within: 'Zeige Events im Umkreis von %km% km'
|
||||
panel_aria: 'Events'
|
||||
panel_nearest: 'In deiner Nähe'
|
||||
panel_events: 'Events'
|
||||
count_placeholder: '— Events'
|
||||
|
||||
browse:
|
||||
title: 'Events — VINDN'
|
||||
eyebrow: 'Alle Events'
|
||||
heading: 'Was los ist'
|
||||
heading_search: 'Suche: „%q%“'
|
||||
count: '{0} Keine Events|{1} 1 Event|]1,Inf[ %n% Events'
|
||||
sort:
|
||||
label: 'Sortieren nach'
|
||||
soonest: 'Sortierung: Demnächst'
|
||||
nearest: 'Sortierung: Nächste zuerst'
|
||||
az: 'Sortierung: A–Z'
|
||||
za: 'Sortierung: Z–A'
|
||||
search:
|
||||
placeholder: 'Nach Event, Künstler oder Ort suchen …'
|
||||
submit: 'Suchen'
|
||||
filters:
|
||||
button: 'Filter'
|
||||
clear_all: 'Alle Filter zurücksetzen'
|
||||
remove_category: 'Kategorie-Filter %name% entfernen'
|
||||
remove_dates: 'Datums-Filter %label% entfernen'
|
||||
remove_location: 'Orts-Filter entfernen'
|
||||
from: 'Ab %date%'
|
||||
until: 'Bis %date%'
|
||||
within_coords: 'Im Umkreis von %km% km um %lat%, %lon%'
|
||||
dialog: 'Filter'
|
||||
close: 'Schließen'
|
||||
category: 'Kategorie'
|
||||
when: 'Wann'
|
||||
near: 'In der Nähe eines Ortes'
|
||||
reset: 'Alle Filter zurücksetzen'
|
||||
apply: 'Events anzeigen'
|
||||
empty:
|
||||
title: 'Keine Events'
|
||||
text: 'Mit diesen Filtern gibt es noch nichts — setz einen zurück oder schau wieder vorbei, sobald der Import gelaufen ist.'
|
||||
|
||||
pager:
|
||||
nav: 'Seitennavigation'
|
||||
first: '« Erste'
|
||||
first_aria: 'Erste Seite'
|
||||
prev: '‹ Zurück'
|
||||
prev_aria: 'Vorherige Seite'
|
||||
next: 'Weiter ›'
|
||||
next_aria: 'Nächste Seite'
|
||||
last: 'Letzte »'
|
||||
last_aria: 'Letzte Seite'
|
||||
page: 'Seite %current% von %total%'
|
||||
@@ -0,0 +1,145 @@
|
||||
skip_link: 'Skip to content'
|
||||
|
||||
nav:
|
||||
home: 'Home'
|
||||
browse: 'Discover events'
|
||||
map: 'Map'
|
||||
primary: 'Primary'
|
||||
home_aria: 'VINDN home'
|
||||
menu_open: 'Open menu'
|
||||
|
||||
search:
|
||||
label: 'Search events'
|
||||
placeholder: 'Search events…'
|
||||
submit: 'Search'
|
||||
|
||||
language:
|
||||
label: 'Language'
|
||||
name:
|
||||
de: 'Deutsch'
|
||||
en: 'English'
|
||||
|
||||
dates:
|
||||
from: 'From'
|
||||
to: 'to'
|
||||
from_aria: 'From date'
|
||||
to_aria: 'To date'
|
||||
group: 'Date range'
|
||||
any: 'Any date'
|
||||
today: 'Today'
|
||||
next7: 'Next 7 days'
|
||||
clear: 'clear'
|
||||
|
||||
location:
|
||||
search_label: 'Search a place'
|
||||
search_placeholder: 'Search a place…'
|
||||
go: 'Go'
|
||||
use_mine: 'Use my location'
|
||||
radius: 'Range'
|
||||
clear: 'clear'
|
||||
|
||||
home:
|
||||
title: "VINDN — Find what's on"
|
||||
hero:
|
||||
title: "What's on near you"
|
||||
map_link: 'Open full map →'
|
||||
browse:
|
||||
eyebrow: 'Browse'
|
||||
title: 'Find events by category'
|
||||
all: 'Browse all events →'
|
||||
recent:
|
||||
eyebrow: 'Live from the importer'
|
||||
title: 'Worth planning around'
|
||||
empty: "No events imported yet — check back once the backend's import job has run."
|
||||
footer:
|
||||
tagline: 'The board for everything on, wherever you are.'
|
||||
explore: 'Explore'
|
||||
organizers: 'Organizers'
|
||||
list_event: 'List an event'
|
||||
pricing: 'Pricing'
|
||||
guidelines: 'Guidelines'
|
||||
company: 'Company'
|
||||
about: 'About'
|
||||
contact: 'Contact'
|
||||
privacy: 'Privacy'
|
||||
|
||||
card:
|
||||
tbd: 'Date TBD'
|
||||
away: '%distance% away'
|
||||
|
||||
event:
|
||||
about: 'About this event'
|
||||
location: 'Location'
|
||||
date_tbd: 'Date TBD'
|
||||
tbd: 'TBD'
|
||||
map_aria: 'Map showing the event location'
|
||||
details_aria: 'Event details'
|
||||
starts: 'Starts'
|
||||
ends: 'Ends'
|
||||
city: 'City'
|
||||
type: 'Type'
|
||||
phone: 'Phone'
|
||||
email: 'Email'
|
||||
website: 'Website'
|
||||
visit: 'Visit site →'
|
||||
not_found: 'Event not found.'
|
||||
|
||||
map:
|
||||
title: 'Map — VINDN'
|
||||
canvas_aria: 'Map of events'
|
||||
categories: 'Categories'
|
||||
all: 'All'
|
||||
go_aria: 'Center map on this place'
|
||||
status_region: 'Showing events near Nordrhein-Westfalen'
|
||||
status_within: 'Showing events within %km% km'
|
||||
panel_aria: 'Events'
|
||||
panel_nearest: 'Nearest to you'
|
||||
panel_events: 'Events'
|
||||
count_placeholder: '— events'
|
||||
|
||||
browse:
|
||||
title: 'Browse events — VINDN'
|
||||
eyebrow: 'All events'
|
||||
heading: "What's on"
|
||||
heading_search: 'Searching: "%q%"'
|
||||
count: '{0} No events|{1} 1 event|]1,Inf[ %n% events'
|
||||
sort:
|
||||
label: 'Sort by'
|
||||
soonest: 'Sort: Soonest'
|
||||
nearest: 'Sort: Nearest'
|
||||
az: 'Sort: A–Z'
|
||||
za: 'Sort: Z–A'
|
||||
search:
|
||||
placeholder: 'Search by event, artist or venue…'
|
||||
submit: 'Search'
|
||||
filters:
|
||||
button: 'Filters'
|
||||
clear_all: 'Clear all'
|
||||
remove_category: 'Remove category filter %name%'
|
||||
remove_dates: 'Remove date filter %label%'
|
||||
remove_location: 'Remove location filter'
|
||||
from: 'From %date%'
|
||||
until: 'Until %date%'
|
||||
within_coords: 'Within %km% km of %lat%, %lon%'
|
||||
dialog: 'Filters'
|
||||
close: 'Close'
|
||||
category: 'Category'
|
||||
when: 'When'
|
||||
near: 'Near a location'
|
||||
reset: 'Reset all'
|
||||
apply: 'Show events'
|
||||
empty:
|
||||
title: 'No events'
|
||||
text: 'Nothing matches those filters yet — try clearing one, or check back once the importer has run.'
|
||||
|
||||
pager:
|
||||
nav: 'Pagination'
|
||||
first: '« First'
|
||||
first_aria: 'First page'
|
||||
prev: '‹ Prev'
|
||||
prev_aria: 'Previous page'
|
||||
next: 'Next ›'
|
||||
next_aria: 'Next page'
|
||||
last: 'Last »'
|
||||
last_aria: 'Last page'
|
||||
page: 'Page %current% of %total%'
|
||||
Reference in new issue
Block a user