bb-helper

Добавляет возможность сохранять шаблоны BB-кода и использовать их

// ==UserScript==
// @name         bb-helper
// @namespace    https://shikimori.one
// @version      1.0
// @description  Добавляет возможность сохранять шаблоны BB-кода и использовать их
// @author       LifeH
// @match        *://shikimori.org/*
// @match        *://shikimori.one/*
// @match        *://shikimori.me/*
// @grant        none
// @license MIT
// @require      https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.6/Sortable.min.js
// ==/UserScript==

(() => {
  'use strict';

  const defaultTemplates = [
    {
      "id": "folder-spoilers",
      "name": "Спойлеры",
      "folder": true,
      "templates": [
        {
          "id": "spoiler-is-fullwidth",
          "name": "Спойлер (fullwidth)",
          "code": "[spoiler=Спойлер is-fullwidth]Скрытый текст[/spoiler]"
        },
        {
          "id": "spoiler-is-fullwidth-is-centered",
          "name": "Спойлер (full+centered)",
          "code": "[spoiler=Спойлер is-fullwidth is-centered]Скрытый текст[/spoiler]"
        },
        {
          "id": "spoiler-is-fullwidth-is-centered-duble",
          "name": "Двойной спойлер",
          "code": "[div=cc-3]\n[div=right c-column mb-2 mr-4]\n[spoiler_block is-fullwidth is-centered]Скрытый текст[/spoiler_block]\n[/div]\n[div=left c-column mb-2 mr-4]\n[spoiler_block is-fullwidth is-centered]Скрытый текст[/spoiler_block]\n[/div]\nтекст\n[/div]"
        },
        {
          "id": "spoiler-is-fullwidth-is-centered-left",
          "name": "Спойлер справа",
          "code": "[div=cc-3]\n[div=right c-column mb-2 mr-4]\n[spoiler_block is-fullwidth is-centered]Скрытый текст[/spoiler_block]\n[/div]\nтекст\n[/div]"
        },
        {
          "id": "spoiler-is-fullwidth-is-centered-right",
          "name": "Спойлер слева",
          "code": "[div=cc-3]\n[div=left c-column mb-2 mr-4]\n[spoiler_block is-fullwidth is-centered]Скрытый текст[/spoiler_block]\n                [/div]\nтекст\n                [/div]"
        }
      ]
    },
    {
      "id": "folder-subheadlines",
      "name": "Заголовки",
      "folder": true,
      "templates": [
        {
          "id": "headline1",
          "name": "Большой заголовок",
          "code": "[div=headline m20]Большой заголовок[/div]"
        },
        {
          "id": "midheadline",
          "name": "Средний заголовок",
          "code": "[div=midheadline m20]Средний заголовок[/div]"
        },
        {
          "id": "subheadline1",
          "name": "Малый заголовок",
          "code": "[div=subheadline m20]Малый заголовок[/div]"
        },
        {
          "id": "subheadline2",
          "name": "дефолтный цвет",
          "code": "[div=subheadline]дефолтный цвет[/div]"
        },
        {
          "id": "subheadlines-with-tag",
          "name": "Заголовок с тегом",
          "code": "[div=headline d-flex align-items-center justify-content-between p-1]\n  [span]Заголовок с тегом[/span]\n  [div=b-anime_status_tag ongoing right m-0]Тег[/div]\n  [/div]"
        },
        {
          "id": "subheadline3",
          "name": "серый",
          "code": "[div=subheadline gray]серый[/div]"
        },
        {
          "id": "subheadline4",
          "name": "синий",
          "code": "[div=subheadline blue]синий[/div]"
        },
        {
          "id": "subheadline5",
          "name": "пыльносиний",
          "code": "[div=subheadline powderblue]пыльносиний[/div]"
        },
        {
          "id": "subheadline6",
          "name": "небосиний",
          "code": "[div=subheadline skyblue]небосиний[/div]"
        },
        {
          "id": "subheadline7",
          "name": "фиолетовый",
          "code": "[div=subheadline purple]фиолетовый[/div]"
        },
        {
          "id": "subheadline8",
          "name": "зелёный",
          "code": "[div=subheadline green]зелёный[/div]"
        },
        {
          "id": "subheadline9",
          "name": "жёлтый",
          "code": "[div=subheadline yellow]жёлтый[/div]"
        },
        {
          "id": "subheadline10",
          "name": "оранжевый",
          "code": "[div=subheadline orange]оранжевый[/div]"
        },
        {
          "id": "subheadline11",
          "name": "розовый",
          "code": "[div=subheadline pink]розовый[/div]"
        },
        {
          "id": "subheadline12",
          "name": "маджентовый",
          "code": "[div=subheadline magenta]маджентовый[/div]"
        },
        {
          "id": "subheadline13",
          "name": "коричневый",
          "code": "[div=subheadline brown]коричневый[/div]"
        }
      ]
    },
    {
      "id": "folder-tabs",
      "name": "Табы",
      "folder": true,
      "templates": [
        {
          "id": "tab1",
          "name": "Пример 1",
          "code": "[div=to-process data-dynamic=tabs]\n  [div=b-js-link active data-tab-switch]Tab 1[/div]\n  [div=b-js-link data-tab-switch]Tab 2[/div]\n  [div data-tab]Content 1[/div]\n  [div=hidden data-tab]Content 2[/div]\n  [/div]"
        },
        {
          "id": "tab2",
          "name": "Пример 2",
          "code": "[div=to-process data-dynamic=tabs]\n  [div=b-button active data-tab-switch]Tab 1[/div]\n  [div=b-button data-tab-switch]Tab 2[/div]\n  [div data-tab]Content 1[/div]\n  [div=hidden data-tab]Content 2[/div]\n  [/div]"
        },
        {
          "id": "tab3",
          "name": "Пример 3",
          "code": "[div=to-process data-dynamic=tabs]\n  [div=b-link_button inline active data-tab-switch]Tab 1[/div]\n  [div=b-link_button inline data-tab-switch]Tab 2[/div]\n  [div data-tab]Content 1[/div]\n  [div=hidden data-tab]Content 2[/div]\n  [/div]"
        },
        {
          "id": "tab4",
          "name": "Пример 4 вертикальный",
          "code": "[div=d-flex to-process data-dynamic=tabs]\n   [div=d-flex flex-column flex-shrink-0 mr-4]\n    [div=b-link_button active data-tab-switch]Tab 1[/div]\n    [div=b-link_button data-tab-switch]Tab 2[/div]\n    [div=b-link_button data-tab-switch]Tab 3[/div]\n    [div=b-link_button data-tab-switch]Tab 4[/div]\n   [/div]\n   [div=p-2 flex-fill data-tab]Content 1[/div]\n   [div=p-2 flex-fill hidden data-tab]Content 2[/div]\n   [div=p-2 flex-fill hidden data-tab]Content 3[/div]\n   [div=p-2 flex-fill hidden data-tab]Content 4[/div]\n  [/div]"
        }
      ]
    },
    {
      "id": "folder-image-and-vid",
      "name": "Изображения ",
      "folder": true,
      "templates": [
        {
          "id": "img1",
          "name": "Абзац с большой картинкой (слева)",
          "code": "[div=cc-3]\n    [div=c-column mb-2 mr-4]\n    [center][img w=360]https://i.imgur.com/aGMILHR.jpg[/img][/center]\n    [/div]\n    Текст\n    [div=clearfix][/div]\n    [/div]"
        },
        {
          "id": "img2",
          "name": "Абзац с большой картинкой (справа)",
          "code": "[div=cc-3]\n[div=c-column right mb-2 ml-4 mr-0]\n[center][img w=360]https://i.imgur.com/aGMILHR.jpg[/img][/center]\n[/div]\nТекст\n[div=clearfix][/div]\n[/div]"
        },
        {
          "id": "img3",
          "name": "Колонка с цитатой-подписью к изображению(слева)",
          "code": "[center][div=left b-quote d-inline-block p-2 pr-3 pl-3 m-0]\n    [img width=360]https://i.imgur.com/aGMILHR.jpg[/img]\n    Подпись\n    [/div][/center]"
        },
        {
          "id": "img4",
          "name": "Колонка с цитатой-подписью к изображению(справа)",
          "code": "[center][div=right b-quote d-inline-block p-2 pr-3 pl-3 m-0]\n    [img width=360]https://i.imgur.com/aGMILHR.jpg[/img]\n    Подпись\n    [/div][/center]"
        },
        {
          "id": "vid2",
          "name": "Колонка с цитатой-подписью к видео(слева)",
          "code": "[center][div=lift b-quote d-inline-block p-2 pr-3 pl-3 m-0]\nhttps://youtu.be/BL0YK8jryK0\n«Trust in you» by [sweet ARMS]\n[/div][/center]"
        },
        {
          "id": "vid1",
          "name": "Колонка с цитатой-подписью к видео(справа)",
          "code": "[center][div=right b-quote d-inline-block p-2 pr-3 pl-3 m-0]\nhttps://youtu.be/BL0YK8jryK0\n«Trust in you» by [sweet ARMS]\n[/div][/center]"
        },
        {
          "id": "char1",
          "name": "Блок персонажа(слева)",
          "code": "[div=left mb-2 mr-4]\n[character=496][img w=120 no-zoom]https://shikimori.one/system/characters/preview/496.jpg[/img][br] Сиро Эмия [/character]\n[/div]"
        },
        {
          "id": "char2",
          "name": "Блок персонажа(справа)",
          "code": "[div=right mb-2 mr-4]\n[character=496][img w=120 no-zoom]https://shikimori.one/system/characters/preview/496.jpg[/img][br] Сиро Эмия [/character]\n[/div]"
        },
        {
          "id": "char3",
          "name": "Колонка с цитатой-подписью к паре персонажей(слева)",
          "code": "[center][div=left b-quote d-inline-block p-2 pr-3 pl-3 m-0][div=d-flex]\n[character=496][img no-zoom]https://shikimori.one/system/characters/preview/496.jpg[/img][br]Сиро Эмия[/character]\n[character=496][img no-zoom]https://shikimori.one/system/characters/preview/497.jpg[/img][br]Сэйбер[/character]\n[/div][/div][/center]"
        },
        {
          "id": "char4",
          "name": "Колонка с цитатой-подписью к паре персонажей(справа)",
          "code": "[center][div=right b-quote d-inline-block p-2 pr-3 pl-3 m-0][div=d-flex]\n[character=496][img no-zoom]https://shikimori.one/system/characters/preview/496.jpg[/img][br]Сиро Эмия[/character]\n[character=496][img no-zoom]https://shikimori.one/system/characters/preview/497.jpg[/img][br]Сэйбер[/character]\n[/div][/div][/center]"
        }
      ]
    },
    {
      "id": "разное-1742059718284",
      "name": "Разное",
      "folder": true,
      "templates": [
        {
          "id": "anime_status_tag-review-positive-1742060047874",
          "name": "anime_status_tag review-positive",
          "code": "[div=b-anime_status_tag review-positive]TEST[/div]"
        },
        {
          "id": "anime_status_tag-review-neutral-1742059741253",
          "name": "anime_status_tag review-neutral",
          "code": "[div=b-anime_status_tag review-neutral]TEST[/div]"
        },
        {
          "id": "anime_status_tag-review-negative-1742059863925",
          "name": "anime_status_tag review-negative",
          "code": "[div=b-anime_status_tag review-negative]TEST[/div]"
        },
        {
          "id": "anime_status_tag-collection-1742059810407",
          "name": "anime_status_tag collection",
          "code": "[div=b-anime_status_tag collection]TEST[/div]"
        },
        {
          "id": "b-anime_status_tag-news-1742059769851",
          "name": "b-anime_status_tag news",
          "code": "[div=b-anime_status_tag news]TEST[/div]"
        },
        {
          "id": "anime_status_tag-censored-1742059787398",
          "name": "anime_status_tag censored",
          "code": "[div=b-anime_status_tag censored]TEST[/div]"
        },
        {
          "id": "anime_status_tag-ongoing-1742059888390",
          "name": "anime_status_tag ongoing",
          "code": "[div=b-anime_status_tag ongoing]TEST[/div]"
        },
        {
          "id": "anime_status_tag-offtopic-1742059898041",
          "name": "anime_status_tag offtopic",
          "code": "[div=b-anime_status_tag offtopic]TEST[/div]"
        },
        {
          "id": "anime_status_tag-critique-1742059912010",
          "name": "anime_status_tag critique",
          "code": "[div=b-anime_status_tag critique]TEST[/div]"
        },
        {
          "id": "anime_status_tag-contest-1742059922183",
          "name": "anime_status_tag contest",
          "code": "[div=b-anime_status_tag contest]TEST[/div]"
        },
        {
          "id": "anime_status_tag-other-1742060078178",
          "name": "anime_status_tag other",
          "code": "[div=b-anime_status_tag other]TEST[/div]"
        },
        {
          "id": "footer_vote-1742060178456",
          "name": "footer_vote",
          "code": "[div=b-footer_vote]\n[div=star]\n[/div]\n[div=notice]Этот отзыв полезен?[/div]\n[/div]"
        },
        {
          "id": "hot_topics-v2-1742060198003",
          "name": "hot_topics-v2",
          "code": "[div=b-hot_topics-v2 center m-0]\n[div=subject]\n1\n[/div]\n[div=subject]\n2\n[/div]\n[div=subject]\n3\n[/div]\n[div=subject]\n4\n[/div]\n[div=subject]\n5\n[/div]\n[div=subject]\n6\n[/div]\n[/div]"
        },
        {
          "id": "link_button-dark-active-1742060231034",
          "name": "link_button dark active",
          "code": "[div=b-link_button dark active]TEST[/div]"
        },
        {
          "id": "link_button-dark-create-topic-1742060257800",
          "name": "link_button dark create-topic",
          "code": "[div=b-link_button dark create-topic]test[/div]"
        }
      ]
    }
  ];

const bbCodeStorage = new Map();
let csrfToken = '';

// ====== сторедж ======
function getTemplates() {
  let stored = localStorage.getItem('bbTemplates');
  if (stored) {
    try {
      return JSON.parse(stored);
    } catch (e) {
      return defaultTemplates;
    }
  }
  return defaultTemplates;
}
function saveTemplates(tpls) {
  localStorage.setItem('bbTemplates', JSON.stringify(tpls));
}
function generateId(name) {
  return name.toLowerCase().replace(/\s+/g, '-') + '-' + Date.now();
}

function initStorage() {
  bbCodeStorage.clear();
  const tpls = getTemplates();
  function addTemplates(arr) {
    arr.forEach(item => {
      if (item.folder) {
        if (Array.isArray(item.templates)) {
          addTemplates(item.templates);
        }
      } else {
        bbCodeStorage.set(item.id, item.code);
      }
    });
  }
  addTemplates(tpls);
}

// ====== превью ======
async function fetchPreview(combinedText, attempt = 1) {
  try {
    const response = await fetch("https://shikimori.one/api/shiki_editor/preview", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-CSRF-Token": csrfToken
      },
      body: JSON.stringify({ text: combinedText })
    });
    if (!response.ok) {
      if (response.status === 429) {
        throw new Error('429');
      } else {
        throw new Error(`status: ${response.status}`);
      }
    }
    const data = await response.json();
    return data.html;
  } catch (error) {
    if (error.message === '429' && attempt < 5) {
      console.warn(`[BB-Helper] 429, ждем 5 сек (попытка ${attempt})...`);
      await new Promise(resolve => setTimeout(resolve, 5000));
      return fetchPreview(combinedText, attempt + 1);
    }
    throw error;
  }
}
// ====== мейн ======
const init = () => {
  csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
  createModal();
  addMenuButton();
  initStorage();
  setupHandlers();
};

