diff --git a/frontend/composer.json b/frontend/composer.json index 0e1eb59..3bf54a0 100644 --- a/frontend/composer.json +++ b/frontend/composer.json @@ -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" diff --git a/frontend/composer.lock b/frontend/composer.lock index 269696d..189fa6d 100644 --- a/frontend/composer.lock +++ b/frontend/composer.lock @@ -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" } diff --git a/frontend/config/packages/translation.yaml b/frontend/config/packages/translation.yaml new file mode 100644 index 0000000..15dd8de --- /dev/null +++ b/frontend/config/packages/translation.yaml @@ -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}..yaml (copy the "de" files and + # translate), and — if flatpickr ships it — nothing else; the language + # switcher, 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: diff --git a/frontend/config/packages/twig.yaml b/frontend/config/packages/twig.yaml index f9f4cc5..deee76c 100644 --- a/frontend/config/packages/twig.yaml +++ b/frontend/config/packages/twig.yaml @@ -1,5 +1,7 @@ twig: default_path: '%kernel.project_dir%/templates' + globals: + enabled_locales: '%kernel.enabled_locales%' when@test: twig: diff --git a/frontend/config/reference.php b/frontend/config/reference.php index ed9ae5a..c8a6a3d 100644 --- a/frontend/config/reference.php +++ b/frontend/config/reference.php @@ -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, * logging?: bool|Param, // Default: false * formatter?: scalar|Param|null, // Default: "translator.formatter.default" diff --git a/frontend/public/js/dates.js b/frontend/public/js/dates.js index 42ef68e..fa7416b 100644 --- a/frontend/public/js/dates.js +++ b/frontend/public/js/dates.js @@ -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 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 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; }); } diff --git a/frontend/public/js/i18n.js b/frontend/public/js/i18n.js new file mode 100644 index 0000000..d0273ef --- /dev/null +++ b/frontend/public/js/i18n.js @@ -0,0 +1,55 @@ +// Browser-side translations. The server prints the catalog for the current +// language (translations/js..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 "." (one, other, few, …) for + // the language's own plural rules, falling back to ".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); diff --git a/frontend/public/js/location.js b/frontend/public/js/location.js index 53442fd..ee08c97 100644 --- a/frontend/public/js/location.js +++ b/frontend/public/js/location.js @@ -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 }); diff --git a/frontend/public/js/map.js b/frontend/public/js/map.js index a1b807d..61b0621 100644 --- a/frontend/public/js/map.js +++ b/frontend/public/js/map.js @@ -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 ? '' + ev.category + '' : '') + '

' + ev.title + '

' + (ev.venue ? '

' + ev.venue + '

' : '') + - (ev.url ? 'View event →' : '') + + (ev.url ? '' + window.VindnI18n.t('map.view_event') + '' : '') + ''; 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); } }); } diff --git a/frontend/public/styles.css b/frontend/public/styles.css index 62854e4..cf3de41 100644 --- a/frontend/public/styles.css +++ b/frontend/public/styles.css @@ -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); } diff --git a/frontend/src/Controller/EventController.php b/frontend/src/Controller/EventController.php index 744707c..42e5274 100644 --- a/frontend/src/Controller/EventController.php +++ b/frontend/src/Controller/EventController.php @@ -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', [ diff --git a/frontend/src/Controller/LanguageController.php b/frontend/src/Controller/LanguageController.php new file mode 100644 index 0000000..ef839fd --- /dev/null +++ b/frontend/src/Controller/LanguageController.php @@ -0,0 +1,41 @@ + $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; + } +} diff --git a/frontend/src/EventSubscriber/LocaleSubscriber.php b/frontend/src/EventSubscriber/LocaleSubscriber.php new file mode 100644 index 0000000..7c31630 --- /dev/null +++ b/frontend/src/EventSubscriber/LocaleSubscriber.php @@ -0,0 +1,57 @@ + $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); + } +} diff --git a/frontend/src/Twig/GermanFormatExtension.php b/frontend/src/Twig/GermanFormatExtension.php deleted file mode 100644 index 7ede31c..0000000 --- a/frontend/src/Twig/GermanFormatExtension.php +++ /dev/null @@ -1,58 +0,0 @@ -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'; - } -} diff --git a/frontend/src/Twig/LocaleFormatExtension.php b/frontend/src/Twig/LocaleFormatExtension.php new file mode 100644 index 0000000..df95033 --- /dev/null +++ b/frontend/src/Twig/LocaleFormatExtension.php @@ -0,0 +1,137 @@ +.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 - +{# Calendar names/first weekday for the page language; English is built in. #} +{% if app.request.locale != 'en' %}{% endif %} diff --git a/frontend/templates/_language_switcher.html.twig b/frontend/templates/_language_switcher.html.twig new file mode 100644 index 0000000..3c28193 --- /dev/null +++ b/frontend/templates/_language_switcher.html.twig @@ -0,0 +1,10 @@ +{# Language links for every enabled locale (config/packages/translation.yaml). The current one is marked; others go through /language/, which remembers the choice in a cookie and returns here. #} + diff --git a/frontend/templates/_map_stage.html.twig b/frontend/templates/_map_stage.html.twig index f3cf99d..54784c9 100644 --- a/frontend/templates/_map_stage.html.twig +++ b/frontend/templates/_map_stage.html.twig @@ -11,39 +11,39 @@ #} {% set compact = compact|default(false) %}
-
+
{% if not compact %} -
- +
+ {% for category in categories %} {% endfor %}
{% endif %} -
- - - - - +
+ + + + +
- - - + + +
{% if not compact %} {% endif %} -

