PageSourceSearch

https://predanie.ru/js/script.js?v=v5.3.139

js predanie.ru collected 2026-09-24 08:54:07 UTC 81,667 bytes, 2,096 lines download raw bytes

1/**
2 * ============================================================================
3 * SCRIPT.JS — Общий скрипт страницы Predanie.ru
4 * ============================================================================
5 *
6 * Этот файл подключается на всех страницах проекта.
7 * Содержит общие UI-улучшения и поведенческие скрипты:
8 *
9 * ФУНКЦИОНАЛ:
10 * 1. Прелоадер — плавное исчезновение при загрузке
11 * 2. Мобильное меню — открытие/закрытие сайдбара
12 * 3. Раскрывающиеся группы меню в сайдбаре
13 * 4. Переключение темы (light/dark) + сохранение в localStorage
14 * 5. Bootstrap-подобная валидация форм
15 * 6. Сворачивание блоков правого сайдбара
16 *
17 * ПРИНЦИПЫ:
18 * - Скрипт не должен падать на страницах, где части разметки отсутствуют
19 * - Везде есть проверки наличия элементов
20 * - Настройки пользователя сохраняются в localStorage
21 *
22 * ЗАВИСИМОСТИ:
23 * - Bootstrap 5 (bootstrap.bundle.min.js) — опционально
24 */
25
26  // ------------------------------
27  // 3.x) Сворачивание/разворачивание блоков правого сайдбара
28  // ВРЕМЕННО ОТКЛЮЧЕНО
29  // ------------------------------
30  /*
31  function initSidebarBannerCollapse() {
32    const storageKey = 'sidebarBannerStates';
33    const readStoredState = () => {
34      try {
35        const raw = localStorage.getItem(storageKey);
36        if (!raw) return {};
37        const parsed = JSON.parse(raw);
38        if (!parsed || typeof parsed !== 'object') return {};
39        return parsed;
40      } catch {
41        return {};
42      }
43    };
44    const writeStoredState = state => {
45      try {
46        localStorage.setItem(storageKey, JSON.stringify(state));
47      } catch {}
48    };
49
50    const banners = document.querySelectorAll('.sidebar-banner[data-sidebar-banner-id]');
51    if (!banners.length) return;
52
53    const state = readStoredState();
54
55    banners.forEach(banner => {
56      const id = banner.getAttribute('data-sidebar-banner-id');
57      if (!id) return;
58      const collapseBtn = banner.querySelector('.sidebar-banner__collapse');
59      const content = Array.from(banner.children).find(
60        el => el.classList && el.classList.contains('sidebar-banner__content')
61      );
62      // Применяем сохранённое состояние
63      const isCollapsed = state[id] === false ? false : state[id] === true;
64      if (isCollapsed) {
65        banner.classList.add('is-collapsed');
66        if (content) content.style.display = 'none';
67        if (collapseBtn) collapseBtn.querySelector('span').classList.remove('fa-chevron-up');
68        if (collapseBtn) collapseBtn.querySelector('span').classList.add('fa-chevron-down');
69      } else {
70        banner.classList.remove('is-collapsed');
71        if (content) content.style.display = '';
72        if (collapseBtn) collapseBtn.querySelector('span').classList.remove('fa-chevron-down');
73        if (collapseBtn) collapseBtn.querySelector('span').classList.add('fa-chevron-up');
74      }
75      if (collapseBtn) {
76        collapseBtn.addEventListener('click', () => {
77          const nowCollapsed = !banner.classList.contains('is-collapsed');
78          banner.classList.toggle('is-collapsed', nowCollapsed);
79          if (content) content.style.display = nowCollapsed ? 'none' : '';
80          if (collapseBtn.querySelector('span')) {
81            collapseBtn.querySelector('span').classList.toggle('fa-chevron-up', !nowCollapsed);
82            collapseBtn.querySelector('span').classList.toggle('fa-chevron-down', nowCollapsed);
83          }
84          state[id] = nowCollapsed;
85          writeStoredState(state);
86        });
87      }
88    });
89    // Отключаем preinit-режим (CSS перестаёт ориентироваться на атрибуты на <html>)
90    document.documentElement.classList.add('sidebar-banners-init');
91  }
92
93  initSidebarBannerCollapse();
94  */
95  // КОНЕЦ ВРЕМЕННО ОТКЛЮЧЁННОГО КОДА
96/**
97 * Этот файл — общий «скрипт страницы».
98 *
99 * Здесь собраны небольшие поведенческие улучшения, которые используются
100 * на многих страницах проекта:
101 * - прелоадер (если присутствует разметка)
102 * - мобильное открытие/закрытие сайдбара (существующий toggle)
103 * - раскрывающиеся группы меню в сайдбаре (Фонд/Медиатека)
104 * - переключение темы (light/dark) + сохранение в localStorage
105 * - Bootstrap-подобная валидация форм
106 *
107 * Важно:
108 * - Скрипт не должен падать на страницах, где части разметки отсутствуют.
109 *   Поэтому почти везде есть проверки наличия элементов.
110 * Источник дизайна исходной темы:
111 * WEBSITE: https://themefisher.com
112 * TWITTER: https://twitter.com/themefisher
113 * FACEBOOK: https://facebook.com/themefisher
114 * GITHUB: https://github.com/themefisher/
115 */
116
117'use strict';
118
119// ------------------------------
120// 1) Прелоадер (если есть)
121// ------------------------------
122// Инициализируется на событии window.load (когда загрузились ресурсы).
123// Контракт разметки:
124// - body получает класс "loaded"
125// - .preloader-wrapper (если существует) тоже получает класс "loaded"
126// Это позволяет CSS'ом скрыть прелоадер после полной загрузки.
127
128window.addEventListener('load', function () {
129  function preLoader() {
130    document.body.classList.add('loaded');
131    const preloaderWrapper = document.querySelector('.preloader-wrapper');
132    if (preloaderWrapper) {
133      preloaderWrapper.classList.add('loaded');
134    }
135  }
136  preLoader();
137});
138
139document.addEventListener('DOMContentLoaded', function () {
140  // ------------------------------
141  // 2) Мобильный toggle сайдбара
142  // ------------------------------
143  // Это существующая логика темы.
144  // Важно: в дальнейших правках мы её НЕ трогаем,
145  // чтобы мобильное поведение оставалось стабильным.
146  //
147  // Контракт разметки:
148  // - кнопка: .nav-toggle
149  // - сайдбар: .sidenav (в мобильной верстке прячется за экраном)
150  // - иконка внутри кнопки: .nav-toggle span (FontAwesome)
151  // - затемнение/оверлей: .nav-toggle-overlay
152
153  // Общие хелперы для «взаимоисключающих» сайдбаров:
154  // в моб/планшетной верстке хотим, чтобы одновременно был открыт
155  // максимум один сайдбар.
156  function initSidebarMutualExclusion() {
157    const sidenav = document.querySelector('.sidenav');
158    const navToggleSpan = document.querySelector('.nav-toggle span');
159    const navToggleOverlay = document.querySelector('.nav-toggle-overlay');
160
161    const rightSidebar = document.querySelector('.right-sidebar');
162    const rightNavToggleSpans = document.querySelectorAll('.right-nav-toggle span');
163    const rightNavToggleOverlay = document.querySelector(
164      '.right-nav-toggle-overlay',
165    );
166
167    const setLeftClosedIcon = () => {
168      if (!navToggleSpan) return;
169      navToggleSpan.classList.remove('fa-times');
170      navToggleSpan.classList.add('fa-bars');
171    };
172
173    const setRightClosedIcon = () => {
174      rightNavToggleSpans.forEach(span => {
175        span.classList.remove('fa-times');
176        span.classList.add('fa-indent');
177        span.classList.add('fa-flip-horizontal');
178      });
179    };
180
181    const closeLeftSidebar = () => {
182      if (sidenav) sidenav.classList.remove('show');
183      setLeftClosedIcon();
184      if (navToggleOverlay) navToggleOverlay.classList.remove('show');
185    };
186
187    const closeRightSidebar = () => {
188      const rightSidebarContainer = document.querySelector(
189        '.right-sidebar-container',
190      );
191      if (rightSidebar) rightSidebar.classList.remove('show');
192      if (rightSidebarContainer) rightSidebarContainer.classList.remove('show');
193      setRightClosedIcon();
194      if (rightNavToggleOverlay) rightNavToggleOverlay.classList.remove('show');
195      document.body.classList.remove('right-sidebar-open');
196    };
197
198    return {
199      closeLeftSidebar,
200      closeRightSidebar,
201      setLeftClosedIcon,
202      setRightClosedIcon,
203    };
204  }
205
206  const sidebarMutex = initSidebarMutualExclusion();
207
208  function navbarToggle() {
209    const navToggle = document.querySelector('.nav-toggle');
210    const sidenav = document.querySelector('.sidenav');
211    const navToggleSpan = document.querySelector('.nav-toggle span');
212    const navToggleOverlay = document.querySelector('.nav-toggle-overlay');
213
214    if (navToggle) {
215      navToggle.addEventListener('click', function () {
216        // Если открываем левый — закрываем правый.
217        const rightSidebarContainer = document.querySelector(
218          '.right-sidebar-container',
219        );
220        if (
221          rightSidebarContainer?.classList.contains('show') ||
222          document.querySelector('.right-sidebar')?.classList.contains('show')
223        ) {
224          sidebarMutex?.closeRightSidebar?.();
225        }
226        if (sidenav) {
227          sidenav.classList.toggle('show');
228        }
229        if (navToggleSpan) {
230          navToggleSpan.classList.toggle('fa-times');
231          navToggleSpan.classList.toggle('fa-bars');
232        }
233        if (navToggleOverlay) {
234          navToggleOverlay.classList.toggle('show');
235        }
236      });
237    }
238
239    if (navToggleOverlay) {
240      navToggleOverlay.addEventListener('click', function () {
241        if (sidenav) {
242          sidenav.classList.remove('show');
243        }
244        if (navToggleSpan) {
245          navToggleSpan.classList.remove('fa-times');
246          navToggleSpan.classList.add('fa-bars');
247        }
248        if (navToggleOverlay) {
249          navToggleOverlay.classList.remove('show');
250        }
251      });
252    }
253  }
254  navbarToggle();
255
256  // ------------------------------
257  // 2.0.1) Подсказка прокрутки сайдбара (мобильные)
258  // ------------------------------
259  function initSidebarScrollHint() {
260    const sidenav = document.querySelector('.sidenav');
261    const scrollHint = document.querySelector('.js-sidebar-scroll-hint');
262    if (!sidenav || !scrollHint) return;
263
264    // Скрываем подсказку после небольшого скролла
265    const handleScroll = () => {
266      if (sidenav.scrollTop > 50) {
267        scrollHint.classList.add('hidden');
268      } else {
269        scrollHint.classList.remove('hidden');
270      }
271    };
272
273    sidenav.addEventListener('scroll', handleScroll, { passive: true });
274
275    // Также скрываем, если сайдбар уже прокручен при загрузке
276    handleScroll();
277  }
278  initSidebarScrollHint();
279
280  // ------------------------------
281  // 2.1) Мобильный toggle ПРАВОГО сайдбара
282  // ------------------------------
283  // Контракт разметки:
284  // - кнопка: .right-nav-toggle
285  // - контейнер правого сайдбара: .right-sidebar-container (на мобильном fixed за экраном)
286  // - правый сайдбар: .right-sidebar (внутри контейнера)
287  // - иконка внутри кнопки: .right-nav-toggle span (FontAwesome)
288  // - затемнение/оверлей: .right-nav-toggle-overlay
289  function rightSidebarToggle() {
290    const rightNavToggles = document.querySelectorAll('.right-nav-toggle');
291    const rightSidebarContainer = document.querySelector(
292      '.right-sidebar-container',
293    );
294    const rightSidebar = document.querySelector('.right-sidebar');
295    const rightNavToggleSpans = document.querySelectorAll('.right-nav-toggle span');
296    const rightNavToggleOverlay = document.querySelector(
297      '.right-nav-toggle-overlay',
298    );
299
300    const setClosedIcon = () => {
301      rightNavToggleSpans.forEach(span => {
302        span.classList.remove('fa-times');
303        span.classList.add('fa-indent');
304        span.classList.add('fa-flip-horizontal');
305      });
306    };
307
308    const setOpenIcon = () => {
309      rightNavToggleSpans.forEach(span => {
310        span.classList.add('fa-times');
311        span.classList.remove('fa-indent');
312        span.classList.remove('fa-flip-horizontal');
313      });
314    };
315
316    rightNavToggles.forEach(btn => {
317      btn.addEventListener('click', function () {
318        // Если открываем правый — закрываем левый.
319        if (document.querySelector('.sidenav')?.classList.contains('show')) {
320          sidebarMutex?.closeLeftSidebar?.();
321        }
322        // Переключаем show на контейнере
323        const isNowOpen = rightSidebarContainer
324          ? rightSidebarContainer.classList.toggle('show')
325          : false;
326        // Также на самом сайдбаре для совместимости
327        if (rightSidebar) {
328          rightSidebar.classList.toggle('show', isNowOpen);
329        }
330
331        if (isNowOpen) {
332          setOpenIcon();
333          document.body.classList.add('right-sidebar-open');
334        } else {
335          setClosedIcon();
336          document.body.classList.remove('right-sidebar-open');
337        }
338
339        if (rightNavToggleOverlay) {
340          rightNavToggleOverlay.classList.toggle('show');
341        }
342      });
343    });
344
345    if (rightNavToggleOverlay) {
346      rightNavToggleOverlay.addEventListener('click', function () {
347        if (rightSidebarContainer) {
348          rightSidebarContainer.classList.remove('show');
349        }
350        if (rightSidebar) {
351          rightSidebar.classList.remove('show');
352        }
353        setClosedIcon();
354        document.body.classList.remove('right-sidebar-open');
355        rightNavToggleOverlay.classList.remove('show');
356      });
357    }
358  }
359  rightSidebarToggle();
360
361  // ------------------------------
362  // 2.1) Подсветка активного пункта меню по URL
363  // ------------------------------
364  function highlightActiveMenuItem() {
365    const path = window.location.pathname;
366    const search = window.location.search;
367    const fullPath = path + search;
368    const navItems = document.querySelectorAll('.sidenav .navbar-nav .nav-item');
369    const dropMenuLinks = document.querySelectorAll('.sidenav .drop-menu a');
370    
371    // Приоритет совпадений для пунктов 1 уровня (от самого специфичного к общему)
372    const menuPaths = [
373      { prefix: '/bible', selector: 'a[href="/bible"]' },
374      { prefix: '/calendar', selector: 'a[href="/calendar"]' },
375      { prefix: '/blog', selector: 'a[href="/blog"]' },
376      { prefix: '/lecture', selector: 'a[href="/lecture/"]', parent: 'media' },
377      { prefix: '/popular', selector: 'a[href="/media"]', parent: 'media' },
378      { prefix: '/authors', selector: 'a[href="/media"]', parent: 'media' },
379      { prefix: '/media', selector: 'a[href="/media"]' },
380      { prefix: '/new', selector: 'a[href="/media"]', parent: 'media' },
381      { prefix: '/special-projects', selector: 'a[href="/special-projects"]' },
382      { prefix: '/kachu', selector: 'a[href="/kachu"]', parent: 'projects' },
383      { prefix: '/hibiny', selector: 'a[href="/hibiny"]', parent: 'projects' },
384      { prefix: '/fond', selector: 'a[href="/fond"]' },
385      { prefix: '/crew', selector: 'a[href="/crew/"]', parent: 'about' },
386      { prefix: '/sovet', selector: 'a[href="/sovet/"]', parent: 'about' },
387      { prefix: '/friends', selector: 'a[href="/friends/"]', parent: 'about' },
388      { prefix: '/about', selector: 'a[href="/about/"]' },
389    ];
390    
391    // Пункты 2 уровня для точного совпадения
392    const subMenuPaths = [
393      // О нас
394      { path: '/crew/', href: '/crew/' },
395      { path: '/sovet/', href: '/sovet/' },
396      { path: '/friends/', href: '/friends/' },
397      { path: '/about/leaders/', href: '/about/leaders/' },
398      // Фонд
399      { path: '/fond/about/', href: '/fond/about/' },
400      { path: '/fond/blago/blog/post/news', href: '/fond/blago/blog/post/news/1/all' },
401      { path: '/fond/blago/blog/post/reports', href: '/fond/blago/blog/post/reports' },
402      { path: '/fond/archive/', href: '/fond/archive/' },
403      { path: '/fond/programmes', href: '/fond/programmes' },
404      { path: '/fond/recurrent/', href: '/fond/recurrent/' },
405      { path: '/fond/security/', href: '/fond/security/' },
406      { path: '/fond/refund/', href: '/fond/refund/' },
407      { path: '/fond/about/map', href: '/fond/about/map' },
408      // Медиатека
409      { path: '/lecture/', href: '/lecture/' },
410      { path: '/new', exact: true, href: '/new' },
411      { path: '/media', exact: true, href: '/media' },
412      { path: '/popular', href: '/popular' },
413      { path: '/authors', href: '/authors' },
414      // Спецпроекты
415      { path: '/kachu', href: '/kachu' },
416      { path: '/hibiny', href: '/hibiny' },
417    ];
418    
419    // Сначала убираем все active
420    navItems.forEach(item => item.classList.remove('active'));
421    dropMenuLinks.forEach(link => link.classList.remove('active'));
422    
423    // Ищем подходящий пункт меню 1 уровня
424    let matched = false;
425    for (const mp of menuPaths) {
426      if (path.startsWith(mp.prefix)) {
427        const link = document.querySelector('.sidenav .navbar-nav ' + mp.selector);
428        if (link) {
429          const navItem = link.closest('.nav-item');
430          if (navItem) {
431            navItem.classList.add('active');
432            matched = true;
433            break;
434          }
435        }
436        // Если есть parent, активируем родительскую группу
437        if (mp.parent) {
438          const parentLink = document.querySelector('.sidenav .navbar-nav .sidebar-group[data-sidebar-group="' + mp.parent + '"]');
439          if (parentLink) {
440            parentLink.classList.add('active');
441            matched = true;
442          }
443        }
444      }
445    }
446    
447    // Ищем подходящий пункт меню 2 уровня
448    // Нормализуем path: убираем trailing slash для сравнения
449    const normPath = path.length > 1 ? path.replace(/\/+$/, '') : path;
450    for (const sp of subMenuPaths) {
451      let isMatch = false;
452      const normSp = sp.path.length > 1 ? sp.path.replace(/\/+$/, '') : sp.path;
453      if (sp.checkSearch) {
454        isMatch = fullPath === sp.path || fullPath.startsWith(sp.path);
455      } else if (sp.exact) {
456        isMatch = normPath === normSp && !search.includes('sort=');
457      } else {
458        isMatch = normPath === normSp || normPath.startsWith(normSp + '/');
459      }
460      
461      if (isMatch) {
462        const subLink = document.querySelector('.sidenav .drop-menu a[href="' + sp.href + '"]');
463        if (subLink) {
464          subLink.classList.add('active');
465          break;
466        }
467      }
468    }
469    
470    // Если ничего не совпало и мы на главной
471    if (!matched && (path === '/' || path === '')) {
472      const homeLink = document.querySelector('.sidenav .navbar-nav a[href="/"]');
473      if (homeLink) {
474        const navItem = homeLink.closest('.nav-item');
475        if (navItem) navItem.classList.add('active');
476      }
477    }
478  }
479  
480  highlightActiveMenuItem();
481
482  // ------------------------------
483  // 3) Группы меню в сайдбаре (dropdown)
484  // ------------------------------
485  // Реализуем «раскрывающиеся» пункты меню (например "Фонд", "Медиатека")
486  // через классы и кнопку со стрелкой.
487  //
488  // Требования:
489  // - верхний пункт остаётся ссылкой
490  // - раскрытие/сворачивание только по клику на стрелку
491  // - на невысоких экранах (по высоте) по умолчанию свёрнуто
492  // - на обычных/высоких по умолчанию раскрыто
493  //
494  // Контракт разметки:
495  // - контейнер группы: .js-sidebar-group (li)
496  // - кнопка переключения: .sidebar-group__toggle
497  // - класс состояния: .is-open
498  // - подменю: .drop-menu
499  function initSidebarGroups() {
500    const groups = document.querySelectorAll('.js-sidebar-group');
501    if (!groups || groups.length === 0) return;
502
503    // Кнопка "Развернуть/Свернуть всё"
504    const expandCollapseBtn = document.getElementById('sidebar-expand-collapse-all');
505    const expandCollapseIcon = document.getElementById('sidebar-expand-collapse-all-icon');
506
507    // Проверка: все ли группы раскрыты
508    const areAllOpen = () => Array.from(groups).every(g => g.classList.contains('is-open'));
509    // Проверка: все ли группы свернуты
510    const areAllClosed = () => Array.from(groups).every(g => !g.classList.contains('is-open'));
511
512    // Обновить иконку/текст кнопки
513    const updateExpandCollapseBtn = () => {
514      if (!expandCollapseBtn || !expandCollapseIcon) return;
515      if (areAllOpen()) {
516        expandCollapseIcon.classList.remove('fa-chevron-down');
517        expandCollapseIcon.classList.add('fa-chevron-up');
518      } else {
519        expandCollapseIcon.classList.remove('fa-chevron-up');
520        expandCollapseIcon.classList.add('fa-chevron-down');
521      }
522    };
523
524    // Массовое раскрытие/сворачивание
525    const setAllGroups = open => {
526      const storedState = readStoredState() || {};
527      groups.forEach((group, index) => {
528        group.classList.toggle('is-open', open);
529        setToggleState(group);
530        const groupId = getGroupId(group, index);
531        storedState[groupId] = open;
532      });
533      writeStoredState(storedState);
534      updateExpandCollapseBtn();
535    };
536
537    if (expandCollapseBtn) {
538      expandCollapseBtn.addEventListener('click', () => {
539        if (areAllOpen()) {
540          setAllGroups(false);
541        } else {
542          setAllGroups(true);
543        }
544      });
545    }
546
547    const storageKey = 'sidebarGroups';
548
549    const readStoredState = () => {
550      try {
551        const raw = localStorage.getItem(storageKey);
552        if (!raw) return null;
553        const parsed = JSON.parse(raw);
554        if (!parsed || typeof parsed !== 'object') return null;
555        return parsed;
556      } catch {
557        return null;
558      }
559    };
560
561    const writeStoredState = state => {
562      try {
563        localStorage.setItem(storageKey, JSON.stringify(state));
564      } catch {
565        // ignore (private mode/quota/etc)
566      }
567    };
568
569    const getGroupId = (group, index) =>
570      (group.dataset && group.dataset.sidebarGroup) || `idx:${index}`;
571
572    // "Невысокие" экраны: ориентируемся именно на высоту (не ширину).
573    // Порог 800px согласован с правилами сайдбара в CSS (max-height: 800px).
574    const lowHeightThreshold = 800;
575
576    // Проставляет aria-expanded в зависимости от наличия класса .is-open.
577    // Стрелка анимируется через CSS, не нужно менять иконку вручную.
578    const setToggleState = group => {
579      const btn = group.querySelector('.sidebar-group__toggle');
580      if (!btn) return;
581
582      const isOpen = group.classList.contains('is-open');
583      btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
584    };
585
586    // Начальное состояние (один раз при загрузке страницы).
587    // Важно: мы не «переигрываем» это состояние при ресайзе,
588    // чтобы пользователь не терял свой выбор во время просмотра.
589    const applyDefaultState = () => {
590      const isLowHeight = window.innerHeight <= lowHeightThreshold;
591
592      const storedState = readStoredState();
593      groups.forEach((group, index) => {
594        const groupId = getGroupId(group, index);
595        const hasStoredValue =
596          storedState && Object.prototype.hasOwnProperty.call(storedState, groupId);
597
598        // Приоритет: если состояние сохранено в localStorage — применяем его,
599        // в том числе на мобильных/низких экранах.
600        if (hasStoredValue) {
601          const shouldBeOpen = Boolean(storedState[groupId]);
602          group.classList.toggle('is-open', shouldBeOpen);
603        } else if (isLowHeight) {
604          group.classList.remove('is-open');
605        } else {
606          group.classList.add('is-open');
607        }
608
609        setToggleState(group);
610      });
611    };
612
613    applyDefaultState();
614
615    // После применения состояния — обновить кнопку
616    updateExpandCollapseBtn();
617
618    // После того как мы применили дефолт/сохранённое состояние,
619    // переключаем CSS из "pre-init" режима в обычный (по .is-open).
620    // Это убирает визуальные скачки на первой отрисовке.
621    document.documentElement.classList.add('sidebar-groups-init');
622
623    groups.forEach((group, index) => {
624      const btn = group.querySelector('.sidebar-group__toggle');
625      if (!btn) return;
626
627      btn.addEventListener('click', e => {
628        e.preventDefault();
629        e.stopPropagation();
630        group.classList.toggle('is-open');
631        setToggleState(group);
632
633        const storedState = readStoredState() || {};
634        const groupId = getGroupId(group, index);
635        storedState[groupId] = group.classList.contains('is-open');
636        writeStoredState(storedState);
637
638        updateExpandCollapseBtn();
639      });
640
641      // Ensure correct state on initial render
642      setToggleState(group);
643    });
644  }
645
646  initSidebarGroups();
647
648  // ------------------------------
649  // 3.1) Выпадающие группы каталога медиатеки
650  // ------------------------------
651  // Аналогичная логика для каталога в правом сайдбаре медиатеки.
652  // Разметка:
653  // - контейнер группы: .js-media-catalog-group
654  // - кнопка переключения: .media-catalog-group__toggle
655  // - класс состояния: .is-open
656  // - подменю: .media-catalog-drop-menu
657  function initMediaCatalogGroups() {
658    const groups = document.querySelectorAll('.js-media-catalog-group');
659    if (!groups || groups.length === 0) return;
660
661    const setToggleState = group => {
662      const btn = group.querySelector('.media-catalog-group__toggle');
663      if (!btn) return;
664
665      const isOpen = group.classList.contains('is-open');
666      btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
667    };
668
669    // Раскрываем путь до активной категории
670    // Находим активную ссылку и поднимаемся вверх по DOM, раскрывая все родительские группы
671    const activeLink = document.querySelector(
672      '.media-catalog-tree .media-catalog-link.active',
673    );
674    if (activeLink) {
675      let parent = activeLink.parentElement;
676      while (parent && !parent.classList.contains('media-catalog-tree')) {
677        if (parent.classList.contains('js-media-catalog-group')) {
678          parent.classList.add('is-open');
679        }
680        parent = parent.parentElement;
681      }
682    }
683
684    // Обновляем состояние всех кнопок
685    groups.forEach(group => {
686      setToggleState(group);
687    });
688
689    groups.forEach(group => {
690      const btn = group.querySelector('.media-catalog-group__toggle');
691      if (!btn) return;
692
693      btn.addEventListener('click', e => {
694        e.preventDefault();
695        e.stopPropagation();
696        group.classList.toggle('is-open');
697        setToggleState(group);
698      });
699
700      setToggleState(group);
701    });
702  }
703
704  initMediaCatalogGroups();
705
706  // ------------------------------
707  // 3.2) Горизонтальное меню каталога медиатеки
708  // ------------------------------
709  // Подменю открывается при hover на стрелку (desktop) или клику (mobile).
710  function initHorizontalCatalogGroups() {
711    const groups = document.querySelectorAll('.js-h-catalog-group');
712    if (!groups || groups.length === 0) return;
713
714    const setToggleState = group => {
715      const btn = group.querySelector('.h-catalog-group__toggle');
716      if (!btn) return;
717      const isOpen = group.classList.contains('is-open');
718      btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
719    };
720
721    const closeGroup = group => {
722      group.classList.remove('is-open');
723      setToggleState(group);
724    };
725
726    const openGroup = group => {
727      group.classList.add('is-open');
728      setToggleState(group);
729    };
730
731    // Закрыть соседние группы на том же уровне
732    const closeSiblings = group => {
733      const parent = group.parentElement;
734      if (!parent) return;
735      groups.forEach(g => {
736        if (g !== group && g.parentElement === parent) {
737          closeGroup(g);
738        }
739      });
740    };
741
742    groups.forEach(group => {
743      setToggleState(group);
744      const toggle = group.querySelector(':scope > .h-catalog-group__header > .h-catalog-group__toggle');
745      const dropdown = group.querySelector(':scope > .h-catalog-drop-menu');
746      if (!toggle) return;
747
748      // Клик по стрелке (основной обработчик для mobile/touch)
749      toggle.addEventListener('click', e => {
750        e.preventDefault();
751        e.stopPropagation();
752        closeSiblings(group);
753        group.classList.toggle('is-open');
754        setToggleState(group);
755      });
756
757      // Hover по стрелке (desktop) — открывает подменю, закрывает при уходе мыши
758      if (dropdown && window.matchMedia('(hover: hover)').matches) {
759        let closeTimer;
760
761        const scheduleClose = () => {
762          closeTimer = setTimeout(() => closeGroup(group), 150);
763        };
764        const cancelClose = () => clearTimeout(closeTimer);
765
766        toggle.addEventListener('mouseenter', () => {
767          cancelClose();
768          closeSiblings(group);
769          openGroup(group);
770        });
771        toggle.addEventListener('mouseleave', scheduleClose);
772
773        dropdown.addEventListener('mouseenter', cancelClose);
774        dropdown.addEventListener('mouseleave', scheduleClose);
775      }
776    });
777
778    // Закрытие по клику вне каталога
779    document.addEventListener('click', e => {
780      if (!e.target.closest('.h-catalog-nav')) {
781        groups.forEach(group => {
782          closeGroup(group);
783        });
784      }
785    });
786  }
787
788  initHorizontalCatalogGroups();
789
790  // ------------------------------
791  // 3.2.1) Раскрывающиеся подкатегории в сайдбаре каталога
792  // ------------------------------
793  function initSidebarCatalogExpandable() {
794    const expandables = document.querySelectorAll('.js-sidebar-catalog-expandable');
795    if (!expandables.length) return;
796
797    expandables.forEach(item => {
798      const toggle = item.querySelector('.sidebar-catalog-expandable__toggle');
799      if (!toggle) return;
800
801      toggle.addEventListener('click', e => {
802        e.preventDefault();
803        e.stopPropagation();
804        item.classList.toggle('is-open');
805      });
806    });
807  }
808
809  initSidebarCatalogExpandable();
810
811  // ------------------------------
812  // 3.3) Кнопка "Показать/Скрыть каталог" для мобильных
813  // ------------------------------
814  (function initMobileCatalogToggle() {
815    // Находим все кнопки и каталоги
816    document.querySelectorAll('.js-mobile-catalog-btn').forEach(function(btn) {
817      btn.addEventListener('click', function() {
818        var catalog = btn.parentElement.querySelector('.js-mobile-catalog-content');
819        if (!catalog) return;
820        
821        var isOpen = catalog.style.display !== 'none';
822        catalog.style.display = isOpen ? 'none' : 'block';
823        btn.setAttribute('aria-expanded', !isOpen);
824        
825        // Меняем текст кнопки
826        var textEl = btn.querySelector('.js-mobile-catalog-btn-text');
827        if (textEl) {
828          textEl.textContent = isOpen ? 'Показать каталог' : 'Скрыть каталог';
829        }
830        
831        // Поворачиваем стрелку
832        var arrow = btn.querySelector('.js-mobile-catalog-btn-arrow');
833        if (arrow) {
834          arrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(180deg)';
835        }
836      });
837    });
838  })();
839
840  // ------------------------------
841  // 3.4) Кнопка открытия правого сайдбара из подсказки каталога
842  // ------------------------------
843  function initOpenRightSidebarHint() {
844    const hintBtns = document.querySelectorAll('.js-open-right-sidebar');
845    if (!hintBtns.length) return;
846
847    hintBtns.forEach(btn => {
848      btn.addEventListener('click', () => {
849        // Триггерим клик на кнопку правого сайдбара
850        const rightNavToggle = document.querySelector('.right-nav-toggle');
851        if (rightNavToggle) {
852          rightNavToggle.click();
853        }
854      });
855    });
856  }
857
858  initOpenRightSidebarHint();
859
860  // ------------------------------
861  // 4) Подсказки поиска (autocomplete)
862  // ------------------------------
863  // Реализация вынесена в отдельный файл search-autocomplete.js
864  // Подключается отдельным <script> тегом для модульности
865  // Здесь только инициализация если модуль загружен
866  if (typeof window.initSearchAutocomplete === 'function') {
867    window.initSearchAutocomplete();
868  } else {
869    // Fallback: встроенная инициализация если модуль не загружен
870    initSearchAutocompleteInline();
871  }
872
873  /**
874   * Встроенная реализация поисковых подсказок (legacy fallback).
875   * Используется если search-autocomplete.js не подключён.
876   * 
877   * @see search-autocomplete.js для подробной документации
878   */
879  function initSearchAutocompleteInline() {
880    const form = document.querySelector('form.search-form');
881    if (!form) return;
882
883    const MIN_QUERY_LENGTH = 3;
884    const SUGGEST_DEBOUNCE_MS = 350;
885
886    const input = form.querySelector('input[name="q"]');
887    const menu = form.querySelector('.search-suggestions');
888    if (!input || !menu) return;
889
890    // Переносим меню в <body> чтобы оно вышло из stacking context сайдбара
891    if (!form.classList.contains('mobile-search-form')) {
892      document.body.appendChild(menu);
893    }
894
895    let debounceTimer = null;
896    let abortController = null;
897
898    // Словари для типов контента
899    const typeLabels = {
900      category: 'Каталог',
901      author: 'Авторы',
902      work: 'Произведения',
903      audio: 'Аудио',
904      video: 'Видео',
905      book: 'Книги',
906      music: 'Музыка',
907      lecture: 'Лекции',
908      recipient: 'Нуждающиеся',
909      fond_news: 'Новости фонда',
910      blog_post: 'Блог',
911      blog_author: 'Авторы блога',
912    };
913    const typeIcons = {
914      category: 'fa-layer-group',
915      work: 'fa-folder-open',
916      audio: 'fa-headphones',
917      video: 'fa-play',
918      book: 'fa-book',
919      music: 'fa-music',
920      lecture: 'fa-chalkboard-teacher',
921      author: 'fa-user',
922      recipient: 'fa-hand-holding-heart',
923      fond_news: 'fa-newspaper',
924      blog_post: 'fa-blog',
925      blog_author: 'fa-pen-fancy',
926    };
927    const typeOrder = ['category', 'recipient', 'fond_news', 'author', 'work', 'audio', 'video', 'book', 'music', 'lecture', 'blog_author', 'blog_post'];
928
929    // Позиционируем меню относительно input
930    const positionMenu = () => {
931      const rect = input.getBoundingClientRect();
932      const isMobile = window.innerWidth <= 767;
933      
934      if (isMobile) {
935        // На мобильном показываем ниже input
936        menu.style.top = (rect.bottom + 8) + 'px';
937        menu.style.left = '12px';
938        menu.style.right = '12px';
939        menu.style.width = 'auto';
940        menu.style.maxHeight = `calc(100vh - ${rect.bottom + 20}px)`;
941      } else {
942        // На десктопе показываем ниже input
943        menu.style.top = rect.bottom + 6 + 'px';
944        menu.style.left = rect.left + 'px';
945        menu.style.right = '';
946        menu.style.maxHeight = '';
947      }
948    };
949
950    const hide = () => {
951      menu.classList.remove('show');
952      menu.innerHTML = '';
953    };
954
955    const escapeHtml = str =>
956      String(str || '')
957        .replace(/</g, '&lt;')
958        .replace(/>/g, '&gt;');
959
960    const getSuggestionItemClasses = r => {
961      const classes = ['search-suggestion-item'];
962      if (r.thumbUrl) classes.push('has-thumb');
963      if (r.isInactiveRecipient) classes.push('search-suggestion-item--recipient-inactive');
964      if (r.isDeceasedRecipient) classes.push('search-suggestion-item--recipient-deceased');
965      return classes.join(' ');
966    };
967
968    const renderSuggestionStatus = r =>
969      r.recipientStatusLabel
970        ? `<span class="search-suggestion-status${r.isDeceasedRecipient ? ' search-suggestion-status--recipient-deceased' : ''}">${escapeHtml(r.recipientStatusLabel)}</span>`
971        : '';
972
973    const render = items => {
974      if (!items || items.length === 0) {
975        hide();
976        return;
977      }
978
979      // Группируем по типу
980      const grouped = {};
981      items.forEach(r => {
982        if (!r.href) return;
983        const type = r.type || 'other';
984        if (!grouped[type]) grouped[type] = [];
985        grouped[type].push(r);
986      });
987
988      // Сортируем типы по порядку и фильтруем пустые
989      const sortedTypes = typeOrder.filter(t => grouped[t] && grouped[t].length > 0);
990      // Добавляем типы, которых нет в typeOrder
991      Object.keys(grouped).forEach(t => {
992        if (!sortedTypes.includes(t)) sortedTypes.push(t);
993      });
994
995      if (sortedTypes.length === 0) {
996        hide();
997        return;
998      }
999
1000      let html = '<div class="search-suggestions-grid">';
1001      sortedTypes.forEach(type => {
1002        const label = typeLabels[type] || type;
1003        const icon = typeIcons[type] || 'fa-file';
1004        const typeItems = grouped[type];
1005
1006        html += `<div class="search-suggestion-column">
1007          <div class="search-suggestion-header">
1008            <i class="fas ${icon}"></i>
1009            <span>${escapeHtml(label)}</span>
1010          </div>
1011          <div class="search-suggestion-list">`;
1012
1013        typeItems.slice(0, 5).forEach(r => {
1014          const title = r.title.length > 80 
1015            ? escapeHtml(r.title.slice(0, 80)) + '…' 
1016            : escapeHtml(r.title);
1017          const desc = r.description
1018            ? escapeHtml(r.description).slice(0, 80) +
1019              (r.description.length > 80 ? '…' : '')
1020            : '';
1021          const thumb = r.thumbUrl ? `<img class="search-suggestion-thumb" src="${r.thumbUrl}" alt="" loading="lazy" />` : '';
1022          const status = renderSuggestionStatus(r);
1023          html += `<a class="${getSuggestionItemClasses(r)}" href="${r.href}">
1024              ${thumb}
1025              <div class="search-suggestion-content">
1026                <span class="search-suggestion-title">${title}</span>
1027                ${status}
1028                ${desc ? `<span class="search-suggestion-desc">${desc}</span>` : ''}
1029              </div>
1030            </a>`;
1031        });
1032
1033        html += '</div></div>';
1034      });
1035      html += '</div>';
1036      
1037      // Добавляем ссылку "Все результаты" внизу
1038      const currentQuery = encodeURIComponent(input.value.trim());
1039      html += `<div class="search-suggestions-footer">
1040        <a class="search-suggestions-all-link" href="/search?q=${currentQuery}">
1041          <i class="fas fa-search"></i>
1042          <span>Все результаты</span>
1043          <i class="fas fa-arrow-right"></i>
1044        </a>
1045      </div>`;
1046
1047      menu.innerHTML = html;
1048      
1049      // Динамическая ширина: ~180px на колонку + padding
1050      const colCount = sortedTypes.length;
1051      const colWidth = 180;
1052      const calculatedWidth = colCount * colWidth + 32; // 32px на padding
1053      menu.style.width = Math.max(calculatedWidth, 220) + 'px';
1054      menu.style.maxWidth = '90vw';
1055      
1056      positionMenu();
1057      menu.classList.add('show');
1058    };
1059
1060    async function fetchSuggestions() {
1061      const q = (input.value || '').trim();
1062      if (q.length < MIN_QUERY_LENGTH) {
1063        hide();
1064        return;
1065      }
1066
1067      if (abortController) abortController.abort();
1068      abortController = new AbortController();
1069
1070      try {
1071        const url = `/search/suggest?q=${encodeURIComponent(q)}`;
1072        const resp = await fetch(url, {
1073          method: 'GET',
1074          headers: { Accept: 'application/json' },
1075          signal: abortController.signal,
1076        });
1077        if (!resp.ok) {
1078          hide();
1079          return;
1080        }
1081
1082        const data = await resp.json();
1083        render((data && data.results) || []);
1084      } catch (e) {
1085        // abort или сетевые ошибки — просто прячем
1086        hide();
1087      }
1088    }
1089
1090    function scheduleFetch() {
1091      if (debounceTimer) window.clearTimeout(debounceTimer);
1092      debounceTimer = window.setTimeout(fetchSuggestions, SUGGEST_DEBOUNCE_MS);
1093    }
1094
1095    input.addEventListener('input', scheduleFetch);
1096    input.addEventListener('focus', scheduleFetch);
1097
1098    document.addEventListener('click', e => {
1099      const target = e.target;
1100      if (target && (form.contains(target) || menu.contains(target))) return;
1101      hide();
1102    });
1103
1104    input.addEventListener('keydown', e => {
1105      if (e.key === 'Escape') hide();
1106    });
1107  }
1108
1109  // Инициализируем встроенную версию если вызвана
1110  // (вызывается только если search-autocomplete.js не загружен)
1111
1112  // ------------------------------
1113  // 5) Валидация форм
1114  // ------------------------------
1115  // Это стандартный паттерн Bootstrap: если форма имеет класс
1116  // .needs-validation, то при submit мы не отправляем её,
1117  // пока браузерная checkValidity() не пройдёт.
1118  //
1119  // Контракт:
1120  // - формы: .needs-validation
1121  // - при попытке submit добавляем класс .was-validated,
1122  //   чтобы CSS/Bootstrap подсветили ошибки.
1123  const forms = document.getElementsByClassName('needs-validation');
1124  Array.from(forms).forEach(function (form) {
1125    form.addEventListener(
1126      'submit',
1127      function (event) {
1128        if (!form.checkValidity()) {
1129          event.preventDefault();
1130          event.stopPropagation();
1131        }
1132        form.classList.add('was-validated');
1133      },
1134      false,
1135    );
1136  });
1137});
1138
1139function toggleTheme() {
1140  // ------------------------------
1141  // 6) Переключение темы (light/dark)
1142  // ------------------------------
1143  // Поддерживаем как "новую" кнопку #theme-toggle,
1144  // так и запасной вариант button.switch.
1145  //
1146  // Состояние хранится в localStorage('theme') и применяется к:
1147  // - document.documentElement.style.colorScheme
1148  // - атрибуту data-theme на <html>
1149  // - классу is-active (нужен для некоторых CSS-эффектов)
1150  const htmlElement = document.documentElement;
1151  const switchButton =
1152    document.getElementById('theme-toggle') ||
1153    document.querySelector('button.switch');
1154  const currentScheme =
1155    htmlElement.getAttribute('data-theme') ||
1156    htmlElement.style.colorScheme ||
1157    'light';
1158  const nextScheme = currentScheme === 'light' ? 'dark' : 'light';
1159  htmlElement.style.colorScheme = nextScheme;
1160  htmlElement.setAttribute('data-theme', nextScheme);
1161  localStorage.setItem('theme', nextScheme);
1162  if (nextScheme === 'dark') {
1163    htmlElement.classList.add('is-active');
1164    if (switchButton) switchButton.classList.add('is-active');
1165  } else {
1166    htmlElement.classList.remove('is-active');
1167    if (switchButton) switchButton.classList.remove('is-active');
1168  }
1169}
1170
1171// ------------------------------
1172// 7) Подписка на события переключения темы
1173// ------------------------------
1174// Предпочитаем прямую кнопку по id, но оставляем делегированный вариант
1175// для совместимости/робастности.
1176const themeToggleButton = document.getElementById('theme-toggle');
1177if (themeToggleButton) {
1178  themeToggleButton.addEventListener('click', () => toggleTheme());
1179} else {
1180  // Delegated listener: any click on a button with class `switch` will toggle theme
1181  document.addEventListener('click', e => {
1182    const btn = e.target.closest && e.target.closest('button.switch');
1183    if (btn) toggleTheme();
1184  });
1185}
1186
1187// ------------------------------
1188// 8) Синхронизация UI кнопки темы при загрузке
1189// ------------------------------
1190// Тема уже применена инлайн-скриптом в <head> (views/layout/header.pug)
1191// чтобы избежать мигания (FOUC). Здесь только синхронизируем состояние кнопки.
1192// Если тема не сохранена — автовыбор по времени: 21:00-07:00 тёмная, 07:00-21:00 светлая
1193document.addEventListener('DOMContentLoaded', () => {
1194  const storedTheme = localStorage.getItem('theme');
1195  const switchButton =
1196    document.getElementById('theme-toggle') ||
1197    document.querySelector('button.switch');
1198
1199  // Определяем активную тему (сохранённая или светлая по умолчанию)
1200  // Авто-тёмная тема по времени суток отключена.
1201  // Раньше: const hour = new Date().getHours();
1202  //         activeTheme = hour >= 21 || hour < 7 ? 'dark' : 'light';
1203  let activeTheme = storedTheme;
1204  if (activeTheme !== 'dark' && activeTheme !== 'light') {
1205    activeTheme = 'light';
1206  }
1207
1208  if (activeTheme === 'dark') {
1209    if (switchButton) switchButton.classList.add('is-active');
1210  } else {
1211    if (switchButton) switchButton.classList.remove('is-active');
1212  }
1213});
1214//('click', ({ currentTarget }) => currentTarget.classList.toggle('is-active'))
1215
1216// ------------------------------
1217// Назначение градиентов рамок кнопкам меню (без перемешивания порядка)
1218// ------------------------------
1219document.addEventListener('DOMContentLoaded', function () {
1220  const nav = document.querySelector('.feed-nav');
1221  if (!nav) return;
1222
1223  const buttons = Array.from(nav.querySelectorAll('.feed-nav__btn'));
1224  if (buttons.length === 0) return;
1225
1226  // Градиенты для рамок
1227  const gradients = [
1228    'linear-gradient(135deg, #667eea, #764ba2)',
1229    'linear-gradient(135deg, #f093fb, #f5576c)',
1230    'linear-gradient(135deg, #4facfe, #00f2fe)',
1231    'linear-gradient(135deg, #43e97b, #38f9d7)',
1232    'linear-gradient(135deg, #fa709a, #fee140)',
1233    'linear-gradient(135deg, #a8edea, #fed6e3)',
1234    'linear-gradient(135deg, #ff9a9e, #fecfef)',
1235    'linear-gradient(135deg, #ffecd2, #fcb69f)',
1236    'linear-gradient(135deg, #a1c4fd, #c2e9fb)',
1237    'linear-gradient(135deg, #d299c2, #fef9d7)',
1238    'linear-gradient(135deg, #89f7fe, #66a6ff)',
1239    'linear-gradient(135deg, #fddb92, #d1fdff)',
1240  ];
1241
1242  // Назначаем градиенты кнопкам (без перемешивания)
1243  buttons.forEach(function (btn, index) {
1244    btn.style.setProperty(
1245      '--btn-gradient',
1246      gradients[index % gradients.length],
1247    );
1248  });
1249});
1250
1251// ------------------------------
1252// Обработка загрузки изображений карточек
1253// ------------------------------
1254document.addEventListener('DOMContentLoaded', function () {
1255  // ------------------------------
1256  // Показываем картинки после их загрузки (чтобы не мигала рамка)
1257  // ------------------------------
1258  document
1259    .querySelectorAll('.feed-card__image:not(.feed-card__image--placeholder)')
1260    .forEach(function (img) {
1261      if (img.complete && img.naturalWidth > 0) {
1262        // Картинка уже загружена (из кэша)
1263        img.classList.add('is-loaded');
1264      } else {
1265        img.addEventListener('load', function () {
1266          img.classList.add('is-loaded');
1267        });
1268      }
1269    });
1270
1271  // ------------------------------
1272  // Если картинка не загрузилась — подставляем плейсхолдер
1273  // (error не всплывает, поэтому используем capture=true)
1274  // ------------------------------
1275  document.addEventListener(
1276    'error',
1277    function (e) {
1278      const target = e.target;
1279      if (!(target instanceof HTMLImageElement)) return;
1280
1281      const wrapper = target.closest('.feed-card-wrapper');
1282      if (!wrapper) return;
1283
1284      wrapper.classList.add('feed-card-wrapper--placeholder');
1285
1286      if (target.classList.contains('feed-card__image')) {
1287        // Подставляем плейсхолдер-картинку без круглой рамки
1288        const imageWrapper = target.closest('.feed-card__image-wrapper');
1289        if (imageWrapper) {
1290          imageWrapper.classList.add('feed-card__image-wrapper--placeholder');
1291          imageWrapper.innerHTML =
1292            '<img class="feed-card__image feed-card__image--placeholder" src="/images/placeholder.png" alt="Изображение">';
1293        }
1294        // Также обновляем класс карточки
1295        const card = wrapper.querySelector('.feed-card');
1296        if (card) {
1297          card.classList.add('feed-card--placeholder');
1298        }
1299      }
1300    },
1301    true,
1302  );
1303});
1304
1305// ==========================================================================
1306// Separation dropdown toggle (разделённые пожертвования)
1307// ==========================================================================
1308
1309// Активный плавающий dropdown (вставлен в body, чтобы обойти stacking context / mask-image)
1310// Используется ТОЛЬКО для .donation-tag-wrapper (горизонтальная лента).
1311// Для .separation-inline (страница donations) dropdown показывается обычным CSS.
1312var activeSepDropdown = null;
1313
1314function closeSeparationDropdown() {
1315  if (activeSepDropdown) {
1316    activeSepDropdown.remove();
1317    activeSepDropdown = null;
1318  }
1319  document.querySelectorAll('.separation-open').forEach(function (el) {
1320    el.classList.remove('separation-open');
1321  });
1322}
1323
1324function openFloatingDropdown(container) {
1325  var srcDropdown = container.querySelector('.separation-dropdown');
1326  if (!srcDropdown) return;
1327
1328  // Клонируем dropdown в body
1329  var clone = srcDropdown.cloneNode(true);
1330  clone.style.display = 'block';
1331  clone.style.position = 'fixed';
1332  clone.style.zIndex = '9999';
1333  clone.classList.add('separation-dropdown--floating');
1334  document.body.appendChild(clone);
1335  activeSepDropdown = clone;
1336
1337  // Позиционируем
1338  var toggle = container.querySelector('.js-separation-toggle') || container;
1339  var rect = toggle.getBoundingClientRect();
1340  var left = rect.left + rect.width / 2 - clone.offsetWidth / 2;
1341  if (left < 8) left = 8;
1342  if (left + clone.offsetWidth > window.innerWidth - 8) {
1343    left = window.innerWidth - clone.offsetWidth - 8;
1344  }
1345  clone.style.top = (rect.bottom + 4) + 'px';
1346  clone.style.left = left + 'px';
1347
1348  container.classList.add('separation-open');
1349}
1350
1351// Закрываем floating dropdown при скролле (но не от автоскролла ленты)
1352document.addEventListener('scroll', function (e) {
1353  if (!activeSepDropdown) return;
1354  if (e.target && e.target.classList && e.target.classList.contains('donation-tags-scroll')) return;
1355  closeSeparationDropdown();
1356}, true);
1357
1358document.addEventListener('click', function (e) {
1359  // Клик внутри плавающего dropdown — не закрываем
1360  if (activeSepDropdown && activeSepDropdown.contains(e.target)) return;
1361
1362  // Клик внутри обычного dropdown (separation-inline) — не закрываем
1363  if (e.target.closest('.separation-dropdown')) return;
1364
1365  var toggle = e.target.closest('.js-separation-toggle');
1366
1367  if (toggle) {
1368    e.preventDefault();
1369    e.stopPropagation();
1370
1371    var container =
1372      toggle.closest('.donation-tag-wrapper') ||
1373      toggle.closest('.separation-inline');
1374    if (!container) return;
1375
1376    var wasOpen = container.classList.contains('separation-open');
1377    closeSeparationDropdown();
1378
1379    if (!wasOpen) {
1380      // .donation-tag-wrapper — используем floating clone (обход mask-image)
1381      // .separation-inline — просто toggle класса, CSS покажет dropdown
1382      if (container.classList.contains('donation-tag-wrapper')) {
1383        openFloatingDropdown(container);
1384      } else {
1385        container.classList.add('separation-open');
1386      }
1387    }
1388    return;
1389  }
1390
1391  // Клик снаружи — закрываем все
1392  closeSeparationDropdown();
1393});
1394
1395// ==========================================================================
1396// Sidebar: обработчик выхода из аккаунта
1397// (Рендеринг блока авторизации теперь выполняется на сервере через Pug SSR)
1398// ==========================================================================
1399
1400(function () {
1401  function buildSidebarAuthHtml(user) {
1402    if (user && user.email) {
1403      return ''
1404        + '<div class="sidebar-auth__row">'
1405        + '<a class="sidebar-auth__link" href="/profile">'
1406        + '<i class="fa-solid fa-user-circle sidebar-auth__icon"></i>'
1407        + 'Личный кабинет'
1408        + '</a>'
1409        + '<a class="sidebar-auth__link sidebar-auth__link--logout js-sidebar-logout" href="#" role="button" title="Выйти">'
1410        + '<i class="fa-solid fa-arrow-right-from-bracket fa-flip-horizontal"></i>'
1411        + '</a>'
1412        + '</div>';
1413    }
1414
1415    return ''
1416      + '<a class="sidebar-auth__link" href="/login">'
1417      + '<i class="fa-solid fa-sign-in-alt sidebar-auth__icon"></i>'
1418      + 'Войти'
1419      + '</a>'
1420      + '<a class="sidebar-auth__link" href="/register">'
1421      + '<i class="fa-solid fa-user-plus sidebar-auth__icon"></i>'
1422      + 'Регистрация'
1423      + '</a>';
1424  }
1425
1426  function renderSidebarAuth(user) {
1427    var sidebarAuth = document.querySelector('.sidebar-auth');
1428    if (!sidebarAuth) return;
1429    sidebarAuth.innerHTML = buildSidebarAuthHtml(user);
1430    bindSidebarLogout();
1431  }
1432
1433  function applyAuthUserToPage(user, source) {
1434    window.__predanieAuthUser = user && user.email ? user : null;
1435    renderSidebarAuth(window.__predanieAuthUser);
1436    window.dispatchEvent(new CustomEvent('predanieAuthStateChanged', {
1437      detail: {
1438        source: source || 'unknown',
1439        user: window.__predanieAuthUser,
1440      },
1441    }));
1442  }
1443
1444  function buildCleanLocationWithoutAuthRefresh(url) {
1445    var cleanUrl = new URL(url.toString());
1446    cleanUrl.searchParams.delete('__auth_refresh');
1447    return cleanUrl.pathname + cleanUrl.search + cleanUrl.hash;
1448  }
1449
1450  function bindSidebarLogout() {
1451    var logoutBtn = document.querySelector('.js-sidebar-logout');
1452    if (!logoutBtn || logoutBtn.__predanieBound) return;
1453    logoutBtn.__predanieBound = true;
1454
1455    logoutBtn.addEventListener('click', function (e) {
1456      e.preventDefault();
1457      fetch('/auth/logout', {
1458        method: 'POST',
1459        credentials: 'same-origin',
1460        headers: { 'Content-Type': 'application/json' },
1461        body: '{}',
1462      })
1463        .catch(function () {})
1464        .then(function () {
1465          applyAuthUserToPage(null, 'sidebar-logout');
1466          try { document.cookie = '__at=; path=/; max-age=0; SameSite=Strict'; } catch (ex) {}
1467          window.location.href = '/';
1468        });
1469    });
1470  }
1471
1472  function syncAuthStateAfterRedirect() {
1473    var url = new URL(window.location.href);
1474    if (!url.searchParams.has('__auth_refresh')) {
1475      return;
1476    }
1477
1478    fetch('/auth/me', {
1479      credentials: 'same-origin',
1480      cache: 'no-store',
1481    })
1482      .then(function (resp) {
1483        if (!resp.ok) return null;
1484        return resp.json();
1485      })
1486      .then(function (user) {
1487        applyAuthUserToPage(user && user.email ? user : null, 'post-auth-redirect');
1488      })
1489      .catch(function () {})
1490      .then(function () {
1491        window.history.replaceState({}, document.title, buildCleanLocationWithoutAuthRefresh(url));
1492      });
1493  }
1494
1495  bindSidebarLogout();
1496  syncAuthStateAfterRedirect();
1497  window.__predanieApplyAuthUserToPage = applyAuthUserToPage;
1498})();
1499
1500// ==========================================================================
1501// Мобильный header-bar: гамбургер → левый сайдбар, лупа → поиск-overlay
1502// ==========================================================================
1503(function () {
1504  // --- Гамбургер в header-bar: открывает левый сайдбар ---
1505  var headerHamburger = document.querySelector('.mobile-header-hamburger');
1506  if (headerHamburger) {
1507    headerHamburger.addEventListener('click', function () {
1508      // Используем тот же .nav-toggle, что и FAB-кнопка
1509      var navToggleFAB = document.querySelector('.mobile-nav-toggles .nav-toggle');
1510      if (navToggleFAB) navToggleFAB.click();
1511    });
1512  }
1513
1514  // --- Лупа → открыть поиск-overlay ---
1515  var searchBtn = document.querySelector('.mobile-header-search-btn');
1516  var searchOverlay = document.querySelector('.mobile-search-overlay');
1517  var searchCloseBtn = document.querySelector('.mobile-search-close-btn');
1518  var searchInput = document.querySelector('.mobile-search-input');
1519
1520  function openMobileSearch() {
1521    if (!searchOverlay) return;
1522    searchOverlay.classList.add('show');
1523    if (searchInput) {
1524      setTimeout(function () { searchInput.focus(); }, 100);
1525    }
1526  }
1527
1528  function closeMobileSearch() {
1529    if (!searchOverlay) return;
1530    searchOverlay.classList.remove('show');
1531    // Закрываем подсказки
1532    var suggestions = searchOverlay.querySelector('.search-suggestions');
1533    if (suggestions) {
1534      suggestions.classList.remove('show');
1535      suggestions.innerHTML = '';
1536    }
1537  }
1538
1539  if (searchBtn) {
1540    searchBtn.addEventListener('click', openMobileSearch);
1541  }
1542  if (searchCloseBtn) {
1543    searchCloseBtn.addEventListener('click', function (e) {
1544      e.preventDefault();
1545      closeMobileSearch();
1546    });
1547  }
1548
1549  // Escape закрывает поиск
1550  document.addEventListener('keydown', function (e) {
1551    if (e.key === 'Escape' && searchOverlay && searchOverlay.classList.contains('show')) {
1552      closeMobileSearch();
1553    }
1554  });
1555
1556  // --- Autocomplete для мобильного поиска ---
1557  if (searchInput && searchOverlay) {
1558    var mobileForm = searchOverlay.querySelector('form.search-form');
1559    var mobileMenu = searchOverlay.querySelector('.search-suggestions');
1560    if (mobileForm && mobileMenu) {
1561      var debounceTimer = null;
1562      var abortCtrl = null;
1563
1564      var typeLabels = {
1565        category: 'Каталог', author: 'Авторы', work: 'Произведения',
1566        audio: 'Аудио', video: 'Видео', book: 'Книги', music: 'Музыка',
1567        lecture: 'Лекции', recipient: 'Нуждающиеся', fond_news: 'Новости фонда', blog_post: 'Блог',
1568        blog_author: 'Авторы блога',
1569      };
1570      var typeIcons = {
1571        category: 'fa-layer-group', work: 'fa-folder-open', audio: 'fa-headphones',
1572        video: 'fa-play', book: 'fa-book', music: 'fa-music',
1573        lecture: 'fa-chalkboard-teacher', author: 'fa-user',
1574        recipient: 'fa-hand-holding-heart', fond_news: 'fa-newspaper', blog_post: 'fa-blog', blog_author: 'fa-pen-fancy',
1575      };
1576      var typeOrder = ['category','recipient','fond_news','author','work','audio','video','book','music','lecture','blog_author','blog_post'];
1577
1578      function escHtml(s) { return String(s||'').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
1579      function getMobileSuggestionItemClasses(r) {
1580        var classes = ['search-suggestion-item'];
1581        if (r.thumbUrl) classes.push('has-thumb');
1582        if (r.isInactiveRecipient) classes.push('search-suggestion-item--recipient-inactive');
1583        if (r.isDeceasedRecipient) classes.push('search-suggestion-item--recipient-deceased');
1584        return classes.join(' ');
1585      }
1586      function renderMobileSuggestionStatus(r) {
1587        return r.recipientStatusLabel
1588          ? '<span class="search-suggestion-status' + (r.isDeceasedRecipient ? ' search-suggestion-status--recipient-deceased' : '') + '">' + escHtml(r.recipientStatusLabel) + '</span>'
1589          : '';
1590      }
1591
1592      function hideMobile() { mobileMenu.classList.remove('show'); mobileMenu.innerHTML = ''; }
1593
1594      function renderMobile(items) {
1595        if (!items || !items.length) { hideMobile(); return; }
1596        var grouped = {};
1597        items.forEach(function(r) {
1598          if (!r.href) return;
1599          var t = r.type || 'other';
1600          if (!grouped[t]) grouped[t] = [];
1601          grouped[t].push(r);
1602        });
1603        var sorted = typeOrder.filter(function(t){return grouped[t]&&grouped[t].length;});
1604        Object.keys(grouped).forEach(function(t){ if (sorted.indexOf(t)===-1) sorted.push(t); });
1605        if (!sorted.length) { hideMobile(); return; }
1606
1607        var html = '<div class="search-suggestions-grid">';
1608        sorted.forEach(function(type) {
1609          var label = typeLabels[type]||type, icon = typeIcons[type]||'fa-file';
1610          html += '<div class="search-suggestion-column"><div class="search-suggestion-header"><i class="fas '+icon+'"></i><span>'+escHtml(label)+'</span></div><div class="search-suggestion-list">';
1611          grouped[type].slice(0,5).forEach(function(r) {
1612            var title = r.title.length>80 ? escHtml(r.title.slice(0,80))+'…' : escHtml(r.title);
1613            var desc = r.description ? escHtml(r.description).slice(0,80)+(r.description.length>80?'…':'') : '';
1614            var thumb = r.thumbUrl ? '<img class="search-suggestion-thumb" src="'+r.thumbUrl+'" alt="" loading="lazy" />' : '';
1615            var status = renderMobileSuggestionStatus(r);
1616            html += '<a class="'+getMobileSuggestionItemClasses(r)+'" href="'+r.href+'">'+thumb+'<div class="search-suggestion-content"><span class="search-suggestion-title">'+title+'</span>'+status+(desc?'<span class="search-suggestion-desc">'+desc+'</span>':'')+'</div></a>';
1617          });
1618          html += '</div></div>';
1619        });
1620        html += '</div>';
1621        var cq = encodeURIComponent(searchInput.value.trim());
1622        html += '<div class="search-suggestions-footer"><a class="search-suggestions-all-link" href="/search?q='+cq+'"><i class="fas fa-search"></i><span>Все результаты</span><i class="fas fa-arrow-right"></i></a></div>';
1623        mobileMenu.innerHTML = html;
1624        mobileMenu.style.width = 'auto';
1625        mobileMenu.style.maxWidth = '100%';
1626        mobileMenu.classList.add('show');
1627      }
1628
1629      function fetchMobile() {
1630        var q = (searchInput.value||'').trim();
1631        if (q.length<3) { hideMobile(); return; }
1632        if (abortCtrl) abortCtrl.abort();
1633        abortCtrl = new AbortController();
1634        fetch('/search/suggest?q='+encodeURIComponent(q), {
1635          method:'GET', headers:{Accept:'application/json'}, signal:abortCtrl.signal
1636        }).then(function(r){ return r.ok ? r.json() : null; })
1637          .then(function(d){ renderMobile(d&&d.results||[]); })
1638          .catch(function(){ hideMobile(); });
1639      }
1640
1641      function scheduleMobile() {
1642        if (debounceTimer) clearTimeout(debounceTimer);
1643        debounceTimer = setTimeout(fetchMobile, 120);
1644      }
1645
1646      searchInput.addEventListener('input', scheduleMobile);
1647      searchInput.addEventListener('focus', scheduleMobile);
1648      searchInput.addEventListener('keydown', function(e) { if (e.key==='Escape') hideMobile(); });
1649    }
1650  }
1651})();
1652
1653// ==========================================================================
1654// Donation modal openers + monthly project support popup
1655// ==========================================================================
1656(function () {
1657  function runWhenReady(fn) {
1658    if (document.readyState === 'loading') {
1659      document.addEventListener('DOMContentLoaded', fn);
1660      return;
1661    }
1662
1663    fn();
1664  }
1665
1666  function whenDonationModalReady(fn) {
1667    if (window.__fondDonationModalReady) {
1668      fn();
1669      return;
1670    }
1671
1672    var resolved = false;
1673
1674    function finish() {
1675      if (resolved) return;
1676      resolved = true;
1677      window.removeEventListener('fondDonationModalReady', finish);
1678      fn();
1679    }
1680
1681    window.addEventListener('fondDonationModalReady', finish, { once: true });
1682    window.setTimeout(finish, 1500);
1683  }
1684
1685  function readTriggerValue(trigger, attrName, fallback) {
1686    if (!trigger || !trigger.getAttribute) return fallback;
1687    var value = trigger.getAttribute(attrName);
1688    return value === null || typeof value === 'undefined' ? fallback : value;
1689  }
1690
1691  function hasTriggerAttribute(trigger, attrName) {
1692    return !!(trigger && trigger.hasAttribute && trigger.hasAttribute(attrName));
1693  }
1694
1695  function getModalLabelText(trigger) {
1696    var explicitLabel = readTriggerValue(trigger, 'data-modal-label', '');
1697    if (explicitLabel) return explicitLabel;
1698
1699    var recipientTitle = readTriggerValue(trigger, 'data-recipient-title', '');
1700    return recipientTitle ? 'Пожертвовать ' + recipientTitle : 'Пожертвовать';
1701  }
1702
1703  function buildDonationRedirectUrl(trigger) {
1704    var params = new URLSearchParams();
1705    params.set('openDonation', '1');
1706    params.set('donationRecipientId', readTriggerValue(trigger, 'data-recipient-id', '341489'));
1707    params.set('donationModalLabel', getModalLabelText(trigger));
1708
1709    if (hasTriggerAttribute(trigger, 'data-recipient-title')) {
1710      params.set('donationRecipientTitle', readTriggerValue(trigger, 'data-recipient-title', ''));
1711    }
1712
1713    if (hasTriggerAttribute(trigger, 'data-recipient-purpose')) {
1714      params.set('donationRecipientPurpose', readTriggerValue(trigger, 'data-recipient-purpose', ''));
1715    }
1716
1717    var openKind = readTriggerValue(trigger, 'data-open-kind', '');
1718    if (openKind) params.set('donationOpenKind', openKind);
1719
1720    var recurringProvider = readTriggerValue(trigger, 'data-open-recurring-provider', '');
1721    if (recurringProvider) params.set('donationRecurringProvider', recurringProvider);
1722
1723    if (readTriggerValue(trigger, 'data-is-programme', '') === 'true') {
1724      params.set('donationIsProgramme', '1');
1725    }
1726
1727    return '/fond?' + params.toString();
1728  }
1729
1730  function configureDonationModal(trigger) {
1731    var donationModal = document.getElementById('donationModal');
1732    if (!donationModal) return null;
1733
1734    var donationForm = document.getElementById('donationForm');
1735    var modalLabel = document.getElementById('donationModalLabel');
1736    var recipientIdInput = donationForm ? donationForm.querySelector('input[name="recipientId"]') : null;
1737    var hiddenRecipientTitle = document.getElementById('hiddenRecipientTitle');
1738    var hiddenRecipientPurpose = document.getElementById('hiddenRecipientPurpose');
1739    var defaultRecipientId = recipientIdInput ? (recipientIdInput.getAttribute('value') || '341489') : '341489';
1740    var defaultRecipientTitle = hiddenRecipientTitle ? (hiddenRecipientTitle.getAttribute('value') || '') : '';
1741    var defaultRecipientPurpose = hiddenRecipientPurpose ? (hiddenRecipientPurpose.getAttribute('value') || '') : '';
1742
1743    var recipientId = readTriggerValue(trigger, 'data-recipient-id', '341489');
1744    var recipientTitle = hasTriggerAttribute(trigger, 'data-recipient-title')
1745      ? readTriggerValue(trigger, 'data-recipient-title', '')
1746      : defaultRecipientTitle;
1747    var recipientPurpose = hasTriggerAttribute(trigger, 'data-recipient-purpose')
1748      ? readTriggerValue(trigger, 'data-recipient-purpose', '')
1749      : defaultRecipientPurpose;
1750    var openKind = readTriggerValue(trigger, 'data-open-kind', '');
1751    var recurringProvider = readTriggerValue(trigger, 'data-open-recurring-provider', '');
1752    var isProgramme = readTriggerValue(trigger, 'data-is-programme', '') === 'true';
1753
1754    if (recipientIdInput) recipientIdInput.value = recipientId || defaultRecipientId;
1755    if (hiddenRecipientTitle) hiddenRecipientTitle.value = recipientTitle;
1756    if (hiddenRecipientPurpose) hiddenRecipientPurpose.value = recipientPurpose;
1757    if (modalLabel) modalLabel.textContent = getModalLabelText(trigger);
1758
1759    if (openKind) {
1760      donationModal.setAttribute('data-open-kind', openKind);
1761    } else {
1762      donationModal.removeAttribute('data-open-kind');
1763    }
1764
1765    if (recurringProvider) {
1766      donationModal.setAttribute('data-open-recurring-provider', recurringProvider);
1767    } else {
1768      donationModal.removeAttribute('data-open-recurring-provider');
1769    }
1770
1771    if (isProgramme) {
1772      donationModal.setAttribute('data-is-programme', 'true');
1773    } else {
1774      donationModal.removeAttribute('data-is-programme');
1775    }
1776
1777    donationModal.classList.remove('is-step2');
1778    return donationModal;
1779  }
1780
1781  function openDonationModalFromTrigger(trigger) {
1782    var donationModal = configureDonationModal(trigger);
1783    if (!donationModal || typeof bootstrap === 'undefined' || !bootstrap || !bootstrap.Modal) {
1784      window.location.href = buildDonationRedirectUrl(trigger);
1785      return;
1786    }
1787
1788    var modalInstance = bootstrap.Modal.getOrCreateInstance
1789      ? bootstrap.Modal.getOrCreateInstance(donationModal)
1790      : new bootstrap.Modal(donationModal);
1791    modalInstance.show();
1792  }
1793
1794  window.openDonationModalFromTrigger = openDonationModalFromTrigger;
1795
1796  document.addEventListener('click', function (e) {
1797    var trigger = e.target.closest('.open-donation-modal');
1798    if (!trigger) return;
1799
1800    e.preventDefault();
1801
1802    var parentFab = trigger.closest('.mobile-charity-fab');
1803    if (parentFab) parentFab.classList.remove('is-open');
1804
1805    openDonationModalFromTrigger(trigger);
1806  });
1807
1808  runWhenReady(function () {
1809    var params = new URLSearchParams(window.location.search);
1810    if (params.get('openDonation') !== '1') return;
1811
1812    if (params.get('predanieAppReturn') === '1') {
1813      // Метка переживает переход к провайдеру в этом же браузере.
1814      document.cookie = 'predanie_donation_app_return=1; path=/fond/payment; max-age=7200; SameSite=Lax; Secure';
1815    }
1816
1817    window.__skipMonthlyProjectSupportPopup = true;
1818
1819    var trigger = document.createElement('button');
1820    trigger.type = 'button';
1821    trigger.setAttribute('data-recipient-id', params.get('donationRecipientId') || '341489');
1822
1823    if (params.has('donationRecipientTitle')) {
1824      trigger.setAttribute('data-recipient-title', params.get('donationRecipientTitle') || '');
1825    }
1826
1827    if (params.has('donationRecipientPurpose')) {
1828      trigger.setAttribute('data-recipient-purpose', params.get('donationRecipientPurpose') || '');
1829    }
1830
1831    var openKind = params.get('donationOpenKind');
1832    if (openKind) trigger.setAttribute('data-open-kind', openKind);
1833
1834    var recurringProvider = params.get('donationRecurringProvider');
1835    if (recurringProvider) trigger.setAttribute('data-open-recurring-provider', recurringProvider);
1836
1837    var modalLabel = params.get('donationModalLabel');
1838    if (modalLabel) trigger.setAttribute('data-modal-label', modalLabel);
1839
1840    if (params.get('donationIsProgramme') === '1') {
1841      trigger.setAttribute('data-is-programme', 'true');
1842    }
1843
1844    whenDonationModalReady(function () {
1845      openDonationModalFromTrigger(trigger);
1846
1847      params.delete('openDonation');
1848      params.delete('predanieAppReturn');
1849      params.delete('donationRecipientId');
1850      params.delete('donationRecipientTitle');
1851      params.delete('donationRecipientPurpose');
1852      params.delete('donationOpenKind');
1853      params.delete('donationRecurringProvider');
1854      params.delete('donationModalLabel');
1855      params.delete('donationIsProgramme');
1856
1857      var cleanUrl = window.location.pathname + (params.toString() ? '?' + params.toString() : '') + window.location.hash;
1858      window.history.replaceState({}, document.title, cleanUrl);
1859    });
1860  });
1861})();
1862
1863(function () {
1864  var POPUP_ID = 'monthlyProjectSupportPopup';
1865  var POPUP_LAST_SHOWN_KEY = 'predanie_monthly_support_popup_last_shown_at';
1866  var LEGACY_POPUP_MONTH_COOKIE = 'predanie_monthly_support_popup_closed_month';
1867  var POPUP_DELAY_MS = 120000;
1868  var BODY_CLASS = 'monthly-project-support-popup-open';
1869  var FOND_PATH_RE = /^\/fond(?:\/|$)/;
1870
1871  function getCookie(name) {
1872    var escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1873    var match = document.cookie.match(new RegExp('(?:^|; )' + escapedName + '=([^;]*)'));
1874    return match ? decodeURIComponent(match[1]) : '';
1875  }
1876
1877  function setCookie(name, value) {
1878    document.cookie = name + '=' + encodeURIComponent(value) + '; path=/; max-age=34560000; SameSite=Lax';
1879  }
1880
1881  function getCurrentMonthStamp() {
1882    var now = new Date();
1883    var month = String(now.getMonth() + 1).padStart(2, '0');
1884    return String(now.getFullYear()) + '-' + month;
1885  }
1886
1887  function isFondPath() {
1888    return FOND_PATH_RE.test(window.location.pathname || '');
1889  }
1890
1891  function wasPopupShownRecently() {
1892    var gate = window.predaniePromoPopupGate;
1893    if (gate && gate.wasShownRecently(POPUP_LAST_SHOWN_KEY)) return true;
1894
1895    // Совместимость с отметкой, которую ставила предыдущая версия попапа.
1896    return getCookie(LEGACY_POPUP_MONTH_COOKIE) === getCurrentMonthStamp();
1897  }
1898
1899  function isSiteBannerPopupVisible() {
1900    var overlay = document.getElementById('popup-banner-overlay');
1901    if (!overlay) return false;
1902
1903    return document.documentElement.getAttribute('data-popup-banner-hidden') !== 'true';
1904  }
1905
1906  function hasAnotherPromoPopup() {
1907    return Boolean(document.getElementById('push-popup-offer'));
1908  }
1909
1910  function initMonthlyProjectSupportPopup() {
1911    var popup = document.getElementById(POPUP_ID);
1912    if (!popup) return;
1913    if (popup.dataset.monthlySupportPopupInitialized === '1') return;
1914
1915    popup.dataset.monthlySupportPopupInitialized = '1';
1916
1917    var dialog = popup.querySelector('.monthly-project-support-popup__dialog');
1918    var closeBtn = popup.querySelector('.monthly-project-support-popup__close');
1919    var ctaBtn = popup.querySelector('.monthly-project-support-popup__cta');
1920    var isVisible = false;
1921    var showTimer = null;
1922    var timerStartedAt = 0;
1923    var remainingDelayMs = POPUP_DELAY_MS;
1924
1925    function shouldSkipPopup() {
1926      if (isFondPath()) return true;
1927      if (window.__skipMonthlyProjectSupportPopup) return true;
1928      if (wasPopupShownRecently()) return true;
1929      if (document.querySelector('.modal.show') || document.body.classList.contains('modal-open')) return true;
1930      if (isSiteBannerPopupVisible()) return true;
1931      if (hasAnotherPromoPopup()) return true;
1932
1933      return false;
1934    }
1935
1936    function claimPopupDisplay() {
1937      var gate = window.predaniePromoPopupGate;
1938
1939      if (gate && !gate.claim(POPUP_LAST_SHOWN_KEY)) return false;
1940
1941      // Старую cookie продолжаем обновлять, чтобы вкладка со старым кешированным
1942      // script.js тоже не показала попап повторно.
1943      setCookie(LEGACY_POPUP_MONTH_COOKIE, getCurrentMonthStamp());
1944      return true;
1945    }
1946
1947    function hidePopup() {
1948      if (!isVisible) return;
1949      isVisible = false;
1950      popup.classList.remove('is-visible');
1951      popup.setAttribute('aria-hidden', 'true');
1952      popup.hidden = true;
1953      document.body.classList.remove(BODY_CLASS);
1954    }
1955
1956    function pauseShowTimer() {
1957      if (!showTimer) return;
1958
1959      window.clearTimeout(showTimer);
1960      showTimer = null;
1961
1962      if (timerStartedAt) {
1963        remainingDelayMs = Math.max(0, remainingDelayMs - (Date.now() - timerStartedAt));
1964        timerStartedAt = 0;
1965      }
1966    }
1967
1968    function showPopup() {
1969      if (shouldSkipPopup()) return;
1970      if (!claimPopupDisplay()) return;
1971      isVisible = true;
1972      popup.hidden = false;
1973      popup.setAttribute('aria-hidden', 'false');
1974      document.body.classList.add(BODY_CLASS);
1975
1976      window.requestAnimationFrame(function () {
1977        popup.classList.add('is-visible');
1978      });
1979    }
1980
1981    function scheduleShowPopup() {
1982      if (isVisible || showTimer) return;
1983      if (shouldSkipPopup()) return;
1984      if (document.visibilityState === 'hidden') return;
1985
1986      timerStartedAt = Date.now();
1987      showTimer = window.setTimeout(function () {
1988        showTimer = null;
1989        timerStartedAt = 0;
1990        remainingDelayMs = 0;
1991        showPopup();
1992      }, remainingDelayMs);
1993    }
1994
1995    scheduleShowPopup();
1996
1997    if (closeBtn) {
1998      closeBtn.addEventListener('click', function () {
1999        hidePopup();
2000      });
2001    }
2002
2003    if (ctaBtn) {
2004      ctaBtn.addEventListener('click', function () {
2005        hidePopup();
2006      });
2007    }
2008
2009    popup.addEventListener('click', function (e) {
2010      if (dialog && dialog.contains(e.target)) return;
2011      hidePopup();
2012    });
2013
2014    document.addEventListener('keydown', function (e) {
2015      if (e.key === 'Escape' && isVisible) {
2016        hidePopup();
2017      }
2018    });
2019
2020    document.addEventListener('visibilitychange', function () {
2021      if (document.visibilityState === 'hidden') {
2022        pauseShowTimer();
2023        return;
2024      }
2025
2026      scheduleShowPopup();
2027    });
2028
2029    // Если тот же попап показался в другой вкладке, текущая вкладка сразу
2030    // закрывает свой экземпляр.
2031    window.addEventListener('storage', function (event) {
2032      if (event.key === POPUP_LAST_SHOWN_KEY) hidePopup();
2033    });
2034  }
2035
2036  if (document.readyState === 'loading') {
2037    document.addEventListener('DOMContentLoaded', initMonthlyProjectSupportPopup);
2038  } else {
2039    initMonthlyProjectSupportPopup();
2040  }
2041})();
2042
2043// ==========================================================================
2044// FAB-меню благотворительности (раскрытие/скрытие)
2045// ==========================================================================
2046(function () {
2047  var fabs = document.querySelectorAll('.mobile-charity-fab');
2048  if (!fabs.length) return;
2049
2050  fabs.forEach(function (fab) {
2051    var trigger = fab.querySelector('.mobile-charity-fab__trigger');
2052    var overlay = fab.querySelector('.mobile-charity-fab__overlay');
2053    var isDirectMode = fab.getAttribute('data-charity-fab-mode') === 'direct';
2054
2055    function toggleFab() {
2056      fab.classList.toggle('is-open');
2057    }
2058
2059    function closeFab() {
2060      fab.classList.remove('is-open');
2061    }
2062
2063    if (trigger && !isDirectMode) {
2064      trigger.addEventListener('click', function (e) {
2065        e.stopPropagation();
2066        // Закрываем другие открытые FAB
2067        fabs.forEach(function (other) {
2068          if (other !== fab) other.classList.remove('is-open');
2069        });
2070        toggleFab();
2071      });
2072    }
2073
2074    if (overlay) {
2075      overlay.addEventListener('click', function () {
2076        closeFab();
2077      });
2078    }
2079
2080    // Закрываем при нажатии на пункт «Помочь»
2081    var donateBtn = fab.querySelector('.mobile-charity-fab__item--donate');
2082    if (donateBtn) {
2083      donateBtn.addEventListener('click', function () {
2084        closeFab();
2085      });
2086    }
2087
2088    // Закрываем при нажатии на «Благотворительность»
2089    var fondLink = fab.querySelector('.mobile-charity-fab__item--fond');
2090    if (fondLink) {
2091      fondLink.addEventListener('click', function () {
2092        closeFab();
2093      });
2094    }
2095  });
2096})();

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.