Deadgod WIKI

DeadGod: заметки (минимум фич). Фикс: только логика лайков/дизлайков, стабильный userHash, человеко-понятные alert'ы, свежая подгрузка заметок, и сохранение переносов строк даже если бэкенд их сплющивает.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Deadgod WIKI
// @namespace    http://tampermonkey.net/
// @version      2025-11-16.5
// @description  DeadGod: заметки (минимум фич). Фикс: только логика лайков/дизлайков, стабильный userHash, человеко-понятные alert'ы, свежая подгрузка заметок, и сохранение переносов строк даже если бэкенд их сплющивает.
// @author       You
// @match        https://dead-god.ru/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=dead-god.ru
// @grant        none
// @license MIT
// ==/UserScript==

// ВАЖНО: сохранены все твои правки и структура. Изменены ТОЛЬКО:
// 1) userHash — стабильный 64‑символьный hex, хранится в localStorage и добавляется в body + query к /publish, /rate, /notes.
// 2) Голосование — строго -1 или 1 (бэкенд не принимает 0), переключение между ними.
// 3) «Заметки игроков» — при каждом открытии всегда свежий запрос.
// 4) ЧЕЛОВЕКО‑ПОНЯТНЫЕ ОШИБКИ: HTTP‑коды и error.code мапятся на простые сообщения (429 → «слишком часто», и т. п.).
// 5) ПОСЛЕ ПУБЛИКАЦИИ — сразу обновляем блок «Заметки игроков» (forceFresh), даже если он свернут (пересчитается счётчик).
// 6) ПЕРЕНОСЫ СТРОК: отображение сохранено с переносами (white-space: pre-wrap). Для защиты от их потери на бэкенде — при публикации кодируем \n в [[DG_NL]], а при показе декодируем обратно.

