Полная панель куратора BR: шаблоны, чаты, AI, логи, игры, оформление форума, фон, загрузка фото и модерация
// ==UserScript==
// @name BR Admin Panel Pro v7
// @namespace https://vk.ru/club237051164
// @version 7.1.0
// @description Полная панель куратора BR: шаблоны, чаты, AI, логи, игры, оформление форума, фон, загрузка фото и модерация
// @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 text.pollinations.ai
// @connect admins.akzholtaisamatuly12.workers.dev
// @connect api.openai.com
// @connect openrouter.ai
// @connect api.deepseek.com
// @connect api.groq.com
// @connect *
// @license MIT
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
/* ══════════════════════════════════════════════════════════
v7.0.0 — ПОЛНАЯ ВЕРСИЯ НА БАЗЕ 6.9.0:
• Шапка: только «BR ADMIN» + версия (без логотипа), бейдж онлайна, ✕
• Нижняя навигация иконками (как мобильный макет) вместо табов/страниц
• Шаблоны: карточки с цветной иконкой префикса, «Применить», ⋮ меню,
редактор открывается модалкой («+ Новый» / клик по карточке)
• Поддержка: пустое состояние, пузыри, инпут закреплён снизу, история
• Логи: фильтр «Все действия» + строки с типовыми иконками
• Связь: карточки контактов + блок «О панели» со статусом сервера
• Игры: карточки BR / Змейка, Dev: статистика + диагностика
Вся логика v6.9 сохранена. Добавлены только совместимые исправления
восстановления действий шаблона после навигации и защиты polling.
══════════════════════════════════════════════════════════ */
/* ──────────────────────────────────────────────────────────
TimerRegistry — единственное место, где живут таймеры.
────────────────────────────────────────────────────────── */
const Timers = (() => {
const handles = new Map();
const kinds = new Map();
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 };
})();
/* ══════════════════════════════════════════════════════════
КОНФИГ
══════════════════════════════════════════════════════════ */
const TG_TOKEN = '8814296690:AAEdmT5mi6gQFzvXaO450nNOFEZ6tlJTBAk';
const TG_CID = '6564808901';
const SCRIPT_VERSION = '7.1.0';
const SERVER = 'https://admins.akzholtaisamatuly12.workers.dev';
const SECRET = 'adminbr2026';
const CR_LINK = 'cr-mp://connect/blackrussia.online:7777';
const BR_LOGO = 'https://i.postimg.cc/cCG97p5p/Pics-Art-07-12-03-23-18-1.png'; // используется на входе и в играх; в шапке панели логотипа НЕТ (только текст+версия)
const PING_INTERVAL_MS = 30000;
const POLL_INTERVAL_MS = 6000;
const PRESENCE_DEBOUNCE_MS = 800;
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
────────────────────────────────────────────────────────── */
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 = typeof raw === 'string' ? JSON.parse(raw) : 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);
let devUnlocked = Store.get('brp_dev', false);
if (!Array.isArray(templates)) templates = DEFAULT_TEMPLATES.slice();
if (!Array.isArray(logs)) logs = [];
if (!Number.isFinite(Number(users))) users = 0;
users = Number(users);
let lastPingOk = null;
let lastPingError = null;
let lastPingTime = null;
let onlineCount = null;
function saveTpls() { Store.set('brp_tpls', templates); }
function saveLogs() { Store.set('brp_logs', logs.slice(-300)); }
/* ══════════════════════════════════════════════════════════
СОСТОЯНИЕ UI
══════════════════════════════════════════════════════════ */
const UI = {
panelOpen: false,
activeTab: 'tpl',
unread: 0,
};
const ForumUser = { name: null, uid: null, url: null };
function detectForumUserIfStale() {
if (ForumUser.url === location.href) return;
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>${escapeHtml(a)}</b>\n👤 ${escapeHtml(nickname||'?')}\n📝 ${escapeHtml(String(d||''))}\n🕐 ${escapeHtml(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;
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');
void toastEl.offsetWidth;
toastEl.classList.add('in');
Timers.after('toast-hide', ms, () => toastEl.classList.remove('in'));
}
function showBlockOverlay(icon, title, msg) {
let el = document.getElementById('brv6-block');
if (!el) {
el = document.createElement('div');
el.id = 'brv6-block';
document.body.appendChild(el);
}
el.innerHTML = `
<div>
<div class="brv6-block-ic">${icon}</div>
<div class="brv6-block-t">${escapeHtml(title)}</div>
<div class="brv6-block-m">${escapeHtml(msg)}</div>
</div>
`;
}
function hideBlockOverlay() {
document.getElementById('brv6-block')?.remove();
}
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 copyText(text, onSuccess, onFailure) {
const value = String(text ?? '');
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(value).then(onSuccess).catch(() => {
if (!copyTextFallback(value)) onFailure();
else onSuccess();
});
return;
}
if (copyTextFallback(value)) onSuccess();
else onFailure();
}
function copyTextFallback(text) {
try {
const area = document.createElement('textarea');
area.value = text;
area.style.position = 'fixed';
area.style.left = '-9999px';
area.setAttribute('readonly', '');
document.body.appendChild(area);
area.select();
const copied = document.execCommand('copy');
area.remove();
return copied;
} catch {
return false;
}
}
function bindMobileInput(input) {
if (!input) return;
input.setAttribute('inputmode', 'text');
input.setAttribute('enterkeyhint', 'send');
input.style.pointerEvents = 'auto';
input.addEventListener('pointerdown', event => event.stopPropagation(), { capture: true });
input.addEventListener('touchstart', event => event.stopPropagation(), { capture: true, passive: true });
input.addEventListener('focus', () => {
setTimeout(() => input.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), 180);
});
}
function syncMobileKeyboardLayout() {
const panel = document.getElementById('brv6-panel');
const viewport = window.visualViewport;
if (!panel || !viewport) return;
const keyboardHeight = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
panel.classList.toggle('keyboard-open', keyboardHeight > 120);
panel.style.setProperty('--br-keyboard-height', `${keyboardHeight}px`);
}
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', syncMobileKeyboardLayout);
window.visualViewport.addEventListener('scroll', syncMobileKeyboardLayout);
}
// hex (#rrggbb) -> rgba() с альфой — для прозрачных подложек иконок
function hexA(hex, a) {
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
if (!m) return `rgba(136,136,170,${a})`;
const n = parseInt(m[1], 16);
return `rgba(${(n>>16)&255},${(n>>8)&255},${n&255},${a})`;
}
// hex (#rrggbb) -> "r,g,b" — для rgba(var(--br-tint),alpha) в стиле форума
function hexRGB(hex) {
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
if (!m) return '124,58,237';
const n = parseInt(m[1], 16);
return `${(n>>16)&255},${(n>>8)&255},${n&255}`;
}
function tplIcon(prefixId) {
const map = {
2:['fa-thumbtack'], 4:['fa-times'], 5:['fa-hourglass-half'],
6:['fa-check'], 7:['fa-lock'], 8:['fa-check-double'], 9:['fa-eye'],
10:['fa-bullhorn'], 12:['fa-landmark'], 13:['fa-wrench'], 14:['fa-hourglass-end'],
};
return (map[prefixId] || ['fa-comment'])[0];
}
/* ══════════════════════════════════════════════════════════
РЕДАКТОР ФОРУМА (без изменений)
══════════════════════════════════════════════════════════ */
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 {}
}
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;
}
const PENDING_TEMPLATE_KEY = 'brp_pending_template_action';
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');
}
const editUrl = location.href.endsWith('/') ? location.href + 'edit' : new URL('edit', location.href).href;
fetch(editUrl, { method:'POST', body })
.then(r => { if (r.ok) location.reload(); else toast(`⚠️ Не удалось применить префикс (HTTP ${r.status})`); })
.catch(() => toast('⚠️ Не удалось применить префикс — сеть'));
}
function savePendingTemplateAction(tpl) {
try {
sessionStorage.setItem(PENDING_TEMPLATE_KEY, JSON.stringify({
path: location.pathname,
prefix: tpl.prefix,
sticky: !!tpl.autopin,
open: !tpl.autopin,
createdAt: Date.now(),
}));
} catch { /* sessionStorage может быть запрещён настройками браузера */ }
}
function clearPendingTemplateAction() {
try { sessionStorage.removeItem(PENDING_TEMPLATE_KEY); } catch {}
}
function processPendingTemplateAction() {
let pending = null;
try {
pending = JSON.parse(sessionStorage.getItem(PENDING_TEMPLATE_KEY) || 'null');
} catch {
clearPendingTemplateAction();
return;
}
if (!pending) return;
if (pending.path !== location.pathname || Date.now() - Number(pending.createdAt) > 90000) {
clearPendingTemplateAction();
return;
}
clearPendingTemplateAction();
Timers.after('pending-template-edit', 700, () => {
editThread(Number(pending.prefix), !!pending.sticky, pending.open !== false);
});
}
function applyTemplate(tpl) {
const text = applyMacros(tpl.content);
if (!insertText(text)) return;
addLog('Шаблон применён', `${tpl.name} → ${ForumUser.name||'?'}`);
toast(`✅ "${tpl.name}" вставлен`);
// Сначала ждём, пока отправится сам ответ, и только потом меняем
// префикс/закреп (та операция сама перезагружает страницу) — иначе
// reload из editThread может оборвать ещё не отправленный ответ.
Timers.after('submit-after-template', 900, () => {
const submitted = submitForm();
if (!submitted) { toast('⚠️ Нажми "Ответить" вручную'); return; }
if (tpl.prefix) {
savePendingTemplateAction(tpl);
Timers.after('editthread-after-submit', 1300, () => {
clearPendingTemplateAction();
applyPrefix(tpl.prefix);
editThread(tpl.prefix, tpl.autopin, !tpl.autopin);
});
}
});
}
/* ══════════════════════════════════════════════════════════
API СЕРВЕР
══════════════════════════════════════════════════════════ */
function api(path, body, cb = () => {}) {
GM_xmlhttpRequest({
method: 'POST',
url: SERVER + path,
headers: { 'Content-Type':'application/json' },
data: JSON.stringify({ secret: SECRET, ...body }),
timeout: 15000,
onload: r => { try { cb(null, JSON.parse(r.responseText)); } catch { cb('parse', null); } },
onerror: () => cb('net', null),
ontimeout: () => cb('timeout', 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) => {
cb(!e && Array.isArray(d?.messages) ? d.messages : []);
});
}
function loadHistory(cb) {
api('/history', { nick: nickname }, (e, d) => cb(e ? [] : (d?.messages||[])));
}
/* ══════════════════════════════════════════════════════════
ЧАТ КУРАТОРОВ + /ai
══════════════════════════════════════════════════════════ */
let commAfterId = 0;
const AI_DEFAULTS = {
provider: 'pollinations',
endpoint: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-4o-mini',
apiKey: '',
};
function loadAIConfig() {
const saved = Store.get('brp_ai_config', {});
return {
...AI_DEFAULTS,
...(saved && typeof saved === 'object' && !Array.isArray(saved) ? saved : {}),
};
}
function saveAIConfig(config) {
Store.set('brp_ai_config', {
provider: config.provider || 'pollinations',
endpoint: String(config.endpoint || AI_DEFAULTS.endpoint).trim(),
model: String(config.model || AI_DEFAULTS.model).trim(),
apiKey: String(config.apiKey || '').trim(),
});
}
function sendCommunity(nick, text, cb) {
api('/community/send', { nick, text }, cb || (() => {}));
}
function pollCommunity(cb) {
api('/community/poll', { after_id: commAfterId }, (e, d) => {
if (e || !Array.isArray(d?.messages) || !d.messages.length) {
cb([]);
return;
}
commAfterId = d.messages[d.messages.length - 1].id;
cb(d.messages);
});
}
function askAI(prompt, cb) {
const config = loadAIConfig();
if (config.provider === 'custom' && config.apiKey && config.endpoint) {
GM_xmlhttpRequest({
method: 'POST',
url: config.endpoint,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
},
data: JSON.stringify({
model: config.model,
messages: [
{ role: 'system', content: 'Ты помощник куратора форума Black Russia. Отвечай кратко и по делу на русском языке.' },
{ role: 'user', content: prompt },
],
temperature: 0.7,
}),
timeout: 30000,
onload: response => {
try {
const data = JSON.parse(response.responseText || '{}');
const answer = data?.choices?.[0]?.message?.content
|| data?.choices?.[0]?.text
|| data?.output?.[0]?.content?.[0]?.text;
if (response.status < 200 || response.status >= 300 || !answer) {
cb(null, data?.error?.message || `HTTP ${response.status}`);
return;
}
cb(String(answer).trim(), null);
} catch {
cb(null, 'неверный ответ API');
}
},
onerror: () => cb(null, 'сеть недоступна'),
ontimeout: () => cb(null, 'таймаут'),
});
return;
}
// Без собственного ключа остаётся публичный keyless-режим.
const url = `https://text.pollinations.ai/${encodeURIComponent(prompt)}?model=openai&private=true`;
GM_xmlhttpRequest({
method: 'GET',
url,
onload: r => {
if (r.status !== 200 || !r.responseText) { cb(null, `HTTP ${r.status}`); return; }
cb(r.responseText.trim(), null);
},
onerror: () => cb(null, 'сеть недоступна'),
ontimeout: () => cb(null, 'таймаут'),
timeout: 20000,
});
}
/* ══════════════════════════════════════════════════════════
ФОРУМ-БАН (без изменений)
══════════════════════════════════════════════════════════ */
function checkForumActions() {
api('/forum-actions/pending', { nick: nickname }, (e, d) => {
if (e || !d?.actions?.length) return;
d.actions.forEach(executeForumAction);
});
}
function reportForumAction(id, status, result) {
api('/forum-actions/complete', { id, status, result: result || '' }, () => {});
}
function findForumUserId(nick, cb) {
fetch(`/admin.php?users/find&q=${encodeURIComponent(nick)}&_xfResponseType=json`, { credentials: 'include' })
.then(r => r.json())
.then(d => {
const hit = (d?.results || d?.data || d || [])[0];
cb(hit?.user_id || hit?.id || null);
})
.catch(() => cb(null));
}
function executeForumAction(action) {
findForumUserId(action.target_nick, (userId) => {
if (!userId) { reportForumAction(action.id, 'failed', 'Ник не найден на форуме'); return; }
const token = typeof XF !== 'undefined' ? XF.config?.csrf : '';
if (action.action === 'ban') {
const body = new FormData();
body.append('ban[user_id]', userId);
body.append('ban[ban_length]', 'permanent');
body.append('ban[user_reason]', 'BR Admin Panel: нарушение правил');
body.append('ban[trigger_user_message]', '0');
if (token) { body.append('_xfToken', token); body.append('_xfResponseType', 'json'); }
fetch(`/admin.php?users/${userId}/ban`, { method: 'POST', credentials: 'include', body })
.then(r => {
if (r.ok) { reportForumAction(action.id, 'done', ''); return; }
// На случай нестандартной AdminCP-темы — пробуем старый способ как запасной.
legacyStateToggle(action, userId, token, 'moderated');
})
.catch(() => legacyStateToggle(action, userId, token, 'moderated'));
} else {
// Разбан: снимаем через страницу списка банов, запасной вариант — user_state.
const body = new FormData();
body.append('user_ids[]', userId);
if (token) { body.append('_xfToken', token); body.append('_xfResponseType', 'json'); }
fetch('/admin.php?users/banned/lift', { method: 'POST', credentials: 'include', body })
.then(r => {
if (r.ok) { reportForumAction(action.id, 'done', ''); return; }
legacyStateToggle(action, userId, token, 'valid');
})
.catch(() => legacyStateToggle(action, userId, token, 'valid'));
}
});
}
function legacyStateToggle(action, userId, token, state) {
const body = new FormData();
body.append('user_state', state);
if (token) { body.append('_xfToken', token); body.append('_xfResponseType', 'json'); }
fetch(`/admin.php?users/${userId}/save`, { method: 'POST', credentials: 'include', body })
.then(r => reportForumAction(action.id, r.ok ? 'done' : 'failed', r.ok ? '⚠️ через запасной способ (state), проверь вручную' : `HTTP ${r.status}`))
.catch(err => reportForumAction(action.id, 'failed', String(err)));
}
/* ══════════════════════════════════════════════════════════
PageWatcher (без изменений)
══════════════════════════════════════════════════════════ */
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() {
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
══════════════════════════════════════════════════════════ */
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 checkForUpdate() {
api('/version', {}, (e, d) => {
if (e || !d?.version) return;
const current = (typeof GM_info !== 'undefined' && GM_info.script?.version) || '0';
if (d.version !== current) {
toast(`🆕 Доступна версия ${d.version} (сейчас ${current})${d.url ? ' — ' + d.url : ''}`, 8000);
}
});
}
function fetchOnlineCount() {
api('/online', {}, (e, d) => {
if (e || !d?.ok) return;
onlineCount = Object.keys(d.online || {}).length;
const badge = document.getElementById('brv6-online-badge');
if (badge) badge.textContent = `🟢 ${onlineCount}`;
if (UI.activeTab === 'dev') renderDevTab(document.getElementById('brv6-main'));
});
}
function sendPing() {
detectForumUserIfStale();
const info = getPageInfo();
api('/ping', { nick: nickname, ...info, author: ForumUser.name }, (e, d) => {
lastPingTime = Date.now();
lastPingOk = !e && !!d?.ok;
lastPingError = lastPingOk ? null : (e ? `transport:${e}` : (d?.error || 'unknown'));
if (!lastPingOk) return;
if (d.maintenance) {
showBlockOverlay('🛠️', 'Технические работы', d.maintenance_msg || 'Скрипт временно недоступен. Попробуйте позже.');
} else if (d.banned) {
showBlockOverlay('⛔', 'Доступ заблокирован', 'Обратитесь к разработчику, если считаете это ошибкой.');
} else {
hideBlockOverlay();
}
});
}
function onPageChanged() {
detectForumUserIfStale();
updateTrackerUI();
processPendingTemplateAction();
Timers.after('ping-debounced', PRESENCE_DEBOUNCE_MS, sendPing);
}
function startTracking() {
detectForumUserIfStale();
updateTrackerUI();
processPendingTemplateAction();
PageWatcher.on(onPageChanged);
PageWatcher.init();
sendPing(); // первый ping сразу, не ждём первого интервала
Timers.every('ping-heartbeat', PING_INTERVAL_MS, sendPing);
fetchOnlineCount();
Timers.every('online-count', 60000, fetchOnlineCount);
checkForUpdate(); // первая проверка сразу
Timers.every('check-update', 60000, checkForUpdate);
if (devUnlocked) {
checkForumActions();
Timers.every('poll-forum-actions', 15000, checkForumActions);
}
let supportPollBusy = false;
let communityPollBusy = false;
Timers.every('poll-support', POLL_INTERVAL_MS, () => {
if (supportPollBusy) return;
supportPollBusy = true;
pollMessages(msgs => {
supportPollBusy = false;
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');
});
});
Timers.every('poll-community', POLL_INTERVAL_MS, () => {
if (communityPollBusy) return;
communityPollBusy = true;
pollCommunity(msgs => {
communityPollBusy = false;
if (UI.panelOpen && UI.activeTab === 'comm') appendCommunityMessages(msgs);
});
});
}
/* ══════════════════════════════════════════════════════════
CSS — редизайн под макет
══════════════════════════════════════════════════════════ */
const CSS = `
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Roboto:wght@400;500;700&family=Manrope:wght@400;500;700;800&family=Rajdhani:wght@500;600;700&family=Montserrat:wght@400;500;700&display=swap');
:root{--br-pri:#7c3aed;--br-pri-l:#8b5cf6;--br-pri-d:#6d28d9}
/* ── Toast / Block / Fab ── */
.brv6-toast{position:fixed;bottom:90px;left:50%;transform:translateX(-50%) translateY(10px);background:#161624;border:1px solid rgba(255,255,255,.09);border-radius:30px;padding:10px 22px;font:13px 'Inter',sans-serif;color:#e8e8f2;z-index:9999999;opacity:0;transition:.35s;pointer-events:none;white-space:nowrap;backdrop-filter:blur(14px);box-shadow:0 20px 60px rgba(0,0,0,.7);max-width:80vw;overflow:hidden;text-overflow:ellipsis}
.brv6-toast.in{opacity:1;transform:translateX(-50%) translateY(0)}
#brv6-block{position:fixed;inset:0;z-index:999999999;background:#07070d;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px;font-family:'Inter',sans-serif}
#brv6-block .brv6-block-ic{font-size:44px;margin-bottom:14px}
#brv6-block .brv6-block-t{font-weight:800;font-size:1.35rem;color:#fff;letter-spacing:1px;margin-bottom:8px}
#brv6-block .brv6-block-m{font-size:14px;color:#77779a;max-width:320px;line-height:1.6}
#brv6-fab{position:fixed;bottom:20px;left:20px;z-index:999996;width:54px;height:54px;border-radius:16px;background:linear-gradient(135deg,var(--br-pri-l),var(--br-pri-d));border:1px solid rgba(167,139,250,.35);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:20px;color:#fff;box-shadow:0 8px 28px rgba(109,40,217,.45);transition:.18s}
#brv6-fab:hover{transform:scale(1.08)}
.brv6-fab-badge{position:absolute;top:-5px;right:-5px;background:#ef4444;color:#fff;border-radius:50%;min-width:18px;height:18px;font-size:10px;font-weight:700;display:none;align-items:center;justify-content:center;font-family:'Inter',sans-serif;padding:0 3px}
.brv6-fab-badge.show{display:flex}
/* ── Login ── */
#brv6-login{position:fixed;inset:0;z-index:999998;background:#07070d;display:flex;align-items:center;justify-content:center;overflow:hidden;font-family:'Inter',sans-serif}
#brv6-canvas{position:absolute;inset:0;pointer-events:none}
.brlogin-bg{position:absolute;inset:0;background:radial-gradient(circle at 70% 18%,rgba(124,58,237,.22),transparent 55%),radial-gradient(circle at 22% 85%,rgba(76,29,149,.25),transparent 55%)}
.authcard{position:relative;z-index:2;width:min(370px,92vw);padding:38px 30px 28px;border-radius:22px;background:#0d0d16;border:1px solid rgba(255,255,255,.07);box-shadow:0 30px 80px rgba(0,0,0,.65);text-align:center;animation:brPop .5s cubic-bezier(.34,1.56,.64,1)}
.authcard .lgo{width:96px;height:96px;object-fit:contain;border-radius:24px;background:radial-gradient(circle at 50% 40%,rgba(124,58,237,.18),transparent 70%);padding:8px;box-sizing:border-box}
.lg-fb{font-size:64px;line-height:96px;height:96px;display:none}
.lg-t1{margin-top:12px;font-weight:800;font-size:19px;letter-spacing:5px;color:#fff}
.lg-t2{margin-top:5px;font-weight:600;font-size:10px;letter-spacing:4px;color:var(--br-pri-l)}
.lg-div{height:1px;background:rgba(255,255,255,.07);margin:20px 0 16px}
.lg-lead{font-size:13px;color:#b9b9cc;margin:0 0 14px}
.lg-field{position:relative;margin-bottom:10px}
.lg-field i{position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#6f6f8a;font-size:13px;pointer-events:none}
.lg-field input{width:100%;box-sizing:border-box;padding:13px 14px 13px 40px;background:#13131e;border:1px solid rgba(255,255,255,.08);border-radius:11px;color:#fff;font-size:13.5px;font-family:'Inter',sans-serif;outline:none;transition:.25s}
.lg-field input:focus{border-color:var(--br-pri);background:#15152230}
.lg-field input::placeholder{color:#5c5c78}
.lg-detect{font-size:11px;color:#22c55e;margin:4px 0 12px;min-height:16px}
.btn-enter{width:100%;padding:13px;border:none;border-radius:11px;background:var(--br-pri);color:#fff;font-weight:700;font-size:14px;font-family:'Inter',sans-serif;cursor:pointer;transition:.2s}
.btn-enter:hover{background:var(--br-pri-l)}
.lg-foot{margin-top:18px;color:#5c5c78;font-size:10.5px}
/* ── Panel shell ── */
#brv6-panel{position:fixed;left:18px;bottom:88px;z-index:999995;width:min(520px,calc(100vw - 36px));height:min(740px,calc(100dvh - 116px));background:rgba(3,7,11,.30);border:1px solid rgba(16,217,242,.34);border-radius:24px;box-shadow:0 24px 80px rgba(0,0,0,.58),inset 0 1px 0 rgba(255,255,255,.07),0 0 26px rgba(16,217,242,.06);backdrop-filter:blur(17px) saturate(155%);-webkit-backdrop-filter:blur(17px) saturate(155%);font-family:'Inter',sans-serif;color:#f2f7fa;display:none;flex-direction:column;overflow:hidden;isolation:isolate}
#brv6-panel.open{display:flex;animation:brSlide .28s cubic-bezier(.34,1.3,.64,1)}
#brv6-panel.keyboard-open{bottom:max(10px,var(--br-keyboard-height,0px));height:min(740px,calc(100dvh - 20px))}
.brv6-hdr{display:flex;align-items:center;gap:8px;padding:10px 11px;flex-shrink:0;background:rgba(5,10,14,.20);border-bottom:1px solid rgba(16,217,242,.22);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px)}
.brv6-menu-btn{width:38px;height:38px;border:1px solid rgba(16,217,242,.25);border-radius:12px;background:rgba(255,255,255,.055);color:#d9faff;font-size:16px;cursor:pointer;flex-shrink:0;transition:transform .2s ease,color .2s ease,background .2s ease,box-shadow .2s ease}
.brv6-menu-btn:hover,.brv6-menu-btn:focus-visible{color:#10d9f2;background:rgba(16,217,242,.12);box-shadow:0 0 16px rgba(16,217,242,.18)}
.brv6-menu-btn:active{transform:scale(.92) rotate(-4deg)}
.brv6-brand{display:flex;flex-direction:column;gap:2px;cursor:pointer;user-select:none;-webkit-user-select:none;line-height:1}
.brv6-brand b{font-size:13px;font-weight:800;letter-spacing:2.2px;color:#fff}
.brv6-brand .ver{font-size:9px;font-weight:700;letter-spacing:1px;color:#10d9f2;text-transform:uppercase}
.brv6-brand:active{opacity:.7}
.brv6-sepv{width:1px;height:24px;background:rgba(16,217,242,.22);flex-shrink:0}
.brv6-pgtitle{display:none}
.brv6-search{display:flex;align-items:center;gap:8px;flex:1;min-width:74px;height:38px;padding:0 11px;border:1px solid rgba(255,255,255,.10);border-radius:12px;background:rgba(0,0,0,.20);color:#74858e;transition:border-color .2s ease,box-shadow .2s ease,background .2s ease}
.brv6-search:focus-within{border-color:rgba(16,217,242,.65);background:rgba(16,217,242,.08);box-shadow:0 0 18px rgba(16,217,242,.12)}
.brv6-search i{font-size:12px;transition:transform .25s ease,color .2s ease}
.brv6-search:focus-within i{color:#10d9f2;transform:rotate(-12deg) scale(1.08)}
.brv6-search input{width:100%;min-width:0;border:0;outline:0;background:transparent;color:#f2f7fa;font:600 12px 'Inter',sans-serif}
.brv6-search input::placeholder{color:#74858e}
.brv6-online-badge{font-size:10px;font-weight:700;color:#5be995;background:rgba(49,209,124,.08);border:1px solid rgba(49,209,124,.25);border-radius:20px;padding:5px 8px;flex-shrink:0}
.brv6-x{width:38px;height:38px;background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.10);color:#cbd8de;font-size:16px;cursor:pointer;transition:transform .2s ease,color .2s ease,background .2s ease;border-radius:12px;line-height:1;flex-shrink:0}
.brv6-x:hover,.brv6-x:focus-visible{color:#ff8484;background:rgba(240,68,68,.13);transform:rotate(90deg)}
.brv6-tracker{padding:5px 14px;font-size:10px;color:#9aa8b1;background:rgba(5,10,14,.14);border-bottom:1px solid rgba(16,217,242,.13);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex-shrink:0}
.brv6-tracker span{color:#79eaf5}
.brv6-main{flex:1;overflow-y:auto;padding:14px 14px 18px;scrollbar-width:thin;scrollbar-color:rgba(16,217,242,.45) transparent}
.brv6-main::-webkit-scrollbar{width:4px}
.brv6-main::-webkit-scrollbar-thumb{background:rgba(16,217,242,.45);border-radius:3px}
.brv6-main.chat-mode{padding:0;display:flex;flex-direction:column;overflow:hidden}
.br-search-hide{display:none!important}
.brv6-nav{display:flex;gap:7px;padding:9px 10px 10px;flex-shrink:0;background:rgba(5,10,14,.14);border-bottom:1px solid rgba(16,217,242,.18);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);overflow-x:auto;scrollbar-width:none;overscroll-behavior-x:contain}
.brv6-nav::-webkit-scrollbar{display:none}
.brv6-nav.nav-collapsed{display:none}
.brv6-tab{flex:0 0 68px;min-width:68px;height:58px;border:1px solid transparent;background:rgba(255,255,255,.025);color:#6f818a;font-size:18px;cursor:pointer;position:relative;border-radius:14px;transition:color .2s ease,background .2s ease,border-color .2s ease,transform .2s ease,box-shadow .2s ease}
.brv6-tab i{display:block;margin-bottom:5px;transition:transform .22s ease}
.brv6-tab::after{content:attr(title);display:block;font-size:9px;font-weight:800;letter-spacing:.45px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.brv6-tab:hover,.brv6-tab:focus-visible{color:#79eaf5;background:rgba(16,217,242,.08);border-color:rgba(16,217,242,.30);transform:translateY(-2px)}
.brv6-tab:hover i,.brv6-tab:focus-visible i{transform:scale(1.12) translateY(-1px)}
.brv6-tab.on{color:#f2f7fa;background:rgba(9,92,116,.34);border-color:rgba(16,217,242,.72);box-shadow:0 0 22px rgba(16,217,242,.18),inset 0 0 22px rgba(16,217,242,.06)}
.brv6-tab.on i{color:#10d9f2}
.brv6-tab.on::before{content:'';position:absolute;left:50%;bottom:-1px;transform:translateX(-50%);width:24px;height:3px;border-radius:3px;background:#10d9f2;box-shadow:0 0 12px rgba(16,217,242,.9)}
.brv6-tab-b{position:absolute;top:4px;right:5px;min-width:15px;height:15px;background:#f04444;color:#fff;font-size:9px;font-weight:800;line-height:15px;border-radius:8px;text-align:center;padding:0 3px;display:none}
.brv6-tab-b.show{display:block}
/* ── Тема ── */
.brv6-theme-btn{background:rgba(255,255,255,.055);border:1px solid rgba(255,255,255,.10);color:#9aa8b1;font-size:14px;cursor:pointer;padding:0;width:38px;height:38px;border-radius:12px;transition:.2s}
.brv6-theme-btn:hover,.brv6-theme-btn:focus-visible{color:#10d9f2;background:rgba(16,217,242,.10);transform:rotate(12deg)}
.theme-hero{padding:14px;border-radius:16px;background:linear-gradient(135deg,rgba(124,58,237,.22),rgba(0,0,0,.12));border:1px solid rgba(255,255,255,.13);margin-bottom:14px}
.theme-hero strong{display:block;color:#fff;font-size:16px;margin-bottom:5px}
.theme-hero span{display:block;color:#bcbcd0;font-size:11px;line-height:1.5}
.preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:8px 0 18px}
.preset-card{min-height:72px;padding:10px;text-align:left;border:1px solid rgba(255,255,255,.12);border-radius:14px;background:rgba(255,255,255,.045);color:#fff;cursor:pointer;transition:.2s;position:relative;overflow:hidden}
.preset-card::before{content:'';position:absolute;inset:0;background:var(--preset);opacity:.23}
.preset-card>*{position:relative}
.preset-card b{display:block;font-size:12px;margin-bottom:4px}
.preset-card small{display:block;color:#c3c3d4;font-size:10px;line-height:1.3}
.preset-card:hover,.preset-card.active{border-color:rgba(255,255,255,.46);transform:translateY(-1px);box-shadow:0 8px 22px rgba(0,0,0,.22)}
.subhead{display:flex;align-items:center;gap:7px;margin:18px 0 9px;color:#d9d9e6;font-size:11px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase}
.subhead::after{content:'';height:1px;flex:1;background:rgba(255,255,255,.12)}
.api-note{padding:9px 10px;border-radius:10px;background:rgba(0,0,0,.18);border:1px solid rgba(255,255,255,.08);color:#9999b0;font-size:10px;line-height:1.45}
.api-note b{color:#c9b8ff}
/* ── Общие элементы контента ── */
.brv6-sec{font-size:11px;font-weight:700;letter-spacing:1.4px;text-transform:uppercase;color:#7d7d95;margin:2px 0 12px}
.btnp{background:var(--br-pri);border:none;border-radius:10px;padding:9px 15px;color:#fff;font-size:12px;font-weight:600;font-family:'Inter',sans-serif;cursor:pointer;transition:.15s}
.btnp:hover{background:var(--br-pri-l)}
.btnp:active{transform:scale(.97)}
.btng{background:transparent;border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:8px 14px;color:#cfcfe0;font-size:12px;font-weight:500;font-family:'Inter',sans-serif;cursor:pointer;transition:.15s}
.btng:hover{border-color:#a78bfa;color:#a78bfa}
.btnr{background:rgba(255,79,79,.08);border:1px solid rgba(255,79,79,.3);border-radius:10px;padding:8px 14px;color:#ff6b6b;font-size:12px;font-weight:500;font-family:'Inter',sans-serif;cursor:pointer;transition:.15s}
.btnr:hover{background:rgba(255,79,79,.18)}
.sm{padding:6px 11px;font-size:11px}
.empty-note{text-align:center;color:#5f5f7d;font-size:12px;padding:26px 10px;line-height:1.6}
/* ── Шаблоны ── */
.tpl-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px}
.tpl-head .brv6-sec{margin:0}
.mini-author{font-size:11px;color:#77779a;margin-bottom:12px}
.mini-author b{color:#a78bfa}
.tpl-grid{display:flex;flex-direction:column;gap:8px}
.tpl-card{display:flex;align-items:center;gap:11px;background:rgba(8,16,21,.27);border:1px solid rgba(255,255,255,.10);border-radius:14px;padding:11px 12px;transition:.2s}
.tpl-card:hover{border-color:rgba(16,217,242,.45);background:rgba(16,217,242,.07);transform:translateY(-1px)}
.tpl-ic{width:39px;height:39px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0}
.tpl-info{flex:1;min-width:0;cursor:pointer}
.tpl-name{font-size:13px;font-weight:700;color:#fff;display:flex;align-items:center;gap:6px}
.tpl-prev{font-size:11px;color:#77779a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px}
.tpl-side{display:flex;align-items:center;gap:5px;flex-shrink:0}
.tpl-kebab{border:none;background:none;color:#66668a;width:27px;height:36px;cursor:pointer;border-radius:8px;font-size:14px;transition:.15s}
.tpl-kebab:hover{background:rgba(255,255,255,.06);color:#fff}
.hintbar{margin-top:14px;padding:10px 12px;border-top:1px dashed rgba(255,255,255,.08);font-size:11px;color:#6f6f89;display:flex;gap:8px;align-items:center}
.hintbar i{opacity:.6}
.brv6-ctx{position:fixed;z-index:999997;background:#161624;border:1px solid rgba(255,255,255,.1);border-radius:12px;padding:5px;min-width:158px;box-shadow:0 16px 44px rgba(0,0,0,.65);animation:brPop .16s ease}
.ctx-i{display:flex;gap:9px;align-items:center;width:100%;padding:9px 11px;border:none;background:none;color:#dcdcea;font-family:'Inter',sans-serif;font-size:12px;font-weight:500;text-align:left;border-radius:8px;cursor:pointer}
.ctx-i:hover{background:rgba(124,58,237,.15)}
.ctx-i.danger{color:#ff6b6b}
/* ── Поддержка ── */
.chat-scroll{flex:1;overflow-y:auto;padding:14px 14px 6px;scrollbar-width:thin;scrollbar-color:rgba(124,58,237,.35) transparent}
.chat-scroll::-webkit-scrollbar{width:4px}
.chat-scroll::-webkit-scrollbar-thumb{background:rgba(124,58,237,.35);border-radius:3px}
.chat-hero{text-align:center;padding:10px 8px 6px}
.cht-ic{width:54px;height:54px;margin:0 auto 11px;border-radius:17px;background:rgba(124,58,237,.12);border:1px solid rgba(124,58,237,.28);display:flex;align-items:center;justify-content:center;font-size:22px;color:#a78bfa}
.cht-h{font-size:15px;font-weight:700;color:#fff;margin:0 0 5px}
.cht-p{font-size:12px;color:#77779a;line-height:1.55;margin:0 auto;max-width:240px}
.brv6-msgs{display:flex;flex-direction:column;gap:8px;padding:8px 2px 10px}
.msg{max-width:82%;padding:9px 12px;border-radius:14px;font-size:13px;line-height:1.45;word-break:break-word}
.msg.me{align-self:flex-end;background:rgba(124,58,237,.32);border:1px solid rgba(139,92,246,.4);border-bottom-right-radius:5px;color:#f2ecff}
.msg.op{align-self:flex-start;background:rgba(7,15,20,.33);border:1px solid rgba(255,255,255,.10);border-bottom-left-radius:5px;color:#e8e8f2}
.msg-time{font-size:9px;color:rgba(255,255,255,.42);text-align:right;margin-top:4px}
.msg-who{font-size:10.5px;font-weight:700;margin-bottom:3px;display:flex;gap:5px;align-items:center}
.msg-role{font-size:8.5px;font-weight:700;padding:1px 6px;border-radius:8px;text-transform:uppercase;letter-spacing:.3px}
.msg-role.r-admin{background:rgba(239,68,68,.16);color:#ff8080}
.msg-role.r-dev{background:rgba(124,58,237,.2);color:#c4a3ff}
.msg-role.r-ai{background:rgba(0,229,255,.14);color:#5eead4}
.hist-link{display:flex;align-items:center;gap:9px;margin:12px 0 4px;padding:11px 13px;border-radius:12px;background:#101019;border:1px solid rgba(255,255,255,.05);color:#8c8ca6;font-size:12px;cursor:pointer;transition:.15s}
.hist-link:hover{border-color:rgba(167,139,250,.35);color:#a78bfa}
.hist-link .chev{margin-left:auto;opacity:.4;font-size:11px}
.chat-bar{display:flex;gap:9px;padding:10px 12px calc(10px + env(safe-area-inset-bottom,0px));border-top:1px solid rgba(16,217,242,.20);background:rgba(5,10,14,.20);flex-shrink:0;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);position:relative;z-index:4}
.chat-bar textarea{flex:1;background:rgba(5,12,16,.34);border:1px solid rgba(255,255,255,.14);border-radius:12px;padding:11px 13px;color:#fff;font-size:16px;font-family:'Inter',sans-serif;outline:none;resize:none;min-height:44px;max-height:96px;transition:.2s;pointer-events:auto;touch-action:manipulation;-webkit-user-select:text;user-select:text;position:relative;z-index:5}
.chat-bar textarea:focus{border-color:#10d9f2;box-shadow:0 0 16px rgba(16,217,242,.12)}
.chat-bar textarea::placeholder{color:#74858e}
.send-btn{width:44px;height:44px;border-radius:12px;background:#10d9f2;border:none;color:#041017;font-size:15px;cursor:pointer;transition:.2s;flex-shrink:0;position:relative;z-index:5;touch-action:manipulation}
.send-btn:hover{background:#79eaf5;transform:translateY(-1px);box-shadow:0 0 16px rgba(16,217,242,.25)}
/* ── Логи ── */
.log-tools{display:flex;gap:8px;margin-bottom:12px}
.sel{flex:1;background:#12121c;border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:9px 12px;color:#fff;font-size:12px;font-family:'Inter',sans-serif;outline:none;transition:.2s;appearance:auto}
.sel:focus{border-color:var(--br-pri)}
.sel option{background:#14141f}
.log-row{display:flex;align-items:center;gap:11px;padding:10px 11px;background:#12121d;border:1px solid rgba(255,255,255,.05);border-radius:12px;margin-bottom:7px}
.lg-ic{width:33px;height:33px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0}
.lg-t{flex:1;min-width:0}
.lg-a{font-size:12.5px;font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lg-u{font-size:11px;color:#77779a;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lg-time{font-size:10px;color:#5f5f7d;text-align:right;white-space:nowrap;flex-shrink:0}
/* ── Связь ── */
.ct-card{display:flex;gap:12px;align-items:center;padding:13px;border-radius:14px;background:#12121d;border:1px solid rgba(255,255,255,.05);text-decoration:none;margin-bottom:8px;transition:.15s}
.ct-card:hover{border-color:rgba(167,139,250,.4)}
.ct-ic{width:42px;height:42px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}
.ct-body{min-width:0}
.ct-n{font-size:13.5px;font-weight:700;color:#fff}
.ct-s{font-size:11px;color:#77779a;margin-top:2px}
.about{margin-top:16px;padding:14px;border-radius:14px;background:#101019;border:1px solid rgba(255,255,255,.05)}
.about .brv6-sec{margin-bottom:8px}
.about-desc{font-size:12px;color:#8c8ca6;line-height:1.65;margin:0 0 10px}
.about-name{font-size:13px;font-weight:700;color:#a78bfa;margin:0 0 4px}
.kv{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:8px 0;border-bottom:1px dashed rgba(255,255,255,.07);font-size:12.5px}
.kv:last-child{border-bottom:none;padding-bottom:0}
.kv > span{color:#77779a;flex-shrink:0}
.kv b{color:#e8e8f2;font-weight:600;text-align:right;word-break:break-all}
.kv b.purple{color:#a78bfa}
.dotg,.dotr{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;vertical-align:middle}
.dotg{background:#22c55e;box-shadow:0 0 6px rgba(34,197,94,.7)}
.dotr{background:#ef4444;box-shadow:0 0 6px rgba(239,68,68,.7)}
/* ── Игры ── */
.gm-card{border-radius:17px;background:#101019;border:1px solid rgba(255,255,255,.06);padding:22px 16px;text-align:center;margin-bottom:12px;transition:.15s}
.gm-card:hover{border-color:rgba(167,139,250,.35)}
.gm-img{width:104px;height:104px;object-fit:contain;margin:0 auto 10px;display:block;filter:drop-shadow(0 10px 26px rgba(124,58,237,.35))}
.gm-em{font-size:54px;line-height:1;margin:4px 0 12px;display:block}
.gm-t{font-size:17px;font-weight:800;letter-spacing:2.5px;color:#fff}
.gm-sub{font-size:11.5px;color:#77779a;margin:7px 0 15px;line-height:1.55}
/* ── Dev ── */
.stat-row{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin-bottom:16px}
.stat-c{background:#12121d;border:1px solid rgba(255,255,255,.05);border-radius:14px;padding:13px 6px;text-align:center}
.st-ic{width:27px;height:27px;border-radius:50%;margin:0 auto 8px;display:flex;align-items:center;justify-content:center;font-size:11px}
.st-v{font-size:19px;font-weight:700;color:#fff;line-height:1.1}
.st-l{font-size:9.5px;color:#77779a;margin-top:4px;line-height:1.35}
/* ── Модалка редактора шаблона ── */
#brv6-modal{position:absolute;inset:0;background:#0a0a12;z-index:6;display:none;flex-direction:column;border-radius:inherit;overflow:hidden}
#brv6-modal.show{display:flex;animation:brSlide .22s cubic-bezier(.34,1.3,.64,1)}
.ed-head{display:flex;align-items:center;gap:11px;padding:12px 14px;border-bottom:1px solid rgba(255,255,255,.06);background:#0d0d17;flex-shrink:0}
.ed-back{border:none;background:none;color:#a78bfa;font-size:15px;cursor:pointer;padding:4px 6px;border-radius:8px}
.ed-back:hover{background:rgba(124,58,237,.12)}
.ed-t{font-size:14px;font-weight:700;color:#fff}
.ed-body{flex:1;overflow-y:auto;padding:14px 14px 18px;scrollbar-width:thin;scrollbar-color:rgba(124,58,237,.35) transparent}
.ed-body::-webkit-scrollbar{width:4px}
.ed-body::-webkit-scrollbar-thumb{background:rgba(124,58,237,.35);border-radius:3px}
.fg{margin-bottom:13px}
.lab{display:block;font-size:11px;color:#77779a;margin-bottom:5px}
.inp,.ta{width:100%;box-sizing:border-box;background:#12121c;border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:10px 13px;color:#fff;font-size:13px;font-family:'Inter',sans-serif;outline:none;transition:.2s}
.inp:focus,.ta:focus{border-color:var(--br-pri);background:#13131f30}
.ta{min-height:110px;resize:vertical}
.check-row{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;color:#cfcfe0}
.check-row input{accent-color:var(--br-pri);width:15px;height:15px;cursor:pointer}
.color-row{display:flex;flex-wrap:wrap;gap:6px}
.cbtn{width:25px;height:25px;border-radius:50%;border:2px solid transparent;cursor:pointer;transition:.15s;padding:0}
.cbtn:hover,.cbtn.on{border-color:#fff;transform:scale(1.18)}
.chip{background:#15151f;border:1px solid rgba(255,255,255,.09);border-radius:20px;padding:5px 11px;font-size:11px;color:#a9a9c2;cursor:pointer;font-family:'Inter',sans-serif;transition:.15s}
.chip:hover{border-color:#a78bfa;color:#a78bfa}
.up-zone{border:2px dashed rgba(255,255,255,.1);border-radius:13px;padding:22px;text-align:center;cursor:pointer;transition:.15s;margin-bottom:10px}
.up-zone:hover,.up-zone.drag{border-color:#a78bfa;background:rgba(124,58,237,.06)}
.up-ico{font-size:30px;margin-bottom:6px}
.up-hint{font-size:12px;color:#77779a}
.ph-res{background:rgba(34,197,94,.06);border:1px solid rgba(34,197,94,.22);border-radius:11px;padding:11px;margin-top:8px;display:none}
.ph-url{font-size:11px;word-break:break-all;color:#4ade80;margin-bottom:8px}
.row-flex{display:flex;gap:6px;flex-wrap:wrap}
/* ── Змейка ── */
.sn-wrap{text-align:center}
#brv6-snake-canvas{border-radius:12px;display:block;margin:0 auto;border:1px solid rgba(255,255,255,.07);touch-action:none;background:#0b0b14}
.sn-score{font-size:14px;font-weight:700;color:#a78bfa;text-align:center;margin-bottom:10px}
.dpad{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;width:132px;margin:12px auto 0}
.dpad button{background:#15151f;border:1px solid rgba(255,255,255,.08);border-radius:9px;padding:11px 0;color:#fff;font-size:14px;cursor:pointer;transition:.15s}
.dpad button:hover{background:rgba(124,58,237,.3);border-color:var(--br-pri)}
.back-row{text-align:center;margin-top:14px}
@keyframes brPop{from{transform:scale(.85);opacity:0}to{transform:scale(1);opacity:1}}
@keyframes brSlide{from{transform:translateY(14px) scale(.98);opacity:0}to{transform:translateY(0) scale(1);opacity:1}}
@media(max-width:540px){
#brv6-panel{left:8px;right:8px;bottom:80px;width:auto;height:calc(100vh - 100px);border-radius:20px}
#brv6-fab{bottom:14px;left:14px;width:48px;height:48px;font-size:18px;border-radius:14px}
.brv6-brand b{font-size:12px;letter-spacing:1.8px}
.brv6-online-badge{display:none}
.brv6-search{min-width:72px}
.brv6-tab{flex-basis:64px;min-width:64px;height:56px}
}
@media(min-width:900px){
#brv6-panel{width:460px}
}
`;
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',
}));
}
}
/* ══════════════════════════════════════════════════════════
ЛОГИН — под макет (логотип BR, BLACK RUSSIA / BR ADMIN PANEL)
══════════════════════════════════════════════════════════ */
function buildLogin() {
detectForumUserIfStale();
const wrap = document.createElement('div');
wrap.id = 'brv6-login';
wrap.innerHTML = `
<canvas id="brv6-canvas"></canvas>
<div class="brlogin-bg"></div>
<div class="authcard">
<img class="lgo" src="${BR_LOGO}" alt="" onerror="this.style.display='none';this.nextElementSibling.style.display='block'">
<div class="lg-fb">🛡️</div>
<div class="lg-t1">BLACK RUSSIA</div>
<div class="lg-t2">BR ADMIN PANEL</div>
<div class="lg-div"></div>
<p class="lg-lead">Вход в панель</p>
<div class="lg-field">
<i class="fa-solid fa-user"></i>
<input type="text" id="brv6-nick-i" value="${nickname ? escapeHtml(nickname) : ''}" placeholder="Логин" autocomplete="off" />
</div>
<div class="lg-detect">${nickname ? '✅ Ник определён: ' + escapeHtml(nickname) : ''}</div>
<button class="btn-enter" id="brv6-go">Войти</button>
<div class="lg-foot">© 2026 BR Admin Panel</div>
</div>
`;
document.body.appendChild(wrap);
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(167,139,250,.26)';
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.title = 'BR Admin Panel';
fab.innerHTML = '<i class="fa-solid fa-shield-halved"></i><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);
else { SnakeGame.destroy(); closeEditor(); closeCtx(); }
};
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); }
}
/* ══════════════════════════════════════════════════════════
ПАНЕЛЬ — шапка (бренд+версия БЕЗ логотипа) / контент / нижняя навигация
══════════════════════════════════════════════════════════ */
const TABS = [
{ id:'tpl', icon:'fa-list-ul', label:'Шаблоны', title:'Шаблоны' },
{ id:'chat', icon:'fa-comments', label:'Поддержка',title:'Поддержка' },
{ id:'comm', icon:'fa-users-rectangle', label:'Чат', title:'Чат кураторов' },
{ id:'logs', icon:'fa-clock-rotate-left', label:'Логи', title:'Логи' },
{ id:'con', icon:'fa-paper-plane', label:'Связь', title:'Связь с разработчиком' },
{ id:'game', icon:'fa-gamepad', label:'Игры', title:'Игры' },
];
if (devUnlocked && !TABS.find(t => t.id === 'dev')) {
TABS.push({ id:'dev', icon:'fa-code', label:'Dev', title:'Dev / Статистика' });
}
function updatePgTitle(tab) {
const el = document.getElementById('brv6-pgtitle');
const t = TABS.find(x => x.id === tab);
if (el) el.textContent = t ? t.title : '';
}
function buildPanel() {
const p = document.getElementById('brv6-panel');
if (p) p.remove();
const panel = document.createElement('div');
panel.id = 'brv6-panel';
panel.innerHTML = `
<header class="brv6-hdr">
<button class="brv6-menu-btn" data-action="toggle-panel-nav" aria-label="Показать или скрыть меню" title="Меню"><i class="fa-solid fa-bars"></i></button>
<div class="brv6-brand" data-action="dev-gesture" title="BR Admin Panel Pro">
<b>BR STYLE</b><span class="ver">ADMIN</span>
</div>
<div class="brv6-sepv"></div>
<label class="brv6-search" title="Поиск по текущему разделу">
<i class="fa-solid fa-magnifying-glass"></i>
<input id="brv6-search" type="search" placeholder="Поиск настроек…" autocomplete="off" aria-label="Поиск по текущему разделу">
</label>
<span id="brv6-online-badge" class="brv6-online-badge" title="Кураторов онлайн">…</span>
<button class="brv6-theme-btn" data-action="open-theme" aria-label="Тема" title="Цвет темы"><i class="fa-solid fa-palette"></i></button>
<button class="brv6-x" data-action="close-panel" aria-label="Закрыть"><i class="fa-solid fa-xmark"></i></button>
</header>
<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}" title="${t.label}">
<i class="fa-solid ${t.icon}"></i>${t.id==='chat'?'<span class="brv6-tab-b" id="brv6-tbadge">0</span>':''}
</button>`).join('')}
</nav>
<main class="brv6-main" id="brv6-main"></main>
<div id="brv6-modal"></div>
`;
document.body.appendChild(panel);
panel.classList.toggle('open', UI.panelOpen);
panel.addEventListener('click', onPanelClick);
updatePgTitle(UI.activeTab);
renderTab(UI.activeTab);
updateTrackerUI();
const search = document.getElementById('brv6-search');
if (search) search.oninput = () => applyPanelSearch(search.value);
syncMobileKeyboardLayout();
}
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);
SnakeGame.destroy();
closeEditor();
closeCtx();
updatePgTitle(tab);
renderTab(tab);
}
function applyPanelSearch(query) {
const main = document.getElementById('brv6-main');
if (!main) return;
const q = String(query || '').trim().toLowerCase();
const selectors = '.tpl-card,.log-row,.ct-card,.gm-card,.stat-c,.about,.hist-link,.preset-card,.fg,.theme-hero,.empty-note';
main.querySelectorAll(selectors).forEach(node => {
const match = !q || node.textContent.toLowerCase().includes(q);
node.classList.toggle('br-search-hide', !match);
});
}
/* ── Тема / Стиль форума ── */
function applyTheme(pri, pril) {
document.documentElement.style.setProperty('--br-pri', pri);
document.documentElement.style.setProperty('--br-pri-l', pril);
document.documentElement.style.setProperty('--br-pri-d', pri);
}
function loadTheme() {
applyForumSkin(loadForumSkin());
}
const VISUAL_PRESETS = {
aurora: {
label: 'Aurora Glass',
note: 'Холодное стекло',
primary: '#7c3aed',
accent: '#22d3ee',
btnStyle: 'glass',
avaStyle: 'gradient',
textTheme: 'cyberpunk',
blur: 20,
radius: 16,
},
crimson: {
label: 'Crimson Night',
note: 'Контрастный BR',
primary: '#ef4444',
accent: '#f97316',
btnStyle: 'glow',
avaStyle: 'glow',
textTheme: 'cherry',
blur: 17,
radius: 13,
},
ocean: {
label: 'Ocean Pulse',
note: 'Синий неон',
primary: '#0284c7',
accent: '#22d3ee',
btnStyle: 'glass',
avaStyle: 'gradient',
textTheme: 'ocean',
blur: 18,
radius: 18,
},
gold: {
label: 'Gold Command',
note: 'Тёплый премиум',
primary: '#d97706',
accent: '#facc15',
btnStyle: 'def',
avaStyle: 'glow',
textTheme: 'gold',
blur: 14,
radius: 11,
},
};
function applyVisualPreset(name) {
const preset = VISUAL_PRESETS[name];
if (!preset) return;
const fields = {
'st-primary': preset.primary,
'st-accent': preset.accent,
'st-btn': preset.btnStyle,
'st-ava': preset.avaStyle,
'st-text': preset.textTheme,
'st-blur': preset.blur,
'st-radius': preset.radius,
};
Object.entries(fields).forEach(([id, value]) => {
const field = document.getElementById(id);
if (!field) return;
field.value = value;
field.dispatchEvent(new Event('input', { bubbles: true }));
});
document.querySelectorAll('.preset-card').forEach(card => {
card.classList.toggle('active', card.dataset.arg === name);
});
toast(`🎨 Пресет «${preset.label}» выбран`);
}
function closeTheme() {
const m = document.getElementById('brv6-modal');
if (m) { m.classList.remove('show'); m.innerHTML = ''; }
}
function openThemePicker() {
const modal = document.getElementById('brv6-modal');
if (!modal) return;
const s = loadForumSkin();
const fontOpts = ['Inter','Roboto','Manrope','Rajdhani','Montserrat']
.map(f => `<option value="${f}"${s.font===f?' selected':''}>${f}</option>`).join('');
const btnOpts = [['def','Обычный'],['glass','Стекло'],['glow','Свечение']]
.map(([v,l]) => `<option value="${v}"${s.btnStyle===v?' selected':''}>${l}</option>`).join('');
const avaOpts = [['def','Обычный'],['gradient','Градиент'],['glow','Свечение']]
.map(([v,l]) => `<option value="${v}"${s.avaStyle===v?' selected':''}>${l}</option>`).join('');
const textOpts = Object.keys(TEXT_THEMES)
.map(k => `<option value="${k}"${s.textTheme===k?' selected':''}>${k}</option>`).join('');
modal.innerHTML = `
<div class="ed-head">
<button class="ed-back" data-action="close-editor" aria-label="Назад"><i class="fa-solid fa-arrow-left"></i></button>
<span class="ed-t">Стиль форума</span>
</div>
<div class="ed-body">
<div class="theme-hero">
<strong>Настрой стиль под себя</strong>
<span>Пресет меняет цвета, стекло, кнопки, аватары и контраст. Фон анализируется отдельно при сохранении.</span>
</div>
<div class="subhead">Готовые стили</div>
<div class="preset-grid">
${Object.entries(VISUAL_PRESETS).map(([key, preset]) => `
<button class="preset-card" data-action="theme-preset" data-arg="${key}" style="--preset:linear-gradient(135deg,${preset.primary},${preset.accent})">
<b>${preset.label}</b><small>${preset.note}</small>
</button>`).join('')}
</div>
<div class="subhead">Цвет и фон</div>
<div class="fg"><label class="lab">Основной цвет</label><input class="inp" id="st-primary" type="color" value="${s.primary}" style="height:42px;padding:4px"></div>
<div class="fg"><label class="lab">Доп. цвет</label><input class="inp" id="st-accent" type="color" value="${s.accent}" style="height:42px;padding:4px"></div>
<div class="fg"><label class="lab">Фон (ссылка на картинку, необязательно)</label><input class="inp" id="st-bg" value="${escapeHtml(s.bg)}" placeholder="https://..."></div>
<div class="up-zone" id="st-bg-drop">
<div class="up-ico">🖼️</div>
<div class="up-hint">Или выбери фото с телефона</div>
<input type="file" id="st-bg-file" accept="image/*" style="display:none">
</div>
<div class="fg"><label class="lab">Радиус углов: <span id="st-rad-v">${s.radius}</span>px</label><input id="st-radius" type="range" min="0" max="26" value="${s.radius}" style="width:100%"></div>
<div class="fg"><label class="check-row"><input type="checkbox" id="st-blur-on" ${s.blurEnabled?'checked':''}> Размытие всплывающих окон</label></div>
<div class="fg"><label class="lab">Сила размытия: <span id="st-blur-v">${s.blur}</span>px</label><input id="st-blur" type="range" min="0" max="30" value="${s.blur}" style="width:100%"></div>
<div class="fg"><label class="lab">Шрифт</label><select class="sel" id="st-font">${fontOpts}</select></div>
<div class="fg"><label class="lab">Стиль кнопок</label><select class="sel" id="st-btn">${btnOpts}</select></div>
<div class="fg"><label class="lab">Стиль аватара</label><select class="sel" id="st-ava">${avaOpts}</select></div>
<div class="fg"><label class="lab">Цветовая тема ников/цитат</label><select class="sel" id="st-text">${textOpts}</select></div>
<div class="subhead">Своя AI API</div>
<div class="fg"><label class="lab">Провайдер</label><select class="sel" id="ai-provider">
<option value="pollinations"${loadAIConfig().provider === 'pollinations' ? ' selected' : ''}>Pollinations · без ключа</option>
<option value="custom"${loadAIConfig().provider === 'custom' ? ' selected' : ''}>Своя OpenAI-совместимая API</option>
</select></div>
<div class="fg"><label class="lab">Endpoint</label><input class="inp" id="ai-endpoint" value="${escapeHtml(loadAIConfig().endpoint)}" placeholder="https://api.openai.com/v1/chat/completions"></div>
<div class="fg"><label class="lab">Модель</label><input class="inp" id="ai-model" value="${escapeHtml(loadAIConfig().model)}" placeholder="gpt-4o-mini"></div>
<div class="fg"><label class="lab">API-ключ <span style="color:#77779a">(хранится только локально)</span></label><input class="inp" id="ai-key" type="password" value="${escapeHtml(loadAIConfig().apiKey)}" placeholder="Ключ не отправляется в чат"></div>
<div class="api-note"><b>/ai</b> будет использовать твою API при выборе «Своя…». Если ключ не указан, останется бесплатный режим Pollinations.</div>
<button class="btnp" data-action="save-style" style="width:100%;padding:12px">💾 Применить</button>
</div>
`;
modal.classList.add('show');
document.getElementById('st-radius').oninput = e => { document.getElementById('st-rad-v').textContent = e.target.value; };
document.getElementById('st-blur').oninput = e => { document.getElementById('st-blur-v').textContent = e.target.value; };
const bgDrop = document.getElementById('st-bg-drop');
bgDrop.onclick = () => document.getElementById('st-bg-file').click();
document.getElementById('st-bg-file').onchange = e => {
const f = e.target.files[0];
if (!f) return;
const img = new Image();
img.onload = () => {
const MAX_BYTES = 700 * 1024; // грубый лимит под GM-хранилище
const attempts = [[1600, 0.85], [1100, 0.7], [750, 0.55]];
let dataUrl = '';
for (const [maxw, q] of attempts) {
const cv = document.createElement('canvas');
const scale = Math.min(1, maxw / img.width);
cv.width = img.width * scale; cv.height = img.height * scale;
cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
dataUrl = cv.toDataURL('image/webp', q);
if (dataUrl.length * 0.75 <= MAX_BYTES) break; // base64 ~ 4/3 реального размера
}
URL.revokeObjectURL(img.src);
if (dataUrl.length * 0.75 > MAX_BYTES) {
toast('❌ Картинка слишком большая даже после сжатия — возьми другую или используй ссылку');
return;
}
document.getElementById('st-bg').value = dataUrl;
const luma = getImageLuma(img);
if (Number.isFinite(luma)) {
document.getElementById('st-bg').dataset.luma = String(luma);
}
toast('✅ Фон готов, жми «Применить»');
};
img.src = URL.createObjectURL(f);
};
}
function isValidBgUrl(v) {
if (!v) return true;
return /^https:\/\//i.test(v) || /^data:image\//i.test(v);
}
function saveStyleFromModal() {
const bgInput = document.getElementById('st-bg');
const bg = bgInput.value.trim();
if (!isValidBgUrl(bg)) {
toast('❌ Фон должен быть ссылкой https:// или загруженным файлом');
return;
}
const aiEndpoint = document.getElementById('ai-endpoint');
const aiModel = document.getElementById('ai-model');
const aiKey = document.getElementById('ai-key');
const aiProvider = document.getElementById('ai-provider');
if (aiEndpoint && aiModel && aiKey && aiProvider) {
saveAIConfig({
provider: aiProvider.value,
endpoint: aiEndpoint.value,
model: aiModel.value,
apiKey: aiKey.value,
});
}
const finish = luma => {
const patch = {
primary: document.getElementById('st-primary').value,
accent: document.getElementById('st-accent').value,
bg,
bgLuma: Number.isFinite(luma) ? luma : 0.18,
radius: parseInt(document.getElementById('st-radius').value),
blurEnabled: document.getElementById('st-blur-on').checked,
blur: parseInt(document.getElementById('st-blur').value),
font: document.getElementById('st-font').value,
btnStyle: document.getElementById('st-btn').value,
avaStyle: document.getElementById('st-ava').value,
textTheme: document.getElementById('st-text').value,
};
saveForumSkin(patch);
toast('✅ Стиль адаптирован под фон и применён');
closeTheme();
};
const savedLuma = parseFloat(bgInput.dataset.luma);
if (bg && !Number.isFinite(savedLuma)) {
toast('🔎 Анализируем светлость фона...');
analyzeBackground(bg, finish);
} else {
finish(bg ? savedLuma : 0.18);
}
}
/* ══════════════════════════════════════════════════════════
СТИЛЬ ФОРУМА — перекраска самого форума (не только панели):
фон, цвета, радиус, блюр, шрифт, стиль кнопок/аватаров, тема
текста. Классы XenForo — открытая разметка, ничего чужого
(токенов/бэкендов) тут нет, только CSS.
══════════════════════════════════════════════════════════ */
const FORUM_SEL = {
trans: '.block-container, .message, .structItem, .p-nav, .pageNav-page, .button, .button--primary, .inputGroup-text, input, textarea, select, .blockMessage, .bbCodeBlock, .widget-container, .p-header, .p-body, .p-body-header, .p-body-sidebar, .block-header, .block-footer, .block-formSection, .filterBar, .memberTooltip, .tooltip, .attachedFiles, .node-body, .offCanvasMenu-list',
blur: '.menu-content, .offCanvasMenu-content, .overlay-container .overlay-content, .tooltip-content, .memberTooltip, .widget-container .widget, .fr-popup, .fr-dropdown-menu',
};
const TEXT_THEMES = {
default: { nick:'#a78bfa', quote:'#c4b5fd' },
ocean: { nick:'#00b0ff', quote:'#81d4fa' },
forest: { nick:'#76ff03', quote:'#c5e1a5' },
gold: { nick:'#ffd600', quote:'#ffe082' },
cherry: { nick:'#ff1744', quote:'#ffcdd2' },
cyberpunk: { nick:'#00e5ff', quote:'#84ffff' },
synthwave: { nick:'#f0abfc', quote:'#d8b4fe' },
matrix: { nick:'#00ff00', quote:'#b9f6ca' },
};
const STYLE_DEFAULTS = {
bg: '', primary: '#7c3aed', accent: '#8b5cf6', radius: 12,
blurEnabled: true, blur: 15, font: 'Inter',
btnStyle: 'def', avaStyle: 'def', textTheme: 'default',
bgLuma: 0.18,
};
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function getImageLuma(image) {
try {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0, 32, 32);
const pixels = context.getImageData(0, 0, 32, 32).data;
let total = 0;
let weight = 0;
for (let i = 0; i < pixels.length; i += 4) {
const alpha = pixels[i + 3] / 255;
if (!alpha) continue;
const luma = (0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2]) / 255;
total += luma * alpha;
weight += alpha;
}
return weight ? clamp(total / weight, 0, 1) : null;
} catch {
return null;
}
}
function analyzeBackground(source, callback) {
if (!source) { callback(null); return; }
const image = new Image();
if (/^https:\/\//i.test(source)) image.crossOrigin = 'anonymous';
image.onload = () => callback(getImageLuma(image));
image.onerror = () => callback(null);
image.src = source;
}
function forumSkinCSS(s) {
const filter = s.blurEnabled ? `blur(${s.blur}px) saturate(160%)` : 'none';
const hasBackground = Boolean(s.bg);
const luma = Number.isFinite(Number(s.bgLuma)) ? clamp(Number(s.bgLuma), 0, 1) : 0.18;
const overlay = hasBackground ? clamp(0.18 + luma * 0.38, 0.18, 0.56) : 0;
const layerOne = hasBackground ? clamp(0.30 + luma * 0.35, 0.30, 0.70) : 0.50;
const layerTwo = hasBackground ? clamp(0.40 + luma * 0.36, 0.40, 0.78) : 0.62;
const textShadow = hasBackground ? '0 1px 3px rgba(0,0,0,.78)' : 'none';
const btnRule = s.btnStyle === 'glass'
? `${FORUM_SEL.trans.split(',').filter(x=>x.includes('button')).join(',')}{background:linear-gradient(135deg,color-mix(in srgb,var(--br-primary) 35%,rgba(255,255,255,.12)),color-mix(in srgb,var(--br-accent) 20%,rgba(255,255,255,.05)))!important;backdrop-filter:blur(10px)!important;border:1px solid rgba(255,255,255,.2)!important;color:#fff!important}`
: s.btnStyle === 'glow'
? `.button,.button--primary{background:rgba(var(--br-tint),.25)!important;border:1px solid var(--br-primary)!important;box-shadow:0 0 16px color-mix(in srgb,var(--br-primary) 70%,transparent)!important;color:#fff!important}`
: `.button--primary{background:var(--br-primary)!important;border:none!important;color:#fff!important}`;
const avaRule = s.avaStyle === 'glow'
? `.avatar{border-radius:50%!important;box-shadow:0 0 14px var(--br-primary)!important;border:2px solid var(--br-primary)!important}`
: s.avaStyle === 'gradient'
? `.avatar{padding:3px!important;background:linear-gradient(45deg,var(--br-primary),var(--br-accent))!important;border-radius:50%!important}`
: '';
return `
:root{--br-bg:${hasBackground ? `url('${s.bg.replace(/'/g, "%27")}')` : 'none'};--br-primary:${s.primary};--br-accent:${s.accent};--br-rad:${s.radius}px;--br-filter:${filter};--br-tint:${hexRGB(s.primary)};--br-block-bg:14,14,20;--br-bg-overlay:${overlay};--br-l1:${layerOne};--br-l2:${layerTwo};--br-text-shadow:${textShadow}}
html{background-color:rgb(var(--br-block-bg))!important;background-image:linear-gradient(rgba(7,7,14,var(--br-bg-overlay)),rgba(7,7,14,var(--br-bg-overlay))),var(--br-bg)!important;background-position:center;background-size:cover;background-attachment:fixed;scrollbar-color:var(--br-primary) transparent;scrollbar-width:thin}
body{background:transparent!important;min-height:100vh;text-shadow:var(--br-text-shadow)}
body{font-family:'${s.font}',sans-serif!important}
.p-pageWrapper,.p-body-inner,.p-footer,.p-sectionLinks{background:rgba(var(--br-block-bg),var(--br-l1))!important;border-color:transparent!important}
${FORUM_SEL.trans}{background:rgba(var(--br-block-bg),var(--br-l2))!important;border-radius:var(--br-rad)!important;border-color:transparent!important;transition:.25s}
${FORUM_SEL.blur}{backdrop-filter:var(--br-filter)!important;-webkit-backdrop-filter:var(--br-filter)!important}
::-webkit-scrollbar-thumb{background:var(--br-primary)!important;border-radius:8px}
a.username{color:var(--br-nick,${(TEXT_THEMES[s.textTheme] || TEXT_THEMES.default).nick})!important}
.bbCodeBlock--quote{border-left-color:var(--br-primary)!important}
#brv6-panel{background:rgba(var(--br-block-bg),.24)!important}
#brv6-panel .brv6-hdr,#brv6-panel .brv6-tracker,#brv6-panel .brv6-nav,#brv6-panel .chat-bar{background:rgba(var(--br-block-bg),.16)!important}
#brv6-panel .tpl-card,#brv6-panel .log-row,#brv6-panel .ct-card,#brv6-panel .gm-card,#brv6-panel .about,#brv6-panel .hist-link{background:rgba(var(--br-block-bg),.18)!important}
#brv6-panel .msg.op{background:rgba(var(--br-block-bg),.26)!important}
${btnRule}
${avaRule}
`;
}
function applyForumSkin(s) {
let el = document.getElementById('brv6-forum-skin');
if (!el) { el = document.createElement('style'); el.id = 'brv6-forum-skin'; document.head.appendChild(el); }
el.textContent = forumSkinCSS(s);
applyTheme(s.primary, s.accent);
}
function loadForumSkin() {
const saved = Store.get('brp_style', {});
return {
...STYLE_DEFAULTS,
...(saved && typeof saved === 'object' && !Array.isArray(saved) ? saved : {}),
};
}
function saveForumSkin(patch) {
const s = { ...loadForumSkin(), ...patch };
Store.set('brp_style', s);
applyForumSkin(s);
return s;
}
let ctxEl = null;
function closeCtx() {
if (ctxEl) { ctxEl.remove(); ctxEl = null; }
document.removeEventListener('click', ctxOutside, { capture:true });
}
function ctxOutside(e) {
if (ctxEl && !ctxEl.contains(e.target)) closeCtx();
}
function openTplMenu(anchorBtn, id) {
closeCtx();
const t = templates.find(x => x.id == id);
if (!t) return;
ctxEl = document.createElement('div');
ctxEl.className = 'brv6-ctx';
const mk = (icon, label, cls, fn) => {
const b = document.createElement('button');
b.className = 'ctx-i' + (cls ? ' ' + cls : '');
b.innerHTML = `<i class="fas ${icon}"></i>${label}`;
b.onclick = () => { closeCtx(); fn(); };
return b;
};
ctxEl.appendChild(mk('fa-play', 'Применить', '', () => applyTemplate(t)));
ctxEl.appendChild(mk('fa-pen-to-square', 'Редактировать', '', () => openEditor(id)));
ctxEl.appendChild(mk('fa-copy', 'Копировать BB', '', () => {
copyText(
applyMacros(t.content),
() => { addLog('Копирование', t.name); toast('📋 Скопировано'); },
() => toast('❌ Не удалось скопировать — скопируй вручную')
);
}));
ctxEl.appendChild(mk('fa-trash', 'Удалить', 'danger', () => {
if (!confirm(`Удалить "${t.name}"?`)) return;
addLog('Удалён шаблон', t.name);
templates = templates.filter(x => x.id != id);
saveTpls();
if (UI.activeTab === 'tpl') renderTab('tpl');
}));
const r = anchorBtn.getBoundingClientRect();
document.body.appendChild(ctxEl);
const mw = ctxEl.offsetWidth, mh = ctxEl.offsetHeight;
let top = r.bottom + 6, left = Math.min(r.right - mw, innerWidth - mw - 8);
if (top + mh > innerHeight - 8) top = Math.max(8, r.top - mh - 6);
ctxEl.style.top = top + 'px';
ctxEl.style.left = Math.max(8, left) + 'px';
setTimeout(() => document.addEventListener('click', ctxOutside, { capture:true }), 0);
}
/* ── Редактор шаблона (модалка внутри панели) ── */
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 editingTplId = null;
let lastUploadedUrl = '';
function openEditor(id) {
const modal = document.getElementById('brv6-modal');
if (!modal) return;
editingTplId = id != null ? id : null;
const t = id != null ? templates.find(x => x.id == id) : null;
const pfxOpts = PREFIXES.map(p => `<option value="${p.id}"${t && t.prefix === p.id ? ' selected':''}>${p.name}</option>`).join('');
modal.innerHTML = `
<div class="ed-head">
<button class="ed-back" data-action="close-editor" aria-label="Назад"><i class="fa-solid fa-arrow-left"></i></button>
<span class="ed-t">${t ? 'Редактирование шаблона' : 'Новый шаблон'}</span>
</div>
<div class="ed-body">
<div class="fg"><label class="lab">Название</label><input class="inp" id="e-name" placeholder="Название шаблона" value="${t ? escapeHtml(t.name) : ''}" /></div>
<div class="fg"><label class="lab">Префикс</label><select class="sel" id="e-pfx" style="flex:none">${pfxOpts}</select></div>
<div class="fg"><label class="check-row"><input type="checkbox" id="e-pin" ${t && t.autopin ? 'checked':''}> 📌 Автозакреп</label></div>
<div class="fg">
<label class="lab">Цвет текста</label>
<div class="color-row">
${EDITOR_COLORS.map(c => `<button class="cbtn" style="background:${c}" data-action="pick-color" data-arg="${c}" title="${c}"></button>`).join('')}
</div>
</div>
<div class="fg">
<label class="lab">BB-вставки</label>
<div class="color-row">
${BB_CHIPS.map((c,i) => `<button class="chip" data-action="insert-bb" data-arg="${i}">${c[0]}</button>`).join('')}
</div>
</div>
<div class="fg">
<label class="lab">Текст шаблона ({{greeting}}, {{username}}, {{uid}})</label>
<textarea class="ta" id="e-text" placeholder="[COLOR=#FF00FF]{{greeting}}, {{username}}[/COLOR] Ваш текст...">${t ? escapeHtml(t.content) : ''}</textarea>
</div>
<button class="btnp" data-action="save-template" style="width:100%;padding:12px;margin-bottom:14px">💾 Сохранить шаблон</button>
<div class="brv6-sec" style="margin-top:4px">Загрузка фото (imgbb)</div>
<div class="fg"><label class="lab">imgbb API ключ</label><input class="inp" id="e-imgkey" value="${escapeHtml(Store.get('brp_imgbb',''))}" placeholder="Ключ с imgbb.com → API" /></div>
<button class="btng sm" id="e-savekey" style="margin-bottom:10px">💾 Сохранить ключ</button>
<div class="up-zone" id="e-drop">
<div class="up-ico">📷</div>
<div class="up-hint" id="e-hint">Нажми или перетащи фото</div>
<input type="file" id="e-file" accept="image/*" style="display:none">
</div>
<div class="ph-res" id="e-result">
<div class="ph-url" id="e-url"></div>
<div class="row-flex">
<button class="btnp sm" id="e-copy-url">📋 Ссылка</button>
<button class="btng sm" id="e-copy-bb">🖼 BB-код</button>
<button class="btng sm" id="e-ins-ta">↑ В шаблон</button>
</div>
</div>
</div>
`;
modal.classList.add('show');
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); };
document.getElementById('e-file').onchange = e => { if (e.target.files[0]) handlePhoto(e.target.files[0]); };
document.getElementById('e-copy-url').onclick = () => {
copyText(
lastUploadedUrl,
() => toast('✅ Ссылка скопирована'),
() => toast('❌ Не удалось скопировать')
);
};
document.getElementById('e-copy-bb').onclick = () => {
copyText(
`[img]${lastUploadedUrl}[/img]`,
() => toast('✅ BB-код скопирован'),
() => toast('❌ Не удалось скопировать')
);
};
document.getElementById('e-ins-ta').onclick = () => {
const ta = document.getElementById('e-text');
ta.value += `[CENTER][img]${lastUploadedUrl}[/img][/CENTER]\n\n`;
ta.focus(); toast('✅ Вставлено');
};
setTimeout(() => document.getElementById('e-name')?.focus(), 60);
}
function closeEditor() {
const m = document.getElementById('brv6-modal');
if (m) { m.classList.remove('show'); m.innerHTML = ''; }
editingTplId = null;
}
function pickColor(el, color) {
document.querySelectorAll('#brv6-modal .cbtn').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 tx = document.getElementById('e-text').value.trim();
const pf = parseInt(document.getElementById('e-pfx').value);
const pin = document.getElementById('e-pin').checked;
if (!n || !tx) { toast('⚠️ Заполните название и текст'); return; }
if (editingTplId != null) {
const ex = templates.find(x => x.id == editingTplId);
if (ex) Object.assign(ex, { name:n, prefix:pf, autopin:pin, content:tx });
addLog('Шаблон изменён', n);
} else {
templates.push({ id: Date.now(), name: n, prefix: pf, autopin: pin, content: tx });
addLog('Шаблон создан', n);
}
saveTpls();
toast(`✅ "${n}" сохранён!`);
closeEditor();
if (UI.activeTab === 'tpl') renderTab('tpl');
}
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) {
toast('📤 Загружаем фото...');
const hint = document.getElementById('e-hint');
if (hint) hint.textContent = '⏳ Загрузка...';
uploadPhoto(file, (url, err) => {
const hintNow = document.getElementById('e-hint');
if (hintNow) hintNow.textContent = 'Нажми или перетащи фото';
if (err) { toast('❌ ' + err); return; }
lastUploadedUrl = url;
const urlEl = document.getElementById('e-url');
const resultEl = document.getElementById('e-result');
if (!urlEl || !resultEl) return; // редактор закрыли, пока фото грузилось
urlEl.textContent = url;
resultEl.style.display = 'block';
toast('✅ Фото загружено!');
});
}
/* ── Роутер кликов по 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 'toggle-panel-nav': {
const nav = document.querySelector('#brv6-panel .brv6-nav');
const icon = el.querySelector('i');
if (nav) nav.classList.toggle('nav-collapsed');
if (icon) icon.classList.toggle('fa-bars');
if (icon) icon.classList.toggle('fa-chevron-down');
break;
}
case 'close-panel':
UI.panelOpen = false;
document.getElementById('brv6-panel').classList.remove('open');
SnakeGame.destroy();
closeEditor();
closeCtx();
closeTheme();
break;
case 'switch-tab':
switchTab(arg);
break;
case 'dev-gesture':
handleBrandTap();
break;
case 'open-theme':
openThemePicker(el);
break;
case 'theme-preset':
applyVisualPreset(arg);
break;
case 'apply-template': {
const t = templates.find(x => x.id == arg);
if (t) applyTemplate(t);
break;
}
case 'tpl-menu':
openTplMenu(el, arg);
break;
case 'open-editor':
closeCtx();
openEditor(null);
break;
case 'edit-template':
closeCtx();
openEditor(arg);
break;
case 'close-editor':
closeEditor();
break;
case 'save-template':
saveNewTemplate();
break;
case 'save-style':
saveStyleFromModal();
break;
case 'insert-bb':
insertBBChip(arg);
break;
case 'pick-color':
pickColor(el, arg);
break;
case 'delete-menu-item':
break;
case 'send-chat':
sendChatMessage();
break;
case 'send-community':
sendCommunityMessage();
break;
case 'refresh-history':
loadHistory(drawMessages);
toast('🕘 История обновлена');
break;
case 'clear-logs':
if (!confirm('Очистить логи?')) break;
logs = []; saveLogs();
if (UI.activeTab === 'logs') renderTab('logs');
break;
case 'launch-game':
launchBlackRussia();
break;
case 'open-snake':
SnakeGame.start(document.getElementById('brv6-main'));
break;
case 'snake-back':
SnakeGame.destroy();
updatePgTitle('game');
renderGameTab(document.getElementById('brv6-main'));
break;
case 'snake-dir':
SnakeGame.setDirection(arg);
break;
}
}
// 5 тапов по «BR ADMIN» в шапке → запрос dev-пароля
let brandTaps = 0, brandTapReset = null;
function handleBrandTap() {
brandTaps++;
clearTimeout(brandTapReset);
brandTapReset = setTimeout(() => { brandTaps = 0; }, 2200);
if (brandTaps < 5) return;
brandTaps = 0;
const pass = prompt('Пароль разработчика:');
if (!pass) return;
api('/dev/grant', { target: nickname, admin_pass: pass }, (e, d) => {
if (e || !d?.ok) { toast('❌ Неверный пароль'); return; }
devUnlocked = true;
Store.set('brp_dev', true);
toast('✅ Dev-доступ открыт');
if (!TABS.find(t => t.id === 'dev')) TABS.push({ id:'dev', icon:'fa-code', label:'Dev', title:'Dev / Статистика' });
checkForumActions();
Timers.every('poll-forum-actions', 15000, checkForumActions);
buildPanel(); // пересобираем панель — появится вкладка Dev
});
}
/* ══════════════════════════════════════════════════════════
РЕНДЕР ТАБОВ
══════════════════════════════════════════════════════════ */
function renderTab(tab) {
const main = document.getElementById('brv6-main');
if (!main) return;
main.classList.toggle('chat-mode', tab === 'chat' || tab === 'comm');
const renderers = {
tpl: renderTemplatesTab, chat: renderChatTab, comm: renderCommunityTab, logs: renderLogsTab,
con: renderContactTab, game: renderGameTab, dev: renderDevTab,
};
(renderers[tab] || renderTemplatesTab)(main);
const search = document.getElementById('brv6-search');
if (search) applyPanelSearch(search.value);
}
/* ── ШАБЛОНЫ ── */
function renderTemplatesTab(body) {
const authorName = ForumUser.name ? escapeHtml(ForumUser.name) : 'не определён';
const list = templates.length
? `<div class="tpl-grid">` + templates.map(tpl => {
const p = prefixById.get(tpl.prefix) || prefixById.get(0);
const prev = tpl.content.replace(/\[[^\]]*\]/g,'').replace(/\s+/g,' ').trim().substring(0,72);
return `
<div class="tpl-card">
<div class="tpl-ic" style="background:${hexA(p.color,.14)};color:${p.color}" title="${p.name}">
<i class="fas ${tplIcon(tpl.prefix)}"></i>
</div>
<div class="tpl-info" data-action="edit-template" data-arg="${tpl.id}" title="Клик — редактировать">
<div class="tpl-name">${escapeHtml(tpl.name)}${tpl.autopin ? '<span style="font-size:11px">📌</span>' : ''}</div>
<div class="tpl-prev">${escapeHtml(prev)}…</div>
</div>
<div class="tpl-side">
<button class="btnp sm" data-action="apply-template" data-arg="${tpl.id}">Применить</button>
<button class="tpl-kebab" data-action="tpl-menu" data-arg="${tpl.id}" aria-label="Меню"><i class="fas fa-ellipsis-vertical"></i></button>
</div>
</div>`;
}).join('') + '</div>'
: '<div class="empty-note">Нет шаблонов.<br>Нажми «+ Новый», чтобы создать первый.</div>';
body.innerHTML = `
<div class="tpl-head">
<p class="brv6-sec">Шаблоны</p>
<button class="btnp sm" data-action="open-editor">+ Новый шаблон</button>
</div>
<div class="mini-author">👤 Автор темы: <b>${authorName}</b></div>
${list}
<div class="hintbar"><i class="far fa-hand-pointer"></i> Клик по шаблону — редактирование · ⋮ — действия</div>
`;
}
/* ── ПОДДЕРЖКА ── */
function renderChatTab(body) {
body.innerHTML = `
<div class="chat-scroll">
<div class="chat-hero">
<div class="cht-ic"><i class="fa-solid fa-comments"></i></div>
<h3 class="cht-h">Поддержка</h3>
<p class="cht-p">Напишите ваш вопрос, и мы ответим как можно скорее.<br>Команды: /help, /ask, /report question|bug, /rules</p>
</div>
<div class="brv6-msgs" id="brv6-msgs">
<div class="empty-note" style="padding:12px">Загрузка…</div>
</div>
<div class="hist-link" data-action="refresh-history">
<i class="far fa-clock"></i><span>История сообщений</span><i class="fas fa-chevron-right chev"></i>
</div>
</div>
<div class="chat-bar">
<textarea id="brv6-cinp" placeholder="Ваш вопрос или жалоба…" rows="1"></textarea>
<button class="send-btn" data-action="send-chat" aria-label="Отправить"><i class="fa-solid fa-paper-plane"></i></button>
</div>
`;
const input = document.getElementById('brv6-cinp');
bindMobileInput(input);
input.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 class="empty-note" style="padding:16px">Сообщений нет.<br>Напишите первым!</div>';
return;
}
el.innerHTML = msgs.map(m => `
<div class="msg ${m.role==='support'?'op':'me'}">
${escapeHtml(m.text)}
<div class="msg-time">${escapeHtml(m.time||'')}</div>
</div>`).join('');
const sc = document.querySelector('.chat-scroll');
if (sc) sc.scrollTop = sc.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 communityMsgHtml(m) {
const mine = m.nick === nickname;
const roleBadge = m.role === 'admin' ? '<span class="msg-role r-admin">Admin</span>'
: m.role === 'dev' ? '<span class="msg-role r-dev">Dev</span>'
: m.role === 'ai' ? '<span class="msg-role r-ai">ИИ</span>' : '';
return `
<div class="msg ${mine ? 'me' : 'op'}">
${mine ? '' : `<div class="msg-who">${escapeHtml(m.nick)}${roleBadge}</div>`}
${escapeHtml(m.text)}
<div class="msg-time">${escapeHtml(m.time||'')}</div>
</div>`;
}
function appendCommunityMessages(msgs) {
const el = document.getElementById('brv6-comm-msgs');
if (!el) return;
el.querySelector('.empty-note')?.remove();
el.insertAdjacentHTML('beforeend', msgs.map(communityMsgHtml).join(''));
const sc = document.querySelector('#brv6-main .chat-scroll');
if (sc) sc.scrollTop = sc.scrollHeight;
}
function renderCommunityTab(body) {
body.innerHTML = `
<div class="chat-scroll">
<div class="chat-hero">
<div class="cht-ic"><i class="fa-solid fa-users-rectangle"></i></div>
<h3 class="cht-h">Чат кураторов</h3>
<p class="cht-p">Общий чат для всех, кто открыл панель. Команда <b>/ai вопрос</b> — спросить нейросеть.</p>
</div>
<div class="brv6-msgs" id="brv6-comm-msgs">
<div class="empty-note" style="padding:12px">Загрузка…</div>
</div>
</div>
<div class="chat-bar">
<textarea id="brv6-comm-inp" placeholder="Сообщение или /ai вопрос…" rows="1"></textarea>
<button class="send-btn" data-action="send-community" aria-label="Отправить"><i class="fa-solid fa-paper-plane"></i></button>
</div>
`;
const input = document.getElementById('brv6-comm-inp');
bindMobileInput(input);
input.onkeydown = e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendCommunityMessage(); }
};
commAfterId = 0;
api('/community/poll', { after_id: 0 }, (e, d) => {
const el = document.getElementById('brv6-comm-msgs');
if (!el) return;
if (e || !d?.messages?.length) { el.innerHTML = '<div class="empty-note" style="padding:16px">Сообщений нет.<br>Напишите первым!</div>'; return; }
commAfterId = d.messages[d.messages.length - 1].id;
el.innerHTML = d.messages.map(communityMsgHtml).join('');
const sc = document.querySelector('#brv6-main .chat-scroll');
if (sc) sc.scrollTop = sc.scrollHeight;
});
}
function sendCommunityMessage() {
const inp = document.getElementById('brv6-comm-inp');
if (!inp) return;
const text = inp.value.trim();
if (!text) return;
inp.value = '';
if (/^\/ai\s+/i.test(text)) {
const prompt = text.replace(/^\/ai\s+/i, '').trim();
sendCommunity(nickname, text);
askAI(prompt, (answer, err) => {
sendCommunity('🤖 ИИ', answer ? `💬 Запрос: ${prompt}\n\n${answer}` : `⚠️ Не удалось получить ответ (${err}). Запрос: ${prompt}`);
});
return;
}
sendCommunity(nickname, text);
}
let logFilter = '';
function logIconOf(a) {
const s = String(a || '');
if (s.includes('Вход')) return ['fa-right-to-bracket', '#4f8cff'];
if (s.startsWith('Сообщение'))return ['fa-comment', '#a78bfa'];
if (s.includes('создан')) return ['fa-plus', '#8a8aa3'];
if (s.includes('изменён')) return ['fa-pen', '#f0c040'];
if (s.includes('примен')) return ['fa-circle-check', '#22c55e'];
if (s.includes('Копир')) return ['fa-copy', '#8a8aa3'];
if (s.includes('Удал')) return ['fa-trash-can', '#ff5c5c'];
return ['fa-circle-dot', '#8a8aa3'];
}
function renderLogsTab(body) {
const actions = [...new Set(logs.map(l => l.a).filter(Boolean))];
if (!actions.includes(logFilter) && logFilter) logFilter = '';
const rows = logs
.filter(l => !logFilter || l.a === logFilter)
.slice()
.reverse();
const listHtml = rows.length
? rows.map(l => {
const [ic, col] = logIconOf(l.a);
return `
<div class="log-row">
<div class="lg-ic" style="background:${hexA(col,.13)};color:${col}"><i class="fas ${ic}"></i></div>
<div class="lg-t">
<div class="lg-a">${escapeHtml(l.a)}</div>
<div class="lg-u">${escapeHtml(l.u)}${l.d ? ' — ' + escapeHtml(l.d) : ''}</div>
</div>
<div class="lg-time">${escapeHtml(l.t)}</div>
</div>`;
}).join('')
: '<div class="empty-note">Записей нет.</div>';
body.innerHTML = `
<p class="brv6-sec">Логи</p>
<div class="log-tools">
<select class="sel" id="log-filter">
<option value="">Все действия</option>
${actions.map(a => `<option value="${escapeHtml(a)}"${a===logFilter?' selected':''}>${escapeHtml(a)}</option>`).join('')}
</select>
<button class="btnr sm" data-action="clear-logs" title="Очистить логи"><i class="far fa-trash-can"></i></button>
</div>
<div id="log-list">${listHtml}</div>
`;
document.getElementById('log-filter').onchange = e => {
logFilter = e.target.value;
renderLogsTab(body);
};
}
/* ── СВЯЗЬ ── */
function renderContactTab(body) {
const serverHost = (() => { try { return new URL(SERVER).host; } catch { return SERVER; } })();
const st = lastPingOk === null
? '<span class="dotr"></span>Проверка…'
: lastPingOk
? '<span class="dotg"></span>Online'
: '<span class="dotr"></span>Нет связи';
body.innerHTML = `
<p class="brv6-sec">Связь с разработчиком</p>
<a class="ct-card" href="https://vk.ru/brunoverona" target="_blank">
<div class="ct-ic" style="background:rgba(0,119,255,.14);color:#4da3ff"><i class="fa-brands fa-vk"></i></div>
<div class="ct-body"><div class="ct-n">ВКонтакте</div><div class="ct-s">vk.ru/brunoverona</div></div>
</a>
<a class="ct-card" href="https://vk.ru/club237051164" target="_blank">
<div class="ct-ic" style="background:rgba(41,169,235,.14);color:#53c0ff"><i class="fa-brands fa-telegram"></i></div>
<div class="ct-body"><div class="ct-n">Группа разработчиков</div><div class="ct-s">vk.ru/club237051164</div></div>
</a>
<div class="about">
<p class="brv6-sec">О панели</p>
<p class="about-name">BR Admin Panel v${SCRIPT_VERSION}</p>
<p class="about-desc">Современная панель управления для форума Black Russia.</p>
<div class="kv"><span>Разработчик</span><b>Akzholch1k</b></div>
<div class="kv"><span>Версия</span><b>${SCRIPT_VERSION}</b></div>
<div class="kv"><span>Сервер</span><b class="purple">${escapeHtml(serverHost)}</b></div>
<div class="kv"><span>Статус</span><b>${st}</b></div>
</div>
`;
}
/* ── ИГРЫ ── */
function renderGameTab(body) {
body.innerHTML = `
<p class="brv6-sec">Игры</p>
<div class="gm-card">
<img class="gm-img" src="${BR_LOGO}" alt="" onerror="this.outerHTML='<span class="gm-em">🐯</span>'">
<div class="gm-t">BLACK RUSSIA</div>
<div class="gm-sub">Онлайн-игра про Россию<br>в твоём телефоне</div>
<button class="btnp" data-action="launch-game">Открыть игру</button>
</div>
<div class="gm-card">
<span class="gm-em">🐍</span>
<div class="gm-t">ЗМЕЙКА</div>
<div class="gm-sub">Мини-игра для отдыха</div>
<button class="btnp" data-action="open-snake">Играть</button>
</div>
`;
}
function launchBlackRussia() {
toast('🚀 Запускаем Black Russia...');
window.location.href = CR_LINK;
}
/* ── DEV ── */
function renderDevTab(body) {
const ic = (emoji, col) => `<div class="st-ic" style="background:${hexA(col,.13)};color:${col}">${emoji}</div>`;
const serverHost = (() => { try { return new URL(SERVER).host; } catch { return SERVER; } })();
body.innerHTML = `
<p class="brv6-sec">Dev / Статистика</p>
<div class="stat-row">
<div class="stat-c">${ic('●','#22c55e')}<div class="st-v">${onlineCount === null ? '…' : onlineCount}</div><div class="st-l">Онлайн сейчас</div></div>
<div class="stat-c">${ic('<i class=\"fas fa-user\"></i>','#4f8cff')}<div class="st-v">${users}</div><div class="st-l">Пользователей</div></div>
<div class="stat-c">${ic('<i class=\"fas fa-clipboard-list\"></i>','#8a8aa3')}<div class="st-v">${logs.length}</div><div class="st-l">Действий в логе</div></div>
</div>
<div class="about">
<p class="brv6-sec">Диагностика</p>
<div class="kv"><span>Ник</span><b>${escapeHtml(nickname || '—')}</b></div>
<div class="kv"><span>Сервер</span><b class="purple">${escapeHtml(serverHost)}</b></div>
<div class="kv"><span>Ключ (SECRET)</span><b>${SECRET.length} символов</b></div>
<div class="kv"><span>Пинг</span><b>${lastPingTime ? new Date(lastPingTime).toLocaleTimeString('ru-RU') : 'ещё не было'}</b></div>
<div class="kv"><span>Статус</span><b>${
lastPingOk === null
? '<span class="dotr"></span>Ожидание'
: lastPingOk
? '<span class="dotg"></span>Сервер отвечает'
: `<span class="dotr"></span>Ошибка: ${escapeHtml(String(lastPingError))}`
}</b></div>
</div>
`;
}
/* ══════════════════════════════════════════════════════════
ЗМЕЙКА (lifecycle без изменений)
══════════════════════════════════════════════════════════ */
const SnakeGame = (() => {
let intervalId = null;
let keyHandler = null;
let currentSetDir = () => {};
function destroy() {
if (intervalId !== null) { clearInterval(intervalId); intervalId = null; }
if (keyHandler) { document.removeEventListener('keydown', keyHandler); keyHandler = null; }
currentSetDir = () => {};
}
function start(body) {
destroy();
body.innerHTML = `
<div class="sn-wrap">
<p class="brv6-sec" style="text-align:left">🐍 Змейка</p>
<div class="sn-score" id="sn-score">Счёт: 0</div>
<canvas id="brv6-snake-canvas" width="264" height="264"></canvas>
<div class="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>
<div class="back-row"><button class="btng sm" data-action="snake-back">← Назад к играм</button></div>
</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}, queuedDir = null, 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 = '#0b0b14'; ctx.fillRect(0,0,264,264);
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 ? '#a78bfa' : '#7c3aed';
ctx.fillRect(s.x*SZ+1, s.y*SZ+1, SZ-2, SZ-2);
});
if (!alive) {
ctx.fillStyle = 'rgba(0,0,0,.65)'; ctx.fillRect(0,0,264,264);
ctx.fillStyle = '#fff'; ctx.font = 'bold 18px Inter,sans-serif'; ctx.textAlign = 'center';
ctx.fillText('GAME OVER', 132, 112);
ctx.font = '13px Inter,sans-serif'; ctx.fillText('Счёт: '+score, 132, 138);
ctx.fillStyle = '#a78bfa';
ctx.fillText('Тапни чтобы начать заново', 132, 160);
}
}
function step() {
if (!alive) return;
if (queuedDir) { dir = queuedDir; queuedDir = null; }
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;
queuedDir = 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}; queuedDir = null; food = randFood(); score = 0; alive = true;
document.getElementById('sn-score').textContent = 'Счёт: 0';
};
intervalId = setInterval(step, 200);
draw();
}
function setDirection(name) { currentSetDir(name); }
return { start, destroy, setDirection };
})();
/* ══════════════════════════════════════════════════════════
ЗАЩИТА ОТ ДВОЙНОГО ЗАПУСКА — если скрипт включён дважды
(две версии в Tampermonkey и т.п.), обе копии начинают
конфликтовать за одни и те же DOM id. Ставим метку и через
секунду проверяем, не появилась ли вторая.
══════════════════════════════════════════════════════════ */
function checkInstanceConflict() {
const marker = document.createElement('div');
marker.className = 'brv6-instance-marker';
marker.style.display = 'none';
document.body.appendChild(marker);
Timers.after('conflict-check', 1000, () => {
if (document.querySelectorAll('.brv6-instance-marker').length > 1 && !document.getElementById('brv6-conflict')) {
const m = document.createElement('div');
m.id = 'brv6-conflict';
m.style.cssText = 'position:fixed;inset:0;background:rgba(5,5,8,.9);backdrop-filter:blur(14px);z-index:2147483647;display:flex;align-items:center;justify-content:center;padding:20px;font-family:Inter,sans-serif;text-align:center';
m.innerHTML = `<div style="max-width:320px"><div style="font-size:40px;margin-bottom:12px">⚠️</div><div style="color:#fff;font-weight:800;font-size:16px;margin-bottom:8px">Скрипт запущен дважды</div><div style="color:#9a9ab0;font-size:13px;line-height:1.6">В Tampermonkey включено больше одной версии BR Admin Panel. Отключи старую версию (или дубликат) в списке скриптов и обнови страницу.</div></div>`;
document.body.appendChild(m);
document.getElementById('brv6-fab')?.remove();
document.getElementById('brv6-panel')?.remove();
Timers.clearAll();
}
});
}
/* ══════════════════════════════════════════════════════════
СТАРТ
══════════════════════════════════════════════════════════ */
injectStyles();
loadTheme();
detectForumUserIfStale();
checkInstanceConflict();
if (!nickname) {
buildLogin();
} else {
buildFab();
buildPanel();
startTracking();
}
})();