//модалка
let currentModalFolder = null;
async function createModal() {
  const modal = document.createElement('div');
  modal.id = 'bb-helper-modal';
  modal.style.cssText = `
    display: none;
    position: fixed;
    top: 60px;
    right: 20px;
    background: #fff;
    z-index: 99999;
    box-shadow: 0 4px 20px rgba(0,0,0,0.1);
    border-radius: 8px;
    width: 400px;
    height: 300px;
    overflow: hidden;
    border: 1px solid #e0e0e0;
    font-family: system-ui;
    min-width: 300px;
    min-height: 200px;
  `;

  modal.innerHTML = `
    <div id="bb-modal-header" style="position: sticky; top: 0; background: #fff; z-index: 1000; display: flex; justify-content: space-between; align-items: center; padding: 5px 10px; border-bottom: 1px solid #e0e0e0;">
      <button id="bb-modal-back" style="display:none; padding: 5px 10px; background: #ddd; border: none; border-radius: 4px; cursor: pointer;">← Назад</button>
      <div id="bb-modal-breadcrumb" style="font-size: 14px;">Корневой уровень</div>
      <button id="bb-helper-close" style="background: none; border: none; font-size: 24px; cursor: pointer;">×</button>
    </div>
    <div id="bb-modal-content" style="padding: 10px; overflow: auto; height: calc(100% - 50px);">
      <div id="bb-templates-list"></div>
    </div>
    <!-- Ручка для изменения размеров (левый нижний угол) -->
    <div id="bb-modal-resizer-corner" style="
    position: absolute;
    bottom: 0;
    left: 0;
    width: 15px;
    height: 15px;
    cursor: nesw-resize;
    z-index: 101;"></div>
  `;

  document.body.appendChild(modal);

  document.getElementById('bb-helper-close').addEventListener('click', () => {
    modal.style.display = 'none';
    currentModalFolder = null;
  });

  document.getElementById('bb-modal-back').addEventListener('click', () => {
    currentModalFolder = null;
    loadTemplates();
  });

  const resizerCorner = document.getElementById('bb-modal-resizer-corner');
  let isResizing = false;
  let lastDownX = 0, lastDownY = 0;

  resizerCorner.addEventListener('mousedown', (e) => {
    isResizing = true;
    lastDownX = e.clientX;
    lastDownY = e.clientY;
    e.preventDefault();
  });

  document.addEventListener('mousemove', (e) => {
    if (!isResizing) return;
    const dx = lastDownX - e.clientX;
    const dy = e.clientY - lastDownY;
    modal.style.width = modal.offsetWidth + dx + 'px';
    modal.style.height = modal.offsetHeight + dy + 'px';
    lastDownX = e.clientX;
    lastDownY = e.clientY;
  });

  document.addEventListener('mouseup', () => {
    isResizing = false;
  });

  await loadTemplates();
}