{{ compact ? 'Showing events near Nordrhein-Westfalen' : 'Showing events within ' ~ rangeKm ~ ' km' }}

+

{{ compact ? 'map.status_region'|trans : 'map.status_within'|trans({'%km%': rangeKm}) }}

@@ -59,10 +59,10 @@
{% endif %} -
+ + diff --git a/frontend/templates/events/_card.html.twig b/frontend/templates/events/_card.html.twig index c393976..ad38b25 100644 --- a/frontend/templates/events/_card.html.twig +++ b/frontend/templates/events/_card.html.twig @@ -15,11 +15,11 @@ {{ category }} {% endif %}

{{ event.title }}

-

{{ event.startDateTime ? event.startDateTime|de_datetime : 'Date TBD' }}

+

{{ event.startDateTime ? event.startDateTime|format_datetime : 'card.tbd'|trans }}

{{ [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 %}

diff --git a/frontend/templates/events/list.html.twig b/frontend/templates/events/list.html.twig index f4d6010..f450cad 100644 --- a/frontend/templates/events/list.html.twig +++ b/frontend/templates/events/list.html.twig @@ -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 %}
-

All events

+

{{ 'browse.eyebrow'|trans }}

-

{{ q ? 'Searching: "' ~ q ~ '"' : "What's on" }}{{ activeCategory ? ' — ' ~ activeCategory : '' }}

+

{{ q ? 'browse.heading_search'|trans({'%q%': q}) : 'browse.heading'|trans }}{{ activeCategory ? ' — ' ~ activeCategory : '' }}

-

{{ total|de_number }} event{{ total == 1 ? '' : 's' }}

- +

{{ 'browse.count'|trans({'%count%': total, '%n%': total|format_number}) }}

+
@@ -103,31 +103,31 @@ {% endif %} - - - + + + {% set activeCount = (activeCategory ? 1 : 0) + (dateFrom or dateTo ? 1 : 0) + (hasLocation ? 1 : 0) %}
- + {% if activeCategory %} - {{ activeCategory }} + {{ activeCategory }} {% 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 %} - {{ dateLabel }} + {% 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 %} + {{ dateLabel }} {% endif %} {% if hasLocation %} - Within {{ rangeKm }} km of {{ lat|de_number(3) }}, {{ lon|de_number(3) }} + {{ 'browse.filters.within_coords'|trans({'%km%': rangeKm, '%lat%': lat|format_number(3), '%lon%': lon|format_number(3)}) }} {% endif %} {% if activeCount %} - Clear all + {{ 'browse.filters.clear_all'|trans }} {% endif %}
@@ -138,15 +138,15 @@ {% if q %}{% endif %} {% if filters.sort %}{% endif %}
-

Filters

- +

{{ 'browse.filters.dialog'|trans }}

+
- Category + {{ 'browse.filters.category'|trans }}
- + {% for category in categories %} {% endfor %} @@ -154,46 +154,46 @@
- When + {{ 'browse.filters.when'|trans }}
- - - + + +
-
- - - - +
+ + + +
- Near a location + {{ 'browse.filters.near'|trans }}
- - - - - + + + + + - +
-

{% if hasLocation %}Within {{ rangeKm }} km of {{ lat|de_number(3) }}, {{ lon|de_number(3) }}{% endif %}

+

{% if hasLocation %}{{ 'browse.filters.within_coords'|trans({'%km%': rangeKm, '%lat%': lat|format_number(3), '%lon%': lon|format_number(3)}) }}{% endif %}

- - + +
@@ -205,8 +205,8 @@ {% include 'events/_card.html.twig' with { event: event, dist: hasLocation ? distances[event.id]|default(null) : null } %} {% else %}
-

No events

-

Nothing matches those filters yet — try clearing one, or check back once the importer has run.

+

{{ 'browse.empty.title'|trans }}

+

{{ 'browse.empty.text'|trans }}

{% endfor %}
@@ -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); diff --git a/frontend/templates/events/show.html.twig b/frontend/templates/events/show.html.twig index d675d6a..3e9cebd 100644 --- a/frontend/templates/events/show.html.twig +++ b/frontend/templates/events/show.html.twig @@ -36,7 +36,7 @@
@@ -54,7 +54,7 @@ {% if category %}{{ category }}{% endif %}

