Единый форум поддержки

Информация о пользователе

Привет, Гость! Войдите или зарегистрируйтесь.


Вы здесь » Единый форум поддержки » Новые возможности форумов » Скрипты от пользователей 3


Скрипты от пользователей 3

Сообщений 561 страница 580 из 587

1

Администрация MyBB не гарантирует работоспособность данных скриптов, вы ставите их на свой страх и риск!

Авторы скриптов, размещающие свои разработки на бесплатной и добровольной основе, оставляют за собой право в любой момент вносить в них изменения на своё усмотрение (в том числе: обновление с целью улучшения функционала и стабильности; изъятие из публичного пользования; включение рекомендательных интеграций, содержащих информацию о других разработках, относящихся к MyBB). Скрипты предоставляются авторами "как есть" и без каких-либо гарантий, высказанных или подразумеваемых. При этом размещаемый скрипт должен соответствовать условиям Пользовательского соглашения, не содержать вредоносного кода и не использоваться для рекламы и продвижения сторонних ресурсов, не относящихся к MyBB.

Пользователь, устанавливающий скрипт к себе на форум, имеет право сохранить копию скрипта, в том числе в файлы форума (если в описании к скрипту не указано иное) и использовать эту копию вместо оригинального файла. Однако следует понимать, что такая копия будет лишена каких-либо обновлений и может перестать работать вследствие изменений на сервисе. По этой причине рекомендуется использовать оригинальные версии скриптов, за исключением случаев крайней необходимости.

Данные правила относятся ко всем свободно распространяемым скриптам, размещаемым как в этой теме, так и в других темах раздела Новые возможности форумов.

Для тех кто выкладывает скрипты:
1) Дайте краткое описание скрипта, не будет лишним наглядно показать работу скрипта на скриншоте.
2) Обязательно укажите информацию по его установке: куда вставлять и как использовать.
3) Сам скрипт вставьте в bb-коды [сode] [/сode] или в [quotе] [/quotе]
4) Если при создании кода Вы используете нейросетевые технологий, пожалуйста, указывайте это на видном месте. В противном случае, будьте готовы отвечать за возможные ошибки или нагрузки на сервера сервиса, вызванные Вашим скриптом. Также будьте готовы к вопросам от пользователей по работоспособности скрипта.
5) Не перегружайте описание скрипта избыточной информацией, не имеющей к нему прямого отношения.

Администрация сервиса имеет право отредактировать ваше сообщение в соответствие с общими стандартами публикаций скриптов в этой теме.

В данной теме запрещено задавать вопросы и просить скрипты!
Для запросов по созданию новых скриптов есть актуальная тема Запросы по скриптам
При нарушение данного правила будет выдаваться предупреждение в профиль!

Если у вас есть вопросы по работе какого-либо скрипта, опубликованного в этой теме, пожалуйста, пишите свой вопрос в Общие вопросы от новичков (63) #3, ссылаясь на сообщение из этой темы.

Отредактировано Alex_63 (Пт, 14 Мар 2025 11:28:27)

+8

561

Быстроплюсы справо в соообщении и плюс с коммментариями(для форумов русфф) через плюсик в Профиле

Плюсик ставится сразу по клику на сердечко(без двойного нажатия)

https://upforme.ru/uploads/001a/fc/23/321/t966505.jpg

В HTML верх(стиль можно отделить и поставить в конец первого окна стиля без тегов <style></style>

<style>
/*Быстроплюсы для Топика!*/
div .post-rating p a {
    text-align: center;
    outline: 1px solid transparent;
    font-weight: 700;
    background: url(https://forumstatic.ru/files/001a/fc/23/26514.svg) no-repeat center;
     background-size:36px auto;
     position:relative;
    z-index:100;
    width: 36px;
    height: 36px;
    text-align:center;
    font-size: 12px !important;
    display: inline-block;
    vertical-align: middle;
    line-height: 34px;
    letter-spacing: -.1px;
}
.noNull:before{content: "+";
    display: inline-block;
    margin-left: -1px;
    font-size: 9px;
    letter-spacing: 0!important;
}   
</style>

<!--// Быстроплюсы v.1 by Deff //-->
<script src="https://forumstatic.ru/files/001b/a6/d4/74583.js"></script>

+9

562

Автообёртывание Хештегов в Поисковую ссылку
(как в ВК, Инсте и Телеге)

       Тут на форуме forumgrimerka.ru дали
задание сделать скрипт поиска по Хештегам,
подумал многим будет полезен                     
 

Например для поиска по таким Хештегам (вставляемым в посты с нужным контентом):
#ЛучшиеПостыФорума
#audio
#video



Требования к Хештегам:
1. Сам Хештег должен быть  без пробелов, максимальная длина 22 символа вместе с #
2. Хештеги прописываем в конце сообщения с нужным контентом, если Хештегов несколько, - прописываем их в строку через пробел, либо каждый на новой строке.
3. При тегировании новых сообщений сразу в отправляемом сообщении поисковые ссылки начинают работать через 30-40мин, тегирование уже запощенных, старых сообщений при редактировании, поисковые ссылки срабатывают через сутки и более...
4. Для кастомизации вида ссылки у ссылки есть класс => .search
Самые актуальные Хештеги удобно выносить в шапку темы

Скрипт вставляем в HTML верх:

<!--// Обёртывание Хештегов в Поисковую ссылку //-->
<script type="text/javascript" src="//forumstatic.ru/files/0012/d8/04/74792.js"></script>



Тестируем, отписываемся в теме Запросы по скриптам #11

Отредактировано Deff (Чт, 27 Окт 2022 15:04:43)

+6

563

Нашлись проблемы в скрипте хештегов, как оказалось давно решённые в скрипте Alex_63 , основной код здесь => Запросы по скриптам[8]

Отредактировано Deff (Вт, 1 Ноя 2022 21:46:35)

+8

564

Тут в связи с установками скрипта поиска по Хештегам просили исключать их при полном цитировании сообщения (Дабы не засорять поиск вторичными тегами)

В HTML низ

Код:
<!-- Исключаем  Хештеги #xxxx, Подпись, Отредактировано при цитировании-->
<style>#pun:not(.gid1) .punbb .post-content p.lastedit{display:none;}</style>
<script>
$(function() {
quoteOld = window.quote;
window.quote = function(x,y){
var pst = $('#p'+y);pst.find('p.lastedit').remove();
var a = pst.find('.post-sig').remove(); quoteOld(x,y);
a.addClass('Deff').appendTo(pst.find('.post-content'));
$('#main-reply').val($('#main-reply').val().replace(/(\]|[\s])#[^\s\[]+/gim,'$1'));
}});
</script>

+5

565

Предварительный анонс нового функционала:

Alex_63 написал(а):

WYSI — визуальный редактор сообщений
Создан специально для форумов MyBB
https://upforme.ru/uploads/0015/c4/3f/2/613655.png

+3

566

+3

567

Обновление скрипта Новый загрузчик изображений, версия 1.2.0.
Исправлены старые баги и добавлены новые возможности.
Новая версия уже работает на форумах, где установлен код (в том числе и этом форуме)!

+1

568

Первые N тем в описании форума (v2.3)
(новая версия скрипта)

https://upforme.ru/uploads/0000/14/1c/15830/532425.png

Значительно переработал старый скрипт от Fover Первые N тем в описании форума.

Описание изменений
  • Теперь скрипт не парсит дочернюю страницу, а работает на форумном API.

  • Добавлена возможность отключать кэширование: если необходимо динамически загружать список тем при каждом открытии или в случае возникновения проблем.

  • В блоке со списком тем выделенные и закрытые темы имеют собственные классы для оформления: .sticky ‒ для выделенных, .closed ‒ для закрытых.

  • Выбрать желаемый вариант открытия блока со списком тем можно без переустановки скрипта: click ‒ по нажатию на символ ">", hover ‒ при наведении курсора на название форума.

  • Переработана логика появления и закрытия блока списка тем: для закрытия блока достаточно нажать в любом месте страницы.

  • Для режима 'click' добавлено закрытие по нажатию на символ.

  • Для режима 'hover' добавлено автоматическое закрытие по таймеру по истечению 2 секунд, если курсор находится вне пределов блока со списком тем или вне ссылки названия форума.

  • Добавлена возможность изменять значок (иконку) для действия click.

  • Добавлен перевод для английской версии форума (переключается автоматически при выборе языка в настройках форума или профиля).
    https://upforme.ru/uploads/0000/14/1c/15830/377955.png

  • Индивидуальная настройка каждого пользователя в профиле с сохранением.
    https://upforme.ru/uploads/0000/14/1c/15830/t184439.png

  • Добавлена возможность индивидуального отключения скрипта в своём профиле (для зарегистрированных пользователей).

  • Добавлен параметр touchDevice, позволяющий администраторам активировать принудительный вариант показа блока 'click' для пользователей с сенсорными устройствами
    (параметр полезен тем, что если Вы установили открытие блока 'hover' или пользователи выбрали у себя в профиле открытие блока "При наведении на заголовок", то при просмотре с сенсорного устройства у них автоматически переключится на 'click' ‒ "Открытие по нажатию на значок").

Код:
<!-- Первые N тем в описании форума (новая версия) -->
<style>
.topicslist {
  position: absolute;
  background-color: #fff;
  border: 1px solid rgba(0, 0, 0, .35);
  margin-top: 5px;
  padding: 10px;
  box-shadow: 0 4px 4px -2px rgba(0, 0, 0, .35);
  border-radius: 3px;
  max-width: 90vw;
  z-index: 110;
}
.topicslist .sticky a {
  font-weight: bold;
  color: #f00;  /* Цвет для выделенных тем */
}
.topicslist .closed a {
  color: #a9a9a9;  /* Цвет для закрытых тем */
}
.topicslist .load {
  animation: slow-load-blink 2s ease-in-out infinite;
}

.clickt {
  cursor: pointer;
  user-select: none;
}
.clickt img {
  border: 0;
  line-height: 1;
  max-height: 16px;
  max-width: 16px;
  vertical-align: middle;
}
@keyframes slow-load-blink{0%,100%{opacity:1}50%{opacity:.3}}
</style>
<script>
window.firstNtopics = {
    // Настройки скрипта
    count: 5,            // Максимальное количество выводимых тем форума
    touchDevice: 1,      // 1- включить принудительно вариант показа блока click для сенсорных устройств , 0 - выключить
    useSession: 1,       // 1- включить кэширование списка тем на 10 минут, 0 - выключить
    selectAct: 'click',  // Вариант показа блока: 'click' (нажатие по значку рядом с названием темы) или 'hover' (наведение курсора на название темы)

    // Шаблон для действия click
    templateClick: ' <span class="clickt" title="Показать список тем"><img src="https://forumstatic.ru/files/001c/3a/d4/70712.png" alt="Список тем"></span>',
};
</script>
<script type="module" src="https://forumstatic.ru/files/001c/3a/d4/90907.js"></script>

Примечание: Вариант с нажатием на символ ('click') рядом с названием форума будет предпочтительнее для сенсорных устройств.

Отредактировано Reysler (Пт, 13 Сен 2024 11:25:53)

+9

569

В теме частично актуализирована информация по скриптам 2015-2020 гг.

Обнаруженные неактуальные скрипты отключены и изъяты из публичного использования. Неактуальными считаются, в частности: нерабочие, устаревшие (имеющие более свежие версии), реализованные в движке форумов, добавленные в Администрирование - Скрипты. Из целей, в том числе, снижение нагрузки на сервис, т.к. многие из таких скриптов использовали ajax запросы к страницам форумов и/или были плохо оптимизированы, а целесообразность в их применении уже отпала ввиду реализации нового функционала от сервиса.

Отключенные скрипты могут содержать рекомендательные интеграции, направленные на популяризацию и распространение актуального функционала на форумах сервиса.

+4

570

Alex_63 написал(а):

Голосовые сообщения
в постах и ЛС

https://upforme.ru/uploads/0015/c4/3f/2/236977.png

+1

571

Alex_63 написал(а):

Мессенджер
Личные сообщения в формате диалогов

https://upforme.ru/uploads/0015/c4/3f/2/924555.png
(скриншот 1)

https://upforme.ru/uploads/0015/c4/3f/2/832158.png
(скриншот 2)

https://upforme.ru/uploads/0015/c4/3f/2/258950.png
(скриншот 3)

https://upforme.ru/uploads/0015/c4/3f/2/74673.png
(скриншот 4)

https://upforme.ru/uploads/0015/c4/3f/2/481269.png
(скриншот 5)

https://upforme.ru/uploads/0015/c4/3f/2/426867.png
(скриншот 6)

+1

572

Вынос наград RusFF в отдельное окно
Весь контент в HTML-низ
Огромное спасибо за помощь в составлении скрипта @Alex_63
https://upforme.ru/uploads/0000/14/1c/39248/960276.png

Сначала добавляем окно, в которое будет встраиваться информация:

Код:
<div id="awards-modal" style="display: none;">
    <div class="modal-content">
        <div class="container" style="display: none;">
            <div class="awards-header">
                 <h3>Награды пользователя</h3>
                 <div id="closeBtn">/*тут ваш крестик*/</div>
            </div>
            <table cellspacing="0">
                <tbody id="awards-content"></tbody>
            </table>
        </div>
    </div>
</div>

Здесь можно менять заголовок, надо добавить кнопку крестика. Контент будет вставляться внутрь #awards-content.
Если хочется вставлять внутрь некоего div, а не внутрь таблицы, можно просто убрать table и добавить div c id "awards-content"

Далее сам скрипт:

Код:
<script>
$(document).on('click', '.ProfileStat .pa-awards a', function (e) {
    e.preventDefault();
    e.stopPropagation();

    const awards = $(this).closest('.post-author').find('.pa-awards');
    const userId = awards.attr('data-id'); 

    const params = {
        check: {
            board_id: BoardID,
            user_id: UserID,
            partner_id: PartnerID,
            group_id: GroupID,
            user_login: UserLogin,
            user_lastvisit: UserLastVisit,
            user_avatar: UserAvatar,
            user_unique_id: UserUniqueID,
            host: document.domain,
            sign: ForumAPITicket
        },
        board_id: BoardID,
        sort: "user",
        users_ids: [userId]
    };

    $('#awards-modal').fadeIn();

    $.jsonRPC.request('awards/index', {
        params: params,
        success: function (data) {
            let tableContent = '';
            data.result[0].awards.forEach(award => {
                tableContent += `
                    <tr class="alt1">
                        <td class="tc2"><img src="${award.item.href}" alt="${award.item.name}"></td>
                        <td class="tcl"><b>${award.item.name}</b><br>${award.item.desc}<br>${award.desc}</td>
                    </tr>
                `;
            });

            $('#awards-content').html(tableContent);
            $('#awards-modal .container').show();
        },
        error: function (error) {
            $('#awards-modal .container').html('<div>Ошибка при загрузке данных. Пожалуйста, попробуйте позже.</div>').show();
        }
    });
});

$(document).on('click', function (e) {
    if ($(e.target).closest('#awards-modal .modal-content').length === 0) {
        $('#awards-modal').fadeOut();
    }
});

$(document).on('click', '#awards-modal #closeBtn', function (e) {
$('#awards-modal').fadeOut();
});

</script>

Контент добавляется в моменте tableContent +=
Если есть желание отображать в другой структуре, с другими столбцами, классами или не в формате таблицы, проч, то можно менять на этом месте.

Далее настраиваем стили (можно как HTML-низ внутри тега <style></style>, так и в Свой стиль)

Код:
/* awards */ 
div.modal-content h3 {
    font-family: 'Times New Roman';
    font-weight: bold;
    font-size: 21px;
    text-align: left;
}
 
#pun #awards-modal tr {
    display: block !important;
}
 
div.modal-content .container {
    padding: 4px 12px;
}
 
div.modal-content div#closeBtn {
    cursor: pointer;
    width: 16px;
    height: 16px;
    position: absolute;
    top: 7px;
    right: -4px;
}
 
#awards-modal .awards-header {
    position: relative;
}
 
#awards-modal {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.7);
    display: flex;
    justify-content: center;
    align-items: center;
    z-index: 1000;
}
 
#awards-modal .modal-content {
    max-width: 600px;
    width: 100%;
    text-align: center;
    border: 2px solid gray;
    background: white;
    box-shadow: 0 0 20px -11px #000;
}
 
#awards-modal .container table {
    width: 100%;
    border-collapse: collapse;
}
 
#awards-modal .container td {
    padding: 4px;
    text-align: left;
}

#awards-modal .container td.tc2 {
   width: 53px;
}

#awards-modal .container img {
    max-width: 53px;
    max-height: 53px;
    border: 1 double gray;
}

+3

573

Alex_63 написал(а):

Живые темы
Автоматическая загрузка новых ответов без обновления страницы

https://upforme.ru/uploads/0015/c4/3f/2/496388.gif

+2

574

Alex_63 написал(а):

Всплывающие кнопки форматирования
при выделении фрагмента в форме ответа
https://upforme.ru/uploads/0000/14/1c/32995/376303.gif

+1

575

Список поддержавших пост (классический простой вид, отображаемый внизу сообщения)

https://images4.imagebam.com/01/ab/26/ME12UW6G_o.jpg
Вставлять в HTML низ.