// шаблоны
async function loadTemplates() {
  const list = document.getElementById('bb-templates-list');
  const breadcrumb = document.getElementById('bb-modal-breadcrumb');
  const backBtn = document.getElementById('bb-modal-back');
  let items;
  if (currentModalFolder === null) {
    items = getTemplates();
    breadcrumb.textContent = '/';
    backBtn.style.display = 'none';
  } else {
    items = currentModalFolder.templates || [];
    breadcrumb.textContent = currentModalFolder.name;
    backBtn.style.display = 'block';
  }

  // сбор в 1
  const flat = [];
  function collectTemplates(arr) {
    arr.forEach(item => {
      if (!item.folder) {
        flat.push(item);
      }
    });
  }
  collectTemplates(items);
  const previewMap = {};

  if (flat.length > 0) {
    const combinedText = flat
      .map(template => template.code.trim())
      .join("\n__SPLIT__\n");
    try {
      const combinedHtml = await fetchPreview(combinedText);
      const parts = combinedHtml.split("__SPLIT__");
      for (let i = 0; i < flat.length; i++) {
        previewMap[flat[i].id] = parts[i];
      }
    } catch (error) {
      console.error("[BB-Helper] Ошибка предпросмотра:", error);
      flat.forEach(template => {
        previewMap[template.id] = 'Ошибка загрузки превью';
      });
    }
  }

  list.innerHTML = '';
  items.forEach(item => {
    const div = document.createElement('div');
    div.style.padding = '8px';
    div.style.border = '1px solid #ccc';
    div.style.borderRadius = '4px';
    div.style.marginBottom = '6px';
    div.style.cursor = item.folder ? 'pointer' : 'grab';

    if (item.folder) {
      // Папка
      div.innerHTML = `<strong>📁 ${item.name}</strong>`;
      div.addEventListener('click', () => {
        currentModalFolder = item;
        loadTemplates();
      });
    } else {
      // Обычный шаблон
      div.innerHTML = `
        <div class="preview-container"
             style="min-width: 300px; overflow-x: auto; padding: 5px;">
          <div style="font-weight: 500; margin-bottom: 5px;">${item.name}</div>
          <div style="font-size:12px; color:#666;">${previewMap[item.id] || ''}</div>
        </div>
      `;
      div.addEventListener('click', () => {});
      div.draggable = true;
      div.addEventListener('dragstart', (e) => {
        const code = bbCodeStorage.get(item.id);
        e.dataTransfer.setData('text/plain', code);
        e.dataTransfer.effectAllowed = 'copy';
      });
    }

    list.appendChild(div);
  });
}

