mirror of
https://github.com/Lapikud/lapikud.github.io.git
synced 2026-08-08 22:59:14 +00:00
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:
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="et-EE">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
@@ -12,9 +12,7 @@
|
|||||||
duration:
|
duration:
|
||||||
en: 3 × 1h 30min
|
en: 3 × 1h 30min
|
||||||
est: 3 × 1h 30min
|
est: 3 × 1h 30min
|
||||||
date:
|
date: 2026-03
|
||||||
en: March 2026
|
|
||||||
est: Märts 2026
|
|
||||||
gallery:
|
gallery:
|
||||||
- temp1
|
- temp1
|
||||||
- temp2
|
- temp2
|
||||||
@@ -31,11 +29,8 @@
|
|||||||
duration:
|
duration:
|
||||||
en: 3 × 1h 30min
|
en: 3 × 1h 30min
|
||||||
est: 3 × 1h 30min
|
est: 3 × 1h 30min
|
||||||
date:
|
date: 2026-03
|
||||||
en: March 2026
|
|
||||||
est: Märts 2026
|
|
||||||
gallery:
|
gallery:
|
||||||
- temp1
|
- temp1
|
||||||
- temp2
|
- temp2
|
||||||
- temp3
|
- temp3
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { onScopeDispose, ref, watch } from 'vue';
|
import { computed, onScopeDispose, ref, watch } from 'vue';
|
||||||
import { getLanguageFromRoute } from '../routes';
|
import { getLanguageFromRoute } from '../routes';
|
||||||
|
|
||||||
const localeModules = import.meta.glob('./locales/*/*.json');
|
const localeModules = import.meta.glob('./locales/*/*.json');
|
||||||
|
const supportedLanguages = new Set(['est', 'en']);
|
||||||
|
export const languageLocales = { est: 'et-EE', en: 'en-GB' };
|
||||||
|
|
||||||
function getInitialLanguage() {
|
function getInitialLanguage() {
|
||||||
if (typeof window === 'undefined') return 'est';
|
if (typeof window === 'undefined') return 'est';
|
||||||
@@ -12,13 +14,17 @@ function getInitialLanguage() {
|
|||||||
return langFromRoute;
|
return langFromRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
return localStorage.getItem('language') || 'est';
|
const savedLanguage = localStorage.getItem('language');
|
||||||
|
return supportedLanguages.has(savedLanguage) ? savedLanguage : 'est';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const currentLang = ref(getInitialLanguage());
|
export const currentLang = ref(getInitialLanguage());
|
||||||
|
export const currentLocale = computed(() => languageLocales[currentLang.value]);
|
||||||
export const text = ref({});
|
export const text = ref({});
|
||||||
|
let translationRequest = 0;
|
||||||
|
|
||||||
async function loadTranslations(lang) {
|
async function loadTranslations(lang) {
|
||||||
|
const request = ++translationRequest;
|
||||||
try {
|
try {
|
||||||
const translations = {};
|
const translations = {};
|
||||||
const matchingModules = Object.entries(localeModules).filter(([path]) =>
|
const matchingModules = Object.entries(localeModules).filter(([path]) =>
|
||||||
@@ -26,10 +32,10 @@ async function loadTranslations(lang) {
|
|||||||
);
|
);
|
||||||
const modules = await Promise.all(matchingModules.map(([, loader]) => loader()));
|
const modules = await Promise.all(matchingModules.map(([, loader]) => loader()));
|
||||||
modules.forEach((module) => Object.assign(translations, module.default || module));
|
modules.forEach((module) => Object.assign(translations, module.default || module));
|
||||||
text.value = translations;
|
if (request === translationRequest) text.value = translations;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to load translations for ${lang}:`, error);
|
console.error(`Failed to load translations for ${lang}:`, error);
|
||||||
text.value = {};
|
if (request === translationRequest) text.value = {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,14 +43,16 @@ watch(currentLang, loadTranslations, { immediate: true });
|
|||||||
|
|
||||||
export function usePageText(pageName) {
|
export function usePageText(pageName) {
|
||||||
const pageText = ref({});
|
const pageText = ref({});
|
||||||
|
let pageRequest = 0;
|
||||||
const stop = watch(currentLang, async (lang) => {
|
const stop = watch(currentLang, async (lang) => {
|
||||||
|
const request = ++pageRequest;
|
||||||
try {
|
try {
|
||||||
const loader = localeModules[`./locales/${lang}/${pageName}.json`];
|
const loader = localeModules[`./locales/${lang}/${pageName}.json`];
|
||||||
const module = loader ? await loader() : null;
|
const module = loader ? await loader() : null;
|
||||||
pageText.value = module?.default || module || {};
|
if (request === pageRequest) pageText.value = module?.default || module || {};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to load ${pageName} translations for ${lang}:`, error);
|
console.error(`Failed to load ${pageName} translations for ${lang}:`, error);
|
||||||
pageText.value = {};
|
if (request === pageRequest) pageText.value = {};
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
@@ -53,10 +61,15 @@ export function usePageText(pageName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function switchLang(lang) {
|
export function switchLang(lang) {
|
||||||
|
if (!supportedLanguages.has(lang)) return;
|
||||||
currentLang.value = lang;
|
currentLang.value = lang;
|
||||||
if (typeof window !== 'undefined') localStorage.setItem('language', lang);
|
if (typeof window !== 'undefined') localStorage.setItem('language', lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(currentLocale, (locale) => {
|
||||||
|
if (typeof document !== 'undefined') document.documentElement.lang = locale;
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener('popstate', () => {
|
window.addEventListener('popstate', () => {
|
||||||
const lang = getLanguageFromRoute(window.location.pathname);
|
const lang = getLanguageFromRoute(window.location.pathname);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { navigate, goBack, reload, getPath, getQuery, switchLanguageRoute } from './router/router.js';
|
export { navigate, goBack, reload, getPath, getQuery, switchLanguageRoute } from './router/router.js';
|
||||||
export { default as Router } from './router/Router.vue';
|
export { default as Router } from './router/Router.vue';
|
||||||
|
|
||||||
export { currentLang, text, switchLang, usePageText } from './i18n.js';
|
export { currentLang, currentLocale, languageLocales, text, switchLang, usePageText } from './i18n.js';
|
||||||
export { getLangText } from './langHelpers.js';
|
export { getLangText } from './langHelpers.js';
|
||||||
|
|||||||
@@ -23,16 +23,28 @@
|
|||||||
"quantity": "Quantity/Time",
|
"quantity": "Quantity/Time",
|
||||||
"price": "Price"
|
"price": "Price"
|
||||||
},
|
},
|
||||||
"note": "Parts are ordered by the client themselves or by agreement with the technician.<br/><br/>If needed, we can come to the student village and Technopol area.<br/><br/>With our student-friendly prices and personal approach, we offer a service you won't be disappointed with!"
|
"notes": [
|
||||||
|
"Parts are ordered by the client themselves or by agreement with the technician.",
|
||||||
|
"If needed, we can come to the student village and Technopol area.",
|
||||||
|
"With our student-friendly prices and personal approach, we offer a service you won't be disappointed with!"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"hours": {
|
"hours": {
|
||||||
"title": "Opening Hours:",
|
"title": "Opening Hours:",
|
||||||
"schedule": "<strong>M</strong>onday – <strong>F</strong>riday 14-19*",
|
"schedule": [
|
||||||
|
{ "emphasis": "M", "text": "onday – " },
|
||||||
|
{ "emphasis": "F", "text": "riday 14-19*" }
|
||||||
|
],
|
||||||
"note": "* This is the time period when we are most likely to be found. However, we cannot guarantee presence."
|
"note": "* This is the time period when we are most likely to be found. However, we cannot guarantee presence."
|
||||||
},
|
},
|
||||||
"location": {
|
"location": {
|
||||||
"title": "Location",
|
"title": "Location",
|
||||||
"busInfo": "Accessible via <span class='text-[#E69635]'>buses 11, 37, 83, 91 from Keemia stop</span> or <span class='text-[#E69635]'>buses 10, 27, 33, 36, 45 from Tehnikaülikool stop.</span>",
|
"busInfo": [
|
||||||
|
{ "text": "Accessible via " },
|
||||||
|
{ "text": "buses 11, 37, 83, 91 from Keemia stop", "highlight": true },
|
||||||
|
{ "text": " or " },
|
||||||
|
{ "text": "buses 10, 27, 33, 36, 45 from Tehnikaülikool stop.", "highlight": true }
|
||||||
|
],
|
||||||
"address": "Akadeemia tee 5 dormitory, room 008A"
|
"address": "Akadeemia tee 5 dormitory, room 008A"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"hero": {
|
"hero": {
|
||||||
"label": "Our work",
|
"label": "Our work",
|
||||||
"title": "Workshops",
|
"title": "Workshops",
|
||||||
"titleHighlight": "& courses.",
|
"titleHighlight": "& courses.",
|
||||||
"subtitle": "A selection of workshops conducted by our members over the years."
|
"subtitle": "A selection of workshops conducted by our members over the years."
|
||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
|
|||||||
@@ -23,16 +23,28 @@
|
|||||||
"quantity": "Kogus/Aeg",
|
"quantity": "Kogus/Aeg",
|
||||||
"price": "Hind"
|
"price": "Hind"
|
||||||
},
|
},
|
||||||
"note": "Jupid tellitakse kliendi enda poolt või kokkuleppel tehnikuga.<br/><br/>Vajadusel tuleme üliõpilasküla ja Tehnopoli piires kohale.<br/><br/>Oma tudengisõbralike hindade ja personaalse lähenemisega pakume teenust, milles ei pea pettuma!"
|
"notes": [
|
||||||
|
"Jupid tellitakse kliendi enda poolt või kokkuleppel tehnikuga.",
|
||||||
|
"Vajadusel tuleme üliõpilasküla ja Tehnopoli piires kohale.",
|
||||||
|
"Oma tudengisõbralike hindade ja personaalse lähenemisega pakume teenust, milles ei pea pettuma!"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"hours": {
|
"hours": {
|
||||||
"title": "Lahtiolekuajad:",
|
"title": "Lahtiolekuajad:",
|
||||||
"schedule": "<strong>E</strong>smaspäevast – <strong>R</strong>eedeni 14-19*",
|
"schedule": [
|
||||||
|
{ "emphasis": "E", "text": "smaspäevast – " },
|
||||||
|
{ "emphasis": "R", "text": "eedeni 14-19*" }
|
||||||
|
],
|
||||||
"note": "* Tegu on ajaperioodiga, mil meid on kõige tõenäolisem tabada. Siiski ei saa garanteerida kohalolekut."
|
"note": "* Tegu on ajaperioodiga, mil meid on kõige tõenäolisem tabada. Siiski ei saa garanteerida kohalolekut."
|
||||||
},
|
},
|
||||||
"location": {
|
"location": {
|
||||||
"title": "Asukoht",
|
"title": "Asukoht",
|
||||||
"busInfo": "Saab <span class='text-[#E69635]'>11, 37, 83, 91 bussiga Keemia peatusest</span> või <span class='text-[#E69635]'>Tehnikaülikool peatusest 10, 27, 33, 36, 45 bussiga.</span>",
|
"busInfo": [
|
||||||
|
{ "text": "Saab " },
|
||||||
|
{ "text": "11, 37, 83, 91 bussiga Keemia peatusest", "highlight": true },
|
||||||
|
{ "text": " või " },
|
||||||
|
{ "text": "Tehnikaülikool peatusest 10, 27, 33, 36, 45 bussiga.", "highlight": true }
|
||||||
|
],
|
||||||
"address": "Akadeemia tee 5 ühiselamu, ruum 008A"
|
"address": "Akadeemia tee 5 ühiselamu, ruum 008A"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"hero": {
|
"hero": {
|
||||||
"label": "Meie tehtud",
|
"label": "Meie tehtud",
|
||||||
"title": "Koolitused",
|
"title": "Koolitused",
|
||||||
"titleHighlight": "& töötoad.",
|
"titleHighlight": "& töötoad.",
|
||||||
"subtitle": "Valik koolitusi, mida meie liikmed on aastate jooksul läbi viinud."
|
"subtitle": "Valik koolitusi, mida meie liikmed on aastate jooksul läbi viinud."
|
||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
|
|||||||
@@ -4,26 +4,29 @@ import { onBeforeUnmount, onMounted, shallowRef, watch } from 'vue';
|
|||||||
const props = defineProps({ routes: { type: Object, required: true } });
|
const props = defineProps({ routes: { type: Object, required: true } });
|
||||||
const currentPath = shallowRef(window.location.pathname);
|
const currentPath = shallowRef(window.location.pathname);
|
||||||
const CurrentComponent = shallowRef(null);
|
const CurrentComponent = shallowRef(null);
|
||||||
|
let routeRequest = 0;
|
||||||
|
|
||||||
async function resolveCurrentComponent(path) {
|
async function resolveCurrentComponent(path) {
|
||||||
|
const request = ++routeRequest;
|
||||||
const routeEntry = props.routes[path] || props.routes['/'];
|
const routeEntry = props.routes[path] || props.routes['/'];
|
||||||
if (typeof routeEntry !== 'function') {
|
if (typeof routeEntry !== 'function') {
|
||||||
CurrentComponent.value = routeEntry || null;
|
if (request === routeRequest) CurrentComponent.value = routeEntry || null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const module = await routeEntry();
|
const module = await routeEntry();
|
||||||
CurrentComponent.value = module?.default || null;
|
if (request === routeRequest) CurrentComponent.value = module?.default || null;
|
||||||
} catch {
|
} catch {
|
||||||
CurrentComponent.value = null;
|
if (request === routeRequest) CurrentComponent.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigate(path) {
|
function navigate(url) {
|
||||||
window.history.pushState({}, '', path);
|
window.history.pushState({}, '', `${url.pathname}${url.search}${url.hash}`);
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
currentPath.value = path;
|
currentPath.value = url.pathname;
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
@@ -31,10 +34,16 @@ const handlePopState = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (event) => {
|
const handleClick = (event) => {
|
||||||
if (event.target.tagName === 'A' && event.target.getAttribute('href')?.startsWith('/')) {
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||||
event.preventDefault();
|
const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null;
|
||||||
navigate(event.target.getAttribute('href'));
|
if (!anchor || anchor.target || anchor.hasAttribute('download')) return;
|
||||||
}
|
|
||||||
|
const url = new URL(anchor.href, window.location.href);
|
||||||
|
if (url.origin !== window.location.origin || !props.routes[url.pathname]) return;
|
||||||
|
if (url.pathname === window.location.pathname && url.hash) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
navigate(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -13,25 +13,30 @@ export function navigate(path, options = {}) {
|
|||||||
if (external) {
|
if (external) {
|
||||||
window.open(path, '_blank', 'noopener,noreferrer');
|
window.open(path, '_blank', 'noopener,noreferrer');
|
||||||
} else {
|
} else {
|
||||||
const selectedLang =
|
const url = new URL(path, window.location.origin);
|
||||||
localStorage.getItem('language') ||
|
if (url.origin !== window.location.origin) {
|
||||||
getLanguageFromRoute(window.location.pathname) ||
|
window.location.assign(url.href);
|
||||||
'est';
|
return;
|
||||||
|
|
||||||
const resolvedPath = getTranslatedRoute(path, selectedLang);
|
|
||||||
|
|
||||||
window.history.pushState({}, "", resolvedPath);
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
||||||
|
|
||||||
// Update language based on new route
|
|
||||||
const lang = getLanguageFromRoute(resolvedPath);
|
|
||||||
if (lang) {
|
|
||||||
switchLang(lang);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedLang =
|
||||||
|
getLanguageFromRoute(window.location.pathname) ||
|
||||||
|
localStorage.getItem('language') ||
|
||||||
|
'est';
|
||||||
|
url.pathname = getTranslatedRoute(url.pathname, selectedLang);
|
||||||
|
commitNavigation(`${url.pathname}${url.search}${url.hash}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commitNavigation(path) {
|
||||||
|
window.history.pushState({}, '', path);
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
|
|
||||||
|
const lang = getLanguageFromRoute(new URL(path, window.location.origin).pathname);
|
||||||
|
if (lang) switchLang(lang);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch language and navigate to translated route
|
* Switch language and navigate to translated route
|
||||||
* @param {string} targetLang - Target language ('est' or 'en')
|
* @param {string} targetLang - Target language ('est' or 'en')
|
||||||
@@ -40,12 +45,10 @@ export function switchLanguageRoute(targetLang) {
|
|||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
const translatedPath = getTranslatedRoute(currentPath, targetLang);
|
const translatedPath = getTranslatedRoute(currentPath, targetLang);
|
||||||
|
|
||||||
// Always update the language first
|
|
||||||
switchLang(targetLang);
|
switchLang(targetLang);
|
||||||
|
|
||||||
// Only navigate if the path is different
|
|
||||||
if (translatedPath !== currentPath) {
|
if (translatedPath !== currentPath) {
|
||||||
navigate(translatedPath);
|
commitNavigation(translatedPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
src/lib/yaml.js
Normal file
18
src/lib/yaml.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import yaml from 'js-yaml';
|
||||||
|
|
||||||
|
export async function loadYaml(path, fallback) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(path);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const value = yaml.load(await response.text());
|
||||||
|
const matchesFallback = Array.isArray(fallback)
|
||||||
|
? Array.isArray(value)
|
||||||
|
: typeof fallback === 'object' && fallback !== null
|
||||||
|
? typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
: value !== undefined && value !== null;
|
||||||
|
return matchesFallback ? value : fallback;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to load YAML from ${path}:`, error);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Section, Center } from "../components/index.js";
|
import { Section, Center } from "../components/index.js";
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { usePageText } from "../lib/index.js";
|
import { currentLocale, usePageText } from "../lib/index.js";
|
||||||
|
|
||||||
const sectionElement = ref(null);
|
const sectionElement = ref(null);
|
||||||
const text = usePageText("Calendar");
|
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(() => {
|
onMounted(() => {
|
||||||
sectionElement.value?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
sectionElement.value?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
@@ -18,7 +31,7 @@
|
|||||||
<div class="w-full max-w-4xl">
|
<div class="w-full max-w-4xl">
|
||||||
<iframe
|
<iframe
|
||||||
style="border: 0;"
|
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'"
|
:title="text.iframeTitle || 'Lapikud Calendar'"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
height="600"
|
height="600"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { usePageText } from "../lib/index.js";
|
import { usePageText } from "../lib/index.js";
|
||||||
import { getHeroImageFallback, getHeroImageSrcSet, getRootAssetPath } from "../lib/imageHelpers.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 maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
@@ -42,12 +42,12 @@
|
|||||||
const pricingData = ref({ services: [] });
|
const pricingData = ref({ services: [] });
|
||||||
const mapElement = ref(null);
|
const mapElement = ref(null);
|
||||||
let map;
|
let map;
|
||||||
|
let disposed = false;
|
||||||
const text = usePageText("Helpdesk");
|
const text = usePageText("Helpdesk");
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const response = await fetch('/_data/hinnakiri.yml');
|
pricingData.value = await loadYaml('/_data/hinnakiri.yml', { services: [] });
|
||||||
const yamlText = await response.text();
|
if (disposed || !mapElement.value) return;
|
||||||
pricingData.value = yaml.load(yamlText) || { services: [] };
|
|
||||||
|
|
||||||
map = new maplibregl.Map({
|
map = new maplibregl.Map({
|
||||||
container: mapElement.value,
|
container: mapElement.value,
|
||||||
@@ -68,13 +68,21 @@
|
|||||||
marker.type = 'button';
|
marker.type = 'button';
|
||||||
marker.className = `helpdesk-marker helpdesk-marker--${type}`;
|
marker.className = `helpdesk-marker helpdesk-marker--${type}`;
|
||||||
marker.setAttribute('aria-label', `Get directions to ${label}`);
|
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));
|
marker.addEventListener('click', () => openDirections(coordinates[0], coordinates[1], type));
|
||||||
new maplibregl.Marker({ element: marker }).setLngLat(coordinates).addTo(map);
|
new maplibregl.Marker({ element: marker }).setLngLat(coordinates).addTo(map);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => map?.remove());
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true;
|
||||||
|
map?.remove();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -146,16 +154,18 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="pt-4 text-lg font-bold">
|
<div class="pt-4 text-lg font-bold">
|
||||||
<span v-html="text.pricing?.note || ''"></span>
|
<p v-for="note in text.pricing?.notes || []" :key="note" class="mb-4 last:mb-0">{{ note }}</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<h2 class="text-4xl font-light pb-4">
|
<h2 class="text-4xl font-light pb-4">
|
||||||
<span v-html="text.hours?.title || ''"></span>
|
{{ text.hours?.title || '' }}
|
||||||
<br />
|
<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>
|
</h2>
|
||||||
<p class="text-lg font-light opacity-70 pb-16">
|
<p class="text-lg font-light opacity-70 pb-16">
|
||||||
{{ text.hours?.note || '' }}
|
{{ text.hours?.note || '' }}
|
||||||
@@ -164,7 +174,11 @@
|
|||||||
<h2 class="text-4xl font-light pb-4">{{ text.location?.title || '' }}</h2>
|
<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>
|
<div ref="mapElement" class="relative w-full h-96 rounded-lg overflow-hidden"></div>
|
||||||
<p class="pb-4 text-lg">
|
<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>
|
||||||
<p class="text-lg font-bold">
|
<p class="text-lg font-bold">
|
||||||
{{ text.location?.address || '' }}
|
{{ text.location?.address || '' }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { usePageText, currentLang } from "../lib/index.js";
|
import { usePageText, currentLang } from "../lib/index.js";
|
||||||
import { getHeroImagePath, getHeroImageSrcSet, getHeroImageFallback, getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
|
import { getHeroImagePath, getHeroImageSrcSet, getHeroImageFallback, getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import yaml from 'js-yaml';
|
import { loadYaml } from '../lib/yaml.js';
|
||||||
|
|
||||||
import { ArrowRight } from "@lucide/vue";
|
import { ArrowRight } from "@lucide/vue";
|
||||||
import { ArrowLeft } from "@lucide/vue";
|
import { ArrowLeft } from "@lucide/vue";
|
||||||
@@ -14,9 +14,7 @@
|
|||||||
const text = usePageText("Home");
|
const text = usePageText("Home");
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const response = await fetch('/_data/partners.yml');
|
partners.value = await loadYaml('/_data/partners.yml', []);
|
||||||
const yamlText = await response.text();
|
|
||||||
partners.value = yaml.load(yamlText) || [];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const partnerLogoExtByName = {
|
const partnerLogoExtByName = {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
Image,
|
Image,
|
||||||
} from "../components/index.js";
|
} from "../components/index.js";
|
||||||
import { onMounted, ref } from 'vue';
|
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 { currentLang, getLangText, usePageText } from "../lib/index.js";
|
||||||
import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
|
import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
|
||||||
import { Mail } from "@lucide/vue";
|
import { Mail } from "@lucide/vue";
|
||||||
@@ -45,13 +45,10 @@
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const currentRes = await fetch('/_data/management.yml');
|
[currentManagement.value, pastManagement.value] = await Promise.all([
|
||||||
const currentYaml = await currentRes.text();
|
loadYaml('/_data/management.yml', []),
|
||||||
currentManagement.value = yaml.load(currentYaml) || [];
|
loadYaml('/_data/past_management.yml', []),
|
||||||
|
]);
|
||||||
const pastRes = await fetch('/_data/past_management.yml');
|
|
||||||
const pastYaml = await pastRes.text();
|
|
||||||
pastManagement.value = yaml.load(pastYaml) || [];
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading management data:', error);
|
console.error('Error loading management data:', error);
|
||||||
loadError.value = true;
|
loadError.value = true;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Section, Container } from "../components/index.js";
|
import { Section, Container } from "../components/index.js";
|
||||||
import { onMounted, ref } from 'vue';
|
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 { currentLang, getLangText, usePageText } from "../lib/index.js";
|
||||||
import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
|
import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
|
||||||
|
|
||||||
@@ -15,9 +15,7 @@
|
|||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const response = await fetch("/_data/mentors.yml");
|
mentors.value = await loadYaml('/_data/mentors.yml', []);
|
||||||
const yamlText = await response.text();
|
|
||||||
mentors.value = yaml.load(yamlText) || [];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function getContactIcon(contactType) {
|
function getContactIcon(contactType) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Section, Grid, Card, Button } from "../components/index.js";
|
import { Section, Grid, Card, Button } from "../components/index.js";
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import yaml from "js-yaml";
|
import { loadYaml } from '../lib/yaml.js';
|
||||||
import { currentLang, getLangText, usePageText } from "../lib/index.js";
|
import { currentLang, getLangText, navigate, usePageText } from "../lib/index.js";
|
||||||
import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
|
import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
|
||||||
|
|
||||||
import { ExternalLink } from "@lucide/vue";
|
import { ExternalLink } from "@lucide/vue";
|
||||||
@@ -11,9 +11,7 @@
|
|||||||
const text = usePageText("OurWork");
|
const text = usePageText("OurWork");
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const response = await fetch("/_data/ourwork.yml");
|
const parsed = await loadYaml('/_data/ourwork.yml', []);
|
||||||
const yamlText = await response.text();
|
|
||||||
const parsed = yaml.load(yamlText) || [];
|
|
||||||
projects.value = parsed.filter((p) => p?.title);
|
projects.value = parsed.filter((p) => p?.title);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,7 +41,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCtaContact() {
|
function openCtaContact() {
|
||||||
window.location.href = "/contact";
|
navigate('/contact');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
Button,
|
Button,
|
||||||
} from "../components/index.js";
|
} from "../components/index.js";
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import yaml from 'js-yaml';
|
import { loadYaml } from '../lib/yaml.js';
|
||||||
import { usePageText } from "../lib/index.js";
|
import { usePageText } from "../lib/index.js";
|
||||||
|
|
||||||
const members = ref({ junior: [], senior: [] });
|
const members = ref({ junior: [], senior: [] });
|
||||||
@@ -13,13 +13,7 @@
|
|||||||
const joinFormUrl = "https://pilves.lapikud.ee/apps/forms/s/WXed8sbG2s45GMKGAiXCemgE";
|
const joinFormUrl = "https://pilves.lapikud.ee/apps/forms/s/WXed8sbG2s45GMKGAiXCemgE";
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
members.value = await loadYaml('/_data/members.yml', { junior: [], senior: [] });
|
||||||
const response = await fetch('/_data/members.yml');
|
|
||||||
const yamlText = await response.text();
|
|
||||||
members.value = yaml.load(yamlText) || { junior: [], senior: [] };
|
|
||||||
} catch {
|
|
||||||
members.value = { junior: [], senior: [] };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const scrollToJoin = () => {
|
const scrollToJoin = () => {
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Section, Stack, Image } from "../components/index.js";
|
import { Section, Stack, Image } from "../components/index.js";
|
||||||
import { onMounted, ref } from 'vue';
|
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 { currentLang, currentLocale, getLangText, usePageText } from "../lib/index.js";
|
||||||
import { getRootAssetPath } from "../lib/imageHelpers.js";
|
import { getRootAssetPath } from "../lib/imageHelpers.js";
|
||||||
|
|
||||||
const workshops = ref([]);
|
const workshops = ref([]);
|
||||||
const text = usePageText("Workshops");
|
const text = usePageText("Workshops");
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const response = await fetch("/_data/workshops.yml");
|
const parsed = await loadYaml('/_data/workshops.yml', []);
|
||||||
const yamlText = await response.text();
|
|
||||||
const parsed = yaml.load(yamlText) || [];
|
|
||||||
workshops.value = parsed.filter((w) => w?.title);
|
workshops.value = parsed.filter((w) => w?.title);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,6 +17,17 @@
|
|||||||
return getLangText(workshop, field, currentLang.value);
|
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) {
|
function getGallery(workshop) {
|
||||||
return Array.isArray(workshop?.gallery) ? workshop.gallery : [];
|
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">
|
<h1 class="font-syne text-5xl md:text-6xl lg:text-7xl font-bold leading-tight mb-4">
|
||||||
{{ text.hero?.title }}
|
{{ text.hero?.title }}
|
||||||
<em class="block text-orange-500 not-italic"
|
<em class="block text-orange-500 not-italic"
|
||||||
><span v-html="text.hero?.titleHighlight"></span></em
|
>{{ text.hero?.titleHighlight }}</em
|
||||||
>
|
>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-gray-400 max-w-sm leading-relaxed font-space-grotesk font-light">
|
<p class="text-sm text-gray-400 max-w-sm leading-relaxed font-space-grotesk font-light">
|
||||||
@@ -120,7 +129,7 @@
|
|||||||
{{ text.details?.date }}
|
{{ text.details?.date }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-gray-700 font-space-grotesk">
|
<span class="text-sm text-gray-700 font-space-grotesk">
|
||||||
{{ getField(workshop, "date") }}
|
{{ formatDate(workshop.date) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user