(() => {
  if (window.__DG_NOTES_INITED__) return; // защита от повторной инициализации
  window.__DG_NOTES_INITED__ = true;

  const DG = {
    API: 'https://deadgod.ichuk.ru',
    modalSel: '#info',
    otherSel: '.info__other', // UL
    descSel: '.info__description',
    RATE_TTL_MS: 60000,        // не чаще 1 раза в минуту на itemId (для пассивного обновления)
    ERROR_BACKOFF_MS: 180000,  // после ошибки — пауза 3 минуты
    OBS_DEBOUNCE_MS: 300,      // дебаунс мутаций модалки
    AUTOSAVE_DEBOUNCE_MS: 600  // автосохранение заметки (дебаунс)
  };

  // Токен для кодирования переносов строк (минимальный шанс коллизии с пользовательским текстом)
  const NL_TOKEN = '[[DG_NL]]';

  // --- СТИЛИ: чёткая контрастность + подсветка активного лайка/дизлайка ---
  const style = document.createElement('style');
  style.textContent = `
  .dg-notes, .dg-notes-published { font-family: inherit; margin: 12px 0; }
  .dg-notes label { display:block; margin-bottom: 6px; font-weight: 600; color: #fff !important; }
  .dg-notes textarea {     min-height: 110px;
    border-radius: 10px;
    outline: none; }
  .dg-notes textarea::placeholder{ color:#aaa; }
  .dg-notes .dg-row { display:flex; gap:8px; align-items:center; margin-top:8px; flex-wrap: wrap; }
  .dg-notes button { border: 1px solid #333;
    border-radius: 8px;
    padding: 8px 12px;
    background: #494949 !important;
    color: #fff !important;
    cursor: pointer; }
  .dg-notes button[disabled]{ opacity:.6; cursor:default; }
  .dg-status{ margin-left:8px; font-size:.9em; color:#ddd; }

  /* В опубликованных — белый текст на тёмном фоне */
  .dg-notes-published { margin-top: 10px; }
  .dg-notes-published details{ border: none;
    border-radius: 12px;
    padding: 8px 12px;
    background: #0d0d0d00;
    color: #fff; }
  .dg-notes-published summary{ cursor:pointer; font-weight:700; color:#fff; }
  .dg-list{ margin-top:8px; display:grid; gap:10px; }
  .dg-note{     border-radius: 12px;
    padding: 8px 12px;
    background: #383838;
    border: 1px solid #0000001c;
    color: #fff;
 }
  .dg-note .dg-actions{ display:flex; gap:10px; align-items:center; margin-top:6px; }
  .dg-like, .dg-dislike{ border:1px solid #444; padding:6px 10px; border-radius:10px; background:#1f1f1f57!important; color:#fff !important; cursor:pointer; outline:none !important; }
  .dg-like.dg-active, .dg-dislike.dg-active{ outline: 2px solid #666; box-shadow: 0 0 0 2px #222 inset; }
  .dg-like[aria-pressed="true"], .dg-dislike[aria-pressed="true"]{ outline: 2px solid #666; box-shadow: 0 0 0 2px #222 inset; }
  .dg-score{ font-size:.9em; color:#bbb; }
  /* Показ переносов строк в тексте заметок */
  .dg-note .dg-text{ white-space: pre-wrap; }

  /* Корректное встраивание в UL.info__other */
  li.dg-notes-li { list-style: none; margin-top: 10px; }
  `;
  document.head.appendChild(style);

  // --- УТИЛИТЫ ---
  const getNumericId = (raw) => {
    if (!raw) return null;
    const m = String(raw).match(/(\d+)(?!.*\d)/);
    return m ? m[1] : String(raw);
  };
  const localKey = (itemId) => `dg:note:${itemId}`;

  const escapeHTML = (s) => String(s)
    .replaceAll('&', '&')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');

  const score = (note) => {
    const likes = Number(note.likes) || 0, dislikes = Number(note.dislikes) || 0;
    const total = likes + dislikes;
    const ratio = total ? likes / total : 0;
    const diff = likes - dislikes;
    return { ratio, diff, total };
  };

  const debounce = (fn, delay) => { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), delay); }; };

  function clearStatusLater(node, ms = 1200) { setTimeout(() => { if (node && node.textContent === '✓ автосохранено') node.textContent = ''; }, ms); }

  // --- СТАБИЛЬНЫЙ userHash (один на браузер, 64 hex) ---
  function getUserHash(){
    const KEY = 'dg:userHash';
    try {
      let uh = localStorage.getItem(KEY);
      if (!uh) {
        if (window.crypto?.getRandomValues) {
          const arr = new Uint8Array(32); // 256 бит => 64 hex
          crypto.getRandomValues(arr);
          uh = Array.from(arr).map(b => b.toString(16).padStart(2,'0')).join('');
        } else {
          uh = 'uh-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
          // доводим до 64 символов
          while (uh.length < 64) uh += uh;
          uh = uh.slice(0,64);
        }
        localStorage.setItem(KEY, uh);
      }
      return uh;
    } catch {
      // на случай недоступного localStorage — volatile
      if (window.crypto?.getRandomValues) {
        const arr = new Uint8Array(32);
        crypto.getRandomValues(arr);
        return Array.from(arr).map(b => b.toString(16).padStart(2,'0')).join('');
      }
      return 'uh-' + Math.random().toString(36).slice(2).padEnd(64,'x').slice(0,64);
    }
  }
  const USER_HASH = getUserHash();

  // Универсальные помощники для ответов бэкенда
  async function readJsonSafe(res){
    try {
      const text = await res.text();
      if (!text) return null;
      try { return JSON.parse(text); } catch { return null; }
    } catch { return null; }
  }

  function friendlyHttpMessage(status, action){
    switch (Number(status)){
      case 429: return `Слишком часто ${action}. Подождите немного и попробуйте снова.`;
      case 400: return `Запрос отклонён: некорректные данные. Обновите страницу и повторите.`;
      case 401:
      case 403: return `Доступ к ${action} сейчас ограничен защитой сайта. Попробуйте позже.`;
      case 404: return `Не найдено. Возможно, заметка уже удалена.`;
      case 500:
      case 502:
      case 503:
      case 504: return `На сервере временная ошибка. Попробуйте чуть позже.`;
      default: return `Не удалось выполнить ${action} (HTTP ${status}). Попробуйте позже.`;
    }
  }

  function friendlyBackendError(error, action){
    const code = (error?.code || '').toUpperCase();
    const msg = error?.message;
    switch (code){
      case 'RATE_LIMITED': return `Слишком часто ${action}. Сделайте паузу 10–15 секунд и попробуйте снова.`;
      case 'VALIDATION_ERROR': return `Данные не прошли проверку на сервере: ${msg || 'проверьте ввод'}.`;
      case 'ALREADY_VOTED': return `Вы уже голосовали за эту заметку в этом браузере. Можно переключить голос, нажав другой значок.`;
      case 'NOT_FOUND': return `Заметка не найдена или уже удалена.`;
      case 'TOO_LONG': return `Слишком длинный текст. Сократите заметку и отправьте снова.`;
      case 'DUPLICATE': return `Такая заметка уже есть. Измените формулировку и попробуйте ещё раз.`;
      case 'SPAM': return `Похоже на спам. Измените формулировку и отправьте заново.`;
      default: return msg ? msg : `Не удалось выполнить ${action}. Попробуйте позже.`;
    }
  }

  function alertFromBackend(json, fallbackMsg, action){
    if (json && json.ok === false && json.error) {
      alert(friendlyBackendError(json.error, action) || fallbackMsg || 'Ошибка');
      return true;
    }
    return false;
  }

  // --- Источник правды про текущий itemId из модалки ---
  function getCurrentModalItemId(modal) {
    const idEl = modal.querySelector('.info__header-id');
    if (idEl) {
      const m = idEl.textContent && idEl.textContent.match(/ID\s*:\s*(\d+)/i);
      if (m) return m[1];
    }
    const img = modal.querySelector('.info__header-img');
    if (img?.src) {
      const m2 = img.src.match(/\/(\d+)\.(?:png|jpg|webp)/i);
      if (m2) return m2[1];
    }
    return null;
  }

  // --- КЭШ заметок + троттлинг запросов ---
  const NotesCache = new Map(); // itemId -> {notes:Array|null, lastFetch:number, nextAllowed:number, inFlight:Promise|null, lastError:boolean}

  async function fetchNotesThrottled(itemId) {
    itemId = String(getNumericId(itemId));
    const now = Date.now();
    const entry = NotesCache.get(itemId) || { notes:null, lastFetch:0, nextAllowed:0, inFlight:null, lastError:false };

    if (entry.inFlight) return entry.inFlight;
    // Возвращаем кэш или пустой массив, если ещё идёт бэкофф
    if (now < entry.nextAllowed) return entry.notes ?? [];

    const run = (async () => {
      const url = `${DG.API}/notes?id=${encodeURIComponent(itemId)}&userHash=${encodeURIComponent(USER_HASH)}`;
      try {
        const res = await fetch(url, { credentials: 'include', cache: 'no-store' });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await readJsonSafe(res);
        const notes = Array.isArray(data) ? data : (data?.notes || []);
        entry.notes = notes;
        entry.lastFetch = now;
        entry.nextAllowed = now + DG.RATE_TTL_MS; // нормальная частота
        entry.lastError = false;
        return entry.notes;
      } catch (e) {
        // CORS / сеть — ставим бэкофф подольше
        entry.nextAllowed = now + DG.ERROR_BACKOFF_MS;
        entry.lastError = true;
        return entry.notes ?? [];
      } finally {
        entry.inFlight = null;
        NotesCache.set(itemId, entry);
      }
    })();

    entry.inFlight = run;
    NotesCache.set(itemId, entry);
    return run;
  }

  // СВЕЖИЙ ЗАПРОС (для каждого раскрытия details) — обход троттлинга
  async function fetchNotesFresh(itemId) {
    itemId = String(getNumericId(itemId));
    const now = Date.now();
    const entry = NotesCache.get(itemId) || { notes:null, lastFetch:0, nextAllowed:0, inFlight:null, lastError:false };

    if (entry.inFlight) return entry.inFlight; // не дублируем конкурентные

    const run = (async () => {
      const url = `${DG.API}/notes?id=${encodeURIComponent(itemId)}&userHash=${encodeURIComponent(USER_HASH)}`;
      try {
        const res = await fetch(url, { credentials: 'include', cache: 'no-store' });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await readJsonSafe(res);
        const notes = Array.isArray(data) ? data : (data?.notes || []);
        entry.notes = notes;
        entry.lastFetch = now;
        entry.nextAllowed = now + DG.RATE_TTL_MS; // подстрахуем кэш для фона
        entry.lastError = false;
        return entry.notes;
      } catch (e) {
        entry.lastError = true;
        return entry.notes ?? [];
      } finally {
        entry.inFlight = null;
        NotesCache.set(itemId, entry);
      }
    })();

    entry.inFlight = run;
    NotesCache.set(itemId, entry);
    return run;
  }

  // --- РЕНДЕР ИНПУТА ЗАМЕТОК (АВТОСОХРАНЕНИЕ, БЕЗ КНОПКИ "СОХРАНИТЬ") ---
  function renderEditor(itemId) {
    const wrap = document.createElement('div');
    wrap.className = 'dg-notes';
    wrap.dataset.itemId = itemId;
    const areaId = `dg-note-area-${itemId}`;
    wrap.innerHTML = `
      <label for="${areaId}">Ваша заметка (автосохранение в браузере; можно опубликовать)</label>
      <textarea class="issue__input" id="${areaId}" placeholder="совет, синергия, баг, нюанс баланса и т. п."></textarea>
      <div class="dg-row">
        <button type="button" class="dg-publish">Опубликовать</button>
        <span class="dg-status" aria-live="polite"></span>
      </div>
    `;

    const ta = wrap.querySelector('textarea');
    const status = wrap.querySelector('.dg-status');

    // восстановление
    try {
      const saved = localStorage.getItem(localKey(itemId));
      if (saved) ta.value = saved;
    } catch {}

    // автосохранение с дебаунсом
    const doSave = () => {
      try {
        localStorage.setItem(localKey(itemId), ta.value.trim());
        status.textContent = '✓ автосохранено';
        clearStatusLater(status);
      } catch {}
    };
    const debouncedSave = debounce(doSave, DG.AUTOSAVE_DEBOUNCE_MS);
    ta.addEventListener('input', debouncedSave);

    // публикация (с подтверждающим диалогом)
    wrap.querySelector('.dg-publish').addEventListener('click', async () => {
      const raw = ta.value; // НЕ трогаем промежуточные переносы
      const normalized = raw.replace(/\r\n/g, '\n');
      const text = normalized.trim();
      if (!text) { status.textContent = 'Пустую заметку нельзя опубликовать'; setTimeout(()=>status.textContent='',1500); return; }

      // Закодируем переносы строк, чтобы бэкенд не «сплющил»
      const encodedText = text.replace(/\n/g, NL_TOKEN);

      // Диалог подтверждения
      const ok = window.confirm(
        'Перед публикацией: заметка должна быть полезна для всех игроков.\nЛичные заметки оставляйте личными.\n\nОпубликовать эту заметку?'
      );
      if (!ok) return;

      const btn = wrap.querySelector('.dg-publish');
      btn.disabled = true; status.textContent = 'Публикуем...';
      try {
        const res = await fetch(`${DG.API}/publish?userHash=${encodeURIComponent(USER_HASH)}`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          credentials: 'include',
          cache: 'no-store',
          body: JSON.stringify({ itemId: getNumericId(itemId), text: encodedText, userHash: USER_HASH })
        });
        const json = await readJsonSafe(res);
        if (!res.ok) {
          alert(friendlyHttpMessage(res.status, 'публикацию'));
          status.textContent = 'Ошибка публикации';
          return;
        }
        if (alertFromBackend(json, 'Ошибка публикации', 'публикацию')) {
          status.textContent = 'Ошибка публикации';
          return;
        }
        status.textContent = 'Отправлено на модерацию/публикацию';

        // ★ После успешной публикации — обновляем блок «Заметки игроков» (forceFresh)
        const container = wrap.closest('li.dg-notes-li') || document;
        const pub = container.querySelector('.dg-notes-published');
        if (pub) {
          const list = pub.querySelector('.dg-list');
          const count = pub.querySelector('.dg-count');
          if (list) safeRefreshList(list, itemId, count, true);
        }
      } catch (e) {
        alert('Не удалось связаться с сервером. Проверьте интернет и попробуйте позже.');
        status.textContent = 'Ошибка публикации';
      } finally { btn.disabled = false; setTimeout(()=>status.textContent='',1500); }
    });

    return wrap;
  }

  // --- СПИСОК ОПУБЛИКОВАННЫХ ЗАМЕТОК ---
  function renderPublishedBlock(itemId) {
    const wrap = document.createElement('div');
    wrap.className = 'dg-notes-published';
    wrap.dataset.itemId = itemId;
    wrap.innerHTML = `
      <details>
        <summary> Заметки игроков <span class="dg-count"></span></summary>
        <div class="dg-list" data-item-id="${itemId}"></div>
      </details>
    `;

    const details = wrap.querySelector('details');
    details.addEventListener('toggle', () => {
      if (details.open) {
        // ВСЕГДА свежий запрос при каждом раскрытии
        const list = wrap.querySelector('.dg-list');
        const count = wrap.querySelector('.dg-count');
        safeRefreshList(list, itemId, count, true /* forceFresh */);
      }
    });

    return wrap;
  }

  function renderNoteCard(note, itemId) {
    const card = document.createElement('div');
    card.className = 'dg-note';
    card.dataset.noteId = note.id || note.noteId || '';
    note.likes = Number(note.likes) || 0;
    note.dislikes = Number(note.dislikes) || 0;

    // Декодируем переносы, если бэкенд вернул плоскую строку с нашим токеном
    const rawText = String(note.text || '');
    const withNewlines = rawText.includes(NL_TOKEN) ? rawText.split(NL_TOKEN).join('\n') : rawText;

    card.innerHTML = `
      <div class="dg-text">${escapeHTML(withNewlines)}</div>
      <div class="dg-actions">
        <button type="button" class="dg-like" aria-pressed="false">👍</button>
        <span class="dg-score"></span>
        <button type="button" class="dg-dislike" aria-pressed="false">👎</button>
      </div>
    `;

    const scoreEl = card.querySelector('.dg-score');
    const likeBtn = card.querySelector('.dg-like');
    const dislikeBtn = card.querySelector('.dg-dislike');

    const updateScore = () => {
      const s = score(note);
      scoreEl.textContent = `${note.likes || 0} / ${note.dislikes || 0} · ${(s.ratio * 100).toFixed(0)}%`;
    };

    const votedKey = `dg:vote:${getNumericId(itemId)}:${card.dataset.noteId}`;
    const getVote = () => {
      const v = Number(localStorage.getItem(votedKey) || '0'); // ожидаем -1 или 1; 0 = не голосовал
      return (v === 1 || v === -1) ? v : 0;
    };

    const reflectButtons = (vote) => {
      const set = (btn, active) => {
        btn.classList.toggle('dg-active', !!active);
        btn.setAttribute('aria-pressed', active ? 'true' : 'false');
      };
      set(likeBtn, vote === 1);
      set(dislikeBtn, vote === -1);
    };

    // Локальное переключение строго между -1 и 1 (0 бэкенд не принимает)
    const applyLocalSwitch = (prev, next) => {
      if (prev === next) return;
      if (prev === 1) note.likes = Math.max(0, note.likes - 1);
      if (prev === -1) note.dislikes = Math.max(0, note.dislikes - 1);
      if (next === 1) note.likes += 1;
      if (next === -1) note.dislikes += 1;
      updateScore();
      reflectButtons(next);
      localStorage.setItem(votedKey, String(next));
    };

    updateScore();
    reflectButtons(getVote());

    let inFlight = false; // чтобы не спамить несколькими запросами подряд

    const sendVote = async (newVote, prevVote) => {
      try {
        const res = await fetch(`${DG.API}/rate?userHash=${encodeURIComponent(USER_HASH)}`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          credentials: 'include',
          cache: 'no-store',
          body: JSON.stringify({ itemId: getNumericId(itemId), noteId: card.dataset.noteId, vote: newVote, userHash: USER_HASH }) // только -1 или 1
        });
        const json = await readJsonSafe(res);
        if (!res.ok) {
          alert(friendlyHttpMessage(res.status, 'голосование'));
          // откатить локальные изменения
          applyLocalSwitch(newVote, prevVote);
          return;
        }
        if (alertFromBackend(json, 'Ошибка голосования', 'голосование')) {
          // откатить локальные изменения
          applyLocalSwitch(newVote, prevVote);
          return;
        }
      } catch (e) {
        alert('Не удалось связаться с сервером. Проверьте интернет и попробуйте позже.');
        // откатить локальные изменения
        applyLocalSwitch(newVote, prevVote);
      } finally {
        inFlight = false;
      }
    };

    likeBtn.addEventListener('click', () => {
      if (inFlight) return;
      const prev = getVote();
      const next = (prev === 1) ? -1 : 1; // 1 -> -1 -> 1
      inFlight = true;
      applyLocalSwitch(prev, next);
      sendVote(next, prev);
    });

    dislikeBtn.addEventListener('click', () => {
      if (inFlight) return;
      const prev = getVote();
      const next = (prev === -1) ? 1 : -1;
      inFlight = true;
      applyLocalSwitch(prev, next);
      sendVote(next, prev);
    });

    return card;
  }

  async function safeRefreshList(listEl, itemId, countEl, forceFresh = false) {
    if (!listEl) return;
    listEl.textContent = 'Загружаем заметки...';
    const notes = await (forceFresh ? fetchNotesFresh(itemId) : fetchNotesThrottled(itemId));

    // если последняя попытка была ошибочной и нет кэша — покажем дружелюбное сообщение
    const cache = NotesCache.get(String(getNumericId(itemId)));
    if (!notes || !notes.length) {
      listEl.textContent = cache?.lastError
        ? 'Не удалось загрузить заметки (CORS/сеть). Повторим позже.'
        : 'Пока нет заметок';
      if (countEl) countEl.textContent = notes && notes.length ? `(${notes.length})` : '(0)';
      return;
    }

    // сортировка
    notes.sort((a, b) => {
      const sa = score(a), sb = score(b);
      if (sb.ratio !== sa.ratio) return sb.ratio - sa.ratio;
      if (sb.diff !== sa.diff) return sb.diff - sa.diff;
      return (sb.total - sa.total);
    });

    listEl.textContent = '';
    if (countEl) countEl.textContent = `(${notes.length})`;
    notes.forEach(n => listEl.appendChild(renderNoteCard(n, itemId)));
  }

  // --- МОНТАЖ ВНУТРИ МОДАЛКИ #info ---
  function mountIntoModal(modal) {
    if (!modal) return;
    const itemId = getCurrentModalItemId(modal);
    if (!itemId) return;

    // если уже смонтировано под этот же id — просто убедимся, что блоки на месте
    if (modal.dataset.dgMountedForId === String(itemId)) {
      ensureBlocks(modal, itemId);
      return;
    }

    // новый item: очистка старых блоков
    modal.querySelectorAll('.dg-notes, .dg-notes-published, li.dg-notes-li').forEach(n => n.remove());
    modal.dataset.dgMountedForId = String(itemId);

    ensureBlocks(modal, itemId);
  }

  function ensureBlocks(modal, itemId) {
    const others = modal.querySelectorAll(DG.otherSel); // UL

    // Редактор + (под ним) опубликованные — как один блок внутри UL.info__other
    if (others.length) {
      others.forEach((ul) => {
        let li = ul.querySelector('li.dg-notes-li');
        if (!li) {
          li = document.createElement('li');
          li.className = 'dg-notes-li';
          const editor = renderEditor(itemId);
          li.appendChild(editor);
          const pub = renderPublishedBlock(itemId);
          li.appendChild(pub); // ЗАМЕТКИ ИГРОКОВ — ПОД ИНПУТОМ
          ul.appendChild(li);
        } else {
          // если li уже есть — убедимся, что внутри него есть editor и published на текущий itemId
          let editor = li.querySelector('.dg-notes');
          if (!editor) {
            editor = renderEditor(itemId);
            li.appendChild(editor);
          }
          let pub = li.querySelector('.dg-notes-published');
          if (!pub) {
            pub = renderPublishedBlock(itemId);
            li.appendChild(pub);
          } else if (pub.dataset.itemId !== String(itemId)) {
            pub.replaceWith(renderPublishedBlock(itemId));
          }
        }
      });
    } else {
      // Фолбэк: если UL нет — поместим в конец модалки (единым блоком editor + published)
      const fallback = modal.querySelector('.info__block') || modal;
      let container = fallback.querySelector('li.dg-notes-li, .dg-notes');
      if (!container) {
        const li = document.createElement('li');
        li.className = 'dg-notes-li';
        li.appendChild(renderEditor(itemId));
        li.appendChild(renderPublishedBlock(itemId));
        fallback.appendChild(li);
      } else {
        const parent = container.closest('li.dg-notes-li') || fallback;
        if (!parent.querySelector('.dg-notes')) parent.appendChild(renderEditor(itemId));
        if (!parent.querySelector('.dg-notes-published')) parent.appendChild(renderPublishedBlock(itemId));
      }
    }
  }

  // --- Наблюдаем за модалкой, но БЕЗ спама: дебаунс + кэш ---
  const modal = document.querySelector(DG.modalSel);
  const schedule = (() => { let t=null; return (fn)=>{ clearTimeout(t); t=setTimeout(fn, DG.OBS_DEBOUNCE_MS); } })();
  function tryMount() { mountIntoModal(modal); }

  if (modal) {
    const obs = new MutationObserver(() => schedule(tryMount));
    obs.observe(modal, { childList: true, subtree: true });
    if (document.readyState === 'loading') {
      window.addEventListener('DOMContentLoaded', tryMount, { once:true });
    } else {
      tryMount();
    }
  } else {
    const bodyObs = new MutationObserver(() => {
      const m = document.querySelector(DG.modalSel);
      if (m) {
        bodyObs.disconnect();
        const obs = new MutationObserver(() => schedule(() => mountIntoModal(m)));
        obs.observe(m, { childList: true, subtree: true });
        mountIntoModal(m);
      }
    });
    bodyObs.observe(document.documentElement || document.body, { childList: true, subtree: true });
  }
})();