Автор скрипта: moondal

[html]
<div id="episode-planner-container">Загрузка…</div>
<script>
  (function() {
    let plannerData = null;
    let container = null;
    let settingsOpen = false; // открыты ли настройки

    // Режим редактирования порядка эпизодов
    let orderEditMode = false;

    // Запоминаем последние выбранные эпизоды в настройках
    let lastAssignEpisodeId = null; // для "Привязка эпизода к персонажу"
    let lastOrderEpisodeId = null;  // для "Порядок игроков"

    // Локальное состояние сворачивания групп (per user, в localStorage)
    let groupCollapsed = null;
    let groupCollapsedUid = null;

    function sendMessage(msg) {
      window.parent.postMessage(msg, '*');
    }

    function requestData() {
      sendMessage({ type: 'getPlannerData' });
    }

    // Ключ для localStorage для свёрнутости групп
    function collapsedStorageKey(uid) {
      return 'episode_planner_groups_u' + (uid || 0);
    }

    function loadGroupCollapsed(uid) {
      if (groupCollapsed !== null && groupCollapsedUid === uid) return;
      groupCollapsedUid = uid;
      groupCollapsed = {};
      try {
        const raw = localStorage.getItem(collapsedStorageKey(uid));
        if (raw) {
          const obj = JSON.parse(raw);
          if (obj && typeof obj === 'object') groupCollapsed = obj;
        }
      } catch (e) {}
    }

    function saveGroupCollapsed(uid) {
      if (!groupCollapsed || groupCollapsedUid !== uid) return;
      try {
        localStorage.setItem(collapsedStorageKey(uid), JSON.stringify(groupCollapsed));
      } catch (e) {}
    }

    // Определяем, мой ли сейчас ход, с учётом персонажей и очереди 3+
    function computeMyTurn(topic, cfg, userId, username) {
      const lastName = (topic.last_username || '').trim();
      const lastLower = lastName.toLowerCase();

      // Собираем множество "моих имён"
      const myNames = new Set();
      if (username) myNames.add(username.trim().toLowerCase());
      if (Array.isArray(cfg.characters)) {
        cfg.characters.forEach(c => {
          if (c && c.name) {
            myNames.add(String(c.name).trim().toLowerCase());
          }
        });
      }

      const amLast = (topic.last_user_id == userId) || myNames.has(lastLower);

      // Если есть очередь 3+ для эпизода
      const order = cfg.orders && cfg.orders[topic.id];
      if (order && Array.isArray(order) && order.length > 1) {
        const orderLower = order
          .map(n => String(n || '').trim().toLowerCase())
          .filter(Boolean);
        if (!orderLower.length) {
          return !amLast;
        }
        const idx = orderLower.indexOf(lastLower);
        if (idx === -1) {
          // если имя последнего не в очереди — fallback по "кто последний"
          return !amLast;
        }
        const next = orderLower[(idx + 1) % orderLower.length];
        return next ? myNames.has(next) : false;
      }

      // Обычный случай 2-х игроков:
      // мой ход, если последним писал НЕ я (ни логином, ни персонажем)
      return !amLast;
    }

    // Явная привязка эпизода к персонажу (через настройки)
    function getCharacterName(tid, cfg) {
      const tidStr = String(tid);
      if (cfg.characters) {
        for (const c of cfg.characters) {
          const list = Array.isArray(c.topics) ? c.topics : [];
          if (list.some(x => String(x) === tidStr)) return c.name;
        }
      }
      return '';
    }

    // Автоопределение персонажа по списку, последнему посту и названию эпизода
    function autoDetectCharacter(topic, cfg, username) {
      const chars = Array.isArray(cfg.characters)
        ? cfg.characters.map(c => c && c.name).filter(Boolean)
        : [];
      if (!chars.length) return '';
      const last = (topic.last_username || '').trim().toLowerCase();
      if (last) {
        const foundByLast = chars.find(n => n.trim().toLowerCase() === last);
        if (foundByLast) return foundByLast;
      }
      const subj = ' ' + String(topic.subject || '') + ' ';
      for (const n of chars) {
        const pat = n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        const re = new RegExp('(^|[\\s\\[\\(\\{\\-_/|])' + pat + '([\\s\\]\\)\\}\\-_/|]|$)', 'i');
        if (re.test(subj)) return n;
      }
      if (chars.length === 1) return chars[0];
      return '';
    }

    function mergeConfig(cfg, update) {
      if (!update) return cfg;
      if (typeof update.showOnlyMyTurn === 'boolean') cfg.showOnlyMyTurn = update.showOnlyMyTurn;
      if (typeof update.sortDescending === 'boolean') cfg.sortDescending = update.sortDescending;
      if (Array.isArray(update.ignoreTopics)) cfg.ignoreTopics = update.ignoreTopics.slice();
      if (Array.isArray(update.ignoreForums)) cfg.ignoreForums = update.ignoreForums.slice();

      if (update.orders && typeof update.orders === 'object') {
        cfg.orders = cfg.orders || {};
        Object.keys(update.orders).forEach(key => {
          const val = update.orders[key];
          if (val) cfg.orders[key] = val;
          else delete cfg.orders[key];
        });
      }

      // ручной порядок эпизодов по группам
      if (update.episodeOrder && typeof update.episodeOrder === 'object') {
        cfg.episodeOrder = cfg.episodeOrder || {};
        Object.keys(update.episodeOrder).forEach(key => {
          const val = update.episodeOrder[key];
          if (Array.isArray(val) && val.length) {
            cfg.episodeOrder[key] = val.slice();
          } else {
            delete cfg.episodeOrder[key]; // null/пустой массив = удалить порядок
          }
        });
      }

      if (Array.isArray(update.characters)) {
        cfg.characters = update.characters.map(c => ({
          name: c.name,
          topics: Array.isArray(c.topics) ? c.topics.slice() : [],
          pattern: c.pattern || ''
        }));
      }
      return cfg;
    }

    function updateConfig(update) {
      if (!plannerData) return;
      mergeConfig(plannerData.config, update); // локально
      sendMessage({
        type: 'updateConfig',
        update
      });
      renderUI();
    }

    function ensureStyles() {
      if (document.getElementById('episode-planner-style')) return;
      const css = `
      #planner-wrapper {
        font-family: system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;
        font-size: 13px;
        color: inherit;
        max-width: 100%;
        overflow-x: auto;
      }

      /* Панель управления */
      #planner-controls {
        margin-bottom: 6px;
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
      }
      #planner-wrapper button {
        margin: 0;
        padding: 4px 8px;
        font-size: 12px;
        cursor: pointer;
        border-radius: 999px;
        border: 1px solid rgba(0,0,0,.15);
        background: rgba(0,0,0,.02);
        color: inherit;
      }
      #planner-wrapper button:hover {
        background: rgba(0,0,0,.06);
      }

      /* Таблица в стилистике настроек */
      #planner-wrapper table {
        width: 100%;
        border-collapse: separate !important;
        border-spacing: 0 !important;
        margin: 0 0 0.75rem;
        border-radius: 8px;
        overflow: hidden;
        background: rgba(0,0,0,.01);
        border: 1px solid rgba(0,0,0,.15) !important;
        min-width: 520px;
      }
      #planner-wrapper th,
      #planner-wrapper td {
        border: none !important;
        padding: 0.4rem 0.5rem;
        text-align: left;
        font-size: 13px;
      }
      #planner-wrapper th {
        background: rgba(0,0,0,.04);
        border-bottom: 1px solid rgba(0,0,0,.12) !important;
        font-weight: 600;
        white-space: nowrap;
      }
      #planner-wrapper td {
        border-bottom: 1px solid rgba(0,0,0,.06) !important;
      }
      #planner-wrapper tr:last-child td {
        border-bottom: 0 !important;
      }

      #planner-wrapper th:nth-child(3),
      #planner-wrapper td:nth-child(3) {
        text-align: center;
        width: 1%;
        white-space: nowrap;
        padding-left: 0.25rem;
        padding-right: 0.25rem;
      }

      #planner-wrapper .ps-group-row td {
        background: rgba(0,0,0,.03);
        font-weight: 600;
        border-bottom: 1px solid rgba(0,0,0,.12) !important;
      }

      #planner-wrapper .ps-group-label {
        cursor: pointer;
      }

      #planner-wrapper tr.my-turn td {
        background: rgba(204,0,0,.05);
      }

      #planner-wrapper a {
        color: inherit;
        text-decoration: none;
      }
      #planner-wrapper a:hover {
        text-decoration: underline;
      }

      #planner-wrapper .ps-sortable {
        cursor: pointer;
        user-select: none;
        white-space: nowrap;
      }
      #planner-wrapper .ps-sort-arrow {
        font-size: 11px;
        margin-left: 4px;
        opacity: .8;
      }

      /* Индикатор статуса (красная точка около названия) */
      #planner-wrapper .ps-status {
        display: inline-block;
        min-width: 1.2em;
        text-align: center;
        font-size: 13px;
      }
      #planner-wrapper .ps-status-my {
        color: #c00;
        font-weight: 600;
      }

      /* Колонка ручного порядка */
      #planner-wrapper th.ps-move-header {
        text-align: center;
        width: 1%;
        white-space: nowrap;
      }
      #planner-wrapper .ps-move-cell {
        text-align: center;
        white-space: nowrap;
        vertical-align: middle;
        width: 1%;
      }
      #planner-wrapper .ps-move-controls {
        display: inline-flex;
        flex-direction: column;
        gap: 2px;
      }
      /* Кнопки-стрелочки: только треугольники, без обводки и фона */
      #planner-wrapper .ps-move-cell button {
        padding: 0;
        margin: 0;
        font-size: 11px;
        line-height: 1;
        width: auto;
        height: auto;
        background: transparent;
        border: none;
        border-radius: 0;
      }
      #planner-wrapper .ps-move-cell button:hover {
        background: transparent;
      }

      /* Кнопка сброса ручного порядка у персонажа */
      #planner-wrapper .ps-reset-order-btn {
        margin-left: 6px;
        padding: 2px 6px;
        font-size: 11px;
        border-radius: 999px;
        border: 1px solid rgba(0,0,0,.15);
        background: rgba(0,0,0,.02);
      }
      #planner-wrapper .ps-reset-order-btn:hover {
        background: rgba(0,0,0,.06);
      }

      /* Блок настроек */
      #planner-settings {
        border: 1px solid rgba(0,0,0,.15);
        border-radius: 8px;
        padding: 8px;
        margin-bottom: 8px;
        background: rgba(0,0,0,.02);
      }
      #planner-settings h4 {
        margin: 4px 0 4px;
        font-size: 13px;
        font-weight: 600;
      }
      #planner-settings .ps-hint {
        font-size: 11px;
        opacity: .75;
        margin: 0 0 6px 0;
      }
      #planner-settings .ps-row {
        display: flex;
        gap: 8px;
        align-items: center;
        margin-bottom: 6px;
        flex-wrap: wrap;
      }
      #planner-settings label {
        font-size: 12px;
        opacity: .9;
      }

      #planner-settings select,
      #planner-settings input[type="text"] {
        padding: 5px 9px;
        font-size: 13px;
        flex: 1 1 auto;
        border-radius: 999px;
        border: 1px solid rgba(0,0,0,.15);
        background: rgba(0,0,0,.02);
        color: inherit;
        box-sizing: border-box;
        outline: none;
      }
      #planner-settings select:focus,
      #planner-settings input[type="text"]:focus {
        border-color: rgba(0,0,0,.35);
        box-shadow: 0 0 0 2px rgba(0,0,0,.08);
        background: rgba(0,0,0,.015);
      }

      #planner-settings .ps-chips {
        display: flex;
        flex-wrap: wrap;
        gap: 4px;
        margin-top: 4px;
      }
      #planner-settings .ps-chip {
        display: inline-flex;
        align-items: center;
        padding: 2px 6px;
        border-radius: 999px;
        border: 1px solid rgba(0,0,0,.2);
        background: rgba(0,0,0,.03);
        font-size: 12px;
      }
      #planner-settings .ps-chip button {
        border: none;
        background: transparent;
        padding: 0 0 0 4px;
        margin: 0;
        cursor: pointer;
        font-size: 12px;
      }

      /* Планшеты и узкие ноуты */
      @media (max-width: 768px) {
        #planner-wrapper {
          font-size: 12px;
        }
        #planner-wrapper th,
        #planner-wrapper td {
          padding: 0.3rem 0.4rem;
        }
        #planner-controls {
          display: flex;
          flex-wrap: wrap;
          gap: 4px;
        }
        #planner-controls button {
          margin-right: 0;
        }
      }

      /* Телефоны */
      @media (max-width: 600px) {
        #planner-settings .ps-row {
          flex-direction: column;
          align-items: flex-start;
        }
        #planner-settings select,
        #planner-settings input[type="text"] {
          width: 100%;
        }
      }

      @media (max-width: 480px) {
        #planner-settings {
          padding: 6px;
        }
        #planner-settings .ps-row {
          flex-direction: column;
          align-items: flex-start;
        }
        #planner-settings label {
          margin-bottom: 2px;
        }
        #planner-settings select,
        #planner-settings input[type="text"] {
          width: 100%;
        }
        #planner-wrapper th:nth-child(2),
        #planner-wrapper td:nth-child(2) {
          white-space: nowrap;
        }
      }
    `;
      const style = document.createElement('style');
      style.id = 'episode-planner-style';
      style.textContent = css;
      document.head.appendChild(style);
    }

    // форматируем дату как ДД.ММ.ГГГГ
    function formatDateOnly(ts) {
      if (!ts) return '';
      const d = new Date(ts * 1000);
      if (isNaN(d.getTime())) return '';
      const dd = String(d.getDate()).padStart(2, '0');
      const mm = String(d.getMonth() + 1).padStart(2, '0');
      const yy = d.getFullYear();
      return dd + '.' + mm + '.' + yy;
    }

    // Настройки (персонажи, привязка, очереди, игнор)
    function renderSettings(settingsDiv, topics, config, username) {
      settingsDiv.innerHTML = '';

      function getCfg() {
        return (plannerData && plannerData.config) || config || {};
      }

      // список эпизодов
      const topicList = topics.slice().sort(
        (a, b) => (a.subject || '').localeCompare(b.subject || '', 'ru')
      );

      // карта форумов
      const forumMap = {};
      topics.forEach(t => {
        if (t.forum_id == null) return;
        const fidStr = String(t.forum_id);
        if (!forumMap[fidStr]) {
          const name = t.forum_name || t.forum_title || '';
          forumMap[fidStr] = name ? name : ('Раздел #' + fidStr);
        }
      });
      const forumIds = Object.keys(forumMap).sort(
        (a, b) => forumMap[a].localeCompare(forumMap[b], 'ru')
      );

      /* --- Мои персонажи --- */
      const cfgNow1 = getCfg();
      const nameSet = new Set();
      if (Array.isArray(cfgNow1.characters)) {
        cfgNow1.characters.forEach(c => {
          if (c && c.name) nameSet.add(c.name);
        });
      }
      if (!nameSet.size && username) nameSet.add(username);
      const charNames = Array.from(nameSet);

      const hChars = document.createElement('h4');
      hChars.textContent = 'Мои персонажи';
      settingsDiv.appendChild(hChars);

      const hintChars = document.createElement('div');
      hintChars.className = 'ps-hint';
      hintChars.textContent =
        'Укажите имена всех своих персонажей через запятую. Подписки могут быть на одном профиле, но здесь можно перечислить всех героев, чтобы эпизоды группировались корректно.';
      settingsDiv.appendChild(hintChars);

      const rowChars = document.createElement('div');
      rowChars.className = 'ps-row';

      const labelChars = document.createElement('label');
      labelChars.textContent = 'Имена:';
      rowChars.appendChild(labelChars);

      const inputChars = document.createElement('input');
      inputChars.type = 'text';
      inputChars.placeholder = 'Через запятую: Лэм, Фионна...';
      inputChars.value = charNames.join(', ');
      rowChars.appendChild(inputChars);
      settingsDiv.appendChild(rowChars);

      function saveCharacters() {
        const raw = inputChars.value || '';
        const names = raw.split(',')
          .map(s => s.trim())
          .filter(Boolean);
        const cfg = getCfg();
        if (!names.length) {
          updateConfig({ characters: [] });
          return;
        }
        const existing = Array.isArray(cfg.characters) ? cfg.characters : [];
        const nextChars = [];
        names.forEach(name => {
          let found = existing.find(c => c && c.name === name);
          if (!found) {
            found = { name, topics: [], pattern: '' };
          } else {
            found = {
              name: found.name,
              topics: Array.isArray(found.topics)
                ? found.topics.map(x => String(x))
                : [],
              pattern: found.pattern || ''
            };
          }
          nextChars.push(found);
        });
        updateConfig({ characters: nextChars });
      }
      inputChars.addEventListener('change', saveCharacters);

      /* --- Привязка эпизода к персонажу --- */
      const hAssign = document.createElement('h4');
      hAssign.textContent = 'Привязка эпизода к персонажу';
      settingsDiv.appendChild(hAssign);

      const hintAssign = document.createElement('div');
      hintAssign.className = 'ps-hint';
      hintAssign.textContent =
        'Если автоопределение не угадало, выберите эпизод и прямо укажите, какому персонажу он принадлежит. Непривязанные эпизоды попадают в группу «Без персонажа».';
      settingsDiv.appendChild(hintAssign);

      const rowEp = document.createElement('div');
      rowEp.className = 'ps-row';
      const labelEp = document.createElement('label');
      labelEp.textContent = 'Эпизод:';
      rowEp.appendChild(labelEp);

      const selEp = document.createElement('select');
      const optE0 = document.createElement('option');
      optE0.value = '';
      optE0.textContent = '— выбрать эпизод —';
      selEp.appendChild(optE0);
      topicList.forEach(t => {
        const op = document.createElement('option');
        op.value = String(t.id);
        op.textContent = t.subject;
        selEp.appendChild(op);
      });
      rowEp.appendChild(selEp);
      settingsDiv.appendChild(rowEp);

      const rowChar = document.createElement('div');
      rowChar.className = 'ps-row';
      const labelChar = document.createElement('label');
      labelChar.textContent = 'Персонаж:';
      rowChar.appendChild(labelChar);

      const selChar = document.createElement('select');
      const optC0 = document.createElement('option');
      optC0.value = '';
      optC0.textContent = '— Без персонажа —';
      selChar.appendChild(optC0);
      charNames.forEach(n => {
        const op = document.createElement('option');
        op.value = n;
        op.textContent = n;
        selChar.appendChild(op);
      });
      rowChar.appendChild(selChar);
      settingsDiv.appendChild(rowChar);

      function refreshAssignChar() {
        const tid = selEp.value;
        if (!tid) {
          selChar.value = '';
          return;
        }
        const cfg = getCfg();
        const assigned = getCharacterName(tid, cfg);
        selChar.value = assigned || '';
      }

      selEp.onchange = () => {
        lastAssignEpisodeId = selEp.value || null;
        refreshAssignChar();
      };

      selChar.onchange = () => {
        const tid = selEp.value;
        if (!tid) return;
        const chosen = selChar.value; // '' = Без персонажа
        const tidStr = String(tid);
        const cfg = getCfg();
        const existing = Array.isArray(cfg.characters) ? cfg.characters : [];
        const nextChars = existing.map(c => ({
          name: c.name,
          topics: Array.isArray(c.topics) ? c.topics.map(x => String(x)) : [],
          pattern: c.pattern || ''
        }));
        // удалить эпизод из всех персонажей
        nextChars.forEach(c => {
          c.topics = c.topics.filter(x => String(x) !== tidStr);
        });
        // добавить к выбранному персонажу
        if (chosen) {
          let target = nextChars.find(c => c.name === chosen);
          if (!target) {
            target = { name: chosen, topics: [], pattern: '' };
            nextChars.push(target);
          }
          if (!target.topics.some(x => String(x) === tidStr)) {
            target.topics.push(tidStr);
          }
        }
        updateConfig({ characters: nextChars });
      };

      if (topicList.length) {
        const initialAssignTid =
          (lastAssignEpisodeId &&
            topicList.some(t => String(t.id) === lastAssignEpisodeId))
            ? lastAssignEpisodeId
            : String(topicList[0].id);
        selEp.value = initialAssignTid;
        lastAssignEpisodeId = initialAssignTid;
        refreshAssignChar();
      }

      /* --- Порядок игроков (для 3+ участников) --- */
      const hOrder = document.createElement('h4');
      hOrder.textContent = 'Порядок игроков (для 3+ участников)';
      settingsDiv.appendChild(hOrder);

      const hintOrder = document.createElement('div');
      hintOrder.className = 'ps-hint';
      hintOrder.textContent =
        'Для эпизодов с тремя и более игроками можно задать очередь ходов: впишите ники по кругу, в том порядке, в котором участники пишут посты. Сохраните изменения кнопкой справа или клавишей Enter.';
      settingsDiv.appendChild(hintOrder);

      const rowOrdEp = document.createElement('div');
      rowOrdEp.className = 'ps-row';
      const labelOrdEp = document.createElement('label');
      labelOrdEp.textContent = 'Эпизод:';
      rowOrdEp.appendChild(labelOrdEp);

      const selOrdEp = document.createElement('select');
      const optOE0 = document.createElement('option');
      optOE0.value = '';
      optOE0.textContent = '— выбрать эпизод —';
      selOrdEp.appendChild(optOE0);
      topicList.forEach(t => {
        const op = document.createElement('option');
        op.value = String(t.id);
        op.textContent = t.subject;
        selOrdEp.appendChild(op);
      });
      rowOrdEp.appendChild(selOrdEp);
      settingsDiv.appendChild(rowOrdEp);

      const rowOrd = document.createElement('div');
      rowOrd.className = 'ps-row';
      const labelOrd = document.createElement('label');
      labelOrd.textContent = 'Очередь:';
      rowOrd.appendChild(labelOrd);

      const inputOrd = document.createElement('input');
      inputOrd.type = 'text';
      inputOrd.placeholder = 'Ники через запятую';
      rowOrd.appendChild(inputOrd);

      const btnOrderSave = document.createElement('button');
      btnOrderSave.type = 'button';
      btnOrderSave.textContent = 'Сохранить';
      rowOrd.appendChild(btnOrderSave);
      settingsDiv.appendChild(rowOrd);

      function refreshOrderInput() {
        const tid = selOrdEp.value;
        if (!tid) {
          inputOrd.value = '';
          return;
        }
        const cfg = getCfg();
        const orders = (cfg.orders && cfg.orders[tid]) || [];
        if (Array.isArray(orders)) {
          inputOrd.value = orders.join(', ');
        } else {
          inputOrd.value = '';
        }
      }

      selOrdEp.onchange = () => {
        lastOrderEpisodeId = selOrdEp.value || null;
        refreshOrderInput();
      };

      inputOrd.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') {
          e.preventDefault();
          btnOrderSave.click();
        }
      });

      btnOrderSave.onclick = () => {
        const tid = selOrdEp.value;
        if (!tid) return;
        const raw = inputOrd.value || '';
        const arr = raw
          .split(',')
          .map(s => s.trim())
          .filter(Boolean);
        const patch = {};
        // Если очередь из 2+ имён — сохраняем,
        // если 0 или 1 имя — считаем, что очереди нет и удаляем её.
        if (arr.length > 1) {
          patch[tid] = arr;
        } else {
          patch[tid] = null; // явный сигнал "удалить очередь" для этого эпизода
        }
        updateConfig({ orders: patch });
      };

      if (topicList.length) {
        const initialOrderTid =
          (lastOrderEpisodeId &&
            topicList.some(t => String(t.id) === lastOrderEpisodeId))
            ? lastOrderEpisodeId
            : String(topicList[0].id);
        selOrdEp.value = initialOrderTid;
        lastOrderEpisodeId = initialOrderTid;
        refreshOrderInput();
      }

      /* --- Игнорирование разделов --- */
      const hForums = document.createElement('h4');
      hForums.textContent = 'Игнорирование разделов';
      settingsDiv.appendChild(hForums);

      const hintForums = document.createElement('div');
      hintForums.className = 'ps-hint';
      hintForums.textContent =
        'Если вы не хотите видеть в планировщике эпизоды из некоторых разделов (например, архив или техраздел), добавьте их сюда.';
      settingsDiv.appendChild(hintForums);

      const rowForums = document.createElement('div');
      rowForums.className = 'ps-row';
      const labelForums = document.createElement('label');
      labelForums.textContent = 'Раздел:';
      rowForums.appendChild(labelForums);

      const selForum = document.createElement('select');
      const optF0 = document.createElement('option');
      optF0.value = '';
      optF0.textContent = '— выбрать раздел —';
      selForum.appendChild(optF0);
      forumIds.forEach(fidStr => {
        const op = document.createElement('option');
        op.value = fidStr;
        op.textContent = forumMap[fidStr];
        selForum.appendChild(op);
      });
      rowForums.appendChild(selForum);

      const btnForumAdd = document.createElement('button');
      btnForumAdd.type = 'button';
      btnForumAdd.textContent = 'Добавить';
      btnForumAdd.onclick = () => {
        const v = selForum.value;
        if (!v) return;
        const cfg = getCfg();
        const cur = (cfg.ignoreForums || []).slice();
        if (!cur.some(x => String(x) === v)) {
          cur.push(v);
          updateConfig({ ignoreForums: cur });
        }
      };
      rowForums.appendChild(btnForumAdd);
      settingsDiv.appendChild(rowForums);

      const chipsForums = document.createElement('div');
      chipsForums.className = 'ps-chips';
      (getCfg().ignoreForums || []).forEach(id => {
        const key = String(id);
        const chip = document.createElement('span');
        chip.className = 'ps-chip';
        const label = forumMap[key] || ('Раздел #' + key);
        chip.textContent = label;
        const btnX = document.createElement('button');
        btnX.type = 'button';
        btnX.textContent = '×';
        btnX.onclick = () => {
          const cfg = getCfg();
          const cur = (cfg.ignoreForums || []).filter(x => String(x) !== key);
          updateConfig({ ignoreForums: cur });
        };
        chip.appendChild(btnX);
        chipsForums.appendChild(chip);
      });
      settingsDiv.appendChild(chipsForums);

      /* --- Игнорирование эпизодов --- */
      const hTopics = document.createElement('h4');
      hTopics.textContent = 'Игнорирование эпизодов';
      settingsDiv.appendChild(hTopics);

      const hintTopics = document.createElement('div');
      hintTopics.className = 'ps-hint';
      hintTopics.textContent =
        'Отдельные эпизоды можно скрыть из планировщика, не снимая подписку — они просто не будут появляться в таблице.';
      settingsDiv.appendChild(hintTopics);

      const rowTopics = document.createElement('div');
      rowTopics.className = 'ps-row';
      const labelTopics = document.createElement('label');
      labelTopics.textContent = 'Эпизод:';
      rowTopics.appendChild(labelTopics);

      const selTopic = document.createElement('select');
      const optT0 = document.createElement('option');
      optT0.value = '';
      optT0.textContent = '— выбрать эпизод —';
      selTopic.appendChild(optT0);
      topicList.forEach(t => {
        const op = document.createElement('option');
        op.value = String(t.id);
        op.textContent = t.subject;
        selTopic.appendChild(op);
      });
      rowTopics.appendChild(selTopic);

      const btnTopicAdd = document.createElement('button');
      btnTopicAdd.type = 'button';
      btnTopicAdd.textContent = 'Добавить';
      btnTopicAdd.onclick = () => {
        const v = selTopic.value;
        if (!v) return;
        const cfg = getCfg();
        const cur = (cfg.ignoreTopics || []).slice();
        if (!cur.some(x => String(x) === v)) {
          cur.push(v);
          updateConfig({ ignoreTopics: cur });
        }
      };
      rowTopics.appendChild(btnTopicAdd);
      settingsDiv.appendChild(rowTopics);

      const chipsTopics = document.createElement('div');
      chipsTopics.className = 'ps-chips';
      (getCfg().ignoreTopics || []).forEach(id => {
        const key = String(id);
        const chip = document.createElement('span');
        chip.className = 'ps-chip';
        const topic = topicList.find(t => String(t.id) === key);
        const label = topic ? topic.subject : ('Эпизод #' + key);
        chip.textContent = label;
        const btnX = document.createElement('button');
        btnX.type = 'button';
        btnX.textContent = '×';
        btnX.onclick = () => {
          const cfg = getCfg();
          const cur = (cfg.ignoreTopics || []).filter(x => String(x) !== key);
          updateConfig({ ignoreTopics: cur });
        };
        chip.appendChild(btnX);
        chipsTopics.appendChild(chip);
      });
      settingsDiv.appendChild(chipsTopics);
    }

    function renderUI() {
      if (!plannerData || !container) return;
      ensureStyles();

      const { topics, config, userId, username } = plannerData;

      // состояние свёрнутости групп — per user
      loadGroupCollapsed(userId);

      const episodeOrder =
        (config.episodeOrder && typeof config.episodeOrder === 'object')
          ? config.episodeOrder
          : {};

      // Вспомогательные функции для ручного порядка (per group, в config.episodeOrder)
      function applyManualOrder(groupName, list) {
        const key = String(groupName || '');
        const arr = episodeOrder[key];
        if (!Array.isArray(arr) || !arr.length) return list;
        const map = {};
        list.forEach(r => {
          map[String(r.id)] = r;
        });
        const res = [];
        arr.forEach(id => {
          const r = map[id];
          if (r) {
            res.push(r);
            delete map[id];
          }
        });
        Object.keys(map).forEach(id => {
          res.push(map[id]);
        });
        return res;
      }

      function moveEpisodeInGroup(groupName, topicId, direction, groups) {
        const baseList = groups[groupName] || [];
        const ordered = applyManualOrder(groupName, baseList.slice());
        const ids = ordered.map(r => String(r.id));
        const idx = ids.indexOf(topicId);
        if (idx === -1) return;
        const newIdx = idx + direction;
        if (newIdx < 0 || newIdx >= ids.length) return;
        const tmp = ids[idx];
        ids[idx] = ids[newIdx];
        ids[newIdx] = tmp;
        const key = String(groupName || '');
        const patchObj = {};
        patchObj[key] = ids;
        updateConfig({ episodeOrder: patchObj });
      }

      container.innerHTML = '';

      const wrapper = document.createElement('div');
      wrapper.id = 'planner-wrapper';

      // панель управления
      const controls = document.createElement('div');
      controls.id = 'planner-controls';

      const btnToggle = document.createElement('button');
      btnToggle.textContent = config.showOnlyMyTurn ? 'Показать все' : 'Только мой ход';
      btnToggle.onclick = () =>
        updateConfig({ showOnlyMyTurn: !config.showOnlyMyTurn });
      controls.append(btnToggle);

      // Кнопка "Изменить порядок"
      const btnOrderMode = document.createElement('button');
      btnOrderMode.textContent = orderEditMode ? 'Готово' : 'Изменить порядок';
      btnOrderMode.title = 'Включить/выключить режим ручного изменения порядка эпизодов';
      btnOrderMode.onclick = () => {
        orderEditMode = !orderEditMode;
        renderUI();
      };
      controls.append(btnOrderMode);

      const btnSettings = document.createElement('button');
      btnSettings.textContent = settingsOpen ? 'Скрыть настройки' : 'Настройки';
      btnSettings.onclick = () => {
        settingsOpen = !settingsOpen;
        renderUI();
      };
      controls.append(btnSettings);

      wrapper.appendChild(controls);

      // блок настроек
      const settingsDiv = document.createElement('div');
      settingsDiv.id = 'planner-settings';
      settingsDiv.style.display = settingsOpen ? '' : 'none';
      wrapper.appendChild(settingsDiv);
      if (settingsOpen) {
        renderSettings(settingsDiv, topics, config, username);
      }

      // построение строк таблицы
      const now = Date.now() / 1000;
      let rows = topics
        .filter(t => {
          const tidStr = String(t.id);
          const fidStr = t.forum_id != null ? String(t.forum_id) : '';
          if (config.ignoreTopics &&
            config.ignoreTopics.some(x => String(x) === tidStr)) {
            return false;
          }
          if (fidStr &&
            config.ignoreForums &&
            config.ignoreForums.some(x => String(x) === fidStr)) {
            return false;
          }
          return true;
        })
        .map(t => {
          const days = Math.floor((now - t.last_post_date) / 86400);
          const explicitChar = getCharacterName(t.id, config);
          const autoChar = explicitChar || autoDetectCharacter(t, config, username);
          return {
            id: t.id,
            subject: t.subject,
            last_username: t.last_username,
            last_user_id: t.last_user_id,
            last_post_date: t.last_post_date,
            forum_id: t.forum_id,
            days: days,
            myTurn: computeMyTurn(t, config, userId, username),
            character: autoChar
          };
        });

      if (config.showOnlyMyTurn) {
        rows = rows.filter(r => r.myTurn);
      }

      // сортировка по дате (по умолчанию)
      rows.sort((a, b) =>
        config.sortDescending
          ? b.last_post_date - a.last_post_date
          : a.last_post_date - b.last_post_date
      );

      // группировка по персонажам / "Без персонажа"
      const groups = {};
      rows.forEach(r => {
        const groupName = r.character || 'Без персонажа';
        if (!groups[groupName]) groups[groupName] = [];
        groups[groupName].push(r);
      });

      const groupNames = [];
      if (Array.isArray(config.characters)) {
        config.characters.forEach(c => {
          if (!c || !c.name) return;
          if (groups[c.name] && groups[c.name].length) {
            groupNames.push(c.name);
          }
        });
      }
      if (groups['Без персонажа'] && groups['Без персонажа'].length) {
        groupNames.push('Без персонажа');
      }

      const table = document.createElement('table');

      // шапка таблицы
      const headRow = document.createElement('tr');

      const thSubj = document.createElement('th');
      thSubj.textContent = 'Эпизод';
      headRow.appendChild(thSubj);

      const thDate = document.createElement('th');
      thDate.className = 'ps-sortable';
      thDate.title = 'Сортировать по дате';
      const spanLabel = document.createElement('span');
      spanLabel.textContent = 'Дата';
      const spanArrow = document.createElement('span');
      spanArrow.className = 'ps-sort-arrow';
      spanArrow.textContent = config.sortDescending ? '↓' : '↑';
      thDate.appendChild(spanLabel);
      thDate.appendChild(spanArrow);
      thDate.onclick = () =>
        updateConfig({ sortDescending: !config.sortDescending });
      headRow.appendChild(thDate);

      const thDays = document.createElement('th');
      thDays.title = 'Дней с последнего сообщения';
      thDays.textContent = '⏱';
      headRow.appendChild(thDays);

      const thAuthor = document.createElement('th');
      thAuthor.textContent = 'Последний ход';
      headRow.appendChild(thAuthor);

      // колонка для стрелочек только в режиме изменения порядка
      if (orderEditMode) {
        const thMove = document.createElement('th');
        thMove.className = 'ps-move-header';
        thMove.title = 'Ручной порядок эпизодов';
        thMove.textContent = '⇅';
        headRow.appendChild(thMove);
      }

      table.appendChild(headRow);

      // строки групп и эпизодов
      groupNames.forEach(char => {
        const baseList = groups[char];
        if (!baseList || !baseList.length) return;

        const isCollapsed = !!(groupCollapsed && groupCollapsed[char]);
        const hasManualOrder = Array.isArray(episodeOrder[String(char)]) && episodeOrder[String(char)].length > 0;

        const groupTr = document.createElement('tr');
        groupTr.className = 'ps-group-row';
        const groupTd = document.createElement('td');
        groupTd.colSpan = orderEditMode ? 5 : 4;

        const labelSpan = document.createElement('span');
        labelSpan.className = 'ps-group-label';
        labelSpan.textContent = (isCollapsed ? '▸ ' : '▾ ') + char;
        labelSpan.onclick = () => {
          if (!plannerData) return;
          const uid = plannerData.userId;
          loadGroupCollapsed(uid);
          if (!groupCollapsed) groupCollapsed = {};
          const key = String(char);
          groupCollapsed[key] = !groupCollapsed[key];
          saveGroupCollapsed(uid);
          renderUI();
        };
        groupTd.appendChild(labelSpan);

        if (hasManualOrder) {
          const resetBtn = document.createElement('button');
          resetBtn.type = 'button';
          resetBtn.className = 'ps-reset-order-btn';
          resetBtn.textContent = '↺';
          resetBtn.title = 'Сбросить ручной порядок (вернуть сортировку по дате)';
          resetBtn.onclick = (e) => {
            e.stopPropagation();
            const patch = {};
            patch[String(char)] = null;
            updateConfig({ episodeOrder: patch });
          };
          groupTd.appendChild(resetBtn);
        }

        groupTr.appendChild(groupTd);
        table.appendChild(groupTr);

        if (isCollapsed) {
          return; // эпизоды этой группы не рендерим
        }

        const list = applyManualOrder(char, baseList.slice());

        list.forEach(row => {
          const tr = document.createElement('tr');
          if (row.myTurn) tr.classList.add('my-turn');

          // Эпизод — + красная точка, если ваш ход
          const td1 = document.createElement('td');
          if (row.myTurn) {
            const dot = document.createElement('span');
            dot.className = 'ps-status ps-status-my';
            dot.textContent = '●';
            dot.title = 'Ваш ход';
            dot.style.marginRight = '4px';
            td1.appendChild(dot);
          }
          const link = document.createElement('a');
          link.href = '/viewtopic.php?id=' + row.id;
          link.target = '_blank';
          link.textContent = row.subject;
          td1.appendChild(link);
          tr.appendChild(td1);

          // Дата (дд.мм.гггг)
          const tdDate = document.createElement('td');
          tdDate.textContent = formatDateOnly(row.last_post_date);
          tr.appendChild(tdDate);

          // Дней
          const tdDays = document.createElement('td');
          tdDays.textContent = row.days;
          tr.appendChild(tdDays);

          // Последний ход
          const tdLast = document.createElement('td');
          tdLast.textContent = row.last_username;
          tr.appendChild(tdLast);

          // Ручной порядок: вверх/вниз только в режиме редактирования
          if (orderEditMode) {
            const tdMove = document.createElement('td');
            tdMove.className = 'ps-move-cell';

            const topicIdStr = String(row.id);

            const wrap = document.createElement('div');
            wrap.className = 'ps-move-controls';

            const btnUp = document.createElement('button');
            btnUp.type = 'button';
            btnUp.textContent = '▲';
            btnUp.title = 'Поднять эпизод выше';
            btnUp.onclick = () => moveEpisodeInGroup(char, topicIdStr, -1, groups);
            wrap.appendChild(btnUp);

            const btnDown = document.createElement('button');
            btnDown.type = 'button';
            btnDown.textContent = '▼';
            btnDown.title = 'Опустить эпизод ниже';
            btnDown.onclick = () => moveEpisodeInGroup(char, topicIdStr, 1, groups);
            wrap.appendChild(btnDown);

            tdMove.appendChild(wrap);
            tr.appendChild(tdMove);
          }

          table.appendChild(tr);
        });
      });

      if (!groupNames.length) {
        const trEmpty = document.createElement('tr');
        const tdEmpty = document.createElement('td');
        tdEmpty.colSpan = orderEditMode ? 5 : 4;
        tdEmpty.textContent = 'Нет эпизодов для отображения.';
        trEmpty.appendChild(tdEmpty);
        table.appendChild(trEmpty);
      }

      wrapper.appendChild(table);
      container.appendChild(wrapper);
    }

    // обработчик ответа от родителя
    function handleMessage(event) {
      const msg = event.data;
      if (!msg || msg.type !== 'plannerData') return;
      if (msg.data && msg.data.error) {
        container.textContent = 'Ошибка: ' + msg.data.error;
        return;
      }
      const data = msg.data || {};
      if (!plannerData) {
        plannerData = data;
      } else {
        plannerData.topics = Array.isArray(data.topics) ? data.topics : [];
        plannerData.userId = data.userId;
        plannerData.username = data.username;
        if (!plannerData.config && data.config) {
          plannerData.config = data.config;
        }
      }
      renderUI();
    }

    function init() {
      container = document.getElementById('episode-planner-container');
      requestData();
    }

    window.addEventListener('message', handleMessage);
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init);
    } else {
      init();
    }
  })();
</script>
[/html]