const addMenuButton = () => {
  const createButton = () => {
    const button = document.createElement('button');
    button.title = "Шаблоны";
    button.classList.add("icon", "icon-preview", "is-button");
    button.innerHTML = `<span style="display:flex;align-items:center;">
          <svg width="16" height="16" viewBox="0 0 24 24" style="fill:currentColor">
            <path d="M14 17H7V15H14V17M17 13H7V11H17V13M17 9H7V7H17V9M19 3H5C3.89 3 3 3.89 3 5V19C3 20.11 3.89 21 5 21H19C20.11 21 21 20.11 21 19V5C21 3.89 20.11 3 19 3Z"/>
          </svg>
          Шаблоны
        </span>`;

    button.style.cssText = `
      display: inline-flex;
      align-items: center;
      cursor: pointer;
      font-size: 13px;
      height: 19px;
      border: none;
      background: none;
      padding: 2px 4px;
    `;

    button.addEventListener('click', (e) => {
      e.preventDefault();
      const modal = document.getElementById('bb-helper-modal');
      if (!modal) return;
      if (modal.style.display === 'block') {
        modal.style.display = 'none';
        currentModalFolder = null;
      } else {
        currentModalFolder = null;
        modal.style.display = 'block';
        loadTemplates();
      }
    });

    return button;
  };

  const tryAddButton = () => {
    const menuGroup = document.querySelector('.menu_group-controls');
    if (menuGroup) {
      menuGroup.appendChild(createButton());
      return true;
    }
    return false;
  };

  if (tryAddButton()) return;

  const observer = new MutationObserver((mutations, obs) => {
    if (tryAddButton()) {
      obs.disconnect();
    }
  });

  observer.observe(document.body, { childList: true, subtree: true });
};

