fix(router,i18n): harden links, loading, and localized content

Make the custom router follow browser link conventions: resolve clicks from nested elements, preserve query strings and hashes, ignore modified, download, targeted, external, and in-page links, synchronize navigation listeners, and prevent stale lazy imports from replacing the newest route.

Keep language selection authoritative during translated navigation, reject unsupported stored locales, prevent stale translation requests from winning races, and synchronize the document language with standards-based et-EE and en-GB locale identifiers.

Route every YAML-backed page through one loader that checks HTTP responses, validates the parsed data shape against its fallback, and returns safe defaults. Render locale-neutral workshop months with Intl.DateTimeFormat and localize the embedded calendar URL from the active locale.

Replace HTML-bearing Helpdesk and workshop translations with structured plain-text segments rendered through escaped Vue bindings. This preserves emphasis and spacing without v-html, while direct MapLibre markers now build fixed DOM nodes through textContent and clean up safely after asynchronous loading.
This commit is contained in:
topsinoty
2026-07-04 15:55:05 +03:00
parent 13322e11cb
commit e5fc209203
19 changed files with 189 additions and 106 deletions

View File

@@ -1,10 +1,23 @@
<script setup>
import { Section, Center } from "../components/index.js";
import { onMounted, ref } from 'vue';
import { usePageText } from "../lib/index.js";
import { computed, onMounted, ref } from 'vue';
import { currentLocale, usePageText } from "../lib/index.js";
const sectionElement = ref(null);
const text = usePageText("Calendar");
const calendarUrl = computed(() => {
const params = new URLSearchParams({
src: 'lapikud@gmail.com',
ctz: 'Europe/Tallinn',
showTitle: '0',
showPrint: '0',
showCalendars: '0',
showTz: '0',
wkst: '2',
hl: currentLocale.value,
});
return `https://www.google.com/calendar/embed?${params}`;
});
onMounted(() => {
sectionElement.value?.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -18,7 +31,7 @@
<div class="w-full max-w-4xl">
<iframe
style="border: 0;"
src="https://www.google.com/calendar/embed?src=lapikud%40gmail.com&ctz=Europe/Tallinn&showTitle=0&showPrint=0&showCalendars=0&showTz=0&wkst=2"
:src="calendarUrl"
:title="text.iframeTitle || 'Lapikud Calendar'"
class="w-full"
height="600"

View File

@@ -6,7 +6,7 @@
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { usePageText } from "../lib/index.js";
import { getHeroImageFallback, getHeroImageSrcSet, getRootAssetPath } from "../lib/imageHelpers.js";
import yaml from 'js-yaml';
import { loadYaml } from '../lib/yaml.js';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
@@ -42,12 +42,12 @@
const pricingData = ref({ services: [] });
const mapElement = ref(null);
let map;
let disposed = false;
const text = usePageText("Helpdesk");
onMounted(async () => {
const response = await fetch('/_data/hinnakiri.yml');
const yamlText = await response.text();
pricingData.value = yaml.load(yamlText) || { services: [] };
pricingData.value = await loadYaml('/_data/hinnakiri.yml', { services: [] });
if (disposed || !mapElement.value) return;
map = new maplibregl.Map({
container: mapElement.value,
@@ -68,13 +68,21 @@
marker.type = 'button';
marker.className = `helpdesk-marker helpdesk-marker--${type}`;
marker.setAttribute('aria-label', `Get directions to ${label}`);
marker.innerHTML = `<span>${label}</span><b aria-hidden="true">${type === 'bus' ? '●' : '◆'}</b>`;
const markerLabel = document.createElement('span');
markerLabel.textContent = label;
const markerIcon = document.createElement('b');
markerIcon.setAttribute('aria-hidden', 'true');
markerIcon.textContent = type === 'bus' ? '●' : '◆';
marker.replaceChildren(markerLabel, markerIcon);
marker.addEventListener('click', () => openDirections(coordinates[0], coordinates[1], type));
new maplibregl.Marker({ element: marker }).setLngLat(coordinates).addTo(map);
});
});
onBeforeUnmount(() => map?.remove());
onBeforeUnmount(() => {
disposed = true;
map?.remove();
});
</script>
<template>
@@ -146,16 +154,18 @@
</tbody>
</table>
</div>
<p class="pt-4 text-lg font-bold">
<span v-html="text.pricing?.note || ''"></span>
</p>
<div class="pt-4 text-lg font-bold">
<p v-for="note in text.pricing?.notes || []" :key="note" class="mb-4 last:mb-0">{{ note }}</p>
</div>
</div>
<div class="flex flex-col">
<h2 class="text-4xl font-light pb-4">
<span v-html="text.hours?.title || ''"></span>
{{ text.hours?.title || '' }}
<br />
<span v-html="text.hours?.schedule || ''"></span>
<template v-for="part in text.hours?.schedule || []" :key="part.emphasis">
<strong>{{ part.emphasis }}</strong>{{ part.text }}
</template>
</h2>
<p class="text-lg font-light opacity-70 pb-16">
{{ text.hours?.note || '' }}
@@ -164,7 +174,11 @@
<h2 class="text-4xl font-light pb-4">{{ text.location?.title || '' }}</h2>
<div ref="mapElement" class="relative w-full h-96 rounded-lg overflow-hidden"></div>
<p class="pb-4 text-lg">
<span v-html="text.location?.busInfo || ''"></span>
<span
v-for="(part, index) in text.location?.busInfo || []"
:key="index"
:class="part.highlight && 'text-[#E69635]'"
>{{ part.text }}</span>
</p>
<p class="text-lg font-bold">
{{ text.location?.address || '' }}

View File

@@ -4,7 +4,7 @@
import { usePageText, currentLang } from "../lib/index.js";
import { getHeroImagePath, getHeroImageSrcSet, getHeroImageFallback, getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
import { onMounted, ref } from 'vue';
import yaml from 'js-yaml';
import { loadYaml } from '../lib/yaml.js';
import { ArrowRight } from "@lucide/vue";
import { ArrowLeft } from "@lucide/vue";
@@ -14,9 +14,7 @@
const text = usePageText("Home");
onMounted(async () => {
const response = await fetch('/_data/partners.yml');
const yamlText = await response.text();
partners.value = yaml.load(yamlText) || [];
partners.value = await loadYaml('/_data/partners.yml', []);
});
const partnerLogoExtByName = {

View File

@@ -6,7 +6,7 @@
Image,
} from "../components/index.js";
import { onMounted, ref } from 'vue';
import yaml from "js-yaml";
import { loadYaml } from '../lib/yaml.js';
import { currentLang, getLangText, usePageText } from "../lib/index.js";
import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
import { Mail } from "@lucide/vue";
@@ -45,13 +45,10 @@
onMounted(async () => {
try {
const currentRes = await fetch('/_data/management.yml');
const currentYaml = await currentRes.text();
currentManagement.value = yaml.load(currentYaml) || [];
const pastRes = await fetch('/_data/past_management.yml');
const pastYaml = await pastRes.text();
pastManagement.value = yaml.load(pastYaml) || [];
[currentManagement.value, pastManagement.value] = await Promise.all([
loadYaml('/_data/management.yml', []),
loadYaml('/_data/past_management.yml', []),
]);
} catch (error) {
console.error('Error loading management data:', error);
loadError.value = true;

View File

@@ -1,7 +1,7 @@
<script setup>
import { Section, Container } from "../components/index.js";
import { onMounted, ref } from 'vue';
import yaml from "js-yaml";
import { loadYaml } from '../lib/yaml.js';
import { currentLang, getLangText, usePageText } from "../lib/index.js";
import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
@@ -15,9 +15,7 @@
onMounted(async () => {
const response = await fetch("/_data/mentors.yml");
const yamlText = await response.text();
mentors.value = yaml.load(yamlText) || [];
mentors.value = await loadYaml('/_data/mentors.yml', []);
});
function getContactIcon(contactType) {

View File

@@ -1,8 +1,8 @@
<script setup>
import { Section, Grid, Card, Button } from "../components/index.js";
import { computed, onMounted, ref } from 'vue';
import yaml from "js-yaml";
import { currentLang, getLangText, usePageText } from "../lib/index.js";
import { loadYaml } from '../lib/yaml.js';
import { currentLang, getLangText, navigate, usePageText } from "../lib/index.js";
import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
import { ExternalLink } from "@lucide/vue";
@@ -11,9 +11,7 @@
const text = usePageText("OurWork");
onMounted(async () => {
const response = await fetch("/_data/ourwork.yml");
const yamlText = await response.text();
const parsed = yaml.load(yamlText) || [];
const parsed = await loadYaml('/_data/ourwork.yml', []);
projects.value = parsed.filter((p) => p?.title);
});
@@ -43,7 +41,7 @@
}
function openCtaContact() {
window.location.href = "/contact";
navigate('/contact');
}
</script>

View File

@@ -5,7 +5,7 @@
Button,
} from "../components/index.js";
import { computed, onMounted, ref } from 'vue';
import yaml from 'js-yaml';
import { loadYaml } from '../lib/yaml.js';
import { usePageText } from "../lib/index.js";
const members = ref({ junior: [], senior: [] });
@@ -13,13 +13,7 @@
const joinFormUrl = "https://pilves.lapikud.ee/apps/forms/s/WXed8sbG2s45GMKGAiXCemgE";
onMounted(async () => {
try {
const response = await fetch('/_data/members.yml');
const yamlText = await response.text();
members.value = yaml.load(yamlText) || { junior: [], senior: [] };
} catch {
members.value = { junior: [], senior: [] };
}
members.value = await loadYaml('/_data/members.yml', { junior: [], senior: [] });
});
const scrollToJoin = () => {

View File

@@ -1,17 +1,15 @@
<script setup>
import { Section, Stack, Image } from "../components/index.js";
import { onMounted, ref } from 'vue';
import yaml from "js-yaml";
import { currentLang, getLangText, usePageText } from "../lib/index.js";
import { loadYaml } from '../lib/yaml.js';
import { currentLang, currentLocale, getLangText, usePageText } from "../lib/index.js";
import { getRootAssetPath } from "../lib/imageHelpers.js";
const workshops = ref([]);
const text = usePageText("Workshops");
onMounted(async () => {
const response = await fetch("/_data/workshops.yml");
const yamlText = await response.text();
const parsed = yaml.load(yamlText) || [];
const parsed = await loadYaml('/_data/workshops.yml', []);
workshops.value = parsed.filter((w) => w?.title);
});
@@ -19,6 +17,17 @@
return getLangText(workshop, field, currentLang.value);
}
function formatDate(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})$/);
if (!match) return '';
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, 1));
return new Intl.DateTimeFormat(currentLocale.value, {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
}).format(date);
}
function getGallery(workshop) {
return Array.isArray(workshop?.gallery) ? workshop.gallery : [];
}
@@ -55,7 +64,7 @@
<h1 class="font-syne text-5xl md:text-6xl lg:text-7xl font-bold leading-tight mb-4">
{{ text.hero?.title }}
<em class="block text-orange-500 not-italic"
><span v-html="text.hero?.titleHighlight"></span></em
>{{ text.hero?.titleHighlight }}</em
>
</h1>
<p class="text-sm text-gray-400 max-w-sm leading-relaxed font-space-grotesk font-light">
@@ -120,7 +129,7 @@
{{ text.details?.date }}
</span>
<span class="text-sm text-gray-700 font-space-grotesk">
{{ getField(workshop, "date") }}
{{ formatDate(workshop.date) }}
</span>
</div>
</div>