Код:
<script>
(function() {
    // Дожидаемся полной загрузки страницы, включая все ресурсы
    window.addEventListener('load', function() {
        function vv(fn) {
            var lang_obj = {
                'Поблагодарили': {
                    en: 'Thanked'
                }
            },
            lang = $('html')[0].lang;
 
            function _(text) {
                return (lang == 'ru' || !(lang_obj[text] && lang_obj[text][lang])) ? text : lang_obj[text][lang]
            };
 
            if ($('#pun-viewtopic').length) {
                var p_id = [];
 
                $('div.post').each(function(i) {
                    p_id[i] = $(this).attr('id').slice(1);
                });
 
                // Убедимся, что есть посты для обработки
                if (!p_id.length) return;
                
                function getJ(url, callback) {
                    $.get(url, function(d) {
                        $.isFunction(callback) && callback(d);
                    }, 'json');
                };
                
                // Функция обработки голосов с задержкой для гарантии загрузки FORUM.postVotes
                function processVotes(data) {
                    // Проверяем, что данные получены
                    if (!data || !data.response) return;
                    
                    // Убеждаемся, что объект FORUM существует
                    if (typeof FORUM === 'undefined' || !FORUM.postVotes) {
                        // Если объект не существует, повторяем попытку через 500мс
                        setTimeout(function() {
                            processVotes(data);
                        }, 500);
                        return;
                    }
                    
                    function votesInit(idp) {
                        // Убеждаемся, что post ID существует в FORUM.postVotes
                        var vot = FORUM.postVotes[idp];
                        if (!vot) return;
                        
                        var j = [], n = '';
                        
                        $.each(vot, function(z) {
                            var q = vot[z];
                            if (q.value == 1) {
                                j[z] = '<a href="/profile.php?id=' + q.user_id + '">' + q.username + '</a>';
                                n += j[z];
                                if (z < vot.length - 1) n += ', ';
                            }
                        });
                        
                        if (n) {
                            // Проверяем, что элемент для списка не существует, чтобы избежать дублирования
                            var postBox = $('.post#p' + idp + ' .post-box');
                            if (postBox.find('.postVoters').length === 0) {
                                n = '<div class="postVoters"><strong>' + _('Поблагодарили') + ':</strong> ' + n + '</div>';
                                postBox.append(n);
                            }
                        }
                    }
                    
                    // Обработка полученных данных
                    for (var i = 0; i < data.response.length; i++) {
                        var pid = data.response[i].post_id;
                    }
                    
                    // Обрабатываем каждый пост с голосами
                    for (var pid in FORUM.postVotes) {
                        votesInit(pid);
                    }
                    
                    $.isFunction(fn) && fn();
                }
                
                // Вызываем API для получения голосов
                getJ('/api.php?method=post.getVotesByPosts&post_id=' + p_id.join(',') + '&fields=post_id,user_id,username,value,datetime&sort_dir=desc', processVotes);
            }
        };
 
        vv(function() {});
        
        // Повторная проверка через 1.5 секунды для случаев AJAX-загрузки или медленной обработки данных
        setTimeout(function() {
            vv(function() {});
        }, 1500);
    });
})();
</script>

+3

576

🛠️Универсальный скрипт замены от Merlin777🛠️

Есть много скриптов замены одних элементов на форуме на другие. Но обычно при отправке сообщения замены элементов не отображаются, и видны лишь при 🔁перезагрузке.
🔥Данный скрипт показывает содержимое автозамен как на загруженных страницах, так и на отправленных постах (live-обновление).
(этот скрипт также совместим  с "живыми темами", если вы их включили, т.к. механизм добавления контента на страницу тот же самый)

💥Скрипт делает:

  • ✅Раскраску ников по группам

👀Посмотреть картинку🖼️

https://upforme.ru/uploads/0000/14/1c/38891/t163351.jpg

Стандартные группы:
1 - Администраторы
2 - Модераторы
3 - Гость
4 - Пользователи

  • ✅Раскраску основных и дополнительных полей в мини-профиле

👀Посмотреть картинку🖼️

https://upforme.ru/uploads/0000/14/1c/38891/t958727.jpg

  • ✅Замену названий в статистике под аватаром

👀Посмотреть картинку🖼️

https://upforme.ru/uploads/0000/14/1c/38891/t790599.jpg

  • ✅Замена личных ссылок в мини-профиле (Профиль, E-mail, ЛС, Вебсайт)

👀Посмотреть картинку🖼️

https://upforme.ru/uploads/0000/14/1c/38891/t598882.jpg

  • ✅Замена кнопок управления   (Удалить, Редактировать, Цитировать)

👀Посмотреть картинку🖼️

https://upforme.ru/uploads/0000/14/1c/38891/766886.jpg

  • ✅Раскрашивание ников по группам в блоке статистики  на главной странице форума

👀Посмотреть картинку🖼️

https://upforme.ru/uploads/0000/14/1c/38891/529498.jpg

🚨Особенность скрипта:
1) Скрипт  🔀динамически изменяет элементы оформления страницы в реальном времени ("живое" или live обновление).
2) Скрипт использует 👍единый обработчик событий и занимается  🎨заменой/перекраской только на изменившихся объектах, что значительно 🚀ускоряет его работу и радикально снижает нагрузку на браузер.
2) Имеет явную 🧩🧩модульную структуру, что позволяет легко изменять параметры,  добавлять или убирать ненужные блоки.
С модульным  строением также хорошо работают 🤖нейросети типа ChatGPT, которые можно использовать для изменения функционала скрипта.


🛠️Установка скрипта: Администрирование - Формы - HTML низ (желательно - в самый низ окошка "HTML низ")

👀Посмотреть код скрипта💫
Код:
<!-- Начало: Универсальный скрипт замены © Merlin777 kuban.mybb.ru -->
<script>
document.addEventListener("DOMContentLoaded", function () {

  // =========================
  // 📦 CONFIG — Настройки
  // =========================
  const CONFIG = {
    observerDelay: 0, // ⏳ Задержка обновления элементов (0 = requestAnimationFrame для максимально плавного обновления)
    
    // ✅ Разрешить покраску ников на главной странице сайта (в статистике)
   enableIndexNickColoring: true,
  
    // 🔤 Автозамены текста в профиле, кнопках и мини-профиле
    replacements: {
      profileElements: [
        ['.pl-email.profile', 'Профиль', '👤Профиль'],   // Заменяем текст "Профиль" на с эмодзи
        ['.pl-email.email', 'E-mail', '📧Mail'],        // "E-mail" → "📧Mail"
        ['.pl-email.pm', 'ЛС', '✉️\u00A0ЛС'],          // "ЛС" → "✉️ ЛС"
        ['.pl-website.website', 'Вебсайт', '🌐']        // "Вебсайт" → "🌐"
      ],
      postButtons: [
        ['.pl-edit a', 'Редактировать', '✍️\u00A0Редактировать'], // Кнопка редактирования
        ['.pl-delete a', 'Удалить', '❌\u00A0Удалить'],           // Кнопка удаления
        ['.pl-quote a', 'Цитировать', '💬\u00A0Цитировать'],     // Кнопка цитирования
        ['.pl-reply a', 'Ответить', '↩️\u00A0Ответить'],        // Кнопка ответа
        ['.pa-posts', 'Сообщений', '📨 Сообщений'],             // Кол-во сообщений с эмодзи
  
      ],
      miniProfile: [
        ['.pa-ip', 'IP', '🖧 IP'],                      // IP пользователя
        ['.pa-sex', 'Пол', '⚧️ Пол'],                  // Пол с эмодзи
        ['.pa-fld3', 'Создано тем', '➕ Создано тем'], // Кол-во созданных тем
        ['.fld-name', 'Откуда', '🧭 Откуда'],         // Местоположение
        ['.pa-respect', 'Уважение', '⭐ Рейтинг']      // Рейтинг
      ]
    },

    // 🎨 Селекторы мини-профиля для перекраски
    miniProfileSelectors: ["li.pa-title","li.pa-fld1"],

    // 🎨 Цвета ников по группам (основной и ховер)
    groupColors: {
      1: ['#008080','#a1b2cc'], // 🛡️ Админ
      2: ['#993232','#d15f13'], // 🧑‍⚖️ Модератор
      3: ['#000000','#B0B0B0'], // 👤 Гость (черный с серым ховером)
      4: ['#072387','#5869a3']  // 🧙‍♂️ Пользователь (темно-синий с светло-синим ховером)
    },

    // 🎨 Цвета для мини-профиля по именам
    miniProfileColors: {
      "Модератор": { color: ['#38761d','#6abd46'], groups: [2] },       // Зеленый оттенок
      "Администратор": { color: ['#5c1eba','#a1b2cc'], groups: [1] },    // Фиолетовый оттенок
      "The Wizard of the Realm": { color: ['#2986cc','#a1b2cc'], groups: [1] } // Синий
    }
  };

  // =========================
  // 💬 CSS hover — плавное изменение цвета при наведении
  // =========================
  const style = document.createElement('style');
  style.innerHTML = 'a[data-base-color], span[data-base]{transition: color 0.25s ease; cursor:pointer;}';
  document.head.appendChild(style);

  // =========================
  // 🔄 Функция автозамены текста
  // =========================
  function ChangeAll(scope=document){
    // Объединяем все массивы автозамен
    [].concat(CONFIG.replacements.profileElements,
             CONFIG.replacements.postButtons,
             CONFIG.replacements.miniProfile).forEach(function(r){
      var sel = r[0], from = r[1], to = r[2];
      // Находим элементы, которые еще не заменены
      scope.querySelectorAll(sel+':not([data-replaced])').forEach(function(el){
        if(el.innerHTML.indexOf(from) !== -1) el.innerHTML = el.innerHTML.replaceAll(from,to);
        el.dataset.replaced = 'true'; // Помечаем элемент как обработанный
      });
    });
  }

  // =========================
  // 🎨 Покраска ников в постах
  // =========================
  function recolorPostNicks(users, scope = document) {
  const userMap = {};
  users.forEach(u => {
    if (u.username) userMap[u.username.trim().toLowerCase()] = parseInt(u.group_id);
  });

  // 🧱 Общая функция покраски по элементу
  function colorNick(el, username) {
    const group_id = userMap[username.toLowerCase()] || 4; // default group
    const colors = CONFIG.groupColors[group_id];
    if (!colors) return;

    el.style.color = colors[0];
    el.dataset.baseColor = colors[0];
    el.dataset.hoverColor = colors[1] || colors[0];
  }

  // 🎯 1. Ники в постах
  scope.querySelectorAll('.post a[href^="javascript:to("]').forEach(el => {
    if (el.closest('.quote-box, .dropdown, #pun-navlinks, .vote-list, .user-menu')) return;
    const username = el.textContent.trim();
    colorNick(el, username);
  });

  // 🏠 2. Ники на главной (например, в #onlinelist)
  if (CONFIG.enableIndexNickColoring && document.getElementById('onlinelist')) {
    document.querySelectorAll('#onlinelist a[href*="profile.php"]').forEach(el => {
      const username = el.textContent.trim();
      colorNick(el, username);
    });
  }
}


  // =========================
  // 🎨 Покраска мини-профиля
  // =========================
  function recolorMiniProfile(scope=document){
    CONFIG.miniProfileSelectors.forEach(function(sel){
      scope.querySelectorAll(sel).forEach(function(el){
        if(el.dataset.mpRecolored) return; // Уже перекрашен
        let html = el.innerHTML;
        Object.entries(CONFIG.miniProfileColors).forEach(function([name,obj]){
          const [base,hover] = obj.color;
          if(html.includes(name)){
            // Заменяем текст на span с цветами
            html = html.replace(name,
              '<span data-base="'+base+'" data-hover="'+hover+'" style="font-weight:bold;color:'+base+';">'+name+'</span>'
            );
          }
        });
        el.innerHTML = html;
        el.dataset.mpRecolored = 'true'; // Отмечаем как обработанный
      });
    });
  }

  // =========================
  // 🎨 Универсальная перекраска всего (посты + мини-профиль)
  // =========================
  function recolorAll(scope=document){
    recolorPostNicks(JSON.parse(localStorage.getItem('UsersAdmList')?.split('|')[1] || '[]'), scope);
    recolorMiniProfile(scope);
  }

  // =========================
  // 🌐 Получение пользователей через API
  // =========================
  function fetchUsersAndRecolor(force=false, scope=document){
    var key='UsersAdmList';
    var now=Math.floor(Date.now()/1000);
    var cached=localStorage.getItem(key);

    if(cached && !force){
      try{
        var arr=cached.split('|');
        if((now-parseInt(arr[0]))<24*3600){ // ⏱ кеш на 24 часа
          recolorAll(scope);
          return;
        }
      }catch(e){console.warn(e);}
    }

    // Запрос через API
    fetch('/api.php',{
      method:'POST',
      headers:{'Content-Type':'application/x-www-form-urlencoded'},
      body:new URLSearchParams({method:'users.get',limit:'500',fields:'user_id,username,group_id'})
    }).then(r=>r.json()).then(function(data){
      if(data?.response?.users){
        var users=data.response.users;
        localStorage.setItem(key,now+'|'+JSON.stringify(users)); // Сохраняем кеш
        recolorAll(scope);
      }
    }).catch(console.error);
  }

  // =========================
  // 👆 Hover — меняем цвет при наведении
  // =========================
  document.body.addEventListener('mouseover',function(e){
    var el = e.target.closest('span[data-base], a[data-base-color]');
    if(el) el.style.color = el.dataset.hoverColor || el.dataset.hover || el.style.color;
  });
  document.body.addEventListener('mouseout',function(e){
    var el = e.target.closest('span[data-base], a[data-base-color]');
    if(el) el.style.color = el.dataset.baseColor || el.dataset.base || el.style.color;
  });

  // =========================
  // ⏳ Планирование обновления через requestAnimationFrame или setTimeout
  // =========================
  let scheduled = false;
  function scheduleUpdate(){
    if(scheduled) return;
    scheduled = true;
    if(CONFIG.observerDelay === 0){
      requestAnimationFrame(()=>{ ChangeAll(); recolorAll(); scheduled=false; });
    } else {
      setTimeout(()=>{ ChangeAll(); recolorAll(); scheduled=false; }, CONFIG.observerDelay);
    }
  }

  // =========================
  // 👁 MutationObserver — следим за изменениями DOM и автоматически перекрашиваем новые элементы
  // =========================
  const observer = new MutationObserver(()=>scheduleUpdate());
  observer.observe(document.body,{childList:true,subtree:true});

  // =========================
  // ▶ Инициализация при загрузке страницы
  // =========================
  ChangeAll();
  fetchUsersAndRecolor();

  // =========================
  // 🆕 Live-подгрузка новых постов
  // =========================
  document.addEventListener('pun_post',function(e){
    var scope = e.detail?.post || document.body;
    ChangeAll(scope);
    fetchUsersAndRecolor(true, scope); // форсируем обновление при новых постах
  });

});
</script>
<!-- Конец: Универсальный скрипт замены © Merlin777 kuban.mybb.ru -->
📄 Краткое текстовое описание работы скрипта по пунктам

1. Загрузка и конфигурация:

  • Скрипт выполняется после полной загрузки страницы (DOMContentLoaded).

  • В объекте CONFIG настраиваются:

  • текстовые автозамены (смайлики и подписи),

  • цвета групп (для ников),

  • цвета мини-профиля,

  • включение/отключение покраски на главной странице (enableIndexNickColoring),

  • задержка обновлений (через observerDelay),

  • список селекторов, в которых производится автозамена текста.

2. Замена текста (ChangeAll):

Заменяет стандартные подписи (например, "Профиль" → "👤Профиль") в:

  • мини-профилях,

  • кнопках постов,

  • элементах шапки/меню (если указано в селекторах).

Каждый элемент помечается data-changed="true", чтобы не заменять повторно.

3. Перекраска ников (recolorUsers и recolorPostNicks):

Загружает список пользователей с группами из:

  • localStorage (если кэш свежий),

  • либо через запрос к API форума.

  • Красит только ники в постах:

.post a[href^="javascript:to("] — ссылки на ники внутри тем.

  • Исключает ненужные области (шапка, цитаты, меню, dropdown'ы и т.п.).

  • Если включено enableIndexNickColoring, то красит ники в блоке #onlinelist на главной странице.

  • Поддерживает hover-эффект (цвет меняется при наведении).

4. Перекраска мини-профиля (recolorMiniProfile):

  • Красит элементы мини-профиля:

li.pa-title, li.pa-fld1, li.pa-fld2, и т.д.

Цвет задаётся в CONFIG.groupProfileColors по group_id.

  • Также поддерживает hover-эффект.

5. Объединённая функция перекраски (recolorAll):

  • Вызывает recolorPostNicks и recolorMiniProfile вместе.

  • Упрощает вызов в MutationObserver и при подгрузке новых сообщений.

6. Hover через делегирование:

  • На body навешиваются события mouseover и mouseout.

  • Отслеживаются все элементы с data-base или data-base-color.

  • При наведении цвет меняется на hover-цвет, при уходе — возвращается.

7. Обновление при динамических изменениях (MutationObserver):

  • Следит за изменениями в document.body.

  • Вызывает scheduleUpdate, который:

  • запускает обновление через requestAnimationFrame (если observerDelay: 0),

  • или через setTimeout (если указана задержка).

  • Позволяет скрипту корректно обрабатывать любые подгрузки и изменения DOM.

8. AJAX события (pun_post):

При событии pun_post (используется live-подгрузка на форуме):

  • вызывается scheduleUpdate() — обновляет DOM,

  • и fetchAndColor(true) — принудительно обновляет список пользователей и перекраску.

Это гарантирует, что все новые сообщения получают раскраску, как старые.

9. Кэширование пользователей (fetchAndColor):

  • Пользователи с username и group_id сохраняются в localStorage.

  • Кэш действует 24 часа (86400 секунд).

  • Это минимизирует запросы и ускоряет загрузку скрипта.

Обновление от 11.12.2025: обновлённая версия объединённого скрипта, в которую я встроил скрипт от ALex_63 Регулировка размера шрифта в постах бегунком.

Подробности

✅ Поддержку всех контекстов из оригинального скрипта Alex_63: 

    .post-content p 
    #post-preview .post-content p 
    #profile-signature 
    .parsedsig

✅ Явную обработку AJAX-событий MyBB: 

    pun_post, pun_edit, pun_preview 
    messenger:post, messenger:messages_ready, messenger:messages_load

✅ Полный отказ от jQuery (только нативный JS)
✅ Сохранение совместимости с покраской ников и автозаменами
✅ Единый механизм обновления через fullUpdate()

Код
Код:
<!-- 🎨 Универсальный скрипт: замена текста, покраска ников, регулировка шрифта (AJAX + без jQuery) -->
<script>
document.addEventListener("DOMContentLoaded", function () {

  // =========================
  // 📦 CONFIG — Настройки
  // =========================
  const CONFIG = {
    observerDelay: 0,
    enableIndexNickColoring: true,
    
    replacements: {
      profileElements: [
        ['.pl-email.profile', 'Профиль', '👤Профиль'],
        ['.pl-email.email', 'E-mail', '📧Mail'],
        ['.pl-email.pm', 'ЛС', '✉️\u00A0ЛС'],
        ['.pa-ip', 'IP', '🖧 IP'],
        ['.pl-website.website', 'Вебсайт', '🌐']
      ],
      postButtons: [
        ['.pl-edit a', 'Редактировать', '✍️\u00A0Редактировать'],
        ['.pl-delete a', 'Удалить', '❌\u00A0Удалить'],
        ['.pl-quote a', 'Цитировать', '💬\u00A0Цитировать'],
        ['.pl-reply a', 'Ответить', '↩️\u00A0Ответить'],
        ['.pa-posts', 'Сообщений', '📨 Сообщений'],
      ],
      miniProfile: [
        ['.pa-sex', 'Пол', '⚧️ Пол'],
        ['.pa-fld3', 'Создано тем', '➕ Создано тем'],
        ['.fld-name', 'Откуда', '🧭 Откуда'],
        ['.pa-respect', 'Уважение', '⭐ Рейтинг']
      ]
    },

    miniProfileSelectors: ["li.pa-title","li.pa-fld1"],

    groupColors: {
      1: ['#008080','#a1b2cc'],
      2: ['#993232','#d15f13'],
      3: ['#000000','#B0B0B0'],
      4: ['#072387','#5869a3']
    },

    miniProfileColors: {
      "Модератор": { color: ['#38761d','#6abd46'], groups: [2] },
      "Администратор": { color: ['#5c1eba','#a1b2cc'], groups: [1] },
      "The Wizard of the Realm": { color: ['#2986cc','#a1b2cc'], groups: [1] }
    }
  };

  // =========================
  // 💄 CSS-стили
  // =========================
  const styles = document.createElement('style');
  styles.innerHTML = `
    a[data-base-color], span[data-base] { transition: color 0.25s ease; cursor: pointer; }
    
    .FNTslider {
      border-radius: 4px; position: relative; z-index: 1000;
      background: #e0e0e0; width: 110px; height: 5px;
      margin: 3px 5px; border: solid 1px #ccc;
      float: right; margin-right: 7%;
    }
    .FNTslider .before {
      height: 5px; border: solid 1px transparent; border-right: none;
      border-radius: 4px 0 0 4px; position: absolute;
      background: #a0bcda; margin-top: -1px;
    }
    .FNTslider .thumb {
      width: 8px; height: 13px; border-radius: 3px;
      position: relative; top: -4px; background: #3980d8; cursor: pointer;
    }
    #fntSlider:before {
      content: attr(data) "px";
      position: absolute; left: -32px; top: -5px;
      font-weight: 400; font-size: .9em;
    }
    @media (max-width: 768px) {
      #fntSlider:before {
        left: calc(100% + 8px); right: auto;
      }
    }
  `;
  document.head.appendChild(styles);

  // =========================
  // 🖍 Применение размера шрифта: посты, preview, подписи
  // =========================
  function applyFontSize(scope = document) {
    const selectors = [
      ".post-content p",
      "#post-preview .post-content p",
      ".parsedsig",
      "#profile-signature"
    ];

    const stored = localStorage.getItem("FNTsize");
    let fontSize = "16"; // fallback

    if (stored) {
      const parts = stored.split(",");
      fontSize = parts[1] || "16";
    }

    selectors.forEach(sel => {
      scope.querySelectorAll(sel).forEach(el => {
        el.style.fontSize = fontSize + "px";
      });
    });

    const slider = scope.querySelector("#fntSlider");
    if (slider) slider.setAttribute("data", fontSize);
  }

  // =========================
  // 🔄 Автозамены текста
  // =========================
  function ChangeAll(scope = document) {
    [].concat(
      CONFIG.replacements.profileElements,
      CONFIG.replacements.postButtons,
      CONFIG.replacements.miniProfile
    ).forEach(([selector, from, to]) => {
      scope.querySelectorAll(selector + ':not([data-replaced])').forEach(el => {
        if (el.innerHTML.includes(from)) {
          el.innerHTML = el.innerHTML.replaceAll(from, to);
        }
        el.dataset.replaced = 'true';
      });
    });
  }

  // =========================
  // 🎨 Покраска ников и мини-профиля (без изменений)
  // =========================
  function recolorPostNicks(users, scope = document) {
    const userMap = {};
    users.forEach(u => {
      if (u.username) userMap[u.username.trim().toLowerCase()] = parseInt(u.group_id);
    });

    function colorNick(el, username) {
      const group_id = userMap[username.toLowerCase()] || 4;
      const colors = CONFIG.groupColors[group_id];
      if (!colors) return;
      el.style.color = colors[0];
      el.dataset.baseColor = colors[0];
      el.dataset.hoverColor = colors[1] || colors[0];
    }

    scope.querySelectorAll('.post a[href^="javascript:to("]').forEach(el => {
      if (el.closest('.quote-box, .dropdown, #pun-navlinks, .vote-list, .user-menu')) return;
      const username = el.textContent.trim();
      colorNick(el, username);
    });

    if (CONFIG.enableIndexNickColoring && document.getElementById('onlinelist')) {
      document.querySelectorAll('#onlinelist a[href*="profile.php"]').forEach(el => {
        const username = el.textContent.trim();
        colorNick(el, username);
      });
    }
  }

  function recolorMiniProfile(scope = document) {
    CONFIG.miniProfileSelectors.forEach(sel => {
      scope.querySelectorAll(sel).forEach(el => {
        if (el.dataset.mpRecolored) return;
        let html = el.innerHTML;
        for (const [name, obj] of Object.entries(CONFIG.miniProfileColors)) {
          const [base, hover] = obj.color;
          if (html.includes(name)) {
            html = html.replace(
              name,
              `<span data-base="${base}" data-hover="${hover}" style="font-weight:bold;color:${base};">${name}</span>`
            );
          }
        }
        el.innerHTML = html;
        el.dataset.mpRecolored = 'true';
      });
    });
  }

  function recolorAll(scope = document) {
    recolorPostNicks(JSON.parse(localStorage.getItem('UsersAdmList')?.split('|')[1] || '[]'), scope);
    recolorMiniProfile(scope);
  }

  // =========================
  // 🌈 Полное обновление: всё + шрифт
  // =========================
  function fullUpdate(scope = document) {
    ChangeAll(scope);
    recolorAll(scope);
    applyFontSize(scope);
  }

  // =========================
  // 🌐 Загрузка пользователей
  // =========================
  function fetchUsersAndRecolor(force = false, scope = document) {
    const key = 'UsersAdmList';
    const now = Math.floor(Date.now() / 1000);
    const cached = localStorage.getItem(key);

    if (cached && !force) {
      try {
        const [tsStr, usersStr] = cached.split('|');
        if ((now - parseInt(tsStr)) < 24 * 3600) {
          recolorAll(scope);
          return;
        }
      } catch (e) { console.warn(e); }
    }

    fetch('/api.php', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({ method: 'users.get', limit: '500', fields: 'user_id,username,group_id' })
    })
    .then(r => r.json())
    .then(data => {
      if (data?.response?.users) {
        localStorage.setItem(key, now + '|' + JSON.stringify(data.response.users));
        recolorAll(scope);
      }
    })
    .catch(console.error);
  }

  // =========================
  // 👆 Hover-эффекты
  // =========================
  document.body.addEventListener('mouseover', e => {
    const el = e.target.closest('span[data-base], a[data-base-color]');
    if (el) el.style.color = el.dataset.hoverColor || el.dataset.hover || el.style.color;
  });
  document.body.addEventListener('mouseout', e => {
    const el = e.target.closest('span[data-base], a[data-base-color]');
    if (el) el.style.color = el.dataset.baseColor || el.dataset.base || el.style.color;
  });

  // =========================
  // 🛠 Инициализация ползунка
  // =========================
  function initFontSizeSlider() {
    if (!document.querySelector(".post")) return;
    if (document.getElementById("fntSlider")) return;

    const header = document.querySelector(".post:first-of-type h3 > span");
    if (!header) return;

    const sliderHTML = '<div id="fntSlider" class="FNTslider"><div class="before"></div><div class="thumb"></div></div>';
    if (header.querySelector("strong")) {
      header.querySelector("strong").insertAdjacentHTML('afterend', sliderHTML);
    } else {
      header.insertAdjacentHTML('beforeend', sliderHTML);
    }

    const slider = document.getElementById("fntSlider");
    const thumb = slider.querySelector(".thumb");
    const before = slider.querySelector(".before");
    const sliderWidth = slider.offsetWidth;
    const paddingLeft = parseInt(getComputedStyle(slider).paddingLeft) || 0;

    thumb.title = "Размер шрифта";

    // Инициализация из localStorage
    let stored = localStorage.getItem("FNTsize");
    let fallbackSize = "16";

    if (stored) {
      const [pos, size, fallback] = stored.split(",");
      thumb.style.left = pos + "px";
      before.style.width = pos + "px";
      fallbackSize = fallback || size;
    } else {
      const firstP = document.querySelector(".post-content p");
      if (firstP) {
        const computed = parseFloat(getComputedStyle(firstP).fontSize);
        fallbackSize = Math.round(computed);
      }
      const pos = Math.max(0, (parseInt(fallbackSize) - 6) * 5);
      thumb.style.left = pos + "px";
      before.style.width = pos + "px";
      localStorage.setItem("FNTsize", pos + "," + fallbackSize + "," + fallbackSize);
    }

    // Сохраняем fallback для сброса
    window.__originalFontSize = fallbackSize;

    applyFontSize(); // применим сразу

    // Drag-логика
    function getOffset(el) {
      const rect = el.getBoundingClientRect();
      return { left: rect.left + window.pageXOffset };
    }

    function startDrag(e) {
      e.preventDefault();
      const pageX = e.type === "mousedown" ? e.pageX : e.touches?.[0]?.pageX;
      if (pageX === undefined) return;

      const thumbOffset = getOffset(thumb).left;
      const sliderOffset = getOffset(slider).left;
      const dragOffset = pageX - thumbOffset;
      const maxPos = sliderWidth - thumb.offsetWidth;

      function moveHandler(ev) {
        ev.preventDefault();
        const currentX = ev.type === "mousemove" ? ev.pageX : ev.touches?.[0]?.pageX;
        if (currentX === undefined) return;

        let newPos = currentX - dragOffset - sliderOffset - paddingLeft;
        newPos = Math.max(0, Math.min(newPos, maxPos));

        thumb.style.left = newPos + "px";
        before.style.width = newPos + "px";

        let fontSize = Math.round(newPos / 5) + 6;
        if (fontSize > 30) fontSize = 30;

        localStorage.setItem("FNTsize", newPos + "," + fontSize + "," + window.__originalFontSize);
        applyFontSize();
      }

      function stopHandler() {
        document.removeEventListener("mousemove", moveHandler);
        document.removeEventListener("mouseup", stopHandler);
        document.removeEventListener("touchmove", moveHandler, { passive: false });
        document.removeEventListener("touchend", stopHandler);
      }

      document.addEventListener("mousemove", moveHandler);
      document.addEventListener("mouseup", stopHandler);
      document.addEventListener("touchmove", moveHandler, { passive: false });
      document.addEventListener("touchend", stopHandler);
    }

    thumb.addEventListener("mousedown", startDrag);
    thumb.addEventListener("touchstart", startDrag, { passive: false });
    thumb.ondragstart = () => false;

    // Сброс по двойному клику
    slider.addEventListener("dblclick", () => {
      const fallback = window.__originalFontSize || "16";
      localStorage.removeItem("FNTsize");
      const resetPos = Math.max(0, (parseInt(fallback) - 6) * 5);
      thumb.style.left = resetPos + "px";
      before.style.width = resetPos + "px";
      applyFontSize();
    });
  }

  // =========================
  // ⏳ Планировщик обновлений
  // =========================
  let scheduled = false;
  function scheduleUpdate() {
    if (scheduled) return;
    scheduled = true;
    if (CONFIG.observerDelay === 0) {
      requestAnimationFrame(() => {
        fullUpdate();
        scheduled = false;
      });
    } else {
      setTimeout(() => {
        fullUpdate();
        scheduled = false;
      }, CONFIG.observerDelay);
    }
  }

  // =========================
  // 👁 MutationObserver (резервный механизм)
  // =========================
  const observer = new MutationObserver(scheduleUpdate);
  observer.observe(document.body, { childList: true, subtree: true });

  // =========================
  // 🔌 Явные AJAX-события MyBB (как в оригинале Alex_63)
  // =========================
  const ajaxEvents = [
    'pun_post', 'pun_edit', 'pun_preview',
    'messenger:post', 'messenger:messages_ready', 'messenger:messages_load'
  ];

  ajaxEvents.forEach(eventName => {
    document.addEventListener(eventName, function(e) {
      let scope = document.body;

      // Пытаемся определить контекст (если есть e.detail или свойства события)
      if (e.detail?.post) {
        scope = e.detail.post;
      } else if (e.target && e.target.nodeType === Node.ELEMENT_NODE) {
        scope = e.target;
      } else if (typeof e.pid === 'string' || typeof e.pid === 'number') {
        const postEl = document.getElementById('p' + e.pid);
        if (postEl) scope = postEl;
      }

      fullUpdate(scope);
    });
  });

  // =========================
  // ▶ Инициализация
  // =========================
  initFontSizeSlider();
  fetchUsersAndRecolor();
  applyFontSize();
});
</script>

При разработке использовался ChatGPT и т.п. нейросети, затем код был проверен и протестирован на реальном форуме.
(указано по требованию администрации сервиса)

Отредактировано Merlin777 (Чт, 11 Дек 2025 01:41:00)

+3

577

Ещё раз про скрипты последних сообщений на форуме:)

Есть известные варианты от Alex_63:

Посмотреть

Для верха страницы:
"Последние N сообщений форума(в несколько столбцов)"  Скрипты от пользователей 3

Для низа страницы:
"Последние 10 сообщений форума, новая версия © Alex_63" Статистика форума, последние 10 сообщений, как добавить под шапку?



🔥Представляю вашему вниманию свой вариант списка последних тем, с "живым" автоматическим обновлением

🚨Внимание! Этот вариант не совместим с другими скриптами вывода тем на главную страницу, хотя вы можете их немного исправить для совместимости.

💥Это два файла, css и js.

✅Оба файла работают вместе как единое целое и реализуют панель "живых сообщений" — динамически обновляемую таблицу с последними сообщениями с сайта.
✅Панель вставляется на главную страницу, отображает последние сообщения с подсветкой новых, возможностью поставить обновления на паузу, а также адаптивным переключателем и стилями для удобного отображения.
✅Переключатель "стоп" останавливает загрузку таблицы.
✅Также встроена защита от перегрузок сервера при ошибках загрузки (лимит на запросы) и адаптация к плохому интернет соединению. При отключении интернета панель убирается, при восстановлении связи - 🔁автоматически загружается и заполняется.

🔀Как подключить к себе на сайт (самый простой вариант):

Вставить в HTML верх

👀Посмотреть код
Код:
<!--Последние сообщения на главной странице форума--> 
<link rel="stylesheet" href="https://forumstatic.ru/files/001a/f0/7d/58276.css">
<script src="https://forumstatic.ru/files/001a/f0/7d/14613.js"></script>
<!--Последние сообщения на главной странице форума-->

Там в подключаемом js файле  -  быстрый минифицированный код, который отлично работает.

🛠️Полные версии файлов, с комментариями в исходных кодах:

CSS
Код:
/* === 🧱 Контейнер всего Live-бокса === */
/* Обёртка для всего компонента: занимает всю ширину, отступы сверху/снизу */
.live-posts-container {
  width: 100%;
  margin: 0.5em 0;
  overflow: visible;           /* Позволяет всплывающим элементам выходить за границы */
  contain: layout style paint; /* 🚀 Оптимизация производительности: браузер знает, что внутри изолировано */
}