//drop
const setupHandlers = () => {
  const editorContainer = document.querySelector('.editor-container');
  if (!editorContainer) return;
  const isCodeMode = editorContainer.classList.contains('is-source');
  if (isCodeMode) {
    const textarea = editorContainer.querySelector('textarea.ProseMirror');
    if (!textarea) return;
    editorContainer.addEventListener('dragover', handleDragOver);
    editorContainer.addEventListener('drop', (e) => {
      e.preventDefault();
      e.stopPropagation();
      const code = e.dataTransfer.getData('text/plain');
      if (code) {
        insert(code, textarea, true);
      }
    });
  } else {
    const editor = editorContainer.querySelector('.ProseMirror');
    if (!editor) return;
    editor.addEventListener('dragover', handleDragOver);
    editor.addEventListener('drop', (e) => {
      e.preventDefault();
      e.stopPropagation();
      const code = e.dataTransfer.getData('text/plain');
      if (code) {
        insert(code, editor, false);
      }
    });
  }
};

const handleDragOver = (e) => {
  e.preventDefault();
  e.dataTransfer.dropEffect = 'copy';
};

//вставка
const insert= (code, editor, isCodeMode) => {
  if (isCodeMode) {
    const start = editor.selectionStart;
    const end = editor.selectionEnd;
    const newValue = editor.value.slice(0, start) + code + editor.value.slice(end);
    editor.value = newValue;
    editor.selectionStart = editor.selectionEnd = start + code.length;
    editor.dispatchEvent(new Event('input', { bubbles: true }));
    editor.dispatchEvent(new Event('change', { bubbles: true }));
  } else {
    const selection = window.getSelection();
    if (!selection.rangeCount) return;
    const range = selection.getRangeAt(0);
    range.deleteContents();
    const frag = document.createDocumentFragment();
    code.split('\n').forEach((line, index) => {
      if (index > 0) {
        frag.appendChild(document.createElement('br'));
      }
      frag.appendChild(document.createTextNode(line));
    });
    range.insertNode(frag);
    range.collapse(false);
    selection.removeAllRanges();
    selection.addRange(range);
    editor.dispatchEvent(new InputEvent('input', { bubbles: true }));
  }
};

