Панель куратора BR — оптимизировано: единый таймер-менеджер, делегирование событий, без утечек
// ==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] Ваш текст..."></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();
}
})();