(function () {
'use strict';
// Настройки фильтрации и пагинации
const POSTS_PER_PAGE = 30;
const EXCLUDED_FORUM_IDS = new Set([42, 50]); // ID разделов, которые не показывать
const EXCLUDED_TOPIC_IDS = new Set([]); // ID тем, которые не показывать
const Utils = {
// Извлекает "чистое" название темы из формата "color;;bg;;title"
extractCleanTitle(titleText) {
if (!titleText || !titleText.includes(';;')) return titleText || '';
const parts = titleText.split(';;');
if (parts.length >= 3) {
return parts.slice(2).join(';;');
}
return titleText;
},
// Обрезает текст с многоточием
truncate(text, maxLength = 40) {
if (!text) return "";
text = text.trim();
return text.length > maxLength ? text.slice(0, maxLength) + "…" : text;
},
// Генерирует уникальный ID для строки темы
getTopicId(item) {
return `topic_${item[4]}`; // item[4] = topic ID
}
};
const Settings = {
// Асинхронная загрузка настроек пользователя
loadUserSettings() {
return new Promise((resolve) => {
const defaultSettings = {
enabled: true,
showNewestOnTop: false,
maxTopicLength: 40,
maxMobileTopicLength: 15,
topicCount: 6,
rowHeight: 33,
updatedAt: 0
};
try {
const localSettings = localStorage.getItem('liveBoxUserSettings');
if (localSettings) {
const parsed = JSON.parse(localSettings);
resolve({ ...defaultSettings, ...parsed });
return;
}
} catch (e) { }
resolve(defaultSettings);
});
},
// Синхронная версия — для частого использования в UI/API
getLiveSettingsSync() {
const defaultSettings = {
enabled: true,
showNewestOnTop: false,
maxTopicLength: 40,
maxMobileTopicLength: 15,
topicCount: 6,
rowHeight: 33,
updatedAt: 0
};
try {
const localSettings = localStorage.getItem('liveBoxUserSettings');
if (localSettings) {
return { ...defaultSettings, ...JSON.parse(localSettings) };
}
} catch (e) { }
return defaultSettings;
},
// Сохранение настроек в localStorage
saveUserSettings(settings) {
return new Promise((resolve, reject) => {
try {
localStorage.setItem('liveBoxUserSettings', JSON.stringify(settings));
resolve();
} catch (error) {
reject(error);
}
});
},
// Проверка, включён ли Live-бокс
shouldShowLiveBox() {
return this.loadUserSettings().then(settings => settings.enabled);
},
// Скрывает стандартный блок "Выбор отображения страниц" в профиле
hideDisplaySettingsSection() {
const fieldsets = document.querySelectorAll('fieldset');
for (const fieldset of fieldsets) {
const legendSpan = fieldset.querySelector('legend > span');
if (legendSpan && legendSpan.textContent.trim() === 'Выбор отображения страниц') {
fieldset.remove();
break;
}
}
},
// Добавляет панель настроек Live-бокса в профиль (только на своей странице)
addProfileSettings() {
if (document.getElementById('live-settings-accordion')) return;
const urlParams = new URLSearchParams(window.location.search);
const profileId = urlParams.get('id');
if (!profileId) return;
// Определяем, чей это профиль — свой или чужой
let myId = null;
if (typeof pun_user !== 'undefined' && pun_user.id && !pun_user.is_guest) {
myId = String(pun_user.id);
}
if (!myId) {
const profileLinks = document.querySelectorAll('a[href*="/profile.php?id="]');
for (const link of profileLinks) {
try {
const hrefId = new URL(link.href).searchParams.get('id');
if (hrefId) {
myId = hrefId;
break;
}
} catch (e) { }
}
}
if (!myId || myId !== profileId) return;
const punMain = document.getElementById('pun-main');
if (!punMain) {
setTimeout(() => this.addProfileSettings(), 500);
return;
}
this.hideDisplaySettingsSection();
this.loadUserSettings().then(settings => {
const isMobile = window.innerWidth <= 768;
const containerStyle = isMobile ? 'width: 100%;' : 'display: inline-block;';
// Генерация полей ввода (разные для мобилки и десктопа)
let textSettingsHtml = '';
if (isMobile) {
textSettingsHtml += `
<div>
<label>
Длина темы (моб.):
<input type="number" id="max-mobile-topic-length" value="${settings.maxMobileTopicLength || 15}" min="1" max="20" />
</label>
</div>
`;
} else {
textSettingsHtml += `
<div>
<label>
Количество символов в теме (макс. 60):
<input type="number" id="max-topic-length" value="${settings.maxTopicLength || 40}" min="1" max="60" />
</label>
</div>
`;
}
// Формируем HTML всей панели
let settingsHtml = `
<div id="live-settings-accordion" class="live-settings-accordion" style="${containerStyle}">
<div class="live-settings-header">
<span>🟢 Настройки Live-бокса</span>
<span class="live-settings-toggle">▼</span>
</div>
<div class="live-settings-content">
<div class="live-settings-inner">
<div>
<label>
<input type="checkbox" id="live-enabled" ${settings.enabled ? 'checked' : ''}>
🟢 Включить Live-бокс на главной странице
</label>
</div>
<div>
<label>
<input type="checkbox" id="live-sort-newest-on-top" ${settings.showNewestOnTop ? 'checked' : ''}>
📌 Новые темы сверху
<small style="display:block;color:#777;font-weight:normal;">(если не отмечено — новые снизу)</small>
</label>
</div>
<div>
<label>
Количество отображаемых тем (3–8):
<input type="number" id="live-topic-count" value="${settings.topicCount || 6}" min="3" max="8" />
</label>
</div>
<div>
<label>
Высота строки (px):
<input type="number" id="live-row-height" value="${settings.rowHeight || 33}" min="20" max="60" />
</label>
</div>
${textSettingsHtml}
<button id="save-live-settings" class="button">💾 Сохранить настройки</button>
<div id="live-settings-status"></div>
</div>
</div>
</div>
`;
// Вставка перед формой редактирования профиля или в #pun-main
const profileForm = document.querySelector('form#edit-profile, form[name="edit_profile"]');
const targetContainer = profileForm || punMain;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = settingsHtml.trim();
const settingsEl = tempDiv.firstElementChild;
if (profileForm) {
profileForm.parentNode.insertBefore(settingsEl, profileForm);
} else {
punMain.prepend(settingsEl);
}
// Обработчик аккордеона (раскрытие/сворачивание)
const header = document.querySelector('.live-settings-header');
if (header) {
header.addEventListener('click', () => {
const content = header.nextElementSibling;
const toggle = header.querySelector('.live-settings-toggle');
if (!content || !toggle) return;
const isOpen = content.style.maxHeight && content.style.maxHeight !== '0px';
if (isOpen) {
content.style.maxHeight = '0px';
content.style.padding = '0';
toggle.style.transform = 'rotate(0deg)';
} else {
const inner = content.querySelector('.live-settings-inner');
if (inner) {
const innerHeight = inner.scrollHeight;
content.style.maxHeight = innerHeight + 'px';
content.style.padding = '20px 16px';
toggle.style.transform = 'rotate(180deg)';
}
}
});
}
// Обработчик сохранения настроек
const saveBtn = document.getElementById('save-live-settings');
if (saveBtn) {
saveBtn.addEventListener('click', e => {
e.preventDefault();
// Сбор значений
const enabled = document.getElementById('live-enabled')?.checked || false;
const newestOnTop = document.getElementById('live-sort-newest-on-top')?.checked || false;
const topicCount = parseInt(document.getElementById('live-topic-count')?.value) || 6;
const rowHeight = parseInt(document.getElementById('live-row-height')?.value) || 33;
const newSettings = {
enabled,
showNewestOnTop: newestOnTop,
topicCount,
rowHeight,
updatedAt: Date.now()
};
if (isMobile) {
newSettings.maxMobileTopicLength = parseInt(document.getElementById('max-mobile-topic-length')?.value) || 15;
} else {
newSettings.maxTopicLength = parseInt(document.getElementById('max-topic-length')?.value) || 40;
}
// Сохранение + обратная связь
this.saveUserSettings(newSettings).then(() => {
const statusEl = document.getElementById('live-settings-status');
if (statusEl) {
// ✅ Обёрнут в .live-emoji для мгновенного отображения
statusEl.innerHTML = '<span class="live-emoji">✅</span> Настройки успешно сохранены!';
statusEl.style.display = 'block';
setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
}
}).catch(error => {
const statusEl = document.getElementById('live-settings-status');
if (statusEl) {
statusEl.textContent = '❌ Ошибка: ' + (error.message || error);
statusEl.style.display = 'block';
setTimeout(() => {
statusEl.style.display = 'none';
}, 5000);
}
});
});
}
});
}
};
const API = {
// Получает последние темы через API
fetchTopics(isInitial = false, currentLimit = null, retryCountAjax = 0, context = {}) {
const { isFetching, paused, retryDelay = 1500 } = context;
if (paused || isFetching) return;
// Параметры для адаптивной загрузки
const INITIAL_LIMIT = 15;
const REGULAR_LIMIT = 15;
const INITIAL_STEP = 10;
const REGULAR_STEP = 6;
const INITIAL_MAX = 80;
const REGULAR_MAX = 40;
const maxTopics = 8;
const limit = currentLimit !== null
? currentLimit
: (isInitial ? INITIAL_LIMIT : REGULAR_LIMIT);
const step = isInitial ? INITIAL_STEP : REGULAR_STEP;
const maxLimit = isInitial ? INITIAL_MAX : REGULAR_MAX;
context.isFetching = true;
const fields = 'id,subject,last_username,last_post_date,forum_id,num_replies';
const url = `/api.php?method=topic.getRecent&limit=${limit}&fields=${encodeURIComponent(fields)}&_=${Date.now()}`;
fetch(url)
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(data => {
context.isFetching = false;
if (!data?.response || !Array.isArray(data.response)) {
if (retryCountAjax < 10) setTimeout(() => this.fetchTopics(isInitial, currentLimit, retryCountAjax + 1, context), retryDelay);
return;
}
// Фильтрация и преобразование тем
let validTopics = data.response
.filter(topic => {
if (!topic.last_post_date || !topic.id) return false;
if (EXCLUDED_FORUM_IDS.has(Number(topic.forum_id))) return false;
if (EXCLUDED_TOPIC_IDS.has(Number(topic.id))) return false;
return true;
})
.map(topic => {
const timestamp = parseInt(topic.last_post_date);
const author = topic.last_username || 'Гость';
const url = `/viewtopic.php?id=${topic.id}`;
const title = topic.subject || '[Без названия]';
const tid = topic.id;
const numReplies = topic.num_replies ? parseInt(topic.num_replies) : 0;
return [timestamp, author, url, title, tid, numReplies]; // item[5] = numReplies
});
if (validTopics.length >= maxTopics) {
UI.processTopics(validTopics.slice(0, maxTopics), context);
return;
}
if (limit < maxLimit) {
setTimeout(() => this.fetchTopics(isInitial, limit + step, retryCountAjax, context), 100);
} else {
UI.processTopics(validTopics, context);
}
})
.catch(error => {
context.isFetching = false;
console.error('Ошибка API topic.getRecent:', error);
if (retryCountAjax < 10) setTimeout(() => this.fetchTopics(isInitial, currentLimit, retryCountAjax + 1, context), retryDelay);
});
}
};
const UI = {
bodyGrid: null,
wrapper: null,
// Главная функция запуска Live-бокса на главной странице
runLiveBox() {
if (window.location.pathname !== "/" && !window.location.pathname.endsWith("index.php")) return;
const maxTopics = 8;
let paused = false, activeTab = true, isFetching = false;
const seenMessages = new Set(), trackedPosts = new Map(); // Оставлены для совместимости с контекстом
let inactiveTime = 0, checkInterval = 7000, firstLoad = true, pendingPosts = 0; // pendingPosts не используется в этом варианте
let lastTopicIds = new Set();
let forceFullReloadNextTime = false;
let lastShowNewestOnTop = Settings.getLiveSettingsSync().showNewestOnTop;
// 🔁 Мгновенная пересортировка при изменении настройки
const instantlyReapplySorting = () => {
const settings = Settings.getLiveSettingsSync();
const rows = Array.from(this.bodyGrid.querySelectorAll('.post-row'));
rows.sort((a, b) => {
const tsA = Number(a.getAttribute('data-timestamp')) || 0;
const tsB = Number(b.getAttribute('data-timestamp')) || 0;
return settings.showNewestOnTop ? tsB - tsA : tsA - tsB;
});
rows.forEach(row => this.bodyGrid.appendChild(row));
if (settings.showNewestOnTop) {
if (this.wrapper) this.wrapper.scrollTop = 0;
} else {
if (this.wrapper) this.wrapper.scrollTop = this.wrapper.scrollHeight;
}
};
// Проверка настроек каждые 300 мс — для мгновенного обновления сортировки
setInterval(() => {
const current = Settings.getLiveSettingsSync().showNewestOnTop;
if (current !== lastShowNewestOnTop) {
lastShowNewestOnTop = current;
instantlyReapplySorting();
}
}, 300);
// Пауза при переходе на другую вкладку
document.addEventListener("visibilitychange", () => {
activeTab = document.visibilityState === "visible";
if (activeTab) {
API.fetchTopics(true, null, 0, {
isFetching, paused, retryDelay: 1500,
seenMessages, trackedPosts, firstLoad, pendingPosts,
lastTopicIds, forceFullReloadNextTime
});
inactiveTime = 0;
}
});
// Восстановление состояния паузы
const storedPaused = localStorage.getItem('livePaused');
if (storedPaused !== null) paused = storedPaused === 'true';
// Удаление старого контейнера, если есть
const existingContainer = document.getElementById('live-posts-container');
if (existingContainer) existingContainer.remove();
// Определение места вставки
let containerParent = null;
const punDebug = document.getElementById('pun-debug');
const punUlinks = document.getElementById('pun-ulinks');
const body = document.body;
if (punDebug) {
containerParent = punDebug.parentNode;
const container = document.createElement('div');
container.id = 'live-posts-container';
containerParent.replaceChild(container, punDebug);
} else if (punUlinks) {
containerParent = punUlinks.parentNode;
const container = document.createElement('div');
container.id = 'live-posts-container';
containerParent.insertBefore(container, punUlinks.nextSibling);
} else {
const container = document.createElement('div');
container.id = 'live-posts-container';
body.prepend(container);
}
// 💠 Добавление CSS для корректного отображения эмодзи
{
const style = document.createElement('style');
style.textContent = '.live-emoji{font-family:"Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif !important;}';
document.head.appendChild(style);
}
// Расчёт высоты контейнера
const userSettingsForHeight = Settings.getLiveSettingsSync();
const topicCount = Math.max(3, Math.min(8, userSettingsForHeight.topicCount || 6));
const rowHeight = Math.max(20, Math.min(60, userSettingsForHeight.rowHeight || 33));
const PADDING_TOP = 12;
const wrapperHeight = rowHeight * topicCount + PADDING_TOP;
// HTML-шаблон Live-бокса
const containerContent = `
<div class="live-posts-container">
<div class="live-header-grid">
<div class="header-cell tcl">
<div class="left-controls">
<label class="switch">
<input type="checkbox" id="pause-updates">
<span class="slider"></span>
</label>
<span class="pause-label">Стоп</span>
</div>
<div class="author-label"><span class="live-emoji">🧑💻</span> Автор</div>
</div>
<div class="header-cell tc2"><span class="live-emoji">🕒</span> Время</div>
<div class="header-cell tcr"><span class="live-emoji">📁</span> Тема</div>
</div>
<div class="live-body-wrapper" style="max-height: ${wrapperHeight}px;">
<div class="live-body-grid" id="live-posts-body">
<div class="post-row"><div class="post-cell">Загрузка тем...</div></div>
</div>
</div>
</div>
`;
const container = document.getElementById('live-posts-container');
container.innerHTML = containerContent;
this.bodyGrid = document.getElementById('live-posts-body');
this.wrapper = document.querySelector('.live-body-wrapper');
// Обработчик кнопки паузы
const pauseCheckbox = document.getElementById('pause-updates');
if (pauseCheckbox) {
pauseCheckbox.checked = paused;
pauseCheckbox.addEventListener('change', () => {
paused = pauseCheckbox.checked;
localStorage.setItem('livePaused', paused);
});
}
// Контекст для передачи в API и обработчики
const context = {
isFetching, paused, retryDelay: 1500,
seenMessages, trackedPosts, firstLoad, pendingPosts,
lastTopicIds, forceFullReloadNextTime,
inactiveTime, checkInterval, activeTab, maxTopics,
bodyGrid: this.bodyGrid,
wrapper: this.wrapper,
getTopicId: Utils.getTopicId,
getLiveSettingsSync: Settings.getLiveSettingsSync,
processTopics: this.processTopics.bind(this),
instantlyReapplySorting
};
// Основной цикл обновления
setInterval(() => {
if (!activeTab) {
inactiveTime += checkInterval;
let interval = 15000;
if (inactiveTime >= 180000) interval = 60000;
else if (inactiveTime >= 60000) interval = 30000;
if (inactiveTime % interval === 0) {
API.fetchTopics(true, null, 0, context);
}
return;
}
inactiveTime = 0;
const isFullReload = forceFullReloadNextTime;
if (isFullReload) {
forceFullReloadNextTime = false;
}
API.fetchTopics(isFullReload, null, 0, context);
}, checkInterval);
// Обновление при возврате в онлайн
window.addEventListener('online', () => {
seenMessages.clear();
trackedPosts.clear();
API.fetchTopics(true, null, 0, context);
});
// Первый запуск
API.fetchTopics(true, null, 0, context);
},
// Обработка массива тем — обновление DOM
processTopics(content, context) {
const { bodyGrid, getTopicId, getLiveSettingsSync, wrapper } = context;
const currentSeenTopics = new Set();
const currentSettings = getLiveSettingsSync();
const isMobileNow = window.innerWidth <= 768;
// Обнаружение удалённых тем → триггер полной перезагрузки
const newTopicIds = new Set();
content.forEach(item => {
newTopicIds.add(getTopicId(item));
});
if (newTopicIds.size > 0) {
let hasDisappeared = false;
context.lastTopicIds.forEach(id => {
if (!newTopicIds.has(id)) {
hasDisappeared = true;
}
});
if (hasDisappeared) {
context.forceFullReloadNextTime = true;
}
}
context.lastTopicIds = newTopicIds;
// Сортировка данных
if (currentSettings.showNewestOnTop) {
content.sort((a, b) => b[0] - a[0]);
} else {
content.sort((a, b) => a[0] - b[0]);
}
// Очистка "загрузки..."
const firstRow = bodyGrid.querySelector('.post-row');
if (firstRow && firstRow.querySelector('.loading-msg')) {
bodyGrid.innerHTML = '';
}
// Обновление или создание строк
content.forEach(item => {
const topicId = getTopicId(item);
const ts = new Date(1000 * item[0]);
const timeStr = `${String(ts.getDate()).padStart(2,'0')}.${String(ts.getMonth()+1).padStart(2,'0')} ${String(ts.getHours()).padStart(2,'0')}:${String(ts.getMinutes()).padStart(2,'0')}`;
let existingRow = null;
bodyGrid.querySelectorAll('.post-row').forEach(row => {
if (row.getAttribute('data-topic-id') === topicId) {
existingRow = row;
}
});
if (existingRow) {
existingRow.setAttribute('data-timestamp', item[0].toString());
const authorCell = existingRow.querySelector('.tcl');
const timeCell = existingRow.querySelector('.tc2');
const topicCell = existingRow.querySelector('.tcr');
const author = isMobileNow ? Utils.truncate(item[1], currentSettings.maxMobileAuthorLength || 12) : item[1];
authorCell.innerHTML = `<span class="live-emoji">🧑💻</span> ${author}`;
timeCell.innerHTML = `<span class="live-emoji">🕒</span> ${timeStr}`;
const topicTitle = item[3];
const processedTopicTitle = this.processTopicTitle(topicTitle, isMobileNow ? (currentSettings.maxMobileTopicLength || 15) : (currentSettings.maxTopicLength || 40));
topicCell.innerHTML = `<a href="${item[2]}" target="_blank">${processedTopicTitle}</a>`;
currentSeenTopics.add(topicId);
return;
}
// Новая строка
const author = isMobileNow ? Utils.truncate(item[1], currentSettings.maxMobileAuthorLength || 12) : item[1];
const topicTitle = item[3];
const processedTopicTitle = this.processTopicTitle(topicTitle, isMobileNow ? (currentSettings.maxMobileTopicLength || 15) : (currentSettings.maxTopicLength || 40));
const row = document.createElement('div');
row.className = 'post-row';
row.setAttribute('data-topic-id', topicId);
row.setAttribute('data-timestamp', item[0].toString());
row.innerHTML = `
<div class="post-cell tcl"><span class="live-emoji">🧑💻</span> ${author}</div>
<div class="post-cell tc2"><span class="live-emoji">🕒</span> ${timeStr}</div>
<div class="post-cell tcr"><a href="${item[2]}" target="_blank">${processedTopicTitle}</a></div>
`.trim();
bodyGrid.appendChild(row);
currentSeenTopics.add(topicId);
});
if (context.firstLoad) context.firstLoad = false;
// Удаление устаревших строк
bodyGrid.querySelectorAll('.post-row').forEach(row => {
const topicId = row.getAttribute('data-topic-id');
if (!currentSeenTopics.has(topicId)) {
row.remove();
const msgId = row.getAttribute('data-msg-id');
if (msgId) {
context.seenMessages.delete(msgId);
context.trackedPosts.delete(msgId);
}
}
});
// Принудительная сортировка DOM
const rows = Array.from(bodyGrid.querySelectorAll('.post-row'));
rows.sort((a, b) => {
const tsA = Number(a.getAttribute('data-timestamp')) || 0;
const tsB = Number(b.getAttribute('data-timestamp')) || 0;
return currentSettings.showNewestOnTop ? tsB - tsA : tsA - tsB;
});
rows.forEach(row => bodyGrid.appendChild(row));
// Прокрутка после обновления
if (!context.firstLoad) {
if (currentSettings.showNewestOnTop) {
if (wrapper) wrapper.scrollTop = 0;
} else {
if (wrapper) wrapper.scrollTop = wrapper.scrollHeight;
}
}
},
// Обработка цветных тем (формат color;;bg;;title)
processTopicTitle(titleText, maxLength = null) {
if (!titleText.includes(';;')) {
return maxLength !== null
? (titleText.length > maxLength ? titleText.slice(0, maxLength) + "…" : titleText)
: titleText;
}
const parts = titleText.split(';;');
if (parts.length < 3) {
return maxLength !== null
? (titleText.length > maxLength ? titleText.slice(0, maxLength) + "…" : titleText)
: titleText;
}
const color = parts[0];
const bg = parts[1];
let cleanTitle = parts.slice(2).join(';;');
if (maxLength !== null) {
cleanTitle = cleanTitle.length > maxLength
? cleanTitle.slice(0, maxLength) + "…"
: cleanTitle;
}
return `<span style="color:${color};background-color:${bg};padding:0 2px;border-radius:2px;">${cleanTitle}</span>`;
}
};
// Запуск после полной загрузки DOM
document.addEventListener('DOMContentLoaded', () => {
// Если страница профиля — показываем настройки (только на своей)
if (
window.location.pathname.includes('/profile.php') ||
window.location.pathname.includes('/user/')
) {
if (!document.getElementById('live-settings-accordion')) {
Settings.addProfileSettings();
}
return;
}
// Если не главная страница — ничего не делаем
if (window.location.pathname !== '/' && !window.location.pathname.endsWith('index.php')) {
return;
}
// На главной — запускаем Live-бокс, если включён
Settings.shouldShowLiveBox().then(shouldShow => {
if (shouldShow) {
UI.runLiveBox();
}
});
});
})();