{{ event.title }}

- {{ 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 %}

@@ -64,19 +64,19 @@
{% if event.description %}
-

About this event

+

{{ 'event.about'|trans }}

{{ event.description }}

{% elseif event.teaser %}
-

About this event

+

{{ 'event.about'|trans }}

{{ event.teaser }}

{% endif %} {% if event.hasCoordinates %} -

Location

-
+

{{ 'event.location'|trans }}

+
{% endif %}

{{ [event.street, (event.zip ~ ' ' ~ event.city)|trim, event.country]|filter(v => v)|join(', ') }} @@ -84,17 +84,17 @@

-
diff --git a/frontend/templates/home.html.twig b/frontend/templates/home.html.twig index 9f3da83..946d24a 100644 --- a/frontend/templates/home.html.twig +++ b/frontend/templates/home.html.twig @@ -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 @@

-

Was in deiner Nähe los ist

- Open full map → +

{{ 'home.hero.title'|trans }}

+ {{ 'home.hero.map_link'|trans }}
@@ -28,10 +28,10 @@
-

Browse

-

Find events by category

+

{{ 'home.browse.eyebrow'|trans }}

+

{{ 'home.browse.title'|trans }}

- Browse all events → + {{ 'home.browse.all'|trans }}
{% for category in popularCategories %} @@ -46,16 +46,16 @@
-

Live from the importer

-

Worth planning around

+

{{ 'home.recent.eyebrow'|trans }}

+

{{ 'home.recent.title'|trans }}

- Browse all events → + {{ 'home.browse.all'|trans }}
{% for event in upcomingEvents %} {% include 'events/_card.html.twig' with { event: event } %} {% else %} -

No events imported yet — check back once the backend's import job has run.

+

{{ 'home.recent.empty'|trans }}

{% endfor %}
@@ -67,29 +67,29 @@ diff --git a/frontend/templates/map.html.twig b/frontend/templates/map.html.twig index d7e5dd7..0698f95 100644 --- a/frontend/templates/map.html.twig +++ b/frontend/templates/map.html.twig @@ -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' %} diff --git a/frontend/translations/formats.de.yaml b/frontend/translations/formats.de.yaml new file mode 100644 index 0000000..cbad32b --- /dev/null +++ b/frontend/translations/formats.de.yaml @@ -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' diff --git a/frontend/translations/formats.en.yaml b/frontend/translations/formats.en.yaml new file mode 100644 index 0000000..be89f9c --- /dev/null +++ b/frontend/translations/formats.en.yaml @@ -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' diff --git a/frontend/translations/js.de.yaml b/frontend/translations/js.de.yaml new file mode 100644 index 0000000..87acf3b --- /dev/null +++ b/frontend/translations/js.de.yaml @@ -0,0 +1,34 @@ +# Texte, die das Browser-JavaScript braucht (public/js/*.js über VindnI18n.t()). +# Plurale: .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%' diff --git a/frontend/translations/js.en.yaml b/frontend/translations/js.en.yaml new file mode 100644 index 0000000..64f5af1 --- /dev/null +++ b/frontend/translations/js.en.yaml @@ -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%' diff --git a/frontend/translations/messages.de.yaml b/frontend/translations/messages.de.yaml new file mode 100644 index 0000000..9a359e4 --- /dev/null +++ b/frontend/translations/messages.de.yaml @@ -0,0 +1,150 @@ +# Seitentexte (Twig). Deutsch ist die Standardsprache — neue Sprachen: +# diese Datei als messages..yaml kopieren und übersetzen, dazu +# js..yaml und formats..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%' diff --git a/frontend/translations/messages.en.yaml b/frontend/translations/messages.en.yaml new file mode 100644 index 0000000..0edd90f --- /dev/null +++ b/frontend/translations/messages.en.yaml @@ -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%'