/* === 🧭 Заголовок таблицы (шапка) === */
/* Используется CSS Grid для трёх колонок: Автор | Время | Сообщение */
.live-header-grid {
  display: grid;
  grid-template-columns: 30% 20% 50%; /* Пропорции колонок */
  width: 100%;
  border: 1px solid #ccc;
  border-bottom: none;                 /* Нижняя граница убрана — она будет у первой строки тела */
  background: linear-gradient(90deg, #3a6fa5, #7b9fcf); /* 🎨 Красивый синий градиент */
  box-sizing: border-box;              /* Включает padding/border в общую ширину */
  color: white;                        /* Белый текст на тёмном фоне */
}

/* Общие стили для ячеек заголовка */
.header-cell {
  padding: 0.3em 0.5em;                /* Внутренние отступы */
  border: 1px solid #ccc;
  border-bottom: none;                 /* Нижняя граница не нужна — она у строк тела */
  vertical-align: middle;              /* Выравнивание по вертикали (на случай table-наследия) */
  box-sizing: border-box;
  font-weight: bold;                   /* Жирный шрифт */
  text-align: center;                  /* Текст по центру */
}

/* === 🎛 Ячейка заголовка с переключателем паузы === */
/* Специальная ячейка для первого столбца (автор + переключатель) */
.header-cell.tcl {
  display: grid;
  grid-template-columns: max-content 1fr; /* Левая часть — по содержимому (переключатель), правая — остаток (надпись "Автор") */
  align-items: center;                    /* Центрирование по вертикали */
  white-space: nowrap;                    /* Запрет переноса строки */
  justify-content: flex-start;            /* Элементы прижаты к левому краю */
}

/* Группа элементов управления слева (переключатель + надпись) */
.left-controls {
  display: flex;
  align-items: center; /* Вертикальное центрирование */
}

/* === 🔄 Стили переключателя "пауза" (как iOS-слайдер) === */
.switch {
  margin-right: 6px;
  position: relative;
  display: inline-block;
  width: 36px;
  height: 16px;
}

/* Скрываем оригинальный checkbox */
.switch input {
  display: none;
}

/* Слайдер-фон */
.slider {
  position: absolute;
  cursor: pointer;
  background-color: #ccc;
  transition: .4s;       /* Плавная анимация */
  border-radius: 18px;   /* Скругление — делает "каплевидной" форму */
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
}

/* Кружок-бегунок внутри слайдера */
.slider:before {
  position: absolute;
  content: "";
  height: 14px;
  width: 14px;
  left: 2px;
  bottom: 2px;
  background-color: white;
  transition: .4s;
  border-radius: 50%; /* Круглая форма */
}

/* Состояние "включено" (пауза активна) */
input:checked + .slider {
  background-color: #2196F3; /* Синий цвет */
}

/* Сдвигаем бегунок вправо при включении */
input:checked + .slider:before {
  transform: translateX(18px);
}

/* Надпись "Стоп" рядом с переключателем */
.pause-label {
  margin-right: 6px;
  font-size: 13px;
  color: silver;
  user-select: none; /* Запрет выделения текста */
}

/* Надпись "Автор" в заголовке — центрируется */
.author-label {
  justify-self: center; /* Центрирование внутри grid-ячейки */
  width: 100%;
  text-align: center;
  font-weight: bold;
}

/* === 📜 Область с прокруткой для строк сообщений === */
/* Ограничивает высоту и добавляет вертикальную прокрутку */
.live-body-wrapper {
  display: block;
  max-height: 210px; /* ← ДОСТАТОЧНО ДЛЯ 5 СТРОК */
  overflow-y: auto;  /* Прокрутка по вертикали, если содержимое не влезает */
  overflow-x: hidden; /* Горизонтальная прокрутка запрещена */
  width: 100%;
  border: 1px solid #ccc;
  border-top: none;   /* Верхняя граница убрана — она уже есть у заголовка */
  padding-top: 12px;  /* Отступ сверху для визуального разделения */
  scrollbar-width: thin;        /* Тонкий скроллбар в Firefox */
  scrollbar-color: #999 transparent; /* Цвет ползунка и трека */
}

/* Стилизация скроллбара в WebKit (Chrome, Safari) */
.live-body-wrapper::-webkit-scrollbar {
  width: 6px; /* Тонкий скроллбар */
}

.live-body-wrapper::-webkit-scrollbar-thumb {
  background-color: #999;
  border-radius: 4px;
}

.live-body-wrapper::-webkit-scrollbar-track {
  background: transparent; /* Прозрачный трек */
}

/* === 🧩 Контейнер для строк тела (реализация таблицы без display: contents) === */
/* Используем flex вместо grid, потому что каждая строка — отдельная grid */
.live-body-grid {
  display: flex;
  flex-direction: column; /* Строки идут вертикально */
  width: 100%;
}

/* === 🧱 Строка данных (одна тема) === */
/* Каждая строка — самостоятельная CSS Grid с тремя колонками */
.post-row {
  display: grid;
  grid-template-columns: 30% 20% 50%;
  gap: 0;
  width: 100%;
  border-bottom: 1px solid #ddd; /* Горизонтальная линия между строками */
  background: white;
  word-wrap: break-word; /* Перенос длинных слов */
}

/* Убираем нижнюю границу у последней строки — выглядит аккуратнее */
.post-row:last-child {
  border-bottom: none;
}

/* === 🧱 Общие стили для ячеек тела === */
.post-cell {
  padding: 0.3em 0.5em;
  vertical-align: middle;
  box-sizing: border-box;
  display: flex;
  align-items: center;           /* Вертикальное центрирование */
  justify-content: flex-start;   /* Текст прижат к левому краю */
  overflow: hidden;              /* Скрываем всё, что не влезает */
  text-overflow: ellipsis;       /* Многоточие при обрезке текста */
}

/* === 💬 Ячейка "Последнее сообщение" — поддерживает две строки === */
/* Здесь: ссылка на тему + текст сообщения */
.post-cell.tcr {
  display: flex;
  flex-direction: column;        /* Элементы друг под другом */
  align-items: flex-start;       /* Выравнивание по левому краю */
  justify-content: center;       /* Центрирование по вертикали внутри ячейки */
  gap: 2px;                      /* Небольшой отступ между строками */
  overflow: hidden;
}

/* === 🕒 Ячейка "Время" — выделена цветом и центрирована === */
.post-cell.tc2 {
  background-color: #eaf0f9;     /* Светло-голубой фон для лучшей читаемости */
  text-align: center;
  display: flex;
  justify-content: center;       /* Горизонтальное центрирование */
  align-items: center;           /* Вертикальное центрирование */
}

/* === ✨ Подсветка обновлённых строк === */
/* Появляется на 2 секунды при изменении сообщения */
.post-row.edited-highlight .post-cell {
  background-color: #DBCDF1;     /* Нежно-фиолетовый фон */
  transition: background-color 0.5s ease; /* Плавное затухание */
}

/* === 🔗 Стили для ссылок внутри сообщений === */
.post-cell a {
  color: #0056b3;                /* Тёмно-синий цвет */
  text-decoration: underline;    /* Подчёркивание для узнаваемости */
  word-break: break-word;        /* Умный перенос */
}

/* === 📱 Адаптив: скрыть надпись "Стоп" на очень узких экранах === */
@media (max-width: 600px) {
  .pause-label {
    display: none !important;    /* Полностью скрываем */
  }
}

/* === 📱 Адаптивность для мобильных устройств (до 768px) === */
@media (max-width: 768px) {
  /* Уменьшаем высоту контейнера — показываем только 3 строки */
  .live-body-wrapper {
    max-height: 128px; /* ← достаточно для 3 строк */
  }

  /* Делаем все колонки одинаковой ширины */
  .live-header-grid,
  .post-row {
    grid-template-columns: repeat(3, 1fr); /* 3 равные колонки */
  }

  /* Унифицируем отступы и шрифты */
  .header-cell,
  .post-cell {
    padding: 8px;
    font-size: 14px;
    border-bottom: 1px solid #ddd;
  }

  /* В заголовке — простой блок (без grid) */
  .header-cell {
    display: block;
  }

  /* Ячейки тела: выравнивание по верху (т.к. могут быть разной высоты) */
  .post-cell {
    align-items: flex-start;
  }

  /* Повтор: убедимся, что высота ограничена */
  .live-body-wrapper {
    max-height: 128px;
  }

  /* Увеличиваем отступ между строками в сообщении на мобиле */
  .post-cell.tcr {
    flex-direction: column;
    gap: 4px;
  }
}

/* === ⚙️ Настройки Live-бокса в профиле (аккордеон) === */
.live-settings-accordion {
  margin: 20px 0;
  border: 1px solid #ddd;
  border-radius: 8px;
  background: #f9f9f9;
  box-shadow: 0 2px 6px rgba(0,0,0,0.1);
  overflow: hidden;
  color: #333;
}

.live-settings-accordion[style*="display: inline-block"] {
  display: inline-block !important;
}

.live-settings-header {
  padding: 14px 16px;
  background: linear-gradient(135deg, #4CAF50, #45a049);
  color: white;
  font-weight: bold;
  cursor: pointer;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.live-settings-toggle {
  transition: transform 0.3s ease;
  font-size: 18px;
}

.live-settings-content {
  padding: 0;
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.4s ease, padding 0.4s ease;
}

.live-settings-inner {
  padding: 20px 16px;
}

.live-settings-accordion label {
  display: block;
  margin: 12px 0;
  color: #333;
}

.live-settings-accordion input[type="number"] {
  width: 80px;
  margin-left: 8px;
  padding: 4px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: white;
  color: #333;
}

.live-settings-accordion input[type="checkbox"] {
  accent-color: #4CAF50;
}

.live-settings-accordion .button {
  background: #4CAF50;
  color: white;
  border: 1px solid #ddd;
  padding: 10px 20px;
  border-radius: 6px;
  cursor: pointer;
  font-weight: bold;
  margin-top: 10px;
  width: 100%;
}

#live-settings-status {
  margin-top: 12px;
  color: green;
  text-align: center;
  min-height: 20px;
}
js
Код:
(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();
      }
    });
  });

})();

При разработке использовался ChatGPT и т.п. нейросети, затем код был проверен и протестирован на реальном форуме. (указано по требованию администрации сервиса)

Отредактировано Merlin777 (Ср, 3 Дек 2025 16:25:12)

+3

578

🎨 Цветовыделение тем.

Есть старый скрипт выделения цветом тем:
https://support.rusff.me/viewtopic.php?id=672
Скрипт отлично работает, но он довольно-таки простой .

🔥А вот более "продвинутая" версия выделения цветом тем на форуме.


🎨 Скрипт «Цветные темы» для форума делает:
✅ Выделение названий тем цветом
✅ Цветной фон под названием темы
✅ Переключение режима (цветной ↔ обычный)
✅ Корректное отображения цветных тем на всём форуме
✅Автоматическая  вставка ссылки на тему в посты
❗В мобильном виде  - цветные темы видно, но создать их ❌нельзя (т.к. палитры будут мелковаты).


👀Посмотреть скриншоты (кликабельно)
  • Панель для "цветных тем"

https://upforme.ru/uploads/0000/14/1c/38891/t387206.jpg


  • Вставка ссылки на тему в посты

https://upforme.ru/uploads/0000/14/1c/38891/t391553.jpg

🛠️Технические подробности

🔧 Основные функции

1. Панель создания цветной темы
✅Появляется только на десктопе и в разрешённых разделах.
✅Заменяет стандартное поле ввода темы на кастомный интерфейс с:   

  • полем ввода названия,

  • счётчиком символов,

  • палитрой из 9 предустановленных цветов,

  • кнопками для вызова расширенного выбора цвета текста (🎨) и фона (🖌),

  • переключателем «Цвет».

2. Динамическое ограничение длины     

  • В цветном режиме: максимум 48 символов.

  • В обычном режиме: максимум 70 символов.

При превышении лимита:   

  • счётчик и рамка поля меняют цвет (оранжевый → красный),

  • при включении цветного режима — текст автоматически обрезается до 48 символов.

3. Переключатель режима    

  • Включено → показываются палитра и "пикеры", применяются цвета.

  • Выключено → панель цветов скрывается, тема отправляется без цветовых кодов.

  • Состояние не сохраняется между страницами — всегда включено по умолчанию.

4. Формат хранения

