[html]
<div id="episode-planner-container">Загрузка…</div>
<script>
(function() {
let plannerData = null;
let container = null;
let settingsOpen = false; // открыты ли настройки
// Запоминаем последние выбранные эпизоды в настройках
let lastAssignEpisodeId = null; // для "Привязка эпизода к персонажу"
let lastOrderEpisodeId = null; // для "Порядок игроков"
function sendMessage(msg) {
window.parent.postMessage(msg, '*');
}
function requestData() {
sendMessage({
type: 'getPlannerData'
});
}
// Определяем, мой ли сейчас ход, с учётом персонажей и очереди 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 (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 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-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 cfg = getCfg();
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;
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 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);
table.appendChild(headRow);
// строки групп и эпизодов
groupNames.forEach(char => {
const list = groups[char];
if (!list || !list.length) return;
const groupTr = document.createElement('tr');
groupTr.className = 'ps-group-row';
const groupTd = document.createElement('td');
groupTd.colSpan = 4;
groupTd.textContent = char;
groupTr.appendChild(groupTd);
table.appendChild(groupTr);
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);
table.appendChild(tr);
});
});
if (!groupNames.length) {
const trEmpty = document.createElement('tr');
const tdEmpty = document.createElement('td');
tdEmpty.colSpan = 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]


![IN:SOMNIA [crossover]](https://upforme.ru/uploads/001c/31/d4/2/519357.jpg)




















![de other side [crossover]](https://i.imgur.com/BQboz9c.png)
















