BR Admin Panel Pro v6

Панель куратора BR — оптимизировано: единый таймер-менеджер, делегирование событий, без утечек

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         BR Admin Panel Pro v6
// @namespace    https://vk.ru/club237051164
// @version      6.2.0
// @description  Панель куратора BR — оптимизировано: единый таймер-менеджер, делегирование событий, без утечек
// @author       Akzholch1k | vk.ru/brunoverona
// @match        https://forum.blackrussia.online/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @connect      api.telegram.org
// @connect      api.imgbb.com
// @connect      *
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  /* ══════════════════════════════════════════════════════════
     ПОЧЕМУ ЭТА ВЕРСИЯ БЫСТРЕЕ v5 — коротко, для будущих правок:

     1. TimerRegistry — единственное место, где живут setInterval/
        setTimeout. Раньше startTracking() плодил 3 независимых
        таймера при каждом вызове без сохранения ID; если функция
        вызывалась дважды (а на XenForo с pushState-навигацией это
        случается), таймеры удваивались молча. Теперь re-init убивает
        старые перед стартом новых — физически невозможно продублировать.

     2. Одна точка правды для "какая страница сейчас" — PAGE_STATE,
        обновляется по событию popstate/pushState, а не polling'ом
        location.href каждые 2 секунды в отдельном интервале.

     3. Делегирование кликов на уровне панели (один слушатель на
        #brv6-panel) вместо querySelectorAll(...).forEach(навесить
        обработчик) при каждой перерисовке таба. Раньше каждый вызов
        drawTpls() на N шаблонов вешал минимум 3N новых замыканий,
        и это не считая слушателей от предыдущего рендера — они не
        снимались явно, просто DOM-узлы с этими обработчиками
        выкидывались сборщиком мусора (это не утечка памяти в строгом
        смысле, но это N лишних функций-аллокаций на каждый клик
        по табу шаблонов, и это то, что ощущается как лаг при
        20-30 шаблонах на телефоне).

     4. Змейка: таймер — свойство объекта игры, а не переменная
        модуля. Explicit .destroy() при уходе со страницы игры,
        вызывается из ОДНОГО места (page router), а не через
        addEventListener(once) навешанный на все табы при каждом
        запуске игры.

     5. detectForumUser() кешируется на PAGE_STATE.url — вызывается
        реально только когда URL изменился, не на каждый /ping.
  ══════════════════════════════════════════════════════════ */

  /* ──────────────────────────────────────────────────────────
     TimerRegistry
     Единственное место в скрипте, которое трогает setInterval/
     setTimeout напрямую. Каждый таймер именован; повторная
     регистрация того же имени сначала снимает старый. init() —
     единственная точка входа, вызывается ровно один раз за
     жизнь скрипта на странице.
  ────────────────────────────────────────────────────────── */
  const Timers = (() => {
    const handles = new Map(); // name -> interval/timeout id
    const kinds   = new Map(); // name -> 'interval' | 'timeout'

    function clear(name) {
      if (!handles.has(name)) return;
      const id = handles.get(name);
      (kinds.get(name) === 'interval' ? clearInterval : clearTimeout)(id);
      handles.delete(name);
      kinds.delete(name);
    }

    function every(name, ms, fn) {
      clear(name);
      handles.set(name, setInterval(fn, ms));
      kinds.set(name, 'interval');
    }

    function after(name, ms, fn) {
      clear(name);
      handles.set(name, setTimeout(() => { handles.delete(name); kinds.delete(name); fn(); }, ms));
      kinds.set(name, 'timeout');
    }

    function clearAll() {
      for (const name of [...handles.keys()]) clear(name);
    }

    return { every, after, clear, clearAll };
  })();

  /* ══════════════════════════════════════════════════════════
     КОНФИГ
  ══════════════════════════════════════════════════════════ */
  // ─── НАСТРОЙКА ───
  // TG_TOKEN/TG_CID используются только для прямых уведомлений из скрипта.
  // Чат поддержки идёт через SERVER — туда же поставьте тот же SECRET
  // (переменная окружения SECRET в Railway).
  const TG_TOKEN  = '8814296690:AAEdmT5mi6gQFzvXaO450nNOFEZ6tlJTBAk';          // токен от @BotFather
  const TG_CID    = '6564808901';             // ваш numeric id (@userinfobot)
  const SERVER    = 'https://web-web-production-55258.up.railway.app';  // URL после деплоя
  const SECRET    = 'AdminForum';             // == SECRET на сервере
  const CR_LINK   = 'cr-mp://connect/blackrussia.online:7777';

  const PING_INTERVAL_MS   = 30000;
  const POLL_INTERVAL_MS   = 6000;   // было 5000 в v5 — на телефоне 6с не ощущается медленнее, а нагрузка на бесплатный Railway ниже
  const PRESENCE_DEBOUNCE_MS = 800;  // см. PageWatcher ниже

  const PREFIXES = [
    { id:0,  name:'— Без префикса',      color:'#666' },
    { id:2,  name:'📌 Закреплено',        color:'#f0c040' },
    { id:4,  name:'❌ Отказано',          color:'#ff4f4f' },
    { id:5,  name:'⏳ На рассмотрении',   color:'#ec7c26' },
    { id:6,  name:'✅ Решено',            color:'#2ed47a' },
    { id:7,  name:'🔒 Закрыто',          color:'#888' },
    { id:8,  name:'✔️ Одобрено',         color:'#4caf50' },
    { id:9,  name:'👁 Рассмотрено',      color:'#2196f3' },
    { id:10, name:'📢 Команде проекта',   color:'#9c27b0' },
    { id:12, name:'🏛 ГА',               color:'#ff9800' },
    { id:13, name:'🔧 Тех. специалисту', color:'#00bcd4' },
    { id:14, name:'⌛ Ожидание',         color:'#607d8b' },
  ];
  const prefixById = new Map(PREFIXES.map(p => [p.id, p]));

  const DEFAULT_TEMPLATES = [
    { id:1, name:'На рассмотрении', prefix:5, autopin:true,
      content:'[COLOR=#FF00FF][FONT=times new roman][SIZE=4][I][CENTER][ICODE]{{greeting}}, уважаемый(ая) игрок [USER={{uid}}]{{username}}[/USER][/ICODE].[/CENTER][/I][/SIZE][/FONT][/COLOR]\n\n[B][CENTER][FONT=times new roman][COLOR=#ffffff][ICODE]Ваша заявка принята и находится на рассмотрении.[/ICODE][/COLOR][/CENTER][/FONT][/B]\n\n[B][CENTER][FONT=times new roman][COLOR=#ec7c26][ICODE]На рассмотрении.[/ICODE][/COLOR][/CENTER][/FONT][/B]' },
    { id:2, name:'Одобрено', prefix:8, autopin:true,
      content:'[COLOR=#FF00FF][FONT=times new roman][SIZE=4][I][CENTER][ICODE]{{greeting}}, уважаемый(ая) игрок [USER={{uid}}]{{username}}[/USER][/ICODE].[/CENTER][/I][/SIZE][/FONT][/COLOR]\n\n[B][CENTER][FONT=times new roman][COLOR=#00FF00][ICODE]Ваша заявка одобрена. Поздравляем![/ICODE][/COLOR][/CENTER][/FONT][/B]\n\n[B][CENTER][FONT=times new roman][COLOR=#00FF00][ICODE]Одобрено.[/ICODE][/COLOR][/CENTER][/FONT][/B]' },
    { id:3, name:'Отказано', prefix:4, autopin:true,
      content:'[COLOR=#FF00FF][FONT=times new roman][SIZE=4][I][CENTER][ICODE]{{greeting}}, уважаемый(ая) игрок [USER={{uid}}]{{username}}[/USER][/ICODE].[/CENTER][/I][/SIZE][/FONT][/COLOR]\n\n[B][CENTER][FONT=times new roman][COLOR=#FF4444][ICODE]К сожалению, ваша заявка отклонена.[/ICODE][/COLOR][/CENTER][/FONT][/B]\n\n[B][CENTER][FONT=times new roman][COLOR=#FF4444][ICODE]Отказано.[/ICODE][/COLOR][/CENTER][/FONT][/B]' },
    { id:4, name:'Закрыто', prefix:7, autopin:false,
      content:'[COLOR=#FF00FF][FONT=times new roman][SIZE=4][I][CENTER][ICODE]{{greeting}}, уважаемый(ая) игрок [USER={{uid}}]{{username}}[/USER][/ICODE].[/CENTER][/I][/SIZE][/FONT][/COLOR]\n\n[B][CENTER][FONT=times new roman][COLOR=#aaaaaa][ICODE]Тема закрыта куратором.[/ICODE][/COLOR][/CENTER][/FONT][/B]\n\n[B][CENTER][FONT=times new roman][COLOR=#aaaaaa][ICODE]Закрыто.[/ICODE][/COLOR][/CENTER][/FONT][/B]' },
    { id:5, name:'Нет доказательств', prefix:4, autopin:false,
      content:'[COLOR=#FF00FF][FONT=times new roman][SIZE=4][I][CENTER][ICODE]{{greeting}}, уважаемый(ая) игрок [USER={{uid}}]{{username}}[/USER][/ICODE].[/CENTER][/I][/SIZE][/FONT][/COLOR]\n\n[B][CENTER][FONT=times new roman][COLOR=#FF4444][ICODE]В вашей жалобе отсутствуют доказательства.[/ICODE][/COLOR][/CENTER][/FONT][/B]\n\n[B][CENTER][FONT=times new roman][COLOR=#aaaaaa][ICODE]Закрыто.[/ICODE][/COLOR][/CENTER][/FONT][/B]' },
  ];

  /* ══════════════════════════════════════════════════════════
     ХРАНИЛИЩЕ — с in-memory кешем поверх GM_getValue.
     GM_getValue/setValue у Tampermonkey — синхронные, но не
     бесплатные (сериализация JSON на каждый вызов). v5 вызывал
     store.get() заново в каждом render*() — на панели с 6 табами
     это 6+ JSON.parse на каждое открытие панели без единой
     причины, потому что данные между открытиями не менялись.
  ══════════════════════════════════════════════════════════ */
  const Store = (() => {
    const cache = new Map();

    function get(key, def) {
      if (cache.has(key)) return cache.get(key);
      let val = def;
      try {
        const raw = GM_getValue(key, null);
        if (raw !== null) val = JSON.parse(raw);
      } catch { /* повреждённые данные — используем default, не падаем */ }
      cache.set(key, val);
      return val;
    }

    function set(key, val) {
      cache.set(key, val);
      GM_setValue(key, JSON.stringify(val));
    }

    return { get, set };
  })();

  let nickname  = Store.get('brp_nick', null);
  let templates = Store.get('brp_tpls', DEFAULT_TEMPLATES);
  let logs      = Store.get('brp_logs', []);
  let users     = Store.get('brp_users', 0);

  function saveTpls() { Store.set('brp_tpls', templates); }
  function saveLogs() { Store.set('brp_logs', logs.slice(-300)); }

  /* ══════════════════════════════════════════════════════════
     СОСТОЯНИЕ UI (единая точка правды)
  ══════════════════════════════════════════════════════════ */
  const UI = {
    panelOpen: false,
    activeTab: 'tpl',
    currentPage: 0,
    unread: 0,
  };

  // Форумный автор темы — пересчитывается только когда URL реально
  // сменился (см. PageWatcher), не на каждый /ping и не на каждый рендер.
  const ForumUser = { name: null, uid: null, url: null };

  function detectForumUserIfStale() {
    if (ForumUser.url === location.href) return; // уже актуально, не трогаем DOM
    ForumUser.url = location.href;
    try {
      const authorLink = document.querySelector('a.username[data-user-id]');
      if (authorLink) {
        ForumUser.name = authorLink.textContent.trim();
        ForumUser.uid  = authorLink.getAttribute('data-user-id') || '';
      }
      if (!nickname) {
        const nameEl = document.querySelector('.p-navgroup-link--user .p-navgroup-linkText');
        if (nameEl) nickname = nameEl.textContent.trim();
      }
    } catch { /* структура страницы форума может отличаться — не критично */ }
  }

  /* ══════════════════════════════════════════════════════════
     УТИЛИТЫ
  ══════════════════════════════════════════════════════════ */
  function greeting() {
    const h = new Date().getHours();
    if (h>=5  && h<12) return 'Доброе утро';
    if (h>=12 && h<17) return 'Добрый день';
    if (h>=17 && h<22) return 'Добрый вечер';
    return 'Доброй ночи';
  }

  function addLog(a, d) {
    const entry = { t: new Date().toLocaleString('ru-RU'), u: nickname||'?', a, d };
    logs.push(entry);
    saveLogs();
    tg(`📋 <b>${a}</b>\n👤 ${nickname||'?'}\n📝 ${d}\n🕐 ${entry.t}`);
  }

  function tg(text, chatId) {
    GM_xmlhttpRequest({
      method: 'POST',
      url: `https://api.telegram.org/bot${TG_TOKEN}/sendMessage`,
      headers: { 'Content-Type': 'application/json' },
      data: JSON.stringify({ chat_id: chatId || TG_CID, text, parse_mode: 'HTML' }),
    });
  }

  let toastEl = null; // переиспользуем один DOM-узел вместо create/remove на каждый вызов
  function toast(msg, ms = 3000) {
    if (!toastEl) {
      toastEl = document.createElement('div');
      toastEl.className = 'brv6-toast';
      document.body.appendChild(toastEl);
    }
    toastEl.textContent = msg;
    toastEl.classList.remove('in');
    // форсируем reflow чтобы анимация перезапустилась даже если toast()
    // вызван повторно до истечения предыдущего таймаута
    void toastEl.offsetWidth;
    toastEl.classList.add('in');
    Timers.after('toast-hide', ms, () => toastEl.classList.remove('in'));
  }

  function applyMacros(text) {
    return text
      .replace(/{{greeting}}/g, greeting())
      .replace(/{{username}}/g, ForumUser.name || 'Игрок')
      .replace(/{{uid}}/g, ForumUser.uid || '0');
  }

  function escapeHtml(s) {
    const div = document.createElement('div');
    div.textContent = s;
    return div.innerHTML;
  }

  /* ══════════════════════════════════════════════════════════
     РЕДАКТОР ФОРУМА
  ══════════════════════════════════════════════════════════ */
  function findEditor() {
    for (const f of document.querySelectorAll('iframe')) {
      try {
        const b = (f.contentDocument || f.contentWindow.document).body;
        if (b?.contentEditable === 'true') return { t:'ce', el:b, doc:f.contentDocument||f.contentWindow.document };
      } catch { /* cross-origin iframe — пропускаем */ }
    }
    const ce = document.querySelector('.fr-element[contenteditable="true"],[contenteditable="true"][role="textbox"]');
    if (ce) return { t:'ce', el:ce, doc:document };
    const ta = document.querySelector('textarea[name="message"],#ctrl_body');
    if (ta) return { t:'ta', el:ta };
    return null;
  }

  function insertText(text) {
    const ed = findEditor();
    if (!ed) { toast('❌ Открой поле ответа на форуме'); return false; }
    if (ed.t === 'ta') {
      ed.el.value = text;
      ed.el.dispatchEvent(new Event('input', { bubbles:true }));
    } else {
      ed.el.focus();
      try { ed.doc.execCommand('selectAll',false,null); ed.doc.execCommand('insertText',false,text); }
      catch { ed.el.innerText = text; }
      ed.el.dispatchEvent(new Event('input', { bubbles:true }));
    }
    return true;
  }

  function applyPrefix(id) {
    if (!id) return;
    const el = document.querySelector('select[name="prefix_id"],#ctrl_prefix_id,select.prefixMenu');
    if (el) { el.value = String(id); el.dispatchEvent(new Event('change', { bubbles:true })); }
  }

  function submitForm() {
    const btn = document.querySelector('button[data-handler="submit"],.js-submit-button,button.button--primary');
    if (btn) { btn.click(); return true; }
    return false;
  }

  function editThread(prefix, sticky = false, open = true) {
    const titleEl = document.querySelector('.p-title-value');
    if (!titleEl) return;
    const title = titleEl.lastChild?.textContent || titleEl.textContent;
    const token = typeof XF !== 'undefined' ? XF.config?.csrf : '';
    const body = new FormData();
    body.append('prefix_id', prefix);
    body.append('title', title);
    if (sticky) body.append('sticky', '1');
    body.append('discussion_open', open ? '1' : '0');
    if (token) {
      body.append('_xfToken', token);
      body.append('_xfRequestUri', location.pathname);
      body.append('_xfWithData', '1');
      body.append('_xfResponseType', 'json');
    }
    fetch(location.href + 'edit', { method:'POST', body }).then(() => location.reload()).catch(() => {});
  }

  function applyTemplate(tpl) {
    const text = applyMacros(tpl.content);
    if (!insertText(text)) return;
    if (tpl.prefix) { applyPrefix(tpl.prefix); editThread(tpl.prefix, tpl.autopin, !tpl.autopin); }
    addLog('Шаблон', `${tpl.name} → ${ForumUser.name||'?'}`);
    toast(`✅ "${tpl.name}" вставлен`);
    Timers.after('submit-after-template', 900, () => {
      if (!submitForm()) toast('⚠️ Нажми "Ответить" вручную');
    });
  }

  /* ══════════════════════════════════════════════════════════
     API СЕРВЕР
  ══════════════════════════════════════════════════════════ */
  function api(path, body, cb) {
    GM_xmlhttpRequest({
      method: 'POST',
      url: SERVER + path,
      headers: { 'Content-Type':'application/json' },
      data: JSON.stringify({ secret: SECRET, ...body }),
      onload: r => { try { cb(null, JSON.parse(r.responseText)); } catch { cb('parse', null); } },
      onerror: () => cb('net', null),
    });
  }

  function sendToSupport(text, cb) {
    api('/send', { nick: nickname, text, topic: document.querySelector('.p-title-value')?.textContent?.trim()||'', url: location.href }, cb);
  }
  function pollMessages(cb) {
    api('/poll', { nick: nickname }, (e, d) => { if (!e && d?.messages?.length) cb(d.messages); });
  }
  function loadHistory(cb) {
    api('/history', { nick: nickname }, (e, d) => cb(e ? [] : (d?.messages||[])));
  }

  /* ══════════════════════════════════════════════════════════
     PageWatcher — единственный источник "URL сменился".
     XenForo умеет частичную (pushState) навигацию без полной
     перезагрузки, поэтому просто слушать 'load' недостаточно —
     но и polling location.href в setInterval каждые 2с (как
     было в v5) означает 30 лишних сравнений строк в минуту
     даже когда человек читает одну и ту же страницу. History API
     даёт события без polling вообще.
  ══════════════════════════════════════════════════════════ */
  const PageWatcher = (() => {
    let lastUrl = location.href;
    const listeners = [];

    function fire() {
      if (location.href === lastUrl) return;
      lastUrl = location.href;
      listeners.forEach(fn => fn());
    }

    function on(fn) { listeners.push(fn); }

    function init() {
      // pushState/replaceState не эмиттят событие сами по себе —
      // патчим один раз, только для детекции, ничего не ломаем в
      // поведении самого History API.
      const origPush = history.pushState;
      const origReplace = history.replaceState;
      history.pushState = function (...args) { origPush.apply(this, args); fire(); };
      history.replaceState = function (...args) { origReplace.apply(this, args); fire(); };
      window.addEventListener('popstate', fire);
    }

    return { on, init };
  })();

  /* ══════════════════════════════════════════════════════════
     Presence/tracking — debounced на PAGE_STATE change, не на
     таймере. /ping всё равно уходит раз в PING_INTERVAL_MS как
     heartbeat, но при быстрой смене страниц (человек кликает по
     разделам форума быстро) не шлём отдельный /ping на КАЖДЫЙ
     клик — debounce схлопывает пачку в один вызов.
  ══════════════════════════════════════════════════════════ */
  function getPageInfo() {
    const topic = document.querySelector('.p-title-value')?.textContent?.trim() || '';
    const crumbs = document.querySelectorAll('.p-breadcrumbs a,.breadBoxMinor a');
    const section = crumbs.length ? crumbs[crumbs.length-2]?.textContent?.trim() : '';
    return { url: location.href, topic, section };
  }

  function updateTrackerUI() {
    const el = document.getElementById('brv6-tracker-text');
    if (!el) return; // панель может быть ещё не построена — не ошибка
    const i = getPageInfo();
    const parts = [];
    if (i.section) parts.push(`📂 ${escapeHtml(i.section)}`);
    if (i.topic)   parts.push(`📌 ${escapeHtml(i.topic.substring(0,35))}`);
    el.innerHTML = parts.length ? parts.map(p => `<span>${p}</span>`).join(' · ') : '📍 Форум';
  }

  function sendPing() {
    detectForumUserIfStale();
    const info = getPageInfo();
    api('/ping', { nick: nickname, ...info, author: ForumUser.name }, () => {});
  }

  function onPageChanged() {
    detectForumUserIfStale();
    updateTrackerUI();
    Timers.after('ping-debounced', PRESENCE_DEBOUNCE_MS, sendPing);
  }

  function startTracking() {
    detectForumUserIfStale();
    updateTrackerUI();

    PageWatcher.on(onPageChanged);
    PageWatcher.init();

    sendPing(); // первый ping сразу, не ждём первого интервала
    Timers.every('ping-heartbeat', PING_INTERVAL_MS, sendPing);

    Timers.every('poll-support', POLL_INTERVAL_MS, () => {
      pollMessages(msgs => {
        if (!msgs.length) return;
        UI.unread += msgs.length;
        setBadge(UI.unread);
        msgs.forEach(m => toast(`💬 ${m.from||'Поддержка'}: ${m.text.substring(0,40)}`, 5000));
        if (UI.panelOpen && UI.activeTab === 'chat') renderTab('chat');
      });
    });
  }

  /* ══════════════════════════════════════════════════════════
     CSS — не менялся структурно относительно v5 (визуал тот же
     по запросу пользователя), только префикс классов brv6-.
  ══════════════════════════════════════════════════════════ */
  const CSS = `
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=Rajdhani:wght@500;700&display=swap');
:root{
  --bg:#080510;--card:rgba(16,10,28,.96);--pri:#8a2be2;--pril:#b066ff;--prid:#5f27cd;
  --txt:#fff;--mut:#777;--inp:rgba(255,255,255,.07);--brd:rgba(138,43,226,.2);
  --grn:#2ed47a;--red:#ff4f4f;--r:14px;--sh:0 20px 60px rgba(0,0,0,.8);
}
.brv6-toast{position:fixed;bottom:90px;left:50%;transform:translateX(-50%) translateY(10px);background:var(--card);border:1px solid var(--brd);border-radius:30px;padding:10px 22px;font:13px 'Inter',sans-serif;color:var(--txt);z-index:9999999;opacity:0;transition:.35s;pointer-events:none;white-space:nowrap;backdrop-filter:blur(14px);box-shadow:var(--sh);max-width:80vw;overflow:hidden;text-overflow:ellipsis}
.brv6-toast.in{opacity:1;transform:translateX(-50%) translateY(0)}

#brv6-login{position:fixed;inset:0;z-index:999998;background:var(--bg);display:flex;align-items:center;justify-content:center;overflow:hidden}
#brv6-canvas{position:absolute;inset:0;pointer-events:none}
.brv6-login-bg{position:absolute;inset:0;background:radial-gradient(circle at 70% 20%,rgba(138,43,226,.35),transparent 55%),radial-gradient(circle at 20% 80%,rgba(45,13,80,.6),transparent 50%)}
.brv6-auth{position:relative;z-index:2;background:var(--card);width:min(400px,92vw);padding:46px 34px;border-radius:22px;border:1px solid var(--brd);backdrop-filter:blur(20px);text-align:center;animation:brv6Pop .5s cubic-bezier(.34,1.56,.64,1);box-shadow:0 0 80px rgba(138,43,226,.15),var(--sh)}
.brv6-logo{font:700 2.2rem 'Rajdhani',sans-serif;color:var(--txt);letter-spacing:3px;margin-bottom:6px}
.brv6-logo span{color:var(--pril)}
.brv6-sub{color:var(--mut);font-size:.82rem;margin-bottom:28px;line-height:1.5}
.brv6-field{position:relative;margin-bottom:14px;text-align:left}
.brv6-field i{position:absolute;left:14px;top:50%;transform:translateY(-50%);color:var(--pril);font-size:1rem;pointer-events:none}
.brv6-field input{width:100%;box-sizing:border-box;padding:13px 14px 13px 44px;background:var(--inp);border:1px solid rgba(255,255,255,.1);border-radius:10px;color:var(--txt);font-size:.9rem;font-family:'Inter',sans-serif;outline:none;transition:.3s}
.brv6-field input:focus{border-color:var(--pri);background:rgba(138,43,226,.12)}
.brv6-detect{font-size:11px;color:var(--grn);margin-bottom:12px;min-height:16px}
.brv6-btn{width:100%;padding:14px;border:none;border-radius:11px;background:linear-gradient(90deg,var(--pri),var(--prid));color:#fff;font-weight:700;font-size:1rem;font-family:'Inter',sans-serif;cursor:pointer;transition:.3s;letter-spacing:.5px}
.brv6-btn:hover{filter:brightness(1.2);box-shadow:0 10px 28px rgba(138,43,226,.4)}
.brv6-foot{margin-top:18px;color:var(--mut);font-size:.8rem}
.brv6-foot a{color:var(--txt);font-weight:600;text-decoration:none}

#brv6-fab{position:fixed;bottom:22px;right:22px;z-index:999996;width:52px;height:52px;border-radius:50%;background:linear-gradient(135deg,var(--pri),var(--prid));border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:0 4px 24px rgba(138,43,226,.5);transition:.2s}
#brv6-fab:hover{transform:scale(1.12)}
.brv6-fab-badge{position:absolute;top:-4px;right:-4px;background:var(--red);color:#fff;border-radius:50%;width:18px;height:18px;font-size:10px;font-weight:700;display:none;align-items:center;justify-content:center;font-family:'Inter',sans-serif}
.brv6-fab-badge.show{display:flex}

#brv6-panel{position:fixed;bottom:84px;right:22px;z-index:999995;width:min(500px,96vw);max-height:86vh;background:var(--card);border:1px solid var(--brd);border-radius:var(--r);box-shadow:var(--sh);backdrop-filter:blur(20px);font-family:'Inter',sans-serif;color:var(--txt);display:none;flex-direction:column;overflow:hidden}
#brv6-panel.open{display:flex;animation:brv6Slide .3s cubic-bezier(.34,1.3,.64,1)}

.brv6-hdr{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;flex-shrink:0;background:linear-gradient(90deg,rgba(40,10,70,.9),rgba(16,10,28,.9));border-bottom:1px solid var(--brd)}
.brv6-hdr-l{display:flex;align-items:center;gap:8px}
.brv6-logo-sm{font:700 16px 'Rajdhani',sans-serif;color:var(--pril);letter-spacing:2px}
.brv6-nick-sm{font-size:11px;color:var(--mut)}
.brv6-dot{width:7px;height:7px;border-radius:50%;background:var(--grn);box-shadow:0 0 6px var(--grn)}
.brv6-x{background:none;border:none;color:var(--mut);font-size:18px;cursor:pointer;transition:.2s;padding:0;line-height:1}
.brv6-x:hover{color:var(--red)}

.brv6-tracker{padding:5px 16px;font-size:10px;color:var(--mut);background:rgba(0,0,0,.3);border-bottom:1px solid var(--brd);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex-shrink:0}
.brv6-tracker span{color:var(--pril)}

.brv6-pages-wrap{position:relative;flex:1;overflow:hidden;display:flex;flex-direction:column}
.brv6-page-dots{display:flex;justify-content:center;gap:5px;padding:5px;flex-shrink:0;background:rgba(0,0,0,.2)}
.brv6-dot-ind{width:6px;height:6px;border-radius:50%;background:var(--brd);transition:.2s;cursor:pointer}
.brv6-dot-ind.on{background:var(--pril);width:18px;border-radius:3px}
.brv6-pages{display:flex;flex:1;transition:transform .35s cubic-bezier(.4,0,.2,1);will-change:transform}
.brv6-page{flex:0 0 100%;display:flex;flex-direction:column;overflow:hidden}

.brv6-nav{display:flex;padding:4px 6px;gap:2px;flex-shrink:0;background:rgba(8,5,16,.9);border-bottom:1px solid var(--brd)}
.brv6-tab{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;padding:7px 2px;background:none;border:none;cursor:pointer;font-size:10px;color:var(--mut);font-family:'Inter',sans-serif;border-radius:8px;transition:.2s;position:relative}
.brv6-tab i{font-size:13px;transition:transform .2s;pointer-events:none}
.brv6-tab:hover{color:var(--pril);background:rgba(138,43,226,.1)}
.brv6-tab.on{color:var(--pril)}
.brv6-tab.on::after{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:16px;height:2px;background:var(--pril);border-radius:2px}
.brv6-tab-b{position:absolute;top:2px;right:4px;background:var(--red);color:#fff;border-radius:50%;width:13px;height:13px;font-size:9px;display:none;align-items:center;justify-content:center;pointer-events:none}
.brv6-tab-b.show{display:flex}

.brv6-body{flex:1;overflow-y:auto;padding:13px}
.brv6-body::-webkit-scrollbar{width:3px}
.brv6-body::-webkit-scrollbar-thumb{background:var(--brd);border-radius:2px}
.brv6-sec{font:700 10px 'Rajdhani',sans-serif;color:var(--mut);text-transform:uppercase;letter-spacing:1.5px;margin:0 0 10px}
.brv6-hr{border:none;border-top:1px solid var(--brd);margin:12px 0}

.brv6-tpl{background:rgba(138,43,226,.07);border:1px solid var(--brd);border-radius:10px;padding:12px 14px;margin-bottom:8px;transition:.2s}
.brv6-tpl:hover{border-color:var(--pril);background:rgba(138,43,226,.13)}
.brv6-tpl-top{display:flex;align-items:center;gap:6px;margin-bottom:3px;flex-wrap:wrap}
.brv6-tpl-name{font-size:13px;font-weight:700}
.brv6-bdg{font-size:9px;padding:2px 7px;border-radius:20px;background:rgba(255,255,255,.07);font-weight:700}
.brv6-prev{font-size:11px;color:var(--mut);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:9px}
.brv6-btns{display:flex;gap:5px;flex-wrap:wrap}

.brv6-msgs{overflow-y:auto;padding:6px 0;display:flex;flex-direction:column;gap:7px;max-height:280px;min-height:150px}
.brv6-msgs::-webkit-scrollbar{width:3px}
.brv6-msgs::-webkit-scrollbar-thumb{background:var(--brd)}
.brv6-msg{max-width:88%;padding:9px 13px;border-radius:12px;font-size:13px;line-height:1.4}
.brv6-msg.user{align-self:flex-end;background:rgba(138,43,226,.25);border:1px solid rgba(138,43,226,.4);border-bottom-right-radius:3px}
.brv6-msg.support{align-self:flex-start;background:rgba(46,212,122,.12);border:1px solid rgba(46,212,122,.3);border-bottom-left-radius:3px}
.brv6-msg-from{font-size:10px;color:var(--grn);font-weight:700;margin-bottom:2px}
.brv6-msg-time{font-size:9px;color:var(--mut);margin-top:3px;text-align:right}
.brv6-chat-hint{font-size:11px;color:var(--mut);margin-bottom:8px;text-align:center;padding:7px;background:rgba(138,43,226,.06);border-radius:8px}
.brv6-chat-row{display:flex;gap:6px;margin-top:8px;align-items:flex-end}
.brv6-chat-ta{flex:1;background:var(--inp);border:1px solid var(--brd);border-radius:10px;padding:9px 12px;color:var(--txt);font-size:13px;font-family:'Inter',sans-serif;outline:none;resize:none;min-height:38px;max-height:90px;transition:.2s}
.brv6-chat-ta:focus{border-color:var(--pri)}
.brv6-chat-send{background:linear-gradient(135deg,var(--pri),var(--prid));border:none;border-radius:10px;padding:9px 13px;color:#fff;font-size:14px;cursor:pointer;flex-shrink:0;transition:.2s}

.brv6-fg{margin-bottom:11px}
.brv6-fg label{display:block;font-size:11px;color:var(--mut);margin-bottom:3px}
.brv6-inp,.brv6-ta,.brv6-sel{width:100%;box-sizing:border-box;background:var(--inp);border:1px solid rgba(255,255,255,.1);border-radius:9px;padding:9px 13px;color:var(--txt);font-size:13px;font-family:'Inter',sans-serif;outline:none;transition:.25s}
.brv6-inp:focus,.brv6-ta:focus,.brv6-sel:focus{border-color:var(--pri);background:rgba(138,43,226,.1)}
.brv6-ta{min-height:80px;resize:vertical}
.brv6-sel option{background:#120c20}

.btn6{background:linear-gradient(90deg,var(--pri),var(--prid));border:none;border-radius:8px;padding:8px 14px;color:#fff;font-size:12px;font-weight:700;font-family:'Inter',sans-serif;cursor:pointer;transition:.2s}
.btn6:hover{filter:brightness(1.2)}.btn6:active{transform:scale(.97)}
.btn6g{background:transparent;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 14px;color:var(--txt);font-size:12px;font-family:'Inter',sans-serif;cursor:pointer;transition:.2s}
.btn6g:hover{border-color:var(--pril);color:var(--pril)}
.btn6r{background:rgba(255,79,79,.1);border:1px solid rgba(255,79,79,.3);border-radius:8px;padding:8px 14px;color:var(--red);font-size:12px;font-family:'Inter',sans-serif;cursor:pointer;transition:.2s}
.btn6r:hover{background:rgba(255,79,79,.25)}
.btn6grn{background:rgba(46,212,122,.12);border:1px solid rgba(46,212,122,.3);border-radius:8px;padding:8px 14px;color:var(--grn);font-size:12px;font-family:'Inter',sans-serif;cursor:pointer;transition:.2s}
.btn6grn:hover{background:rgba(46,212,122,.28)}

.brv6-color-row{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px}
.brv6-color-btn{width:24px;height:24px;border-radius:50%;border:2px solid transparent;cursor:pointer;transition:.2s;flex-shrink:0}
.brv6-color-btn:hover,.brv6-color-btn.on{border-color:#fff;transform:scale(1.2)}
.brv6-chip{background:rgba(138,43,226,.1);border:1px solid var(--brd);border-radius:20px;padding:4px 10px;font-size:11px;color:var(--mut);cursor:pointer;font-family:'Inter',sans-serif;transition:.2s}
.brv6-chip:hover{border-color:var(--pril);color:var(--pril)}

.brv6-upload-zone{border:2px dashed var(--brd);border-radius:12px;padding:24px;text-align:center;cursor:pointer;transition:.2s;margin-bottom:10px}
.brv6-upload-zone:hover{border-color:var(--pril)}
.brv6-upload-zone.drag{border-color:var(--pril);background:rgba(138,43,226,.08)}
.brv6-upload-ico{font-size:32px;margin-bottom:6px}
.brv6-upload-hint{font-size:12px;color:var(--mut)}
.brv6-photo-result{background:rgba(46,212,122,.08);border:1px solid rgba(46,212,122,.2);border-radius:9px;padding:10px;margin-top:8px;display:none}
.brv6-photo-url{font-size:11px;word-break:break-all;color:var(--grn);margin-bottom:6px}

#brv6-snake-canvas{border-radius:8px;display:block;margin:0 auto;touch-action:none}
.brv6-snake-controls{display:flex;gap:8px;margin-top:8px;flex-direction:column;align-items:center}
.brv6-dpad{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;width:120px}
.brv6-dpad button{background:rgba(138,43,226,.15);border:1px solid var(--brd);border-radius:7px;padding:10px;color:var(--txt);font-size:14px;cursor:pointer;transition:.2s}
.brv6-dpad button:hover{background:rgba(138,43,226,.35)}
.brv6-snake-score{font:700 14px 'Rajdhani',sans-serif;color:var(--pril);text-align:center;margin-bottom:6px}

.brv6-log{font-size:11px;padding:7px 10px;border-left:2px solid var(--brd);margin-bottom:5px;color:var(--mut);line-height:1.5}
.brv6-log b{color:var(--txt)}
.brv6-log-t{font-size:10px;float:right}

.brv6-con{display:flex;align-items:center;gap:12px;background:rgba(138,43,226,.07);border:1px solid var(--brd);border-radius:10px;padding:12px 14px;margin-bottom:8px;text-decoration:none;transition:.2s}
.brv6-con:hover{border-color:var(--pril);background:rgba(138,43,226,.15)}
.brv6-con-i{font-size:20px}.brv6-con-n{font-size:13px;font-weight:700;color:var(--txt)}.brv6-con-s{font-size:11px;color:var(--mut)}

.brv6-game-card{background:rgba(138,43,226,.1);border:2px solid var(--brd);border-radius:14px;padding:20px;text-align:center;margin-bottom:12px;transition:.2s}
.brv6-game-card:hover{border-color:var(--pril)}
.brv6-game-ico{font-size:40px;margin-bottom:8px}
.brv6-game-title{font:700 16px 'Rajdhani',sans-serif;color:var(--pril);margin-bottom:4px;letter-spacing:1px}
.brv6-game-sub{font-size:11px;color:var(--mut);margin-bottom:14px}

@keyframes brv6Pop{from{transform:scale(.8) translateY(20px);opacity:0}to{transform:scale(1);opacity:1}}
@keyframes brv6Slide{from{transform:translateY(16px) scale(.97);opacity:0}to{transform:translateY(0) scale(1);opacity:1}}

@media(max-width:540px){
  #brv6-panel{right:5px;left:5px;width:auto;bottom:72px}
  #brv6-fab{bottom:14px;right:14px;width:46px;height:46px;font-size:18px}
  .brv6-tab{font-size:9px}.brv6-auth{padding:32px 18px}
}
  `;

  /* ══════════════════════════════════════════════════════════
     ИНИЦИАЛИЗАЦИЯ СТИЛЕЙ (один раз)
  ══════════════════════════════════════════════════════════ */
  function injectStyles() {
    document.head.appendChild(Object.assign(document.createElement('style'), { textContent: CSS }));
    if (!document.querySelector('link[href*="font-awesome"]')) {
      document.head.appendChild(Object.assign(document.createElement('link'), {
        rel: 'stylesheet',
        href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css',
      }));
    }
  }

  /* ══════════════════════════════════════════════════════════
     ЛОГИН
  ══════════════════════════════════════════════════════════ */
  function buildLogin() {
    detectForumUserIfStale();

    const wrap = document.createElement('div');
    wrap.id = 'brv6-login';
    wrap.innerHTML = `
      <canvas id="brv6-canvas"></canvas>
      <div class="brv6-login-bg"></div>
      <div class="brv6-auth">
        <div class="brv6-logo">BR <span>ADMIN</span></div>
        <p class="brv6-sub">Панель куратора форума Black Russia</p>
        <div class="brv6-detect">${nickname ? '✅ Ник определён: ' + escapeHtml(nickname) : '🔍 Введите ваш ник'}</div>
        <div class="brv6-field">
          <i class="fa-solid fa-user"></i>
          <input type="text" id="brv6-nick-i" value="${nickname ? escapeHtml(nickname) : ''}" placeholder="Ваш никнейм" autocomplete="off" />
        </div>
        <button class="brv6-btn" id="brv6-go">Войти в панель</button>
        <div class="brv6-foot">Разработчик: <a href="https://vk.ru/brunoverona" target="_blank">vk.ru/brunoverona</a></div>
      </div>
    `;
    document.body.appendChild(wrap);

    // Частицы — простая canvas-анимация. Останавливается явно при
    // уходе с экрана логина через cancelAnimationFrame, не оставляет
    // requestAnimationFrame-цикл висеть после того как wrap.remove().
    const cv = document.getElementById('brv6-canvas'), ctx = cv.getContext('2d');
    cv.width = innerWidth; cv.height = innerHeight;
    const pts = Array.from({length:60}, () => ({
      x: Math.random()*innerWidth, y: Math.random()*innerHeight,
      r: Math.random()*1.8+.3, vx:(Math.random()-.5)*.4, vy:(Math.random()-.5)*.4,
    }));
    let rafId = null;
    function anim() {
      ctx.clearRect(0,0,cv.width,cv.height);
      for (const p of pts) {
        p.x+=p.vx; p.y+=p.vy;
        if (p.x<0) p.x=cv.width; if (p.x>cv.width) p.x=0;
        if (p.y<0) p.y=cv.height; if (p.y>cv.height) p.y=0;
        ctx.fillStyle = 'rgba(176,102,255,.28)';
        ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,Math.PI*2); ctx.fill();
      }
      rafId = requestAnimationFrame(anim);
    }
    rafId = requestAnimationFrame(anim);

    const go = () => {
      const v = document.getElementById('brv6-nick-i').value.trim();
      if (!v) return;
      nickname = v;
      Store.set('brp_nick', v);
      users++; Store.set('brp_users', users);
      addLog('Вход', v);
      cancelAnimationFrame(rafId);
      wrap.style.transition = 'opacity .5s';
      wrap.style.opacity = '0';
      Timers.after('login-transition', 500, () => {
        wrap.remove();
        buildFab();
        buildPanel();
        startTracking();
      });
    };
    document.getElementById('brv6-go').onclick = go;
    document.getElementById('brv6-nick-i').onkeydown = e => { if (e.key === 'Enter') go(); };
  }

  /* ══════════════════════════════════════════════════════════
     FAB
  ══════════════════════════════════════════════════════════ */
  function buildFab() {
    const fab = document.createElement('button');
    fab.id = 'brv6-fab';
    fab.innerHTML = '<span>⚙️</span><span class="brv6-fab-badge" id="brv6-fbadge">0</span>';
    fab.onclick = () => {
      UI.panelOpen = !UI.panelOpen;
      document.getElementById('brv6-panel').classList.toggle('open', UI.panelOpen);
      if (UI.panelOpen) renderTab(UI.activeTab);
    };
    document.body.appendChild(fab);
  }

  function setBadge(n) {
    UI.unread = n;
    const b = document.getElementById('brv6-fbadge'), tb = document.getElementById('brv6-tbadge');
    if (b) { b.textContent = n; b.classList.toggle('show', n>0); }
    if (tb) { tb.textContent = n; tb.classList.toggle('show', n>0); }
  }

  /* ══════════════════════════════════════════════════════════
     ПАНЕЛЬ — делегирование событий на уровне контейнера.
     Один addEventListener('click', ...) на #brv6-panel вместо
     навешивания обработчика на каждую кнопку при каждом рендере.
     Кнопки помечаются data-action="имя_действия" data-arg="...",
     единый роутер решает что делать.
  ══════════════════════════════════════════════════════════ */
  const TABS = [
    { id:'tpl',  icon:'fa-list',        label:'Шаблоны' },
    { id:'chat', icon:'fa-comments',    label:'Поддержка' },
    { id:'log',  icon:'fa-chart-bar',   label:'Логи' },
    { id:'con',  icon:'fa-paper-plane', label:'Связь' },
    { id:'game', icon:'fa-gamepad',     label:'Игры' },
  ];

  function buildPanel() {
    const p = document.createElement('div');
    p.id = 'brv6-panel';
    p.innerHTML = `
      <div class="brv6-hdr">
        <div class="brv6-hdr-l">
          <div class="brv6-dot"></div>
          <span class="brv6-logo-sm">BR ADMIN</span>
          <span class="brv6-nick-sm">👤 ${escapeHtml(nickname)}</span>
        </div>
        <button class="brv6-x" data-action="close-panel">✕</button>
      </div>
      <div class="brv6-tracker"><span id="brv6-tracker-text">📍 Загрузка...</span></div>
      <nav class="brv6-nav">
        ${TABS.map(t => `<button class="brv6-tab${t.id===UI.activeTab?' on':''}" data-action="switch-tab" data-arg="${t.id}"><i class="fa-solid ${t.icon}"></i>${t.label}${t.id==='chat'?'<span class="brv6-tab-b" id="brv6-tbadge"></span>':''}</button>`).join('')}
      </nav>
      <div class="brv6-pages-wrap">
        <div class="brv6-page-dots">
          <div class="brv6-dot-ind on" data-action="goto-page" data-arg="0"></div>
          <div class="brv6-dot-ind" data-action="goto-page" data-arg="1"></div>
        </div>
        <div class="brv6-pages" id="brv6-pages">
          <div class="brv6-page"><div class="brv6-body" id="brv6-body"></div></div>
          <div class="brv6-page"><div class="brv6-body" id="brv6-body2"></div></div>
        </div>
      </div>
    `;
    document.body.appendChild(p);

    // ── Единый обработчик кликов ──
    p.addEventListener('click', onPanelClick);

    // Свайп между страницами
    let swipeStartX = 0, swiping = false;
    const pages = document.getElementById('brv6-pages');
    pages.addEventListener('touchstart', e => { swipeStartX = e.touches[0].clientX; swiping = true; }, { passive:true });
    pages.addEventListener('touchend', e => {
      if (!swiping) return; swiping = false;
      const dx = swipeStartX - e.changedTouches[0].clientX;
      if (Math.abs(dx) > 40) gotoPage(dx > 0 ? 1 : 0);
    }, { passive:true });

    renderTab(UI.activeTab);
    renderEditorPage();
    updateTrackerUI();
  }

  function gotoPage(n) {
    UI.currentPage = n;
    const pages = document.getElementById('brv6-pages');
    if (pages) pages.style.transform = `translateX(-${n*100}%)`;
    document.querySelectorAll('.brv6-dot-ind').forEach((d,i) => d.classList.toggle('on', i===n));
  }

  function switchTab(tab) {
    UI.activeTab = tab;
    document.querySelectorAll('.brv6-tab').forEach(b => b.classList.toggle('on', b.dataset.arg === tab));
    if (tab === 'chat') setBadge(0);
    // Уходим со страницы игры — обязательно уничтожаем таймер змейки,
    // иначе он продолжит тикать по несуществующему canvas.
    SnakeGame.destroy();
    gotoPage(0);
    renderTab(tab);
  }

  /* ── Роутер кликов по data-action ── */
  function onPanelClick(e) {
    const el = e.target.closest('[data-action]');
    if (!el) return;
    const action = el.dataset.action;
    const arg = el.dataset.arg;

    switch (action) {
      case 'close-panel':
        UI.panelOpen = false;
        document.getElementById('brv6-panel').classList.remove('open');
        SnakeGame.destroy();
        break;
      case 'switch-tab':
        switchTab(arg);
        break;
      case 'goto-page':
        gotoPage(parseInt(arg));
        break;
      case 'apply-template': {
        const t = templates.find(x => x.id == arg);
        if (t) applyTemplate(t);
        break;
      }
      case 'copy-template': {
        const t = templates.find(x => x.id == arg);
        if (!t) break;
        navigator.clipboard.writeText(applyMacros(t.content)).catch(() => {});
        addLog('Копирование', t.name);
        el.textContent = '✅';
        Timers.after('copy-label-reset', 1500, () => { el.textContent = '📋 Копировать'; });
        break;
      }
      case 'delete-template': {
        const t = templates.find(x => x.id == arg);
        if (!t || !confirm(`Удалить "${t.name}"?`)) break;
        addLog('Удалён шаблон', t.name);
        templates = templates.filter(x => x.id != arg);
        saveTpls();
        renderTemplatesTab(document.getElementById('brv6-body'));
        break;
      }
      case 'send-chat': sendChatMessage(); break;
      case 'clear-logs':
        if (!confirm('Очистить логи?')) break;
        logs = []; saveLogs();
        renderLogsTab(document.getElementById('brv6-body'));
        break;
      case 'save-template': saveNewTemplate(); break;
      case 'insert-bb': insertBBChip(arg); break;
      case 'pick-color': pickColor(el, arg); break;
      case 'launch-game': launchBlackRussia(); break;
      case 'open-snake': SnakeGame.start(document.getElementById('brv6-body')); break;
      case 'snake-back':
        SnakeGame.destroy();
        renderGameTab(document.getElementById('brv6-body'));
        break;
      case 'snake-dir': SnakeGame.setDirection(arg); break;
    }
  }

  /* ══════════════════════════════════════════════════════════
     РЕНДЕР ТАБОВ — каждая функция строит innerHTML один раз,
     без вложенных querySelectorAll(...).forEach для навешивания
     обработчиков (это делает делегирование выше).
  ══════════════════════════════════════════════════════════ */
  function renderTab(tab) {
    const body = document.getElementById('brv6-body');
    if (!body) return;
    const renderers = {
      tpl: renderTemplatesTab, chat: renderChatTab, log: renderLogsTab,
      con: renderContactTab, game: renderGameTab,
    };
    (renderers[tab] || renderTemplatesTab)(body);
  }

  function renderTemplatesTab(body) {
    const authorName = ForumUser.name ? escapeHtml(ForumUser.name) : 'не определён';
    const list = templates.length
      ? templates.map(tpl => {
          const p = prefixById.get(tpl.prefix) || prefixById.get(0);
          return `
            <div class="brv6-tpl">
              <div class="brv6-tpl-top">
                <span class="brv6-tpl-name">${escapeHtml(tpl.name)}</span>
                <span class="brv6-bdg" style="color:${p.color}">${p.name}</span>
                ${tpl.autopin ? '<span style="color:#f0c040;font-size:10px">📌</span>' : ''}
              </div>
              <div class="brv6-prev">${escapeHtml(tpl.content.replace(/\[.*?\]/g,'').trim().substring(0,80))}…</div>
              <div class="brv6-btns">
                <button class="btn6" data-action="apply-template" data-arg="${tpl.id}">🚀 Применить</button>
                <button class="btn6g" data-action="copy-template" data-arg="${tpl.id}">📋 Копировать</button>
                <button class="btn6r" data-action="delete-template" data-arg="${tpl.id}">🗑</button>
              </div>
            </div>`;
        }).join('')
      : '<p style="color:var(--mut);font-size:12px">Нет шаблонов. Добавьте на второй странице (свайп ←)</p>';

    body.innerHTML = `
      <p class="brv6-sec">Шаблоны — клик применит и отправит</p>
      <div style="font-size:11px;color:var(--mut);margin-bottom:10px">👤 Автор темы: <b style="color:var(--pril)">${authorName}</b></div>
      <div>${list}</div>
    `;
  }

  function renderChatTab(body) {
    body.innerHTML = `
      <p class="brv6-sec">Поддержка — чат с разработчиком</p>
      <div class="brv6-chat-hint">💬 Напишите вопрос — разработчик или поддержка ответят здесь</div>
      <div class="brv6-msgs" id="brv6-msgs"><div style="text-align:center;color:var(--mut);font-size:12px;padding:16px">Загрузка...</div></div>
      <div class="brv6-chat-row">
        <textarea class="brv6-chat-ta" id="brv6-cinp" placeholder="Ваш вопрос или жалоба..."></textarea>
        <button class="brv6-chat-send" data-action="send-chat"><i class="fa-solid fa-paper-plane"></i></button>
      </div>
    `;
    document.getElementById('brv6-cinp').onkeydown = e => {
      if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
    };
    loadHistory(drawMessages);
  }

  function drawMessages(msgs) {
    const el = document.getElementById('brv6-msgs');
    if (!el) return;
    if (!msgs.length) {
      el.innerHTML = '<div style="text-align:center;color:var(--mut);font-size:12px;padding:16px">Сообщений нет. Напишите первым!</div>';
      return;
    }
    el.innerHTML = msgs.map(m => `
      <div class="brv6-msg ${m.role==='support'?'support':'user'}">
        ${m.role==='support' ? `<div class="brv6-msg-from">🛡 ${escapeHtml(m.from||'Поддержка')}</div>` : ''}
        ${escapeHtml(m.text)}
        <div class="brv6-msg-time">${m.time||''}</div>
      </div>`).join('');
    el.scrollTop = el.scrollHeight;
  }

  function sendChatMessage() {
    const inp = document.getElementById('brv6-cinp');
    if (!inp) return;
    const text = inp.value.trim();
    if (!text) return;
    inp.value = '';
    inp.disabled = true;
    sendToSupport(text, (e, d) => {
      inp.disabled = false;
      if (e || !d?.ok) { toast('❌ Сервер недоступен'); return; }
      addLog('Сообщение в поддержку', text.substring(0,40));
      loadHistory(drawMessages);
      toast('✅ Отправлено');
    });
  }

  function renderLogsTab(body) {
    const rows = logs.length
      ? [...logs].reverse().map(l => `<div class="brv6-log"><span class="brv6-log-t">${l.t}</span><b>${escapeHtml(l.a)}</b><br>${escapeHtml(l.u)} — ${escapeHtml(l.d)}</div>`).join('')
      : '<p style="color:var(--mut);font-size:12px">Логов нет.</p>';
    body.innerHTML = `
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
        <p class="brv6-sec" style="margin:0">История (${logs.length})</p>
        <button class="btn6r" data-action="clear-logs" style="padding:5px 10px;font-size:11px">🗑</button>
      </div>
      <div>${rows}</div>
    `;
  }

  function renderContactTab(body) {
    body.innerHTML = `
      <p class="brv6-sec">Связь с разработчиком</p>
      <a class="brv6-con" href="https://vk.ru/brunoverona" target="_blank"><span class="brv6-con-i">💙</span><div><div class="brv6-con-n">ВКонтакте разработчика</div><div class="brv6-con-s">vk.ru/brunoverona</div></div></a>
      <a class="brv6-con" href="https://vk.ru/club237051164" target="_blank"><span class="brv6-con-i">🏢</span><div><div class="brv6-con-n">Группа разработчиков</div><div class="brv6-con-s">vk.ru/club237051164</div></div></a>
      <div style="padding:12px;background:rgba(138,43,226,.07);border:1px solid var(--brd);border-radius:10px;margin-top:12px">
        <p style="font-size:11px;color:var(--mut);margin:0;line-height:1.7"><b style="color:var(--pril)">BR Admin Panel Pro v6.0</b><br>Оптимизировано: единый таймер-менеджер, делегирование событий</p>
      </div>
    `;
  }

  function renderGameTab(body) {
    body.innerHTML = `
      <p class="brv6-sec">Запуск и игры</p>
      <div class="brv6-game-card">
        <div class="brv6-game-ico">🎮</div>
        <div class="brv6-game-title">BLACK RUSSIA</div>
        <div class="brv6-game-sub">Нажми чтобы запустить игру<br>(требуется установленный лаунчер)</div>
        <button class="btn6" data-action="launch-game">🚀 Запустить Black Russia</button>
      </div>
      <div class="brv6-game-card">
        <div class="brv6-game-ico">🐍</div>
        <div class="brv6-game-title">ЗМЕЙКА</div>
        <div class="brv6-game-sub">Мини-игра для отдыха</div>
        <button class="btn6g" data-action="open-snake">🎮 Играть в змейку</button>
      </div>
    `;
  }

  function launchBlackRussia() {
    toast('🚀 Запускаем Black Russia...');
    window.location.href = CR_LINK;
  }

  /* ══════════════════════════════════════════════════════════
     ЗМЕЙКА — инкапсулирована в объект с явным lifecycle.
     .start()/.destroy() — единственные точки входа. Таймер
     хранится как свойство объекта, не переменная модуля, так
     что повторный .start() без предварительного .destroy()
     физически не может создать второй параллельный тик — start()
     сам вызывает destroy() первым делом.
  ══════════════════════════════════════════════════════════ */
  const SnakeGame = (() => {
    let intervalId = null;
    let keyHandler = null;
    let currentSetDir = () => {}; // объявлена до start(), а не после — иначе TDZ на use strict

    function destroy() {
      if (intervalId !== null) { clearInterval(intervalId); intervalId = null; }
      if (keyHandler) { document.removeEventListener('keydown', keyHandler); keyHandler = null; }
      currentSetDir = () => {};
    }

    function start(body) {
      destroy(); // страховка: если start() вызван без предыдущего destroy()

      body.innerHTML = `
        <p class="brv6-sec">🐍 Змейка</p>
        <div class="brv6-snake-score" id="sn-score">Счёт: 0</div>
        <canvas id="brv6-snake-canvas" width="240" height="240"></canvas>
        <div class="brv6-snake-controls">
          <div class="brv6-dpad">
            <div></div><button data-action="snake-dir" data-arg="up">▲</button><div></div>
            <button data-action="snake-dir" data-arg="left">◀</button>
            <button data-action="snake-dir" data-arg="down">▼</button>
            <button data-action="snake-dir" data-arg="right">▶</button>
          </div>
          <button class="btn6g" data-action="snake-back" style="margin-top:8px">← Назад</button>
        </div>
      `;

      const cv = document.getElementById('brv6-snake-canvas');
      const ctx = cv.getContext('2d');
      const SZ = 16, W = 15, H = 15;
      let snake = [{x:7,y:7}], dir = {x:1,y:0}, food = randFood(), score = 0, alive = true;

      function randFood() { return { x: Math.floor(Math.random()*W), y: Math.floor(Math.random()*H) }; }

      function draw() {
        ctx.fillStyle = '#0a0614'; ctx.fillRect(0,0,240,240);
        ctx.fillStyle = '#ff4f4f';
        ctx.beginPath(); ctx.arc(food.x*SZ+8, food.y*SZ+8, 6, 0, Math.PI*2); ctx.fill();
        snake.forEach((s,i) => {
          ctx.fillStyle = i===0 ? '#b066ff' : '#7a35c0';
          ctx.fillRect(s.x*SZ+1, s.y*SZ+1, SZ-2, SZ-2);
        });
        if (!alive) {
          ctx.fillStyle = 'rgba(0,0,0,.6)'; ctx.fillRect(0,0,240,240);
          ctx.fillStyle = '#fff'; ctx.font = 'bold 18px Rajdhani,sans-serif'; ctx.textAlign = 'center';
          ctx.fillText('GAME OVER', 120, 110);
          ctx.font = '13px Inter,sans-serif'; ctx.fillText('Счёт: '+score, 120, 135);
          ctx.fillText('Тапни чтобы начать заново', 120, 155);
        }
      }

      function step() {
        if (!alive) return;
        const head = { x: snake[0].x+dir.x, y: snake[0].y+dir.y };
        if (head.x<0 || head.x>=W || head.y<0 || head.y>=H || snake.some(s => s.x===head.x && s.y===head.y)) {
          alive = false; draw(); return;
        }
        snake.unshift(head);
        if (head.x===food.x && head.y===food.y) {
          score++;
          document.getElementById('sn-score').textContent = 'Счёт: ' + score;
          food = randFood();
        } else {
          snake.pop();
        }
        draw();
      }

      function setDir(name) {
        const map = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} };
        const nd = map[name];
        if (!nd || Math.abs(nd.x) === Math.abs(dir.x)) return; // нельзя развернуться на 180°
        dir = nd;
      }
      currentSetDir = setDir;

      keyHandler = e => {
        const map = { ArrowUp:'up', ArrowDown:'down', ArrowLeft:'left', ArrowRight:'right' };
        if (map[e.key]) setDir(map[e.key]);
      };
      document.addEventListener('keydown', keyHandler);

      cv.onclick = () => {
        if (alive) return;
        snake = [{x:7,y:7}]; dir = {x:1,y:0}; food = randFood(); score = 0; alive = true;
        document.getElementById('sn-score').textContent = 'Счёт: 0';
      };

      intervalId = setInterval(step, 150);
      draw();
    }

    function setDirection(name) { currentSetDir(name); }

    return { start, destroy, setDirection };
  })();

  /* ══════════════════════════════════════════════════════════
     СТРАНИЦА 2 — редактор шаблонов + загрузка фото.
     Строится один раз при открытии панели (не пересобирается
     при каждом свайпе — данные внутри неё не зависят от табов
     первой страницы).
  ══════════════════════════════════════════════════════════ */
  const EDITOR_COLORS = ['#FF00FF','#00FF00','#FF4444','#2196f3','#ec7c26','#f0c040','#ffffff','#aaaaaa','#00bcd4','#9c27b0'];
  const BB_CHIPS = [
    ['🌸 Привет', '[COLOR=#FF00FF][FONT=times new roman][SIZE=4][I][CENTER][ICODE]{{greeting}}, уважаемый(ая) игрок [USER={{uid}}]{{username}}[/USER][/ICODE].[/CENTER][/I][/SIZE][/FONT][/COLOR]\n\n'],
    ['✅ Одобрено', '[B][CENTER][FONT=times new roman][COLOR=#00FF00][ICODE]Одобрено.[/ICODE][/COLOR][/CENTER][/FONT][/B]'],
    ['❌ Отказано', '[B][CENTER][FONT=times new roman][COLOR=#FF4444][ICODE]Отказано.[/ICODE][/COLOR][/CENTER][/FONT][/B]'],
    ['⏳ На рассм.', '[B][CENTER][FONT=times new roman][COLOR=#ec7c26][ICODE]На рассмотрении.[/ICODE][/COLOR][/CENTER][/FONT][/B]'],
    ['🔒 Закрыто', '[B][CENTER][FONT=times new roman][COLOR=#aaaaaa][ICODE]Закрыто.[/ICODE][/COLOR][/CENTER][/FONT][/B]'],
    ['🖼 Фото', '[CENTER][img]https://i.postimg.cc/cCG97p5p/Pics-Art-07-12-03-23-18-1.png[/img][/CENTER]\n\n'],
  ];

  let lastUploadedUrl = '';

  function renderEditorPage() {
    const body = document.getElementById('brv6-body2');
    if (!body) return;
    const pfxOpts = PREFIXES.map(p => `<option value="${p.id}">${p.name}</option>`).join('');

    body.innerHTML = `
      <p class="brv6-sec">← Свайп для возврата</p>
      <div class="brv6-hr"></div>
      <p class="brv6-sec">Новый шаблон</p>
      <div class="brv6-fg"><label>Название</label><input class="brv6-inp" id="e-name" placeholder="Название шаблона" /></div>
      <div class="brv6-fg"><label>Префикс</label><select class="brv6-sel" id="e-pfx">${pfxOpts}</select></div>
      <div class="brv6-fg"><label style="display:flex;align-items:center;gap:6px;cursor:pointer"><input type="checkbox" id="e-pin" style="accent-color:var(--pri)"> 📌 Автозакреп</label></div>
      <div class="brv6-fg">
        <label>Цвет текста</label>
        <div class="brv6-color-row">
          ${EDITOR_COLORS.map(c => `<div class="brv6-color-btn" style="background:${c}" data-action="pick-color" data-arg="${c}" title="${c}"></div>`).join('')}
        </div>
      </div>
      <div class="brv6-fg">
        <label>BB-вставки</label>
        <div class="brv6-color-row">
          ${BB_CHIPS.map((c,i) => `<button class="brv6-chip" data-action="insert-bb" data-arg="${i}">${c[0]}</button>`).join('')}
        </div>
      </div>
      <div class="brv6-fg">
        <label>Текст шаблона ({{greeting}}, {{username}}, {{uid}})</label>
        <textarea class="brv6-ta" id="e-text" placeholder="[COLOR=#FF00FF]{{greeting}}, {{username}}[/COLOR]&#10;&#10;Ваш текст..."></textarea>
      </div>
      <button class="btn6" data-action="save-template" style="margin-bottom:14px">💾 Сохранить шаблон</button>
      <div class="brv6-hr"></div>
      <p class="brv6-sec">Загрузка фото (imgbb)</p>
      <div class="brv6-fg"><label>imgbb API ключ</label><input class="brv6-inp" id="e-imgkey" value="${escapeHtml(Store.get('brp_imgbb',''))}" placeholder="Ключ с imgbb.com → API" /></div>
      <button class="btn6g" id="e-savekey" style="margin-bottom:10px">💾 Сохранить ключ</button>
      <div class="brv6-upload-zone" id="e-drop">
        <div class="brv6-upload-ico">📷</div>
        <div class="brv6-upload-hint">Нажми или перетащи фото</div>
        <input type="file" id="e-file" accept="image/*" style="display:none">
      </div>
      <div class="brv6-photo-result" id="e-result">
        <div class="brv6-photo-url" id="e-url"></div>
        <div style="display:flex;gap:6px;flex-wrap:wrap">
          <button class="btn6" id="e-copy-url">📋 Ссылка</button>
          <button class="btn6g" id="e-copy-bb">🖼 BB-код</button>
          <button class="btn6grn" id="e-ins-ta">↑ В шаблон</button>
        </div>
      </div>
    `;

    document.getElementById('e-savekey').onclick = () => {
      Store.set('brp_imgbb', document.getElementById('e-imgkey').value.trim());
      toast('✅ Ключ сохранён');
    };

    const drop = document.getElementById('e-drop');
    drop.onclick = () => document.getElementById('e-file').click();
    drop.ondragover = e => { e.preventDefault(); drop.classList.add('drag'); };
    drop.ondragleave = () => drop.classList.remove('drag');
    drop.ondrop = e => { e.preventDefault(); drop.classList.remove('drag'); const f = e.dataTransfer.files[0]; if (f) handlePhoto(f, drop); };
    document.getElementById('e-file').onchange = e => { if (e.target.files[0]) handlePhoto(e.target.files[0], drop); };

    document.getElementById('e-copy-url').onclick = () => { navigator.clipboard.writeText(lastUploadedUrl).catch(()=>{}); toast('✅ Ссылка скопирована'); };
    document.getElementById('e-copy-bb').onclick = () => { navigator.clipboard.writeText(`[img]${lastUploadedUrl}[/img]`).catch(()=>{}); toast('✅ BB-код скопирован'); };
    document.getElementById('e-ins-ta').onclick = () => {
      const ta = document.getElementById('e-text');
      ta.value += `[CENTER][img]${lastUploadedUrl}[/img][/CENTER]\n\n`;
      ta.focus(); toast('✅ Вставлено');
    };
  }

  function pickColor(el, color) {
    document.querySelectorAll('.brv6-color-btn').forEach(x => x.classList.remove('on'));
    el.classList.add('on');
    const ta = document.getElementById('e-text');
    if (!ta) return;
    const pos = ta.selectionStart, end = ta.selectionEnd, sel = ta.value.substring(pos,end);
    if (sel) ta.value = ta.value.substring(0,pos) + `[COLOR=${color}]${sel}[/COLOR]` + ta.value.substring(end);
    else { ta.value += `[COLOR=${color}][/COLOR]`; ta.selectionStart = ta.selectionEnd = ta.value.length-8; }
    ta.focus();
  }

  function insertBBChip(idx) {
    const ta = document.getElementById('e-text');
    if (ta) { ta.value += BB_CHIPS[parseInt(idx)][1]; ta.focus(); }
  }

  function saveNewTemplate() {
    const n = document.getElementById('e-name').value.trim();
    const t = document.getElementById('e-text').value.trim();
    const p = parseInt(document.getElementById('e-pfx').value);
    const pin = document.getElementById('e-pin').checked;
    if (!n || !t) { toast('⚠️ Заполните название и текст'); return; }
    templates.push({ id: Date.now(), name: n, prefix: p, autopin: pin, content: t });
    saveTpls();
    addLog('Шаблон создан', n);
    toast(`✅ "${n}" сохранён!`);
    document.getElementById('e-name').value = '';
    document.getElementById('e-text').value = '';
  }

  function uploadPhoto(file, cb) {
    const key = Store.get('brp_imgbb', '');
    if (!key) { cb(null, 'Нет ключа imgbb'); return; }
    const fd = new FormData();
    fd.append('image', file);
    GM_xmlhttpRequest({
      method: 'POST',
      url: `https://api.imgbb.com/1/upload?key=${key}`,
      data: fd,
      onload: r => {
        try {
          const d = JSON.parse(r.responseText);
          if (d.success) cb(d.data.url, null); else cb(null, 'Ошибка imgbb: ' + (d.error?.message||'?'));
        } catch { cb(null, 'parse error'); }
      },
      onerror: () => cb(null, 'network error'),
    });
  }

  function handlePhoto(file, dropEl) {
    toast('📤 Загружаем фото...');
    const hint = dropEl.querySelector('.brv6-upload-hint');
    hint.textContent = '⏳ Загрузка...';
    uploadPhoto(file, (url, err) => {
      hint.textContent = 'Нажми или перетащи фото';
      if (err) { toast('❌ ' + err); return; }
      lastUploadedUrl = url;
      document.getElementById('e-url').textContent = url;
      document.getElementById('e-result').style.display = 'block';
      toast('✅ Фото загружено!');
    });
  }

  /* ══════════════════════════════════════════════════════════
     СТАРТ
  ══════════════════════════════════════════════════════════ */
  injectStyles();
  detectForumUserIfStale();

  if (!nickname) {
    buildLogin();
  } else {
    buildFab();
    buildPanel();
    startTracking();
  }

})();