Greasy Fork is available in English.
Tampermonkey-скрипт для eblo.id: открывает посты в модалке поверх ленты, поддерживает просмотр фото и видео, навигацию между постами, закрытие по клику вне окна и переход к оригинальному посту.
// ==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();
})();