// ====== GUI ======
let isEditing = false;
let editingIndex = null;

function updateParentSelect() {
  const select = document.getElementById('tpl-parent');
  if (!select) return;
  select.innerHTML = '<option value="none">Без папки</option>';
  const tpls = getTemplates();
  tpls.forEach(item => {
    if (item.folder) {
      const option = document.createElement('option');
      option.value = item.id;
      option.textContent = item.name;
      select.appendChild(option);
    }
  });
  select.disabled = false;
}

function templateGUI() {
  const settingsBlock = document.querySelector('.block.edit-page.misc');
  if (!settingsBlock) return;
  if (document.querySelector('.bb-template-config')) return;

  let container = document.createElement('div');
  container.className = 'bb-template-config';
  container.style.padding = '20px';
  container.style.border = '1px solid #ccc';
  container.style.marginTop = '20px';
  container.style.background = '#f9f9f9';
  container.style.borderRadius = '8px';
  container.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
  container.innerHTML = `
    <h3 style="margin-bottom: 20px; text-align: center;">Настройка шаблонов</h3>
    <div style="display: flex; flex-direction: column; gap: 10px;">
      <input type="text" id="tpl-name" placeholder="Название" style="padding: 8px; border-radius: 4px; border: 1px solid #ccc;">
      <textarea id="tpl-code" placeholder="BB-код шаблона (оставьте пустым для папки)" style="padding: 8px; border-radius: 4px; border: 1px solid #ccc; resize: vertical;"></textarea>
      <div style="display: flex; align-items: center; gap: 10px;">
        <div class="toggle-switch" style="position: relative; width: 40px; height: 20px;">
          <input type="checkbox" id="tpl-folder" style="opacity: 0; width: 100%; height: 100%; margin: 0; padding: 0; position: absolute; z-index: 2; cursor: pointer;">
          <span class="slider" style="
            position: absolute;
            cursor: pointer;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background-color: #ccc;
            transition: .4s;
            border-radius: 20px;
          "></span>
        </div>
        <label for="tpl-folder" style="font-size:14px;">Это папка</label>
      </div>
      <div style="display: flex; align-items: center; gap: 10px;">
        <label for="tpl-parent" style="font-size:14px;">Родительская папка:</label>
        <select id="tpl-parent" style="padding: 8px; border-radius: 4px; border: 1px solid #ccc;">
          <option value="none">Без папки</option>
        </select>
      </div>
      <button id="tpl-add" style="padding: 10px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;">Добавить шаблон</button>
    </div>
    <div id="tpl-list" style="margin-top: 20px; display: flex; flex-direction: column; gap: 10px;"></div>
  `;
  settingsBlock.appendChild(container);

  let resetBtn = document.createElement('button');
  resetBtn.id = 'tpl-reset';
  resetBtn.textContent = 'Сбросить данные';
  resetBtn.style.cssText = `
    margin-top: 10px;
    padding: 10px;
    background-color: #F44336;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
  `;
  container.appendChild(resetBtn);
  resetBtn.addEventListener('click', () => {
    if (confirm('Внимание: Все шаблоны будут сброшены на стандартные. Продолжить?')) {
      saveTemplates(defaultTemplates);
      updateTemplatesList();
      updateParentSelect();
      initStorage();
      clearTemplateForm();
      alert('Данные сброшены до стандартных.');
    }
  });

  let style = document.createElement('style');
  style.textContent = `
    .toggle-switch input:checked + .slider {
      background-color: #4CAF50;
    }
    .toggle-switch .slider:before {
      position: absolute;
      content: "";
      height: 16px;
      width: 16px;
      left: 2px;
      bottom: 2px;
      background-color: white;
      transition: .4s;
      border-radius: 50%;
    }
    .toggle-switch input:checked + .slider:before {
      transform: translateX(20px);
    }
  `;
  document.head.appendChild(style);

  document.getElementById('tpl-folder').addEventListener('change', (e) => {
    document.getElementById('tpl-parent').disabled = e.target.checked;
  });

  document.getElementById('tpl-add').addEventListener('click', () => {
    let name = document.getElementById('tpl-name').value.trim();
    let code = document.getElementById('tpl-code').value.trim();
    let isFolder = document.getElementById('tpl-folder').checked;
    let parentId = document.getElementById('tpl-parent').value;
    if (!name || (!isFolder && !code)) {
      alert('Пожалуйста, заполните поля.');
      return;
    }
    let allTpls = getTemplates();
    let newElement = isFolder
      ? { id: generateId(name), name, folder: true, templates: [] }
      : { id: generateId(name), name, code };

    if (isEditing && editingIndex !== null) {
      if (typeof editingIndex === 'object') {
        let currentFolder = allTpls[editingIndex.parent];
        let currentTemplate = currentFolder.templates[editingIndex.child];
        let updatedElement = { ...currentTemplate, name, code };
        if (parentId && parentId !== "none") {
          if (parentId === currentFolder.id) {
            currentFolder.templates[editingIndex.child] = updatedElement;
          } else {
            currentFolder.templates.splice(editingIndex.child, 1);
            let targetFolder = allTpls.find(item => item.folder && item.id === parentId);
            if (targetFolder) {
              targetFolder.templates.push(updatedElement);
            } else {
              allTpls.push(updatedElement);
            }
          }
        } else {
          currentFolder.templates.splice(editingIndex.child, 1);
          allTpls.push(updatedElement);
        }
      } else {
        if (allTpls[editingIndex].folder) {
          allTpls[editingIndex].name = name;
        } else {
          if (parentId && parentId !== "none") {
            let elem = allTpls.splice(editingIndex, 1)[0];
            newElement = { ...elem, name, code };
            let folder = allTpls.find(item => item.folder && item.id === parentId);
            if (folder) {
              folder.templates.push(newElement);
            } else {
              allTpls.push(newElement);
            }
          } else {
            allTpls[editingIndex] = newElement;
          }
        }
      }
      isEditing = false;
      editingIndex = null;
      document.getElementById('tpl-add').textContent = 'Добавить шаблон';
      document.getElementById('tpl-folder').disabled = false;
      document.getElementById('tpl-parent').disabled = false;
    } else {
      if (parentId && parentId !== "none") {
        let folder = allTpls.find(item => item.folder && item.id === parentId);
        if (folder) {
          folder.templates.push(newElement);
        } else {
          allTpls.push(newElement);
        }
      } else {
        allTpls.push(newElement);
      }
    }
    saveTemplates(allTpls);
    updateTemplatesList();
    clearTemplateForm();
    initStorage();
  });

  updateTemplatesList();
  updateParentSelect();
}