Цветная тема сохраняется в формате:
цвет;;фон;;название
(например: #e74c3c;;#f1c40f;;Моя тема)

5. Отображение цветных тем

    Скрипт автоматически находит все элементы с цветовыми кодами на странице (ссылки, заголовки, "хлебные крошки" и т.д.).
    Заменяет их на <span> с заданными цветом и фоном.
    Поддерживает любой динамически подгружаемый контент (через MutationObserver).
     
6. Дополнительно    

  • Очистка заголовка вкладки и Open Graph (если у вас это есть) от цветовых кодов (остаётся только читаемое название).

  • Автоматическая вставка ссылки «Тема: …» в посты (опционально).

  • Блокировка в мобильной версии (скрипт не создаёт интерфейс на экранах ≤ 768px).

    Гибкая настройка:       

  • можно указать разрешённые/заблокированные разделы,

  • отключить вставку ссылок на тему.

💡Технические особенности
  • Использует библиотеку Vanilla Picker для выбора цвета.

  • Не зависит от localStorage — работает локально на странице.

  • Совместим с AJAX-загрузкой постов и динамическим контентом.

  • Сохраняет оригинальное поле формы (req_subject) в DOM для корректной отправки.

⚙️Настройки

🚨Основные настройки в скрипте находятся здесь:

const ALLOWED_FORUM_IDS = [];        // 📌 Разрешённые разделы ["1","2"]; (пусто = везде)
const BLOCKED_FORUM_IDS = ["5"];     // 🚫 Заблокированные разделы (приоритет над разрешёнными)
const ENABLE_TOPIC_LINKS = true;     // 🔗 Включить автоматическую вставку ссылки "Тема: ..." в посты

В них вы можете задать разрешённые разделы для этого скрипта, заблокировать разделы для его работы, а также включить вставку ссылки на тему в постах.

🚀Подключение скрипта

Версия в исходных кодах с комментариями
Код:
(function () {
  // 🛠️ === НАСТРОЙКИ СКРИПТА ===
  const ALLOWED_FORUM_IDS = [];        // 📌 Разрешённые разделы ["1","2"]; (пусто = везде)
  const BLOCKED_FORUM_IDS = ["5"];     // 🚫 Заблокированные разделы (приоритет над разрешёнными)
  const ENABLE_TOPIC_LINKS = true;     // 🔗 Включить автоматическую вставку ссылки "Тема: ..." в посты?

  // 📱 Определяем, мобильное ли устройство (ширина экрана ≤ 768px)
  const IS_MOBILE = window.innerWidth <= 768;

  // 🔍 Вспомогательная функция: извлекает ID раздела (fid) или темы (id) из URL
  function getForumOrTopicId() {
    const url = window.location.href;
    let fidMatch = url.match(/[?&]fid=(\d+)/i);   // ID раздела
    let vidMatch = url.match(/[?&]id=(\d+)/i);    // ID темы
    return fidMatch ? fidMatch[1] : (vidMatch ? vidMatch[1] : null);
  }

  // ✅ Проверяет, разрешён ли текущий раздел/тема для работы скрипта
  function isForumAllowed() {
    const currentId = getForumOrTopicId();

    if (BLOCKED_FORUM_IDS.length && currentId && BLOCKED_FORUM_IDS.includes(currentId)) {
      return false;
    }

    if (!ALLOWED_FORUM_IDS.length) return true;

    return currentId && ALLOWED_FORUM_IDS.includes(currentId);
  }

  const forumAllowed = isForumAllowed();

  // 🧹 === ОЧИСТКА ЗАГОЛОВКА ВКЛАДКИ И Open Graph ОТ ЦВЕТОВЫХ КОДОВ ===
  (function () {
    if (!document.querySelector('.punbb#pun-viewtopic')) return;

    let rawSubject = null;
    if (typeof FORUM !== 'undefined' && FORUM.topic && FORUM.topic.subject) {
      rawSubject = FORUM.topic.subject;
    } else {
      const h1 = document.querySelector('.main h1 span');
      rawSubject = h1 ? h1.textContent : null;
    }

    if (rawSubject && rawSubject.includes(';;')) {
      const cleanTitle = rawSubject.split(';;').slice(2).join(';;');
      const suffix = document.title.includes(' — ')
        ? ' — ' + document.title.split(' — ').slice(1).join(' — ')
        : '';
      document.title = cleanTitle + suffix;
      const ogTitle = document.querySelector('meta[property="og:title"]');
      if (ogTitle) ogTitle.setAttribute('content', cleanTitle);
    }
  })();

  // 🎨 === ЗАГРУЗКА БИБЛИОТЕКИ Vanilla Picker ===
  function loadVanillaPicker() {
    return new Promise((resolve, reject) => {
      if (typeof Picker !== 'undefined') {
        resolve(Picker);
        return;
      }
      const script = document.createElement('script');
      script.src = 'https://forumstatic.ru/files/001a/f0/7d/21376.js';
      script.onload = () => resolve(window.Picker);
      script.onerror = () => reject(new Error('Failed to load Vanilla Picker'));
      document.head.appendChild(script);
    });
  }

  // 🎨 === ИНИЦИАЛИЗАЦИЯ ПАНЕЛИ ВЫБОРА ЦВЕТА (только на десктопе!) ===
  async function initColorPicker() {
    if (IS_MOBILE) return;
    if (!forumAllowed) return;

    const subjectInput = document.querySelector('input[name="req_subject"]');
    if (!subjectInput || document.getElementById("color-topic-interface")) return;

    subjectInput.style.visibility = "hidden";
    subjectInput.style.position = "absolute";
    const container = subjectInput.parentNode;
    if (window.getComputedStyle(container).position === 'static') {
      container.style.position = 'relative';
    }

    // 🔸 === СОЗДАНИЕ ИНТЕРФЕЙСА ===
    const interfaceHTML = `
      <div id="color-topic-interface" style="display:flex;flex-direction:column;gap:8px;margin-top:6px;width:100%;font-family:inherit;">
        <div style="position: relative; width: 100%;">
          <input type="text" id="topic-title-input" placeholder="Название темы"
            style="width:100%;padding:7px 50px 7px 10px;border:1px solid #ccc;border-radius:6px;font-size:14px;transition:color .2s ease;box-sizing:border-box;" />
          <div id="char-counter" style="position:absolute;right:10px;top:50%;transform:translateY(-50%);font-size:12px;color:#888;pointer-events:none;">0/48</div>
        </div>
        <div id="color-controls-row" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
          <div id="color-elements" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
            <div id="color-palette" style="display:flex;gap:6px;flex-wrap:wrap;">
              ${['#e74c3c','#e67e22','#f1c40f','#2ecc71','#3498db','#9b59b6','#16a085','#d35400','#c0392b'].map(c =>
                `<button type="button" class="palette-color" data-color="${c}" style="width:26px;height:26px;border:1px solid #aaa;border-radius:4px;background:${c};cursor:pointer;"></button>`).join('')}
            </div>
            <div style="display:flex;align-items:center;gap:6px;">
              <button type="button" id="open-color-picker-btn" title="Выбрать цвет названия" style="width:34px;height:26px;border:1px solid #aaa;border-radius:6px;background:#f6f6f6;cursor:pointer;">🎨</button>
              <div id="current-color-display" style="width:30px;height:26px;border:1px solid #999;border-radius:4px;background:transparent;"></div>
              <button type="button" id="open-bg-picker-btn" title="Выбрать цвет фона" style="width:34px;height:26px;border:1px solid #aaa;border-radius:6px;background:#f6f6f6;cursor:pointer;">🖌</button>
              <div id="current-bg-display" style="width:30px;height:26px;border:1px solid #999;border-radius:4px;background:transparent;"></div>
            </div>
          </div>
          <div style="display:flex;align-items:center;gap:6px;margin-left:auto;font-size:13px;color:#555;user-select:none;">
            <span>Цвет</span>
            <label style="display:inline-block;width:40px;height:20px;position:relative;background:#ccc;border-radius:10px;cursor:pointer;">
              <input type="checkbox" id="color-mode-toggle" checked style="opacity:0;position:absolute;">
              <span id="toggle-thumb" style="position:absolute;top:2px;left:2px;width:16px;height:16px;background:#fff;border-radius:50%;transition:transform 0.2s;"></span>
            </label>
          </div>
        </div>
        <input type="hidden" id="selected-color-value" name="topic_color" value="">
        <input type="hidden" id="selected-bg-value" name="topic_bg_color" value="">
      </div>
    `;
    container.insertAdjacentHTML("beforeend", interfaceHTML);

    // 🔍 Элементы интерфейса
    const titleInput = document.getElementById("topic-title-input");
    const colorValueInput = document.getElementById("selected-color-value");
    const bgValueInput = document.getElementById("selected-bg-value");
    const paletteButtons = document.querySelectorAll(".palette-color");
    const openPickerBtn = document.getElementById("open-color-picker-btn");
    const openBgBtn = document.getElementById("open-bg-picker-btn");
    const currentColorDisplay = document.getElementById("current-color-display");
    const currentBgDisplay = document.getElementById("current-bg-display");
    const charCounter = document.getElementById("char-counter");
    const toggleSwitch = document.getElementById('color-mode-toggle');
    const toggleThumb = document.getElementById('toggle-thumb');
    const colorElements = document.getElementById('color-elements');

    let isTextPickerOpen = false;
    let isBgPickerOpen = false;

    // 🧹 Удаляет цветовые коды из строки (формат: "цвет;;фон;;текст")
    function stripColorCode(text) {
      if (!text.includes(';;')) return text;
      return text.split(';;').slice(2).join(';;');
    }

    // 📤 ЕДИНСТВЕННАЯ ТОЧКА УПРАВЛЕНИЯ ФОРМАТОМ ЗАГОЛОВКА
    function updateHiddenSubject() {
      const title = titleInput.value.trim();

      if (toggleSwitch.checked) {
        // 🎨 Цветной режим
        let color = colorValueInput.value.trim();
        let bg = bgValueInput.value.trim();

        // Подставляем значения по умолчанию, если пусто — и сохраняем их в поля
        if (!color) {
          color = "#000000";
          colorValueInput.value = color;
          currentColorDisplay.style.backgroundColor = color;
        }
        if (!bg) {
          bg = "transparent";
          bgValueInput.value = bg;
          currentBgDisplay.style.backgroundColor = bg;
        }

        const finalTitle = title.length > 48 ? title.slice(0, 48) : title;
        subjectInput.value = finalTitle ? `${color};;${bg};;${finalTitle}` : "";

        // Обновляем визуал
        titleInput.style.color = color;
        titleInput.style.backgroundColor = bg === "transparent" ? "" : bg;
      } else {
        // 📝 Простой режим — только текст
        subjectInput.value = title;
        titleInput.style.color = "";
        titleInput.style.backgroundColor = "";
      }
    }

    // 🔢 Обновление счётчика символов
    function updateCharCounter() {
      const len = titleInput.value.length;
      const max = toggleSwitch.checked ? 48 : 70;
      charCounter.textContent = `${len}/${max}`;

      if (len > max) {
        charCounter.style.color = "#e74c3c";
        titleInput.style.borderColor = "#e74c3c";
      } else if (len > (max - 8)) {
        charCounter.style.color = "#d35400";
        titleInput.style.borderColor = "#d35400";
      } else {
        charCounter.style.color = "#888";
        titleInput.style.borderColor = "#ccc";
      }
    }

    // 🔄 Инициализация значения из оригинального поля
    titleInput.value = stripColorCode(subjectInput.value);
    titleInput.maxLength = toggleSwitch.checked ? 48 : 70;

    // 📝 Обработчик ввода
    titleInput.addEventListener("input", () => {
      if (toggleSwitch.checked && titleInput.value.length > 48) {
        titleInput.value = titleInput.value.slice(0, 48);
      }
      updateHiddenSubject();
      updateCharCounter();
    });

    // 🎨 Обработка палитры
    paletteButtons.forEach(btn => {
      btn.addEventListener("click", function () {
        paletteButtons.forEach(b => (b.style.border = "1px solid #aaa"));
        this.style.border = "2px solid #000";
        const color = this.dataset.color;
        colorValueInput.value = color;
        currentColorDisplay.style.backgroundColor = color;
        updateHiddenSubject();
      });
    });

    // 🎨 Загрузка и инициализация пикеров
    const Picker = await loadVanillaPicker();

    // === ЦВЕТ ТЕКСТА ===
    const textPickerContainer = document.createElement('div');
    textPickerContainer.id = 'text-picker-container';
    textPickerContainer.style.cssText = `
      position: absolute; top: calc(100% + 4px); left: 0; z-index: 1000; display: none;
      background: #fff; border: 1px solid #ccc; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.15);
    `;
    openPickerBtn.parentNode.appendChild(textPickerContainer);

    const textPicker = new Picker({
      parent: textPickerContainer,
      popup: false,
      color: colorValueInput.value || '#000000',
      editorFormat: 'hex',
      components: { interaction: { ok: true, cancel: true, input: true } },
      onChange: (color) => {
        titleInput.style.color = color.rgbaString;
        currentColorDisplay.style.backgroundColor = color.rgbaString;
      },
      onDone: (color) => {
        paletteButtons.forEach(b => b.style.border = "1px solid #aaa");
        colorValueInput.value = color.hex;
        titleInput.style.color = color.rgbaString;
        currentColorDisplay.style.backgroundColor = color.rgbaString;
        updateHiddenSubject();
        textPickerContainer.style.display = 'none';
        isTextPickerOpen = false;
      },
      onCancel: () => {
        titleInput.style.color = colorValueInput.value || '';
        currentColorDisplay.style.backgroundColor = colorValueInput.value || '';
        textPickerContainer.style.display = 'none';
        isTextPickerOpen = false;
      }
    });

    openPickerBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      if (isTextPickerOpen) {
        textPickerContainer.style.display = 'none';
        isTextPickerOpen = false;
      } else {
        if (isBgPickerOpen) {
          bgPickerContainer.style.display = 'none';
          isBgPickerOpen = false;
        }
        textPickerContainer.style.display = 'block';
        isTextPickerOpen = true;
      }
    });

    // === ЦВЕТ ФОНА ===
    const bgPickerContainer = document.createElement('div');
    bgPickerContainer.id = 'bg-picker-container';
    bgPickerContainer.style.cssText = `
      position: absolute; top: calc(100% + 4px); left: 0; z-index: 1000; display: none;
      background: #fff; border: 1px solid #ccc; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.15);
    `;
    openBgBtn.parentNode.appendChild(bgPickerContainer);

    const bgPicker = new Picker({
      parent: bgPickerContainer,
      popup: false,
      color: bgValueInput.value || '#ffffff',
      editorFormat: 'hex',
      components: { interaction: { ok: true, cancel: true, input: true } },
      onChange: (color) => {
        titleInput.style.backgroundColor = color.hex;
        currentBgDisplay.style.backgroundColor = color.rgbaString;
      },
      onDone: (color) => {
        bgValueInput.value = color.hex;
        titleInput.style.backgroundColor = color.hex;
        currentBgDisplay.style.backgroundColor = color.rgbaString;
        updateHiddenSubject();
        bgPickerContainer.style.display = 'none';
        isBgPickerOpen = false;
      },
      onCancel: () => {
        titleInput.style.backgroundColor = bgValueInput.value || '';
        currentBgDisplay.style.backgroundColor = bgValueInput.value || '';
        bgPickerContainer.style.display = 'none';
        isBgPickerOpen = false;
      }
    });

    openBgBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      if (isBgPickerOpen) {
        bgPickerContainer.style.display = 'none';
        isBgPickerOpen = false;
      } else {
        if (isTextPickerOpen) {
          textPickerContainer.style.display = 'none';
          isTextPickerOpen = false;
        }
        bgPickerContainer.style.display = 'block';
        isBgPickerOpen = true;
      }
    });

    // 🔄 Восстановление цветов из существующего значения (если есть)
    if (subjectInput.value.includes(';;')) {
      const [color, bg, ...rest] = subjectInput.value.split(';;');
      const title = rest.join(';;');
      colorValueInput.value = color;
      bgValueInput.value = bg;
      titleInput.value = title;
      titleInput.style.color = color;
      titleInput.style.backgroundColor = bg;
      currentColorDisplay.style.backgroundColor = color;
      currentBgDisplay.style.backgroundColor = bg;
      const match = [...paletteButtons].find(b => b.dataset.color === color);
      if (match) match.style.border = "2px solid #000";
    }

    // 🔘 УПРАВЛЕНИЕ ПЕРЕКЛЮЧАТЕЛЕМ
    function updateToggleStyle() {
      const isEnabled = toggleSwitch.checked;
      toggleThumb.style.transform = isEnabled ? 'translateX(20px)' : 'translateX(0)';
      toggleThumb.parentNode.style.background = isEnabled ? '#3498db' : '#ccc';
      colorElements.style.display = isEnabled ? 'flex' : 'none';
    }

    toggleSwitch.addEventListener('change', () => {
      updateToggleStyle();
      titleInput.maxLength = toggleSwitch.checked ? 48 : 70;
      if (toggleSwitch.checked && titleInput.value.length > 48) {
        titleInput.value = titleInput.value.slice(0, 48);
      }
      // 💡 ЕДИНСТВЕННЫЙ ВЫЗОВ — гарантирует корректное состояние формы
      updateHiddenSubject();
      updateCharCounter();
    });

    // 🖥️ Инициализация интерфейса
    updateCharCounter();
    updateToggleStyle();
  }

  // 🔗 === ДОБАВЛЕНИЕ ССЫЛКИ "ТЕМА: ..." В ПОСТЫ ===
  function addTopicLinkToPostHeader() {
    if (!ENABLE_TOPIC_LINKS) return;
    if (!document.querySelector('.punbb#pun-viewtopic')) return;
    const alt = document.querySelector('link[rel="alternate"]');
    if (!alt) return;
    const match = alt.getAttribute("href").match(/id=(\d+)/);
    if (!match) return;
    const topicId = match[1];
    const topicUrl = "/viewtopic.php?id=" + topicId;
    const topicTitle = document.querySelector(".main h1 span")?.textContent?.trim();
    if (!topicTitle) return;
    const linkHTML = `<li class="TopicLnk" style="font-weight:normal;display:inline-block;color:#7c7cc2;"> 📖 Тема: <a href="${topicUrl}">${topicTitle}</a></li>`;
    document.querySelectorAll(".post h3>span a.permalink").forEach(link => {
      if (link.parentElement.querySelector(".TopicLnk")) return;
      const isTopicPost = link.closest(".topicpost") !== null;
      const finalHTML = isTopicPost ? linkHTML.replace("Re: ", "") : linkHTML;
      link.insertAdjacentHTML("afterend", finalHTML);
    });
  }

  // 🎨 === ПРИМЕНЕНИЕ ЦВЕТОВ КО ВСЕМ ЭЛЕМЕНТАМ НА СТРАНИЦЕ ===
  function applyTopicColors() {
    document.querySelectorAll(
      'a[href*="viewtopic.php"], .TopicLnk, .post-cell.tcr, .crumbs a, .main h1 span'
    ).forEach(el => {
      if (el.dataset.colorProcessed) return;
      let text = el.textContent || "";
      if (!text.includes(';;')) return;
      const [color, bg, ...rest] = text.split(';;');
      const cleanText = rest.join(';;');
      el.innerHTML = `<span style="color:${color};background-color:${bg};padding:0 2px;border-radius:2px;">${cleanText}</span>`;
      el.dataset.colorProcessed = "1";
    });

    // 🔍 Обход текстовых узлов для сложных случаев
    const allTextNodes = [];
    const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
      acceptNode: function (node) {
        if (node.textContent.includes(';;')) {
          let currentParent = node.parentElement;
          while (currentParent && currentParent !== document.body) {
            if (currentParent.dataset.colorProcessed) return NodeFilter.FILTER_REJECT;
            currentParent = currentParent.parentElement;
          }
          return NodeFilter.FILTER_ACCEPT;
        }
        return NodeFilter.FILTER_REJECT;
      }
    });

    let node;
    while (node = walk.nextNode()) allTextNodes.push(node);

    allTextNodes.forEach(textNode => {
      let currentParent = textNode.parentElement;
      while (currentParent && currentParent !== document.body) {
        if (currentParent.dataset.colorProcessed) return;
        currentParent = currentParent.parentElement;
      }
      const parent = textNode.parentElement;
      const text = textNode.textContent;
      if (!text.includes(';;')) return;
      const [color, bg, ...rest] = text.split(';;');
      const cleanText = rest.join(';;');
      const coloredSpan = document.createElement('span');
      coloredSpan.style.cssText = `color:${color};background-color:${bg};padding:0 2px;border-radius:2px;`;
      coloredSpan.textContent = cleanText;
      textNode.replaceWith(coloredSpan);
      parent.dataset.colorProcessed = '1';
    });
  }

  function applyTopicColorsInHeader() {} // Заглушка для будущего расширения

  // ▶️ === ЗАПУСК СКРИПТА ===
  document.addEventListener("DOMContentLoaded", () => {
    initColorPicker();
    addTopicLinkToPostHeader();
    applyTopicColors();
    applyTopicColorsInHeader();
  });

  // 👁️ === НАБЛЮДАТЕЛЬ ЗА ДИНАМИЧЕСКИМИ ЭЛЕМЕНТАМИ ===
  const liveObserver = new MutationObserver(() => {
    applyTopicColors();
    applyTopicColorsInHeader();
  });
  liveObserver.observe(document.body, { childList: true, subtree: true });
})();

✅ Версия с кнопкой отключения в профиле: https://disk.yandex.ru/d/6gHRSP5-nrYTEg

✅ Версия с кнопкой отключения в профиле и тёмной темой: https://disk.yandex.ru/d/fSdMYmjq9xnwpw

Так как скрипт большой и сложный, то, после задания требуемых параметров,  рекомендуется использовать минификацию (сжатие) с помощью сервиса https://minify-js.com/ затем сохранить код в файл, закачать его через админ-панель на ваш сервер и подключать в HTML верх в виде:

<!-- Цветные названия тем -->
<script src="ссылка на ваш скрипт на сервере.js?v=1"></script>
<!-- Цветные названия тем -->

Но для настройки и ознакомления вы можете вставить вышеприведённый код между тегами <script>тут код</script> в HTML низ.

📜Для редактирования кода рекомендуется использовать бесплатный редактор VS Code.

update
10.11.2025 скрипт обновлён на новую версию.
21.11.2025 добавлен вариант с кнопкой отключения в профиле .


При разработке использовался ChatGPT и т.п. нейросети, затем код был проверен и протестирован на реальном форуме. (указано по требованию администрации сервиса)

Отредактировано Merlin777 (Ср, 3 Дек 2025 16:21:06)

+3

579

👁Превью темы и профиля

🚩 Возможности: предпросмотр темы и профиля (без захода в тему)

✅ 👁 Превью при наведении на ссылку темы или профиля
✅ 🏼🟨Можно просмотреть первое и последнее сообщение в теме  с переключением (как в Invision Community)
✅ 🌓 Полная поддержка тёмной темы (body.dark-theme) 
✅ 👤 Профиль пользователя: аватар, группа, статистика, дата регистрации 
✅ 📱 Отключено на мобильных — не мешает навигации 

🖼️ Скриншоты:

Превью профиля

https://upforme.ru/uploads/001a/f0/7d/2/92199.jpg

Превью темы

https://upforme.ru/uploads/001a/f0/7d/2/754160.jpg

✅ Всплывающие окошки привью убираются по щелчку за их пределами, либо сами через 2 секунды.
✅ Если курсор мыши находится в окошке, то отсчёт 2 секунд идёт после убирания курсора.

🔥Это самое красивое, надёжное и функциональное превью по эту сторону Миссисиппи на этом движке форума :)

(защиту от сбоев при доступе на сервер, как в лайв-боксе на главной странице, я не делал, т.к. это же всего лишь превью, можно убрать мышку и навести заново :) )

Для администраторов форумов:

Краткие особенности реализации

● Скрипт при предпросмотре темы показывает содержание  темы, при этом максимально удобно показывается текстовый  и графический контент.

● Картинки и видео в теме - масштабируются по размеру окошка привью.

● Заменяется на заглушки контент между некоторыми тегами. Смысл - обеспечение полной безопасности (для html), невозможность адекватно отобразить элемент на маленьком окошке (для table), исключение показа  заведомо «тяжёлого» контента (для media).

● Ники и группы пользователей, и их атрибуты  - везде запрашиваются только через API сервера.

● Для элементов «Цитата», «Код» и «Спойлер» сконструированы выпадающие списки , т. н. «аккордеоны», с поддержкой и раскраской вложенности.

● Установлен максимальный уровень вложенности (MAX_NESTING_LEVEL = 10) для ускорения обработки элементов с очень большой вложенностью, после этого уровня выводится заглушка [Вложенные элементы].

● Всё это даёт независимость работы привью от любых «улучшений» на форуме внешнего вида цитат, спойлеров, блоков с кодом.

Исходные коды: CSS
Код:
/* ============================================== */
/* 👁🗨 Превью темы и профиля */
/* ============================================== */

.tooltipsy {
    padding: 10px;
    background-color: #fff;
    color: #333;
    font-size: 13px;
    position: absolute;
    z-index: 100000;
    box-shadow: 0 4px 20px rgba(0,0,0,0.12);
    border-radius: 8px;
    display: none;
    pointer-events: auto;
    min-width: 250px;
    border: 1px solid #e0e0e0;
    width: auto;
}

/* 🔺 Треугольник указателя */
.tooltipsy:after {
    content: '';
    position: absolute;
    bottom: -10px;
    left: 50%;
    margin-left: -8px;
    border: 8px solid transparent;
    border-top-color: #fff;
}

/* 📏 Общие размеры и прокрутка */
.tooltipsy.topic {
    max-height: 280px;
}
.tooltipsy .scrollable-content {
    height: 150px;
    overflow-y: auto;
    overflow-x: hidden;
    padding-right: 6px;
    margin-top: 10px;
    box-sizing: border-box;
}

