24 Commits

Author SHA1 Message Date
f803ec27a5 WIP Chat overhaul 2026-07-26 21:31:28 +02:00
e61e24dfe8 fix scroller tagging invalid csrf token 2026-07-26 21:20:00 +02:00
44af6ae061 mobile default tab selection 2026-07-26 20:45:16 +02:00
0739d72334 remove the fucking animations for new comments 2026-07-25 22:49:27 +02:00
848055446a enabling ipv6 for f0ckm-nginx 2026-07-25 22:38:34 +02:00
88e6b6efb9 persist hide item ratings 2026-07-25 21:52:47 +02:00
eea70ed62e fix quoting 2026-07-25 21:49:11 +02:00
b6c906636c bggg 2026-07-23 21:40:13 +02:00
55b5a0ba9f fuyo 2026-07-23 21:37:22 +02:00
5d60d80fc9 g 2026-07-23 21:35:36 +02:00
4dfb80b149 modern shit 2026-07-23 21:25:38 +02:00
b319f21cd4 favvv 2026-07-23 21:16:25 +02:00
28ed8d985c f0ck95d love 2026-07-23 21:14:32 +02:00
0f5e795793 fuck no 2026-07-23 20:51:06 +02:00
78d14a1b56 daisuki 2026-07-23 20:46:40 +02:00
3f149531da genki 2026-07-23 20:42:49 +02:00
7a4432277d fix tags page 2026-07-19 23:19:47 +02:00
84eee7e13d plastic surgery 2026-07-19 23:05:08 +02:00
2206b3eb86 fix modern layout 2026-07-19 23:01:28 +02:00
5529724982 sunshine daisy 2026-07-19 22:49:13 +02:00
9c6070afff xd 2026-07-19 19:53:37 +02:00
5bfa336773 staleness sucks 2026-07-19 19:44:54 +02:00
517444f9ce ignore svelte for prod 2026-07-19 19:43:21 +02:00
edb92ffa2a vert align top context link 2026-07-19 19:42:32 +02:00
22 changed files with 1922 additions and 623 deletions

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ public/tag_cache
config.json config.json
bundle.css bundle.css
public/s/fonts/impact.woff public/s/fonts/impact.woff
f0ck-svelte/*

View File

@@ -99,6 +99,8 @@ services:
container_name: f0ckm-nginx container_name: f0ckm-nginx
profiles: profiles:
- f0ckm-nginx - f0ckm-nginx
environment:
- ENABLE_IPV6=true
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,9 @@
/* Meme Creator Styles */ /* Meme Creator Styles */
.meme-creator-container { .meme-creator-container {
box-sizing: border-box !important;
padding: 20px; padding: 20px;
max-width: 1200px; max-width: 1100px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
position: relative; position: relative;
@@ -111,6 +112,7 @@
.canvas-wrapper { .canvas-wrapper {
flex: 1; flex: 1;
max-width: 650px;
min-width: 0; /* Allow shrinking */ min-width: 0; /* Allow shrinking */
background: #000; background: #000;
border: 2px solid var(--nav-bg, #2b2b2b); border: 2px solid var(--nav-bg, #2b2b2b);
@@ -238,22 +240,49 @@ canvas#memeCanvas {
color: var(--white, #fff); color: var(--white, #fff);
} }
/* Mobile Stacking - Simple 2-Row Layout */ /* Mobile Stacking - Clean Flexbox Column Layout */
@media (max-width: 950px) { @media (max-width: 950px) {
.meme-editor-layout { .meme-editor-layout {
display: grid !important; display: flex !important;
grid-template-columns: 1fr !important; flex-direction: column !important;
grid-template-rows: 0.6fr auto !important; align-items: center !important;
gap: 20px; gap: 20px;
} }
.meme-controls { .meme-controls {
box-sizing: border-box !important;
width: 100% !important; width: 100% !important;
grid-row: 2; max-width: 500px !important;
overflow: visible !important; overflow: visible !important;
} }
.canvas-wrapper { .canvas-wrapper {
box-sizing: border-box !important;
width: 100% !important; width: 100% !important;
grid-row: 1; max-width: 650px !important;
margin-bottom: 10px;
overflow: visible !important;
height: auto !important;
}
}
/* Stack early when sidebar is open (sidebar is 300px, so 950 + 300 = 1250px)
This prevents the canvas from shrinking too much when the sidebar takes space. */
@media (max-width: 1250px) {
body:not(.sidebar-right-hidden) .meme-editor-layout {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
gap: 20px;
}
body:not(.sidebar-right-hidden) .meme-controls {
box-sizing: border-box !important;
width: 100% !important;
max-width: 500px !important;
overflow: visible !important;
}
body:not(.sidebar-right-hidden) .canvas-wrapper {
box-sizing: border-box !important;
width: 100% !important;
max-width: 650px !important;
margin-bottom: 10px; margin-bottom: 10px;
overflow: visible !important; overflow: visible !important;
height: auto !important; height: auto !important;
@@ -294,20 +323,19 @@ canvas#memeCanvas {
/* Sidebar space reservation for meme pages */ /* Sidebar space reservation for meme pages */
.meme-layout-wrapper { .meme-layout-wrapper {
box-sizing: border-box !important;
width: 100%; width: 100%;
transition: padding-right 0.3s ease-in-out;
} }
@media (min-width: 1200px) { @media (min-width: 1000px) {
/* Reserve space for the fixed sidebar so content doesn't flow behind it */ /* Reserve space for the fixed sidebar so content doesn't flow behind it */
.meme-layout-wrapper { body:not(.sidebar-right-hidden) .meme-layout-wrapper {
padding-right: 300px; padding-right: 300px;
} }
/* Collapse reserved space when sidebar is hidden */
body.sidebar-right-hidden .meme-layout-wrapper {
padding-right: 0;
}
} }
/* Collapse reserved space when sidebar is hidden */
body.sidebar-right-hidden .meme-layout-wrapper {
padding-right: 0 !important;
}

View File

@@ -54,7 +54,7 @@ class CommentSystem {
document.body.classList.remove('sidebar-left-hidden'); document.body.classList.remove('sidebar-left-hidden');
const layout = this.container.closest('.item-layout-container'); const layout = this.container.closest('.item-layout-container');
if (layout) layout.classList.remove('sidebar-hidden'); if (layout) layout.classList.remove('sidebar-hidden');
} else if (isHidden) { } else if (isHidden && (window.innerWidth >= 1000 || !document.body.classList.contains('layout-modern'))) {
this.container.classList.add('faded-out'); this.container.classList.add('faded-out');
this.container.style.display = 'none'; this.container.style.display = 'none';
document.body.classList.add('sidebar-left-hidden'); document.body.classList.add('sidebar-left-hidden');
@@ -596,7 +596,7 @@ class CommentSystem {
if (cached) cached.content = fullContent; if (cached) cached.content = fullContent;
} }
contentEl.dataset.raw = this.escapeHtml(fullContent); contentEl.dataset.raw = fullContent;
contentEl.innerHTML = this.renderCommentContent(fullContent, commentId); contentEl.innerHTML = this.renderCommentContent(fullContent, commentId);
CommentSystem.autoplayConvertedGifs(contentEl); CommentSystem.autoplayConvertedGifs(contentEl);
CommentSystem.playEmojiVideos(contentEl); CommentSystem.playEmojiVideos(contentEl);
@@ -622,6 +622,7 @@ class CommentSystem {
const contentEl = el.querySelector('.comment-content'); const contentEl = el.querySelector('.comment-content');
if (contentEl) { if (contentEl) {
contentEl.dataset.raw = data.content;
contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id); contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id);
CommentSystem.autoplayConvertedGifs(contentEl); CommentSystem.autoplayConvertedGifs(contentEl);
CommentSystem.playEmojiVideos(contentEl); CommentSystem.playEmojiVideos(contentEl);
@@ -857,26 +858,14 @@ class CommentSystem {
* border-right) is fully restored after the effect completes. * border-right) is fully restored after the effect completes.
* *
* @param {Element} el - The .comment element to animate. * @param {Element} el - The .comment element to animate.
* @param {boolean} entering - True to also add 'comment-entering' (own post); * @param {boolean} entering - Unused (kept for compatibility).
* false for 'new-item-fade' only (live others).
*/ */
static _animateNewComment(el, entering = false) { static _animateNewComment(el, entering = false) {
if (!el) return; if (!el) return;
el.classList.add('new-item-fade');
const classes = entering setTimeout(() => {
? ['comment-entering', 'new-item-fade'] el.classList.remove('new-item-fade');
: ['new-item-fade']; }, 2500);
// Reset in case the element already has a stale animation
classes.forEach(c => el.classList.remove(c));
void el.offsetWidth; // force reflow so re-adding the class restarts the animation
classes.forEach(c => el.classList.add(c));
const cleanup = () => {
classes.forEach(c => el.classList.remove(c));
};
el.addEventListener('animationend', cleanup, { once: true });
} }
@@ -892,7 +881,6 @@ class CommentSystem {
if (el) { if (el) {
// Remove highlight from any previously highlighted or newly-posted comment // Remove highlight from any previously highlighted or newly-posted comment
document.querySelectorAll('.comment-highlighted').forEach(c => c.classList.remove('comment-highlighted')); document.querySelectorAll('.comment-highlighted').forEach(c => c.classList.remove('comment-highlighted'));
document.querySelectorAll('.comment-entering').forEach(c => c.classList.remove('comment-entering'));
document.querySelectorAll('.new-item-fade').forEach(c => c.classList.remove('new-item-fade')); document.querySelectorAll('.new-item-fade').forEach(c => c.classList.remove('new-item-fade'));
// Always smooth-scroll so there's no jarring jump // Always smooth-scroll so there's no jarring jump
@@ -1038,7 +1026,7 @@ class CommentSystem {
preview.dataset.id = targetId; preview.dataset.id = targetId;
// Remove temporary animation/highlight classes // Remove temporary animation/highlight classes
preview.classList.remove('new-item-fade', 'comment-highlighted', 'comment-entering'); preview.classList.remove('new-item-fade', 'comment-highlighted');
// Remove input forms or action buttons // Remove input forms or action buttons
const actions = preview.querySelector('.comment-actions'); const actions = preview.querySelector('.comment-actions');
@@ -1173,7 +1161,12 @@ class CommentSystem {
const contentEl = body.querySelector('.comment-content'); const contentEl = body.querySelector('.comment-content');
if (contentEl) { if (contentEl) {
const LINE_MAX = 200; const LINE_MAX = 200;
const rawText = (contentEl.dataset.raw || '').trim(); let rawText = (contentEl.dataset.raw || '').trim();
if (rawText.includes('>') || rawText.includes('<') || rawText.includes('&')) {
const txt = document.createElement('textarea');
txt.innerHTML = rawText;
rawText = txt.value;
}
// Preserve all lines but cap any single line exceeding LINE_MAX chars // Preserve all lines but cap any single line exceeding LINE_MAX chars
const lines = rawText.split('\n').map(line => const lines = rawText.split('\n').map(line =>
line.length > LINE_MAX ? line.substring(0, LINE_MAX) + '\u2026' : line line.length > LINE_MAX ? line.substring(0, LINE_MAX) + '\u2026' : line
@@ -1794,7 +1787,7 @@ class CommentSystem {
const renderedContent = quoteEmojis const renderedContent = quoteEmojis
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n)) ? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: quoteContent; : quoteContent;
return `<span class="greentext">&gt;${renderedContent}</span>`; return `<span class="greentext">&gt;${renderedContent.replace(/>/g, '&gt;')}</span>`;
} }
// 2. Per-line limit to prevent marked.parse recursion on single giant lines // 2. Per-line limit to prevent marked.parse recursion on single giant lines
@@ -2199,7 +2192,7 @@ class CommentSystem {
if (!unsafe) return ''; if (!unsafe) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = unsafe; div.textContent = unsafe;
return div.innerHTML; return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
} }
renderCommentAttachments(files, content = '') { renderCommentAttachments(files, content = '') {
@@ -3336,9 +3329,15 @@ class CommentSystem {
params.append('has_poll', '1'); params.append('has_poll', '1');
} }
const csrfToken = window.f0ckSession?.csrf_token || '';
if (csrfToken) params.append('csrf_token', csrfToken);
const res = await fetch('/api/comments', { const res = await fetch('/api/comments', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: params body: params
}); });
@@ -3808,9 +3807,12 @@ class CommentSystem {
toggleComments() { toggleComments() {
if (!this.container) return; if (!this.container) return;
if (document.body.classList.contains('layout-modern') && typeof window.toggleSidebarLeft === 'function') { if (document.body.classList.contains('layout-modern')) {
window.toggleSidebarLeft(); if (window.innerWidth < 1000) return;
return; if (typeof window.toggleSidebarLeft === 'function') {
window.toggleSidebarLeft();
return;
}
} }
const layout = this.container.closest('.item-layout-container'); const layout = this.container.closest('.item-layout-container');
@@ -4457,20 +4459,71 @@ class CommentSystem {
}, { passive: true }); }, { passive: true });
}; };
// Helper: reliable touch handling for tab buttons on mobile // Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
const addTabTouch = (btn, cb) => { let lastHoldTime = 0;
const addTabTouch = (btn, cb, onHold) => {
let ts = null; let ts = null;
let holdTimer = null;
let holdFired = false;
btn.addEventListener('touchstart', (e) => { btn.addEventListener('touchstart', (e) => {
holdFired = false;
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY }; ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
if (onHold) {
holdTimer = setTimeout(() => {
holdFired = true;
lastHoldTime = Date.now();
try { navigator.vibrate?.(40); } catch(err) {}
onHold();
}, 500);
}
e.stopPropagation(); e.stopPropagation();
}, { passive: true }); }, { passive: true });
btn.addEventListener('touchmove', (e) => {
if (!ts) return;
const dx = Math.abs(e.touches[0].clientX - ts.x);
const dy = Math.abs(e.touches[0].clientY - ts.y);
if (dx > 10 || dy > 10) {
clearTimeout(holdTimer);
ts = null;
}
}, { passive: true });
btn.addEventListener('touchend', (e) => { btn.addEventListener('touchend', (e) => {
clearTimeout(holdTimer);
if (holdFired) {
e.preventDefault();
e.stopPropagation();
holdFired = false;
ts = null;
return;
}
if (!ts) return; if (!ts) return;
const dx = Math.abs(e.changedTouches[0].clientX - ts.x); const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
const dy = Math.abs(e.changedTouches[0].clientY - ts.y); const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
ts = null; ts = null;
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); } if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
}); });
btn.addEventListener('touchcancel', () => {
clearTimeout(holdTimer);
holdFired = false;
ts = null;
});
};
const toggleFavDefault = (tabEl) => {
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
tabEl.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
tabEl.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
}; };
// Build tab buttons — Favorites first // Build tab buttons — Favorites first
@@ -4482,23 +4535,13 @@ class CommentSystem {
favTab.innerHTML = '<i class="fa-solid fa-star" style="color:var(--emoji-fav-color,#f5c518);font-size:1.1em;"></i>'; favTab.innerHTML = '<i class="fa-solid fa-star" style="color:var(--emoji-fav-color,#f5c518);font-size:1.1em;"></i>';
favTab.addEventListener('mousedown', e => e.preventDefault()); favTab.addEventListener('mousedown', e => e.preventDefault());
favTab.addEventListener('click', () => showTab('__favs__')); favTab.addEventListener('click', () => showTab('__favs__'));
addTabTouch(favTab, () => showTab('__favs__')); addTabTouch(favTab, () => showTab('__favs__'), () => toggleFavDefault(favTab));
// Right-click on fav tab → set/unset as default // Right-click on fav tab → set/unset as default
favTab.addEventListener('contextmenu', (e) => { favTab.addEventListener('contextmenu', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (navigator.maxTouchPoints > 0) return; if (Date.now() - lastHoldTime < 1000) return;
const KEY = 'f0ck_emoji_default_tab'; toggleFavDefault(favTab);
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
favTab.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
favTab.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
}); });
// Mark as default if already set // Mark as default if already set
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') { if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {

View File

@@ -2307,9 +2307,49 @@ window.cancelAnimFrame = (function () {
hidden = hiddenValue === 'true'; hidden = hiddenValue === 'true';
} }
document.body.classList.toggle('sidebar-right-hidden', hidden); document.body.classList.toggle('sidebar-right-hidden', hidden);
// Always force-hide on very small screens regardless of saved preference
if (window.innerWidth <= 599) {
document.body.classList.add('sidebar-right-hidden');
}
}; };
window.initSidebarRightToggle(); window.initSidebarRightToggle();
// Auto-hide sidebar on very small screens (≤599px), restore on wider
(() => {
const mq = window.matchMedia('(max-width: 599px)');
const applyBreakpoint = (e) => {
// Freeze transitions so the class change is instant (no sliding during resize)
const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
const wrappers = document.querySelectorAll('.index-layout-wrapper, .item-layout-container, .pagination-container-fluid, .pagewrapper');
if (sidebar) sidebar.style.setProperty('transition', 'none', 'important');
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
if (e.matches) {
// Entering narrow: force-hide without touching localStorage
document.body.classList.add('sidebar-right-hidden');
} else {
// Leaving narrow: restore from saved preference
const saved = localStorage.getItem('sidebarRightHidden');
const hidden = saved === 'true';
document.body.classList.toggle('sidebar-right-hidden', hidden);
}
// Force reflow then restore transitions
if (sidebar) void sidebar.offsetWidth;
requestAnimationFrame(() => {
if (sidebar) sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
});
};
mq.addEventListener('change', applyBreakpoint);
applyBreakpoint(mq); // run once on init
})();
const loadPageAjax = async (url, replace = true, options = {}) => { const loadPageAjax = async (url, replace = true, options = {}) => {
if (isNavigating) return; if (isNavigating) return;
@@ -2398,7 +2438,7 @@ window.cancelAnimFrame = (function () {
const isAdmin = !!pathname.match(/^\/admin/); const isAdmin = !!pathname.match(/^\/admin/);
const isMod = !!pathname.match(/^\/mod/); const isMod = !!pathname.match(/^\/mod/);
const isSettings = pathname.match(/\/settings\/?(?:$|\?)/); const isSettings = pathname.match(/\/settings\/?(?:$|\?)/);
const isStatic = pathname.match(/\/(about|rules|terms|upload|subscriptions|stats|docs|discord|ranking|meme|memes)($|\/|\?)/); const isStatic = pathname.match(/\/(about|rules|terms|upload|subscriptions|stats|docs|discord|ranking|meme|memes|chat)($|\/|\?)/);
const isUpload = pathname.match(/\/upload\/?(?:$|\?)/); const isUpload = pathname.match(/\/upload\/?(?:$|\?)/);
const parts = pathname.split('/').filter(Boolean); const parts = pathname.split('/').filter(Boolean);
const isItem = !pathname.match(/\/p\//) && ( const isItem = !pathname.match(/\/p\//) && (
@@ -2406,8 +2446,9 @@ window.cancelAnimFrame = (function () {
(parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && /^\d+$/.test(parts[parts.length - 1])) (parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && /^\d+$/.test(parts[parts.length - 1]))
); );
const isMessages = !!pathname.match(/^\/messages(\/|$)/); const isMessages = !!pathname.match(/^\/messages(\/|$)/);
const isChat = !!pathname.match(/^\/chat(\/|$)/);
const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss'; const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss';
const isGrid = !isProfile && !isUserHall && !isUserHalls && !isHall && !isHalls && !isTags && !isComments && !isNotifs && !isItem && !isAdmin && !isMod && !isSettings && !isStatic && !isUpload && !isMessages && !isAbyss; const isGrid = !isProfile && !isUserHall && !isUserHalls && !isHall && !isHalls && !isTags && !isComments && !isNotifs && !isItem && !isAdmin && !isMod && !isSettings && !isStatic && !isUpload && !isMessages && !isChat && !isAbyss;
if (isItem) { if (isItem) {
isNavigating = false; isNavigating = false;
@@ -4039,7 +4080,7 @@ window.cancelAnimFrame = (function () {
return false; return false;
} }
if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) { if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes|chat)/) || pathname.startsWith('/abyss'))) {
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
@@ -4427,6 +4468,10 @@ window.cancelAnimFrame = (function () {
if (window.renderTags) { if (window.renderTags) {
window.renderTags(res.tags); window.renderTags(res.tags);
} }
// Evict the cached item HTML so navigating back fetches fresh content
if (window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
} else { } else {
revert(); revert();
window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error'); window.flashMessage('Error: ' + (res.msg || 'Failed to update rating'), 3000, 'error');
@@ -4489,7 +4534,7 @@ window.cancelAnimFrame = (function () {
(parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) || (parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) ||
(parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1])) (parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1]))
); );
const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking'); const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking') || p.startsWith('/chat');
const isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/'; const isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/';
window.f0ckDebug("[popstate] Navigation to:", url, { isItem, isSpecial, isGridLike }); window.f0ckDebug("[popstate] Navigation to:", url, { isItem, isSpecial, isGridLike });
@@ -6277,8 +6322,10 @@ window.cancelAnimFrame = (function () {
if (settled) return; if (settled) return;
settled = true; settled = true;
// Freeze all transitions (override CSS !important) so the class toggle // Freeze sidebar AND wrapper transitions so the class toggle
// doesn't trigger an additional right-property animation. // doesn't trigger a second padding-right animation.
const wrappers = getSidebarWrappers();
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
sidebar.style.setProperty('transition', 'none', 'important'); sidebar.style.setProperty('transition', 'none', 'important');
if (stateChanging) { if (stateChanging) {
@@ -6296,6 +6343,11 @@ window.cancelAnimFrame = (function () {
// Restore CSS-controlled transitions on the next frame // Restore CSS-controlled transitions on the next frame
requestAnimationFrame(() => { requestAnimationFrame(() => {
sidebar.style.removeProperty('transition'); sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
swipeRT.isDraggingSidebar = false; swipeRT.isDraggingSidebar = false;
}); });
}; };
@@ -6533,6 +6585,10 @@ window.cancelAnimFrame = (function () {
const settle = () => { const settle = () => {
if (settled) return; if (settled) return;
settled = true; settled = true;
// Freeze sidebar AND wrapper transitions so the class toggle
// doesn't trigger a second padding-right animation.
const wrappers = getSidebarWrappers();
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
sidebar.style.setProperty('transition', 'none', 'important'); sidebar.style.setProperty('transition', 'none', 'important');
if (stateChanging) { if (stateChanging) {
@@ -6545,6 +6601,11 @@ window.cancelAnimFrame = (function () {
sidebar.style.transform = ''; sidebar.style.transform = '';
requestAnimationFrame(() => { requestAnimationFrame(() => {
sidebar.style.removeProperty('transition'); sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
}); });
}; };
@@ -6612,15 +6673,55 @@ window.cancelAnimFrame = (function () {
}); });
window.addEventListener('resize', syncEdgeZone, { passive: true }); window.addEventListener('resize', syncEdgeZone, { passive: true });
// Touchend on edgeZone: instant tap-to-toggle (no 300ms synthetic click delay) // Returns true if (clientX, clientY) is within the pill's hit area.
// The pill is #sidebar-drag-zone::after — 4×40px, centered on pointer:fine,
// right-aligned with padding-right:6px on pointer:coarse.
const isWithinPillArea = (clientX, clientY) => {
const rect = edgeZone.getBoundingClientRect();
const pillH = 40;
const hitPad = 12; // generous padding so it's easy to hit
// Vertical: pill is always centered in the drag zone
const pillCenterY = rect.top + rect.height / 2;
if (clientY < pillCenterY - pillH / 2 - hitPad || clientY > pillCenterY + pillH / 2 + hitPad) return false;
// Horizontal: right-aligned on touch, centered on mouse
const isCoarse = window.matchMedia('(pointer: coarse)').matches;
const pillCenterX = isCoarse ? rect.right - 6 - 2 : rect.left + rect.width / 2;
return clientX >= pillCenterX - 2 - hitPad && clientX <= pillCenterX + 2 + hitPad;
};
// Touchend: tap on the pill toggles sidebar; rest of drag zone only responds to swipe
edgeZone.addEventListener('touchend', e => { edgeZone.addEventListener('touchend', e => {
// Only if it was a clean tap (no significant swipe movement)
if (Math.abs(swipeRT.xDiff || 0) < 15 && Math.abs(swipeRT.yDiff || 0) < 15) { if (Math.abs(swipeRT.xDiff || 0) < 15 && Math.abs(swipeRT.yDiff || 0) < 15) {
e.preventDefault(); e.preventDefault(); // always prevent 300ms synthetic click
edgeZone.dispatchEvent(new Event('click')); const touch = e.changedTouches[0];
if (touch && isWithinPillArea(touch.clientX, touch.clientY)) {
window.toggleSidebarRight();
}
} }
}, { passive: false }); }, { passive: false });
// Helper: all layout wrappers that have padding-right animated by sidebar state
const getSidebarWrappers = () => {
// At ≤999px: sidebar is a pure overlay — only pagination tracks it.
if (window.innerWidth <= 599) {
return document.querySelectorAll('.pagination-container-fluid');
}
// At ≥600px: exclude pagewrapper when a dedicated layout wrapper is present.
// CSS already zeroes pagewrapper.padding-right via :has on those pages,
// so including it here causes double-padding (pagewrapper + index-layout-wrapper both
// get 300px snapped, making the grid 600px too narrow, then jumping back on settle).
const hasDedicatedWrapper = !!(
document.querySelector('.index-layout-wrapper') ||
document.querySelector('.item-layout-container') ||
document.querySelector('.meme-layout-wrapper') ||
document.querySelector('.messages-convo-page')
);
const sel = hasDedicatedWrapper
? '.index-layout-wrapper, .meme-layout-wrapper, .messages-convo-page, .pagination-container-fluid'
: '.pagination-container-fluid, .pagewrapper';
return document.querySelectorAll(sel);
};
// Smoothly toggle sidebar open/closed // Smoothly toggle sidebar open/closed
window.toggleSidebarRight = () => { window.toggleSidebarRight = () => {
const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right'); const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
@@ -6630,6 +6731,29 @@ window.cancelAnimFrame = (function () {
const sidebarWidth = sidebar.offsetWidth || 300; const sidebarWidth = sidebar.offsetWidth || 300;
const targetTranslate = isHidden ? -sidebarWidth : sidebarWidth; const targetTranslate = isHidden ? -sidebarWidth : sidebarWidth;
// At ≤599px the sidebar is a pure overlay — never animate wrapper padding
const animateWrappers = window.innerWidth > 599;
// Grid/pagewrapper: snap instantly in both directions (no animated reflow).
// Pagination always animates — it tracks sidebar position with no reflow cost.
const wrappers = getSidebarWrappers();
if (animateWrappers) {
wrappers.forEach(w => {
const isPagination = w.classList.contains('pagination-container-fluid');
const isMessages = w.classList.contains('messages-convo-page');
const prop = (isPagination || isMessages) ? 'right' : 'padding-right';
const targetVal = isHidden ? sidebarWidth + 'px' : '0px';
// Lightweight layouts (pagination, messages) animate smoothly.
// Heavy/reflow-intensive layouts snap instantly to avoid reflow lag.
const shouldAnimate = (isPagination || isMessages);
w.style.setProperty('transition', shouldAnimate ? `${prop} 0.3s ease-in-out` : 'none', 'important');
w.style.setProperty(prop, targetVal, 'important');
});
void sidebar.offsetWidth; // settle layout before sidebar transform begins
}
sidebar.style.setProperty('transition', 'transform 0.3s ease-in-out', 'important'); sidebar.style.setProperty('transition', 'transform 0.3s ease-in-out', 'important');
sidebar.style.transform = `translateX(${targetTranslate}px)`; sidebar.style.transform = `translateX(${targetTranslate}px)`;
@@ -6637,13 +6761,22 @@ window.cancelAnimFrame = (function () {
const settle = () => { const settle = () => {
if (settled) return; if (settled) return;
settled = true; settled = true;
// Freeze wrapper transitions so the class commit doesn't re-trigger them
wrappers.forEach(w => w.style.setProperty('transition', 'none', 'important'));
sidebar.style.setProperty('transition', 'none', 'important'); sidebar.style.setProperty('transition', 'none', 'important');
const toHidden = !isHidden; const toHidden = !isHidden;
document.body.classList.toggle('sidebar-right-hidden', toHidden); document.body.classList.toggle('sidebar-right-hidden', toHidden);
localStorage.setItem('sidebarRightHidden', toHidden); localStorage.setItem('sidebarRightHidden', toHidden);
void sidebar.offsetWidth; void sidebar.offsetWidth;
sidebar.style.transform = ''; sidebar.style.transform = '';
requestAnimationFrame(() => sidebar.style.removeProperty('transition')); requestAnimationFrame(() => {
sidebar.style.removeProperty('transition');
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
});
}; };
const onEnd = ev => { const onEnd = ev => {
@@ -6655,9 +6788,11 @@ window.cancelAnimFrame = (function () {
setTimeout(settle, 350); setTimeout(settle, 350);
}; };
// Click on edgeZone: smoothly toggle sidebar open/closed // Click on edgeZone: only toggle when the click lands on the pill
edgeZone.addEventListener('click', () => { edgeZone.addEventListener('click', (e) => {
window.toggleSidebarRight(); if (isWithinPillArea(e.clientX, e.clientY)) {
window.toggleSidebarRight();
}
}); });
// </mouse-sidebar-drag> // </mouse-sidebar-drag>
@@ -6677,7 +6812,9 @@ window.cancelAnimFrame = (function () {
}); });
const isLSActive = () => const isLSActive = () =>
document.body.classList.contains('layout-modern') && !!document.querySelector('.item-sidebar-left'); document.body.classList.contains('layout-modern') &&
!!document.querySelector('.item-sidebar-left') &&
window.innerWidth >= 1000;
// Prepare sidebar for drag — keeps it in the CSS grid so overflow:hidden clips translateX. // Prepare sidebar for drag — keeps it in the CSS grid so overflow:hidden clips translateX.
// OPEN: removes sidebar-hidden/display:none, places sidebar at translateX(-sw), reflows twice. // OPEN: removes sidebar-hidden/display:none, places sidebar at translateX(-sw), reflows twice.
@@ -6936,6 +7073,7 @@ window.cancelAnimFrame = (function () {
}; };
window.toggleSidebarLeft = () => { window.toggleSidebarLeft = () => {
if (window.innerWidth < 1000) return;
const { sb, layout } = getLS(); const { sb, layout } = getLS();
if (!sb || !layout) return; if (!sb || !layout) return;
const isHidden = layout.classList.contains('sidebar-hidden'); const isHidden = layout.classList.contains('sidebar-hidden');

File diff suppressed because it is too large Load Diff

View File

@@ -1976,6 +1976,73 @@ if (window.__dmLoaded) {
dmGridArea = document.createElement('div'); dmGridArea = document.createElement('div');
dmGridArea.className = 'emoji-picker-grid'; dmGridArea.className = 'emoji-picker-grid';
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
let lastDmHoldTime = 0;
const addDmTabTouch = (btn, cb, onHold) => {
let ts = null;
let holdTimer = null;
let holdFired = false;
btn.addEventListener('touchstart', (e) => {
holdFired = false;
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
if (onHold) {
holdTimer = setTimeout(() => {
holdFired = true;
lastDmHoldTime = Date.now();
try { navigator.vibrate?.(40); } catch(err) {}
onHold();
}, 500);
}
e.stopPropagation();
}, { passive: true });
btn.addEventListener('touchmove', (e) => {
if (!ts) return;
const dx = Math.abs(e.touches[0].clientX - ts.x);
const dy = Math.abs(e.touches[0].clientY - ts.y);
if (dx > 10 || dy > 10) {
clearTimeout(holdTimer);
ts = null;
}
}, { passive: true });
btn.addEventListener('touchend', (e) => {
clearTimeout(holdTimer);
if (holdFired) {
e.preventDefault();
e.stopPropagation();
holdFired = false;
ts = null;
return;
}
if (!ts) return;
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
ts = null;
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
});
btn.addEventListener('touchcancel', () => {
clearTimeout(holdTimer);
holdFired = false;
ts = null;
});
};
const toggleDmFavDefault = (tabEl) => {
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
tabEl.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
tabEl.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
};
// ── Favorites tab ── // ── Favorites tab ──
const favsTab = document.createElement('button'); const favsTab = document.createElement('button');
favsTab.className = 'ep-tab ep-tab-favs'; favsTab.className = 'ep-tab ep-tab-favs';
@@ -1985,22 +2052,13 @@ if (window.__dmLoaded) {
favsTab.innerHTML = '<i class="fa-solid fa-star" style="font-size:14px;color:var(--emoji-fav-color,#f5c518);"></i>'; favsTab.innerHTML = '<i class="fa-solid fa-star" style="font-size:14px;color:var(--emoji-fav-color,#f5c518);"></i>';
favsTab.addEventListener('mousedown', e => e.preventDefault()); favsTab.addEventListener('mousedown', e => e.preventDefault());
favsTab.addEventListener('click', () => showDmTab('__favs__', packs)); favsTab.addEventListener('click', () => showDmTab('__favs__', packs));
addDmTabTouch(favsTab, () => showDmTab('__favs__', packs), () => toggleDmFavDefault(favsTab));
// Right-click on fav tab → set/unset as default // Right-click on fav tab → set/unset as default
favsTab.addEventListener('contextmenu', (e) => { favsTab.addEventListener('contextmenu', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (navigator.maxTouchPoints > 0) return; if (Date.now() - lastDmHoldTime < 1000) return;
const KEY = 'f0ck_emoji_default_tab'; toggleDmFavDefault(favsTab);
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
favsTab.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
favsTab.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
}); });
// Mark as default if already set // Mark as default if already set
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') { if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
@@ -2028,6 +2086,7 @@ if (window.__dmLoaded) {
} }
tab.addEventListener('mousedown', e => e.preventDefault()); tab.addEventListener('mousedown', e => e.preventDefault());
tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs)); tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs));
addDmTabTouch(tab, () => showDmTab(pack.id ?? null, packs));
dmTabBar.appendChild(tab); dmTabBar.appendChild(tab);
}); });

View File

@@ -1000,9 +1000,10 @@
e.stopPropagation(); e.stopPropagation();
const itemId = slide.dataset.id; const itemId = slide.dataset.id;
try { try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, { const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'x-csrf-token': window.scrollerCsrf || '' } headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
}); });
const data = await resp.json(); const data = await resp.json();
if (data.success) pill.remove(); if (data.success) pill.remove();
@@ -1157,10 +1158,14 @@
if (nowFaved) flashFav(slide); if (nowFaved) flashFav(slide);
} }
try { try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch('/api/v2/togglefav', { const resp = await fetch('/api/v2/togglefav', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
body: `postid=${id}` 'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
}); });
const data = await resp.json(); const data = await resp.json();
// Sync count to server truth (handles race conditions) // Sync count to server truth (handles race conditions)
@@ -1582,9 +1587,10 @@
e.stopPropagation(); e.stopPropagation();
const itemId = slide.dataset.id; const itemId = slide.dataset.id;
try { try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, { const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'x-csrf-token': window.scrollerCsrf || '' } headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
}); });
const data = await resp.json(); const data = await resp.json();
if (data.success) pill.remove(); if (data.success) pill.remove();
@@ -1850,11 +1856,12 @@
else if (item.external_board === 'gif') rating = 'nsfw'; else if (item.external_board === 'gif') rating = 'nsfw';
try { try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch('/api/v2/scroller/rehost', { const resp = await fetch('/api/v2/scroller/rehost', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'x-csrf-token': window.scrollerCsrf || '' ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
}, },
body: new URLSearchParams({ body: new URLSearchParams({
url: item.external_media_url || item.dest, url: item.external_media_url || item.dest,
@@ -1966,9 +1973,10 @@
showShareToast(info.toast); showShareToast(info.toast);
} }
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
fetch(`/api/v2/tags/${id}/cycle-rating`, { fetch(`/api/v2/tags/${id}/cycle-rating`, {
method: 'PUT', method: 'PUT',
headers: { 'x-csrf-token': window.scrollerCsrf || '' } headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -2119,7 +2127,12 @@
const contentEl = commentEl.querySelector('.comment-content'); const contentEl = commentEl.querySelector('.comment-content');
if (!contentEl) return; if (!contentEl) return;
const raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim(); let raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
if (raw.includes('&gt;') || raw.includes('&lt;') || raw.includes('&amp;')) {
const txt = document.createElement('textarea');
txt.innerHTML = raw;
raw = txt.value;
}
const lines = raw.split('\n'); const lines = raw.split('\n');
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`; const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
@@ -2787,11 +2800,16 @@
if (!content || !commentsItemId || commentsPosting) return; if (!content || !commentsItemId || commentsPosting) return;
commentsPosting = true; commentSendBtn.disabled = true; commentsPosting = true; commentSendBtn.disabled = true;
try { try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`; let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`;
if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`; if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`;
if (csrfToken) postBody += `&csrf_token=${encodeURIComponent(csrfToken)}`;
const resp = await fetch('/api/comments', { const resp = await fetch('/api/comments', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: postBody body: postBody
}); });
const data = await resp.json(); const data = await resp.json();
@@ -2899,10 +2917,14 @@
if (addTagSendBtn) addTagSendBtn.disabled = true; if (addTagSendBtn) addTagSendBtn.disabled = true;
closeSugg(); closeSugg();
try { try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${targetId}`, { const resp = await fetch(`/api/v2/tags/${targetId}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
body: `tagname=${encodeURIComponent(tag)}` 'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: `tagname=${encodeURIComponent(tag)}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
}); });
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
@@ -3658,7 +3680,11 @@
if (sMarkAll) { if (sMarkAll) {
sMarkAll.addEventListener('click', async () => { sMarkAll.addEventListener('click', async () => {
try { try {
await fetch('/api/notifications/read', { method: 'POST' }); const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
await fetch('/api/notifications/read', {
method: 'POST',
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
});
updateScrollerNotifBadge(0); updateScrollerNotifBadge(0);
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true })); sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
updateScrollerTabBadges(sCachedNotifs); updateScrollerTabBadges(sCachedNotifs);
@@ -3673,7 +3699,12 @@
if (!item) return; if (!item) return;
const nid = item.dataset.id; const nid = item.dataset.id;
if (nid && item.classList.contains('unread')) { if (nid && item.classList.contains('unread')) {
fetch(`/api/notifications/${nid}/read`, { method: 'POST', keepalive: true }).catch(() => {}); const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
fetch(`/api/notifications/${nid}/read`, {
method: 'POST',
keepalive: true,
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
}).catch(() => {});
item.classList.remove('unread'); item.classList.remove('unread');
// Update cache // Update cache
const cached = sCachedNotifs.find(n => String(n.id) === String(nid)); const cached = sCachedNotifs.find(n => String(n.id) === String(nid));

View File

@@ -47,7 +47,7 @@
if (!unsafe) return ''; if (!unsafe) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = unsafe; div.textContent = unsafe;
return div.innerHTML; return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}; };
const playSidebarEmojiVideos = (container) => { const playSidebarEmojiVideos = (container) => {
@@ -271,7 +271,7 @@
const rendered = quoteEmojis const rendered = quoteEmojis
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n)) ? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
: quoteContent; : quoteContent;
return `<span class="greentext">&gt;${rendered}</span>`; return `<span class="greentext">&gt;${rendered.replace(/>/g, '&gt;')}</span>`;
} }
// Per-line limit to prevent marked.parse recursion on single giant lines // Per-line limit to prevent marked.parse recursion on single giant lines

View File

@@ -306,7 +306,7 @@ if (!window.UserCommentSystem) {
const trimmed = line.trimStart(); const trimmed = line.trimStart();
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) { if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1); const quoteContent = line.substring(line.indexOf('>') + 1);
return `<span class="greentext">&gt;${quoteContent}</span>`; return `<span class="greentext">&gt;${quoteContent.replace(/>/g, '&gt;')}</span>`;
} }
// Per-line limit // Per-line limit
@@ -450,7 +450,7 @@ if (!window.UserCommentSystem) {
if (!unsafe) return ''; if (!unsafe) return '';
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = unsafe; div.textContent = unsafe;
return div.innerHTML; return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
} }
} }
} }

View File

@@ -4,6 +4,7 @@
"meme": "Meme", "meme": "Meme",
"halls": "Hallen", "halls": "Hallen",
"tags": "Tags", "tags": "Tags",
"chat": "Chat",
"search": "Suchen", "search": "Suchen",
"random": "Zufall", "random": "Zufall",
"profile": "Profil", "profile": "Profil",

View File

@@ -4,6 +4,7 @@
"meme": "Meme", "meme": "Meme",
"halls": "Halls", "halls": "Halls",
"tags": "Tags", "tags": "Tags",
"chat": "Chat",
"search": "Search", "search": "Search",
"random": "Random", "random": "Random",
"profile": "Profile", "profile": "Profile",

View File

@@ -4,6 +4,7 @@
"meme": "meme", "meme": "meme",
"halls": "Hallen", "halls": "Hallen",
"tags": "Tags", "tags": "Tags",
"chat": "Chat",
"search": "Zoeken", "search": "Zoeken",
"random": "Willekeurig", "random": "Willekeurig",
"profile": "profiel", "profile": "profiel",

View File

@@ -4,6 +4,7 @@
"meme": "Memen", "meme": "Memen",
"halls": "Hallen", "halls": "Hallen",
"tags": "Etiketten", "tags": "Etiketten",
"chat": "Geschwafel",
"search": "Suche", "search": "Suche",
"random": "Zufall", "random": "Zufall",
"profile": "Profil", "profile": "Profil",

View File

@@ -25,7 +25,8 @@ export default router => {
group.post(/$/, lib.loggedin, async (req, res) => { group.post(/$/, lib.loggedin, async (req, res) => {
// assign and/or create tag // assign and/or create tag
if (!req.params.postid || !req.post.tagname) { const rawTagname = req.post?.tagname || req.body?.tagname;
if (!req.params.postid || !rawTagname) {
return res.json({ return res.json({
success: false, success: false,
msg: 'missing postid or tag' msg: 'missing postid or tag'
@@ -33,7 +34,7 @@ export default router => {
} }
const postid = +req.params.postid; const postid = +req.params.postid;
const tagname = req.post.tagname?.trim(); const tagname = rawTagname.trim();
const protectedTags = ['sfw', 'nsfw', 'nsfl']; const protectedTags = ['sfw', 'nsfw', 'nsfl'];
if (protectedTags.includes(tagname.toLowerCase())) { if (protectedTags.includes(tagname.toLowerCase())) {

View File

@@ -47,6 +47,25 @@ function buildBgHostRegex() {
export default (router, tpl) => { export default (router, tpl) => {
// GET /chat — Dedicated site chat page
router.get('/chat', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: 'Not found' });
}
if (!req.session) {
return res.redirect('/login');
}
if (req.session.banned) {
return res.reply({ code: 403, body: 'Access denied: You are banned' });
}
return res.html(tpl.render('chat', {
session: req.session,
hidePagination: true,
link: { main: '/chat', path: '/chat' },
domain: cfg.main.url.domain
}, req));
});
// GET /api/chat — fetch recent messages // GET /api/chat — fetch recent messages
router.get('/api/chat', async (req, res) => { router.get('/api/chat', async (req, res) => {
if (!cfg.websrv.enable_global_chat) { if (!cfg.websrv.enable_global_chat) {

View File

@@ -95,7 +95,7 @@ export default (router, tpl) => {
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) { if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
return res.json({ return res.json({
success: true, success: true,
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req), html: tpl.render('tag-cards', { toptags: tags, user: req.session?.user ? { user: req.session.user } : null, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
currentPage: page, currentPage: page,
hasMore: tags.length === TAGS_PER_PAGE hasMore: tags.length === TAGS_PER_PAGE
}); });
@@ -128,6 +128,7 @@ export default (router, tpl) => {
phrase, phrase,
tmp: null, tmp: null,
hidePagination: true, hidePagination: true,
user: req.session?.user ? { user: req.session.user } : null,
session: (req.session && req.session.user) ? { ...req.session } : false, session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: { page_meta: {
title: 'Tags', title: 'Tags',

9
views/chat.html Normal file
View File

@@ -0,0 +1,9 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="chat-page">
<div id="site-chat-container" class="site-chat-container">
<!-- Site chat widget is mounted here by globalchat.js -->
</div>
</div>
</div>
@include(snippets/footer)

View File

@@ -8,7 +8,7 @@
.meme-title { font-family: 'Impact', 'VCR', sans-serif; } .meme-title { font-family: 'Impact', 'VCR', sans-serif; }
/* NUCLEAR VISIBILITY RESET: Override any and all parent constraints for this page */ /* NUCLEAR VISIBILITY RESET: Override any and all parent constraints for this page */
@media (max-width: 950px) { @media (max-width: 1250px) {
/* Target all possible shell triggers */ /* Target all possible shell triggers */
html, body, .pagewrapper, #main, .layout-modern react-wrapper, .layout-legacy react-wrapper { html, body, .pagewrapper, #main, .layout-modern react-wrapper, .layout-legacy react-wrapper {
height: auto !important; height: auto !important;

View File

@@ -8,6 +8,7 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{{ domain }}"> <meta name="apple-mobile-web-app-title" content="{{ domain }}">
<script>(function(){var h=document.documentElement;if(localStorage.getItem('hideItemRatings')==='true')h.classList.add('hide-item-ratings-active');if(localStorage.getItem('blurNsfw')==='true')h.classList.add('blur-nsfw-active');if(localStorage.getItem('blurNsfl')==='true')h.classList.add('blur-nsfl-active');if(localStorage.getItem('blurSfw')==='true')h.classList.add('blur-sfw-active');if(localStorage.getItem('blurUntagged')==='true')h.classList.add('blur-untagged-active');if(localStorage.getItem('blurDetail')!=='false')h.classList.add('blur-detail-active');if(localStorage.getItem('imageExpandOnClick')!=='false')h.classList.add('image-expand-active');})();</script>
<style> <style>
html { background-color: #000; color: #fff; } html { background-color: #000; color: #fff; }
@if(session && session.font) @if(session && session.font)
@@ -22,6 +23,7 @@
} }
@endif @endif
:root { :root {
--favicon-url: url('@if(custom_favicon && custom_favicon.length > 0){{ custom_favicon }}@else/s/img/favicon.gif@endif');
@if(session && session.font) @if(session && session.font)
--font: 'CustomUserFont', monospace !important; --font: 'CustomUserFont', monospace !important;
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0) @elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)

View File

@@ -26,6 +26,9 @@
</div> </div>
@endif @endif
<a href="/tags" title="{{ t('nav.tags') }}"><i class="fa-solid fa-tags"></i></a> <a href="/tags" title="{{ t('nav.tags') }}"><i class="fa-solid fa-tags"></i></a>
@if(enable_global_chat)
<a href="/chat" title="{{ t('nav.chat') }}"><i class="fa-solid fa-comments"></i></a>
@endif
@if(abyss_enabled) @if(abyss_enabled)
<a href="/abyss" title="{{ t('nav.abyss') }}"><i class="fa-solid fa-dice-d6"></i></a> <a href="/abyss" title="{{ t('nav.abyss') }}"><i class="fa-solid fa-dice-d6"></i></a>
@endif @endif