PageSourceSearch

https://tma.edu.ge/v16/js/site.js?v=1784901266

js tma.edu.ge collected 2026-09-25 02:46:59 UTC 44,883 bytes, 1,229 lines download raw bytes

1const navToggle = document.querySelector('[data-nav-toggle]');
2const mobileNav = document.querySelector('[data-mnav]');
3const mobileNavSheet = mobileNav?.querySelector('[data-mnav-sheet]') || null;
4const dropdowns = [...document.querySelectorAll('.main-menu--desktop [data-dropdown]')];
5const topbarMenu = document.querySelector('[data-topbar-menu]');
6const header = document.querySelector('[data-header]');
7const desktopHeaderBreakpoint = 1280;
8const desktopHeaderTopThreshold = 16;
9const menuOpenLabel = navToggle?.getAttribute('data-label-open') || (document.documentElement.lang === 'en' ? 'Open menu' : 'მენიუს გახსნა');
10const menuCloseLabel = navToggle?.getAttribute('data-label-close') || (document.documentElement.lang === 'en' ? 'Close menu' : 'მენიუს დახურვა');
11
12const syncSiteHeaderOffset = () => {
13    if (!header) {
14        return;
15    }
16
17    header.style.setProperty('--v16-site-header-offset', `${header.offsetHeight}px`);
18};
19
20if (topbarMenu) {
21    const topbarTray = topbarMenu.querySelector('[data-topbar-tray]');
22    const topbarTriggers = [...topbarMenu.querySelectorAll('[data-topbar-trigger]')];
23    const topbarPanels = [...topbarMenu.querySelectorAll('[data-topbar-panel]')];
24    const topbarCloseButtons = [...topbarMenu.querySelectorAll('[data-topbar-close]')];
25    const topbarMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
26    const topbarCloseDuration = () => (topbarMotionQuery.matches ? 0 : 340);
27    const topbarSwitchDuration = () => (topbarMotionQuery.matches ? 0 : 220);
28    let topbarCloseTimer = null;
29
30    const resetTopbarPanels = () => {
31        topbarPanels.forEach((panel) => {
32            panel.classList.remove('is-active', 'is-switching');
33            panel.setAttribute('hidden', '');
34        });
35    };
36
37    const setTopbarTriggerState = (activeTrigger = null) => {
38        topbarTriggers.forEach((trigger) => {
39            const isActive = trigger === activeTrigger;
40            trigger.classList.toggle('is-active', isActive);
41            trigger.setAttribute('aria-expanded', String(isActive));
42        });
43    };
44
45    const activateTopbarPanel = (panel) => {
46        panel.classList.remove('is-switching');
47        panel.classList.add('is-active');
48        panel.removeAttribute('hidden');
49    };
50
51    const finalizeTopbarClose = () => {
52        window.clearTimeout(topbarCloseTimer);
53        topbarCloseTimer = null;
54        topbarMenu.classList.remove('is-tray-open', 'is-tray-closing');
55        topbarTray?.setAttribute('aria-hidden', 'true');
56        resetTopbarPanels();
57        setTopbarTriggerState();
58        syncSiteHeaderOffset();
59    };
60
61    const closeTopbarTray = (immediate = false) => {
62        if (!topbarMenu.classList.contains('is-tray-open')) {
63            return;
64        }
65
66        if (immediate || topbarCloseDuration() === 0) {
67            finalizeTopbarClose();
68            return;
69        }
70
71        topbarMenu.classList.add('is-tray-closing');
72        topbarMenu.classList.remove('is-tray-open');
73        syncSiteHeaderOffset();
74
75        window.clearTimeout(topbarCloseTimer);
76        topbarCloseTimer = window.setTimeout(finalizeTopbarClose, topbarCloseDuration());
77    };
78
79    const switchTopbarPanel = (panel, trigger) => {
80        const activePanel = topbarPanels.find((item) => item.classList.contains('is-active'));
81
82        if (activePanel && activePanel !== panel) {
83            activePanel.classList.add('is-switching');
84            activePanel.classList.remove('is-active');
85
86            window.setTimeout(() => {
87                activePanel.classList.remove('is-switching');
88                activePanel.setAttribute('hidden', '');
89                activateTopbarPanel(panel);
90                setTopbarTriggerState(trigger);
91                syncSiteHeaderOffset();
92            }, topbarSwitchDuration());
93
94            return;
95        }
96
97        activateTopbarPanel(panel);
98        setTopbarTriggerState(trigger);
99        syncSiteHeaderOffset();
100    };
101
102    const openTopbarTray = (panelId, trigger) => {
103        const panel = topbarMenu.querySelector(`[data-topbar-panel="${panelId}"]`);
104        if (!panel || !topbarTray) {
105            return;
106        }
107
108        window.clearTimeout(topbarCloseTimer);
109        topbarMenu.classList.remove('is-tray-closing');
110        topbarTray.setAttribute('aria-hidden', 'false');
111
112        if (topbarMenu.classList.contains('is-tray-open')) {
113            switchTopbarPanel(panel, trigger);
114            return;
115        }
116
117        resetTopbarPanels();
118        topbarMenu.classList.add('is-tray-open');
119        activateTopbarPanel(panel);
120        setTopbarTriggerState(trigger);
121        syncSiteHeaderOffset();
122    };
123
124    topbarTriggers.forEach((trigger) => {
125        trigger.addEventListener('click', (event) => {
126            event.stopPropagation();
127            const panelId = trigger.getAttribute('data-topbar-trigger');
128            if (!panelId) {
129                return;
130            }
131
132            if (trigger.classList.contains('is-active')) {
133                closeTopbarTray();
134                trigger.focus();
135                return;
136            }
137
138            openTopbarTray(panelId, trigger);
139        });
140    });
141
142    topbarCloseButtons.forEach((button) => {
143        button.addEventListener('click', (event) => {
144            event.stopPropagation();
145            const activeTrigger = topbarTriggers.find((trigger) => trigger.classList.contains('is-active'));
146            closeTopbarTray();
147            activeTrigger?.focus();
148        });
149    });
150
151    topbarMenu.addEventListener('keydown', (event) => {
152        if (event.key === 'Escape') {
153            const activeTrigger = topbarTriggers.find((trigger) => trigger.classList.contains('is-active'));
154            closeTopbarTray();
155            activeTrigger?.focus();
156        }
157    });
158
159    topbarMenu.addEventListener('click', (event) => {
160        if (event.target instanceof HTMLAnchorElement) {
161            closeTopbarTray();
162        }
163    });
164
165    document.addEventListener('click', (event) => {
166        if (!(event.target instanceof Node) || topbarMenu.contains(event.target)) {
167            return;
168        }
169
170        closeTopbarTray();
171    });
172
173    topbarTray?.addEventListener('transitionend', (event) => {
174        if (event.target !== topbarTray) {
175            return;
176        }
177
178        if (event.propertyName === 'grid-template-rows') {
179            syncSiteHeaderOffset();
180        }
181    });
182}
183
184const closeDropdowns = (exceptDropdown = null) => {
185    dropdowns.forEach((dropdown) => {
186        if (dropdown === exceptDropdown) {
187            return;
188        }
189
190        // Keep ancestor menus open when expanding a nested submenu.
191        if (exceptDropdown && dropdown.contains(exceptDropdown)) {
192            return;
193        }
194
195        dropdown.classList.remove('is-open');
196        dropdown.querySelectorAll('[data-dropdown-toggle]').forEach((toggle) => {
197            toggle.setAttribute('aria-expanded', 'false');
198        });
199    });
200};
201
202dropdowns.forEach((dropdown) => {
203    const toggle = dropdown.querySelector('[data-dropdown-toggle]');
204
205    toggle?.addEventListener('click', (event) => {
206        event.preventDefault();
207        event.stopPropagation();
208        const isOpen = dropdown.classList.toggle('is-open');
209        toggle.setAttribute('aria-expanded', String(isOpen));
210        closeDropdowns(isOpen ? dropdown : null);
211
212        if (!isOpen) {
213            dropdown.querySelectorAll('[data-dropdown].is-open').forEach((nested) => {
214                nested.classList.remove('is-open');
215                nested.querySelector('[data-dropdown-toggle]')?.setAttribute('aria-expanded', 'false');
216            });
217        }
218    });
219
220    dropdown.addEventListener('keydown', (event) => {
221        if (event.key === 'Escape') {
222            dropdown.classList.remove('is-open');
223            toggle?.setAttribute('aria-expanded', 'false');
224            toggle?.focus();
225        }
226    });
227
228    dropdown.addEventListener('click', (event) => {
229        if (event.target instanceof HTMLAnchorElement) {
230            // Only close sibling menus when navigating; leave parent chain for page leave.
231            closeDropdowns();
232        }
233    });
234});
235
236document.addEventListener('click', (event) => {
237    const target = event.target;
238    if (!(target instanceof Node)) {
239        return;
240    }
241
242    if (target.closest('.main-menu--desktop [data-dropdown]')) {
243        return;
244    }
245
246    closeDropdowns();
247});
248
249const setMobileNavOpen = (isOpen) => {
250    if (!mobileNav || !navToggle) {
251        return;
252    }
253
254    mobileNav.classList.toggle('is-open', isOpen);
255    mobileNav.setAttribute('aria-hidden', String(!isOpen));
256    navToggle.classList.toggle('is-active', isOpen);
257    navToggle.setAttribute('aria-expanded', String(isOpen));
258    navToggle.setAttribute('aria-label', isOpen ? menuCloseLabel : menuOpenLabel);
259    document.body.classList.toggle('nav-open', isOpen);
260
261    if (isOpen) {
262        const navScroller = mobileNav.querySelector('.v16-mnav__nav');
263        if (navScroller) {
264            navScroller.scrollTop = 0;
265        }
266        mobileNavSheet?.querySelector('[data-mnav-close]')?.focus({ preventScroll: true });
267    }
268};
269
270const closeMobileNav = () => setMobileNavOpen(false);
271
272if (navToggle && mobileNav) {
273    navToggle.addEventListener('click', (event) => {
274        event.stopPropagation();
275        setMobileNavOpen(!mobileNav.classList.contains('is-open'));
276    });
277
278    mobileNav.querySelectorAll('[data-mnav-close]').forEach((button) => {
279        button.addEventListener('click', closeMobileNav);
280    });
281
282    mobileNav.addEventListener('click', (event) => {
283        if (event.target instanceof HTMLAnchorElement) {
284            closeMobileNav();
285        }
286    });
287
288    // Keep only one top-level accordion open for faster scanning.
289    mobileNav.querySelectorAll(':scope > .v16-mnav__sheet > .v16-mnav__nav > .v16-mnav__acc').forEach((acc) => {
290        acc.addEventListener('toggle', () => {
291            if (!acc.open) {
292                return;
293            }
294
295            mobileNav.querySelectorAll(':scope > .v16-mnav__sheet > .v16-mnav__nav > .v16-mnav__acc').forEach((other) => {
296                if (other !== acc) {
297                    other.open = false;
298                }
299            });
300        });
301    });
302
303    document.addEventListener('keydown', (event) => {
304        if (event.key === 'Escape' && mobileNav.classList.contains('is-open')) {
305            closeMobileNav();
306            navToggle.focus();
307        }
308    });
309
310    window.addEventListener('resize', () => {
311        if (window.innerWidth > desktopHeaderBreakpoint) {
312            closeMobileNav();
313            closeDropdowns();
314        }
315    });
316}
317
318syncSiteHeaderOffset();
319window.addEventListener('resize', syncSiteHeaderOffset, { passive: true });
320
321if (header) {
322    const resetHeaderAnimation = () => {
323        header.style.setProperty('--header-progress', '0');
324        header.classList.remove('is-scrolled');
325    };
326
327    let headerScrollRaf = 0;
328    const syncHeaderAnimation = () => {
329        if (window.innerWidth <= desktopHeaderBreakpoint) {
330            resetHeaderAnimation();
331            return;
332        }
333
334        if (window.scrollY > desktopHeaderTopThreshold) {
335            header.style.setProperty('--header-progress', '1');
336            header.classList.add('is-scrolled');
337        } else {
338            header.style.setProperty('--header-progress', '0');
339            header.classList.remove('is-scrolled');
340        }
341    };
342
343    const onHeaderScroll = () => {
344        if (headerScrollRaf) {
345            return;
346        }
347
348        headerScrollRaf = window.requestAnimationFrame(() => {
349            headerScrollRaf = 0;
350            syncHeaderAnimation();
351        });
352    };
353
354    syncHeaderAnimation();
355    window.addEventListener('scroll', onHeaderScroll, { passive: true });
356    window.addEventListener('resize', () => {
357        if (window.innerWidth <= desktopHeaderBreakpoint) {
358            resetHeaderAnimation();
359            return;
360        }
361
362        syncHeaderAnimation();
363    });
364}
365
366const searchPanel = document.querySelector('[data-search-panel]');
367const searchInput = document.querySelector('[data-search-input]');
368const searchResults = document.querySelector('[data-search-results]');
369const searchEmpty = document.querySelector('[data-search-empty]');
370const searchOpenButtons = [...document.querySelectorAll('[data-search-open]')];
371const searchCloseButtons = [...document.querySelectorAll('[data-search-close]')];
372const heroSearchForms = [...document.querySelectorAll('[data-hero-search]')];
373let searchIndex = [];
374let searchApiUrl = '';
375let searchTimer = null;
376let searchRequestId = 0;
377
378try {
379    searchIndex = JSON.parse(document.getElementById('v16-search-index')?.textContent || document.getElementById('v15-search-index')?.textContent || '[]');
380} catch (error) {
381    searchIndex = [];
382}
383
384try {
385    const searchConfig = JSON.parse(document.getElementById('v16-search-config')?.textContent || '{}');
386    searchApiUrl = typeof searchConfig.api === 'string' ? searchConfig.api : '';
387} catch (error) {
388    searchApiUrl = '';
389}
390
391const searchLocale = document.documentElement.lang === 'en' ? 'en' : 'ka';
392
393const normalizeSearchValue = (value) => String(value ?? '')
394    .normalize('NFC')
395    .toLocaleLowerCase(searchLocale)
396    .trim();
397
398const searchHaystackFor = (item) => normalizeSearchValue(
399    `${item.title || ''} ${item.group || ''} ${item.keywords || ''}`
400);
401
402const matchesSearchQuery = (haystack, query) => {
403    if (query === '') {
404        return true;
405    }
406
407    const tokens = query.split(/\s+/).filter(Boolean);
408    if (tokens.length === 0) {
409        return true;
410    }
411
412    return tokens.every((token) => haystack.includes(token));
413};
414
415const paintSearchResults = (matches) => {
416    if (!searchResults) {
417        return;
418    }
419
420    searchResults.replaceChildren(...matches.map((item) => {
421        const link = document.createElement('a');
422        const group = document.createElement('span');
423        const title = document.createElement('strong');
424
425        link.href = item.href || '#';
426        if (/^https?:\/\//i.test(link.href) && !link.href.includes(window.location.host)) {
427            link.target = '_blank';
428            link.rel = 'noopener noreferrer';
429        } else if (/\.pdf(?:$|\?)/i.test(link.href)) {
430            link.target = '_blank';
431            link.rel = 'noopener noreferrer';
432        }
433        group.textContent = item.group || '';
434        title.textContent = item.title || '';
435        link.append(group, title);
436
437        return link;
438    }));
439
440    if (searchEmpty) {
441        searchEmpty.hidden = matches.length > 0;
442    }
443};
444
445const mergeSearchResults = (primary, secondary, limit = 16) => {
446    const seen = new Set();
447    const merged = [];
448
449    [...primary, ...secondary].forEach((item) => {
450        const href = String(item?.href || '');
451        if (!href || seen.has(href)) {
452            return;
453        }
454        seen.add(href);
455        merged.push(item);
456    });
457
458    return merged.slice(0, limit);
459};
460
461const renderSearchResults = (query = '') => {
462    if (!searchResults) {
463        return;
464    }
465
466    const normalizedQuery = normalizeSearchValue(query);
467    const newsItems = Array.from(document.querySelectorAll('[data-newsstage-item]')).map(item => ({
468        title: item.dataset.title || '',
469        href: item.dataset.url || '#',
470        group: 'News'
471    }));
472    const combinedIndex = searchIndex.concat(newsItems);
473    const staticMatches = combinedIndex
474        .filter((item) => matchesSearchQuery(searchHaystackFor(item), normalizedQuery))
475        .slice(0, 12);
476
477    paintSearchResults(staticMatches);
478
479    clearTimeout(searchTimer);
480    if (!searchApiUrl || normalizedQuery.length < 2) {
481        return;
482    }
483
484    const requestId = ++searchRequestId;
485    searchTimer = window.setTimeout(async () => {
486        try {
487            const response = await fetch(`${searchApiUrl}?q=${encodeURIComponent(query.trim())}`, {
488                headers: { Accept: 'application/json' },
489                credentials: 'same-origin',
490            });
491            if (!response.ok || requestId !== searchRequestId) {
492                return;
493            }
494
495            const payload = await response.json();
496            if (requestId !== searchRequestId) {
497                return;
498            }
499
500            const apiItems = Array.isArray(payload?.items) ? payload.items : [];
501            // Live content first (news/events/docs), then pages/staff.
502            paintSearchResults(mergeSearchResults(apiItems, staticMatches.slice(0, 8), 18));
503        } catch (error) {
504            // Keep static matches if the live index is unavailable.
505        }
506    }, 280);
507};
508
509const closeSearch = () => {
510    if (!searchPanel) {
511        return;
512    }
513
514    clearTimeout(searchTimer);
515    searchRequestId += 1;
516    searchPanel.classList.remove('is-open');
517    searchPanel.setAttribute('aria-hidden', 'true');
518    document.body.classList.remove('search-open');
519};
520
521const openSearch = () => {
522    if (!searchPanel) {
523        return;
524    }
525
526    closeMobileNav();
527    closeDropdowns();
528    renderSearchResults(searchInput?.value || '');
529    searchPanel.classList.add('is-open');
530    searchPanel.setAttribute('aria-hidden', 'false');
531    document.body.classList.add('search-open');
532    window.setTimeout(() => searchInput?.focus(), 60);
533};
534
535searchOpenButtons.forEach((button) => button.addEventListener('click', openSearch));
536searchCloseButtons.forEach((button) => button.addEventListener('click', closeSearch));
537heroSearchForms.forEach((form) => {
538    form.addEventListener('submit', (event) => {
539        event.preventDefault();
540        const heroInput = form.querySelector('[data-hero-search-input]');
541
542        if (searchInput && heroInput instanceof HTMLInputElement) {
543            searchInput.value = heroInput.value;
544        }
545
546        openSearch();
547    });
548});
549searchInput?.addEventListener('input', () => renderSearchResults(searchInput.value));
550searchResults?.addEventListener('click', (event) => {
551    const target = event.target;
552    if (target instanceof Element && target.closest('a')) {
553        closeSearch();
554    }
555});
556
557document.addEventListener('keydown', (event) => {
558    if (document.querySelector('[data-locale-picker]')) {
559        return;
560    }
561
562    if (event.key === 'Escape') {
563        closeSearch();
564    }
565
566    if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k') {
567        event.preventDefault();
568        openSearch();
569    }
570});
571
572const V16_LOCALE_COOKIE = 'v16_locale_pref';
573const V16_LOCALE_COOKIE_TTL = 31536000;
574
575const setV16LocaleCookie = (locale) => {
576    const secure = window.location.protocol === 'https:' ? '; secure' : '';
577    document.cookie = `${V16_LOCALE_COOKIE}=${locale}; path=/; max-age=${V16_LOCALE_COOKIE_TTL}; samesite=lax${secure}`;
578};
579
580document.querySelectorAll('[data-v16-locale-set]').forEach((link) => {
581    link.addEventListener('click', () => {
582        const locale = link.getAttribute('data-v16-locale-set');
583        if (locale === 'ge' || locale === 'ka' || locale === 'en') {
584            setV16LocaleCookie(locale);
585        }
586    });
587});
588
589const localePicker = document.querySelector('[data-locale-picker]');
590
591if (localePicker) {
592    document.body.classList.add('v16-locale-picker-open');
593
594    const currentLocale = document.body.dataset.v16UrlLocale === 'en' ? 'en' : 'ge';
595
596    const closeLocalePicker = () => {
597        localePicker.remove();
598        document.body.classList.remove('v16-locale-picker-open');
599    };
600
601    localePicker.querySelectorAll('[data-locale-pick]').forEach((link) => {
602        link.addEventListener('click', (event) => {
603            const locale = link.getAttribute('data-locale-pick');
604            if (locale !== 'ge' && locale !== 'en') {
605                return;
606            }
607
608            setV16LocaleCookie(locale);
609
610            if (locale === currentLocale) {
611                event.preventDefault();
612                closeLocalePicker();
613            }
614        });
615    });
616}
617
618document.querySelectorAll('[data-slider]').forEach((slider) => {
619    const slides = [...slider.querySelectorAll('[data-slide]')];
620    const dots = [...slider.querySelectorAll('[data-slider-dot]')];
621    const thumbs = [...slider.querySelectorAll('[data-slider-thumb]')];
622    const sideItems = [...slider.querySelectorAll('[data-slider-side]')];
623    const previousButton = slider.querySelector('[data-slider-prev]');
624    const nextButton = slider.querySelector('[data-slider-next]');
625
626    if (slides.length === 0) {
627        return;
628    }
629
630    let activeIndex = Math.max(0, slides.findIndex((slide) => slide.classList.contains('is-active')));
631    let timerId;
632
633    const showSlide = (nextIndex) => {
634        activeIndex = (nextIndex + slides.length) % slides.length;
635
636        slides.forEach((slide, index) => {
637            const isActive = index === activeIndex;
638            slide.classList.toggle('is-active', isActive);
639            slide.setAttribute('aria-hidden', String(!isActive));
640        });
641
642        dots.forEach((dot, index) => {
643            dot.classList.toggle('is-active', index === activeIndex);
644            dot.setAttribute('aria-selected', String(index === activeIndex));
645        });
646
647        thumbs.forEach((thumb, index) => {
648            const isActive = index === activeIndex;
649            thumb.classList.toggle('is-active', isActive);
650            thumb.setAttribute('aria-pressed', String(isActive));
651        });
652
653        sideItems.forEach((item, index) => {
654            const isActive = index === activeIndex;
655            item.classList.toggle('is-active', isActive);
656            item.setAttribute('aria-pressed', String(isActive));
657        });
658    };
659
660    const startSlider = () => {
661        window.clearInterval(timerId);
662
663        if (slides.length > 1) {
664            timerId = window.setInterval(() => showSlide(activeIndex + 1), 6500);
665        }
666    };
667
668    previousButton?.addEventListener('click', () => {
669        showSlide(activeIndex - 1);
670        startSlider();
671    });
672
673    nextButton?.addEventListener('click', () => {
674        showSlide(activeIndex + 1);
675        startSlider();
676    });
677
678    dots.forEach((dot, index) => {
679        dot.addEventListener('click', () => {
680            showSlide(index);
681            startSlider();
682        });
683    });
684
685    thumbs.forEach((thumb, index) => {
686        thumb.addEventListener('click', () => {
687            showSlide(index);
688            startSlider();
689        });
690    });
691
692    sideItems.forEach((item, index) => {
693        item.addEventListener('click', () => {
694            showSlide(index);
695            startSlider();
696        });
697    });
698
699    slider.addEventListener('mouseenter', () => window.clearInterval(timerId));
700    slider.addEventListener('mouseleave', startSlider);
701    slider.addEventListener('focusin', () => window.clearInterval(timerId));
702    slider.addEventListener('focusout', startSlider);
703
704    showSlide(activeIndex);
705    startSlider();
706});
707
708document.querySelectorAll('[data-newsstage]').forEach((newsstage) => {
709    const storyList = newsstage.querySelector('.tma-news-timeline');
710    const featureLink = newsstage.querySelector('[data-newsstage-feature-link]');
711    const featureMedia = newsstage.querySelector('[data-newsstage-feature-media]');
712    const featureImage = newsstage.querySelector('[data-newsstage-feature-image]');
713    const featureMeta = newsstage.querySelector('.tma-news-featured__meta');
714    const featureDate = newsstage.querySelector('[data-newsstage-feature-date]');
715    const featureTitle = newsstage.querySelector('[data-newsstage-feature-title]');
716    const featureExcerpt = newsstage.querySelector('[data-newsstage-feature-excerpt]');
717    const items = [...newsstage.querySelectorAll('[data-newsstage-item]')];
718
719    if (!storyList || !featureLink || !featureMedia || !featureImage || !featureDate || !featureTitle || !featureExcerpt || items.length === 0) {
720        return;
721    }
722
723    const INTERVAL = 6200;
724    newsstage.style.setProperty('--v16-newsstage-interval', `${INTERVAL}ms`);
725
726    const storyItems = items.filter((i) => i.closest('.tma-news-timeline'));
727
728    const syncFixedHeight = () => {
729        if (window.innerWidth <= 1220) {
730            newsstage.style.removeProperty('--v16-newsstage-fixed-height');
731            return;
732        }
733        newsstage.style.setProperty('--v16-newsstage-fixed-height', `${storyList.offsetHeight}px`);
734    };
735
736    const restartBar = (activeItem) => {
737        const bar = activeItem.querySelector('.tma-news-timeline__progress');
738        if (!bar) return;
739        bar.classList.remove('is-running');
740        void bar.offsetWidth;
741        bar.classList.add('is-running');
742    };
743
744    const crossfadeImage = (src, alt) => {
745        featureImage.style.transition = 'opacity 220ms ease';
746        featureImage.style.opacity = '0';
747        const onLoad = () => {
748            featureImage.style.opacity = '1';
749        };
750        featureImage.setAttribute('alt', alt);
751        featureImage.setAttribute('src', src);
752        if (featureImage.complete) {
753            onLoad();
754        } else {
755            featureImage.addEventListener('load', onLoad, { once: true });
756        }
757    };
758
759    const syncFeature = (item) => {
760        const url = item.getAttribute('data-url') || '#';
761        const title = item.getAttribute('data-title') || '';
762        const date = item.getAttribute('data-date') || '';
763        const datetime = item.getAttribute('data-datetime') || '';
764        const excerpt = item.getAttribute('data-excerpt') || '';
765        const image = item.getAttribute('data-image') || '';
766        const imageAlt = item.getAttribute('data-image-alt') || '';
767        const isFallback = item.getAttribute('data-fallback') === 'true';
768
769        featureLink.setAttribute('href', url);
770        featureLink.setAttribute('aria-label', title);
771        featureTitle.textContent = title;
772
773        if (date !== '') {
774            featureDate.textContent = date;
775            featureDate.setAttribute('datetime', datetime);
776            featureDate.hidden = false;
777            if (featureMeta) featureMeta.hidden = false;
778        } else {
779            featureDate.textContent = '';
780            featureDate.setAttribute('datetime', '');
781            featureDate.hidden = true;
782            if (featureMeta) featureMeta.hidden = true;
783        }
784
785        if (excerpt !== '') {
786            featureExcerpt.textContent = excerpt;
787            featureExcerpt.hidden = false;
788        } else {
789            featureExcerpt.textContent = '';
790            featureExcerpt.hidden = true;
791        }
792
793        crossfadeImage(image, imageAlt);
794        featureMedia.classList.toggle('is-fallback', isFallback);
795
796        items.forEach((button) => {
797            const isActive = (button.getAttribute('data-url') || '#') === url;
798            button.classList.toggle('is-active', isActive);
799            button.setAttribute('aria-pressed', String(isActive));
800            if (isActive) restartBar(button);
801        });
802
803        window.requestAnimationFrame(syncFixedHeight);
804    };
805
806    // Auto-advance
807    let autoTimer = null;
808
809    const stopAuto = () => {
810        if (autoTimer !== null) {
811            window.clearInterval(autoTimer);
812            autoTimer = null;
813        }
814    };
815
816    const advance = () => {
817        const active = storyItems.find((i) => i.classList.contains('is-active'));
818        const currentIdx = active ? storyItems.indexOf(active) : 0;
819        syncFeature(storyItems[(currentIdx + 1) % storyItems.length]);
820    };
821
822    const startAuto = () => {
823        stopAuto();
824        autoTimer = window.setInterval(advance, INTERVAL);
825    };
826
827    // Click handlers
828    items.forEach((item) => {
829        item.addEventListener('click', () => {
830            stopAuto();
831            syncFeature(item);
832            startAuto();
833        });
834    });
835
836    // Pause on hover / focus
837    newsstage.addEventListener('mouseenter', stopAuto);
838    newsstage.addEventListener('mouseleave', startAuto);
839    newsstage.addEventListener('focusin', stopAuto);
840    newsstage.addEventListener('focusout', startAuto);
841
842    // Touch / swipe on feature card
843    let touchStartX = 0;
844    let touchStartY = 0;
845    featureLink.addEventListener('touchstart', (e) => {
846        touchStartX = e.touches[0].clientX;
847        touchStartY = e.touches[0].clientY;
848    }, { passive: true });
849    featureLink.addEventListener('touchend', (e) => {
850        const dx = e.changedTouches[0].clientX - touchStartX;
851        const dy = e.changedTouches[0].clientY - touchStartY;
852        if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 44) {
853            const active = storyItems.find((i) => i.classList.contains('is-active'));
854            const currentIdx = active ? storyItems.indexOf(active) : 0;
855            const nextIdx = dx < 0
856                ? (currentIdx + 1) % storyItems.length
857                : (currentIdx - 1 + storyItems.length) % storyItems.length;
858            stopAuto();
859            syncFeature(storyItems[nextIdx]);
860            startAuto();
861        }
862    });
863
864    // Keyboard navigation on story list (arrow up/down)
865    storyList.addEventListener('keydown', (e) => {
866        if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
867        const focused = document.activeElement;
868        const idx = storyItems.indexOf(focused);
869        if (idx === -1) return;
870        e.preventDefault();
871        const nextIdx = e.key === 'ArrowDown'
872            ? (idx + 1) % storyItems.length
873            : (idx - 1 + storyItems.length) % storyItems.length;
874        storyItems[nextIdx].focus();
875        stopAuto();
876        syncFeature(storyItems[nextIdx]);
877        startAuto();
878    });
879
880    if ('ResizeObserver' in window) {
881        const resizeObserver = new ResizeObserver(() => syncFixedHeight());
882        resizeObserver.observe(storyList);
883        resizeObserver.observe(newsstage);
884    } else {
885        window.addEventListener('resize', syncFixedHeight);
886    }
887
888    if (featureImage.complete) {
889        syncFixedHeight();
890    } else {
891        featureImage.addEventListener('load', syncFixedHeight, { once: true });
892    }
893
894    // Initialise
895    syncFixedHeight();
896    const firstStory = storyItems[0];
897    if (firstStory) restartBar(firstStory);
898    startAuto();
899});
900
901
902const initAcademicDirectory = () => {
903    const academicDirectory = document.querySelector('[data-academic-directory]');
904    if (!academicDirectory) return;
905
906    const searchInput = academicDirectory.querySelector('[data-academic-search]');
907    const rankButtons = [...academicDirectory.querySelectorAll('[data-rank-filter]')];
908    const dirButtons = [...academicDirectory.querySelectorAll('[data-dir-filter]')];
909    const cards = [...academicDirectory.querySelectorAll('[data-academic-card]')];
910    const groups = [...academicDirectory.querySelectorAll('[data-academic-group]')];
911    const visibleCountEl = academicDirectory.querySelector('[data-academic-visible-count]');
912    const emptyState = academicDirectory.querySelector('[data-academic-empty]');
913    const modal = academicDirectory.querySelector('[data-academic-modal]');
914    const modalImage = academicDirectory.querySelector('[data-academic-modal-image]');
915    const modalName = academicDirectory.querySelector('[data-academic-modal-name]');
916    const modalRole = academicDirectory.querySelector('[data-academic-modal-role]');
917    const modalEmail = academicDirectory.querySelector('[data-academic-modal-email]');
918    const modalBio = academicDirectory.querySelector('[data-academic-modal-bio]');
919    let activeRank = 'all';
920    let activeDir = 'all';
921    const noRankToggle = academicDirectory.hasAttribute('data-rank-filter-no-toggle');
922    const deptDropdown = academicDirectory.querySelector('[data-admin-dept-dropdown]');
923    const deptDropdownToggle = deptDropdown?.querySelector('[data-admin-dept-dropdown-toggle]');
924    const deptDropdownMenu = deptDropdown?.querySelector('[data-admin-dept-dropdown-menu]');
925    const deptCurrentLabel = deptDropdown?.querySelector('[data-admin-dept-current]');
926    const deptDefaultLabel = deptDropdown?.dataset.adminDeptDefault ?? '';
927
928    const setDeptDropdownOpen = (open) => {
929        if (!deptDropdown || !deptDropdownToggle || !deptDropdownMenu) return;
930        deptDropdown.classList.toggle('is-open', open);
931        deptDropdownToggle.setAttribute('aria-expanded', String(open));
932        deptDropdownMenu.hidden = !open;
933    };
934
935    const updateDeptDropdownLabel = (rankBtn) => {
936        if (!deptCurrentLabel || !rankBtn) return;
937        const val = rankBtn.dataset.rankFilter ?? 'all';
938        if (val === 'all') {
939            deptCurrentLabel.textContent = deptDefaultLabel;
940            return;
941        }
942        deptCurrentLabel.textContent = rankBtn.dataset.deptLabel ?? deptDefaultLabel;
943    };
944
945    const normalize = (value) => value.toLowerCase().trim();
946
947    const applyFilters = () => {
948        const query = normalize(searchInput?.value ?? '');
949        let visibleCount = 0;
950
951        cards.forEach((card) => {
952            const rank = card.dataset.rank ?? '';
953            const direction = card.dataset.direction ?? '';
954            const haystack = normalize(card.dataset.search ?? '');
955            const matchesRank = activeRank === 'all' || rank === activeRank;
956            const matchesDir = activeDir === 'all' || direction === activeDir;
957            const matchesQuery = query === '' || haystack.includes(query);
958            const isVisible = matchesRank && matchesDir && matchesQuery;
959            card.classList.toggle('is-hidden', !isVisible);
960            if (isVisible) visibleCount += 1;
961        });
962
963        groups.forEach((group) => {
964            const visibleInGroup = group.querySelectorAll('[data-academic-card]:not(.is-hidden)').length;
965            const countBadge = group.querySelector('[data-academic-group-count]');
966            if (countBadge) countBadge.textContent = String(visibleInGroup);
967            group.classList.toggle('is-hidden', visibleInGroup === 0);
968        });
969
970        if (visibleCountEl) visibleCountEl.textContent = String(visibleCount);
971        if (emptyState) emptyState.hidden = visibleCount !== 0;
972    };
973
974    // Rank filter button click handling via event delegation
975    academicDirectory.addEventListener('click', (event) => {
976        const rankBtn = event.target.closest('[data-rank-filter]');
977        if (rankBtn) {
978            event.preventDefault();
979            event.stopPropagation();
980            const val = rankBtn.dataset.rankFilter ?? 'all';
981            activeRank = noRankToggle ? val : (activeRank === val ? 'all' : val);
982            rankButtons.forEach((item) => {
983                const isActive = item.dataset.rankFilter === activeRank;
984                item.classList.toggle('is-active', isActive);
985                item.setAttribute('aria-selected', String(isActive));
986            });
987            if (deptDropdown && rankBtn.closest('[data-admin-dept-dropdown]')) {
988                updateDeptDropdownLabel(rankBtn);
989                setDeptDropdownOpen(false);
990            }
991            applyFilters();
992            return;
993        }
994
995        const dirBtn = event.target.closest('[data-dir-filter]');
996        if (dirBtn) {
997            event.preventDefault();
998            event.stopPropagation();
999            const val = dirBtn.dataset.dirFilter ?? 'all';
1000            activeDir = val;
1001            dirButtons.forEach((item) => {
1002                const isActive = item.dataset.dirFilter === activeDir;
1003                item.classList.toggle('is-active', isActive);
1004                item.setAttribute('aria-selected', String(isActive));
1005            });
1006            applyFilters();
1007        }
1008    });
1009
1010    searchInput?.addEventListener('input', applyFilters);
1011
1012    deptDropdownToggle?.addEventListener('click', (event) => {
1013        event.preventDefault();
1014        event.stopPropagation();
1015        const isOpen = deptDropdown?.classList.contains('is-open');
1016        setDeptDropdownOpen(!isOpen);
1017    });
1018
1019    document.addEventListener('click', (event) => {
1020        if (!deptDropdown?.classList.contains('is-open')) return;
1021        if (event.target.closest('[data-admin-dept-dropdown]')) return;
1022        setDeptDropdownOpen(false);
1023    });
1024
1025    document.addEventListener('keydown', (event) => {
1026        if (event.key === 'Escape') setDeptDropdownOpen(false);
1027    });
1028
1029    const openModal = (member) => {
1030        if (!modal || !member) return;
1031        if (modalImage) modalImage.src = member.image || '';
1032        if (modalName) modalName.textContent = member.name || '';
1033        if (modalRole) modalRole.textContent = member.role || '';
1034        if (modalEmail) {
1035            if (member.email) {
1036                modalEmail.href = `mailto:${member.email}`;
1037                modalEmail.textContent = member.email;
1038                modalEmail.hidden = false;
1039            } else {
1040                modalEmail.hidden = true;
1041            }
1042        }
1043        if (modalBio && member.bio_html) {
1044            modalBio.innerHTML = member.bio_html;
1045        } else if (modalBio && member.bio) {
1046            modalBio.textContent = member.bio;
1047        }
1048        if (typeof modal.showModal === 'function') modal.showModal();
1049    };
1050
1051    const closeModal = () => {
1052        if (modal && typeof modal.close === 'function') modal.close();
1053    };
1054
1055    academicDirectory.querySelectorAll('[data-academic-open]').forEach((button) => {
1056        button.addEventListener('click', () => {
1057            try {
1058                const memberJson = button.getAttribute('data-member');
1059                if (memberJson) {
1060                    const member = JSON.parse(memberJson);
1061                    openModal(member);
1062                }
1063            } catch (error) {
1064                console.error('[Academic Directory] Error parsing member data:', error);
1065            }
1066        });
1067    });
1068
1069    academicDirectory.querySelector('[data-academic-close]')?.addEventListener('click', closeModal);
1070
1071    modal?.addEventListener('cancel', (event) => {
1072        event.preventDefault();
1073        closeModal();
1074    });
1075
1076    const photoLightbox = academicDirectory.querySelector('[data-admin-photo-lightbox]');
1077    const photoImage = academicDirectory.querySelector('[data-admin-photo-image]');
1078    const photoName = academicDirectory.querySelector('[data-admin-photo-name]');
1079    const photoRole = academicDirectory.querySelector('[data-admin-photo-role]');
1080    const modalUnit = academicDirectory.querySelector('[data-admin-modal-unit]');
1081    const modalEmailRow = academicDirectory.querySelector('[data-admin-modal-email-row]');
1082    const modalPhoneRow = academicDirectory.querySelector('[data-admin-modal-phone-row]');
1083    const modalEmailLink = academicDirectory.querySelector('[data-admin-modal-email]');
1084    const modalPhoneLink = academicDirectory.querySelector('[data-admin-modal-phone]');
1085    const modalMailBtn = academicDirectory.querySelector('[data-admin-modal-mail-btn]');
1086    const modalPhoneBtn = academicDirectory.querySelector('[data-admin-modal-phone-btn]');
1087    const modalMailLabel = academicDirectory.querySelector('[data-admin-modal-mail-label]');
1088    const modalPhoneLabel = academicDirectory.querySelector('[data-admin-modal-phone-label]');
1089
1090    const setLinkField = (row, link, href, text) => {
1091        if (!row || !link) return;
1092        const value = (text ?? '').trim();
1093        if (value === '') {
1094            row.hidden = true;
1095            link.removeAttribute('href');
1096            link.textContent = '';
1097            return;
1098        }
1099        row.hidden = false;
1100        link.href = href;
1101        link.textContent = value;
1102    };
1103
1104    const setActionButton = (button, labelEl, href, label) => {
1105        if (!button || !labelEl) return;
1106        const value = (label ?? '').trim();
1107        if (value === '') {
1108            button.hidden = true;
1109            button.removeAttribute('href');
1110            labelEl.textContent = '';
1111            return;
1112        }
1113        button.hidden = false;
1114        button.href = href;
1115        labelEl.textContent = value;
1116        labelEl.title = value;
1117    };
1118
1119    const openMemberModal = (member) => {
1120        if (!photoLightbox || !photoImage || !member) return;
1121
1122        const name = member.name ?? '';
1123        const role = member.role ?? '';
1124        const email = member.email ?? '';
1125        const mobile = member.mobile ?? '';
1126        const image = member.image ?? '';
1127        const unit = member.unit ?? '';
1128
1129        if (photoName) photoName.textContent = name;
1130        if (photoRole) photoRole.textContent = role;
1131        if (modalUnit) modalUnit.textContent = unit;
1132
1133        setLinkField(modalEmailRow, modalEmailLink, `mailto:${email}`, email);
1134        setLinkField(modalPhoneRow, modalPhoneLink, `tel:${mobile.replace(/\s+/g, '')}`, mobile);
1135
1136        setActionButton(modalMailBtn, modalMailLabel, `mailto:${email}`, email);
1137        setActionButton(modalPhoneBtn, modalPhoneLabel, `tel:${mobile.replace(/\s+/g, '')}`, mobile);
1138
1139        if (image === '') {
1140            photoImage.removeAttribute('src');
1141            photoImage.alt = name;
1142            photoImage.hidden = true;
1143        } else {
1144            photoImage.hidden = false;
1145            photoImage.src = image;
1146            photoImage.alt = name;
1147        }
1148
1149        if (typeof photoLightbox.showModal === 'function') {
1150            photoLightbox.showModal();
1151        }
1152        document.body.classList.add('v16-admin-photo-open');
1153    };
1154
1155    const closePhotoLightbox = () => {
1156        if (photoLightbox && typeof photoLightbox.close === 'function') {
1157            photoLightbox.close();
1158        }
1159        document.body.classList.remove('v16-admin-photo-open');
1160    };
1161
1162    academicDirectory.querySelectorAll('[data-admin-member-open]').forEach((button) => {
1163        button.addEventListener('click', () => {
1164            try {
1165                const memberJson = button.getAttribute('data-member');
1166                if (!memberJson) return;
1167                openMemberModal(JSON.parse(memberJson));
1168            } catch (error) {
1169                console.error('[Admin Directory] Error parsing member data:', error);
1170            }
1171        });
1172    });
1173
1174    photoLightbox?.querySelector('[data-admin-photo-close]')?.addEventListener('click', closePhotoLightbox);
1175
1176    photoLightbox?.addEventListener('click', (event) => {
1177        if (event.target === photoLightbox) {
1178            closePhotoLightbox();
1179        }
1180    });
1181
1182    photoLightbox?.addEventListener('cancel', (event) => {
1183        event.preventDefault();
1184        closePhotoLightbox();
1185    });
1186
1187    const applyStaffDeepLink = () => {
1188        const params = new URLSearchParams(window.location.search);
1189        const staffId = params.get('staff');
1190        const query = params.get('q');
1191
1192        if (query && searchInput) {
1193            searchInput.value = query;
1194            applyFilters();
1195        }
1196
1197        if (!staffId) {
1198            return;
1199        }
1200
1201        const openButtons = academicDirectory.querySelectorAll('[data-academic-open], [data-admin-member-open]');
1202        for (const button of openButtons) {
1203            try {
1204                const member = JSON.parse(button.getAttribute('data-member') || 'null');
1205                if (!member || String(member.id) !== staffId) {
1206                    continue;
1207                }
1208
1209                const card = button.closest('[data-academic-card]');
1210                card?.scrollIntoView({ behavior: 'smooth', block: 'center' });
1211                button.click();
1212                break;
1213            } catch (error) {
1214                // Ignore malformed member payloads.
1215            }
1216        }
1217    };
1218
1219    applyFilters();
1220    applyStaffDeepLink();
1221};
1222
1223// Run initialization when DOM is ready
1224if (document.readyState === 'loading') {
1225    document.addEventListener('DOMContentLoaded', initAcademicDirectory);
1226} else {
1227    // DOM is already loaded (script is deferred), so init immediately
1228    initAcademicDirectory();
1229}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.