added translations

This commit is contained in:
Hendrik Behme committed 2026-10-09 16:37:53 +02:00
1 parent 36ed3880e8
commit 71635eb574
33 files changed
+1025 -241

No files matched your search

+1
View File
@@ -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"
+102 -2
View File
@@ -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"
}
+13
View File
@@ -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:
+2
View File
@@ -1,5 +1,7 @@
twig:
default_path: '%kernel.project_dir%/templates'
globals:
enabled_locales: '%kernel.enabled_locales%'
when@test:
twig:
+1 -1
View File
@@ -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"
+13 -9
View File
@@ -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;
});
}
+55
View File
@@ -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);
+1 -1
View File
@@ -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
View File
@@ -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); }
});
}
+4
View File
@@ -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); }
+3 -2
View File
@@ -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';
}
}
+137
View File
@@ -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);
}
}
+13
View File
@@ -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>
+18 -18
View File
@@ -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>
+6 -6
View File
@@ -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 %}
+22 -19
View File
@@ -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>
+2 -2
View File
@@ -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>
+56 -56
View File
@@ -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);
+14 -14
View File
@@ -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>
+22 -22
View File
@@ -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 -1
View File
@@ -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' %}
+15
View File
@@ -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'
+13
View File
@@ -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'
+34
View File
@@ -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%'
+32
View File
@@ -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%'
+150
View File
@@ -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%'
+145
View File
@@ -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%'