Eblo Modal Viewer

Tampermonkey-скрипт для eblo.id: открывает посты в модалке поверх ленты, поддерживает просмотр фото и видео, навигацию между постами, закрытие по клику вне окна и переход к оригинальному посту.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Eblo Modal Viewer
// @namespace    https://greasyfork.org/users/1589566
// @version      5.2.0
// @description  Tampermonkey-скрипт для eblo.id: открывает посты в модалке поверх ленты, поддерживает просмотр фото и видео, навигацию между постами, закрытие по клику вне окна и переход к оригинальному посту.
// @author       mizu299
// @match        https://eblo.id/*
// @icon         https://eblo.id/favicon.ico
// @run-at       document-start
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const CARD_SELECTOR = '.feed-card[data-link]';
  const FEED_SELECTOR = '#feed-grid.feed-grid';

  const VOLUME_KEY = 'tm_eblo_volume';
  const MUTED_KEY = 'tm_eblo_muted';

  let overlay = null;
  let modal = null;
  let contentBox = null;
  let prevBtn = null;
  let nextBtn = null;

  let currentIndex = -1;
  let feedUrlBeforeOpen = '';
  let currentPostUrl = '';
  let currentPostId = '';
  let currentCommentSort = 'best';

  let currentVote = 0; // 1 up, -1 down, 0 none
  let currentScore = 0;

  function q(sel, root = document) {
    return root.querySelector(sel);
  }

  function qa(sel, root = document) {
    return [...root.querySelectorAll(sel)];
  }

  function esc(text) {
    const div = document.createElement('div');
    div.textContent = text ?? '';
    return div.innerHTML;
  }

  function abs(url) {
    return url ? new URL(url, location.origin).href : '';
  }

  function isFeedPage() {
    return !!q(FEED_SELECTOR);
  }

  function getCards() {
    return qa(CARD_SELECTOR).filter(el => el.offsetParent !== null);
  }

  function getUrlFromCard(card) {
    return abs(card?.dataset?.link || '');
  }

  function getIndexByUrl(url) {
    return getCards().findIndex(card => getUrlFromCard(card) === url);
  }

  function getPostIdFromUrl(url) {
    try {
      const pathname = new URL(url).pathname.replace(/^\/+|\/+$/g, '');
      return pathname.split('/')[0] || '';
    } catch {
      return '';
    }
  }

  function getSavedVolume() {
    const raw = localStorage.getItem(VOLUME_KEY);
    const num = raw == null ? 1 : Number(raw);
    return Number.isFinite(num) ? Math.max(0, Math.min(1, num)) : 1;
  }

  function getSavedMuted() {
    return localStorage.getItem(MUTED_KEY) === '1';
  }

  function saveMediaState(video) {
    if (!video) return;
    try {
      localStorage.setItem(VOLUME_KEY, String(video.volume));
      localStorage.setItem(MUTED_KEY, video.muted ? '1' : '0');
    } catch { }
  }

  function applyMediaState(video) {
    if (!video) return;
    video.volume = getSavedVolume();
    video.muted = getSavedMuted();
    video.addEventListener('volumechange', () => saveMediaState(video));
  }

  function getCsrfToken(doc = document) {
    const meta =
      doc.querySelector('meta[name="csrf-token"]') ||
      doc.querySelector('meta[name="csrf_token"]') ||
      doc.querySelector('meta[name="csrf"]');

    if (meta?.content) return meta.content;

    const el =
      doc.querySelector('[data-csrf-token]') ||
      doc.querySelector('input[name="csrf_token"]') ||
      doc.querySelector('input[name="csrf"]');

    if (el?.value) return el.value;
    if (el?.dataset?.csrfToken) return el.dataset.csrfToken;

    const html = document.documentElement.innerHTML;
    const patterns = [
      /"csrf_token"\s*:\s*"([^"]+)"/i,
      /"csrfToken"\s*:\s*"([^"]+)"/i,
      /csrf_token['"]?\s*[:=]\s*['"]([^'"]+)['"]/i,
      /csrfToken['"]?\s*[:=]\s*['"]([^'"]+)['"]/i
    ];

    for (const re of patterns) {
      const m = html.match(re);
      if (m?.[1]) return m[1];
    }

    return '';
  }

  function ensureStyles() {
    if (q('#tm-eblo-style')) return;

    const style = document.createElement('style');
    style.id = 'tm-eblo-style';
    style.textContent = `
      :root {
        --tm-accent: #789d2a;
        --tm-bg: #202020;
        --tm-card-bg: #262626;
        --tm-border: #333;
        --tm-feed-card-border: #ffffff33;
        --tm-title: #ebebeb;
        --tm-muted: #989898;
        --tm-stats: #696969;
        --tm-search-border: #444444;
      }

      #tm-eblo-overlay {
        position: fixed;
        inset: 0;
        z-index: 900;
        display: none;
        font-family: 'Handjet', Arial, sans-serif;
        color: var(--tm-title);
      }

      body > div[id*="emote"], body > div[class*="emote"], body > div[class*="picker"] {
        z-index: 99999 !important;
      }

      #tm-eblo-overlay * {
        box-sizing: border-box;
      }

      #tm-eblo-overlay .tm-eblo-backdrop {
        position: absolute;
        inset: 0;
        background: rgba(0, 0, 0, 0.84);
        backdrop-filter: blur(4px);
      }

      #tm-eblo-overlay .tm-eblo-shell {
        position: absolute;
        inset: 2vh 0;
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
      }

      #tm-eblo-overlay .tm-eblo-modal {
        position: relative;
        height: 96vh;
        width: 100%;
        max-width: 980px;
        overflow: hidden;
        border-radius: 1rem;
        background: transparent;
      }

      #tm-eblo-overlay .tm-eblo-scroll {
        height: 100%;
        overflow: auto;
        padding: 8px 0;
        scrollbar-width: thin;
        scrollbar-color: #8f8f8f transparent;
      }

      #tm-eblo-overlay .tm-eblo-scroll::-webkit-scrollbar {
        width: 10px;
      }

      #tm-eblo-overlay .tm-eblo-scroll::-webkit-scrollbar-thumb {
        background: #8f8f8f;
        border-radius: 10px;
      }

      #tm-eblo-overlay .tm-eblo-nav {
        position: fixed;
        top: 50%;
        transform: translateY(-50%);
        z-index: 10;
        border: 1px solid var(--tm-feed-card-border);
        background: rgba(38, 38, 38, 0.96);
        color: #fff;
        cursor: pointer;
        font-family: inherit;
        width: 48px;
        height: 48px;
        border-radius: 999px;
        font-size: 28px;
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 0;
        padding-bottom: 2px;
        line-height: 0;
      }
      
      #tm-eblo-overlay .tm-eblo-prev { left: max(2vw, 16px); }
      #tm-eblo-overlay .tm-eblo-next { right: max(2vw, 16px); }

      #tm-eblo-overlay .tm-eblo-nav:hover {
        border-color: var(--tm-accent);
        color: var(--tm-accent);
      }

      #tm-eblo-overlay .tm-eblo-post,
      #tm-eblo-overlay .tm-eblo-comments-wrap {
        width: 100%;
        background: var(--tm-card-bg);
        border: 1px solid var(--tm-border);
        border-radius: 0.75rem;
      }

      #tm-eblo-overlay .tm-eblo-post {
        position: relative;
        padding: 1.5rem;
        margin-bottom: 1rem;
      }

      #tm-eblo-overlay .tm-eblo-author {
        display: flex;
        align-items: center;
        gap: 12px;
        margin-bottom: 1rem;
      }

      #tm-eblo-overlay .tm-eblo-author-avatar {
        width: 54px;
        height: 54px;
        border-radius: 999px;
        object-fit: cover;
        flex: 0 0 auto;
      }

      #tm-eblo-overlay .tm-eblo-author-meta {
        display: flex;
        align-items: center;
        gap: 10px;
        flex-wrap: wrap;
      }

      #tm-eblo-overlay .tm-eblo-author-name {
        color: #fff;
        text-decoration: none;
        font-size: 1.8rem;
        line-height: 1;
      }

      #tm-eblo-overlay .tm-eblo-author-date {
        color: var(--tm-muted);
        font-size: 1.2rem;
      }

      #tm-eblo-overlay .tm-eblo-title {
        margin: 0 0 1.2rem;
        color: var(--tm-title);
        font-size: 3.2rem;
        line-height: 1;
        letter-spacing: 0.02em;
      }

      #tm-eblo-overlay .tm-eblo-post-header {
        display: flex;
        align-items: center;
        gap: 12px;
        margin-bottom: 20px;
      }
      #tm-eblo-overlay .tm-eblo-post-desc {
        flex: 1;
        text-align: left;
        color: #828282;
        font-weight: 600;
        font-size: 1.25rem;
        line-height: 1.5rem;
        letter-spacing: 0.04em;
        overflow-wrap: break-word;
        word-wrap: break-word;
        word-break: break-word;
        max-width: 100%;
        min-width: 0;
        margin: 10px 0 20px 0;
        white-space: pre-wrap;
      }
      #tm-eblo-overlay .tm-eblo-post-desc p {
        margin: 0 0 0.6em;
        white-space: pre-wrap;
      }
      #tm-eblo-overlay .tm-eblo-post-desc a {
        color: var(--tm-accent);
        text-decoration: underline;
      }
      #tm-eblo-overlay .tm-eblo-post-desc a:hover {
        color: #fff;
      }
      #tm-eblo-overlay .tm-eblo-media-gallery {
        position: relative;
        width: 100%;
        border-radius: 8px;
        margin-top: 15px;
        overflow: hidden;
      }
      #tm-eblo-overlay .tm-eblo-gallery-track {
        display: flex;
        width: 100%;
        transition: transform 0.3s ease;
      }
      #tm-eblo-overlay .tm-eblo-gallery-item {
        flex: 0 0 100%;
        width: 100%;
        object-fit: contain;
        display: block;
        max-height: 80vh;
        background: #000;
      }

      /* Censorship styles start */
      #tm-eblo-overlay .tm-eblo-item-wrapper {
        position: relative;
        flex: 0 0 100%;
        width: 100%;
        display: flex;
        justify-content: center;
        align-items: center;
        background: #000;
        overflow: hidden;
      }

      #tm-eblo-overlay .tm-eblo-gallery-item.blurred {
        opacity: 0;
        pointer-events: none;
        transition: opacity 0.4s cubic-bezier(.4, 0, .2, 1);
      }

      #tm-eblo-overlay .tm-eblo-censored-overlay {
        position: absolute;
        inset: 0;
        z-index: 20;
        display: flex;
        justify-content: center;
        align-items: center;
        background: rgba(0, 0, 0, 0.2);
        cursor: pointer;
        backdrop-filter: blur(2px);
      }

      #tm-eblo-overlay .tm-eblo-censored-box {
        background: #262626;
        border-radius: 12px;
        padding: 30px 40px;
        box-shadow: 0 8px 32px rgba(0,0,0,0.5);
        border: 1px solid #333;
        text-align: center;
        max-width: 90%;
      }

      #tm-eblo-overlay .tm-eblo-censored-title {
        font-size: 2.5rem;
        color: #fff;
        margin-bottom: 8px;
        line-height: 1.1;
      }

      #tm-eblo-overlay .tm-eblo-censored-sub {
        font-size: 1.25rem;
        color: #989898;
        font-weight: 400;
      }

      #tm-eblo-overlay .is-revealed .tm-eblo-gallery-item.blurred {
        opacity: 1 !important;
        pointer-events: auto !important;
      }

      #tm-eblo-overlay .is-revealed .tm-eblo-censored-overlay {
        display: none !important;
      }

      #tm-eblo-overlay .tm-eblo-gallery-track.no-transition {
        transition: none !important;
      }
      /* Censorship styles end */

      /* Стили для кнопок навигации галереи */
      #tm-eblo-overlay .tm-gallery-nav {
         position: absolute;
         top: 50%;
         transform: translateY(-50%);
         z-index: 10;
         width: 44px;
         height: 44px;
         min-width: 44px;
         min-height: 44px;
         border-radius: 50%;
         border: none;
         cursor: pointer;
         display: flex;
         justify-content: center;
         align-items: center;
         background: #4CAF50 !important;
         box-shadow: 0 4px 12px rgba(0,0,0,0.3);
         transition: filter 0.2s;
         flex-shrink: 0;
         padding: 0;
      }
      #tm-eblo-overlay .tm-gallery-nav:hover {
        filter: brightness(1.1);
      }
      #tm-eblo-overlay .tm-gallery-prev {
        left: 10px;
      }
      #tm-eblo-overlay .tm-gallery-next {
        right: 10px;
      }
      #tm-eblo-overlay .arrow-left { transform: rotate(-90deg); }
      #tm-eblo-overlay .arrow-right { transform: rotate(90deg); }
      #tm-eblo-overlay .arrow-up { transform: rotate(0deg); }
      #tm-eblo-overlay .arrow-down { transform: rotate(180deg); }

      #tm-eblo-overlay .tm-eblo-watermark {
        position: absolute;
        top: 1.2rem;
        right: 1.5rem;
        font-size: 1rem;
        color: #4a4a4a;
        letter-spacing: 0.06em;
        pointer-events: none;
        user-select: none;
      }

      /* Fix for EmotePicker going off-screen */
      .seventv-emote-picker-container, #emote-picker, .emote-picker, .picker-container {
         position: fixed !important;
         top: auto !important;
         bottom: 20px !important;
         right: 20px !important;
         left: auto !important;
         z-index: 9999999 !important;
         max-height: calc(100vh - 40px) !important;
      }

      #tm-eblo-overlay .tm-eblo-interaction {
        margin-top: 1rem;
        border-top: 1px solid var(--tm-border);
        padding-top: 1rem;
      }

      #tm-eblo-overlay .tm-eblo-stats-row {
        display: flex;
        justify-content: space-between;
        align-items: center;
        gap: 1rem;
        flex-wrap: wrap;
        margin-bottom: 1rem;
      }

      #tm-eblo-overlay .tm-eblo-stats-left,
      #tm-eblo-overlay .tm-eblo-stats-right {
        display: flex;
        align-items: center;
        gap: 1.2rem;
        flex-wrap: wrap;
      }

      #tm-eblo-overlay .tm-eblo-votes {
        display: inline-flex;
        align-items: center;
        gap: 0.55rem;
      }

      #tm-eblo-overlay .tm-eblo-vote-btn,
      #tm-eblo-overlay .tm-eblo-action-btn,
      .tm-eblo-open-post-btn {
        font-family: inherit;
        border-radius: 0.5rem;
      }

      #tm-eblo-overlay .tm-eblo-vote-btn {
        background: transparent;
        border: none;
        color: #fff;
        cursor: pointer;
        padding: 0;
        line-height: 1;
        display: flex;
        align-items: center;
        justify-content: center;
      }

      #tm-eblo-overlay .tm-eblo-vote-btn img {
        width: 20px;
        height: 20px;
      }

      #tm-eblo-overlay .tm-eblo-vote-btn:hover img {
        filter: brightness(0) saturate(100%) invert(60%) sepia(60%) saturate(400%) hue-rotate(50deg);
      }

      #tm-eblo-overlay .tm-eblo-vote-btn.active img {
        filter: brightness(0) saturate(100%) invert(60%) sepia(60%) saturate(400%) hue-rotate(50deg);
      }

      #tm-eblo-overlay .tm-eblo-vote-btn:disabled {
        opacity: 0.6;
        cursor: wait;
      }

      #tm-eblo-overlay .tm-eblo-vote-score,
      #tm-eblo-overlay .tm-eblo-stat {
        font-size: 1.5rem;
        color: #fff;
      }

      #tm-eblo-overlay .tm-eblo-stat-muted {
        font-size: 1.4rem;
        color: var(--tm-stats);
      }

      #tm-eblo-overlay .tm-eblo-actions {
        display: flex;
        gap: 0.75rem;
        flex-wrap: wrap;
      }

      #tm-eblo-overlay .tm-eblo-action-btn,
      .tm-eblo-open-post-btn {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        min-height: 40px;
        padding: 0.7rem 1rem;
        text-decoration: none;
        cursor: pointer;
      }

      #tm-eblo-overlay .tm-eblo-action-btn {
        background: transparent;
        border: 1px solid var(--tm-search-border);
        color: var(--tm-title);
      }

      #tm-eblo-overlay .tm-eblo-action-btn:hover,
      .tm-eblo-open-post-btn:hover {
        border-color: var(--tm-accent);
        color: var(--tm-accent);
      }

      #tm-eblo-overlay .tm-eblo-comments-wrap {
        background: var(--tm-card-bg);
        border: 1px solid #333;
        border-radius: 0.75rem;
        padding: 1.5rem;
        width: 100%;
      }

      #tm-eblo-overlay .tm-eblo-comments-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        gap: 1rem;
        margin-bottom: 1.5rem;
      }

      #tm-eblo-overlay .tm-eblo-comments-title {
        margin: 0;
        font-size: 2rem;
        line-height: 1;
        color: #fff;
      }

      #tm-eblo-overlay .tm-eblo-sort-opt {
        background: transparent;
        color: var(--tm-muted);
        border: 1px solid transparent;
        cursor: pointer;
        font-family: inherit;
        font-size: 1.3rem;
        border-radius: 8px;
        padding: 4px 8px;
      }
      #tm-eblo-overlay .tm-eblo-sort-opt.active {
        color: #fff;
        background: rgba(255,255,255,0.1);
      }
      #tm-eblo-overlay .tm-eblo-sort-opt:hover {
        color: #fff;
      }

      #tm-eblo-overlay .tm-eblo-comment-form {
        display: flex;
        gap: 10px;
        margin-bottom: 20px;
        align-items: flex-start;
      }
      #tm-eblo-overlay .tm-eblo-comment-form textarea {
        flex: 1;
        background: rgba(255,255,255,0.05);
        border: 1px solid var(--tm-search-border);
        color: #fff;
        border-radius: 8px;
        padding: 10px;
        font-family: inherit;
        font-size: 1.4rem;
        min-height: 48px;
        resize: vertical;
      }
      #tm-eblo-overlay .tm-eblo-comment-form button {
        background: var(--tm-accent);
        color: #fff;
        border: none;
        border-radius: 8px;
        padding: 10px 16px;
        font-family: inherit;
        font-size: 1.4rem;
        cursor: pointer;
        min-height: 48px;
      }
      #tm-eblo-overlay .tm-eblo-comment-form button:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }

      #tm-eblo-overlay .tm-eblo-login-prompt {
        text-align: center;
        color: var(--tm-muted);
        font-size: 1.3rem;
        margin-bottom: 1.4rem;
      }

      #tm-eblo-overlay .tm-eblo-comment-item {
        display: flex;
        gap: 14px;
        padding: 0.9rem 0;
        border-bottom: 1px solid rgba(255,255,255,0.05);
      }

      #tm-eblo-overlay .tm-eblo-comment-item:last-child {
        border-bottom: none;
      }

      /* Стили для вложенных комментариев (ответов) */
      #tm-eblo-overlay .tm-eblo-comment-reply {
        margin-left: 1.5rem;
        border-left: 2px solid #333;
        padding-left: 1rem;
        margin-top: 0.5rem;
        border-bottom: none; /* Убираем полоску снизу у реплаев */
      }

      #tm-eblo-overlay .tm-eblo-comment-vote-col {
        width: 28px;
        flex: 0 0 28px;
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 0.15rem;
        color: #fff;
        padding-top: 2px;
      }

      #tm-eblo-overlay .tm-eblo-comment-vote-btn {
        border: none;
        background: transparent;
        color: #999;
        cursor: pointer;
        padding: 0;
        line-height: 1;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      #tm-eblo-overlay .tm-eblo-comment-vote-btn img {
        width: 16px;
        height: 16px;
      }
      #tm-eblo-overlay .tm-eblo-comment-vote-btn:hover img {
        filter: brightness(0) saturate(100%) invert(60%) sepia(60%) saturate(400%) hue-rotate(50deg);
      }
      #tm-eblo-overlay .tm-eblo-comment-vote-btn.active img {
        filter: brightness(0) saturate(100%) invert(60%) sepia(60%) saturate(400%) hue-rotate(50deg);
      }

      #tm-eblo-overlay .tm-eblo-comment-score {
        font-size: 1.15rem;
        color: #fff;
        line-height: 1;
        font-weight: 600;
      }

      #tm-eblo-overlay .tm-eblo-comment-body {
        min-width: 0;
        flex: 1 1 auto;
      }

      #tm-eblo-overlay .tm-eblo-comment-meta {
        display: flex;
        align-items: center;
        gap: 0.55rem;
        flex-wrap: wrap;
        margin-bottom: 0.35rem;
      }

      #tm-eblo-overlay .tm-eblo-comment-author-avatar {
        width: 28px;
        height: 28px;
        border-radius: 999px;
        object-fit: cover;
      }

      #tm-eblo-overlay .tm-eblo-comment-author {
        color: #fff;
        text-decoration: none;
        font-size: 1.45rem;
        line-height: 1;
      }

      #tm-eblo-overlay .tm-eblo-comment-time {
        color: var(--tm-muted);
        text-decoration: none;
        font-size: 1.15rem;
      }

      #tm-eblo-overlay .tm-eblo-comment-text {
        color: #fff;
        font-size: 1.35rem;
        line-height: 1.35;
        word-break: break-word;
      }

      #tm-eblo-overlay .tm-eblo-comment-text img.emote-inline {
        height: 28px;
        width: auto;
        vertical-align: middle;
      }

      #tm-eblo-overlay .tm-eblo-loading,
      #tm-eblo-overlay .tm-eblo-error {
        background: var(--tm-card-bg);
        border: 1px solid var(--tm-border);
        border-radius: 0.75rem;
        padding: 2rem;
        text-align: center;
        color: #fff;
        font-size: 1.6rem;
      }

      body.tm-eblo-lock {
        overflow: hidden !important;
      }

      .tm-eblo-open-post-btn {
        margin-top: 10px;
        background: transparent;
        border: 1px solid var(--tm-search-border);
        color: var(--tm-title) !important;
        font-size: 1.15rem;
        font-weight: 600;
        position: relative;
        z-index: 5;
      }

      @media (max-width: 900px) {
        #tm-eblo-overlay .tm-eblo-shell {
          grid-template-columns: 1fr;
          inset: 1vh 1vw;
        }

        #tm-eblo-overlay .tm-eblo-nav {
          position: fixed;
          bottom: 18px;
          z-index: 5;
        }

        #tm-eblo-overlay .tm-eblo-prev { left: 14px; }
        #tm-eblo-overlay .tm-eblo-next { right: 14px; }

        #tm-eblo-overlay .tm-eblo-title {
          font-size: 2.3rem;
        }

        #tm-eblo-overlay .tm-eblo-comments-header {
          align-items: flex-start;
          flex-direction: column;
        }
      }
    `;

    document.documentElement.appendChild(style);
  }

  function ensureModal() {
    if (overlay) return;

    ensureStyles();

    overlay = document.createElement('div');
    overlay.id = 'tm-eblo-overlay';
    overlay.innerHTML = `
      <div class="tm-eblo-backdrop"></div>
        <button class="tm-eblo-nav tm-eblo-prev" aria-label="Назад">‹</button>
        <button class="tm-eblo-nav tm-eblo-next" aria-label="Вперед">›</button>
        <div class="tm-eblo-shell">
        <div class="tm-eblo-modal">
          <div class="tm-eblo-scroll">
            <div class="tm-eblo-content">
              <div class="tm-eblo-loading">Загрузка...</div>
            </div>
          </div>
        </div>
        </div>
      </div>
    `;

    document.documentElement.appendChild(overlay);

    modal = q('.tm-eblo-modal', overlay);
    contentBox = q('.tm-eblo-content', overlay);
    prevBtn = q('.tm-eblo-prev', overlay);
    nextBtn = q('.tm-eblo-next', overlay);

    q('.tm-eblo-backdrop', overlay).addEventListener('click', closeModal);
    q('.tm-eblo-shell', overlay).addEventListener('click', closeModal);

    modal.addEventListener('click', (e) => {
      e.stopPropagation();
    });

    prevBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      goRelative(-1);
    });

    nextBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      goRelative(1);
    });

    document.addEventListener('keydown', (e) => {
      if (!overlay || overlay.style.display === 'none') return;
      if (e.key === 'Escape') closeModal();
      if (e.key === 'ArrowLeft') goRelative(-1);
      if (e.key === 'ArrowRight') goRelative(1);
    });
  }

  function openModalShell() {
    ensureModal();
    overlay.style.display = 'block';
    document.body.classList.add('tm-eblo-lock');
  }

  function closeModal() {
    if (!overlay) return;
    overlay.style.display = 'none';
    contentBox.innerHTML = '';
    document.body.classList.remove('tm-eblo-lock');
    currentPostUrl = '';
    currentPostId = '';
    currentVote = 0;
    currentScore = 0;

    if (feedUrlBeforeOpen && location.href !== feedUrlBeforeOpen) {
      history.replaceState({}, '', feedUrlBeforeOpen);
    }
  }

  async function sendCommentVote(commentId, direction, btn) {
    if (!commentId) return;
    const csrf = getCsrfToken(document);
    if (!csrf) return;

    btn.disabled = true;
    try {
      const res = await fetch(`/api/comment/${commentId}/vote`, {
        method: 'POST',
        credentials: 'include',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': csrf
        },
        body: JSON.stringify({ vote: direction })
      });
      const data = await res.json();
      if (data.success) {
        const col = btn.closest('.tm-eblo-comment-vote-col');
        if (col) {
          const scoreSpan = col.querySelector('.tm-eblo-comment-score');
          if (scoreSpan) scoreSpan.textContent = data.score;
          const up = col.querySelector('[data-action="up"]');
          const down = col.querySelector('[data-action="down"]');
          if (up) up.classList.toggle('active', data.user_vote === 'up' || data.user_vote === 1);
          if (down) down.classList.toggle('active', data.user_vote === 'down' || data.user_vote === -1);
        }
      }
    } catch (e) {
      console.error('[EbloModal] comment vote err', e);
    } finally {
      btn.disabled = false;
    }
  }

  async function submitNewComment(textContent = null, replyToId = null) {
    if (!currentPostId) return;
    const csrf = getCsrfToken(document);
    if (!csrf) {
      console.error('[EbloModal] CSRF token not found');
      return;
    }

    const text = textContent || q('#tm-eblo-new-comment-text', contentBox)?.value?.trim();
    if (!text) return;

    const mainSubmitBtn = q('#tm-eblo-submit-comment', contentBox);
    if (mainSubmitBtn && !textContent) mainSubmitBtn.disabled = true;

    const payload = { content: text };
    if (replyToId) payload.reply_to = String(replyToId);

    try {
      const res = await fetch(`/api/post/${encodeURIComponent(currentPostId)}/comments`, {
        method: 'POST',
        credentials: 'include',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': csrf
        },
        body: JSON.stringify(payload)
      });

      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      if (!textContent) {
        const input = q('#tm-eblo-new-comment-text', contentBox);
        if (input) input.value = '';
      }
      fetchAndRenderComments(currentPostId);
    } catch (e) {
      console.error('[EbloModal] failed to submit commment', e);
    } finally {
      if (mainSubmitBtn && !textContent) mainSubmitBtn.disabled = false;
    }
  }

  function hookGlobalContentEvents() {
    if (!contentBox) return;
    contentBox.addEventListener('click', (e) => {
      const sortBtn = e.target.closest('.tm-eblo-sort-opt');
      if (sortBtn) {
        currentCommentSort = sortBtn.dataset.sort;
        qa('.tm-eblo-sort-opt', contentBox).forEach(b => b.classList.remove('active'));
        sortBtn.classList.add('active');
        if (currentPostId) fetchAndRenderComments(currentPostId);
      }

      const cvBtn = e.target.closest('.tm-eblo-comment-vote-btn');
      if (cvBtn) {
        const id = cvBtn.dataset.id;
        const action = cvBtn.dataset.action;
        sendCommentVote(id, action, cvBtn);
      }

      const submitBtn = e.target.closest('#tm-eblo-submit-comment');
      if (submitBtn) {
        submitNewComment();
      }

      const inlineSubmit = e.target.closest('.tm-eblo-inline-reply-submit');
      if (inlineSubmit) {
        const form = inlineSubmit.closest('.tm-eblo-inline-reply-form');
        const txt = form.querySelector('textarea').value.trim();
        const replyTo = inlineSubmit.getAttribute('data-reply-to');
        if (txt) {
          submitNewComment(txt, replyTo).then(() => { form.remove(); });
        }
      }

      const inlineCancel = e.target.closest('.tm-eblo-inline-reply-cancel');
      if (inlineCancel) {
        inlineCancel.closest('.tm-eblo-inline-reply-form').remove();
      }

      const replyBtn = e.target.closest('.tm-eblo-reply-btn');
      if (replyBtn) {
        const commentId = replyBtn.getAttribute('data-id');
        const login = replyBtn.getAttribute('data-login');
        const item = replyBtn.closest('.tm-eblo-comment-item');

        const oldForms = contentBox.querySelectorAll('.tm-eblo-inline-reply-form');
        oldForms.forEach(f => f.remove());

        const formHtml = `
                    <form class="reply-form tm-eblo-inline-reply-form" style="margin-top: 10px; display: flex; flex-direction: column; gap: 8px;">
                        <textarea class="reply-input tm-eblo-inline-reply-text" placeholder="${login ? 'Ответ @' + esc(login) + '...' : 'Написать ответ...'}" rows="2" maxlength="2000" autocomplete="off" style="width: 100%; background: #1a1a1a; border: 1px solid #333; color: white; padding: 10px; border-radius: 6px; resize: vertical; min-height: 60px; font-family: inherit; font-size: 1rem;"></textarea>
                        <div style="display: flex; justify-content: flex-end; gap: 8px;">
                            <button type="button" class="emote-btn tm-eblo-inline-emote-btn" title="7TV смайлики" style="background: transparent; border: none; color: #999; cursor: pointer; align-self: center; display: flex; align-items: center; justify-content: center;">
                                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M8 13s1.5 2 4 2 4-2 4-2"></path><line x1="9" y1="9" x2="9.01" y2="9"></line><line x1="15" y1="9" x2="15.01" y2="9"></line></svg>
                            </button>
                            <button class="reply-cancel-btn tm-eblo-inline-reply-cancel" type="button" style="background: none; border: none; color: #999; cursor: pointer; padding: 5px 10px;">Отмена</button>
                            <button class="reply-submit-btn tm-eblo-inline-reply-submit" data-reply-to="${commentId}" type="button" style="background: var(--tm-primary); color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer;">Ответить</button>
                        </div>
                    </form>
                  `;
        item.querySelector('.tm-eblo-comment-text').insertAdjacentHTML('afterend', formHtml);

        const input = item.querySelector('.tm-eblo-inline-reply-text');
        const emoteBtn = item.querySelector('.tm-eblo-inline-emote-btn');
        if (input) {
          input.focus();
          if (window.EmotePicker) {
            window.EmotePicker.attach(input);
            if (emoteBtn) window.EmotePicker.attachPickerBtn(emoteBtn, input);
          }
        }
      }
    });

    contentBox.addEventListener('input', (e) => {
      const input = e.target.closest('#tm-eblo-new-comment-text');
      if (input) {
        const submitBtn = q('#tm-eblo-submit-comment', contentBox);
        if (submitBtn) submitBtn.disabled = !input.value.trim();
      }
    });

    contentBox.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
        const input = e.target.closest('#tm-eblo-new-comment-text');
        if (input && input.value.trim()) {
          submitNewComment();
        }
      }
    });

    // Gallery init moved to bindDynamicActions() per-post
  }

  function updateNav() {
    const cards = getCards();
    prevBtn.style.visibility = currentIndex > 0 ? 'visible' : 'hidden';
    nextBtn.style.visibility = currentIndex >= 0 && currentIndex < cards.length - 1 ? 'visible' : 'hidden';
  }

  async function fetchDoc(url) {
    const res = await fetch(url, { credentials: 'include' });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const html = await res.text();
    return new DOMParser().parseFromString(html, 'text/html');
  }

  function parseVoteState(root) {
    const scoreText = root.querySelector('#vote-score')?.textContent?.trim() || '0';
    const score = parseInt(scoreText, 10) || 0;

    let vote = 0;
    const upBtn = root.querySelector('#vote-up-btn');
    const downBtn = root.querySelector('#vote-down-btn');

    const isUpActive =
      upBtn?.classList.contains('active') ||
      upBtn?.getAttribute('aria-pressed') === 'true' ||
      /active|selected|voted/i.test(upBtn?.className || '');

    const isDownActive =
      downBtn?.classList.contains('active') ||
      downBtn?.getAttribute('aria-pressed') === 'true' ||
      /active|selected|voted/i.test(downBtn?.className || '');

    if (isUpActive) vote = 1;
    else if (isDownActive) vote = -1;

    return { score, vote };
  }

  function extractImageFromPost(root, doc) {
    const candidates = [
      '#media-container img',
      '.post-card img[src*="/uploads/"]',
      '.post-card img:not(.author-avatar):not(.action-icon):not(.stat-img-sm)',
      '#page-wrap img[src*="/uploads/"]'
    ];

    for (const sel of candidates) {
      const img = q(sel, root) || q(sel, doc);
      if (!img) continue;
      const src = img.getAttribute('src') || img.getAttribute('data-src') || '';
      if (!src) continue;
      return abs(src);
    }

    return '';
  }

  function buildCommentsSection(doc) {
    const commentsRoot = doc.querySelector('#comments-section');
    if (!commentsRoot) return '';

    const title = commentsRoot?.querySelector('.comments-title')?.textContent?.trim() || 'Комментарии';
    const sortLabel = commentsRoot?.querySelector('#comments-sort-label')?.textContent?.trim() || 'По рейтингу';
    const loginPrompt = commentsRoot?.querySelector('.comment-login-prompt span')?.textContent?.trim() || '';

    const csrf = getCsrfToken(document);
    let formStr = '';
    if (csrf && !loginPrompt) {
      formStr = `
              <div class="tm-eblo-comment-form">
                <textarea id="tm-eblo-new-comment-text" placeholder="Написать комментарий..."></textarea>
                <div style="display: flex; flex-direction: column; gap: 8px;">
                  <button id="tm-eblo-submit-comment" type="button" disabled>Отправить</button>
                  <button type="button" class="emote-btn" id="tm-eblo-emote-picker-btn" title="7TV смайлики" style="background: transparent; border: none; color: #999; cursor: pointer; align-self: center;">
                    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M8 13s1.5 2 4 2 4-2 4-2"></path><line x1="9" y1="9" x2="9.01" y2="9"></line><line x1="15" y1="9" x2="15.01" y2="9"></line></svg>
                  </button>
                </div>
              </div>
            `;
    }

    return `
      <section class="tm-eblo-comments-wrap">
        <div class="tm-eblo-comments-header">
          <h3 class="tm-eblo-comments-title" id="tm-eblo-comments-title">${esc(title)}</h3>
          <div class="tm-eblo-sort-wrap">
             <button class="tm-eblo-sort-opt ${currentCommentSort === 'best' ? 'active' : ''}" data-sort="best" type="button">По рейтингу</button>
             <button class="tm-eblo-sort-opt ${currentCommentSort === 'new' ? 'active' : ''}" data-sort="new" type="button">Сначала новые</button>
             <button class="tm-eblo-sort-opt ${currentCommentSort === 'controversial' ? 'active' : ''}" data-sort="controversial" type="button">Спорные</button>
          </div>
        </div>
        ${formStr}
        ${loginPrompt ? `<div class="tm-eblo-login-prompt">${esc(loginPrompt)}</div>` : ''}
        <div class="tm-eblo-comments-list" id="tm-eblo-comments-list">
          <div class="tm-eblo-login-prompt">Загрузка комментариев...</div>
        </div>
      </section>
    `;
  }

  function buildInteraction(root, url) {
    const interaction = root.querySelector('#interaction-panel');
    if (!interaction) return '';

    const commentsCount = interaction.querySelector('#comments-count-label')?.textContent?.trim() || '0';
    const views = interaction.querySelector('.stat-value-muted')?.textContent?.trim() || '0';

    const voteState = parseVoteState(root);
    currentScore = voteState.score;
    currentVote = voteState.vote;

    return `
      <div class="tm-eblo-interaction">
        <div class="tm-eblo-stats-row">
          <div class="tm-eblo-stats-left">
            <div class="tm-eblo-votes">
              <button class="tm-eblo-vote-btn tm-eblo-vote-up ${currentVote === 1 ? 'active' : ''}" type="button" aria-label="Апвоут"><img src="/static/image/btn-arrow.svg" alt="up" class="btn-arrow arrow-up"></button>
              <span class="tm-eblo-vote-score" id="tm-eblo-vote-score">${esc(String(currentScore))}</span>
              <button class="tm-eblo-vote-btn tm-eblo-vote-down ${currentVote === -1 ? 'active' : ''}" type="button" aria-label="Даунвоут"><img src="/static/image/btn-arrow.svg" alt="down" class="btn-arrow arrow-down"></button>
            </div>
            <div class="tm-eblo-stat">💬 ${esc(commentsCount)}</div>
          </div>
          <div class="tm-eblo-stats-right">
            <div class="tm-eblo-stat-muted">👁 ${esc(views)}</div>
          </div>
        </div>

        <div class="tm-eblo-actions">
          <a class="tm-eblo-action-btn" href="${esc(url)}" target="_blank" rel="noopener noreferrer">Открыть пост</a>
          <button class="tm-eblo-action-btn tm-eblo-copy-link" data-url="${esc(url)}" type="button">Скопировать ссылку</button>
        </div>
      </div>
    `;
  }

  function buildPostHtml(doc, url, overrideCard = null) {
    const root = doc.querySelector('.post-card') || doc.querySelector('#page-wrap.preview-content') || doc;
    if (!root) throw new Error('Не найден пост');

    const authorLink = root.querySelector('.author-link');
    const authorNameEl = root.querySelector('.author-name');
    const authorAvatar = root.querySelector('.author-avatar')?.getAttribute('src') || '';
    const authorName = authorNameEl?.textContent?.trim() || '';
    const authorHref = authorLink?.getAttribute('href') || '#';
    const authorDate = root.querySelector('.author-date')?.textContent?.trim() || '';
    const title = root.querySelector('#post-title')?.textContent?.trim() || 'Пост';

    const descSelectors = [
      '#post-desc', '#post-description', '#post-text',
      '.post-description', '.post-text', '.post-content',
      '.media-description', '.content-text', '.preview-text',
      '.post-body', '.desc-text', '.post-desc',
      '[data-role="description"]', '.description',
      '.post-card-text', '.card-text', '.post-caption',
      '.caption', '.text-content', '.media-caption'
    ];

    let descNode = null;
    for (const sel of descSelectors) {
      descNode = root.querySelector(sel);
      if (descNode) break;
    }

    if (!descNode && overrideCard) {
      for (const sel of descSelectors) {
        descNode = overrideCard.querySelector(sel);
        if (descNode) break;
      }
    }

    let descHtml = '';
    if (descNode) {
      // data-original contains raw text with \n — the site's JS uses this to build the HTML
      const rawOriginal = descNode.getAttribute('data-original')?.trim() || '';
      let descInner = rawOriginal || descNode.innerHTML?.trim() || '';

      // If #post-desc is empty, fall back to og:description meta tag
      if (!descInner) {
        const ogDesc = doc.querySelector('meta[property="og:description"]')?.getAttribute('content')
          || doc.querySelector('meta[name="description"]')?.getAttribute('content')
          || '';
        if (ogDesc) descInner = ogDesc;
      }

      if (descInner) {
        const hasBlocks = /<(p|div|li|ul|ol|h[1-6])[\s>\/]/i.test(descInner);
        const hasBr = /<br\s*\/?>/i.test(descInner);

        if (hasBlocks || hasBr) {
          // Already structured HTML — linkify any plain-text URLs not already wrapped
          descInner = descInner.replace(/(^|[^"'>])(https?:\/\/[^\s<>"']+)/g, (_, pre, url) => {
            return `${pre}<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
          });
        } else {
          // Plain text (e.g. data-original or og:description) — linkify + convert newlines
          descInner = descInner.replace(/\r\n|\r/g, '\n');
          descInner = descInner.replace(/\n{3,}/g, '\n\n');
          descInner = descInner.replace(/(https?:\/\/[^\s<>"']+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
          descInner = descInner.replace(/\n/g, '<br>');
        }
        descHtml = `<div class="tm-eblo-post-desc">${descInner}</div>`;
      }
    }

    const mediaNodes = Array.from(root.querySelectorAll('#media-container img, #media-container video, #media-container .media-video, .post-media img, .post-media video, .post-media .media-video'));
    const uniqueMedia = [];

    for (const node of mediaNodes) {
      const src = node.getAttribute('src') || node.getAttribute('data-src');
      if (!src) continue;
      if (src.includes('/static/')) continue;

      if (!uniqueMedia.find(m => (m.getAttribute('src') || m.getAttribute('data-src')) === src)) {
        uniqueMedia.push(node);
      }
    }

    let mediaHtml = '';
    if (uniqueMedia.length > 0) {
      mediaHtml += '<div class="tm-eblo-media-gallery">';
      if (uniqueMedia.length > 1) {
        mediaHtml += `<button class="tm-gallery-nav tm-gallery-prev" aria-label="Prev" type="button"><img src="/static/image/btn-arrow.svg" alt="prev" class="btn-arrow arrow-left"></button>`;
      }
      mediaHtml += '<div class="tm-eblo-gallery-track">';
      for (const node of uniqueMedia) {
        const isBlurred = node.classList.contains('blurred') || node.style.opacity === '0' || node.getAttribute('style')?.includes('opacity: 0');
        
        mediaHtml += `<div class="tm-eblo-item-wrapper ${isBlurred ? 'tm-eblo-censored' : ''}">`;
        
        if (node.tagName === 'IMG') {
          const src = node.getAttribute('src') || node.getAttribute('data-src');
          mediaHtml += `<img class="tm-eblo-image tm-eblo-gallery-item ${isBlurred ? 'blurred' : ''}" src="${esc(abs(src))}" alt="">`;
        } else {
          const src = node.getAttribute('src') || node.getAttribute('data-src');
          const poster = node.getAttribute('poster') || node.getAttribute('data-poster') || '';
          mediaHtml += `
                         <video
                             class="tm-eblo-video tm-eblo-gallery-item ${isBlurred ? 'blurred' : ''}"
                             controls
                             playsinline
                             preload="metadata"
                             poster="${esc(abs(poster))}"
                             src="${esc(abs(src))}"
                         ></video>
                     `;
        }

        if (isBlurred) {
          mediaHtml += `
            <div class="tm-eblo-censored-overlay">
              <div class="tm-eblo-censored-box">
                <div class="tm-eblo-censored-title">Контент скрыт цензурой</div>
                <div class="tm-eblo-censored-sub">Нажмите, чтобы открыть материалы</div>
              </div>
            </div>
          `;
        }

        mediaHtml += '</div>';
      }
      mediaHtml += '</div>';
      if (uniqueMedia.length > 1) {
        mediaHtml += `<button class="tm-gallery-nav tm-gallery-next" aria-label="Next" type="button"><img src="/static/image/btn-arrow.svg" alt="next" class="btn-arrow arrow-right"></button>`;
      }
      mediaHtml += '</div>';
    } else {
      const fallbackImg = extractImageFromPost(root, doc);
      if (fallbackImg && !fallbackImg.includes('/static/')) {
        mediaHtml = `<div class="tm-eblo-media-wrap" style="margin-top: 15px;"><img class="tm-eblo-image" src="${esc(fallbackImg)}" alt="" style="max-width: 100%; border-radius: 8px;"></div>`;
      }
    }

    return `
      <article class="tm-eblo-post">
        <span class="tm-eblo-watermark">Modal Window by mizu299</span>
        <div class="tm-eblo-author">
          <img class="tm-eblo-author-avatar" src="${esc(authorAvatar)}" alt="">
          <div class="tm-eblo-author-meta">
            <a class="tm-eblo-author-name" href="${esc(authorHref)}" target="_blank" rel="noopener noreferrer">${esc(authorName)}</a>
            <span class="tm-eblo-author-date">${esc(authorDate)}</span>
          </div>
        </div>

        <h1 class="tm-eblo-title">${esc(title)}</h1>
        ${descHtml}

        ${mediaHtml}

        ${buildInteraction(root, url)}
      </article>

      ${buildCommentsSection(doc)}
    `;
  }

  function updateVoteUi() {
    const scoreEl = q('#tm-eblo-vote-score', contentBox);
    const upBtn = q('.tm-eblo-vote-up', contentBox);
    const downBtn = q('.tm-eblo-vote-down', contentBox);

    if (scoreEl) scoreEl.textContent = String(currentScore);

    if (upBtn) upBtn.classList.toggle('active', currentVote === 1);
    if (downBtn) downBtn.classList.toggle('active', currentVote === -1);
  }

  function setVoteButtonsDisabled(disabled) {
    const upBtn = q('.tm-eblo-vote-up', contentBox);
    const downBtn = q('.tm-eblo-vote-down', contentBox);
    if (upBtn) upBtn.disabled = disabled;
    if (downBtn) downBtn.disabled = disabled;
  }

  async function sendVote(direction) {
    if (!currentPostId) return;

    const csrf = getCsrfToken(document);
    if (!csrf) {
      console.error('[EbloModal] CSRF token not found');
      return;
    }

    const prevVote = currentVote;
    const prevScore = currentScore;

    let nextVote = prevVote;
    let nextScore = prevScore;

    if (direction === 'up') {
      if (prevVote === 1) {
        nextVote = 0;
        nextScore = prevScore - 1;
      } else if (prevVote === 0) {
        nextVote = 1;
        nextScore = prevScore + 1;
      } else if (prevVote === -1) {
        nextVote = 1;
        nextScore = prevScore + 2;
      }
    } else if (direction === 'down') {
      if (prevVote === -1) {
        nextVote = 0;
        nextScore = prevScore + 1;
      } else if (prevVote === 0) {
        nextVote = -1;
        nextScore = prevScore - 1;
      } else if (prevVote === 1) {
        nextVote = -1;
        nextScore = prevScore - 2;
      }
    }

    currentVote = nextVote;
    currentScore = nextScore;
    updateVoteUi();
    setVoteButtonsDisabled(true);

    try {
      const res = await fetch(`/api/post/${encodeURIComponent(currentPostId)}/vote`, {
        method: 'POST',
        credentials: 'include',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': csrf
        },
        body: JSON.stringify({ vote: direction })
      });

      if (!res.ok) {
        throw new Error(`Vote request failed: ${res.status}`);
      }

      let data = null;
      try {
        data = await res.json();
      } catch { }

      if (data && typeof data === 'object') {
        const maybeScore =
          data.score ??
          data.vote_score ??
          data.rating ??
          data.post_score;

        const maybeVote =
          data.user_vote ??
          data.vote_state ??
          data.current_vote;

        if (typeof maybeScore === 'number') {
          currentScore = maybeScore;
        }

        if (maybeVote === 'up' || maybeVote === 1) currentVote = 1;
        else if (maybeVote === 'down' || maybeVote === -1) currentVote = -1;
        else if (maybeVote === null || maybeVote === 0 || maybeVote === 'none') currentVote = 0;

        updateVoteUi();
      }
    } catch (err) {
      console.error('[EbloModal] vote error', err);
      currentVote = prevVote;
      currentScore = prevScore;
      updateVoteUi();
    } finally {
      setVoteButtonsDisabled(false);
    }
  }

  function bindDynamicActions() {
    const copyBtn = q('.tm-eblo-copy-link', contentBox);
    if (copyBtn) {
      copyBtn.addEventListener('click', async () => {
        try {
          await navigator.clipboard.writeText(copyBtn.dataset.url);
          copyBtn.textContent = 'Скопировано';
          setTimeout(() => {
            copyBtn.textContent = 'Скопировать ссылку';
          }, 1000);
        } catch (e) {
          console.error('[EbloModal] clipboard error', e);
        }
      });
    }

    const upBtn = q('.tm-eblo-vote-up', contentBox);
    const downBtn = q('.tm-eblo-vote-down', contentBox);

    if (upBtn) upBtn.addEventListener('click', () => sendVote('up'));
    if (downBtn) downBtn.addEventListener('click', () => sendVote('down'));

    // --- Gallery navigation (wired per-post) ---
    const track = q('.tm-eblo-gallery-track', contentBox);
    if (track) {
      let galIndex = 0;
      const galItems = Array.from(track.querySelectorAll('.tm-eblo-gallery-item'));
      const galWrappers = Array.from(track.querySelectorAll('.tm-eblo-item-wrapper'));
      const galPrev = q('.tm-gallery-prev', contentBox);
      const galNext = q('.tm-gallery-next', contentBox);

      const updateGallery = () => {
        track.style.transform = `translateX(-${galIndex * 100}%)`;
        if (galPrev) galPrev.style.display = galIndex > 0 ? 'flex' : 'none';
        if (galNext) galNext.style.display = galIndex < galItems.length - 1 ? 'flex' : 'none';
        galItems.forEach((item, i) => {
          if (item.tagName === 'VIDEO' && i !== galIndex) item.pause();
        });
        // autoplay video when it becomes active AND not blurred
        const activeItem = galItems[galIndex];
        const activeWrapper = galWrappers[galIndex];
        const isRevealed = activeWrapper && activeWrapper.classList.contains('is-revealed');
        const isBlurred = activeItem && activeItem.classList.contains('blurred');

        if (activeItem && activeItem.tagName === 'VIDEO' && (!isBlurred || isRevealed)) {
          applyMediaState(activeItem);
          activeItem.play().catch(() => { });
        }
      };

      if (galPrev) {
        galPrev.addEventListener('click', (e) => {
          e.preventDefault(); e.stopPropagation();
          if (galIndex > 0) { galIndex--; updateGallery(); }
        });
      }
      if (galNext) {
        galNext.addEventListener('click', (e) => {
          e.preventDefault(); e.stopPropagation();
          if (galIndex < galItems.length - 1) { galIndex++; updateGallery(); }
        });
      }

      // Handle Censorship Clicks
      track.addEventListener('click', (e) => {
        const censoredOverlay = e.target.closest('.tm-eblo-censored-overlay');
        if (censoredOverlay) {
          e.preventDefault(); e.stopPropagation();
          const wrapper = censoredOverlay.closest('.tm-eblo-item-wrapper');
          if (wrapper) {
            wrapper.classList.add('is-revealed');
            const video = wrapper.querySelector('video');
            if (video) {
              applyMediaState(video);
              video.play().catch(() => { });
            }
          }
        }
      });

      updateGallery();
    }

    // --- Video autoplay ---
    const video = q('.tm-eblo-video', contentBox);
    if (video && !track) {
      applyMediaState(video);
      video.play().catch(() => { });
    }
  }

  function timeAgo(ts) {
    if (!ts) return '';
    var now = Math.floor(Date.now() / 1000);
    var diff = now - ts;
    if (diff < 60) return 'только что';
    if (diff < 3600) return Math.floor(diff / 60) + ' мин назад';
    if (diff < 86400) return Math.floor(diff / 3600) + ' ч назад';
    if (diff < 604800) return Math.floor(diff / 86400) + ' дн назад';
    var d = new Date(ts * 1000);
    return d.toLocaleDateString('ru-RU');
  }

  async function fetchAndRenderComments(postId) {
    const listEl = q('#tm-eblo-comments-list', contentBox);
    const titleEl = q('#tm-eblo-comments-title', contentBox);
    if (!listEl) return;

    try {
      const res = await fetch(`/api/post/${encodeURIComponent(postId)}/comments?sort=${currentCommentSort}`);
      const data = await res.json();
      if (data.success) {
        const comments = data.comments || [];
        const total = data.total || 0;
        if (titleEl) titleEl.textContent = `Комментарии (${total})`;

        if (comments.length === 0) {
          listEl.innerHTML = '<div class="tm-eblo-login-prompt">Комментариев пока нет. Будьте первым!</div>';
          return;
        }

        let itemsHtml = '';
        function renderCommentObj(c, isReply = false) {
          if (c.is_deleted) return '';
          const avatar = c.author?.avatar || '/static/image/user.svg';
          const author = c.author?.name || c.author?.login || 'Пользователь';
          const authorHref = c.author?.login ? `/@${c.author.login}` : '#';
          const timeStr = timeAgo(c.created_at);

          let html = `
                        <div class="tm-eblo-comment-item ${isReply ? 'tm-eblo-comment-reply' : ''}">
                          <div class="tm-eblo-comment-vote-col">
                            <button class="tm-eblo-comment-vote-btn ${c.user_vote === 'up' || c.user_vote === 1 ? 'active' : ''}" data-action="up" data-id="${c.id}" type="button" title="Апвоут"><img src="/static/image/btn-arrow.svg" alt="up" class="btn-arrow arrow-up"></button>
                            <span class="tm-eblo-comment-score">${esc(String(c.score || 0))}</span>
                            <button class="tm-eblo-comment-vote-btn ${c.user_vote === 'down' || c.user_vote === -1 ? 'active' : ''}" data-action="down" data-id="${c.id}" type="button" title="Даунвоут"><img src="/static/image/btn-arrow.svg" alt="down" class="btn-arrow arrow-down"></button>
                          </div>
                          <div class="tm-eblo-comment-body">
                            <div class="tm-eblo-comment-meta">
                              <img class="tm-eblo-comment-author-avatar" src="${esc(avatar)}" alt="">
                              <a class="tm-eblo-comment-author" href="${esc(abs(authorHref))}" target="_blank" rel="noopener noreferrer">${esc(author)}</a>
                              <span class="tm-eblo-comment-time">${esc(timeStr)}</span>
                            </div>
                            <div class="tm-eblo-comment-text">${c.content || ''}</div>
                            <div class="tm-eblo-comment-actions" style="margin-top: 8px; display: flex; align-items: center; gap: 16px;">
                               <button class="tm-eblo-comment-action-btn tm-eblo-reply-btn" data-id="${c.id}" data-login="${c.author?.login || ''}" type="button" style="background: none; border: none; color: #a0a0a0; font-size: 0.9rem; cursor: pointer; padding: 0;">Ответить</button>
                            </div>
                          </div>
                        </div>
                    `;

          if (c.replies && c.replies.length > 0) {
            for (const rep of c.replies) {
              html += renderCommentObj(rep, true);
            }
          }
          return html;
        }

        for (const c of comments) {
          itemsHtml += renderCommentObj(c, false);
        }
        listEl.innerHTML = itemsHtml;

        listEl.querySelectorAll('.tm-eblo-comment-text').forEach(el => {
          const raw = el.textContent;
          if (window.EmotePicker && window.EmotePicker.loaded && raw.includes(':')) {
            window.EmotePicker.applyRender(el, raw);
          }
          if (window.HashtagUtils) {
            try { window.HashtagUtils.renderInElement(el); } catch (err) { }
          }
        });
      } else {
        listEl.innerHTML = '<div class="tm-eblo-login-prompt">Не удалось загрузить комментарии</div>';
      }
    } catch (e) {
      console.error('[EbloModal] load comments error', e);
      listEl.innerHTML = '<div class="tm-eblo-login-prompt">Ошибка сети при загрузке</div>';
    }
  }

  async function openPost(url, index) {
    openModalShell();
    currentIndex = index;

    const cards = getCards();
    const card = cards[index] || null;

    currentPostUrl = url;
    currentPostId = getPostIdFromUrl(url);
    updateNav();
    contentBox.innerHTML = '<div class="tm-eblo-loading">Загрузка поста...</div>';

    try {
      const doc = await fetchDoc(url);
      contentBox.innerHTML = buildPostHtml(doc, url, card);
      bindDynamicActions();

      if (window.EmotePicker) {
        const input = q('#tm-eblo-new-comment-text', contentBox);
        const emoteBtn = q('#tm-eblo-emote-picker-btn', contentBox);
        if (input && emoteBtn) {
          window.EmotePicker.attach(input);
          window.EmotePicker.attachPickerBtn(emoteBtn, input);
        }
      }

      history.replaceState({}, '', url);
      fetchAndRenderComments(currentPostId);
    } catch (e) {
      console.error('[EbloModal]', e);
      contentBox.innerHTML = `
        <div class="tm-eblo-error">
          Не удалось загрузить пост.<br><br>
          <a class="tm-eblo-action-btn" href="${esc(url)}" target="_blank" rel="noopener noreferrer">Открыть отдельно</a>
        </div>
      `;
    }
  }

  function goRelative(delta) {
    const currentVideo = q('.tm-eblo-video', contentBox);
    if (currentVideo) saveMediaState(currentVideo);

    const cards = getCards();
    const newIndex = currentIndex + delta;
    if (newIndex < 0 || newIndex >= cards.length) return;

    const url = getUrlFromCard(cards[newIndex]);
    if (!url) return;

    openPost(url, newIndex);
  }

  function injectOpenButtons() {
    for (const card of getCards()) {
      if (card.querySelector('.tm-eblo-open-post-btn')) continue;

      const url = getUrlFromCard(card);
      if (!url) continue;

      const target = card.querySelector('.feed-card-info') || card;
      const btn = document.createElement('a');
      btn.className = 'tm-eblo-open-post-btn';
      btn.href = url;
      btn.target = '_blank';
      btn.rel = 'noopener noreferrer';
      btn.textContent = 'Открыть пост';

      btn.addEventListener('click', (e) => {
        e.stopPropagation();
      });

      target.appendChild(btn);
    }
  }

  function handleCardOpen(event) {
    if (!isFeedPage()) return;
    if (event.button !== 0) return;
    if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;

    const openBtn = event.target.closest('.tm-eblo-open-post-btn');
    if (openBtn) return;

    const card = event.target.closest(CARD_SELECTOR);
    if (!card) return;

    event.preventDefault();
    event.stopPropagation();
    event.stopImmediatePropagation();

    const url = getUrlFromCard(card);
    if (!url) return;

    feedUrlBeforeOpen = location.href;
    openPost(url, getIndexByUrl(url));
  }

  function installInterceptors() {
    const types = ['click', 'auxclick', 'mousedown', 'mouseup', 'pointerdown', 'pointerup'];
    for (const type of types) {
      document.addEventListener(type, handleCardOpen, true);
    }
  }

  function observeFeed() {
    const mo = new MutationObserver(() => injectOpenButtons());
    mo.observe(document.documentElement, { childList: true, subtree: true });
    injectOpenButtons();
  }

  function init() {
    ensureModal();
    hookGlobalContentEvents();
    installInterceptors();
    observeFeed();
    console.log('[EbloModal] ready v5.1');
  }

  init();
})();