function clearTemplateForm() {
  document.getElementById('tpl-name').value = '';
  document.getElementById('tpl-code').value = '';
  document.getElementById('tpl-folder').checked = false;
  document.getElementById('tpl-folder').disabled = false;
  document.getElementById('tpl-parent').value = 'none';
  document.getElementById('tpl-parent').disabled = false;
  document.getElementById('tpl-add').textContent = 'Добавить шаблон';
  isEditing = false;
  editingIndex = null;
}

function updateTemplatesList() {
  let list = document.getElementById('tpl-list');
  list.innerHTML = '';
  let tpls = getTemplates();
  tpls.forEach((item, index) => {
    if (item.folder) {
      let folderDiv = document.createElement('div');
      folderDiv.className = 'tpl-folder';
      folderDiv.setAttribute('data-index', index);
      folderDiv.style.border = '1px solid #aaa';
      folderDiv.style.padding = '10px';
      folderDiv.style.marginBottom = '10px';
      folderDiv.style.background = '#eee';
      folderDiv.innerHTML = `
        <div class="folder-header" style="display:flex; justify-content: space-between; align-items: center; cursor: pointer;">
          <span class="folder-icon" style="font-size:18px; margin-right:5px;">📂</span>
          <strong>${item.name}</strong>
          <div>
            <button onclick="editTemplate(${index})" style="padding: 5px 10px; background-color: #FFC107; color: white; border: none; border-radius: 4px; cursor: pointer;">Редактировать</button>
            <button onclick="deleteTemplate(${index})" style="padding: 5px 10px; background-color: #F44336; color: white; border: none; border-radius: 4px; cursor: pointer;">Удалить</button>
          </div>
        </div>
        <div class="folder-templates" style="margin-top: 10px; padding-left: 10px; border-left: 2px dashed #ccc;"></div>
      `;
      let inner = folderDiv.querySelector('.folder-templates');
      if (item.templates && item.templates.length) {
        item.templates.forEach((tpl, tplIndex) => {
          let tplDiv = document.createElement('div');
          tplDiv.className = 'tpl-item';
          tplDiv.style.display = 'flex';
          tplDiv.style.justifyContent = 'space-between';
          tplDiv.style.alignItems = 'center';
          tplDiv.style.padding = '5px';
          tplDiv.style.border = '1px solid #ccc';
          tplDiv.style.borderRadius = '4px';
          tplDiv.style.marginBottom = '5px';
          tplDiv.innerHTML = `
            <span>${tpl.name}</span>
            <div>
              <button onclick="editTemplate(${index}, ${tplIndex}, true)" style="padding: 3px 8px; background-color: #FFC107; color: white; border: none; border-radius: 4px; cursor: pointer;">Редактировать</button>
              <button onclick="deleteTemplate(${index}, ${tplIndex}, true)" style="padding: 3px 8px; background-color: #F44336; color: white; border: none; border-radius: 4px; cursor: pointer;">Удалить</button>
            </div>
          `;
          inner.appendChild(tplDiv);
        });
        new Sortable(inner, {
          group: { name: 'templates', pull: false, put: false },
          animation: 150,
          onEnd: function (evt) {
            let allTpls = getTemplates();
            let folderId = evt.from.closest('.tpl-folder').getAttribute('data-index');
            let folder = allTpls[folderId];
            if (folder && folder.templates) {
              let movedItem = folder.templates.splice(evt.oldIndex, 1)[0];
              folder.templates.splice(evt.newIndex, 0, movedItem);
              allTpls[folderId] = folder;
              saveTemplates(allTpls);
              updateTemplatesList();
              initStorage();
            }
          }
        });
      } else {
        inner.innerHTML = `<div style="font-size:13px; color:#777;">(Пустая папка)</div>`;
      }
      list.appendChild(folderDiv);
    } else {
      let card = document.createElement('div');
      card.style.display = 'flex';
      card.style.justifyContent = 'space-between';
      card.style.alignItems = 'center';
      card.style.padding = '10px';
      card.style.border = '1px solid #ccc';
      card.style.borderRadius = '8px';
      card.style.background = '#fff';
      card.style.marginBottom = '10px';
      card.innerHTML = `
        <span>${item.name}</span>
        <div>
          <button onclick="editTemplate(${index})" style="padding: 5px 10px; background-color: #FFC107; color: white; border: none; border-radius: 4px; cursor: pointer;">Редактировать</button>
          <button onclick="deleteTemplate(${index})" style="padding: 5px 10px; background-color: #F44336; color: white; border: none; border-radius: 4px; cursor: pointer;">Удалить</button>
        </div>
      `;
      list.appendChild(card);
    }
  });
  new Sortable(list, {
    group: { name: 'templates', pull: false, put: false },
    animation: 150,
    onEnd: function (evt) {
      let tpls = getTemplates();
      let movedItem = tpls.splice(evt.oldIndex, 1)[0];
      tpls.splice(evt.newIndex, 0, movedItem);
      saveTemplates(tpls);
      updateTemplatesList();
      initStorage();
    }
  });
  updateParentSelect();
}

