/** * Интерактивность темы TCP UDP Ports. * * Никакого фреймворка: весь HTML приходит с сервера, скрипт только оживляет * уже существующую разметку. Если JS не выполнится, сайт остаётся рабочим — * поиск отправляется обычным POST, FAQ доступен как обычный текст. */ (function () { 'use strict'; var TUPCfg = window.TUP || {}; /* --------------------------------------------------------------------- * Шапка: класс при прокрутке * ------------------------------------------------------------------ */ var header = document.querySelector('[data-header]'); if (header) { var onScroll = function () { header.classList.toggle('is-scrolled', window.scrollY > 20); }; onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); } /* --------------------------------------------------------------------- * Мобильное меню * ------------------------------------------------------------------ */ var toggle = document.querySelector('[data-menu-toggle]'); var mobileNav = document.querySelector('[data-mobile-nav]'); function closeMenu() { if (!toggle || !mobileNav) return; toggle.setAttribute('aria-expanded', 'false'); toggle.setAttribute('aria-label', 'Open menu'); mobileNav.classList.remove('is-open'); document.body.classList.remove('menu-open'); } function openMenu() { if (!toggle || !mobileNav) return; toggle.setAttribute('aria-expanded', 'true'); toggle.setAttribute('aria-label', 'Close menu'); mobileNav.classList.add('is-open'); // Прокрутка страницы под открытым меню блокируется. document.body.classList.add('menu-open'); } if (toggle && mobileNav) { toggle.addEventListener('click', function () { var open = toggle.getAttribute('aria-expanded') === 'true'; if (open) closeMenu(); else openMenu(); }); // Escape закрывает меню. document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && toggle.getAttribute('aria-expanded') === 'true') { closeMenu(); toggle.focus(); } }); // Клик вне панели закрывает меню. document.addEventListener('click', function (e) { if (toggle.getAttribute('aria-expanded') !== 'true') return; if (mobileNav.contains(e.target) || toggle.contains(e.target)) return; closeMenu(); }); // Переход по ссылке внутри меню закрывает его. mobileNav.addEventListener('click', function (e) { if (e.target.closest('a')) closeMenu(); }); // Поворот устройства или переход на десктопную ширину. window.addEventListener('resize', function () { if (window.innerWidth >= 1024) closeMenu(); }); } /* --------------------------------------------------------------------- * FAQ — аккордеон * ------------------------------------------------------------------ */ // .faq-q — внутренние страницы, .faq-r__q — главная (разметка из макета). document.querySelectorAll('.faq-q, .faq-r__q').forEach(function (btn) { btn.addEventListener('click', function () { var expanded = btn.getAttribute('aria-expanded') === 'true'; var panel = document.getElementById(btn.getAttribute('aria-controls')); btn.setAttribute('aria-expanded', expanded ? 'false' : 'true'); if (panel) panel.hidden = expanded; }); }); /* --------------------------------------------------------------------- * Кнопки копирования * ------------------------------------------------------------------ */ document.querySelectorAll('[data-copy], [data-copy-text]').forEach(function (btn) { btn.addEventListener('click', function () { // Кнопка либо копирует соседний блок кода, либо явно заданное значение. var text = btn.getAttribute('data-copy-text'); if (text === null) { var block = btn.closest('.code-block'); var code = block && block.querySelector('code'); if (!code) return; text = code.textContent; } var label = btn.querySelector('.copy-label'); var done = function () { btn.classList.add('is-copied'); if (label) label.textContent = TUPCfg.i18n ? TUPCfg.i18n.copied : 'Copied'; setTimeout(function () { btn.classList.remove('is-copied'); if (label) label.textContent = TUPCfg.i18n ? TUPCfg.i18n.copy : 'Copy'; }, 1800); }; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(done).catch(fallbackCopy); } else { fallbackCopy(); } function fallbackCopy() { // Локальная разработка идёт по http, где Clipboard API недоступен. var ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); done(); } catch (err) { /* копирование недоступно — молча ничего не делаем */ } document.body.removeChild(ta); } }); }); /* --------------------------------------------------------------------- * Кнопка «наверх» * ------------------------------------------------------------------ */ var toTop = document.querySelector('[data-to-top]'); if (toTop) { var toggleTop = function () { toTop.classList.toggle('is-visible', window.scrollY > 600); }; toggleTop(); window.addEventListener('scroll', toggleTop, { passive: true }); toTop.addEventListener('click', function () { var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; window.scrollTo({ top: 0, behavior: reduce ? 'auto' : 'smooth' }); // Фокус возвращается в начало страницы, а не теряется. var main = document.getElementById('main'); if (main) { main.setAttribute('tabindex', '-1'); main.focus({ preventScroll: true }); } }); } /* --------------------------------------------------------------------- * Поиск с подсказками * ------------------------------------------------------------------ */ function escapeHtml(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function highlight(text, query) { var safe = escapeHtml(text); if (!query) return safe; var i = safe.toLowerCase().indexOf(query.toLowerCase()); if (i === -1) return safe; return ( safe.slice(0, i) + '' + safe.slice(i, i + query.length) + '' + safe.slice(i + query.length) ); } document.querySelectorAll('[data-search-form]').forEach(function (form, formIndex) { var input = form.querySelector('[data-search-input]'); var box = form.querySelector('[data-search-suggest]'); if (!input || !box || !TUPCfg.restUrl) return; var timer = null; var activeIndex = -1; var items = []; var lastQuery = ''; var idBase = 'tup-opt-' + formIndex + '-'; function close() { box.classList.remove('is-open'); box.innerHTML = ''; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); activeIndex = -1; items = []; } /** * Подгоняет список под свободное место. * * Снизу места хватает — открывается вниз. Если снизу тесно, а сверху * просторнее, список разворачивается вверх: иначе он уходит за нижний * край экрана, и последние результаты недоступны. */ function fitToViewport() { var rect = input.getBoundingClientRect(); var gap = 8; var below = window.innerHeight - rect.bottom - gap - 8; var above = rect.top - gap - 8; var flip = below < 200 && above > below; var room = Math.max(120, Math.min(360, flip ? above : below)); box.style.maxHeight = room + 'px'; box.classList.toggle('is-above', flip); } function setActive(i) { items.forEach(function (li) { li.classList.remove('is-active'); li.setAttribute('aria-selected', 'false'); }); activeIndex = i; if (i < 0) { input.removeAttribute('aria-activedescendant'); return; } var li = items[i]; li.classList.add('is-active'); li.setAttribute('aria-selected', 'true'); input.setAttribute('aria-activedescendant', li.id); li.scrollIntoView({ block: 'nearest' }); } function open() { box.classList.add('is-open'); input.setAttribute('aria-expanded', 'true'); fitToViewport(); } function render(results, query, corrected) { if (!results.length) { var msg = corrected ? 'No matches. Did you mean ' + escapeHtml(corrected) + '?' : escapeHtml(TUPCfg.i18n ? TUPCfg.i18n.noResults : 'No matches found'); box.innerHTML = '
' + msg + '
'; items = []; activeIndex = -1; open(); return; } var html = ''; box.innerHTML = html; items = Array.prototype.slice.call(box.querySelectorAll('li[role="option"]')); activeIndex = -1; open(); } function fetchSuggestions(q) { fetch(TUPCfg.restUrl + '?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (data) { if (!data || input.value.trim() !== q) return; render(data.results || [], q, data.corrected || ''); }) .catch(function () { /* сеть недоступна — форма всё равно отправится */ }); } input.addEventListener('input', function () { var q = input.value.trim(); if (timer) clearTimeout(timer); if (q.length < 1) { close(); return; } if (q === lastQuery && box.classList.contains('is-open')) return; lastQuery = q; timer = setTimeout(function () { fetchSuggestions(q); }, 160); }); input.addEventListener('keydown', function (e) { if (e.key === 'Escape') { close(); return; } if (!items.length) return; if (e.key === 'ArrowDown') { e.preventDefault(); setActive((activeIndex + 1) % items.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((activeIndex - 1 + items.length) % items.length); } else if (e.key === 'Home') { e.preventDefault(); setActive(0); } else if (e.key === 'End') { e.preventDefault(); setActive(items.length - 1); } else if (e.key === 'Enter' && activeIndex > -1) { // Enter без выбора отправляет форму обычным путём. e.preventDefault(); var link = items[activeIndex].querySelector('a'); if (link) window.location.href = link.href; } else if (e.key === 'Tab') { close(); } }); // Наведение мышью синхронизируется с клавиатурным выбором. box.addEventListener('mousemove', function (e) { var li = e.target.closest('li[role="option"]'); if (li) setActive(items.indexOf(li)); }); // Список закрывается после перехода — иначе он остаётся висеть при // возврате назад из кеша браузера. box.addEventListener('click', function (e) { if (e.target.closest('a')) close(); }); document.addEventListener('click', function (e) { if (!form.contains(e.target)) close(); }); window.addEventListener('resize', function () { if (box.classList.contains('is-open')) fitToViewport(); }); }); /* --------------------------------------------------------------------- * Таблица популярных портов на главной: фильтр по протоколу и быстрый поиск. * * Фильтрация только среди уже выведенных сервером строк — адрес страницы не * меняется, GET-параметры не появляются. Полный список живёт на /ports/. * ------------------------------------------------------------------ */ var portTable = document.querySelector('[data-port-table]'); if (portTable) { var filterBtns = document.querySelectorAll('[data-port-filter]'); var quickInput = document.querySelector('[data-port-quick]'); var emptyMsg = portTable.querySelector('[data-port-empty]'); var countEl = document.querySelector('[data-port-count]'); var items = portTable.querySelectorAll('[data-proto]'); var activeProto = 'all'; function applyFilters() { var q = quickInput ? quickInput.value.trim().toLowerCase() : ''; var visibleRows = 0; items.forEach(function (el) { var proto = el.getAttribute('data-proto'); var blob = el.getAttribute('data-search') || ''; // Порт, зарегистрированный и на TCP, и на UDP, обязан попадать в оба // фильтра: он действительно является и TCP-портом, и UDP-портом. // Раньше сравнение шло строго по равенству, а у всех популярных портов // значение «tcpudp» — поэтому кнопки TCP и UDP не находили ничего. var okProto = activeProto === 'all' || proto === activeProto || (proto === 'tcpudp' && (activeProto === 'tcp' || activeProto === 'udp')); var okQuery = !q || blob.indexOf(q) !== -1; var show = okProto && okQuery; el.style.display = show ? '' : 'none'; // Каждый порт присутствует дважды (строка и карточка) — считаем один раз. if (show && el.classList.contains('ports-table__row')) visibleRows++; }); // На узких экранах видимы карточки, а не строки: считаем по ним. if (window.matchMedia('(max-width: 1023px)').matches) { visibleRows = 0; items.forEach(function (el) { if (el.style.display !== 'none' && el.classList.contains('ports-table__card')) visibleRows++; }); } if (emptyMsg) emptyMsg.hidden = visibleRows > 0; if (countEl) countEl.textContent = 'Showing ' + visibleRows; } filterBtns.forEach(function (btn) { btn.addEventListener('click', function () { activeProto = btn.getAttribute('data-port-filter'); filterBtns.forEach(function (b) { b.setAttribute('aria-pressed', b === btn ? 'true' : 'false'); }); applyFilters(); }); }); if (quickInput) { quickInput.addEventListener('input', applyFilters); // Enter в быстром поиске уводит на полноценную выдачу. quickInput.addEventListener('keydown', function (e) { if (e.key !== 'Enter') return; e.preventDefault(); var q = quickInput.value.trim(); // Статическая версия: переход на выдачу отключён, фильтр работает на месте. if (!TUPCfg.searchUrl) return; if (q) window.location.href = TUPCfg.searchUrl + encodeURIComponent(q) + '/'; }); } } })();