// ==UserScript==
// @name Shikimori Comments Pagination
// @namespace http://tampermonkey.net/
// @version 1.2
// @description Пагинация комментариев
// @author karuma
// @license MIT
// @match https://shikimori.one/*
// @grant GM_addStyle
// ==/UserScript==
(function () {
'use strict';
const COMMENTS_PER_PAGE = 5; // Число комментариев на странице
const CHECK_INTERVAL = 500; // Частота проверки элементов на странице (Не ставить сликшом маленький)
GM_addStyle(`
.shiki-comments-pagination {
display: flex;
justify-content: center;
align-items: center;
margin: 20px 0;
gap: 10px;
padding: 10px;
background: #f8f8f8;
border-radius: 4px;
}
.shiki-comments-pagination button {
background: #579;
color: white;
border: none;
border-radius: 4px;
padding: 5px 10px;
cursor: pointer;
min-width: 30px;
transition: background 0.2s;
}
.shiki-comments-pagination button:hover {
background: #467;
}
.shiki-comments-pagination button:disabled {
background: #ccc;
cursor: not-allowed;
}
.shiki-comments-pagination input {
width: 60px;
text-align: center;
padding: 5px;
border: 1px solid #ddd;
border-radius: 4px;
}
.shiki-comments-pagination .page-info {
margin: 0 10px;
font-size: 14px;
}
.shiki-comments-loading {
opacity: 0.7;
pointer-events: none;
}
.b-spoiler_inline.opened {
background-color: #f5f5f5;
color: #333;
padding: 2px 4px;
border-radius: 3px;
box-shadow: 0 0 0 1px rgba(0,0,0,0.1);
}
`);
/* ========== ОБРАБОТКА СПОЙЛЕРОВ И УДАЛЕНИЯ ========== */
// Функция для раскрытия/закрытия inline-спойлеров (текстовых)
function bindSpoilerDeleteButtons(container) {
container.querySelectorAll('.b-spoiler_inline').forEach(spoiler => {
spoiler.addEventListener('click', async () => {
if (spoiler.classList.contains('opened')) {
// Если спойлер уже открыт - закрываем его
const originalContent = spoiler.dataset.originalContent;
if (originalContent) {
spoiler.innerHTML = originalContent;
}
spoiler.classList.remove('opened');
} else {
// Если спойлер закрыт - открываем его
spoiler.dataset.originalContent = spoiler.innerHTML;
const text = spoiler.textContent.trim();
spoiler.innerHTML = text;
spoiler.classList.add('opened');
}
});
});
}
/**
* Заменяет все даты в комментариях на относительное время (например, "2 часа назад").
* @param {HTMLElement|string} container - Контейнер с комментариями (DOM-элемент или CSS-селектор).
*/
function replaceCommentDates(container) {
// Если передан селектор, находим контейнер
const commentsContainer = typeof container === 'string'
? document.querySelector(container)
: container;
if (!commentsContainer) {
console.error('Контейнер с комментариями не найден!');
return;
}
// Находим все даты внутри контейнера
const dateElements = commentsContainer.querySelectorAll('time[datetime]');
dateElements.forEach((dateElement) => {
const dateTime = dateElement.getAttribute('datetime');
if (!dateTime) return;
const relativeTime = getRelativeTime(dateTime);
dateElement.textContent = relativeTime;
dateElement.setAttribute('title', new Date(dateTime).toLocaleString()); // Подсказка с полной датой
});
}
// Вспомогательная функция для форматирования времени
function getRelativeTime(dateTime) {
const now = new Date();
const past = new Date(dateTime);
const diffInSeconds = Math.floor((now - past) / 1000);
const intervals = {
год: { seconds: 31536000, endings: ['год', 'года', 'лет'] },
месяц: { seconds: 2592000, endings: ['месяц', 'месяца', 'месяцев'] },
неделя: { seconds: 604800, endings: ['неделя', 'недели', 'недель'] },
день: { seconds: 86400, endings: ['день', 'дня', 'дней'] },
час: { seconds: 3600, endings: ['час', 'часа', 'часов'] },
минута: { seconds: 60, endings: ['минута', 'минуты', 'минут'] },
секунда: { seconds: 1, endings: ['секунда', 'секунды', 'секунд'] },
};
for (const [unit, data] of Object.entries(intervals)) {
const interval = Math.floor(diffInSeconds / data.seconds);
if (interval >= 1) {
// Правильное склонение для русского языка
let ending;
if (interval % 10 === 1 && interval % 100 !== 11) {
ending = data.endings[0]; // 1 минута, 1 день
} else if ([2, 3, 4].includes(interval % 10) && ![12, 13, 14].includes(interval % 100)) {
ending = data.endings[1]; // 2 минуты, 3 дня
} else {
ending = data.endings[2]; // 5 минут, 11 дней
}
return `${interval} ${ending} назад`;
}
}
return "только что";
}
// Функция для раскрытия block-спойлеров (с контентом)
function bindSpoilerBlockButtons(container) {
const spoilerStyles = document.createElement('style');
spoilerStyles.textContent = `
.b-spoiler_block.to-process {
cursor: pointer;
display: inline;
margin: 0 1px;
}
.b-spoiler_block.to-process > span[tabindex="0"] {
display: inline;
padding: 1px 4px;
background-color: #687687;
color: #fff;
font-size: 12px;
font-family: inherit;
border-radius: 2px;
transition: all 0.15s ease;
line-height: 1.3;
}
.b-spoiler_block.to-process:hover > span[tabindex="0"] {
background-color: #5a6775;
}
.b-spoiler_block.to-process.is-opened > span[tabindex="0"] {
background-color: #f5f5f5;
color: #333;
box-shadow: 0 0 0 1px rgba(0,0,0,0.1);
}
.b-spoiler_block.to-process > div {
display: none;
margin-top: 3px;
padding: 5px;
background: #f5f5f5;
border-radius: 2px;
border: 1px solid #e0e0e0;
}
.b-spoiler_block.to-process.is-opened > div {
display: block;
}
`;
document.head.appendChild(spoilerStyles);
// Остальная часть функции остается без изменений
container.querySelectorAll('.b-spoiler_block.to-process').forEach(spoilerBlock => {
const spoilerTitle = spoilerBlock.querySelector('span[tabindex="0"]');
const contentDiv = spoilerBlock.querySelector('div');
if (!spoilerTitle || !contentDiv) return;
contentDiv.style.display = 'none';
spoilerTitle.addEventListener('click', (e) => {
e.stopPropagation();
if (contentDiv.style.display === 'none') {
contentDiv.style.display = 'block';
spoilerBlock.classList.add('is-opened');
initImageModalViewer(contentDiv);
} else {
contentDiv.style.display = 'none';
spoilerBlock.classList.remove('is-opened');
}
});
});
initImageModalViewer(container);
initVideoModalViewer(container);
}
// Функция для обработки обычных спойлеров
function bindSpoilerInlineBlocks(container) {
container.querySelectorAll('.b-spoiler.unprocessed').forEach(spoiler => {
spoiler.addEventListener('click', () => {
// Заменяем спойлер на его содержимое
const innerDiv = spoiler.querySelector('.content').querySelector('.inner');
if (innerDiv) {
spoiler.replaceWith(innerDiv.cloneNode(true));
}
});
});
}
// Кликабельность картинок
function initImageModalViewer(container) {
// Создаем модальное окно
const modal = document.createElement('div');
modal.style.cssText = `
display: none;
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.95);
cursor: zoom-out;
align-items: center;
justify-content: center;
overflow: auto;
`;
const img = document.createElement('img');
img.style.cssText = `
max-width: 90vw;
max-height: 90vh;
display: block;
cursor: default;
object-fit: contain;
animation: fadeInScale 0.3s ease-out;
`;
const closeBtn = document.createElement('span');
closeBtn.style.cssText = `
position: fixed;
top: 20px;
right: 30px;
font-size: 40px;
font-weight: bold;
cursor: pointer;
color: white;
transition: color 0.2s;
text-shadow: 0 0 5px rgba(0,0,0,0.8);
z-index: 10000;
`;
closeBtn.innerHTML = '×';
// Добавляем анимацию
const style = document.createElement('style');
style.textContent = `
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeInScale { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
`;
document.head.appendChild(style);
modal.append(img, closeBtn);
document.body.append(modal);
// Функции управления
const open = src => {
img.src = src.replace('/thumbnail/', '/original/').replace('/x48/', '/x160/');
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
// Сброс стилей перед загрузкой нового изображения
img.style.width = 'auto';
img.style.height = 'auto';
return false;
};
const close = () => {
modal.style.display = 'none';
document.body.style.overflow = '';
};
// Обработчики событий
modal.onclick = e => {
if (e.target === modal || e.target === img) {
close();
}
};
closeBtn.onclick = close;
document.addEventListener('keydown', e => e.key === 'Escape' && close());
// Получаем контейнер
const containerEl = typeof container === 'string'
? document.querySelector(container)
: container;
if (!containerEl) return;
// Обрабатываем изображения без изменения их исходного отображения
containerEl.querySelectorAll('img').forEach(el => {
// Пропускаем изображения без src или те, что находятся внутри .b-video
if (!el.src || el.closest('.b-video')) return;
// Сохраняем исходные стили
const originalStyles = el.getAttribute('style');
// Создаем копию изображения для превью
const preview = el.cloneNode(true);
// Добавляем обработчик клика
preview.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Открываем оригинальное изображение в модальном окне
const originalSrc = el.src
.replace('/thumbnail/', '/original/')
.replace('/x48/', '/x160/')
.replace('/small/', '/large/');
open(originalSrc);
});
// Заменяем оригинальное изображение на нашу копию
el.parentNode.replaceChild(preview, el);
// Восстанавливаем исходные стили
if (originalStyles) {
preview.setAttribute('style', originalStyles);
}
// Добавляем cursor: zoom-in только если его нет в исходных стилях
if (!originalStyles || !originalStyles.includes('cursor:')) {
preview.style.cursor = 'zoom-in';
}
});
}
function initVideoModalViewer(container) {
// Создаем модальное окно
const modal = document.createElement('div');
modal.style.cssText = `
display: none;
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.95);
cursor: zoom-out;
align-items: center;
justify-content: center;
overflow: auto;
`;
const videoContainer = document.createElement('div');
videoContainer.style.cssText = `
position: relative;
width: 90vw;
max-width: 1200px;
height: 0;
padding-bottom: 56.25%; /* 16:9 */
animation: fadeInScale 0.3s ease-out;
`;
const iframe = document.createElement('iframe');
iframe.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
`;
iframe.setAttribute('allowfullscreen', '');
iframe.setAttribute('allow', 'autoplay');
const closeBtn = document.createElement('span');
closeBtn.style.cssText = `
position: fixed;
top: 20px;
right: 30px;
font-size: 40px;
font-weight: bold;
cursor: pointer;
color: white;
transition: color 0.2s;
text-shadow: 0 0 5px rgba(0,0,0,0.8);
z-index: 10000;
`;
closeBtn.innerHTML = '×';
// Добавляем анимацию
const style = document.createElement('style');
style.textContent = `
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeInScale { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
`;
document.head.appendChild(style);
videoContainer.appendChild(iframe);
modal.append(videoContainer, closeBtn);
document.body.append(modal);
// Управляющие функции
const openVideo = videoId => {
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0`;
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
};
const closeVideo = () => {
iframe.src = '';
modal.style.display = 'none';
document.body.style.overflow = '';
};
// Обработчики
modal.onclick = e => {
if (e.target === modal) {
closeVideo();
}
};
closeBtn.onclick = closeVideo;
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeVideo();
});
// Контейнер
const containerEl = typeof container === 'string'
? document.querySelector(container)
: container;
if (!containerEl) return;
// Обработка видео
containerEl.querySelectorAll('.b-video.youtube .video-link').forEach(link => {
if (!link.dataset.href) return;
const youtubeUrl = link.dataset.href;
const videoId = youtubeUrl.match(/embed\/([^?]+)/)?.[1] ||
youtubeUrl.match(/youtu\.be\/([^?]+)/)?.[1] ||
youtubeUrl.match(/v=([^&]+)/)?.[1];
if (!videoId) return;
const preview = link.querySelector('img');
if (!preview) return;
preview.setAttribute('data-video-preview', 'true');
const originalStyles = preview.getAttribute('style');
// Удаляем ссылку, чтобы не было перехода
link.removeAttribute('href');
// Обработчик клика
link.onclick = function (e) {
e.preventDefault();
e.stopPropagation();
openVideo(videoId);
return false;
};
// Курсор
if (!originalStyles || !originalStyles.includes('cursor:')) {
preview.style.cursor = 'zoom-in';
}
});
}
// Функция для цитирования
function setupSimpleQuoteButtons(container) {
if (!container) {
console.error('Container not found');
return;
}
// Находим редактор как следующий элемент после контейнера
const editorContainer = container.nextElementSibling?.classList.contains('editor-container')
? container.nextElementSibling
: container.nextElementSibling?.nextElementSibling;
const editor = editorContainer?.querySelector('.editor-area');
container.querySelectorAll('.item-quote').forEach(button => {
button.classList.add('is-active');
button.classList.remove('to-process');
button.addEventListener('click', function(e) {
e.preventDefault();
const comment = this.closest('.b-comment');
if (!comment) return;
// Получаем данные комментария
const commentId = comment.id || comment.getAttribute('data-track_comment');
const userId = comment.getAttribute('data-user_id');
const userName = comment.getAttribute('data-user_nickname');
// Получаем текст комментария
const commentBody = comment.querySelector('.body');
if (!commentBody) return;
const textToQuote = commentBody.textContent.trim();
// Формируем цитату
const quote = `[quote=${commentId};${userId};${userName}]${textToQuote}[/quote]\n\n`;
// Вставляем в редактор, если он найден
if (editor) {
// Добавляем перенос, если уже есть текст
const prefix = editor.value.trim() ? '\n\n' : '';
editor.value += prefix + quote;
editor.focus();
// Показываем редактор
if (editorContainer) {
editorContainer.style.display = 'block';
editorContainer.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
}
});
});
}
// Функция для редактирования комментария
function setupEditButtons(container) {
container.querySelectorAll('.item-edit').forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const comment = this.closest('.b-comment');
if (!comment) return;
// Получаем ID комментария
const commentId = comment.id || comment.getAttribute('data-track_comment');
// Загружаем форму редактирования
fetch(`https://shikimori.one/comments/${commentId}/edit`, {
method: 'GET',
headers: {
'Accept': 'text/html',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin'
})
.then(response => response.text())
.then(html => {
// Создаем временный элемент для парсинга HTML
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const form = doc.querySelector('.edit_comment');
if (form) {
// Заменяем содержимое комментария на форму редактирования
comment.querySelector('.inner').innerHTML = form.outerHTML;
comment.querySelector('.inner').classList.add('is-editing');
// Инициализируем редактор
initEditor(comment);
// Настраиваем обработчик отправки формы
setupEditFormSubmit(comment, commentId);
}
})
.catch(error => {
console.error('Error loading edit form:', error);
});
});
});
}
function initEditor(comment) {
// Здесь можно добавить инициализацию редактора, если требуется
const textarea = comment.querySelector('.editor-area');
if (textarea) {
textarea.focus();
}
}
function setupEditFormSubmit(comment, commentId) {
const form = comment.querySelector('.edit_comment');
if (!form) return;
form.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(form);
fetch(form.action, {
method: 'PATCH',
body: formData,
headers: {
'Accept': 'application/json',
'X-CSRF-Token': form.querySelector('[name="authenticity_token"]').value,
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.content) {
// Обновляем содержимое комментария
const inner = comment.querySelector('.inner');
inner.classList.remove('is-editing');
inner.innerHTML = data.content;
// Можно добавить обработчики снова
setupEditButtons(comment.parentElement);
}
})
.catch(error => {
console.error('Error submitting edit:', error);
});
});
// Обработчик кнопки "Отмена"
const cancelButton = comment.querySelector('.cancel');
if (cancelButton) {
cancelButton.addEventListener('click', function(e) {
e.preventDefault();
// Загружаем оригинальный комментарий
fetch(`https://shikimori.one/comments/${commentId}`, {
method: 'GET',
headers: {
'Accept': 'text/html',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin'
})
.then(response => response.text())
.then(html => {
comment.querySelector('.inner').innerHTML = html;
comment.querySelector('.inner').classList.remove('is-editing');
});
});
}
}
// Функция для ответа на комментарий
function setupReplyButtons(container) {
container.querySelectorAll('.item-reply').forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const comment = this.closest('.b-comment');
if (!comment) return;
// Получаем ID комментария и пользователя
const commentId = comment.getAttribute('data-track_comment') ||
comment.id.replace('comment-', '');
const userId = comment.getAttribute('data-user_id');
const userName = comment.getAttribute('data-user_nickname');
// Формируем упоминание
const mention = `[comment=${commentId};${userId}], `;
// Ищем редактор относительно контейнера (аналогично функции цитирования)
const editorContainer = container.nextElementSibling?.classList.contains('editor-container')
? container.nextElementSibling
: container.nextElementSibling?.nextElementSibling;
const editor = editorContainer?.querySelector('.editor-area');
if (editor) {
editor.value += mention;
editor.focus();
if (editorContainer) {
editorContainer.style.display = 'block';
editorContainer.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
}
});
});
}
function setupModerationButtonsGlobal() {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
if (!csrfToken) {
console.error('CSRF token not found');
return;
}
document.addEventListener('click', async (e) => {
const target = e.target;
const comment = target.closest('.b-comment');
if (!comment) return;
if (target.classList.contains('item-moderation')) {
e.preventDefault();
toggleModerationPanel(comment);
return;
}
if (target.classList.contains('item-moderation-cancel')) {
e.preventDefault();
toggleModerationPanel(comment, false);
return;
}
const actionBtn = target.closest('[data-action]');
if (actionBtn?.closest('.moderation-controls')) {
e.preventDefault();
await handleModerationAction(actionBtn, csrfToken);
}
});
function toggleModerationPanel(comment, show) {
const mainControls = comment.querySelector('.main-controls');
const modControls = comment.querySelector('.moderation-controls');
if (!mainControls || !modControls) return;
const showPanel = typeof show === 'boolean' ? show : modControls.style.display !== 'block';
mainControls.style.display = showPanel ? 'none' : '';
modControls.style.display = showPanel ? 'block' : 'none';
}
async function handleModerationAction(button, token) {
const actionUrl = button.getAttribute('data-action');
const method = button.getAttribute('data-method') || 'POST';
if (!await verifyAction(button)) return;
try {
if (button.classList.contains('item-ban')) {
window.open(actionUrl, '_blank');
return;
}
const headers = {
'X-CSRF-Token': token,
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
};
let requestOptions = { method, headers, credentials: 'same-origin' };
if (method === 'POST') {
const formData = new FormData();
formData.append('authenticity_token', token);
requestOptions.body = formData;
}
const response = await fetch(actionUrl, requestOptions);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
updateUI(button, data);
} catch (error) {
console.error('Moderation failed:', error);
alert('Ошибка при выполнении действия');
}
}
async function verifyAction(button) {
const confirmAdd = button.getAttribute('data-confirm-add');
const confirmRemove = button.getAttribute('data-confirm-remove');
if (!confirmAdd && !confirmRemove) return true;
const isActive = button.classList.contains('selected');
const message = isActive ? confirmRemove : confirmAdd;
return message ? confirm(message) : true;
}
function updateUI(button, response) {
const comment = button.closest('.b-comment');
if (!comment) return;
if (button.classList.contains('item-offtopic')) {
const marker = comment.querySelector('.b-offtopic_marker');
if (marker) {
button.classList.toggle('selected');
marker.style.display = button.classList.contains('selected') ? 'block' : 'none';
}
}
toggleModerationPanel(comment, false);
console.log('Moderation success:', response);
}
}
// Функция для обработки кнопок удаления комментариев
function bindDeleteButtons(container) {
container.querySelectorAll('.item-delete').forEach(button => {
button.addEventListener('click', async () => {
// Находим родительский комментарий
const comment = button.closest('.b-comment');
if (!comment) return;
// Получаем URL для удаления
const deleteUrl = comment.querySelector('.item-delete-confirm')?.getAttribute('data-action');
if (!deleteUrl) return;
// Подтверждение перед удалением
if (!confirm('Удалить комментарий?')) return;
try {
// Отправляем DELETE-запрос
const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
if (response.ok) {
comment.remove();
} else {
console.error('Ошибка удаления комментария:', await response.text());
alert('Не удалось удалить комментарий.');
}
} catch (err) {
console.error('Ошибка удаления:', err);
alert('Ошибка сети при удалении.');
}
});
});
}
/* ========== КЛАСС ДЛЯ РАБОТЫ С БЛОКАМИ КОММЕНТАРИЕВ ========== */
class CommentsBlock {
constructor(container) {
// Инициализация свойств
this.container = container; // DOM-элемент контейнера
this.loader = container.querySelector('.comments-loader');// Элемент загрузки
this.fetchId = null;// ID для запросов
this.entityId = null; // Может быть topicId или userId
this.entityType = null; // 'Topic' или 'User'
this.currentPage = 1;// Текущая страница
this.totalPages = 1;// Всего страниц
this.pagination = null;// Элемент пагинации
this.init();
}
// Основная инициализация
init() {
if (!this.loader) return;
const ids = this.getCommentsIDs();
if (!ids) return;
this.fetchId = ids.fetchId;
this.entityId = ids.entityId;
this.entityType = ids.entityType;
// Рассчитываем общее количество страниц
const commentsCount = parseInt(this.loader.getAttribute('data-count')) + parseInt(this.loader.getAttribute('data-skip')) || 0;
this.totalPages = Math.max(1, Math.ceil(commentsCount / COMMENTS_PER_PAGE));
}
// Создание и загрузка блока комментариев
async CreateCommentsBlock() {
// Проверяем наличие всех необходимых данных перед загрузкой
if (!this.hasRequiredAttributes()) {
console.error('Cannot create comments block - missing required attributes');
return false;
}
await this.loadComments();
this.renderPagination();
}
// Проверка наличия всех необходимых атрибутов
hasRequiredAttributes() {
if (!this.loader) {
console.error('Missing comments loader element');
return false;
}
const urlTemplate = this.loader.getAttribute('data-clickloaded-url-template');
if (!urlTemplate) {
console.error('Missing data-clickloaded-url-template attribute');
return false;
}
this.ids = this.getCommentsIDs();
if (!this.ids || !this.ids.fetchId || !this.ids.entityId || !this.ids.entityType) {
console.error('Invalid or missing IDs in URL template');
return false;
}
const count = this.loader.getAttribute('data-count');
if (!count) {
console.error('Missing data-count attribute');
return false;
}
return true;
}
// Получение идентификаторов топика
getCommentsIDs() {
try {
const urlTemplate = this.loader.getAttribute('data-clickloaded-url-template');
if (!urlTemplate) return null;
const matches = urlTemplate.match(/\/fetch\/(\d+)\/(Topic|User)\/(\d+)/);
if (!matches || matches.length < 4) return null;
return {
fetchId: matches[1],
entityId: matches[3],
entityType: matches[2] // 'Topic' или 'User'
};
} catch (error) {
console.error('Error parsing comment IDs:', error);
return null;
}
}
//Создание Url для запроса
buildCommentsUrl(offset) {
// Формируем URL в зависимости от типа сущности
if (this.entityType === 'User') {
return `https://shikimori.one/comments/fetch/${this.fetchId}/User/${this.entityId}/${offset}/${COMMENTS_PER_PAGE}`;
} else {
// По умолчанию считаем, что это Topic
return `https://shikimori.one/comments/fetch/${this.fetchId}/Topic/${this.entityId}/${offset}/${COMMENTS_PER_PAGE}`;
}
}
/**
* Загружает комментарии с автоматическим повтором при ошибках
* @param {string} url - URL для запроса
* @param {number} [maxRetries] - Максимальное количество попыток (по умолчанию: 4)
* @param {number} [retryDelay] - Задержка между попытками в миллисекундах (по умолчанию: 2 секунды)
* @returns {Promise<string>} HTML-контент комментариев
* @throws {Error} Если все попытки завершились ошибкой
*/
async fetchComments(url, maxRetries = 4, initialRetryDelay = 2000) {
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url);
// Обработка HTTP-ошибок
if (!response.ok) {
// Особый случай: Too Many Requests (429)
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || initialRetryDelay / 1000, 10);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
continue; // Повторяем попытку без увеличения счетчика
}
throw new Error(`HTTP error ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!data?.content) throw new Error('Invalid response format: missing content');
return data.content;
} catch (error) {
lastError = error;
if (attempt < maxRetries) {
const currentDelay = initialRetryDelay * Math.pow(2, attempt - 1);
await new Promise(resolve => setTimeout(resolve, currentDelay));
}
}
}
throw lastError || new Error('All retry attempts failed');
}
// Основная функция загрузки
async loadComments() {
try {
const offset = (this.currentPage - 1) * COMMENTS_PER_PAGE;
this.container.classList.add('shiki-comments-loading');
const html = await this.fetchComments(this.buildCommentsUrl(offset));
this.replaceComments(html);
} catch (error) {
console.error('Ошибка загрузки комментариев:', error);
} finally {
this.container.classList.remove('shiki-comments-loading');
}
}
// Замена содержимого блока комментариев
replaceComments(html) {
this.container.innerHTML = html;
this.loader = this.container.querySelector('.comments-loader');
bindDeleteButtons(this.container);
// Привязываем обработчики событий к новым элементам
bindSpoilerDeleteButtons(this.container);
bindSpoilerBlockButtons(this.container);
bindSpoilerInlineBlocks(this.container);
replaceCommentDates(this.container);
setupReplyButtons(this.container);
setupSimpleQuoteButtons(this.container);
setupEditButtons(this.container);
initImageModalViewer(this.container);
initVideoModalViewer(this.container);
}
// Создание интерфейса пагинации
renderPagination() {
if (this.pagination) {
this.pagination.remove(); // Удаляем старую пагинацию
}
// Создаем новый элемент пагинации
this.pagination = document.createElement('div');
this.pagination.className = 'shiki-comments-pagination';
this.pagination.innerHTML = `
<button class="prev-page">< Назад</button>
<span class="page-info">Страница ${this.currentPage} из ${this.totalPages}</span>
<input type="number" class="page-input" min="1" max="${this.totalPages}" value="${this.currentPage}">
<button class="next-page">Вперед ></button>
`;
// Находим editor-container (может быть рядом с контейнером или в другом месте)
const editorContainer = this.container.closest('.b-topic')?.querySelector('.editor-container') ||
document.querySelector('.editor-container');
// Вставляем после editor-container если найден, иначе после контейнера комментариев
const insertAfter = editorContainer || this.container;
insertAfter.parentNode.insertBefore(this.pagination, insertAfter.nextSibling);
// Обработчики событий для кнопок пагинации
this.pagination.querySelector('.prev-page').addEventListener('click', async () => {
if (this.currentPage > 1) {
this.currentPage--;
await this.loadComments();
this.renderPagination(); // Обновляем отображение
}
});
// Обработчик для поля ввода страницы
this.pagination.querySelector('.next-page').addEventListener('click', async () => {
if (this.currentPage < this.totalPages) {
this.currentPage++;
await this.loadComments();
this.renderPagination();
}
});
this.pagination.querySelector('.page-input').addEventListener('change', async (e) => {
const newPage = parseInt(e.target.value, 10);
if (newPage >= 1 && newPage <= this.totalPages) {
this.currentPage = newPage;
await this.loadComments();
this.renderPagination();
}
});
}
}
/* ========== ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ И ФУНКЦИИ ========== */
let initializedBlocks = new WeakMap(); // Хранит инициализированные блоки
let isInitializing = false; // Флаг для защиты от повторной инициализации
async function init() {
if (isInitializing) return;
isInitializing = true;
console.log('Parallel INIT started');
const updatedBlocks = document.querySelectorAll('.b-comments');
try {
// Создаем массив промисов для всех блоков
const initializationPromises = Array.from(updatedBlocks).map(async (container) => {
try {
if (!initializedBlocks.has(container)) {
const instance = new CommentsBlock(container);
initializedBlocks.set(container, instance);
await instance.CreateCommentsBlock();
console.log('Successfully initialized:', container);
} else {
await initializedBlocks.get(container).CreateCommentsBlock();
}
} catch (error) {
console.error(`Error processing block ${container}:`, error);
// Пробрасываем ошибку дальше, если нужно прервать все операции
throw error;
}
});
// Ожидаем завершения ВСЕХ операций параллельно
await Promise.all(initializationPromises);
} catch (error) {
console.error('Global initialization error:', error);
} finally {
isInitializing = false;
}
}
let checkInterval = null;
let lastKnownBlocks = [];
// Функция для проверки новых блоков комментариев на странице и инициализации
async function observeNewComments() {
if (checkInterval) clearInterval(checkInterval);
console.log("Запуск наблюдения за .b-comments");
checkInterval = setInterval(async () => {
const currentBlocks = Array.from(document.querySelectorAll('.b-comments'));
if (currentBlocks.length !== lastKnownBlocks.length ||
currentBlocks.some(block => !lastKnownBlocks.includes(block))) {
console.log("Обнаружены изменения в .b-comments");
await init(); // Добавлен await для асинхронной init()
lastKnownBlocks = currentBlocks;
}
}, CHECK_INTERVAL);
const initialBlocks = Array.from(document.querySelectorAll('.b-comments'));
if (initialBlocks.length > 0) {
await init();
lastKnownBlocks = initialBlocks;
}
}
function stopObserving() {
if (checkInterval) {
clearInterval(checkInterval);
checkInterval = null;
}
}
observeNewComments();
setupModerationButtonsGlobal()
})();