/* 🌈 Нижняя акцентная полоса (только для тем) */
.tooltipsy.topic::before {
    content: '';
    position: absolute;
    bottom: 0;
    left: 0; right: 0;
    height: 3px;
    background: linear-gradient(90deg, #3a5f8a, #4a7bb5);
    border-radius: 0 0 8px 8px;
}

/* ============================================== */
/* 👤 Профиль пользователя */
/* ============================================== */

/* 🖼️ Аватары */
.tooltipsy .avatar-img {
    width: 52px;
    height: 52px;
    object-fit: contain;
    background-color: #f8f8f8;
    border: 1px solid #ddd;
    border-radius: 6px;
}
.tooltipsy .avatarTop .avatar-img {
    width: 32px;
    height: 32px;
}
.tooltipsy .userInfo,
.tooltipsy .avatarTop {
    display: flex;
    align-items: flex-start;
    gap: 10px;
}
.tooltipsy .avatarTop { margin-bottom: 6px; }

/* 🧾 Имя и статус */
.tooltipsy .userTitle h3,
.tooltipsy .userTitle h4 {
    margin: 0;
    font-size: 14px;
}
.tooltipsy .userTitle h4 {
    font-weight: bold;
    background-color: #f0f0f0;
    color: inherit;
    padding: 3px 8px;
    border-radius: 4px;
    display: inline-block;
    margin-top: 2px;
}

/* 🎨 Цвета групп пользователей */
.tooltipsy .usTitle-1 { color: #008080 !important; }
.tooltipsy .usTitle-2 { color: #993232 !important; }
.tooltipsy .usTitle-3 { color: #000 !important; }
.tooltipsy .usTitle-4 { color: #072387 !important; }

/* 📝 Дополнительная информация */
.tooltipsy .userBlurb p,
.tooltipsy .lastActivity p {
    margin: 0;
    word-spacing: 8px;
    font-size: 12px;
}
.tooltipsy hr {
    margin: 8px 0;
    border: none;
    height: 1px;
    background-color: #eee;
}
.tooltipsy .datepost {
    display: block;
    font-size: 12px;
    color: #555;
}

/* ============================================== */
/* 💬 Контент темы */
/* ============================================== */

.tooltipsy .topicStart {
    margin-top: 8px;
    line-height: 1.5;
    font-size: 13px;
}

/* 🖼️ Масштабирование изображений в контенте (но не аватаров) */
.tooltipsy .post-content img,
.tooltipsy .post-content a img,
.tooltipsy img.postimg,
.tooltipsy a img.postimg {
    max-width: 100% !important;
    height: auto !important;
    max-height: none !important;
    width: auto !important;
}

/* 🛡️ Исключение: аватар автора в предпросмотре темы */
.tooltipsy .avatarTop img {
    width: 32px !important;
    height: 32px !important;
    max-width: 32px !important;
    max-height: 32px !important;
}

/* ============================================== */
/* 📑 Кнопки: Первое / Последнее сообщение */
/* ============================================== */

.tooltipsy .topPreview {
    display: flex;
    gap: 8px;
    margin-bottom: 10px;
}
.tooltipsy .tab {
    flex: 1;
    text-align: center;
    padding: 6px 0;
    background: #f5f9ff;
    cursor: pointer;
    border-radius: 5px;
    font-weight: 600;
    font-size: 12px;
    color: #2c5aa0;
    transition: background 0.2s ease, color 0.2s ease;
    border: 1px solid #d0e0ff;
}
.tooltipsy .tab:hover,
.tooltipsy .tab.active {
    background: #e0ecff;
}

/* ============================================== */
/* 🧩 Кастомные аккордеоны: цитаты, код, спойлеры */
/* ============================================== */

.tooltipsy .preview-accordion {
    margin: 4px 0;
    border: 1px solid #ccc;
    border-radius: 4px;
    background: #fafafa;
}

/* 🧹 Полный сброс отступов внутри аккордеонов */
.tooltipsy .preview-accordion *,
.tooltipsy .preview-accordion *::before,
.tooltipsy .preview-accordion *::after {
    margin: 0 !important;
    padding: 0 !important;
    box-sizing: border-box;
}

/* 📌 Заголовок аккордеона */
.tooltipsy .preview-summary {
    cursor: pointer;
    list-style: none;
    background: #f5f5f5;
    border-bottom: 1px solid #ddd;
    line-height: 1.4;
    font-size: 13px;
    padding: 4px 8px !important; /* убран правый отступ под иконку */
    position: relative;
    outline: none;
}

/* 🧭 Индикатор раскрытия (стрелка вниз/вверх) */
.tooltipsy .preview-summary::after {
    content: "\25BC";
    font-size: 10px;
    color: #777;
    margin-left: 6px;
    transition: transform 0.2s ease;
    display: inline-block;
    transform-origin: center;
}
.tooltipsy .preview-accordion.expanded > .preview-summary::after {
    transform: rotate(180deg);
}

/* 🎨 Цвет фона заголовка при раскрытии/сворачивании */
.tooltipsy .preview-accordion.collapsed > .preview-summary {
    background: #f5f5f5 !important;
    color: #333 !important;
}
.tooltipsy .preview-accordion.expanded > .preview-summary {
    background: #e8f0ff !important;
    color: #072387 !important;
    font-weight: 600 !important;
}

/* 🎯 Цвета по типу блока при раскрытии */
.tooltipsy .preview-accordion[data-type="quote"].expanded > .preview-summary {
    background: #e0ecff;
    border-left: 3px solid #072387;
}
.tooltipsy .preview-accordion[data-type="code"].expanded > .preview-summary {
    background: #e0f8f8;
    border-left: 3px solid #008080;
}
.tooltipsy .preview-accordion[data-type="spoiler"].expanded > .preview-summary {
    background: #f8e0ff;
    border-left: 3px solid #8B008B;
}

/* 🔑 КЛЮЧЕВОЕ ПРАВИЛО: изоляция через прямых потомков */
.tooltipsy .preview-accordion.collapsed > .preview-content {
    display: none !important;
}
.tooltipsy .preview-accordion.expanded > .preview-content {
    display: block !important;
}

/* 📦 Вложенные аккордеоны */
.tooltipsy .preview-content .preview-accordion,
.tooltipsy .preview-accordion .preview-accordion {
    width: 100%;
    margin: 4px 0 !important;
    border-radius: 4px;
}
.tooltipsy .preview-content .preview-accordion .preview-summary,
.tooltipsy .preview-accordion .preview-accordion .preview-summary {
    background: #f7f7f7;
    font-size: 12.5px;
}

/* ============================================== */
/* 💻 Блоки кода */
/* ============================================== */

.tooltipsy pre,
.tooltipsy code {
    display: block;
    width: 100% !important;
    max-width: 100% !important;
    white-space: pre-wrap;
    word-wrap: break-word;
    background: #f4f4f4;
    border: 1px solid #ddd;
    border-radius: 0;
    font-size: 12px;
    line-height: 1.4;
    overflow-x: hidden;
}
.tooltipsy pre {
    padding: 4px 6px !important;
}

/* ============================================== */
/* 🧱 Заглушка для слишком глубокой вложенности */
/* ============================================== */

.tooltipsy .nesting-limit {
    font-style: italic;
    color: #888;
    padding: 4px 8px;
    background: #f9f9f9;
    border-left: 2px solid #ccc;
    font-size: 12px;
}
/* 🚫 Защита: у заглушки не должно быть иконки */
.tooltipsy .nesting-limit::after {
    display: none !important;
}

/* ============================================== */
/* 🌙 Тёмная тема */
/* ============================================== */

/* 🎨 Основной стиль */
body.dark-theme .tooltipsy {
    background-color: var(--title);
    color: var(--white-7);
    border: 1px solid var(--black-2);
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
body.dark-theme .tooltipsy:after {
    border-top-color: var(--title);
}
body.dark-theme .tooltipsy hr {
    background-color: var(--black-2);
}

/* 👤 Профиль — текст и аватар */
body.dark-theme .tooltipsy .datepost,
body.dark-theme .tooltipsy .userBlurb p,
body.dark-theme .tooltipsy .lastActivity p {
    color: var(--white-4);
}
body.dark-theme .tooltipsy .userTitle h3 {
    color: var(--white-8);
}
body.dark-theme .tooltipsy .userTitle h4 {
    background-color: rgba(255,255,255,0.12);
}
body.dark-theme .tooltipsy .avatar-img {
    background-color: var(--bg2);
    border-color: var(--black-2);
}

/* 📑 Вкладки */
body.dark-theme .tooltipsy .tab {
    background: rgba(40,46,56,0.6);
    color: var(--white-7);
    border: 1px solid var(--black-2);
}
body.dark-theme .tooltipsy .tab:hover,
body.dark-theme .tooltipsy .tab.active {
    background: rgba(53,61,75,0.8);
    color: var(--white-8);
}

/* 🧩 Аккордеоны — тёмная тема */
body.dark-theme .tooltipsy .preview-accordion {
    background: rgba(255,255,255,0.03);
    border-color: var(--black-2);
}
body.dark-theme .tooltipsy .preview-summary {
    background: rgba(255,255,255,0.06);
    color: var(--white-7);
}
body.dark-theme .tooltipsy .preview-accordion.expanded .preview-summary {
    background: rgba(255,255,255,0.1);
}
body.dark-theme .tooltipsy .preview-summary::after {
    color: var(--white-6) !important;
}

/* 🎨 Цвета заголовков аккордеонов в тёмной теме */
body.dark-theme .tooltipsy .preview-accordion.collapsed > .preview-summary {
    background: rgba(255,255,255,0.06) !important;
    color: var(--white-7) !important;
}
body.dark-theme .tooltipsy .preview-accordion.expanded > .preview-summary {
    background: rgba(65, 105, 225, 0.15) !important; /* тёмно-синий акцент */
    color: #a0c0ff !important;
}

/* 🔗 Ссылки и скроллбар */
body.dark-theme .tooltipsy a {
    color: #a3c0d3;
    text-decoration: underline;
}
body.dark-theme .tooltipsy a:hover {
    color: #e0f0ff;
}
body.dark-theme .tooltipsy .scrollable-content::-webkit-scrollbar {
    width: 8px;
}
body.dark-theme .tooltipsy .scrollable-content::-webkit-scrollbar-thumb {
    background-color: rgba(200,200,200,0.4);
    border-radius: 4px;
}
body.dark-theme .tooltipsy .scrollable-content::-webkit-scrollbar-track {
    background-color: rgba(0,0,0,0.1);
    border-radius: 4px;
}

/* 💻 Код в тёмной теме */
body.dark-theme .tooltipsy pre,
body.dark-theme .tooltipsy code {
    background: rgba(255,255,255,0.05);
    border-color: var(--black-2);
    color: var(--white-8);
}
Исходные коды: JS
Код:
(function($) {
    // 📱 Отключаем превью на мобильных устройствах (ширина ≤ 768px)
    if (window.screen.width <= 768) return;

    // 🧱 Максимальный уровень вложенности аккордеонов (защита от бесконечной рекурсии)
    const MAX_NESTING_LEVEL = 10;

    // 🔍 Извлекает ID из URL (например, из ?id=123 или &id=456)
    function getJid(href) {
        const match = href.match(/[?&]id=(\d+)/);
        return match ? match[1] : null;
    }

    // 🖼️ Возвращает URL аватара по умолчанию
    function defaultAvatar() {
        return 'https://forumstatic.ru/files/0000/14/1c/20038.jpg';
    }

    // 🧠 Глобальные переменные состояния
    let activeTooltip = null;      // Текущий активный тултип
    let hideTimer = null;          // Таймер автоскрытия
    let isClosing = false;         // Флаг: идёт ли закрытие тултипа
    let pendingTarget = null;      // Цель, ожидающая отображения (если тултип занят)
    let scrollHandler = null;      // Обработчик скролла для позиционирования

    // ⏳ Планирует скрытие тултипа через 2 секунды бездействия
    function scheduleHide() {
        if (hideTimer) clearTimeout(hideTimer);
        hideTimer = setTimeout(() => {
            hideTooltip();
        }, 2000);
    }

    // 🚪 Скрывает текущий тултип с плавным исчезновением
    function hideTooltip() {
        if (!activeTooltip || isClosing) return;
        isClosing = true;

        // Отключаем обработчик скролла
        if (scrollHandler) {
            $(window).off('scroll', scrollHandler);
            scrollHandler = null;
        }

        activeTooltip.stop(true, true).fadeOut(200, function() {
            $(this).remove();              // Удаляем DOM-элемент
            activeTooltip = null;          // Сбрасываем ссылку
            isClosing = false;             // Разрешаем новые действия
            if (hideTimer) clearTimeout(hideTimer);
            hideTimer = null;

            // Если есть отложенная цель — показываем её
            if (pendingTarget) {
                const { $link, href, Jid } = pendingTarget;
                pendingTarget = null;
                showPreview($link, href, Jid);
            }
        });
    }

    // 📍 Обновляет позицию тултипа относительно целевого элемента
    function updatePosition($tip, $target) {
        if (!activeTooltip) return;
        const targetPos = $target.offset();
        const tipHeight = $tip.outerHeight();
        const tipWidth = $tip.outerWidth();
        const targetCenter = targetPos.left + $target.outerWidth() / 2;

        // Горизонтальное позиционирование: центрируем по цели
        let left = targetCenter - tipWidth / 2;
        const winWidth = $(window).width();
        if (left < 10) left = 10; // отступ слева
        if (left + tipWidth > winWidth - 10) left = winWidth - tipWidth - 10; // отступ справа

        // Для тем — смещаем вправо (лучше читается)
        if ($tip.hasClass('topic')) {
            const ideal = targetCenter + 20;
            if (ideal + tipWidth <= winWidth - 10 && ideal >= 10) {
                left = ideal;
            }
        }

        // Вертикальное позиционирование: над элементом
        const top = targetPos.top - tipHeight - 8;
        const safeTop = Math.max(10, top); // не выше 10px от верха

        $tip.css({ left: left, top: safeTop });
    }

    // 👁️‍🗨️ Создаёт и показывает превью (профиль или тема)
    function showPreview($link, href, Jid) {
        // Если тултип уже активен — ставим задачу в очередь
        if (activeTooltip || isClosing) {
            pendingTarget = { $link, href, Jid };
            return;
        }

        // Определяем тип ссылки
        const isProfile = href && href.includes('profile.php');
        const isTopic = Jid && !isProfile; // если ID есть, и это не профиль — считаем темой
        if (!isProfile && !isTopic) return;

        // Создаём контейнер тултипа
        const $tip = $(`<div class="tooltipsy ${isProfile ? 'profile' : 'topic'}">Загрузка...</div>`)
            .appendTo('body')
            .css({ display: 'none' });

        activeTooltip = $tip;

        // 🔁 Загрузка и обработка контента
        const loadContent = () => {
            if (!activeTooltip) return;

            // 📅 Форматирует timestamp в читаемую дату (ru-RU)
            const formatDate = (ts) => {
                return new Date(ts * 1000).toLocaleString('ru-RU', {
                    day: '2-digit',
                    month: '2-digit',
                    year: 'numeric',
                    hour: '2-digit',
                    minute: '2-digit'
                }).replace(/,/g, ' в ');
            };

            // 🧼 Очищает и преобразует сообщение для безопасного отображения
            const processMessage = (msg) => {
                if (!msg) return '[пусто]';

                // Заменяем BB-теги на заглушки
                let safeMsg = msg
                    .replace(/\[html\][\s\S]*?\[\/html\]/gi, '[HTML контент]')
                    .replace(/\[table\][\s\S]*?\[\/table\]/gi, '[Таблица]')
                    .replace(/\[media\][\s\S]*?\[\/media\]/gi, '[Медиа]');

                // Заменяем <details> на обычные div (чтобы не конфликтовали)
                safeMsg = safeMsg.replace(/<details\b[^>]*>/gi, '<div class="old-details">');
                safeMsg = safeMsg.replace(/<\/details>/gi, '</div>');

                const temp = $('<div>').html(safeMsg);

                // 🎨 Стили для разных типов блоков
                const TAG_STYLES = {
                    quote:    { icon: '📌', color: '#072387', bg: '#d0e0ff' },
                    code:     { icon: '💻', color: '#008080', bg: '#d0f0f0' },
                    spoiler:  { icon: '🔒', color: '#8B008B', bg: '#f0d0ff' },
                    html:     { icon: '🖼️', color: '#d32f2f', bg: '#ffe0e0' },
                    table:    { icon: '📊', color: '#2e7d32', bg: '#e8f5e9' },
                    media:    { icon: '🎬', color: '#ad1457', bg: '#fce4ec' }
                };

                // 💅 Генерирует inline-стиль для заголовка блока
                const getTagStyle = (type) => {
                    const s = TAG_STYLES[type];
                    return `color: ${s.color}; background-color: ${s.bg}; padding: 0 4px; border-radius: 3px; font-weight: bold;`;
                };

                // 🔁 Рекурсивная обработка вложенных блоков (.quote-box, .code-box, .spoiler-box)
                function processBlocks($element, level = 0) {
                    // Защита от слишком глубокой вложенности
                    if (level >= MAX_NESTING_LEVEL) {
                        $element.replaceWith('<div class="nesting-limit">[Вложенные элементы]</div>');
                        return;
                    }

                    // Находим все поддерживаемые блоки
                    $element.find('.quote-box, .code-box, .spoiler-box').each(function() {
                        const $block = $(this);
                        const tagName = $block.hasClass('quote-box') ? 'quote' :
                                        $block.hasClass('code-box') ? 'code' : 'spoiler';

                        // Удаляем <cite> из цитат
                        if (tagName === 'quote') {
                            $block.children('cite').remove();
                        }

                        // Извлекаем заголовок спойлера
                        let title = 'Спойлер';
                        if (tagName === 'spoiler') {
                            const $titleEl = $block.children('.spoiler-title').first();
                            if ($titleEl.length) {
                                title = $titleEl.text().trim() || 'Спойлер';
                                $titleEl.remove();
                            }
                        }

                        // 🧩 Создаём кастомный аккордеон
                        const $accordion = $('<div class="preview-accordion collapsed"></div>');
                        $accordion.attr('data-type', tagName); // ← для стилизации по типу
                        const $summary = $(`<div class="preview-summary" style="${getTagStyle(tagName)}">${TAG_STYLES[tagName].icon} [${tagName === 'quote' ? 'Цитата' : tagName === 'code' ? 'Код' : `Спойлер: ${title}`}]</div>`);
                        const $content = $('<div class="preview-content"></div>');

                        // Переносим содержимое внутрь аккордеона
                        $content.append($block.contents());
                        $accordion.append($summary).append($content);
                        $block.replaceWith($accordion);

                        // Рекурсивно обрабатываем вложенные блоки
                        processBlocks($content, level + 1);
                    });
                }

                processBlocks(temp, 0);

                // 🖼️ Масштабируем медиа в контенте
                temp.find('img, video, iframe, table, pre').css({
                    'max-width': '100%',
                    'height': 'auto',
                    'box-sizing': 'border-box'
                });
                temp.find('table').css('table-layout', 'fixed');

                let finalHtml = temp.html();

                // Заменяем заглушки на стилизованные теги
                const replacements = [
                    { from: /\[HTML контент\]/g, type: 'html' },
                    { from: /\[Таблица\]/g, type: 'table' },
                    { from: /\[Медиа\]/g, type: 'media' }
                ];

                replacements.forEach(({ from, type }) => {
                    const s = TAG_STYLES[type];
                    const label = type === 'html' ? 'HTML контент' : type === 'table' ? 'Таблица' : 'Медиа';
                    const styledTag = `<span style="color: ${s.color}; background-color: ${s.bg}; padding: 0 4px; border-radius: 3px; font-weight: bold;">${s.icon} [${label}]</span>`;
                    finalHtml = finalHtml.replace(from, styledTag);
                });

                // 📌 Заменяем упоминания на заглушки с data-атрибутом
                finalHtml = finalHtml.replace(/(\d+),(\d+)\s+написал\(а\):/g, '<span class="mention-placeholder" data-post-id="$2">Загрузка...</span>');

                return finalHtml;
            };

            // 👤 Загрузка профиля
            if (isProfile) {
                $.getJSON('/api.php?method=users.get&user_id=' + Jid + '&fields=username,group_id,group_title,avatar,registered,sex,age,num_posts,last_visit,respect_plus')
                    .done(function(j) {
                        const u = j?.response?.users?.[0];
                        if (u) {
                            const sex = { '2': 'Женский', '1': 'Мужской', '0': 'Неизвестно' }[u.sex] || 'Неизвестно';
                            const lastVisit = formatDate(u.last_visit);
                            const registered = new Date(u.registered * 1000).toLocaleString('ru-RU').split(',')[0];
                            const avatar = u.avatar || defaultAvatar();
                            const groupClass = u.group_id ? `usTitle-${u.group_id}` : 'usTitle-4';

                            const html = `
                                <div class="member">
                                    <div class="userInfo">
                                        <img src="${avatar}" alt="" class="avatar-img">
                                        <div class="userTitle">
                                            <h3 class="username">${u.username}</h3>
                                            <h4 class="${groupClass}">${u.group_title}</h4>
                                        </div>
                                    </div>
                                    <hr>
                                    <div class="userBlurb">
                                        <span>Пол: <strong>${sex}</strong></span>
                                        <span>Возраст: <strong>${u.age || '—'}</strong></span><br>
                                        <span class="userStats">
                                            <p>На форуме с: <strong>${registered}</strong></p>
                                            <p>Сообщения: <strong>${u.num_posts}</strong></p>
                                            <p>Уважение: <strong class="reit">${u.respect_plus}</strong></p>
                                        </span>
                                    </div>
                                    <hr>
                                    <span class="lastActivity">
                                        <p>Последняя активность: <strong>${lastVisit}</strong></p>
                                    </span>
                                </div>
                            `;
                            $tip.html(html);
                            finalizeTooltip($tip, $link, isProfile);
                        } else {
                            $tip.html('Профиль не найден');
                            finalizeTooltip($tip, $link, isProfile);
                        }
                    })
                    .fail(() => {
                        $tip.html('Ошибка загрузки профиля');
                        finalizeTooltip($tip, $link, isProfile);
                    });
            } 
            // 💬 Загрузка темы (первое и последнее сообщение)
            else {
                const htmlButtons = `
                    <div class="topPreview">
                        <div class="tab active" data-target="first">Первое сообщение</div>
                        <div class="tab" data-target="last">Последнее сообщение</div>
                    </div>
                    <hr>
                    <div class="scrollable-content">
                        <div class="post-content" style="display:block;">
                            <div class="avatarTop">
                                <img src="${defaultAvatar()}" alt="" class="avatar-img">
                                <span class="datepost">Загрузка...</span>
                            </div>
                        </div>
                    </div>
                `;
                $tip.html(htmlButtons);

                // Измеряем ширину тултипа для корректного позиционирования
                $tip.css({ left: '-9999px', display: 'block' });
                const tipWidth = $tip.outerWidth();
                $tip.css('width', tipWidth);

                // Загружаем первое и последнее сообщение параллельно
                $.when(
                    $.getJSON('/api.php?method=post.get&topic_id=' + Jid + '&limit=1&sort_by=posted&sort_dir=asc&fields=id,username,avatar,message,posted'),
                    $.getJSON('/api.php?method=post.get&topic_id=' + Jid + '&limit=1&sort_by=posted&sort_dir=desc&fields=id,username,avatar,message,posted')
                ).done(function(firstRes, lastRes) {
                    const firstPost = firstRes[0]?.response?.[0];
                    const lastPost = lastRes[0]?.response?.[0];
                    if (!firstPost) {
                        $tip.find('.scrollable-content').html('<div class="post-content">Тема пуста</div>');
                        finalizeTooltip($tip, $link, isProfile);
                        return;
                    }

                    // 🖋️ Рендер одного сообщения
                    const renderPost = (post, isActive = false) => {
                        if (!post) return '';
                        const avatar = post.avatar || defaultAvatar();
                        const date = formatDate(post.posted);
                        const content = processMessage(post.message);
                        const username = post.username || 'Аноним';

                        return `
                            <div class="post-content" style="display:${isActive ? 'block' : 'none'};">
                                <div class="avatarTop">
                                    <img src="${avatar}" alt="" class="avatar-img">
                                    <span class="datepost">🕒 ${date}<br>👤 <strong>${username}</strong></span>
                                </div>
                                <div class="topicStart">${content}</div>
                            </div>
                        `;
                    };

                    const messagesHtml = `
                        ${renderPost(firstPost, true)}
                        ${renderPost(lastPost, false)}
                    `;
                    $tip.find('.scrollable-content').html(messagesHtml);
                    finalizeTooltip($tip, $link, isProfile);
                }).fail(() => {
                    $tip.find('.scrollable-content').html('<div class="post-content">Ошибка загрузки</div>');
                    finalizeTooltip($tip, $link, isProfile);
                });
            }
        };

        loadContent();
    }

    // ✨ Финальная настройка тултипа после загрузки контента
    function finalizeTooltip($tip, $link, isProfile) {
        if (!activeTooltip) return;

        updatePosition($tip, $link);
        $tip.css({ display: 'none' }).fadeIn(200, () => {
            if (!isProfile) {
                // 📑 Переключение между первым и последним сообщением
                $tip.find('.tab').on('click', function() {
                    const target = $(this).data('target');
                    $tip.find('.post-content').hide();
                    $tip.find(`.post-content${target === 'first' ? ':first' : ':last'}`).show();
                    $tip.find('.tab').removeClass('active');
                    $(this).addClass('active');
                    if (hideTimer) clearTimeout(hideTimer); // сбрасываем таймер при взаимодействии
                });

                // 🧩 Обработчик клика по аккордеону: переключаем классы expanded/collapsed
                $tip.on('click', '.preview-summary', function() {
                    const $acc = $(this).closest('.preview-accordion');
                    $acc.toggleClass('expanded collapsed');
                    // ❗ Важно: НЕ используем .toggle(), .show(), .hide() — всё управляется через CSS!
                });

                // 📌 Загрузка имён авторов цитат
                const quoteCites = $tip.find('.topicStart .quote-box > cite');
                const postIds = [];
                const citeMap = [];

                quoteCites.each(function() {
                    const $cite = $(this);
                    const text = $cite.text();
                    const match = text.match(/^(\d+),(\d+)\s+написал/);
                    if (match) {
                        const postId = match[2];
                        postIds.push(postId);
                        citeMap.push({ $cite, postId });
                        $cite.text('Автор цитаты...');
                    }
                });

                if (postIds.length > 0) {
                    const uniquePostIds = [...new Set(postIds)];
                    uniquePostIds.forEach(postId => {
                        $.getJSON(`/api.php?method=post.get&post_id=${postId}&fields=username`)
                            .done(function(data) {
                                const username = data?.response?.[0]?.username || 'Пользователь';
                                citeMap
                                    .filter(item => item.postId === postId)
                                    .forEach(item => {
                                        item.$cite.text(username + ' написал(а):');
                                    });
                            })
                            .fail(() => {
                                citeMap
                                    .filter(item => item.postId === postId)
                                    .forEach(item => {
                                        item.$cite.text('Цитата:');
                                    });
                            });
                    });
                }

                // 🏷️ Загрузка имён в упоминаниях ([user] написал(а))
                const mentionPlaceholders = $tip.find('.topicStart .mention-placeholder');
                const postIdsAll = [];
                const mentionMap = [];

                mentionPlaceholders.each(function() {
                    const $el = $(this);
                    const postId = $el.data('post-id');
                    if (postId) {
                        postIdsAll.push(postId);
                        mentionMap.push({ $el, postId });
                        $el.text('Загрузка...');
                    }
                });

                if (postIdsAll.length > 0) {
                    const uniquePostIds = [...new Set(postIdsAll)];
                    uniquePostIds.forEach(postId => {
                        $.getJSON(`/api.php?method=post.get&post_id=${postId}&fields=username`)
                            .done(function(data) {
                                const username = data?.response?.[0]?.username || 'Пользователь';
                                mentionMap
                                    .filter(item => item.postId == postId)
                                    .forEach(item => {
                                        item.$el.text(username + ' написал(а):');
                                    });
                            })
                            .fail(() => {
                                mentionMap
                                    .filter(item => item.postId == postId)
                                    .forEach(item => {
                                        item.$el.text('Цитата:');
                                    });
                            });
                    });
                }
            }

            // 🕒 Запускаем таймер автоскрытия
            scheduleHide();

            // 🔄 Обновляем позицию при скролле
            scrollHandler = () => updatePosition($tip, $link);
            $(window).on('scroll', scrollHandler);
        });
    }

    // 🔗 Функция для привязки обработчиков к конкретным элементам
    function bindHoverToElement($element) {
        if ($element.hasClass('tooltip-bound')) return;

        let $link = null;
        let href = null;
        let Jid = null;

        // Проверяем, является ли элемент ссылкой
        if ($element.is('a') && $element.is('[href]')) {
            $link = $element;
            href = $element.attr('href');
            Jid = getJid(href);
        }
        // Если элемент — это изображение, ищем родительскую ссылку с ID
        else if ($element.is('img')) {
            const $parentLink = $element.closest('a[href]');
            if ($parentLink.length) {
                href = $parentLink.attr('href');
                Jid = getJid(href);
                if (Jid) {
                    $link = $parentLink;
                }
            }
        }

        if (!$link || !Jid) return;

        const isProfile = href && href.includes('profile.php');
        const isTopic = Jid && !isProfile;
        if (!isProfile && !isTopic) return;

        $link.addClass('tooltip-bound');

        $link.on('mouseenter', function(e) {
            e.stopPropagation(); // Останавливаем всплытие, чтобы не срабатывал на родителях
            pendingTarget = { $link, href, Jid };
            showPreview($link, href, Jid);
        });

        $link.on('mouseleave', function() {
            if (!activeTooltip || !activeTooltip.is(':hover')) {
                pendingTarget = null;
            }
            scheduleHide();
        });
    }

    // 🖱️ Привязываем обработчики ТОЛЬКО к:
    // 1. Ссылкам на темы, которые находятся ВНУТРИ div.tclcon, но НЕ ВНУТРИ span.pagestext И НЕ СО СПЕЦИАЛЬНЫМИ ДЕЙСТВИЯМИ (new)
    // Это означает, что мы ищем ссылки внутри div.tclcon, у которых есть span.acchide рядом,
    // И при этом эти ссылки НЕ находятся внутри .pagestext и НЕ содержат action=new (например, "Новые сообщения").
    $('.tclcon').has('span.acchide').find('a[href*="viewtopic.php"]:not(.pagestext a):not([href*="action=new"])').each(function() {
        bindHoverToElement($(this));
    });

    // 2. Ссылкам на профили (внутри em.user-avatar)
    $('em.user-avatar a').each(function() {
        bindHoverToElement($(this));
    });

    // 🖱️ Скрытие тултипа при клике вне его области
    $(document).on('click', function(e) {
        if (activeTooltip && !$(e.target).closest('.tooltipsy').length) {
            pendingTarget = null;
            hideTooltip();
        }
    });

    // 🐭 Дополнительные обработчики для самого тултипа
    $(document).on('mouseenter', '.tooltipsy', function() {
        if (hideTimer) clearTimeout(hideTimer); // отменяем скрытие при наведении на тултип
    }).on('mouseleave', '.tooltipsy', function() {
        scheduleHide(); // возобновляем таймер при уходе с тултипа
    });
})(jQuery);
📚 Подробное описание работы кода

Этот скрипт реализует интерактивные превью (тултипы) при наведении на ссылки на профили и темы на форуме. Работает только на десктопе (отключено на экранах ≤768px).

🔧 Основные компоненты


1. Инициализация и ограничения
● Скрипт не запускается на мобильных устройствах.
● Установлен максимальный уровень вложенности (MAX_NESTING_LEVEL = 10) для защиты от бесконечной рекурсии в цитатах/спойлерах.

2. Извлечение ID из URL
Функция getJid(href) парсит URL вида:

    profile.php?id=123
    viewtopic.php?id=456
 
и возвращает числовой ID. 

3. Система управления тултипами
● Поддерживается только один активный тултип одновременно.
● При попытке открыть новый, пока предыдущий закрывается — цель ставится в очередь (pendingTarget).
● Тултип автоматически скрывается через 2 секунды бездействия (scheduleHide).
● При наведении на тултип — таймер сбрасывается.
● При клике вне тултипа — он закрывается.

4. Позиционирование
● Тултип позиционируется над целевым элементом.
● Для тем — смещается вправо для лучшей читаемости.
● Учитывается ширина окна: тултип не выходит за границы экрана.


👤 Превью профиля

1. Запрашивается API: /api.php?method=users.get&user_id=...

2. Полученные данные:

● аватар (с fallback)
● ник
● группа (через group_id → CSS-класс usTitle-{id})
● пол, возраст, дата регистрации, количество сообщений, уважение, последняя активность

3. Отображается в структурированном виде с разделителями (<hr>).


💬 Превью темы

1. Запрашиваются первое и последнее сообщения параллельно через:
/api.php?method=post.get&topic_id=...&sort_dir=asc
/api.php?method=post.get&topic_id=...&sort_dir=desc
         
● Отображаются вкладки: «Первое сообщение» / «Последнее сообщение».
● Сообщение проходит обработку через processMessage():
● Удаляются или заменяются опасные/неподдерживаемые BB-теги ([html], [table], [media]).
● Заменяются <details> на <div class="old-details">.
● Все вложенные блоки (цитаты, код, спойлеры) рекурсивно преобразуются в кастомные аккордеоны.
● Медиа-элементы масштабируются (max-width: 100%).
● Упоминания и цитаты заменяются на заглушки с последующей подгрузкой имён.


🧩 Кастомные аккордеоны

● Реализованы без использования <details>, чтобы избежать конфликтов со стилями форума.
● Каждый блок (цитата, код, спойлер) оборачивается в:

Код

<div class="preview-accordion collapsed" data-type="quote">
  <div class="preview-summary">📌 [Цитата]</div>
  <div class="preview-content">...</div>
</div>

● Раскрытие/сворачивание управляется только через CSS-классы (expanded / collapsed).
● Рекурсивная обработка вложенных блоков с ограничением глубины.
● При раскрытии — заголовок получает цветовую индикацию по типу (синий для цитат, зелёный для таблиц и т.д.).

Таким образом, этот код — полноценная система интерактивных превью, сочетающая:

● API-интеграцию (запросы к /api.php),
● Безопасную обработку контента,
● Рекурсивную трансформацию вложенных структур,
● Адаптивное позиционирование,
● Поддержку тёмной темы,
● Оптимизацию UX (очередь, таймеры, плавные анимации).
     
Он не использует внешние библиотеки, кроме jQuery, и полностью автономен внутри замыкания.


🎨 Стилизация (CSS)

● Тултипы имеют тень, скругления, указатель (треугольник).
● Для тем — градиентная полоса снизу.
● Аватары масштабируются с object-fit: contain.
● Группы пользователей стилизуются через CSS-классы (usTitle-1, usTitle-2 и т.д.).
● Поддержка тёмной темы через body.dark-theme.
● Все вложенные элементы в аккордеонах обнуляют отступы (margin: 0 !important), чтобы избежать визуального "наложения".


⚙️ Применяемые алгоритмы и паттерны

● Управление состоянием тултипа:
State machine с флагами activeTooltip, isClosing, pendingTarget

● Обработка вложенных блоков:
Рекурсивный обход DOM с ограничением глубины

● Параллельная загрузка данных
$.when() для одновременного запроса первого и последнего сообщения

● Ленивая подгрузка имён
Замена текста на заглушки → асинхронная подстановка имён по post_id

● Позиционирование
Динамический расчёт координат с учётом границ окна

● Изоляция стилей
Использование прямых потомков (>) и !important  для предотвращения конфликтов

● Таймеры и отмена
setTimeout + clearTimeout для управления временем жизни тултипа

Этот код — полноценная система интерактивных превью, сочетающая:

● API-интеграцию (запросы к /api.php),
● Безопасную обработку контента,
● Рекурсивную трансформацию вложенных структур,
● Адаптивное позиционирование,
● Поддержку тёмной темы,
● Оптимизацию UX (очередь, таймеры, плавные анимации).
   
Он не использует внешние библиотеки, кроме jQuery, и полностью автономен внутри замыкания.

Установка на форум

CSS код ставится между тегами <style> </style> в секцию Администрирование - Формы - HTML верх.
JS код ставится между тегами <script> </script> в секцию Администрирование - Формы - HTML низ.


❗Так как скрипт большой и сложный, то, после задания требуемых параметров,  рекомендуется использовать минификацию (сжатие) с помощью сервисов https://minify-js.com/ и https://minify-css.com/ затем сохранить код в файлы, закачать их через админ-панель на ваш сервер и подключать в виде:

в HTML верх

<!-- Превью темы и профиля (Invision Community style) -->
<link rel="stylesheet" href="https://forumstatic.ru/files/001a/f0/7d/91001.css?v=151">
<!-- Превью темы и профиля (Invision Community style) -->

в HTML низ

<!-- Превью темы и профиля (Invision Community style) -->
<script src="https://forumstatic.ru/files/001a/f0/7d/44934.js?v=91"></script>
<!-- Превью темы и профиля (Invision Community style) -->

Этот код используется на моём форуме, а вы вставите свои адреса файлов.
Но для ознакомления с функционалом вы можете использовать вышеприведённый код вставки :)


📜Для редактирования кода рекомендуется использовать бесплатный редактор VS Code.


При разработке использовался ChatGPT и т.п. нейросети, затем код был проверен и протестирован на реальном форуме. (указано по требованию администрации сервиса)

Код обновлён: 17.11.2025

Отредактировано Merlin777 (Пн, 17 Ноя 2025 02:16:16)

+2

580

❤︎Чёрный Список❤︎

Представляем вашему вниманию универсальный скрипт "Чёрный список" (далее - ЧС), который помогает форумчанам игнорировать друг друга:)

Принцип работы: посты пользователей, попавших в чёрный список, не показываются лично вам.

Как это работает: Вы можете добавлять и убирать пользователя в ЧС на странице ЕГО профиля, а на странице СВОЕГО профиля у вас список пользователей, где их можно также удалять из ЧС.

🚨Особенности:

⚡Админа и модераторов в ЧС добавить нельзя :) (но вы, в принципе, можете это разрешить в исходном коде)
📱 Отлично работает  на мобильных устройствах

Почему скрипт универсальный? Потому, что всё управление ЧС  находится в профиле, т.е. Вам не нужно придумывать, куда его вставлять в  страницы, чтобы он не мешал внешнему виду. 

(Скрипт ЧС будет отлично работать сразу и везде, вам остаётся только поменять цвета оформления под свой дизайн (если вас не устраивает тот, что работает  по-умолчанию). Вы даже можете кнопку блокировки подвинуть куда-нибудь, если у вас какой-то особый дизайн профиля. Но в 99% случаев вам нужно просто поменять цвет "шапки" в выпадающем списке, на этом всё. )

💥Реализация: чёрный список хранится в вашем конкретном БРАУЗЕРЕ, а не на сервере. То есть, в разных браузерах вы можете сделать разные настройки ЧС :)

🖼️Скриншоты
Как 👀выглядит функционал "чёрного списка":

Профиль пользователя не в ЧС

https://upforme.ru/uploads/0000/14/1c/38891/t378119.webp

Профиль пользователя, добавленного  в ЧС

https://upforme.ru/uploads/0000/14/1c/38891/t63964.webp

Список пользователей в ЧС в вашем профиле (список свёрнут)

https://upforme.ru/uploads/0000/14/1c/38891/t461721.webp

Список пользователей в ЧС в вашем профиле (список развёрнут)

https://upforme.ru/uploads/0000/14/1c/38891/t969173.webp


🔀Как подключить к себе на сайт (самый простой вариант): вставить следующие строчки в HTML верх или HTML низ.

Вставить в HTML верх (или в HTML низ, в данном случае нет разницы):

<!-- Чёрный список -->
<link rel="stylesheet" href="https://forumstatic.ru/files/001a/f0/7d/46139.css?v=5">
<script src="https://forumstatic.ru/files/001a/f0/7d/48003.js?v=110"></script>
<!-- Чёрный список -->

Итог: У вас на форуме есть "чёрный список", а Вы молодец:)


🛠️Исходные коды скрипта

Исходные коды снабжены подробными поблочными комментариями в JS (javascript) и очень подробными комментариями  в CSS.

CSS
Код:
/* Стили для блока "Чёрный список" в профиле */

/* ───────────────────────────────────────
   Основной контейнер аккордеона (т.е. всего блока "Чёрный список")
   ─────────────────────────────────────── */
.blocked-users-accordion {
  /* Ведёт себя как "строчный блок" — занимает столько места, сколько нужно по ширине */
  display: inline-block;
  /* Используем простой и читаемый шрифт */
  font-family: Arial, sans-serif;
  /* Не даём ему выходить за пределы экрана */
  max-width: 100%;
}

/* На мобильных устройствах (экран ≤ 768px) — растягиваем на всю ширину */
@media (max-width: 768px) {
  .blocked-users-accordion {
    display: block;     /* Занимает всю строку */
    width: 100%;        /* Растягивается на всю ширину экрана */
  }
}

/* ───────────────────────────────────────
   "Шапка" аккордеона — кликабельная строка с надписью "Чёрный список"
   ─────────────────────────────────────── */
.blocked-users-header {
  padding: 14px 16px;               /* Отступы внутри: сверху/снизу 14px, слева/справа 16px */
  font-weight: bold;                /* Жирный шрифт */
  cursor: pointer;                  /* Курсор в виде "руки" — показывает, что можно кликнуть */
  display: flex;                    /* Элементы внутри выстраиваются в строку */
  justify-content: space-between;   /* Один элемент слева, другой — справа (надпись и стрелка) */
  align-items: center;              /* Выравнивание по вертикали по центру */
  user-select: none;                /* Запрещает выделять текст — чтобы не мешало при клике */
  border-radius: 8px 8px 0 0;       /* Скругление только верхних углов */
  /* Плавное изменение фона и цвета текста при смене темы */
  transition: background 0.3s ease, color 0.3s ease;
}

/* Цвета "шапки" в светлой теме */
body:not(.dark-theme) .blocked-users-header {
  /* Градиент от светло-голубого к чуть более насыщенному */
  background: linear-gradient(135deg, #64B5F6, #42A5F5);
  color: #ffffff; /* Белый текст */
}

/* Цвета "шапки" в тёмной теме */
body.dark-theme .blocked-users-header {
  /* Тёмно-синий градиент */
  background: linear-gradient(135deg, #3a5f7a, #2a4a60);
  color: #e0e0e0; /* Светло-серый текст */
}

/* ───────────────────────────────────────
   Внутренняя часть аккордеона (список заблокированных)
   Изначально скрыта: max-height = 0
   ─────────────────────────────────────── */
.blocked-users-content {
  padding: 0;                       /* Нет внутренних отступов, пока скрыт */
  max-height: 0;                    /* Скрыт! */
  overflow: hidden;                 /* Всё, что не помещается — скрывается */
  /* Плавное раскрытие/закрытие */
  transition: max-height 0.4s ease, padding 0.4s ease, background 0.4s ease;
  border-radius: 0 0 8px 8px;       /* Скругление только нижних углов */
  border: 1px solid;                /* Тонкая рамка вокруг */
}

/* Цвет рамки и фон в светлой теме */
body:not(.dark-theme) .blocked-users-content {
  border-color: #ccc;     /* Светло-серая рамка */
  background: #f9f9f9;    /* Очень светлый фон */
}

/* Цвет рамки и фон в тёмной теме */
body.dark-theme .blocked-users-content {
  border-color: #555;     /* Тёмно-серая рамка */
  background: #2c2c2c;    /* Тёмный фон */
}

/* Когда аккордеон открыт — добавляем отступы и меняем фон */
.blocked-users-content[data-open="1"] {
  padding: 20px 16px;     /* Отступы внутри: сверху/снизу 20px, слева/справа 16px */
}

/* Фон открытого блока в светлой теме */
body:not(.dark-theme) .blocked-users-content[data-open="1"] {
  background: #f4f6f8;    /* Чуть более заметный светлый фон */
}

/* ───────────────────────────────────────
   Текст внутри списка (имена пользователей)
   ─────────────────────────────────────── */
.blocked-users-inner {
  /* Основной цвет текста — можно менять через переменную или напрямую */
  color: var(--text-color, #333);
}

/* В тёмной теме — светлый текст */
body.dark-theme .blocked-users-inner {
  --text-color: #e0e0e0;
  color: #e0e0e0;
}

/* Список без маркеров и отступов */
.blocked-users-inner ul {
  list-style: none; /* Убираем точки/цифры перед пунктами */
  padding: 0;       /* Нет внутренних отступов у списка */
  margin: 0;        /* Нет внешних отступов */
}

/* Каждый пользователь в списке — отдельная строка */
.blocked-users-inner li {
  display: flex;                    /* Элементы в строке: имя слева, кнопка "Удалить" справа */
  justify-content: space-between;   /* Максимальное расстояние между ними */
  align-items: center;              /* По центру по вертикали */
  padding: 6px 0;                   /* Небольшой вертикальный отступ */
}

/* Ссылки на профили: наследуют цвет текста и без подчёркивания */
.blocked-users-inner a {
  color: inherit;         /* Цвет как у родителя (чтобы не было синих ссылок) */
  text-decoration: none;  /* Без подчёркивания */
}

/* При наведении на ссылку — появляется подчёркивание */
.blocked-users-inner a:hover {
  text-decoration: underline;
}

/* ───────────────────────────────────────
   Кнопка "Удалить" рядом с каждым именем
   ─────────────────────────────────────── */
.blocked-users-inner button.delete-btn {
  padding: 4px 8px;           /* Внутренние отступы */
  font-size: 12px;            /* Мелкий шрифт */
  cursor: pointer;            /* Курсор "рука" */
  margin-left: 10px;          /* Отступ слева от имени */
  border-radius: 4px;         /* Скруглённые углы */
  border: 1px solid;          /* Тонкая рамка */
  overflow: hidden;           /* Обрезаем всё, что не помещается */
  white-space: nowrap;        /* Не переносим текст на новую строку */
  max-width: 100%;            /* Не вылезаем за пределы контейнера */
  /* Плавные переходы при наведении */
  transition: 
    background 0.18s ease,
    color 0.18s ease,
    font-size 0.12s ease;
}

/* ─── Светлая тема ─────────────────────── */
body:not(.dark-theme) .blocked-users-inner button.delete-btn {
  border-color: #d0b5b5;    /* Светло-розовая рамка */
  background: #ffecec;      /* Очень светлый розовый фон */
  color: #8b1f1f;           /* Тёмно-красный текст */
}

/* При наведении — чуть увеличиваем шрифт и делаем текст ярче */
body:not(.dark-theme) .blocked-users-inner button.delete-btn:hover {
  font-size: 13px;
  background: #ffecec;      /* Фон остаётся тем же */
  color: #b71c1c;           /* Более насыщенный красный */
}

/* ─── Тёмная тема ──────────────────────── */
body.dark-theme .blocked-users-inner button.delete-btn {
  border-color: #6b4a4a;    /* Тёмно-бордовая рамка */
  background: #5a2a2a;      /* Тёмно-красный фон */
  color: #ffdede;           /* Светло-розовый текст */
}

/* При наведении — фон чуть светлее, текст остаётся светлым */
body.dark-theme .blocked-users-inner button.delete-btn:hover {
  font-size: 13px;
  background: #7a3a3a;      /* Чуть светлее фон */
  color: #ffdede;
}

/* ───────────────────────────────────────
   Горизонтальная линия-разделитель под аккордеоном
   ─────────────────────────────────────── */
.blocked-users-separator {
  display: block;                     /* Ведёт себя как блок (на своей строке) */
  width: 100%;                        /* На всю ширину */
  margin: 12px 0 8px 0;               /* Отступ сверху 12px, снизу 8px */
  border: 0;                          /* Убираем стандартную рамку <hr> */
  border-top: 1px solid rgba(128, 128, 128, 0.35); /* Тонкая полупрозрачная линия сверху */
}
JS
Код:
(function () {
  'use strict';

  const STORAGE_KEY = 'blockedUsers';
  const BLOCKED_GROUP_IDS = [1, 2];// ← ID групп, которые запрещено добавлять в ЧС
  let currentAccordion = null;

  // === LocalStorage (хранит массив объектов { name, id }) ===
  function getBlockedUsers() {
    try {
      const data = localStorage.getItem(STORAGE_KEY);
      const parsed = data ? JSON.parse(data) : [];
      return parsed.map(item => typeof item === 'string' ? { name: item, id: null } : item);
    } catch {
      return [];
    }
  }

  function saveBlockedUsers(blockedList) {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(blockedList));
    } catch (e) {
      console.error('Ошибка сохранения чёрного списка:', e);
    }
  }

  // === Вспомогательные функции ===
  const isDarkTheme = () => document.body.classList.contains('dark-theme');
  const isMobile = () => window.innerWidth <= 768;

  function isOwnProfilePage() {
    if (typeof UserID === 'undefined' || !window.location.pathname.includes('/profile.php')) return false;
    const id = parseInt(new URLSearchParams(window.location.search).get('id'), 10);
    return id === UserID;
  }

  const getProfileUsername = () => {
    const el = document.getElementById('profile-name');
    return el ? el.textContent.trim() : null;
  };

  function getProfileGroupId() {
    const block = document.querySelector('#viewprofile-next');
    if (!block) return null;
    const match = block.className.match(/group(\\d+)/);
    return match ? parseInt(match[1], 10) : null;
  }

  function getProfileUserId() {
    const id = new URLSearchParams(window.location.search).get('id');
    return id ? parseInt(id, 10) : null;
  }

  const isGroupBlockedFromBlacklist = id => BLOCKED_GROUP_IDS.includes(id);

  // === Обновление темы аккордеона ===
  function updateAccordionTheme(accordion) {
    if (!accordion) return;
    const content = accordion.querySelector('.blocked-users-content');
    const inner = accordion.querySelector('.blocked-users-inner');
    if (!content || !inner) return;

    if (content.dataset.open === '1') {
      content.style.maxHeight = inner.scrollHeight + 'px';
    }
  }

  // === Кнопка блокировки ===
  function createBlockButton(username) {
    const userId = getProfileUserId();
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.dataset.blockBtnStyle = '1';
    btn.style.cssText = `
      margin: 10px 0; padding: 6px 12px; font-size: 14px;
      cursor: pointer; border: 1px solid; border-radius: 6px;
      font-weight: bold; transition: all 0.25s ease; overflow:hidden; white-space:nowrap;
    `;

    function updateState() {
      const blockedList = getBlockedUsers();
      const isBlocked = blockedList.some(item => item.name === username);
      const dark = isDarkTheme();
      btn.textContent = isBlocked ? '\u2705 Разблокировать' : '\u26D4 В чёрный список';
      if (dark) {
        btn.style.background = isBlocked ? '#4a3d5a' : '#2a4a60';
        btn.style.color = isBlocked ? '#ff8a80' : '#81d4fa';
        btn.style.borderColor = isBlocked ? '#ff8a80' : '#81d4fa';
      } else {
        btn.style.background = isBlocked ? '#ffebee' : '#e3f2fd';
        btn.style.color = isBlocked ? '#c62828' : '#1565c0';
        btn.style.borderColor = isBlocked ? '#c62828' : '#1565c0';
      }
      btn.style.fontSize = '14px';
    }

    btn.addEventListener('mouseover', () => {
      btn.style.fontSize = '15px';
      if (isDarkTheme()) {
        btn.style.background = 'rgba(129, 212, 250, 0.08)';
        btn.style.color = '#81d4fa';
      } else {
        btn.style.background = 'rgba(21, 101, 192, 0.08)';
        btn.style.color = '#1565c0';
      }
    });

    btn.addEventListener('mouseout', updateState);

    updateState();
    btn.onclick = () => {
      const blockedList = getBlockedUsers();
      const exists = blockedList.some(item => item.name === username);
      if (exists) {
        saveBlockedUsers(blockedList.filter(item => item.name !== username));
      } else {
        saveBlockedUsers([...blockedList, { name: username, id: userId }]);
      }
      updateState();
      if (window.location.pathname.includes('/viewtopic.php')) {
        hideBlockedPostsOnTopicPage();
      }
      if (currentAccordion) {
        const updateListFn = currentAccordion.__updateList;
        if (typeof updateListFn === 'function') updateListFn();
      }
    };

    const themeObserver = new MutationObserver(updateState);
    themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });

    return btn;
  }

  // === Скрытие постов ===
  function hideBlockedPostsOnTopicPage() {
    const blockedList = getBlockedUsers();
    const blockedIds = new Set(
      blockedList
        .filter(item => item.id != null)
        .map(item => Number(item.id))
        .filter(id => !isNaN(id))
    );
    const blockedNames = new Set(blockedList.map(item => item.name));

    if (blockedIds.size === 0 && blockedNames.size === 0) return;

    // Извлечение ID из любой ссылки
    function extractUserIdFromHref(href) {
      if (!href) return null;
      try {
        const url = new URL(href, window.location.origin);
        const idParam = url.searchParams.get('id');
        if (idParam !== null) {
          const id = parseInt(idParam, 10);
          return isNaN(id) ? null : id;
        }
      } catch (e) {
        // Некорректный URL — пропускаем
      }
      return null;
    }

    document.querySelectorAll('.post').forEach(post => {
      const link = post.querySelector('.post-author .pa-author a');
      let name = '';
      let userId = null;

      if (link) {
        name = link.textContent.trim();
        const href = link.getAttribute('href');
        userId = extractUserIdFromHref(href);
      } else {
        // Гость или автор без ссылки
        const authorEl = post.querySelector('.post-author .pa-author');
        if (authorEl) {
          name = authorEl.textContent.trim();
        }
      }

      // Скрываем, если совпадает ID ИЛИ имя
      if ((userId !== null && blockedIds.has(userId)) || blockedNames.has(name)) {
        post.style.display = 'none';
      }
    });
  }

  // === Аккордеон с гиперссылками ===
  function createBlacklistAccordion(container) {
    const accordion = document.createElement('div');
    accordion.id = 'blocked-users-accordion';
    accordion.className = 'blocked-users-accordion';

    const header = document.createElement('div');
    header.className = 'blocked-users-header';
    const title = document.createElement('span');
    title.textContent = '\u26D4 Чёрный список';
    const toggle = document.createElement('span');
    toggle.className = 'blocked-users-toggle';
    toggle.textContent = '\u25BC';
    toggle.style.transition = 'transform 0.3s ease';
    toggle.style.fontSize = '18px';
    header.append(title, toggle);

    const content = document.createElement('div');
    content.className = 'blocked-users-content';
    content.dataset.open = '0';
    const inner = document.createElement('div');
    inner.className = 'blocked-users-inner';
    content.appendChild(inner);
    accordion.append(header, content);

    function updateList() {
      inner.innerHTML = '';
      const blockedList = getBlockedUsers();
      if (blockedList.length === 0) {
        const p = document.createElement('p');
        p.textContent = 'Список пуст.';
        p.style.margin = '0';
        inner.appendChild(p);
      } else {
        const ul = document.createElement('ul');
        blockedList.forEach(item => {
          const li = document.createElement('li');
          const nameWrapper = document.createElement('span');

          if (item.id) {
            const link = document.createElement('a');
            link.href = `/profile.php?id=${item.id}`;
            link.textContent = item.name;
            nameWrapper.appendChild(link);
          } else {
            nameWrapper.textContent = item.name;
          }

          const del = document.createElement('button');
          del.className = 'delete-btn';
          del.textContent = '\uD83D\uDDD1\uFE0F Удалить';

          del.onclick = () => {
            saveBlockedUsers(getBlockedUsers().filter(u => u.name !== item.name));
            updateList();
          };

          li.append(nameWrapper, del);
          ul.append(li);
        });
        inner.append(ul);
      }
      updateAccordionTheme(accordion);
    }

    accordion.__updateList = updateList;

    header.onclick = () => {
      const open = content.dataset.open === '1';
      if (open) {
        content.style.maxHeight = '0';
        content.style.padding = '0';
        toggle.style.transform = 'rotate(0deg)';
        content.dataset.open = '0';
      } else {
        updateList();
        content.style.maxHeight = inner.scrollHeight + 'px';
        content.style.padding = '20px 16px';
        toggle.style.transform = 'rotate(180deg)';
        content.dataset.open = '1';
      }
      updateAccordionTheme(accordion);
    };

    updateList();
    currentAccordion = accordion;

    const hr = document.createElement('hr');
    hr.className = 'blocked-users-separator';
    return { accordion, hr };
  }

  // === Инициализация ===
  function init() {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init);
      return;
    }

    if (window.location.pathname.includes('/profile.php')) {
      const own = isOwnProfilePage();
      const username = getProfileUsername();
      const groupId = getProfileGroupId();
      const container = document.getElementById('pun-main') || document.body;

      if (own) {
        if (!document.getElementById('blocked-users-accordion')) {
          const { accordion, hr } = createBlacklistAccordion(container);
          container.prepend(accordion);
          accordion.insertAdjacentElement('afterend', hr);
        }
      } else if (username && !own && !isGroupBlockedFromBlacklist(groupId)) {
        if (!container.querySelector('button[data-block-btn]')) {
          const btn = createBlockButton(username);
          btn.dataset.blockBtn = '1';
          const target = document.getElementById('profile-name');
          if (target && target.parentNode) {
            target.parentNode.insertBefore(btn, target.nextSibling);
          } else {
            container.prepend(btn);
          }
        }
      }
    }

    if (window.location.pathname.includes('/viewtopic.php')) {
      hideBlockedPostsOnTopicPage();
      const observer = new MutationObserver(() => hideBlockedPostsOnTopicPage());
      observer.observe(document.body, { childList: true, subtree: true });
    }

    const themeObserver = new MutationObserver(() => {
      if (currentAccordion) updateAccordionTheme(currentAccordion);
    });
    themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
  }

  init();
})();

🐈‍⬛Пояснения:

✔ У меня форуме стоит скрипт "тёмной темы" и эта версия его поддерживает, но если у вас нет тёмной темы, то можете удалить блоки для тёмной темы из CSS.
✔ В JS скрипте вы можете включать и выключать запрет на добавление определённых групп в ЧС. Это строчка:

const BLOCKED_GROUP_IDS = [1, 2];// ← ID групп, которые запрещено добавлять в ЧС

Здесь 1 - это группа Администраторы, 2- Модераторы.


При разработке использовался ChatGPT и т.п. нейросети, затем код был проверен и протестирован на реальном форуме.
(указано по требованию администрации сервиса)

Отредактировано Merlin777 (Чт, 13 Ноя 2025 13:55:05)

+4


Вы здесь » Единый форум поддержки » Новые возможности форумов » Скрипты от пользователей 3