Быстрые ответы (папки, поиск, закрепление) + мгновенные действия с темой + автообновление + настройки/темы оформления + дедлайн-таймер в списке тем
// ==UserScript==
// @name BB Quick fasReply (Admin)
// @namespace https://forum.blackrussia.online
// @version 2.4.1
// @description Быстрые ответы (папки, поиск, закрепление) + мгновенные действия с темой + автообновление + настройки/темы оформления + дедлайн-таймер в списке тем
// @match https://forum.blackrussia.online/threads/*
// @match https://forum.blackrussia.online/forums/*
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const QUICK_ANSWERS_KEY = 'bb_quick_answers_v1';
const QUICK_FOLDERS_KEY = 'bb_quick_folders_v1';
const RECENT_ANSWERS_KEY = 'bb_recent_answers_v1';
const HOME_PREFIX_KEY = 'bb_home_prefixes';
const HOME_ACTIONS_KEY = 'bb_home_actions';
const THEME_KEY = 'bb_theme_v1';
const DEADLINE_CONFIG_KEY = 'bb_deadline_config_v1';
const MAX_RECENT_SHOWN = 6;
const FORUM_DEADLINES = {
'435': { prefixes: { 0: 48, 2: 48, 14: 48 } },
'433': { prefixes: { 0: 48, 2: 48, 14: 48 } },
'434': { prefixes: { 0: 48, 2: 48, 14: 48 } },
'436': { prefixes: { 0: 72, 2: 72, 14: 72 } },
'415': { prefixes: { 0: 72, 2: 72, 14: 72 } },
'416': { prefixes: { 0: 72, 2: 72, 14: 72 } },
'413': { prefixes: { 0: 72, 2: 72, 14: 72 } },
};
const REPLY_BTN_SELECTOR =
'button.button--primary.button--icon--reply, button.button--icon--reply, button.rippleButton.button--icon--reply';
const PREFIX_MAP = [
['Важно', 1, 'label--red'],
['На рассмотрении', 2, 'label--orange'],
['Решено', 6, 'label--green'],
['Закрыто', 7, 'label--red'],
['Одобрено', 8, 'label--green'],
['Рассмотрено', 9, 'label--green'],
['Команде проекта', 10, 'label--yellow'],
['Специальному администратору', 11, 'label--accent'],
['Главному администратору', 12, 'label--red'],
['Тех. специалисту', 13, 'label--royalBlue'],
['Проверено контролем качества', 15, 'label--skyBlue'],
['Руководству модерации', 20, 'label--blue'],
['Реализовано', 5, 'label--green'],
['Отказано', 4, 'label--red'],
['Ожидание', 14, 'label--silver'],
];
const THEMES = {
dark: { label: 'Тёмная', bg: '#1a1e26', card: '#242a33', border: '#323a45', text: '#e8edf5', textDim: '#9aa5b5', accent: '#3f8bff' },
light: { label: 'Светлая', bg: '#ffffff', card: '#f2f3f5', border: '#dcdfe4', text: '#15181d', textDim: '#6b7280', accent: '#2563eb' },
blue: { label: 'Синяя', bg: '#0f1b2d', card: '#16273f', border: '#213a58', text: '#e8f0ff', textDim: '#8fa8c9', accent: '#4d9eff' },
green: { label: 'Зелёная', bg: '#10201a', card: '#173226', border: '#234a37', text: '#e6fff2', textDim: '#8fc9a8', accent: '#33c17b' },
purple: { label: 'Фиолетовая', bg: '#1c1526', card: '#2a1f3d', border: '#3d2d57', text: '#f1e9ff', textDim: '#b39ddb', accent: '#9c6bff' },
red: { label: 'Красная', bg: '#241213', card: '#331a1c', border: '#4a2529', text: '#ffecec', textDim: '#d99a9c', accent: '#ff5c5c' },
};
let bbLastQuickSettings = null;
let bbOpenOverlayCount = 0;
let bbqrWheelBlocker = null;
let bbqrTouchBlocker = null;
function bbqrCountOpenOverlays() {
return document.querySelectorAll('.overlay-container[id^="bbqr"]').length;
}
function bbqrSyncOverlayLock() {
const openCount = bbqrCountOpenOverlays();
if (openCount > 0) {
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
if (!bbqrWheelBlocker) {
bbqrWheelBlocker = (e) => { if (!e.target.closest('.bbqr-shell-body')) e.preventDefault(); };
bbqrTouchBlocker = (e) => { if (!e.target.closest('.bbqr-shell-body')) e.preventDefault(); };
window.addEventListener('wheel', bbqrWheelBlocker, { passive: false });
window.addEventListener('touchmove', bbqrTouchBlocker, { passive: false });
}
} else {
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
if (bbqrWheelBlocker) {
window.removeEventListener('wheel', bbqrWheelBlocker);
window.removeEventListener('touchmove', bbqrTouchBlocker);
bbqrWheelBlocker = null;
bbqrTouchBlocker = null;
}
}
}
function trackOverlayOpen() { bbqrSyncOverlayLock(); }
function trackOverlayClose() { bbqrSyncOverlayLock(); }
function safeJsonParse(s) { try { return JSON.parse(s); } catch { return null; } }
function genId() {
return 'id_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function loadQuickAnswers() {
try {
const raw = localStorage.getItem(QUICK_ANSWERS_KEY);
const arr = raw ? safeJsonParse(raw) : [];
return Array.isArray(arr) ? arr : [];
} catch { return []; }
}
function saveQuickAnswers(list) {
try { localStorage.setItem(QUICK_ANSWERS_KEY, JSON.stringify(list)); return true; } catch { return false; }
}
function loadQuickFolders() {
try {
const raw = localStorage.getItem(QUICK_FOLDERS_KEY);
const arr = raw ? safeJsonParse(raw) : [];
return Array.isArray(arr) ? arr : [];
} catch { return []; }
}
function saveQuickFolders(list) {
try { localStorage.setItem(QUICK_FOLDERS_KEY, JSON.stringify(list)); return true; } catch { return false; }
}
function loadRecentAnswers() {
try {
const raw = localStorage.getItem(RECENT_ANSWERS_KEY);
const arr = raw ? safeJsonParse(raw) : [];
return Array.isArray(arr) ? arr : [];
} catch { return []; }
}
function saveRecentAnswers(list) {
try { localStorage.setItem(RECENT_ANSWERS_KEY, JSON.stringify(list)); return true; } catch { return false; }
}
function loadHomePrefixes() {
try {
const raw = localStorage.getItem(HOME_PREFIX_KEY);
const arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? new Set(arr) : new Set();
} catch { return new Set(); }
}
function saveHomePrefixes(set) {
try { localStorage.setItem(HOME_PREFIX_KEY, JSON.stringify([...set])); return true; } catch { return false; }
}
function loadHomeActions() {
try {
const raw = localStorage.getItem(HOME_ACTIONS_KEY);
const arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? new Set(arr) : new Set();
} catch { return new Set(); }
}
function saveHomeActions(set) {
try { localStorage.setItem(HOME_ACTIONS_KEY, JSON.stringify([...set])); return true; } catch { return false; }
}
function loadTheme() {
try { return localStorage.getItem(THEME_KEY) || 'dark'; } catch { return 'dark'; }
}
function saveTheme(key) {
try { localStorage.setItem(THEME_KEY, key); return true; } catch { return false; }
}
function loadDeadlineConfig() {
try {
const raw = localStorage.getItem(DEADLINE_CONFIG_KEY);
const obj = raw ? JSON.parse(raw) : {};
return {
enabled: true,
...obj
};
} catch { return { enabled: true }; }
}
function saveDeadlineConfig(cfg) {
try { localStorage.setItem(DEADLINE_CONFIG_KEY, JSON.stringify(cfg)); return true; } catch { return false; }
}
function themeColors() {
return THEMES[loadTheme()] || THEMES.dark;
}
function ensureDefaultFolder() {
const folders = loadQuickFolders();
let def = folders.find((f) => f.isDefault);
if (!def) {
def = { id: genId(), title: 'Общие', pinned: false, usageCount: 0, isDefault: true, createdAt: Date.now() };
folders.push(def);
saveQuickFolders(folders);
}
return def.id;
}
function migrateData() {
const answers = loadQuickAnswers();
let changed = false;
let defaultFolderId = null;
answers.forEach((a) => {
if (!a.id) { a.id = genId(); changed = true; }
if (!a.folderId) {
if (!defaultFolderId) defaultFolderId = ensureDefaultFolder();
a.folderId = defaultFolderId;
changed = true;
}
if (typeof a.pinned !== 'boolean') { a.pinned = false; changed = true; }
if (typeof a.usageCount !== 'number') { a.usageCount = 0; changed = true; }
});
if (changed) saveQuickAnswers(answers);
const folders = loadQuickFolders();
let foldersChanged = false;
folders.forEach((f) => {
if (typeof f.pinned !== 'boolean') { f.pinned = false; foldersChanged = true; }
});
if (foldersChanged) saveQuickFolders(folders);
}
function sortByPinAndUsage(list) {
return [...list].sort((a, b) => {
const pa = a.pinned ? 1 : 0, pb = b.pinned ? 1 : 0;
if (pa !== pb) return pb - pa;
const ua = a.usageCount || 0, ub = b.usageCount || 0;
if (ua !== ub) return ub - ua;
return (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0);
});
}
function setEditorMessageText(rawText) {
const view = document.querySelector('div.fr-element.fr-view[contenteditable="true"], div.fr-element.fr-view');
const text = String(rawText ?? '');
if (view) {
view.focus();
const range = document.createRange();
range.selectNodeContents(view);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
try {
document.execCommand('delete', false, null);
const lines = text.split('\n');
lines.forEach((line, idx) => {
document.execCommand('insertText', false, line);
if (idx < lines.length - 1) document.execCommand('insertText', false, '\n');
});
} catch (err) {
console.warn('[BB Quick Reply] insertText не сработал, запасной путь:', err);
view.textContent = '';
const lines = text.split('\n');
lines.forEach((line, idx) => {
view.appendChild(document.createTextNode(line));
if (idx < lines.length - 1) view.appendChild(document.createElement('br'));
});
view.dispatchEvent(new Event('input', { bubbles: true }));
}
}
const $textarea = document.querySelector(
'textarea[name="message_html"], textarea[data-xf-key="r"], textarea[name="message"]'
);
if ($textarea && $textarea.offsetParent !== null) {
$textarea.value = text;
$textarea.dispatchEvent(new Event('input', { bubbles: true }));
$textarea.dispatchEvent(new Event('change', { bubbles: true }));
}
}
function getFormDataObject(obj) {
const fd = new FormData();
Object.entries(obj).forEach(([k, v]) => { if (v !== undefined) fd.append(k, v); });
return fd;
}
function getThreadTitle() {
const t = document.querySelector('.p-title-value');
if (!t) return null;
return t.lastChild && t.lastChild.textContent ? t.lastChild.textContent : t.textContent;
}
function getThreadIdFromUrl() {
const m = String(document.URL).match(/\/threads\/.+\.(\d+)(?:\/?$|[/?#])/i);
return m ? m[1] : null;
}
function csrfToken() {
return window.XF && XF.config && XF.config.csrf ? XF.config.csrf : null;
}
function getBaseThreadUrl() {
return document.URL.replace(/\/(reply|page-\d+)?(\?.*)?(#.*)?$/i, '');
}
function findThreadToolLink(action) {
const threadId = getThreadIdFromUrl();
const candidates = Array.from(document.querySelectorAll(`a[href*="/${action}"]`)).filter(
(a) => a.getAttribute('data-xf-click') === 'switch'
);
if (!threadId) return candidates[0];
const scoped = candidates.find((a) => (a.getAttribute('href') || '').includes(`.${threadId}/${action}`));
return scoped || candidates[0];
}
function findThreadToolLinkFallback(hrefSelector, textRegex) {
const threadId = getThreadIdFromUrl();
let candidates = Array.from(document.querySelectorAll(hrefSelector));
if (threadId) {
const scoped = candidates.filter((a) => (a.getAttribute('href') || '').includes(`.${threadId}/`));
if (scoped.length) candidates = scoped;
}
return candidates.find((a) => textRegex.test(a.textContent)) || candidates[0];
}
function getThreadFlags() {
let closeLink = findThreadToolLink('quick-close');
let stickLink = findThreadToolLink('quick-stick');
if (!closeLink) {
closeLink = findThreadToolLinkFallback(
'a[href*="quick-close"], a[href*="/close"], a[href*="/open"]',
/открыть|закрыть/i
);
}
if (!stickLink) {
stickLink = findThreadToolLinkFallback(
'a[href*="quick-stick"], a[href*="/stick"], a[href*="/unstick"]',
/открепить|закрепить/i
);
}
const isClosed = closeLink ? /Открыть/i.test(closeLink.textContent) : false;
const isSticky = stickLink ? /Открепить/i.test(stickLink.textContent) : false;
return { isSticky, isClosed, closeLink, stickLink };
}
function getThreadActionUrls() {
const base = getBaseThreadUrl();
const { isSticky, isClosed, closeLink, stickLink } = getThreadFlags();
return {
isSticky, isClosed,
editUrl: `${base}/edit`,
closeUrl: closeLink ? closeLink.getAttribute('href') : `${base}/quick-close`,
stickUrl: stickLink ? stickLink.getAttribute('href') : `${base}/quick-stick`,
};
}
function getCurrentForumId() {
const m = location.pathname.match(/\/forums\/[^/]+\.(\d+)\//);
if (m) return m[1];
const urlParams = new URLSearchParams(location.search);
const f = urlParams.get('f');
if (f) return f;
const crumb = document.querySelector('.p-breadcrumbs a[href*="/forums/"]');
if (crumb) {
const cm = crumb.getAttribute('href').match(/\/forums\/[^/]+\.(\d+)\//);
if (cm) return cm[1];
}
return null;
}
function getPrefixIdFromLabelText(text) {
if (!text || text.trim() === '') return 0; // Нет префикса = ID 0
const found = PREFIX_MAP.find(p => p[0] === text.trim());
return found ? found[1] : null;
}
function getThreadTimestampFromListing(row) {
// ВАЖНО: точкой отсчёта дедлайна является время СОЗДАНИЯ темы
// (structItem-startDate), а НЕ время последнего сообщения
// (.structItem-cell--latest). Если считать от последней активности,
// то у тем, в которых недавно писали, дедлайн всегда будет «зелёным»,
// хотя срок уже мог пройти.
const readTime = (el) => {
if (!el) return null;
const unix = el.getAttribute('data-time');
if (unix) return Number(unix) * 1000;
const dt = el.getAttribute('datetime');
if (dt) {
const d = new Date(dt);
if (!isNaN(d)) return d.getTime();
}
return null;
};
// 1. Время создания темы (в ячейке заголовка, идёт «впереди» строки)
const startTime = readTime(
row.querySelector('.structItem-startDate time[data-time], .structItem-startDate time[datetime]')
);
if (startTime) return startTime;
// 2. Fallback: время последней активности
const latestTime = readTime(
row.querySelector('.structItem-cell--latest time[data-time], .structItem-cell--latest time[datetime]')
);
if (latestTime) return latestTime;
// 3. Fallback: любой time в строке
return readTime(row.querySelector('time[data-time]'));
}
function formatDuration(ms) {
const sec = Math.floor(ms / 1000);
const m = Math.floor(sec / 60);
const h = Math.floor(m / 60);
// Только часы, без дней
if (h > 0) return `${h}ч ${m % 60}м`;
return `${m}м`;
}
function injectDeadlineBadgesInListing() {
const config = loadDeadlineConfig();
if (!config.enabled) return;
const forumId = getCurrentForumId();
if (!forumId || !FORUM_DEADLINES[forumId]) return;
const rows = document.querySelectorAll('.structItem--thread');
if (!rows.length) return;
rows.forEach(row => {
// === ВАЖНО: всегда сначала чистим классы строки и старый бейдж ===
// (до всех return, чтобы полоса/фон не «залипали» после смены
// префикса или состояния темы)
row.classList.remove(
'bbqr-deadline-row--green',
'bbqr-deadline-row--yellow',
'bbqr-deadline-row--red'
);
const oldBadge = row.querySelector('.bbqr-deadline-badge');
if (oldBadge) oldBadge.remove();
// === ПРОВЕРКА: есть ли префикс и нужен ли для него дедлайн? ===
// Ищем префикс. Если его нет — считаем что ID = 0 (без префикса)
const prefixLabel = row.querySelector('.label');
let prefixId = 0; // По умолчанию — без префикса
if (prefixLabel) {
const prefixText = prefixLabel.textContent.trim();
const foundId = getPrefixIdFromLabelText(prefixText);
if (foundId !== null) prefixId = foundId;
}
// Проверяем, есть ли дедлайн для этого префикса В ЭТОМ разделе
const sectionConfig = FORUM_DEADLINES[forumId];
const hours = sectionConfig.prefixes[prefixId];
if (!hours || hours <= 0) return; // Для этого префикса дедлайн не нужен — выходим
// === ПРОВЕРКА: удалена ли тема? ===
// Бейдж «Тема удалена» показываем ТОЛЬКО если у темы есть дедлайн-префикс,
// иначе у тем без дедлайна (Одобрено, Отказано и т.д.) бейдж не появится.
const isDeleted = row.querySelector('.structItem-status--deleted, i[title="Удалено"], .structItem-statuses li i.structItem-status--deleted');
if (isDeleted) {
const badge = document.createElement('span');
badge.className = 'bbqr-deadline-badge';
badge.style.cssText = 'color:#9aa5b5; font-size:12px; font-weight:600; margin-left:4px;';
badge.textContent = '· Тема удалена';
const prefixLink = prefixLabel.closest('a.labelLink') || prefixLabel;
prefixLink.insertAdjacentElement('afterend', badge);
return; // Выходим
}
// === Всё ок, считаем дедлайн ===
const created = getThreadTimestampFromListing(row);
if (!created) return;
const deadline = created + hours * 3600000;
const now = Date.now();
const diff = deadline - now;
const total = hours * 3600000;
const leftRatio = diff / total;
let color, text;
if (diff <= 0) {
color = '#ff5c5c';
row.classList.add('bbqr-deadline-row--red');
text = `· Просрочено ${formatDuration(Math.abs(diff))}`;
} else if (leftRatio < 0.25) {
color = '#ff5c5c';
row.classList.add('bbqr-deadline-row--red');
text = `· ${formatDuration(diff)}`;
} else if (leftRatio < 0.5) {
color = '#ffcc4d';
row.classList.add('bbqr-deadline-row--yellow');
text = `· ${formatDuration(diff)}`;
} else {
color = '#33c17b';
row.classList.add('bbqr-deadline-row--green');
text = `· ${formatDuration(diff)}`;
}
// Создаём бейдж
const badge = document.createElement('span');
badge.className = 'bbqr-deadline-badge';
badge.style.cssText = `color:${color}; font-size:12px; font-weight:600; margin-left:4px;`;
badge.textContent = text;
// Вставляем ПОСЛЕ ссылки префикса
// Вставляем бейдж: после префикса если есть, или перед названием темы
if (prefixLabel) {
const prefixLink = prefixLabel.closest('a.labelLink') || prefixLabel;
prefixLink.insertAdjacentElement('afterend', badge);
} else {
// Нет префикса — вставляем перед ссылкой на тему
const titleLink = row.querySelector('.structItem-title a[data-tp-primary="on"], .structItem-title a');
if (titleLink) {
titleLink.insertAdjacentElement('beforebegin', badge);
}
}
});
}
function getThreadAuthorName() {
const firstMsg = document.querySelector(
'article.message[data-author], ' +
'.block-container:first-child article:first-child[data-author]'
);
if (firstMsg) {
const author = firstMsg.getAttribute('data-author');
if (author) return author.trim();
}
// Запасной вариант: ищем ссылку с именем пользователя в первом сообщении
const usernameLink = document.querySelector(
'article.message:first-child .message-name a.username, ' +
'article.message:first-child [data-xf-init] .username, ' +
'.message:first-child a.username, ' +
'.block-container:first-child .message:first-child .message-name a'
);
if (usernameLink) return usernameLink.textContent.trim();
return '';
}
function replaceAnswerTags(text) {
if (!text) return '';
const authorName = getThreadAuthorName();
return text
.replace(/\{name_fa\}/gi, authorName)
.replace(/\{name\}/gi, authorName);
}
function showBbqrToast(message, type = 'error') {
let toast = document.querySelector('#bbqrToast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'bbqrToast';
toast.style.cssText =
'position:fixed; bottom:16px; right:16px; z-index:999999; max-width:340px;' +
'border-radius:8px; padding:10px 14px; font-size:12.5px; line-height:1.4;' +
'box-shadow:0 8px 24px rgba(0,0,0,.4);';
document.body.appendChild(toast);
}
if (type === 'success') {
toast.style.background = '#1f3a24'; toast.style.color = '#b4ffc0'; toast.style.border = '1px solid #2a7a35';
} else {
toast.style.background = '#3a1f22'; toast.style.color = '#ffb4bb'; toast.style.border = '1px solid #7a2a30';
}
toast.textContent = message;
toast.style.display = 'block';
clearTimeout(toast._hideTimer);
toast._hideTimer = setTimeout(() => { toast.style.display = 'none'; }, 6000);
}
async function sendThreadAction(label, url, bodyObj) {
const token = csrfToken();
if (!token) { showBbqrToast('Быстрый ответ: не найден CSRF-токен.', 'error'); return false; }
const attempt = (extra = {}) =>
fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: getFormDataObject({
...bodyObj,
_xfToken: token,
_xfResponseType: 'json',
_xfWithData: 1,
_xfRequestUri: window.location.pathname + window.location.search,
...extra
}),
});
const readPayload = async (res) => { try { return await res.clone().json(); } catch { return null; } };
const isSuccess = (res, payload) => res.ok && (!payload || payload.status === 'ok' || payload.status === undefined);
try {
let res = await attempt();
let payload = await readPayload(res);
console.log(`[BB Quick Reply] ${label} → HTTP ${res.status}`, payload ?? '(не JSON)');
if (isSuccess(res, payload)) return true;
if (payload && payload.status === 'error') {
console.warn(`[BB Quick Reply] ${label} — ошибка, повтор с подтверждением:`, payload.errors || payload);
res = await attempt({ _xfConfirm: 1 });
payload = await readPayload(res);
console.log(`[BB Quick Reply] ${label} (повтор) → HTTP ${res.status}`, payload ?? '(не JSON)');
if (isSuccess(res, payload)) return true;
}
const reason = (payload && payload.errors && payload.errors.join(', ')) || `HTTP ${res.status}`;
console.error(`[BB Quick Reply] ${label} — не выполнено:`, reason);
showBbqrToast(`Быстрый ответ: не удалось выполнить «${label}» (${reason}).`);
return false;
} catch (err) {
console.error(`[BB Quick Reply] ${label} — сетевая ошибка:`, err, url);
showBbqrToast(`Быстрый ответ: сетевая ошибка при «${label}».`);
return false;
}
}
function clickNativeThreadLink(link) {
return new Promise((resolve) => {
if (!link) { resolve(false); return; }
console.log('[BB Quick Reply] Клик по нативной ссылке:', link.getAttribute('href'), link.textContent.trim());
try {
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
} catch {
link.click();
}
setTimeout(() => {
const confirmBtn = document.querySelector(
'.overlay.is-active .button--cta, .overlay.is-active [data-xf-click="overlay-submit"], .overlay.is-active input[type="submit"], .overlay.is-active button[type="submit"]'
);
if (confirmBtn) {
console.log('[BB Quick Reply] Найден диалог подтверждения, подтверждаю:', confirmBtn);
confirmBtn.click();
setTimeout(() => resolve(true), 500);
} else {
resolve(true);
}
}, 500);
});
}
async function runThreadToggle(label, link, fallbackUrl, fallbackBody) {
if (link) {
await clickNativeThreadLink(link);
return true;
}
return sendThreadAction(label, fallbackUrl, { ...(fallbackBody || {}), _xfConfirm: 1 });
}
function getThreadActionUrls_withLinks() {
const base = getBaseThreadUrl();
const { isSticky, isClosed, closeLink, stickLink } = getThreadFlags();
return {
isSticky, isClosed, closeLink, stickLink,
editUrl: `${base}/edit`,
closeUrl: closeLink ? closeLink.getAttribute('href') : `${base}/quick-close`,
stickUrl: stickLink ? stickLink.getAttribute('href') : `${base}/quick-stick`,
};
}
async function refreshThreadActionUrls() {
const html = await fetch(location.href, {
credentials: 'same-origin'
}).then(r => r.text());
const doc = new DOMParser().parseFromString(html, 'text/html');
const base = getBaseThreadUrl();
const stickLink =
doc.querySelector('a[href*="/quick-stick"]') ||
doc.querySelector('a[href*="/stick"]') ||
doc.querySelector('a[href*="/unstick"]');
const closeLink =
doc.querySelector('a[href*="/quick-close"]') ||
doc.querySelector('a[href*="/close"]') ||
doc.querySelector('a[href*="/open"]');
const isSticky = stickLink ? /Открепить/i.test(stickLink.textContent) : false;
const isClosed = closeLink ? /Открыть/i.test(closeLink.textContent) : false;
return {
isSticky,
isClosed,
closeLink,
stickLink,
editUrl: `${base}/edit`,
closeUrl: closeLink ? closeLink.getAttribute('href') : `${base}/quick-close`,
stickUrl: stickLink ? stickLink.getAttribute('href') : `${base}/quick-stick`,
};
}
async function applyThreadSettingsFromQuickAnswer(settings) {
if (!settings) return false;
if (!getThreadIdFromUrl()) return false;
const {
prefix_id, threadStick, threadCloser,
_editUrl, _closeUrl, _stickUrl,
_isSticky, _isClosed, _threadTitle
} = settings;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const wantPrefix = prefix_id !== undefined && prefix_id !== null && String(prefix_id) !== '-1';
const wantSticky = threadStick === '1';
const wantUnsticky = threadStick === '2';
const wantClosed = threadCloser === '1';
const wantOpened = threadCloser === '2';
let state = getThreadActionUrls_withLinks();
if (wantPrefix) {
const editUrl = _editUrl || `${getBaseThreadUrl()}/edit`;
const threadTitle = _threadTitle || getThreadTitle();
if (!threadTitle) {
showBbqrToast('Не удалось определить заголовок темы.', 'error');
return false;
}
const ok = await sendThreadAction('изменение префикса', editUrl, {
prefix_id: String(prefix_id),
title: threadTitle,
});
if (!ok) {
showBbqrToast('Не удалось изменить префикс. Дальнейшие действия отменены.', 'error');
return false;
}
await sleep(300);
state = await refreshThreadActionUrls();
}
if (threadStick === '1' || threadStick === '2') {
const needStick = wantSticky && !state.isSticky;
const needUnstick = wantUnsticky && state.isSticky;
if (needStick || needUnstick) {
await sleep(300);
const label = needStick ? 'закрепление темы' : 'открепление темы';
const ok = await runThreadToggle(label, null, state.stickUrl, {});
if (!ok) {
showBbqrToast(`Не удалось выполнить «${label}». Дальнейшие действия отменены.`, 'error');
return false;
}
state.isSticky = !state.isSticky;
}
}
if (threadCloser === '1' || threadCloser === '2') {
const needClose = wantClosed && !state.isClosed;
const needOpen = wantOpened && state.isClosed;
if (needClose || needOpen) {
await sleep(300);
const label = needClose ? 'закрытие темы' : 'открытие темы';
const ok = await runThreadToggle(label, null, state.closeUrl, {});
if (!ok) {
showBbqrToast(`Не удалось выполнить «${label}».`, 'error');
return false;
}
state.isClosed = !state.isClosed;
}
}
return true;
}
async function applyPrefixNow(prefixId) {
const { editUrl } = getThreadActionUrls();
const threadTitle = getThreadTitle();
if (!threadTitle) { showBbqrToast('Не удалось определить заголовок темы.', 'error'); return; }
const ok = await sendThreadAction('изменение префикса', editUrl, { prefix_id: String(prefixId), title: threadTitle });
if (ok) { showBbqrToast('Готово: префикс изменён.', 'success'); setTimeout(() => window.location.reload(), 600); }
}
async function toggleCloseNow() {
const { isClosed, closeLink, closeUrl } = getThreadActionUrls_withLinks();
const ok = await runThreadToggle(isClosed ? 'открытие темы' : 'закрытие темы', closeLink, closeUrl, {});
if (ok) { showBbqrToast('Готово: состояние темы изменено.', 'success'); setTimeout(() => window.location.reload(), 600); }
}
async function toggleStickNow() {
const { isSticky, stickLink, stickUrl } = getThreadActionUrls_withLinks();
const ok = await runThreadToggle(isSticky ? 'открепление темы' : 'закрепление темы', stickLink, stickUrl, {});
if (ok) { showBbqrToast('Готово: состояние темы изменено.', 'success'); setTimeout(() => window.location.reload(), 600); }
}
let bbqrIsApplying = false;
function pickAnswer(a) {
const { isSticky, isClosed, closeLink, stickLink } = getThreadFlags();
const { editUrl, closeUrl, stickUrl } = getThreadActionUrls_withLinks();
const threadTitle = getThreadTitle();
const token = csrfToken();
bbLastQuickSettings = {
prefix_id: a.prefix_id ?? -1,
threadStick: a.threadStick ?? 'no',
threadCloser: a.threadCloser ?? 'no',
_editUrl: editUrl,
_closeUrl: closeUrl,
_stickUrl: stickUrl,
_isSticky: isSticky,
_isClosed: isClosed,
_threadTitle: threadTitle,
_csrfToken: token,
};
setEditorMessageText(replaceAnswerTags(a.content) || '');
const list = loadQuickAnswers();
const idx = list.findIndex((x) => x.id === a.id);
if (idx !== -1) {
list[idx].usageCount = (list[idx].usageCount || 0) + 1;
list[idx].lastUsedAt = Date.now();
saveQuickAnswers(list);
}
let recent = loadRecentAnswers().filter((id) => id !== a.id);
recent.unshift(a.id);
if (recent.length > 20) recent.length = 20;
saveRecentAnswers(recent);
}
function injectBbqrGlobalStyles() {
if (document.querySelector('#bbqrGlobalStyles')) return;
const style = document.createElement('style');
style.id = 'bbqrGlobalStyles';
style.textContent = `
.bbqr-themed { display:flex; flex-direction:column; overflow:hidden; border-radius:12px;
background:var(--bbqr-bg); color:var(--bbqr-text); border:1px solid var(--bbqr-border);
box-shadow:0 20px 60px rgba(0,0,0,.6); max-height:90vh; }
.bbqr-shell-header { padding:16px 22px; background:var(--bbqr-card); border-bottom:1px solid var(--bbqr-border);
display:flex; align-items:center; gap:12px; flex-wrap:wrap; flex:0 0 auto; }
.bbqr-shell-title { font-size:17px; font-weight:700; }
.bbqr-shell-header-actions { margin-left:auto; display:flex; align-items:center; gap:8px; }
.bbqr-shell-close { cursor:pointer; width:26px; height:26px; display:flex;
align-items:center; justify-content:center; border-radius:7px; color:var(--bbqr-text-dim); }
.bbqr-shell-close:hover { background:rgba(120,120,140,.2); color:var(--bbqr-text); }
.bbqr-shell-close::after { content:"\\2715"; font-size:13px; }
.bbqr-shell-settings { cursor:pointer; width:26px; height:26px; display:flex;
align-items:center; justify-content:center; border-radius:7px; color:var(--bbqr-text-dim); font-size:15px; }
.bbqr-shell-settings:hover { background:rgba(120,120,140,.2); color:var(--bbqr-text); }
.bbqr-shell-body { padding:20px; max-height:78vh; overflow-y:auto; background:var(--bbqr-bg);
flex:1 1 auto; min-height:0; -webkit-overflow-scrolling:touch; overscroll-behavior:contain; touch-action:pan-y; }
.bbqr-shell-footer { padding:14px 22px; background:var(--bbqr-card); border-top:1px solid var(--bbqr-border);
display:flex; justify-content:flex-end; gap:10px; flex-wrap:wrap; flex:0 0 auto; }
@media (max-width: 640px) {
.bbqr-themed { max-height:92vh; border-radius:10px; }
.bbqr-shell-header { padding:12px 14px; }
.bbqr-shell-body { padding:14px; max-height:none; }
.bbqr-shell-footer { padding:12px 14px; }
.bbqr-grid { grid-template-columns:repeat(auto-fill, minmax(140px, 1fr)) !important; }
}
.bbqr-search { width:100%; box-sizing:border-box; padding:10px 14px; border-radius:10px;
border:1px solid var(--bbqr-border); background:var(--bbqr-card); color:var(--bbqr-text);
font-size:13.5px; margin-bottom:14px; }
.bbqr-search:focus { outline:none; border-color:var(--bbqr-accent); }
.bbqr-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(190px, 1fr)); gap:10px; }
.bbqr-card { position:relative; background:var(--bbqr-card); border:1px solid var(--bbqr-border);
border-radius:12px; padding:14px 34px 12px 14px; cursor:pointer; min-height:60px;
display:flex; flex-direction:column; justify-content:space-between;
transition:border-color .15s ease, transform .1s ease; }
.bbqr-card:hover { border-color:var(--bbqr-accent); transform:translateY(-2px); }
.bbqr-card__title { font-weight:600; font-size:13.5px; word-break:break-word; }
.bbqr-card__meta { font-size:11px; color:var(--bbqr-text-dim); margin-top:6px; }
.bbqr-card__pin { position:absolute; top:10px; right:10px; font-size:15px; opacity:.35; cursor:pointer; }
.bbqr-card__pin.is-pinned { opacity:1; color:#ffcc4d; }
.bbqr-card__actions { display:flex; gap:8px; margin-top:10px; }
.bbqr-card__actions span { font-size:13px; opacity:.6; cursor:pointer; width:24px; height:24px;
display:flex; align-items:center; justify-content:center; border-radius:6px; }
.bbqr-card__actions span:hover { opacity:1; background:rgba(120,120,140,.2); }
.bbqr-empty { color:var(--bbqr-text-dim); font-size:13px; padding:16px 2px; text-align:center; }
.bbqr-btn { appearance:none; border:1px solid var(--bbqr-border); background:var(--bbqr-card);
color:var(--bbqr-text); border-radius:9px; padding:8px 16px; font-size:13px; font-weight:600;
cursor:pointer; transition:filter .15s ease, transform .1s ease; }
.bbqr-btn:hover { filter:brightness(1.15); transform:translateY(-1px); }
.bbqr-btn:disabled { opacity:.4; cursor:not-allowed; transform:none; filter:none; }
.bbqr-btn--primary { background:var(--bbqr-accent); border-color:var(--bbqr-accent); color:#fff; }
.bbqr-field { margin-bottom:14px; }
.bbqr-label { display:block; font-size:12.5px; font-weight:700; margin-bottom:6px; }
.bbqr-hint { font-size:11.5px; color:var(--bbqr-text-dim); margin-top:6px; }
.bbqr-input, .bbqr-textarea, .bbqr-select { width:100%; box-sizing:border-box; background:var(--bbqr-card);
border:1px solid var(--bbqr-border); color:var(--bbqr-text); border-radius:9px; padding:9px 11px;
font-size:13px; outline:none; }
.bbqr-input:focus, .bbqr-textarea:focus, .bbqr-select:focus { border-color:var(--bbqr-accent); }
.bbqr-textarea { resize:vertical; min-height:90px; }
.bbqr-radio-group { display:flex; flex-wrap:wrap; gap:8px; }
.bbqr-radio { position:relative; }
.bbqr-radio input { position:absolute; opacity:0; }
.bbqr-radio span { display:inline-block; padding:6px 12px; font-size:12.5px; border-radius:999px;
border:1px solid var(--bbqr-border); background:var(--bbqr-card); color:var(--bbqr-text-dim); cursor:pointer; }
.bbqr-radio input:checked + span { background:var(--bbqr-accent); border-color:var(--bbqr-accent); color:#fff; }
.bbqr-theme-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(120px,1fr)); gap:10px; }
.bbqr-theme-swatch { border-radius:10px; padding:16px 10px; text-align:center; font-size:12.5px;
font-weight:700; cursor:pointer; border:2px solid transparent; }
.bbqr-theme-swatch.is-active { box-shadow:0 0 0 2px var(--bbqr-accent) inset; }
.bbqr-page-chip { padding:8px 14px; font-size:12.5px; border-radius:999px; cursor:pointer;
border:1px solid rgba(120,120,140,.3); background:rgba(120,120,140,.08); font-weight:600;
color:#ffffff; transition:transform .1s ease, border-color .15s ease, background .15s ease, color .15s ease; }
.bbqr-page-chip:hover { transform:translateY(-1px); border-color:rgba(90,140,255,.6); background:rgba(90,140,255,.1); color:#ffffff; }
.bbqr-page-chip--icon { padding:8px 12px; }
.bbqr-deadline-badge { white-space:nowrap; }
/* === ДЕДЛАЙН: визуальные эффекты строк в списке тем === */
.structItem--thread.bbqr-deadline-row--green { box-shadow: inset 3px 0 0 #33c17b; }
.structItem--thread.bbqr-deadline-row--yellow {
box-shadow: inset 3px 0 0 #ffcc4d;
background: rgba(255,204,77,.07);
}
.structItem--thread.bbqr-deadline-row--red {
box-shadow: inset 3px 0 0 #ff5c5c;
animation: bbqrDeadlinePulse 2s ease-in-out infinite;
}
@keyframes bbqrDeadlinePulse {
0%, 100% { background: rgba(255,92,92,.08); }
50% { background: rgba(255,92,92,.16); }
}
`;
document.head.appendChild(style);
}
function overlayShell({ title, bodyHtml, footerHtml = '', maxWidth = 900, headerExtraHtml = '' }) {
const t = themeColors();
const vars = `--bbqr-bg:${t.bg}; --bbqr-card:${t.card}; --bbqr-border:${t.border}; --bbqr-text:${t.text}; --bbqr-text-dim:${t.textDim}; --bbqr-accent:${t.accent};`;
return `
<div class="overlay bbqr-themed" style="${vars} max-width:${maxWidth}px;">
<div class="bbqr-shell-header">
<span class="bbqr-shell-title">${title}</span>
<div class="bbqr-shell-header-actions">
${headerExtraHtml}
<a class="bbqr-shell-close" data-bbqr-shell-close aria-label="Закрыть"></a>
</div>
</div>
<div class="bbqr-shell-body">${bodyHtml}</div>
${footerHtml ? `<div class="bbqr-shell-footer">${footerHtml}</div>` : ''}
</div>`;
}
function openOverlay(id, innerHtml) {
document.querySelector(`#${id}`)?.remove();
const overlay = document.createElement('div');
overlay.id = id;
overlay.className = 'overlay-container is-active';
overlay.style.zIndex = '999999';
overlay.innerHTML = innerHtml;
document.body.appendChild(overlay);
trackOverlayOpen();
const close = () => { overlay.remove(); trackOverlayClose(); };
overlay.querySelector('[data-bbqr-shell-close]')?.addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
return { overlay, close };
}
function exportSettings() {
const data = {
answers: loadQuickAnswers(),
folders: loadQuickFolders(),
recent: loadRecentAnswers(),
prefixes: [...loadHomePrefixes()],
actions: [...loadHomeActions()],
theme: loadTheme(),
version: '2.0',
exportedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bb_quick_reply_settings_${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
showBbqrToast('Настройки экспортированы.', 'success');
}
function importSettings(jsonString) {
let data;
try { data = JSON.parse(jsonString); }
catch (err) {
console.error('[BB Quick Reply] Импорт: некорректный JSON.', err);
showBbqrToast('Не удалось прочитать файл: это не корректный JSON.', 'error');
return false;
}
if (data.answers) saveQuickAnswers(data.answers);
if (data.folders) saveQuickFolders(data.folders);
if (data.recent) saveRecentAnswers(data.recent);
if (data.prefixes) saveHomePrefixes(new Set(data.prefixes));
if (data.actions) saveHomeActions(new Set(data.actions));
if (data.theme && THEMES[data.theme]) saveTheme(data.theme);
migrateData();
showBbqrToast('Настройки импортированы!', 'success');
return true;
}
function triggerImportFilePicker(onDone) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.style.display = 'none';
document.body.appendChild(input);
input.addEventListener('change', () => {
const file = input.files && input.files[0];
if (!file) { input.remove(); return; }
const reader = new FileReader();
reader.onload = () => {
const ok = importSettings(String(reader.result || ''));
input.remove();
if (ok && typeof onDone === 'function') onDone();
};
reader.onerror = () => { showBbqrToast('Не удалось прочитать файл.', 'error'); input.remove(); };
reader.readAsText(file);
});
input.click();
}
function ensureInlineAnswerButtons() {
const replyBtn = document.querySelector(REPLY_BTN_SELECTOR);
if (!replyBtn) return;
const anchor = replyBtn.closest('.formButtonGroup') || replyBtn.parentElement;
if (!anchor) return;
const homePrefixes = loadHomePrefixes();
const homeActions = loadHomeActions();
const { isClosed, isSticky } = getThreadFlags();
let prefixRow = document.querySelector('#bbqrPrefixInlineRow');
if (!prefixRow) {
prefixRow = document.createElement('div');
prefixRow.id = 'bbqrPrefixInlineRow';
prefixRow.style.cssText = 'display:flex; flex-wrap:wrap; gap:6px; margin-bottom:8px;';
anchor.insertAdjacentElement('beforebegin', prefixRow);
}
const sig1 = [...homePrefixes].sort().join('|') + '::' + [...homeActions].sort().join('|') + `::${isClosed}:${isSticky}`;
if (prefixRow.dataset.sig !== sig1) {
prefixRow.dataset.sig = sig1;
prefixRow.innerHTML = '';
homePrefixes.forEach((label) => {
const p = PREFIX_MAP.find((x) => x[0] === label);
if (!p) return;
const b = document.createElement('button');
b.type = 'button';
b.className = 'bbqr-page-chip';
b.textContent = label;
b.addEventListener('click', () => applyPrefixNow(p[1]));
prefixRow.appendChild(b);
});
if (homeActions.has('close')) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'bbqr-page-chip';
b.textContent = isClosed ? 'Открыть тему' : 'Закрыть тему';
b.addEventListener('click', () => toggleCloseNow());
prefixRow.appendChild(b);
}
if (homeActions.has('stick')) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'bbqr-page-chip';
b.textContent = isSticky ? 'Открепить тему' : 'Закрепить тему';
b.addEventListener('click', () => toggleStickNow());
prefixRow.appendChild(b);
}
prefixRow.style.display = prefixRow.children.length ? '' : 'none';
}
const allAnswers = loadQuickAnswers();
const recentIds = loadRecentAnswers();
const recentAnswers = recentIds
.map((id) => allAnswers.find((a) => a.id === id))
.filter(Boolean)
.slice(0, MAX_RECENT_SHOWN);
let row = document.querySelector('#bbqrInlineRow');
if (!row) {
row = document.createElement('div');
row.id = 'bbqrInlineRow';
row.style.cssText = 'display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; align-items:center;';
anchor.insertAdjacentElement('afterend', row);
}
const sig2 = recentAnswers.map((a) => a.id + ':' + a.title).join('|');
if (row.dataset.sig === sig2) return;
row.dataset.sig = sig2;
row.innerHTML = '';
recentAnswers.forEach((a) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'bbqr-page-chip';
b.textContent = a.title || 'Ответ';
b.addEventListener('click', () => pickAnswer(a));
row.appendChild(b);
});
const mainBtn = document.createElement('button');
mainBtn.type = 'button';
mainBtn.className = 'bbqr-page-chip';
mainBtn.style.opacity = '.9';
mainBtn.textContent = 'Быстрый ответ';
mainBtn.addEventListener('click', () => renderQuickAnswersOverlay());
row.appendChild(mainBtn);
const homeBtn = document.createElement('button');
homeBtn.type = 'button';
homeBtn.className = 'bbqr-page-chip';
homeBtn.style.opacity = '.85';
homeBtn.textContent = 'Кнопки на главный экран';
homeBtn.addEventListener('click', () => renderPrefixControlOverlay());
row.appendChild(homeBtn);
}
function renderAnswerCard(a) {
return `
<div class="bbqr-card" data-bbqr-pick="${a.id}">
<span class="bbqr-card__pin ${a.pinned ? 'is-pinned' : ''}" data-bbqr-pin-answer="${a.id}" title="Закрепить">★</span>
<div class="bbqr-card__title">${escapeHtml(a.title || 'Ответ')}</div>
<div class="bbqr-card__meta">Использован: ${a.usageCount || 0}</div>
<div class="bbqr-card__actions">
<span data-bbqr-edit="${a.id}" title="Редактировать">✎</span>
<span data-bbqr-del="${a.id}" title="Удалить">✕</span>
</div>
</div>`;
}
function renderAnswerGrid(list) {
if (!list.length) return '<div class="bbqr-empty">Ничего не найдено.</div>';
return `<div class="bbqr-grid">${list.map(renderAnswerCard).join('')}</div>`;
}
function renderFolderCard(f, count) {
return `
<div class="bbqr-card" data-bbqr-open-folder="${f.id}">
<span class="bbqr-card__pin ${f.pinned ? 'is-pinned' : ''}" data-bbqr-pin-folder="${f.id}" title="Закрепить">★</span>
<div class="bbqr-card__title">📁 ${escapeHtml(f.title || 'Папка')}</div>
<div class="bbqr-card__meta">${count} ${count === 1 ? 'ответ' : 'ответов'}</div>
<div class="bbqr-card__actions">
<span data-bbqr-edit-folder="${f.id}" title="Переименовать">✎</span>
<span data-bbqr-del-folder="${f.id}" title="Удалить папку">✕</span>
</div>
</div>`;
}
function renderFolderGrid(folders, allAnswers) {
if (!folders.length) return '<div class="bbqr-empty">Пока нет папок. Создайте первую, чтобы добавить ответы.</div>';
return `<div class="bbqr-grid">${folders
.map((f) => renderFolderCard(f, allAnswers.filter((a) => a.folderId === f.id).length))
.join('')}</div>`;
}
function buildQuickAnswersResultsHtml(folderId, search) {
const allAnswers = loadQuickAnswers();
const folders = sortByPinAndUsage(loadQuickFolders());
const searchTrim = search.trim().toLowerCase();
if (searchTrim) {
const matches = allAnswers.filter(
(a) => (a.title || '').toLowerCase().includes(searchTrim) || (a.content || '').toLowerCase().includes(searchTrim)
);
return renderAnswerGrid(sortByPinAndUsage(matches));
}
if (folderId) {
const folder = folders.find((f) => f.id === folderId);
const inFolder = sortByPinAndUsage(allAnswers.filter((a) => a.folderId === folderId));
return `
<div style="display:flex; align-items:center; gap:10px; margin-bottom:14px; flex-wrap:wrap;">
<button type="button" class="bbqr-btn" data-bbqr-back>← Папки</button>
<div style="font-weight:700; font-size:15px;">📁 ${escapeHtml(folder?.title || 'Папка')}</div>
<button type="button" class="bbqr-btn bbqr-btn--primary" style="margin-left:auto;" data-bbqr-create-in-folder="${folderId}">+ Ответ</button>
</div>
${renderAnswerGrid(inFolder)}`;
}
return `
<div style="display:flex; justify-content:flex-end; margin-bottom:14px;">
<button type="button" class="bbqr-btn bbqr-btn--primary" data-bbqr-create-folder>+ Папка</button>
</div>
${renderFolderGrid(folders, allAnswers)}`;
}
function renderQuickAnswersOverlay(state = {}) {
const folderId = state.folderId ?? null;
const initialSearch = state.search ?? '';
const bodyHtml =
`<input type="text" class="bbqr-search" id="bbqrSearchInput" placeholder="Поиск по названию и тексту ответа..." value="${escapeHtml(initialSearch)}" />` +
`<div id="bbqrResultsArea">${buildQuickAnswersResultsHtml(folderId, initialSearch)}</div>`;
const html = overlayShell({
title: 'Быстрый ответ',
bodyHtml,
maxWidth: 920,
headerExtraHtml: '<a class="bbqr-shell-settings" data-bbqr-open-settings title="Настройки">⚙</a>',
});
const { overlay, close } = openOverlay('bbqrAnswersOverlay', html);
const liveState = { folderId, search: initialSearch };
overlay.querySelector('[data-bbqr-open-settings]')?.addEventListener('click', () => {
close();
renderSettingsOverlay();
});
const searchInput = overlay.querySelector('#bbqrSearchInput');
searchInput?.addEventListener('input', () => {
liveState.search = searchInput.value;
const resultsArea = overlay.querySelector('#bbqrResultsArea');
if (resultsArea) resultsArea.innerHTML = buildQuickAnswersResultsHtml(liveState.folderId, liveState.search);
});
overlay.addEventListener('click', (e) => {
if (e.target.closest('[data-bbqr-back]')) { close(); renderQuickAnswersOverlay({}); return; }
if (e.target.closest('[data-bbqr-create-folder]')) { close(); renderCreateFolderOverlay(); return; }
const createInFolder = e.target.closest('[data-bbqr-create-in-folder]');
if (createInFolder) {
close();
renderCreateAnswerOverlay(null, null, createInFolder.getAttribute('data-bbqr-create-in-folder'));
return;
}
const openFolder = e.target.closest('[data-bbqr-open-folder]');
if (openFolder && !e.target.closest('[data-bbqr-pin-folder], [data-bbqr-edit-folder], [data-bbqr-del-folder]')) {
close();
renderQuickAnswersOverlay({ folderId: openFolder.getAttribute('data-bbqr-open-folder') });
return;
}
const pinFolder = e.target.closest('[data-bbqr-pin-folder]');
if (pinFolder) {
e.stopPropagation();
const id = pinFolder.getAttribute('data-bbqr-pin-folder');
const list = loadQuickFolders();
const f = list.find((x) => x.id === id);
if (f) { f.pinned = !f.pinned; saveQuickFolders(list); }
close();
renderQuickAnswersOverlay({ folderId: liveState.folderId, search: liveState.search });
return;
}
const editFolder = e.target.closest('[data-bbqr-edit-folder]');
if (editFolder) {
e.stopPropagation();
const id = editFolder.getAttribute('data-bbqr-edit-folder');
const f = loadQuickFolders().find((x) => x.id === id);
close();
renderCreateFolderOverlay(f);
return;
}
const delFolder = e.target.closest('[data-bbqr-del-folder]');
if (delFolder) {
e.stopPropagation();
const id = delFolder.getAttribute('data-bbqr-del-folder');
const folderTitle = loadQuickFolders().find((x) => x.id === id)?.title || 'папку';
if (!window.confirm(`Удалить папку «${folderTitle}» вместе со всеми ответами внутри неё?`)) return;
saveQuickFolders(loadQuickFolders().filter((x) => x.id !== id));
saveQuickAnswers(loadQuickAnswers().filter((a) => a.folderId !== id));
saveRecentAnswers(loadRecentAnswers().filter((rid) => {
const a = loadQuickAnswers().find((x) => x.id === rid);
return !!a;
}));
close();
renderQuickAnswersOverlay({});
return;
}
const delBtn = e.target.closest('[data-bbqr-del]');
if (delBtn) {
e.stopPropagation();
const id = delBtn.getAttribute('data-bbqr-del');
saveQuickAnswers(loadQuickAnswers().filter((a) => a.id !== id));
saveRecentAnswers(loadRecentAnswers().filter((rid) => rid !== id));
close();
renderQuickAnswersOverlay({ folderId: liveState.folderId, search: liveState.search });
return;
}
const editBtn = e.target.closest('[data-bbqr-edit]');
if (editBtn) {
e.stopPropagation();
const id = editBtn.getAttribute('data-bbqr-edit');
const a = loadQuickAnswers().find((x) => x.id === id);
close();
renderCreateAnswerOverlay(a, id, a?.folderId);
return;
}
const pinAnswer = e.target.closest('[data-bbqr-pin-answer]');
if (pinAnswer) {
e.stopPropagation();
const id = pinAnswer.getAttribute('data-bbqr-pin-answer');
const list = loadQuickAnswers();
const a = list.find((x) => x.id === id);
if (a) { a.pinned = !a.pinned; saveQuickAnswers(list); }
close();
renderQuickAnswersOverlay({ folderId: liveState.folderId, search: liveState.search });
return;
}
const pick = e.target.closest('[data-bbqr-pick]');
if (pick) {
const id = pick.getAttribute('data-bbqr-pick');
const a = loadQuickAnswers().find((x) => x.id === id);
if (a) { pickAnswer(a); close(); }
}
});
}
function renderCreateFolderOverlay(existing = null) {
const bodyHtml = `
<div class="bbqr-field">
<label class="bbqr-label">Название папки</label>
<input type="text" class="bbqr-input" id="bbqrFolderTitle" maxlength="60" value="${escapeHtml(existing?.title || '')}" />
</div>`;
const footerHtml = `
<button type="button" class="bbqr-btn" data-bbqr-cancel>Отмена</button>
<button type="button" class="bbqr-btn bbqr-btn--primary" id="bbqrSaveFolder">${existing ? 'Сохранить' : 'Создать папку'}</button>`;
const html = overlayShell({ title: existing ? 'Переименовать папку' : 'Новая папка', bodyHtml, footerHtml, maxWidth: 480 });
const { overlay, close } = openOverlay('bbqrFolderOverlay', html);
const backToList = () => { close(); renderQuickAnswersOverlay({}); };
overlay.querySelector('[data-bbqr-shell-close]')?.addEventListener('click', backToList);
overlay.querySelector('[data-bbqr-cancel]')?.addEventListener('click', backToList);
overlay.querySelector('#bbqrSaveFolder')?.addEventListener('click', () => {
const titleInput = overlay.querySelector('#bbqrFolderTitle');
const title = titleInput.value.trim();
if (!title) { titleInput.focus(); return; }
const list = loadQuickFolders();
if (existing) {
const f = list.find((x) => x.id === existing.id);
if (f) f.title = title;
} else {
list.push({ id: genId(), title, pinned: false, usageCount: 0, createdAt: Date.now() });
}
saveQuickFolders(list);
close();
renderQuickAnswersOverlay({});
});
}
function renderCreateAnswerOverlay(existing = null, editId = null, forcedFolderId = null) {
const folders = loadQuickFolders();
const stick = existing?.threadStick ?? 'no';
const closer = existing?.threadCloser ?? 'no';
const prefixId = existing?.prefix_id ?? -1;
const selectedFolderId = existing?.folderId ?? forcedFolderId ?? (folders[0]?.id ?? '');
if (!folders.length) {
const bodyHtml = `<div class="bbqr-empty">Сначала нужно создать папку — ответы обязательно должны лежать в папке.</div>`;
const footerHtml = `
<button type="button" class="bbqr-btn" data-bbqr-cancel>Закрыть</button>
<button type="button" class="bbqr-btn bbqr-btn--primary" id="bbqrGoCreateFolder">+ Создать папку</button>`;
const html = overlayShell({ title: 'Создание ответа', bodyHtml, footerHtml, maxWidth: 480 });
const { overlay, close } = openOverlay('bbqrCreateOverlay', html);
const backToList = () => { close(); renderQuickAnswersOverlay({}); };
overlay.querySelector('[data-bbqr-shell-close]')?.addEventListener('click', backToList);
overlay.querySelector('[data-bbqr-cancel]')?.addEventListener('click', backToList);
overlay.querySelector('#bbqrGoCreateFolder')?.addEventListener('click', () => { close(); renderCreateFolderOverlay(); });
return;
}
const folderOptionsHtml = folders.map((f) => `<option value="${f.id}">${escapeHtml(f.title)}</option>`).join('');
const prefixOptionsHtml =
'<option value="-1">(Не изменять)</option><option value="0">(Без префикса)</option>' +
PREFIX_MAP.map(([label, id]) => `<option value="${id}">${escapeHtml(label)}</option>`).join('');
const bodyHtml = `
<div class="bbqr-field">
<label class="bbqr-label">Папка<span style="color:var(--bbqr-accent);">*</span></label>
<select class="bbqr-select" id="bbqrFolderSelect">${folderOptionsHtml}</select>
</div>
<div class="bbqr-field">
<label class="bbqr-label">Название кнопки<span style="color:var(--bbqr-accent);">*</span></label>
<input type="text" class="bbqr-input" id="bbqrTitleButton" maxlength="40" value="${escapeHtml(existing?.title || '')}" />
</div>
<div class="bbqr-field">
<label class="bbqr-label">Текст<span style="color:var(--bbqr-accent);">*</span></label>
<textarea class="bbqr-textarea" id="bbqrAnswerText">${escapeHtml(existing?.content || '')}</textarea>
</div>
<div class="bbqr-field">
<label class="bbqr-label">Закрыть тему</label>
<div class="bbqr-radio-group">
<label class="bbqr-radio"><input type="radio" name="bbqrThreadCloser" value="no" ${closer === 'no' ? 'checked' : ''}><span>Не изменять</span></label>
<label class="bbqr-radio"><input type="radio" name="bbqrThreadCloser" value="1" ${closer === '1' ? 'checked' : ''}><span>Закрыть</span></label>
<label class="bbqr-radio"><input type="radio" name="bbqrThreadCloser" value="2" ${closer === '2' ? 'checked' : ''}><span>Открыть</span></label>
</div>
</div>
<div class="bbqr-field">
<label class="bbqr-label">Закрепить тему</label>
<div class="bbqr-radio-group">
<label class="bbqr-radio"><input type="radio" name="bbqrThreadStick" value="no" ${stick === 'no' ? 'checked' : ''}><span>Не изменять</span></label>
<label class="bbqr-radio"><input type="radio" name="bbqrThreadStick" value="1" ${stick === '1' ? 'checked' : ''}><span>Закрепить</span></label>
<label class="bbqr-radio"><input type="radio" name="bbqrThreadStick" value="2" ${stick === '2' ? 'checked' : ''}><span>Открепить</span></label>
</div>
</div>
<div class="bbqr-field">
<label class="bbqr-label">Изменение префикса</label>
<select class="bbqr-select" id="bbqrPrefixId">${prefixOptionsHtml}</select>
</div>`;
const footerHtml = `
<button type="button" class="bbqr-btn" data-bbqr-cancel>Отмена</button>
<button type="button" class="bbqr-btn bbqr-btn--primary" id="bbqrSaveAnswer">${existing ? 'Сохранить' : 'Добавить ответ'}</button>`;
const html = overlayShell({ title: existing ? 'Редактирование ответа' : 'Создание ответа', bodyHtml, footerHtml, maxWidth: 640 });
const { overlay, close } = openOverlay('bbqrCreateOverlay', html);
const folderSelect = overlay.querySelector('#bbqrFolderSelect');
if (folderSelect) folderSelect.value = String(selectedFolderId);
const prefixSelect = overlay.querySelector('#bbqrPrefixId');
if (prefixSelect) prefixSelect.value = String(prefixId);
const backToList = () => { close(); renderQuickAnswersOverlay({ folderId: existing?.folderId ?? forcedFolderId }); };
overlay.querySelector('[data-bbqr-shell-close]')?.addEventListener('click', backToList);
overlay.querySelector('[data-bbqr-cancel]')?.addEventListener('click', backToList);
overlay.querySelector('#bbqrSaveAnswer')?.addEventListener('click', () => {
const title = overlay.querySelector('#bbqrTitleButton')?.value.trim() || 'Мой ответ';
const content = overlay.querySelector('#bbqrAnswerText')?.value || '';
if (!content.trim()) { overlay.querySelector('#bbqrAnswerText')?.focus(); return; }
const folderId = overlay.querySelector('#bbqrFolderSelect')?.value;
const stickVal = overlay.querySelector('input[name="bbqrThreadStick"]:checked')?.value || 'no';
const closerVal = overlay.querySelector('input[name="bbqrThreadCloser"]:checked')?.value || 'no';
const prefixVal = overlay.querySelector('#bbqrPrefixId')?.value;
const prefix_id = prefixVal === undefined ? -1 : Number(prefixVal);
const list = loadQuickAnswers();
if (editId) {
const idx = list.findIndex((x) => x.id === editId);
if (idx !== -1) {
list[idx] = {
...list[idx],
title, content, folderId,
threadStick: String(stickVal),
threadCloser: String(closerVal),
prefix_id: Number.isNaN(prefix_id) ? -1 : prefix_id,
updatedAt: Date.now(),
};
}
} else {
list.push({
id: genId(), title, content, folderId,
threadStick: String(stickVal),
threadCloser: String(closerVal),
prefix_id: Number.isNaN(prefix_id) ? -1 : prefix_id,
pinned: false, usageCount: 0,
createdAt: Date.now(), updatedAt: Date.now(),
});
}
saveQuickAnswers(list);
close();
renderQuickAnswersOverlay({ folderId });
});
}
function renderSettingsOverlay() {
const currentTheme = loadTheme();
const themeSwatchesHtml = Object.entries(THEMES)
.map(([key, th]) => `
<div class="bbqr-theme-swatch ${key === currentTheme ? 'is-active' : ''}"
data-bbqr-theme="${key}"
style="background:${th.bg}; color:${th.text}; border-color:${th.border};">
${escapeHtml(th.label)}
</div>`)
.join('');
const bodyHtml = `
<div class="bbqr-field">
<label class="bbqr-label">Тема оформления</label>
<div class="bbqr-theme-grid">${themeSwatchesHtml}</div>
<div class="bbqr-hint">Применяется к окнам «Быстрый ответ» и «Кнопки на главный экран».</div>
</div>
<div class="bbqr-field">
<label class="bbqr-label">Данные скрипта</label>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button type="button" class="bbqr-btn" id="bbqrExportBtn">Экспорт настроек</button>
<button type="button" class="bbqr-btn" id="bbqrImportBtn">Импорт настроек</button>
</div>
<div class="bbqr-hint">Экспорт сохранит все ответы, папки, кнопки-префиксы и выбранную тему в файл.</div>
</div>`;
const footerHtml = `<button type="button" class="bbqr-btn" data-bbqr-cancel>← Назад</button>`;
const html = overlayShell({ title: 'Настройки', bodyHtml, footerHtml, maxWidth: 560 });
const { overlay, close } = openOverlay('bbqrSettingsOverlay', html);
overlay.querySelector('[data-bbqr-cancel]')?.addEventListener('click', () => { close(); renderQuickAnswersOverlay({}); });
overlay.querySelectorAll('[data-bbqr-theme]').forEach((sw) => {
sw.addEventListener('click', () => {
saveTheme(sw.getAttribute('data-bbqr-theme'));
close();
renderSettingsOverlay();
});
});
overlay.querySelector('#bbqrExportBtn')?.addEventListener('click', () => exportSettings());
overlay.querySelector('#bbqrImportBtn')?.addEventListener('click', () => {
triggerImportFilePicker(() => { close(); renderSettingsOverlay(); });
});
}
function renderPrefixControlOverlay() {
if (!getThreadIdFromUrl()) { showBbqrToast('Не удалось определить ID темы.', 'error'); return; }
const stagedPrefixes = loadHomePrefixes();
const stagedActions = loadHomeActions();
const actionCards = [['close', 'Закрыть/Открыть тему'], ['stick', 'Закрепить/Открепить тему']];
const bodyHtml = `
<div class="bbqr-grid">
${PREFIX_MAP.map(([label, id]) => {
const active = stagedPrefixes.has(label);
return `
<div class="bbqr-card" data-prefix-label="${escapeHtml(label)}" data-prefix-id="${id}" style="${active ? 'border-color:var(--bbqr-accent);' : ''}">
<span class="bbqr-card__pin ${active ? 'is-pinned' : ''}" style="position:static; opacity:1; font-size:13px;">${active ? '✓' : ''}</span>
<div class="bbqr-card__title">${escapeHtml(label)}</div>
</div>`;
}).join('')}
</div>
<hr style="border:0; border-top:1px solid var(--bbqr-border); margin:20px 0;" />
<div class="bbqr-grid" style="grid-template-columns:repeat(auto-fill, minmax(220px,1fr));">
${actionCards.map(([key, label]) => {
const active = stagedActions.has(key);
return `
<div class="bbqr-card" data-action-key="${key}" style="${active ? 'border-color:var(--bbqr-accent);' : ''} text-align:center;">
<div class="bbqr-card__title">${active ? '✓ ' : ''}${label}</div>
</div>`;
}).join('')}
</div>`;
const footerHtml = `
<button type="button" class="bbqr-btn" data-bbqr-cancel>Отмена</button>
<button type="button" class="bbqr-btn bbqr-btn--primary" id="bbqrSavePrefixControl">Сохранить</button>`;
const html = overlayShell({ title: 'Кнопки на главный экран', bodyHtml, footerHtml, maxWidth: 900 });
const { overlay, close } = openOverlay('bbqrPrefixControlOverlay', html);
overlay.querySelector('[data-bbqr-cancel]')?.addEventListener('click', close);
overlay.querySelectorAll('[data-prefix-label]').forEach((card) => {
card.addEventListener('click', () => {
const label = card.getAttribute('data-prefix-label');
if (stagedPrefixes.has(label)) stagedPrefixes.delete(label); else stagedPrefixes.add(label);
const active = stagedPrefixes.has(label);
card.style.borderColor = active ? 'var(--bbqr-accent)' : '';
const pin = card.querySelector('.bbqr-card__pin');
if (pin) { pin.classList.toggle('is-pinned', active); pin.textContent = active ? '✓' : ''; }
});
});
overlay.querySelectorAll('[data-action-key]').forEach((card) => {
card.addEventListener('click', () => {
const key = card.getAttribute('data-action-key');
if (stagedActions.has(key)) stagedActions.delete(key); else stagedActions.add(key);
const active = stagedActions.has(key);
card.style.borderColor = active ? 'var(--bbqr-accent)' : '';
const title = card.querySelector('.bbqr-card__title');
if (title) {
const label = actionCards.find(([k]) => k === key)?.[1] || '';
title.textContent = (active ? '✓ ' : '') + label;
}
});
});
overlay.querySelector('#bbqrSavePrefixControl')?.addEventListener('click', () => {
saveHomePrefixes(stagedPrefixes);
saveHomeActions(stagedActions);
close();
showBbqrToast('Готово: кнопки сохранены.', 'success');
setTimeout(() => window.location.reload(), 400);
});
}
function interceptReplySubmit() {
document.addEventListener(
'click',
async (e) => {
const btn = e.target.closest(REPLY_BTN_SELECTOR);
if (!btn || bbqrIsApplying) return;
const settingsToApply = bbLastQuickSettings;
if (!settingsToApply) {
setTimeout(() => { window.location.reload(); }, 3000);
return;
}
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
bbLastQuickSettings = null;
bbqrIsApplying = true;
const originalText = btn.innerHTML;
btn.innerHTML = '⏳ Применяю настройки...';
btn.disabled = true;
try {
await applyThreadSettingsFromQuickAnswer(settingsToApply);
} catch (err) {
console.error('[BB Quick Reply] Ошибка применения настроек:', err);
showBbqrToast('Ошибка: ' + err.message, 'error');
} finally {
bbqrIsApplying = false;
btn.disabled = false;
btn.innerHTML = originalText;
setTimeout(() => { btn.click(); }, 50);
}
},
true
);
}
injectBbqrGlobalStyles();
migrateData();
interceptReplySubmit();
injectDeadlineBadgesInListing();
let bbqrDeadlineTimer = null;
const scheduleDeadlineUpdate = () => {
if (bbqrDeadlineTimer) return;
bbqrDeadlineTimer = setTimeout(() => {
bbqrDeadlineTimer = null;
injectDeadlineBadgesInListing();
}, 200);
};
const deadlineObserver = new MutationObserver(() => scheduleDeadlineUpdate());
deadlineObserver.observe(document.body, { childList: true, subtree: true });
let bbqrObserverTimer = null;
const scheduleDomUpdate = () => {
if (bbqrObserverTimer) return;
bbqrObserverTimer = setTimeout(() => {
bbqrObserverTimer = null;
ensureInlineAnswerButtons();
}, 200);
};
const observer = new MutationObserver(() => scheduleDomUpdate());
observer.observe(document.body, { childList: true, subtree: true });
ensureInlineAnswerButtons();
injectDeadlineBadgesInListing();
})();