msos/main.js

1196 lines
55 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

document.addEventListener('DOMContentLoaded', function() {
/**
* ===================================================================
* 1. OSNOVNA FUNKCIONALNOST MOBILNE NAVIGACIJE
* ===================================================================
*/
// === MOBILNI MENI: Preklop vidnosti menija ===
try {
const icon = document.querySelector('.mobile-menu-icon');
const root = document.documentElement;
if (!icon) {
console.error('Mobile menu icon not found.');
} else {
// Accessibility: the icon is a <div>, so expose it to keyboard and
// screen-reader users as a button (focusable, labelled, toggle state).
if (!icon.hasAttribute('role')) icon.setAttribute('role', 'button');
if (!icon.hasAttribute('tabindex')) icon.setAttribute('tabindex', '0');
if (!icon.hasAttribute('aria-label')) icon.setAttribute('aria-label', 'Open menu');
icon.setAttribute('aria-expanded', 'false');
const setMenu = (open) => {
root.classList.toggle('nav-open', open);
icon.setAttribute('aria-expanded', open ? 'true' : 'false');
icon.setAttribute('aria-label', open ? 'Close menu' : 'Open menu');
};
icon.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
setMenu(!root.classList.contains('nav-open'));
});
// Activate with Enter / Space like a native button.
icon.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
setMenu(!root.classList.contains('nav-open'));
}
});
document.addEventListener('click', (e) => {
if (root.classList.contains('nav-open') && !e.target.closest('.mobile-nav-panel') && !e.target.closest('.mobile-menu-icon')) {
setMenu(false);
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
setMenu(false);
}
});
}
} catch (err) {
console.error('Error in Mobile Menu Toggle:', err);
}
// --- Mobilni Accordion podmeni ---
try {
const mobileNav = document.querySelector('.mobile-nav-panel .mobile-nav');
if (mobileNav) {
const menuItemsWithChildren = mobileNav.querySelectorAll('.menu-item-has-children');
menuItemsWithChildren.forEach(item => {
const link = item.querySelector('a');
if (link) {
link.addEventListener('click', (event) => {
if (event.currentTarget.parentElement.classList.contains('menu-item-has-children')) {
event.preventDefault();
}
const parentLi = event.currentTarget.parentElement;
menuItemsWithChildren.forEach(otherItem => {
if (otherItem !== parentLi && otherItem.classList.contains('open')) {
otherItem.classList.remove('open');
}
});
parentLi.classList.toggle('open');
});
}
});
}
} catch (error) {
console.error('Error in Mobile Accordion Menu:', error);
}
// Desktop: top-level dropdown parents (Who We Are, What We Do, For Students) are
// triggers, not real pages. Their href="#" caused a dead "/en/#" navigation on click.
// The submenu already opens on hover, so just cancel the navigation.
try {
document.querySelectorAll('.desktop-nav .menu-item-has-children > a').forEach((a) => {
a.addEventListener('click', (e) => { e.preventDefault(); });
});
} catch (error) {
console.error('Error disabling desktop dropdown parent links:', error);
}
/**
* ===================================================================
* 2. IZBOLJŠAVE UPORABNIŠKE IZKUŠNJE (UX/UI)
* ===================================================================
*/
// --- Funkcija za poudarjanje aktivne povezave v navigaciji ---
function highlightActiveLink() {
try {
const currentPath = window.location.pathname;
const navLinks = document.querySelectorAll('.navigation a');
let bestMatch = null;
let longestMatch = 0;
navLinks.forEach(link => {
if (link.closest('.dropdown-menu')) return;
const linkPath = new URL(link.href, window.location.origin).pathname;
if (currentPath.startsWith(linkPath) && linkPath.length > longestMatch) {
longestMatch = linkPath.length;
bestMatch = link;
}
});
if (bestMatch) {
const parentMenuItem = bestMatch.closest('.menu-item');
if (parentMenuItem) {
parentMenuItem.classList.add('active-page');
}
}
} catch (error) {
console.error("Error highlighting active link:", error);
}
}
// --- Funkcionalnost za preklopnik jezika (Desktop Dropdown + Mobile Modal) ---
function setupLanguageSwitcher() {
try {
const path = window.location.pathname;
const pathParts = path.split('/').filter(Boolean);
const validLangs = ['en', 'si', 'mk'];
let pathWithoutLang = '/';
if (pathParts.length > 0 && validLangs.includes(pathParts[0])) {
pathWithoutLang = '/' + pathParts.slice(1).join('/');
if (path.endsWith('/') && pathWithoutLang !== '/') {
pathWithoutLang += '/';
}
} else {
pathWithoutLang = path;
}
let docLang = (document.documentElement.getAttribute('lang') || 'en').toLowerCase();
if (docLang === 'sl') docLang = 'si'; // <html lang="sl"> (correct ISO for Slovene) maps to the site's /si/ locale
document.querySelectorAll('a[data-lang]').forEach(link => {
const targetLang = link.getAttribute('data-lang');
const newUrl = `/${targetLang}${pathWithoutLang}`;
link.setAttribute('href', newUrl.replace(/\/{2,}/g, '/'));
link.classList.toggle('is-current', targetLang === docLang);
// Manually choosing a language is a deliberate preference — don't nag with the banner afterwards.
link.addEventListener('click', () => {
try { localStorage.setItem('msos-lang-banner', 'dismissed'); } catch (e) {}
});
});
const desktopSelectors = document.querySelectorAll('.header-right-wrapper .language-selector');
desktopSelectors.forEach(selector => {
const currentLang = selector.querySelector('.current-lang');
if (currentLang) {
currentLang.addEventListener('click', (event) => {
event.stopPropagation();
const isActive = selector.classList.contains('active');
document.querySelectorAll('.language-selector.active').forEach(s => s.classList.remove('active'));
if (!isActive) {
selector.classList.add('active');
}
});
}
});
window.addEventListener('click', () => {
desktopSelectors.forEach(s => s.classList.remove('active'));
});
const mobileLangButton = document.getElementById('mobile-lang-trigger');
const languageModal = document.getElementById('language-modal-overlay');
const closeModalButton = document.getElementById('language-modal-close');
if (mobileLangButton && languageModal && closeModalButton) {
mobileLangButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
languageModal.classList.add('active');
});
const closeModal = () => {
languageModal.classList.remove('active');
};
closeModalButton.addEventListener('click', closeModal);
languageModal.addEventListener('click', (e) => {
if (e.target === languageModal) closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && languageModal.classList.contains('active')) closeModal();
});
}
} catch (error) {
console.error('Error in Language Switcher setup:', error);
}
}
/**
* ===================================================================
* 3. OSTALE FUNKCIONALNOSTI (FAQ, ACTIVITIES, ITD.)
* ===================================================================
*/
// --- FAQ Accordion ---
try {
document.querySelectorAll('.faq-item h3').forEach(header => {
header.addEventListener('click', () => {
const faqItem = header.parentElement;
faqItem.classList.toggle('open');
});
});
} catch (error) {
console.error('Error in FAQ Accordion:', error);
}
// --- Activities Tabs ---
// Index-based: content lives in the HTML panels (one per language),
// so switching works regardless of the tab label language.
try {
const activityTabs = document.querySelectorAll('.activities-nav .activity-tab');
const activityPanels = document.querySelectorAll('.activities-panels .activity-panel');
if (activityTabs.length > 0 && activityPanels.length > 0) {
activityTabs.forEach(tab => {
tab.addEventListener('click', () => {
const idx = tab.getAttribute('data-panel');
activityTabs.forEach(t => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
activityPanels.forEach(p => {
p.classList.remove('active');
p.hidden = true;
});
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
const panel = document.querySelector(
`.activities-panels .activity-panel[data-panel="${idx}"]`
);
if (panel) {
panel.hidden = false;
panel.classList.add('active');
}
});
});
}
} catch (error) {
console.error('Error in Activities Tabs:', error);
}
/**
* ===================================================================
* 4. INTERAKTIVNA SEKCIJA "MISSION & VISION"
* ===================================================================
*/
try {
const missionVisionContainer = document.getElementById('mission-vision-interactive');
if (missionVisionContainer) {
const missionBox = missionVisionContainer.querySelector('.mission-box');
const visionBox = missionVisionContainer.querySelector('.vision-box');
missionBox.addEventListener('mouseenter', () => {
missionVisionContainer.classList.remove('show-vision');
});
visionBox.addEventListener('mouseenter', () => {
missionVisionContainer.classList.add('show-vision');
});
}
} catch(error) {
console.error('Error in Mission/Vision interaction setup:', error);
}
/**
* ===================================================================
* 5. SCROLLSPY IN PROGRESS BAR ZA STRANI ČLANKOV (POSODOBLJENO)
* ===================================================================
*/
try {
const articlePage = document.querySelector('.article-page');
if (articlePage) {
const articleContainer = articlePage.querySelector('.article-container');
const articleBody = articlePage.querySelector('.article-body');
const headings = articleBody ? Array.from(articleBody.querySelectorAll('h2')) : [];
const articleTitleEl = articlePage.querySelector('.article-header h1');
const articleTitle = articleTitleEl ? articleTitleEl.textContent.trim()
: (document.title || '').trim();
// Label for a heading in the table of contents. An optional
// data-nav-title attribute lets an article override the full <h2>
// text with a shorter label; otherwise we use the heading text.
const navLabel = (heading) =>
(heading.getAttribute('data-nav-title') || heading.textContent).trim();
// --- 5a. Zagotovi, da ima vsak naslov enoličen id (za sidra) ---
// Transliterate then slugify so anchors are readable in every language:
// Slovenian diacritics (č/š/ž/…) and Macedonian Cyrillic map to ASCII.
// Anything still unmapped is dropped and falls back to section-N below.
const TRANSLIT = {
// Slovenian / Latin diacritics
'č':'c','ć':'c','š':'s','ž':'z','đ':'dj',
'á':'a','à':'a','â':'a','ä':'a','ã':'a','é':'e','è':'e','ê':'e','ë':'e',
'í':'i','ì':'i','î':'i','ï':'i','ó':'o','ò':'o','ô':'o','ö':'o','õ':'o',
'ú':'u','ù':'u','û':'u','ü':'u','ñ':'n','ç':'c','ß':'ss',
// Macedonian Cyrillic
'а':'a','б':'b','в':'v','г':'g','д':'d','ѓ':'gj','е':'e','ж':'zh','з':'z',
'ѕ':'dz','и':'i','ј':'j','к':'k','л':'l','љ':'lj','м':'m','н':'n','њ':'nj',
'о':'o','п':'p','р':'r','с':'s','т':'t','ќ':'kj','у':'u','ф':'f','х':'h',
'ц':'c','ч':'ch','џ':'dj','ш':'sh'
};
const usedIds = new Set();
const transliterate = (text) =>
text.replace(/[^\x00-\x7F]/g, (ch) => (ch in TRANSLIT ? TRANSLIT[ch] : ''));
const slugify = (text) => transliterate(text.toLowerCase())
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
headings.forEach((heading, i) => {
let id = heading.id || slugify(heading.textContent);
if (id.length < 2) id = 'section-' + (i + 1);
let unique = id, n = 2;
while (usedIds.has(unique)) { unique = id + '-' + (n++); }
usedIds.add(unique);
heading.id = unique;
});
// --- 5b. Zagotovi postavitveni ovoj in stransko vrstico (Desktop) ---
// Older article pages (SI/MK and some EN) ship without the TOC
// scaffolding, so we build it here from the page's own headings.
let layoutContainer = articlePage.querySelector('.article-layout-container');
if (!layoutContainer && articleContainer) {
layoutContainer = document.createElement('div');
layoutContainer.className = 'article-layout-container';
articleContainer.parentNode.insertBefore(layoutContainer, articleContainer);
layoutContainer.appendChild(articleContainer);
}
let sidebar = articlePage.querySelector('.article-sidebar');
if (!sidebar && layoutContainer && headings.length > 0) {
sidebar = document.createElement('aside');
sidebar.className = 'article-sidebar';
sidebar.innerHTML = '<nav class="scrollspy-nav"><ul></ul></nav>';
layoutContainer.insertBefore(sidebar, layoutContainer.firstChild);
}
const scrollspyNav = sidebar ? sidebar.querySelector('.scrollspy-nav') : null;
const desktopUl = scrollspyNav ? scrollspyNav.querySelector('ul') : null;
// --- 5c. Napolni namizni seznam, če je prazen (ohrani ročno napisanega) ---
if (desktopUl && desktopUl.children.length === 0 && headings.length > 0) {
headings.forEach(heading => {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = '#' + heading.id;
a.textContent = navLabel(heading);
li.appendChild(a);
desktopUl.appendChild(li);
});
}
// --- 5d. Naslov članka nad kazalom (Desktop) ---
if (sidebar && !sidebar.querySelector('.article-sidebar-title') && articleTitle) {
const titleEl = document.createElement('p');
titleEl.className = 'article-sidebar-title';
titleEl.textContent = articleTitle;
sidebar.insertBefore(titleEl, sidebar.firstChild);
}
// --- 5e. Zagotovi lepljivo mobilno glavo z naslovom + progress bar ---
let mobileHeader = articlePage.querySelector('.mobile-article-header');
if (!mobileHeader && headings.length > 0) {
mobileHeader = document.createElement('div');
mobileHeader.className = 'mobile-article-header';
mobileHeader.innerHTML =
'<span id="mobile-article-title"></span>' +
'<i class="fas fa-chevron-down"></i>' +
'<div class="progress-bar-container"><div class="progress-bar"></div></div>' +
'<div class="mobile-scrollspy-dropdown"><ul id="mobile-scrollspy-links"></ul></div>';
articlePage.insertBefore(mobileHeader, articlePage.firstChild);
}
const mobileTitle = document.getElementById('mobile-article-title');
if (mobileTitle && !mobileTitle.textContent.trim()) {
mobileTitle.textContent = articleTitle; // known at load; scrollspy refines it
}
const mobileDropdown = articlePage.querySelector('.mobile-scrollspy-dropdown');
const mobileLinksContainer = document.getElementById('mobile-scrollspy-links');
const progressBar = articlePage.querySelector('.progress-bar');
const desktopNavLinks = desktopUl ? Array.from(desktopUl.querySelectorAll('a')) : [];
// --- 5f. Napolni mobilni spustni seznam ---
if (headings.length > 0 && mobileLinksContainer && mobileLinksContainer.children.length === 0) {
headings.forEach(heading => {
const listItem = document.createElement('li');
const link = document.createElement('a');
link.href = '#' + heading.id;
link.textContent = navLabel(heading);
listItem.appendChild(link);
mobileLinksContainer.appendChild(listItem);
});
}
// --- 5g. Odpiranje/zapiranje mobilnega spustnega seznama ---
if (mobileHeader && mobileDropdown) {
mobileHeader.addEventListener('click', (e) => {
if (!e.target.closest('a')) {
mobileHeader.classList.toggle('open');
mobileDropdown.classList.toggle('open');
}
});
mobileDropdown.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
mobileHeader.classList.remove('open');
mobileDropdown.classList.remove('open');
});
});
}
// --- 5h. Progress Bar ---
function updateProgressBar() {
if (!progressBar) return;
const scrollableHeight = document.documentElement.scrollHeight - window.innerHeight;
const scrollTop = window.scrollY;
progressBar.style.width = scrollableHeight > 0
? `${(scrollTop / scrollableHeight) * 100}%`
: '0%';
}
// --- 5i. Scrollspy (posodobi navigacijo, naslov in URL sidro) ---
if (headings.length > 0) {
let activeId = null;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const id = entry.target.id;
if (id === activeId) return;
activeId = id;
// Namizna navigacija
const desktopNavLink = desktopNavLinks.find(link => link.getAttribute('href') === `#${id}`);
if (desktopNavLink) {
desktopNavLinks.forEach(link => link.classList.remove('active'));
desktopNavLink.classList.add('active');
}
// Mobilni naslov + aktivna povezava
if (mobileTitle) mobileTitle.textContent = navLabel(entry.target);
if (mobileLinksContainer) {
const mobileNavLink = mobileLinksContainer.querySelector(`a[href="#${id}"]`);
if (mobileNavLink) {
mobileLinksContainer.querySelectorAll('a').forEach(link => link.classList.remove('active'));
mobileNavLink.classList.add('active');
}
}
// Posodobi URL sidro brez skoka in brez polnjenja zgodovine
if (window.history && history.replaceState) {
history.replaceState(null, '', '#' + id);
}
});
}, {
rootMargin: "-100px 0px -55% 0px" // Sproži, ko je naslov v zgornjem delu zaslona
});
headings.forEach(heading => observer.observe(heading));
}
window.addEventListener('scroll', updateProgressBar);
updateProgressBar();
}
} catch (error) {
console.error('Error in Article Scrollspy/Progress Bar setup:', error);
}
/**
* ===================================================================
* 6. FILTER KATEGORIJ ZA SEZNAM PROJEKTOV / OBJAV
* ===================================================================
*
* Deluje na katerikoli strani s seznamom (projekti, novice, ...).
* Popolnoma jezikovno neodvisen: gumbi nosijo stabilni ključ
* (data-filter / data-subfilter), kartice pa data-category /
* data-subcategory. Oznake so lahko v katerem koli jeziku.
*/
try {
const grid = document.querySelector('.posts-grid');
if (grid) {
const filterSection = document.querySelector('.filter-section');
const pagination = document.querySelector('.pagination');
const cards = Array.from(grid.querySelectorAll('.post-card'));
const emptyMsg = document.querySelector('.filter-empty');
// Koliko kartic na stran. Ko bo objav več kot toliko, se
// paginacija samodejno vklopi; do takrat prikaže eno stran.
const PAGE_SIZE = 9;
let activeCat = 'all';
let activeSub = 'all';
let currentPage = 1;
let matching = cards.slice();
// --- Paginacija ---
const pageNumbers = pagination ? pagination.querySelector('.pagination-numbers') : null;
const arrows = pagination ? pagination.querySelectorAll('.pagination-arrow') : [];
const prevArrow = arrows.length ? arrows[0] : null;
const nextArrow = arrows.length > 1 ? arrows[arrows.length - 1] : null;
const totalPages = () => Math.max(1, Math.ceil(matching.length / PAGE_SIZE));
const renderPageNumbers = (total) => {
if (!pageNumbers) return;
pageNumbers.innerHTML = '';
const addNum = (n) => {
const a = document.createElement('a');
a.href = '#';
a.className = 'page-number' + (n === currentPage ? ' active' : '');
a.textContent = n;
a.addEventListener('click', (e) => { e.preventDefault(); goToPage(n); });
pageNumbers.appendChild(a);
};
const addDots = () => {
const s = document.createElement('span');
s.className = 'page-dots';
s.textContent = '...';
pageNumbers.appendChild(s);
};
// Do 7 strani pokažemo vse; sicer okno okoli trenutne strani.
const seq = [];
if (total <= 7) {
for (let i = 1; i <= total; i++) seq.push(i);
} else {
seq.push(1);
const start = Math.max(2, currentPage - 1);
const end = Math.min(total - 1, currentPage + 1);
if (start > 2) seq.push('...');
for (let i = start; i <= end; i++) seq.push(i);
if (end < total - 1) seq.push('...');
seq.push(total);
}
seq.forEach(p => (p === '...' ? addDots() : addNum(p)));
};
const render = () => {
const total = totalPages();
if (currentPage > total) currentPage = total;
if (currentPage < 1) currentPage = 1;
cards.forEach(c => { c.hidden = true; });
const start = (currentPage - 1) * PAGE_SIZE;
matching.slice(start, start + PAGE_SIZE).forEach(c => { c.hidden = false; });
if (emptyMsg) emptyMsg.hidden = matching.length !== 0;
if (pagination) {
// Skrij paginacijo, ko ni zadetkov.
pagination.hidden = matching.length === 0;
renderPageNumbers(total);
if (prevArrow) prevArrow.classList.toggle('disabled', currentPage <= 1);
if (nextArrow) nextArrow.classList.toggle('disabled', currentPage >= total);
}
};
function goToPage(n) {
const total = totalPages();
currentPage = Math.min(Math.max(1, n), total);
render();
// Po menjavi strani se pomaknemo na vrh seznama.
const anchor = filterSection || grid;
const y = anchor.getBoundingClientRect().top + window.scrollY - 100;
window.scrollTo({ top: y, behavior: 'smooth' });
}
if (prevArrow) {
prevArrow.addEventListener('click', (e) => {
e.preventDefault();
if (currentPage > 1) goToPage(currentPage - 1);
});
}
if (nextArrow) {
nextArrow.addEventListener('click', (e) => {
e.preventDefault();
if (currentPage < totalPages()) goToPage(currentPage + 1);
});
}
// --- Filter (če je na strani) ---
const computeMatching = () => {
matching = cards.filter(card => {
const cat = (card.getAttribute('data-category') || '').trim();
const sub = (card.getAttribute('data-subcategory') || '').trim();
let show = activeCat === 'all' || cat === activeCat;
if (show && activeCat !== 'all' && activeSub !== 'all') {
show = sub === activeSub;
}
return show;
});
};
if (filterSection) {
const pills = Array.from(filterSection.querySelectorAll('.filter-pill'));
const subnavs = Array.from(filterSection.querySelectorAll('.filter-subnav'));
const showSubnavFor = (cat) => {
subnavs.forEach(sn => {
const match = sn.getAttribute('data-parent') === cat;
sn.hidden = !match;
if (match) {
sn.querySelectorAll('.filter-chip').forEach(chip =>
chip.classList.toggle('active',
(chip.getAttribute('data-subfilter') || 'all') === 'all'));
}
});
};
pills.forEach(pill => {
pill.addEventListener('click', () => {
activeCat = pill.getAttribute('data-filter') || 'all';
activeSub = 'all';
currentPage = 1;
pills.forEach(p => {
const on = p === pill;
p.classList.toggle('active', on);
p.setAttribute('aria-pressed', on ? 'true' : 'false');
});
showSubnavFor(activeCat);
computeMatching();
render();
});
});
subnavs.forEach(sn => {
sn.querySelectorAll('.filter-chip').forEach(chip => {
chip.addEventListener('click', () => {
activeSub = chip.getAttribute('data-subfilter') || 'all';
currentPage = 1;
sn.querySelectorAll('.filter-chip').forEach(c =>
c.classList.toggle('active', c === chip));
computeMatching();
render();
});
});
});
// Začetno stanje: "Vse" aktivno, podfiltri skriti.
showSubnavFor(activeCat);
}
computeMatching();
render();
}
} catch (error) {
console.error('Error in Project Filter/Pagination setup:', error);
}
/**
* ===================================================================
* 7. NEWSLETTER (MailerLite)
* ===================================================================
*/
function setupNewsletter() {
// --- MailerLite configuration -----------------------------------
// Fill these in from your MailerLite embedded form
// (Forms > Embedded forms > your form > Embed code). Both values
// appear in the embed's subscribe URL:
// https://assets.mailerlite.com/jsonp/<ACCOUNT>/forms/<FORM>/subscribe
const ML_ACCOUNT = '1253161';
const ML_FORM = '194374752800868123';
// ----------------------------------------------------------------
const forms = document.querySelectorAll('form.subscribe-form, form.subscribe-form-bottom');
if (!forms.length) return;
let lang = (document.documentElement.getAttribute('lang') || 'en').toLowerCase();
if (lang === 'sl') lang = 'si'; // <html lang="sl"> maps to the site's /si/ locale
if (['en', 'si', 'mk'].indexOf(lang) === -1) lang = 'en';
const T = {
invalid: { en: 'Please enter a valid email address.', si: 'Vnesite veljaven e-poštni naslov.', mk: 'Внесете важечка е-адреса.' },
sending: { en: 'Sending…', si: 'Pošiljanje…', mk: 'Се испраќа…' },
success: { en: 'Thank you! Please check your inbox to confirm your subscription.', si: 'Hvala! Za potrditev naročnine preverite svoj e-poštni predal.', mk: 'Ви благодариме! Проверете го вашето сандаче за да ја потврдите претплатата.' },
error: { en: 'Something went wrong. Please try again later.', si: 'Nekaj je šlo narobe. Poskusite znova pozneje.', mk: 'Нешто не успеа. Обидете се повторно подоцна.' },
notready: { en: 'The newsletter is being set up. Please check back soon.', si: 'Novice se pravkar nastavljajo. Preverite kmalu znova.', mk: 'Билтенот се поставува. Проверете повторно наскоро.' }
};
const t = (k) => (T[k][lang] || T[k].en);
forms.forEach((form) => {
const input = form.querySelector('input[type="email"], input[type="text"], input');
if (!input) return;
input.setAttribute('type', 'email');
input.setAttribute('name', 'fields[email]');
input.setAttribute('required', '');
input.setAttribute('autocomplete', 'email');
const status = document.createElement('p');
status.className = 'subscribe-status';
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
status.hidden = true;
form.parentNode.insertBefore(status, form.nextSibling);
const show = (msg, type) => {
status.textContent = msg;
status.className = 'subscribe-status is-' + type;
status.hidden = false;
};
form.addEventListener('submit', (e) => {
e.preventDefault();
const email = (input.value || '').trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
show(t('invalid'), 'error');
input.focus();
return;
}
if (!ML_ACCOUNT || !ML_FORM) { show(t('notready'), 'error'); return; }
const button = form.querySelector('button[type="submit"], button');
if (button) button.disabled = true;
show(t('sending'), 'info');
const url = 'https://assets.mailerlite.com/jsonp/' + ML_ACCOUNT + '/forms/' + ML_FORM + '/subscribe';
const body = new URLSearchParams();
body.append('fields[email]', email);
body.append('ml-submit', '1');
body.append('anticsrf', 'true');
fetch(url, { method: 'POST', body: body, mode: 'no-cors' })
.then(() => {
// Double opt-in: MailerLite emails a confirmation link, so we
// optimistically confirm here (the opt-in email is the real check).
form.reset();
show(t('success'), 'success');
})
.catch(() => show(t('error'), 'error'))
.finally(() => { if (button) button.disabled = false; });
});
});
}
/**
* ===================================================================
* LANGUAGE SUGGESTION BANNER (browser-language based, dismissible)
* ===================================================================
*/
function setupLanguageSuggestion() {
try {
var STORE = 'msos-lang-banner';
if (localStorage.getItem(STORE) === 'dismissed') return;
var validLangs = ['en', 'si', 'mk'];
// Current page language (<html lang>; site uses /si/ for Slovene "sl").
var docLang = (document.documentElement.getAttribute('lang') || 'en').toLowerCase();
if (docLang === 'sl') docLang = 'si';
// Detect the visitor's preferred language from the browser.
var navLangs = navigator.languages || [navigator.language || ''];
var detected = null;
for (var i = 0; i < navLangs.length; i++) {
var l = (navLangs[i] || '').toLowerCase();
if (l.indexOf('mk') === 0) { detected = 'mk'; break; }
if (l.indexOf('sl') === 0) { detected = 'si'; break; }
if (l.indexOf('en') === 0) { detected = 'en'; break; }
}
if (!detected || detected === docLang) return; // unknown or already correct
// Build the equivalent URL in the detected language (same logic as the switcher).
var path = window.location.pathname;
var parts = path.split('/').filter(Boolean);
var pathWithoutLang = path;
if (parts.length > 0 && validLangs.indexOf(parts[0]) !== -1) {
pathWithoutLang = '/' + parts.slice(1).join('/');
if (path.endsWith('/') && pathWithoutLang !== '/') pathWithoutLang += '/';
}
var targetUrl = ('/' + detected + pathWithoutLang).replace(/\/{2,}/g, '/');
var MSG = {
en: { text: 'This page is also available in English.', btn: 'View in English', flag: 'gb', aria: 'Language suggestion' },
mk: { text: 'Оваа страница е достапна и на македонски.', btn: 'Прикажи на македонски', flag: 'mk', aria: 'Предлог за јазик' },
si: { text: 'Ta stran je na voljo tudi v slovenščini.', btn: 'Prikaži v slovenščini', flag: 'si', aria: 'Predlog jezika' }
};
var m = MSG[detected];
function showBanner() {
if (document.querySelector('.lang-suggest')) return;
var bar = document.createElement('div');
bar.className = 'lang-suggest';
bar.setAttribute('role', 'region');
bar.setAttribute('aria-label', m.aria);
bar.innerHTML =
'<span class="lang-flag lang-flag-' + m.flag + '" aria-hidden="true"></span>' +
'<p class="lang-suggest-text">' + m.text + '</p>' +
'<a class="lang-suggest-btn" href="' + targetUrl + '">' + m.btn + '</a>' +
'<button type="button" class="lang-suggest-close" aria-label="Dismiss">&times;</button>';
document.body.appendChild(bar);
requestAnimationFrame(function () { bar.classList.add('is-visible'); });
function remember() { try { localStorage.setItem(STORE, 'dismissed'); } catch (e) {} }
bar.querySelector('.lang-suggest-btn').addEventListener('click', remember);
bar.querySelector('.lang-suggest-close').addEventListener('click', function () {
remember();
bar.classList.remove('is-visible');
setTimeout(function () { bar.remove(); }, 300);
});
}
// Don't collide with the cookie-consent card (also a bottom-centred fixed card).
// If the visitor hasn't answered consent yet, wait until they do, then show.
var consentDecided;
try {
var c = localStorage.getItem('msos-consent');
consentDecided = (c === 'granted' || c === 'denied');
} catch (e) { consentDecided = true; }
if (consentDecided && !document.querySelector('.msos-cc')) {
showBanner();
} else if ('MutationObserver' in window) {
var obs = new MutationObserver(function () {
if (!document.querySelector('.msos-cc')) { obs.disconnect(); showBanner(); }
});
obs.observe(document.body, { childList: true });
} else {
// Fallback: poll until the consent card is gone.
var tries = 0;
var iv = setInterval(function () {
if (!document.querySelector('.msos-cc') || ++tries > 120) { clearInterval(iv); showBanner(); }
}, 500);
}
} catch (error) {
console.error('Error in language suggestion banner:', error);
}
}
/**
* ===================================================================
* REUSABLE LIGHTBOX (for .event-gallery-grid.js-lightbox blocks)
* ===================================================================
*/
function setupLightbox() {
var grids = document.querySelectorAll('.js-lightbox');
if (!grids.length) return;
var lb = document.createElement('div');
lb.className = 'lightbox';
lb.setAttribute('role', 'dialog');
lb.setAttribute('aria-label', 'Photo viewer');
lb.setAttribute('aria-hidden', 'true');
lb.innerHTML =
'<button type="button" class="lightbox-close" aria-label="Close">&times;</button>' +
'<button type="button" class="lightbox-prev" aria-label="Previous">&#10094;</button>' +
'<img class="lightbox-img" src="" alt="">' +
'<button type="button" class="lightbox-next" aria-label="Next">&#10095;</button>' +
'<a class="lightbox-project" href="#" hidden></a>' +
'<div class="lightbox-caption"></div>';
document.body.appendChild(lb);
var img = lb.querySelector('.lightbox-img');
var cap = lb.querySelector('.lightbox-caption');
var proj = lb.querySelector('.lightbox-project');
var group = [];
var idx = 0;
function render() {
var it = group[idx];
img.setAttribute('src', it.full);
img.setAttribute('alt', it.caption || '');
cap.textContent = (it.caption || '') + (group.length > 1 ? ' (' + (idx + 1) + '/' + group.length + ')' : '');
}
function open(grid, start) {
group = Array.prototype.map.call(grid.querySelectorAll('.eg-thumb'), function (b) {
return { full: b.getAttribute('data-full'), caption: b.getAttribute('data-caption') || '' };
});
idx = start;
// Optional "go to project" link (present on gallery-page albums).
var purl = grid.getAttribute('data-project-url');
if (purl) {
proj.setAttribute('href', purl);
proj.textContent = grid.getAttribute('data-project-label') || 'View project';
proj.hidden = false;
} else {
proj.hidden = true;
}
render();
lb.classList.add('is-open');
lb.setAttribute('aria-hidden', 'false');
document.documentElement.style.overflow = 'hidden';
// hide the floating "Ask MSOS" pill so it doesn't overlap the viewer
document.body.classList.add('lightbox-open');
}
function close() {
lb.classList.remove('is-open');
lb.setAttribute('aria-hidden', 'true');
document.documentElement.style.overflow = '';
document.body.classList.remove('lightbox-open');
img.setAttribute('src', '');
}
function step(d) { idx = (idx + d + group.length) % group.length; render(); }
grids.forEach(function (grid) {
grid.querySelectorAll('.eg-thumb').forEach(function (b, i) {
b.addEventListener('click', function () { open(grid, i); });
});
});
// Triggers that open a target gallery (e.g. gallery-page album covers).
document.querySelectorAll('[data-lightbox-target]').forEach(function (trig) {
trig.addEventListener('click', function () {
var g = document.getElementById(trig.getAttribute('data-lightbox-target'));
if (g) open(g, 0);
});
});
lb.querySelector('.lightbox-close').addEventListener('click', close);
lb.querySelector('.lightbox-prev').addEventListener('click', function () { step(-1); });
lb.querySelector('.lightbox-next').addEventListener('click', function () { step(1); });
lb.addEventListener('click', function (e) { if (e.target === lb) close(); });
document.addEventListener('keydown', function (e) {
if (!lb.classList.contains('is-open')) return;
if (e.key === 'Escape') close();
else if (e.key === 'ArrowLeft') step(-1);
else if (e.key === 'ArrowRight') step(1);
});
// Swipe on touch devices
var sx = 0;
lb.addEventListener('touchstart', function (e) { sx = e.changedTouches[0].clientX; }, { passive: true });
lb.addEventListener('touchend', function (e) {
var dx = e.changedTouches[0].clientX - sx;
if (Math.abs(dx) > 40) step(dx < 0 ? 1 : -1);
}, { passive: true });
}
/**
* ===================================================================
* HUB USE-CASE CAROUSEL (auto-scrolling + drag + position dots)
* ===================================================================
* Drives every .hub-carousel on the page (Hub "What you can do here"
* and the "ways to be part of MSOS" slider). The track is built from
* three identical .hub-group copies so the scroll loops seamlessly in
* both directions; auto-scroll is JS-driven (not a CSS animation) so it
* can co-exist with click-and-drag and touch swiping.
*/
function setupHubCarousel() {
const carousels = document.querySelectorAll('.hub-carousel');
if (!carousels.length) return;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const LOOP_MS = 55000; // time to travel one group width (matches old 55s marquee)
carousels.forEach(function (carousel) {
if (carousel.dataset.carouselInit) return;
carousel.dataset.carouselInit = '1';
const scroller = carousel.querySelector('.hub-uses');
const track = carousel.querySelector('.hub-track');
if (!scroller || !track) return;
const firstGroup = track.querySelector('.hub-group');
if (!firstGroup) return;
// Need 3 identical groups so a full neighbour is always visible at each wrap point.
while (track.querySelectorAll('.hub-group').length < 3) {
const clone = firstGroup.cloneNode(true);
clone.setAttribute('aria-hidden', 'true');
track.appendChild(clone);
}
const cards = firstGroup.children;
const cardCount = cards.length;
if (!cardCount) return;
// --- Position dots ---
const dotsWrap = document.createElement('div');
dotsWrap.className = 'hub-dots';
dotsWrap.setAttribute('role', 'tablist');
dotsWrap.setAttribute('aria-label', 'Carousel position');
const dotEls = [];
for (let i = 0; i < cardCount; i++) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'hub-dot';
b.setAttribute('aria-label', 'Go to item ' + (i + 1));
b.addEventListener('click', (function (idx) {
return function () { goTo(idx); };
})(i));
dotsWrap.appendChild(b);
dotEls.push(b);
}
carousel.appendChild(dotsWrap);
// --- Measurements (recomputed on resize / image load) ---
let groupWidth = 0, step = 0;
function measure() {
groupWidth = firstGroup.offsetWidth;
step = cardCount > 1 ? (cards[1].offsetLeft - cards[0].offsetLeft) : groupWidth;
if (!step) step = groupWidth / cardCount;
}
// Keep scroll within the middle copy [groupWidth, 2*groupWidth) so it loops both ways.
function wrap() {
if (!groupWidth) return;
if (scroller.scrollLeft >= 2 * groupWidth) scroller.scrollLeft -= groupWidth;
else if (scroller.scrollLeft < groupWidth) scroller.scrollLeft += groupWidth;
}
function updateDots() {
if (!step || !groupWidth) return;
const within = ((scroller.scrollLeft % groupWidth) + groupWidth) % groupWidth;
const active = Math.round(within / step) % cardCount;
for (let i = 0; i < cardCount; i++) {
const dist = Math.min(Math.abs(i - active), cardCount - Math.abs(i - active));
dotEls[i].classList.toggle('is-active', i === active);
dotEls[i].classList.toggle('is-near', dist === 1);
dotEls[i].classList.toggle('is-far', dist >= 2);
}
}
function reset() {
measure();
if (groupWidth) scroller.scrollLeft = groupWidth;
updateDots();
}
reset();
window.addEventListener('load', reset);
window.addEventListener('resize', function () { measure(); wrap(); updateDots(); });
// --- Jump to a specific card when a dot is clicked ---
// Custom eased scroll (not native smooth) so it never fights the
// rAF auto-scroll loop. Target always sits in the middle copy
// [groupWidth, 2*groupWidth), so wrap() stays a no-op during it.
let programmatic = 0; // ms remaining where auto-scroll yields
let animId = 0;
function goTo(idx) {
if (!step) measure();
const start = scroller.scrollLeft;
const target = groupWidth + idx * step;
const dur = 450, t0 = performance.now();
const ease = function (t) { return 1 - Math.pow(1 - t, 3); };
cancelAnimationFrame(animId);
programmatic = dur + 80;
(function anim(now) {
const t = Math.min(1, (now - t0) / dur);
scroller.scrollLeft = start + (target - start) * ease(t);
updateDots();
if (t < 1) animId = requestAnimationFrame(anim);
})(t0);
}
// --- Auto-scroll loop ---
let paused = false;
let dragging = false;
let last = null;
function tick(ts) {
if (last == null) last = ts;
const dt = ts - last;
last = ts;
if (!reduceMotion && !paused && programmatic <= 0 && groupWidth) {
scroller.scrollLeft += (groupWidth / LOOP_MS) * dt;
}
if (programmatic > 0) programmatic -= dt;
wrap();
updateDots();
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
// Pause on hover (desktop).
carousel.addEventListener('mouseenter', function () { paused = true; });
carousel.addEventListener('mouseleave', function () { if (!dragging) paused = false; });
// --- Click-and-drag (mouse) + pause during touch (native scroll handles the swipe) ---
let lastX = 0;
scroller.addEventListener('pointerdown', function (e) {
paused = true;
if (e.pointerType !== 'mouse') return; // touch/pen: let the browser scroll natively
dragging = true;
lastX = e.clientX;
scroller.classList.add('is-dragging');
try { scroller.setPointerCapture(e.pointerId); } catch (err) {}
});
scroller.addEventListener('pointermove', function (e) {
if (!dragging) return;
const dx = e.clientX - lastX;
lastX = e.clientX;
scroller.scrollLeft -= dx;
wrap();
updateDots();
e.preventDefault();
});
function endDrag(e) {
if (dragging) {
dragging = false;
scroller.classList.remove('is-dragging');
try { scroller.releasePointerCapture(e.pointerId); } catch (err) {}
}
// Resume auto-scroll unless the cursor is still hovering the carousel.
if (!carousel.matches(':hover')) paused = false;
}
scroller.addEventListener('pointerup', endDrag);
scroller.addEventListener('pointercancel', endDrag);
// Prevent the browser's native image drag-ghost while dragging.
track.addEventListener('dragstart', function (e) { e.preventDefault(); });
});
}
/**
* ===================================================================
* ABOUT US TEAM HERO (auto-rotating crossfade slideshow)
* ===================================================================
*/
function setupAboutHero() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('.about-hero').forEach(function (hero) {
const slides = hero.querySelectorAll('.about-hero-slide');
if (slides.length < 2) return;
let i = 0;
setInterval(function () {
slides[i].classList.remove('is-active');
i = (i + 1) % slides.length;
slides[i].classList.add('is-active');
}, 4500);
});
}
/**
* ===================================================================
* APPRECIATION MARQUEE (Apple-style focal zoom)
* The strip auto-scrolls via CSS; here we scale + brighten each card
* by its distance from the horizontal centre, so whichever photo is
* in the middle grows and lightens, then shrinks as it moves on.
* ===================================================================
*/
function setupApprMarquee() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const marquee = document.querySelector('.appr-marquee');
if (!marquee) return;
const cards = marquee.querySelectorAll('.appr-card');
if (!cards.length) return;
// scale: 1.0 at centre -> MIN_SCALE at the edges; brightness likewise
const MIN_SCALE = 0.72;
const MIN_BRIGHT = 0.5;
function frame() {
const vpCenter = window.innerWidth / 2;
for (let n = 0; n < cards.length; n++) {
const r = cards[n].getBoundingClientRect();
const cardCenter = r.left + r.width / 2;
// 0 at centre, ~1 near the edges of the viewport
let d = Math.abs(cardCenter - vpCenter) / vpCenter;
if (d > 1) d = 1;
const e = d * d; // ease so only the very centre is full size
const scale = 1 - (1 - MIN_SCALE) * e;
const bright = 1 - (1 - MIN_BRIGHT) * e;
cards[n].style.transform = 'scale(' + scale.toFixed(3) + ')';
cards[n].style.filter = 'brightness(' + bright.toFixed(3) + ')';
}
rafId = requestAnimationFrame(frame);
}
let rafId = requestAnimationFrame(frame);
}
/**
* ===================================================================
* 8. ZAGON FUNKCIJ PO NALOŽITVI STRANI
* ===================================================================
*/
highlightActiveLink();
setupLanguageSwitcher();
setupLanguageSuggestion();
setupNewsletter();
setupHubCarousel();
setupAboutHero();
setupApprMarquee();
setupLightbox();
});