This commit is contained in:
2026-09-20 21:43:02 +02:00
parent 5601c282b7
commit 4e857fd5ef
26 changed files with 3567 additions and 142 deletions
+236 -8
View File
@@ -1559,9 +1559,11 @@
if (txt) ids.add(txt);
}
// 2. From URL pathname (/tag/:tag/:slugOrId, /user/:user/favs/:slugOrId, /user/:user/:slugOrId, /:slugOrId)
// 2. From URL pathname (/tag/:tag/:slugOrId, /user/:user/favs/:slugOrId, /user/:user/:slugOrId, /:slugOrId, /4/:board/:thread/:post)
const segments = window.location.pathname.split('/').filter(Boolean);
if (segments.length >= 2 && segments[0] === 'tag') {
if (segments.length >= 4 && segments[0] === '4') {
ids.add(decodeURIComponent(segments[3]).trim());
} else if (segments.length >= 2 && segments[0] === 'tag') {
if (segments[2]) {
ids.add(decodeURIComponent(segments[2]).trim());
}
@@ -1576,7 +1578,7 @@
ids.add(decodeURIComponent(segments[2]).trim());
}
} else if (segments.length === 1) {
const forbidden = ['s', 'b', 't', 'ca', 'a', 'login', 'register', 'settings', 'about', 'terms', 'rules', 'api', 'logout', 'auth', 'admin', 'mod', 'comments', 'notifications', 'feed', 'upload', 'tags', 'halls', 'ranking', 'abyss', 'random', 'scroller', 'p'];
const forbidden = ['s', 'b', 't', 'ca', 'a', '4', 'login', 'register', 'settings', 'about', 'terms', 'rules', 'api', 'logout', 'auth', 'admin', 'mod', 'comments', 'notifications', 'feed', 'upload', 'tags', 'halls', 'ranking', 'abyss', 'random', 'scroller', 'p'];
if (!forbidden.includes(segments[0])) {
ids.add(decodeURIComponent(segments[0]).trim());
}
@@ -2089,20 +2091,232 @@
}, { passive: true });
};
// ── Dedicated 4chan Thread Feed (mirrors Tag/Favs Feed for /4/:board/:thread context) ─────
let currentChanBoard = null;
let currentChanThread = null;
let chanFeedLoaded = false;
let chanFeedLoading = false;
const getCurrentChanContext = () => {
const m = window.location.pathname.match(/^\/4\/([a-z0-9_]+)\/(\d+)(?:\/(\d+))?/i);
if (m) {
return {
board: m[1].toLowerCase(),
thread: m[2],
post: m[3] || null
};
}
return null;
};
const highlightActiveChanCard = (scrollIntoView = false) => {
const chanCtx = getCurrentChanContext();
const container = document.getElementById('sidebar-chan-container');
if (!container || !chanCtx) return;
const currentPost = chanCtx.post || '';
let matchedCard = null;
container.querySelectorAll('.sidebar-video-card').forEach(card => {
const cardId = card.dataset.id ? String(card.dataset.id).trim() : '';
const isMatch = (currentPost && cardId === currentPost);
if (isMatch) {
card.classList.add('active-tag-item');
if (!matchedCard) matchedCard = card;
} else {
card.classList.remove('active-tag-item');
}
});
if (matchedCard && scrollIntoView) {
const isChanTabActive = document.querySelector('.sidebar-tab.active')?.dataset.tab === 'chan' &&
container.style.display !== 'none';
if (isChanTabActive) {
try {
const cr = container.getBoundingClientRect();
const mr = matchedCard.getBoundingClientRect();
const targetScrollTop = container.scrollTop + (mr.top - cr.top) - (container.clientHeight / 2) + (mr.height / 2);
container.scrollTo({ top: Math.max(0, targetScrollTop), behavior: 'smooth' });
} catch (_) {
matchedCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
};
const loadChanFeed = async (reset = false) => {
const chanCtx = getCurrentChanContext();
const container = document.getElementById('sidebar-chan-container');
const itemsContainer = container?.querySelector('.sidebar-chan-items-container');
if (!container || !itemsContainer || chanFeedLoading) return;
if (!chanCtx) return;
chanFeedLoading = true;
if (reset) {
itemsContainer.innerHTML = `
<div class="sidebar-loading-state">
<i class="fa-solid fa-circle-notch fa-spin"></i>
<span>Loading thread media...</span>
</div>
`;
}
try {
const res = await fetch(`/api/v2/chan/${encodeURIComponent(chanCtx.board)}/${encodeURIComponent(chanCtx.thread)}/media`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
const items = data.items || data.media || [];
if (data.success && items.length > 0) {
chanFeedLoaded = true;
// Update header
const countEl = container.querySelector('.sidebar-chan-count');
if (countEl) countEl.textContent = `(${items.length})`;
const nameEl = container.querySelector('.sidebar-chan-name');
if (nameEl) nameEl.textContent = `/${chanCtx.board}/${chanCtx.thread}`;
const overviewBtn = container.querySelector('.sidebar-chan-overview-btn');
if (overviewBtn) {
overviewBtn.href = `/4/${chanCtx.board}/${chanCtx.thread}`;
}
let html = '';
items.forEach(v => {
const isActive = chanCtx.post && String(v.no) === String(chanCtx.post);
const activeClass = isActive ? ' active-tag-item' : '';
const targetHref = `/4/${chanCtx.board}/${chanCtx.thread}/${v.no}`;
const formatBadge = v.ext ? `<span class="sidebar-media-format-badge">${escapeHtml(v.ext.replace('.', '').toUpperCase())}</span>` : '';
const displayTitle = v.filename ? `${v.filename}` : `#${v.no}`;
const overlayIcon = v.is_video ? 'fa-solid fa-play' : 'fa-solid fa-image';
const fsizeStr = v.fsize ? `${(v.fsize / 1024 / 1024).toFixed(1)} MB` : '';
html += `
<div class="sidebar-video-card${activeClass}" data-id="${v.no}">
<a href="${targetHref}" class="sidebar-video-link" data-inherit-context="false">
<div class="sidebar-video-thumb-wrap">
<img src="${escapeHtml(v.thumb)}" class="sidebar-video-thumb" alt="${escapeHtml(displayTitle)}" loading="lazy" draggable="false" onerror="this.style.display='none'; if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');" />
<div class="sidebar-media-placeholder hidden">
<i class="${overlayIcon}"></i>
</div>
<div class="sidebar-video-badges">
${formatBadge}
<span class="sidebar-video-badge" style="background: rgba(74, 222, 128, 0.2); color: #4ade80;">4CHAN</span>
</div>
</div>
<div class="sidebar-video-details">
<div class="sidebar-video-info">
<div class="sidebar-video-title" title="${escapeHtml(displayTitle)}">${escapeHtml(displayTitle)}</div>
<div class="sidebar-video-channel">
<span class="sidebar-video-user" style="color: #4ade80;">#${v.no}</span>
</div>
<div class="sidebar-video-meta">
${fsizeStr ? `<span class="sidebar-video-time">${escapeHtml(fsizeStr)}</span>` : ''}
${v.w && v.h ? `<span>${fsizeStr ? '&bull; ' : ''}${v.w}x${v.h}</span>` : ''}
</div>
</div>
</div>
</a>
</div>
`;
});
itemsContainer.innerHTML = html;
setTimeout(() => highlightActiveChanCard(true), 60);
} else if (reset) {
itemsContainer.innerHTML = `
<div class="sidebar-empty-state" style="text-align:center;padding:30px 15px;color:var(--text-muted,#888);font-size:0.88em;">
<i class="fa-solid fa-clover" style="font-size:1.8em;opacity:0.4;margin-bottom:8px;display:block;color:#4ade80;"></i>
No media found in thread
</div>
`;
}
} catch (err) {
console.error('[CHAN-FEED] Failed:', err);
if (reset) {
itemsContainer.innerHTML = `<div class="sidebar-empty-state" style="text-align:center;padding:25px;color:var(--danger,#ff4444);font-size:0.88em;">Failed to load thread media</div>`;
}
} finally {
chanFeedLoading = false;
}
};
const updateChanTabVisibility = (forceSwitchToChan = false) => {
const chanCtx = getCurrentChanContext();
const chanBtn = document.getElementById('sidebar-tab-chan');
if (!chanBtn) return;
if (chanCtx) {
chanBtn.style.display = 'inline-flex';
chanBtn.title = `/${chanCtx.board}/${chanCtx.thread}`;
if (currentChanBoard !== chanCtx.board || currentChanThread !== chanCtx.thread) {
currentChanBoard = chanCtx.board;
currentChanThread = chanCtx.thread;
chanFeedLoaded = false;
userManuallySelectedNonTagTab = false;
switchSidebarTab('chan');
} else {
if (forceSwitchToChan || (!userManuallySelectedNonTagTab && document.querySelector('.sidebar-tab.active')?.dataset.tab !== 'chan')) {
switchSidebarTab('chan');
} else {
highlightActiveChanCard(true);
}
}
} else {
chanBtn.style.display = 'none';
currentChanBoard = null;
currentChanThread = null;
chanFeedLoaded = false;
const activeTab = document.querySelector('.sidebar-tab.active')?.dataset.tab;
if (activeTab === 'chan') {
if (getCurrentTag()) {
switchSidebarTab('tag');
} else {
switchSidebarTab('recommendations');
}
}
}
};
const bindChanContainerEvents = () => {
const container = document.getElementById('sidebar-chan-container');
if (!container || container.dataset.eventsBound === 'true') return;
container.dataset.eventsBound = 'true';
container.addEventListener('click', (e) => {
const link = e.target.closest('a.sidebar-video-link');
if (link && link.href) {
e.preventDefault();
container.querySelectorAll('.sidebar-video-card').forEach(c => c.classList.remove('active-tag-item'));
const card = link.closest('.sidebar-video-card');
if (card) card.classList.add('active-tag-item');
if (typeof window.loadItemAjax === 'function') {
window.loadItemAjax(link.href, false);
} else {
window.location.href = link.href;
}
}
});
};
const switchSidebarTab = (tabName) => {
if (window.clearHoverPreview) {
window.clearHoverPreview();
}
const normalizedTab = (tabName === 'videos' || tabName === 'recommendations')
? 'recommendations'
: (tabName === 'tag' ? 'tag' : (tabName === 'favs' ? 'favs' : tabName));
: (tabName === 'tag' ? 'tag' : (tabName === 'favs' ? 'favs' : (tabName === 'chan' ? 'chan' : tabName)));
const tabs = document.querySelectorAll('.sidebar-tab');
const contents = document.querySelectorAll('.sidebar-tab-content');
tabs.forEach(t => {
const tTab = (t.dataset.tab === 'videos' || t.dataset.tab === 'recommendations')
? 'recommendations'
: (t.dataset.tab === 'tag' ? 'tag' : (t.dataset.tab === 'favs' ? 'favs' : t.dataset.tab));
: (t.dataset.tab === 'tag' ? 'tag' : (t.dataset.tab === 'favs' ? 'favs' : (t.dataset.tab === 'chan' ? 'chan' : t.dataset.tab)));
if (tTab === normalizedTab) {
t.classList.add('active');
} else {
@@ -2113,7 +2327,7 @@
contents.forEach(c => {
const cTab = (c.dataset.tabContent === 'videos' || c.dataset.tabContent === 'recommendations')
? 'recommendations'
: (c.dataset.tabContent === 'tag' ? 'tag' : (c.dataset.tabContent === 'favs' ? 'favs' : c.dataset.tabContent));
: (c.dataset.tabContent === 'tag' ? 'tag' : (c.dataset.tabContent === 'favs' ? 'favs' : (c.dataset.tabContent === 'chan' ? 'chan' : c.dataset.tabContent)));
if (cTab === normalizedTab) {
c.classList.add('active');
c.style.display = 'block';
@@ -2143,6 +2357,12 @@
} else {
setTimeout(() => highlightActiveFavsCard(true), 60);
}
} else if (normalizedTab === 'chan') {
if (!chanFeedLoaded) {
loadChanFeed(true);
} else {
setTimeout(() => highlightActiveChanCard(true), 60);
}
}
};
window.switchSidebarTab = switchSidebarTab;
@@ -2154,7 +2374,7 @@
const btn = e.target.closest('.sidebar-tab');
if (btn && btn.dataset.tab) {
e.preventDefault();
if (btn.dataset.tab !== 'tag' && btn.dataset.tab !== 'favs') {
if (btn.dataset.tab !== 'tag' && btn.dataset.tab !== 'favs' && btn.dataset.tab !== 'chan') {
userManuallySelectedNonTagTab = true;
} else {
userManuallySelectedNonTagTab = false;
@@ -2170,7 +2390,9 @@
savedTab = localStorage.getItem('sidebar_active_tab') || 'comments';
} catch (_) {}
if (getCurrentFavsUser()) {
if (getCurrentChanContext()) {
switchSidebarTab('chan');
} else if (getCurrentFavsUser()) {
switchSidebarTab('favs');
} else if (getCurrentTag()) {
switchSidebarTab('tag');
@@ -2410,22 +2632,28 @@
initTagOrderButton();
bindTagContainerEvents();
bindFavsContainerEvents();
bindChanContainerEvents();
updateTagTabVisibility();
updateFavsTabVisibility();
updateChanTabVisibility();
document.addEventListener('f0ck:contentLoaded', () => {
updateTagTabVisibility();
updateFavsTabVisibility();
updateChanTabVisibility();
setTimeout(() => {
highlightActiveTagCard(true);
highlightActiveFavsCard(true);
highlightActiveChanCard(true);
}, 60);
});
window.addEventListener('popstate', () => {
setTimeout(() => {
updateTagTabVisibility();
updateFavsTabVisibility();
updateChanTabVisibility();
highlightActiveTagCard(true);
highlightActiveFavsCard(true);
highlightActiveChanCard(true);
}, 60);
});
bindRecommendationEvents();