// редактирование/удаление
function editTemplate(parentIndex, tplIndex, isNested) {
  let tpls = getTemplates();
  if (isNested) {
    let tpl = tpls[parentIndex].templates[tplIndex];
    document.getElementById('tpl-name').value = tpl.name;
    document.getElementById('tpl-code').value = tpl.code;
    document.getElementById('tpl-folder').checked = false;
    //в папке можно изменять папку (шаблон)
    document.getElementById('tpl-folder').disabled = true; // тип менять нельзя
    document.getElementById('tpl-parent').disabled = false;
    document.getElementById('tpl-parent').value = tpls[parentIndex].id;
    isEditing = true;
    editingIndex = { parent: parentIndex, child: tplIndex };
    document.getElementById('tpl-add').textContent = 'Сохранить изменения';
  } else {
    let item = tpls[parentIndex];
    document.getElementById('tpl-name').value = item.name;
    if (item.folder) {
      document.getElementById('tpl-code').value = '';
      document.getElementById('tpl-folder').checked = true;
      document.getElementById('tpl-folder').disabled = true;
      document.getElementById('tpl-parent').value = 'none';
      document.getElementById('tpl-parent').disabled = true;
    } else {
      document.getElementById('tpl-code').value = item.code;
      document.getElementById('tpl-folder').checked = false;
      document.getElementById('tpl-folder').disabled = true;
      document.getElementById('tpl-parent').value = 'none';
      document.getElementById('tpl-parent').disabled = false;
    }
    isEditing = true;
    editingIndex = parentIndex;
    document.getElementById('tpl-add').textContent = 'Сохранить изменения';
  }
}
function deleteTemplate(parentIndex, tplIndex, isNested) {
  if (confirm('Вы уверены, что хотите удалить этот шаблон?')) {
    let tpls = getTemplates();
    if (isNested) {
      tpls[parentIndex].templates.splice(tplIndex, 1);
    } else {
      tpls.splice(parentIndex, 1);
    }
    saveTemplates(tpls);
    updateTemplatesList();
    initStorage();
  }
}
window.editTemplate = editTemplate;
window.deleteTemplate = deleteTemplate;

function ready(fn) {
  document.addEventListener('page:load', fn);
  document.addEventListener('turbolinks:load', fn);
  if (document.attachEvent ? document.readyState === "complete" : document.readyState !== "loading") {
    fn();
  } else {
    document.addEventListener('DOMContentLoaded', fn);
  }
}
ready(templateGUI);
ready(init);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址