60 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
3344064030 tags 2026-07-19 07:37:13 +02:00
e998ba8fc7 chickendöner 2026-07-19 03:30:52 +02:00
6b087efab3 xd 2026-07-19 02:12:58 +02:00
6c9f019646 350 2026-07-19 02:11:05 +02:00
d35437373f possible fix for 502 2026-07-19 00:15:36 +02:00
6efb67f2f5 fuck this auto tagging 2026-07-18 23:44:03 +02:00
fecb0f0b0c tag depths 2026-07-18 23:33:25 +02:00
d89c610dcb gfd 2026-07-18 23:09:28 +02:00
93c042251a set default font via config 2026-07-18 22:16:56 +02:00
990de4b710 vertalign 2026-07-18 22:03:33 +02:00
2f9ad4d349 gfd 2026-07-18 17:21:04 +02:00
701bee7a9a fdsa 2026-07-18 17:15:35 +02:00
4b7fba11ad smaller font = better 2026-07-18 17:11:40 +02:00
6f8eb6f83f g 2026-07-18 17:07:55 +02:00
d78b31633a bbb 2026-07-18 16:52:01 +02:00
294cf2455a hide sidebar yes no 2026-07-18 16:48:55 +02:00
663ba13d18 starto 2026-07-18 16:30:22 +02:00
84c3f1ccda gfsd 2026-07-18 16:18:22 +02:00
4dc7f10ae2 kibort 2026-07-18 16:15:47 +02:00
3ff1edf8bf I do love my keyboard 2026-07-18 16:11:06 +02:00
9ad6eaefec gfsd 2026-07-18 16:01:44 +02:00
d4eb0e52d8 confusion 2026-07-18 15:58:59 +02:00
ce54ed460d fugg :D 2026-07-18 15:40:12 +02:00
cddc763c44 consistency refe 2026-07-18 15:36:49 +02:00
61036558a0 Revert "awesome!!"
This reverts commit 8f0e0d9b3a.
2026-07-18 15:03:02 +02:00
3354baf09f Revert "ff"
This reverts commit c3c485af79.
2026-07-18 15:01:01 +02:00
c3c485af79 ff 2026-07-18 14:59:46 +02:00
8f0e0d9b3a awesome!! 2026-07-18 14:45:46 +02:00
fefd3b6ca6 gdf 2026-07-18 14:37:40 +02:00
5818a0ff39 dragging!! 2026-07-18 14:34:18 +02:00
036a9d372d im pathetic at this point 2026-07-18 14:27:04 +02:00
bef96160db the most pathetic fix to exist yet 2026-07-18 14:24:21 +02:00
a99c17c16b actually useful fix 2026-07-18 14:08:50 +02:00
469f699dd7 button fligger 2026-07-18 13:59:43 +02:00
d3f8c87317 stripping source identifier for IG 2026-07-18 13:54:04 +02:00
568555eab8 62% 2026-07-18 11:18:10 +02:00
40 changed files with 2380 additions and 754 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

@@ -53,11 +53,13 @@
"amoled" "amoled"
], ],
"theme": "amoled", "theme": "amoled",
"default_font": "",
"default_layout": "legacy", "default_layout": "legacy",
"custom_favicon": "/s/img/favicon.gif", "custom_favicon": "/s/img/favicon.gif",
"custom_brand_image": [], "custom_brand_image": [],
"custom_navbar_brand_text": "", "custom_navbar_brand_text": "",
"show_koepfe": false, "show_koepfe": false,
"hide_sidebar_default": true,
"koepfe": [], "koepfe": [],
"enable_global_chat": true, "enable_global_chat": true,
"enable_danmaku": true, "enable_danmaku": true,

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 */ /* Collapse reserved space when sidebar is hidden */
body.sidebar-right-hidden .meme-layout-wrapper { body.sidebar-right-hidden .meme-layout-wrapper {
padding-right: 0; padding-right: 0 !important;
}
} }

View File

@@ -155,7 +155,13 @@
postid, postid,
existingTags: tags, existingTags: tags,
anchorEl: anchor, anchorEl: anchor,
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }), onSubmit: async (tag) => {
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
if (res.success && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
return res;
},
renderTags renderTags
}); });
}; };

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');
@@ -126,14 +126,43 @@ class CommentSystem {
const container = document.getElementById('comments-container'); const container = document.getElementById('comments-container');
if (!container) return; if (!container) return;
const rect = container.getBoundingClientRect(); const currentlyAtBottom = btn.classList.contains('is-at-bottom');
// Detect if we are at the bottom of the comments section let isAtBottom = currentlyAtBottom;
const isAtBottom = rect.bottom < window.innerHeight + 100;
if (isAtBottom) { // Base the bottom state primarily on whether the input box / end-of-thread marker is visible
const bottomElement = container.querySelector('.main-input') || container.querySelector('.lock-notice') || container.querySelector('.login-placeholder');
if (bottomElement) {
const bottomRect = bottomElement.getBoundingClientRect();
if (currentlyAtBottom) {
// We are pointing UP. Switch to DOWN if we scroll significantly UP away from bottomElement
if (bottomRect.top > window.innerHeight + 250) {
isAtBottom = false;
}
} else {
// We are pointing DOWN. Switch to UP if bottomElement enters viewport or we scroll past it
if (bottomRect.top <= window.innerHeight + 50) {
isAtBottom = true;
}
}
} else {
// Fallback to container rect if no bottom element
const rect = container.getBoundingClientRect();
if (currentlyAtBottom) {
if (rect.bottom > window.innerHeight + 250) {
isAtBottom = false;
}
} else {
if (rect.bottom <= window.innerHeight + 50) {
isAtBottom = true;
}
}
}
if (isAtBottom && !currentlyAtBottom) {
btn.classList.add('is-at-bottom'); btn.classList.add('is-at-bottom');
btn.setAttribute('title', 'Scroll to top'); btn.setAttribute('title', 'Scroll to top');
} else { } else if (!isAtBottom && currentlyAtBottom) {
btn.classList.remove('is-at-bottom'); btn.classList.remove('is-at-bottom');
btn.setAttribute('title', 'Scroll to bottom'); btn.setAttribute('title', 'Scroll to bottom');
} }
@@ -567,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);
@@ -593,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);
@@ -828,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 });
} }
@@ -863,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
@@ -1009,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');
@@ -1144,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('&gt;') || rawText.includes('&lt;') || rawText.includes('&amp;')) {
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
@@ -1765,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
@@ -2170,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 = '') {
@@ -3307,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
}); });
@@ -3779,6 +3807,14 @@ class CommentSystem {
toggleComments() { toggleComments() {
if (!this.container) return; if (!this.container) return;
if (document.body.classList.contains('layout-modern')) {
if (window.innerWidth < 1000) 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');
const sidebar = this.container.closest('.item-sidebar-left'); const sidebar = this.container.closest('.item-sidebar-left');
const siblings = sidebar ? [ const siblings = sidebar ? [
@@ -4423,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
@@ -4448,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

@@ -46,11 +46,19 @@
return; return;
} }
// Mark internal drags to prevent them from triggering the dropzone
window.addEventListener('dragstart', (e) => {
if (e.dataTransfer) {
e.dataTransfer.setData('application/x-f0ck-internal', 'true');
}
});
// Global Drag Events // Global Drag Events
window.addEventListener('dragenter', (e) => { window.addEventListener('dragenter', (e) => {
e.preventDefault(); e.preventDefault();
if (window.location.pathname === '/upload') return; if (window.location.pathname === '/upload') return;
if (e.dataTransfer.types.includes('Files')) { if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
dragCounter++; dragCounter++;
dropOverlay.classList.add('active'); dropOverlay.classList.add('active');
} }
@@ -59,10 +67,17 @@
window.addEventListener('dragover', (e) => { window.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
if (window.location.pathname === '/upload') return; if (window.location.pathname === '/upload') return;
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) {
// Change dropEffect to none to indicate this dropzone doesn't accept internal elements
e.dataTransfer.dropEffect = 'none';
}
}); });
window.addEventListener('dragleave', (e) => { window.addEventListener('dragleave', (e) => {
if (window.location.pathname === '/upload') return; if (window.location.pathname === '/upload') return;
// dragleave doesn't always have reliable dataTransfer.types, but if it does, check it
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
dragCounter--; dragCounter--;
if (dragCounter <= 0) { if (dragCounter <= 0) {
dragCounter = 0; dragCounter = 0;
@@ -72,6 +87,8 @@
window.addEventListener('drop', (e) => { window.addEventListener('drop', (e) => {
if (window.location.pathname === '/upload') return; if (window.location.pathname === '/upload') return;
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
e.preventDefault(); e.preventDefault();
dragCounter = 0; dragCounter = 0;
dropOverlay.classList.remove('active'); dropOverlay.classList.remove('active');

View File

@@ -2301,15 +2301,55 @@ window.cancelAnimFrame = (function () {
let hiddenValue = localStorage.getItem('sidebarRightHidden'); let hiddenValue = localStorage.getItem('sidebarRightHidden');
let hidden; let hidden;
if (hiddenValue === null) { if (hiddenValue === null) {
hidden = false; // Default to visible on all devices hidden = (window.f0ckSession && window.f0ckSession.hide_sidebar_default) ? true : false; // Default based on config
localStorage.setItem('sidebarRightHidden', hidden); localStorage.setItem('sidebarRightHidden', hidden);
} else { } else {
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;
@@ -2603,6 +2644,10 @@ window.cancelAnimFrame = (function () {
if (isFav) ajaxUrl += `&fav=true`; if (isFav) ajaxUrl += `&fav=true`;
if (mime) ajaxUrl += `&mime=${encodeURIComponent(mime)}`; if (mime) ajaxUrl += `&mime=${encodeURIComponent(mime)}`;
// Preserve tagger filter from URL query string
const taggerMatch = urlObj.searchParams.get('tagger');
if (taggerMatch) ajaxUrl += `&tagger=${encodeURIComponent(taggerMatch)}`;
const isRandom = document.cookie.includes('random_mode=1') || url.includes('random=1') || window.location.search.includes('random=1'); const isRandom = document.cookie.includes('random_mode=1') || url.includes('random=1') || window.location.search.includes('random=1');
if (isRandom) ajaxUrl += `&random=1`; if (isRandom) ajaxUrl += `&random=1`;
const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true'); const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true');
@@ -3144,6 +3189,9 @@ window.cancelAnimFrame = (function () {
window.location.href = url; // Fallback window.location.href = url; // Fallback
} finally { } finally {
isNavigating = false; isNavigating = false;
if (replace && document.activeElement && document.activeElement !== document.body) {
document.activeElement.blur();
}
if (navbar) navbar.classList.remove("pbwork"); if (navbar) navbar.classList.remove("pbwork");
if (window.updateStrictLinks && window.f0ckSession) window.updateStrictLinks(window.f0ckSession.strict_mode); if (window.updateStrictLinks && window.f0ckSession) window.updateStrictLinks(window.f0ckSession.strict_mode);
} }
@@ -3324,10 +3372,15 @@ window.cancelAnimFrame = (function () {
const itemid = numericSegments.pop(); const itemid = numericSegments.pop();
// Extract context from Target URL first // Extract context from Target URL first
let tag = null, user = null, isFavs = false, mime = null, hall = null, userHall = null, userHallOwner = null; let tag = null, user = null, isFavs = false, mime = null, hall = null, userHall = null, userHallOwner = null, tagger = null;
const tagMatch = url.match(/\/tag\/([^/?]+)/); const tagMatch = url.match(/\/tag\/([^/?]+)/);
if (tagMatch) tag = decodeURIComponent(tagMatch[1]); if (tagMatch) tag = decodeURIComponent(tagMatch[1]);
// Tagger filter from query string
const taggerUrlObj = new URL(url, window.location.origin);
const taggerParam = taggerUrlObj.searchParams.get('tagger');
if (taggerParam) tagger = decodeURIComponent(taggerParam);
// User hall: /user/:owner/hall/:slug/:id // User hall: /user/:owner/hall/:slug/:id
const userHallMatch = url.match(/\/user\/([^/]+)\/hall\/([^/]+)/); const userHallMatch = url.match(/\/user\/([^/]+)\/hall\/([^/]+)/);
if (userHallMatch) { if (userHallMatch) {
@@ -3365,6 +3418,10 @@ window.cancelAnimFrame = (function () {
const wTagMatch = window.location.href.match(/\/tag\/([^/?]+)/); const wTagMatch = window.location.href.match(/\/tag\/([^/?]+)/);
if (wTagMatch) tag = decodeURIComponent(wTagMatch[1]); if (wTagMatch) tag = decodeURIComponent(wTagMatch[1]);
} }
if (!tagger) {
const wTaggerParam = new URL(window.location.href).searchParams.get('tagger');
if (wTaggerParam) tagger = decodeURIComponent(wTaggerParam);
}
if (!user && !userHall) { if (!user && !userHall) {
const wUserMatch = window.location.href.match(/\/user\/([^/?]+)/); const wUserMatch = window.location.href.match(/\/user\/([^/?]+)/);
if (wUserMatch && !window.location.href.match(/\/user\/[^/]+\/hall\//) ) { if (wUserMatch && !window.location.href.match(/\/user\/[^/]+\/hall\//) ) {
@@ -3400,6 +3457,7 @@ window.cancelAnimFrame = (function () {
} else if (user) { } else if (user) {
params.append('user', user); params.append('user', user);
} }
if (tagger) params.append('tagger', tagger);
if (isFavs) params.append('fav', 'true'); if (isFavs) params.append('fav', 'true');
const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true'); const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true');
@@ -3499,6 +3557,7 @@ window.cancelAnimFrame = (function () {
else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`; } else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`; }
else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemid}`; else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemid}`;
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemid}`; else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemid}`;
if (tagger && tag) _pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`); if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
if (_hash) _pushUrl += _hash; if (_hash) _pushUrl += _hash;
@@ -3701,6 +3760,8 @@ window.cancelAnimFrame = (function () {
} }
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemid}`; else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemid}`;
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemid}`; else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemid}`;
// Append tagger filter so item nav stays in tagger context
if (tagger && tag) pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
if (mime) { if (mime) {
// If it already has itemid at the end, insert mime before it // If it already has itemid at the end, insert mime before it
@@ -4019,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();
@@ -4407,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');
@@ -4469,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 });
@@ -6257,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) {
@@ -6276,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;
}); });
}; };
@@ -6402,22 +6474,13 @@ window.cancelAnimFrame = (function () {
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');
if (!sidebar) return; if (!sidebar) return;
mouseRT.active = true;
mouseRT.isDragging = false;
mouseRT.isCandidate = false;
mouseRT.xDown = e.clientX;
mouseRT.yDown = e.clientY;
mouseRT.xDiff = 0;
mouseRT.yDiff = 0;
mouseRT.timeDown = Date.now();
mouseRT.sidebarEl = sidebar;
mouseRT.sidebarWidth = sidebar.offsetWidth;
const isHidden = document.body.classList.contains('sidebar-right-hidden'); const isHidden = document.body.classList.contains('sidebar-right-hidden');
let candidate = false;
if (isHidden) { if (isHidden) {
// Open zone: 80px from right edge // Open zone: 80px from right edge
if (e.clientX > window.innerWidth - 30) { if (e.clientX > window.innerWidth - 30) {
mouseRT.isCandidate = true; candidate = true;
} }
} else { } else {
// Close: only from the left-edge grab strip (first 16px of sidebar) OR the edgeZone overhang // Close: only from the left-edge grab strip (first 16px of sidebar) OR the edgeZone overhang
@@ -6425,9 +6488,22 @@ window.cancelAnimFrame = (function () {
const sidebarLeft = sidebar.getBoundingClientRect().left; const sidebarLeft = sidebar.getBoundingClientRect().left;
const inGrabStrip = e.clientX >= sidebarLeft && e.clientX <= sidebarLeft + 16; const inGrabStrip = e.clientX >= sidebarLeft && e.clientX <= sidebarLeft + 16;
if (inGrabStrip || e.target === dragZone) { if (inGrabStrip || e.target === dragZone) {
candidate = true;
}
}
if (!candidate) return;
mouseRT.active = true;
mouseRT.isDragging = false;
mouseRT.isCandidate = true; mouseRT.isCandidate = true;
} mouseRT.xDown = e.clientX;
} mouseRT.yDown = e.clientY;
mouseRT.xDiff = 0;
mouseRT.yDiff = 0;
mouseRT.timeDown = Date.now();
mouseRT.sidebarEl = sidebar;
mouseRT.sidebarWidth = sidebar.offsetWidth;
}); });
document.addEventListener('mousemove', e => { document.addEventListener('mousemove', e => {
@@ -6447,6 +6523,8 @@ window.cancelAnimFrame = (function () {
window.getSelection()?.removeAllRanges(); // clear any selection that formed before threshold window.getSelection()?.removeAllRanges(); // clear any selection that formed before threshold
} else if (absY > absX) { } else if (absY > absX) {
mouseRT.isCandidate = false; // Scrolling — bail mouseRT.isCandidate = false; // Scrolling — bail
mouseRT.active = false;
return;
} }
} }
@@ -6507,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) {
@@ -6519,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');
});
}); });
}; };
@@ -6586,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');
@@ -6604,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)`;
@@ -6611,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 => {
@@ -6629,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) => {
if (isWithinPillArea(e.clientX, e.clientY)) {
window.toggleSidebarRight(); window.toggleSidebarRight();
}
}); });
// </mouse-sidebar-drag> // </mouse-sidebar-drag>
@@ -6651,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.
@@ -6887,7 +7050,10 @@ window.cancelAnimFrame = (function () {
// ── Edge zone & sync ──────────────────────────────────────────────────── // ── Edge zone & sync ────────────────────────────────────────────────────
const syncLeftEdgeZone = () => { const syncLeftEdgeZone = () => {
const { sb, layout } = getLS(); const { sb, layout } = getLS();
if (!sb || !layout || !document.body.classList.contains('layout-modern')) { // The left drag zone only applies to the 3-column desktop grid (≥1000px).
// On mobile the sidebar becomes in-flow, so hide the zone to avoid it
// landing next to the right-sidebar drag zone on narrow screens.
if (!sb || !layout || !document.body.classList.contains('layout-modern') || window.innerWidth < 1000) {
leftEdgeZone.style.display = 'none'; leftEdgeZone.style.display = 'none';
leftEdgeZone.style.pointerEvents = 'none'; leftEdgeZone.style.pointerEvents = 'none';
return; return;
@@ -6907,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');
@@ -6944,6 +7111,19 @@ window.cancelAnimFrame = (function () {
// Export loaders for external systems (like NotificationSystem) // Export loaders for external systems (like NotificationSystem)
window.loadPageAjax = loadPageAjax; window.loadPageAjax = loadPageAjax;
window.loadItemAjax = loadItemAjax; window.loadItemAjax = loadItemAjax;
// Invalidate all itemCacheMap entries that contain a given item ID.
// Call this after mutating an item (e.g. adding a tag) so the next
// AJAX navigation fetches fresh HTML instead of serving stale cache.
window.invalidateItemCache = (itemId) => {
if (!itemId) return;
const needle = `/${itemId}`;
for (const key of itemCacheMap.keys()) {
if (key.includes(needle)) {
itemCacheMap.delete(key);
}
}
};
// Continued... // Continued...
const sbtForm = document.getElementById('sbtForm'); const sbtForm = document.getElementById('sbtForm');
@@ -9382,22 +9562,24 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
window.startHoverPreview = startPreview; window.startHoverPreview = startPreview;
document.addEventListener('mouseover', (e) => { const handleEnter = (e) => {
const thumb = e.target.closest('.thumb'); const thumb = e.target.closest('.thumb');
// If we are hovering a thumb, and it's NOT the active one
if (thumb && thumb !== activeThumb) { if (thumb && thumb !== activeThumb) {
startPreview(thumb, 150); startPreview(thumb, 150);
} }
}); };
document.addEventListener('mouseover', handleEnter);
document.addEventListener('focusin', handleEnter);
document.addEventListener('mouseout', (e) => { const handleLeave = (e) => {
const thumb = e.target.closest('.thumb'); const thumb = e.target.closest('.thumb');
if (thumb) { if (thumb) {
if (e.relatedTarget && thumb.contains(e.relatedTarget)) return; if (e.type === 'mouseout' && e.relatedTarget && thumb.contains(e.relatedTarget)) return;
clearPreview(); clearPreview();
} }
}); };
document.addEventListener('mouseout', handleLeave);
document.addEventListener('focusout', handleLeave);
// Touch handling to support "tap to activate" behavior // Touch handling to support "tap to activate" behavior
// 1. First tap: Activate (visuals + preview) AND prevent navigation // 1. First tap: Activate (visuals + preview) AND prevent navigation
@@ -10188,14 +10370,21 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
// Only expand if the image is actually being clipped (not fully visible). // Only expand if the image is actually being clipped (not fully visible).
// Compare the image's rendered height to its natural height —
// if rendered >= natural, the image already fits fully in the box.
const img = wrapper.querySelector('img#f0ck-image'); const img = wrapper.querySelector('img#f0ck-image');
if (img && img.complete && img.naturalHeight > 0) { if (img && img.complete && img.naturalHeight > 0 && img.naturalWidth > 0) {
const rendered = img.getBoundingClientRect(); const rendered = img.getBoundingClientRect();
// Allow a 2px tolerance for subpixel rounding
// Calculate the max height the image can take given the container width
const expandedHeight = rendered.width * (img.naturalHeight / img.naturalWidth);
if (rendered.height + 2 >= img.naturalHeight) { if (rendered.height + 2 >= img.naturalHeight) {
// Fully visible — do nothing (no expand needed, no modal) // Image is unscaled or upscaled. No detail to gain by expanding.
return;
}
if (rendered.height + 2 >= expandedHeight) {
// Expanding inline will not increase display size because it is width-constrained.
openImageModal(elfe.href);
return; return;
} }
} }
@@ -11268,3 +11457,90 @@ document.addEventListener('click', (e) => {
} }
}, true); }, true);
})(); })();
// --- Grid Keyboard Accessibility ---
document.addEventListener('keydown', (e) => {
const isTab = e.key === 'Tab';
const isArrow = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key);
if (!isTab && !isArrow) return;
const thumbs = Array.from(document.querySelectorAll('.posts .thumb'));
if (thumbs.length === 0) return;
if (isTab && document.activeElement === document.body) {
e.preventDefault();
thumbs[0].focus();
return;
}
const active = document.activeElement;
if (isArrow && active && active.classList.contains('thumb')) {
const activeRect = active.getBoundingClientRect();
const activeCenter = {
x: activeRect.left + activeRect.width / 2,
y: activeRect.top + activeRect.height / 2
};
let bestMatch = null;
let minDistance = Infinity;
thumbs.forEach(thumb => {
if (thumb === active) return;
const rect = thumb.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
const center = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
const dxCenter = center.x - activeCenter.x;
const dyCenter = center.y - activeCenter.y;
const absDx = Math.abs(dxCenter);
const absDy = Math.abs(dyCenter);
let isCandidate = false;
let primaryDist = 0;
let secondaryGap = 0;
if (e.key === 'ArrowRight') {
// Must be to the right, and within a 60 degree cone
isCandidate = dxCenter > 0 && absDx >= absDy * 0.5;
primaryDist = absDx;
// Gap on the Y axis. If they overlap, gap is 0
secondaryGap = Math.max(0, Math.max(activeRect.top, rect.top) - Math.min(activeRect.bottom, rect.bottom));
} else if (e.key === 'ArrowLeft') {
isCandidate = dxCenter < 0 && absDx >= absDy * 0.5;
primaryDist = absDx;
secondaryGap = Math.max(0, Math.max(activeRect.top, rect.top) - Math.min(activeRect.bottom, rect.bottom));
} else if (e.key === 'ArrowDown') {
isCandidate = dyCenter > 0 && absDy >= absDx * 0.5;
primaryDist = absDy;
secondaryGap = Math.max(0, Math.max(activeRect.left, rect.left) - Math.min(activeRect.right, rect.right));
} else if (e.key === 'ArrowUp') {
isCandidate = dyCenter < 0 && absDy >= absDx * 0.5;
primaryDist = absDy;
secondaryGap = Math.max(0, Math.max(activeRect.left, rect.left) - Math.min(activeRect.right, rect.right));
}
if (isCandidate) {
// Massive penalty for gap to force it to pick overlapping items (same row/col)
// Tiny penalty for center misalignment so ties are broken cleanly
const centerMisalignment = (e.key === 'ArrowLeft' || e.key === 'ArrowRight') ? absDy : absDx;
const distance = primaryDist + (secondaryGap * 15) + (centerMisalignment * 0.1);
if (distance < minDistance) {
minDistance = distance;
bestMatch = thumb;
}
}
});
if (bestMatch) {
e.preventDefault();
bestMatch.focus();
bestMatch.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
});

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();
@@ -1688,7 +1694,7 @@
avatar: '/a/default.png', avatar: '/a/default.png',
stamp: p.time, stamp: p.time,
timeago: timeAgo(p.time * 1000), timeago: timeAgo(p.time * 1000),
tags: `4chan, /${data.board}/`, tags: ``,
is_video: isVideo, is_video: isVideo,
is_image: isImage, is_image: isImage,
is_audio: false, is_audio: false,
@@ -1850,16 +1856,17 @@
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,
rating: rating, rating: rating,
tags: '4chan', tags: '',
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`, comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`,
...(item.original_filename ? { original_filename: item.original_filename } : {}) ...(item.original_filename ? { original_filename: item.original_filename } : {})
}) })
@@ -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
@@ -496,8 +496,8 @@
|| rawContent.split('\n').length > 2 || rawContent.split('\n').length > 2
|| (c.files && c.files.length > 0); || (c.files && c.files.length > 0);
} }
// Always force isLong if there are inline media tags that expand vertically // Always force isLong if there are inline media tags that expand vertically or files are attached
if (!isLong && (/\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(rawContent) || displayContent.includes('<video') || displayContent.includes('<img'))) { if (!isLong && ((c.files && c.files.length > 0) || /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(rawContent) || displayContent.includes('<video') || displayContent.includes('<img'))) {
isLong = true; isLong = true;
} }
@@ -587,7 +587,7 @@
const isExpanded = container.classList.contains('expanded'); const isExpanded = container.classList.contains('expanded');
const scrollHeight = inner.scrollHeight; const scrollHeight = inner.scrollHeight;
const clientHeight = inner.clientHeight; const clientHeight = inner.clientHeight;
const hasUnloadedImages = Array.from(inner.querySelectorAll('img:not(.emoji), video')).some( const hasUnloadedImages = Array.from(inner.querySelectorAll('img, video')).some(
media => { media => {
if (media.tagName === 'IMG') { if (media.tagName === 'IMG') {
return (!media.complete || media.naturalHeight === 0) && media.dataset.error !== 'true'; return (!media.complete || media.naturalHeight === 0) && media.dataset.error !== 'true';
@@ -639,8 +639,8 @@
const attachMediaLoadListeners = (element) => { const attachMediaLoadListeners = (element) => {
// Only target images and videos inside comment-content-inner. // Only target images and videos inside comment-content-inner.
// Avatars, emojis, and item preview thumbnails have fixed sizes and do not affect text overflow. // Avatars and item preview thumbnails have fixed sizes and do not affect text overflow.
element.querySelectorAll('.comment-content-inner img:not(.emoji), .comment-content-inner video').forEach(media => { element.querySelectorAll('.comment-content-inner img, .comment-content-inner video').forEach(media => {
if (media.dataset.loadListenerBound) return; if (media.dataset.loadListenerBound) return;
media.dataset.loadListenerBound = 'true'; media.dataset.loadListenerBound = 'true';

View File

@@ -120,7 +120,13 @@
postid, postid,
existingTags: tags, existingTags: tags,
anchorEl: anchor, anchorEl: anchor,
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }), onSubmit: async (tag) => {
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
if (res.success && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
return res;
},
renderTags renderTags
}); });
}; };

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

@@ -5,33 +5,33 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
<div class="v0ck_progress_filled"></div> <div class="v0ck_progress_filled"></div>
<div class="v0ck_seek_marker"></div> <div class="v0ck_seek_marker"></div>
</div> </div>
<button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play"> <button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play" tabindex="-1">
<svg style="width: 20px; height: 20px;"> <svg style="width: 20px; height: 20px;">
<use id="v0ck_svg_play" href="${svg}#play"></use> <use id="v0ck_svg_play" href="${svg}#play"></use>
<use id="v0ck_svg_pause" class="v0ck_hidden" href="${svg}#pause"></use> <use id="v0ck_svg_pause" class="v0ck_hidden" href="${svg}#pause"></use>
</svg> </svg>
</button> </button>
<div class="v0ck_volume_group"> <div class="v0ck_volume_group">
<button class="v0ck_player_button v0ck_volume"> <button class="v0ck_player_button v0ck_volume" tabindex="-1">
<svg style="width: 20px; height: 20px;"> <svg style="width: 20px; height: 20px;">
<use id="v0ck_svg_volume_full" href="${svg}#volume_full"></use> <use id="v0ck_svg_volume_full" href="${svg}#volume_full"></use>
<use id="v0ck_svg_volume_mid" class="v0ck_hidden" href="${svg}#volume_mid"></use> <use id="v0ck_svg_volume_mid" class="v0ck_hidden" href="${svg}#volume_mid"></use>
<use id="v0ck_svg_volume_mute" class="v0ck_hidden" href="${svg}#volume_mute"></use> <use id="v0ck_svg_volume_mute" class="v0ck_hidden" href="${svg}#volume_mute"></use>
</svg> </svg>
</button> </button>
<input type="range" name="volume" min="0" max="1" step="0.01" value="1" /> <input type="range" name="volume" min="0" max="1" step="0.01" value="1" tabindex="-1" />
</div> </div>
<button class="v0ck_player_button v0ck_playtime">00:00 / 00:00</button> <button class="v0ck_player_button v0ck_playtime" tabindex="-1">00:00 / 00:00</button>
<span style="flex: 30"></span> <span style="flex: 30"></span>
<div class="v0ck_settings_container"> <div class="v0ck_settings_container">
<button class="v0ck_player_button v0ck_settings_btn" title="Settings"> <button class="v0ck_player_button v0ck_settings_btn" title="Settings" tabindex="-1">
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;"> <svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/> <path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
</svg> </svg>
</button> </button>
<div class="v0ck_settings_menu v0ck_hidden"> <div class="v0ck_settings_menu v0ck_hidden">
<button id="toggleswf" class="v0ck_menu_item" title="Flash Yank">SWF</button> <button id="toggleswf" class="v0ck_menu_item" title="Flash Yank" tabindex="-1">SWF</button>
<div class="v0ck_menu_item v0ck_bg_row"> <div class="v0ck_menu_item v0ck_bg_row">
<span class="v0ck_switch_label">Background</span> <span class="v0ck_switch_label">Background</span>
<div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div> <div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div>
@@ -44,11 +44,11 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
<span class="v0ck_switch_label">Danmaku</span> <span class="v0ck_switch_label">Danmaku</span>
<div id="toggledanmaku" class="v0ck_cool_switch" title="Toggle Danmaku comments"></div> <div id="toggledanmaku" class="v0ck_cool_switch" title="Toggle Danmaku comments"></div>
</div> </div>
<button id="v0ck_download" class="v0ck_menu_item" title="Download File">Download${size ? ` (${size})` : ''}</button> <button id="v0ck_download" class="v0ck_menu_item" title="Download File" tabindex="-1">Download${size ? ` (${size})` : ''}</button>
</div> </div>
</div> </div>
<button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen"> <button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen" tabindex="-1">
<svg style="width: 20px; height: 20px;"><use id="v0ck_svg_fullscreen" href="${svg}#fullscreen"></use></svg> <svg style="width: 20px; height: 20px;"><use id="v0ck_svg_fullscreen" href="${svg}#fullscreen"></use></svg>
</button> </button>
</div> </div>

View File

@@ -30,7 +30,7 @@ const resolvePath = (defaultRel) => {
const absStorage = path.resolve(storage); const absStorage = path.resolve(storage);
if (defaultRel.startsWith('public/')) { if (defaultRel.startsWith('public/')) {
const sub = defaultRel.replace('public/', ''); const sub = defaultRel.replace('public/', '');
if (sub === 's/emojis' || sub === 's/koepfe') { if (sub === 's/emojis' || sub === 's/koepfe' || sub === 's/fonts') {
const storagePath = path.join(absStorage, sub.split('/').pop()); const storagePath = path.join(absStorage, sub.split('/').pop());
if (fs.existsSync(storagePath)) return path.resolve(storagePath); if (fs.existsSync(storagePath)) return path.resolve(storagePath);
return local; return local;
@@ -51,6 +51,7 @@ config.paths = {
s: path.join(base, 'public/s'), s: path.join(base, 'public/s'),
emojis: resolvePath('public/s/emojis'), emojis: resolvePath('public/s/emojis'),
koepfe: resolvePath('public/s/koepfe'), koepfe: resolvePath('public/s/koepfe'),
fonts: resolvePath('public/s/fonts'),
memes: resolvePath('public/memes'), memes: resolvePath('public/memes'),
e: resolvePath('e'), e: resolvePath('e'),
pending: resolvePath('pending'), pending: resolvePath('pending'),

View File

@@ -132,6 +132,7 @@ export default new class {
// Build suffix with query params // Build suffix with query params
let suffix = env.strict ? '?strict=1' : ''; let suffix = env.strict ? '?strict=1' : '';
if (env.tagger) suffix += (suffix ? '&' : '?') + `tagger=${encodeURIComponent(env.tagger)}`;
// mainDisplay: decoded for human-readable display (e.g. div.location) // mainDisplay: decoded for human-readable display (e.g. div.location)
// main: keeps percent-encoding for use in href attributes // main: keeps percent-encoding for use in href attributes

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

@@ -18,7 +18,7 @@ const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === '
const COUNT_CACHE_TTL_MS = 90_000; const COUNT_CACHE_TTL_MS = 90_000;
const countCache = new Map(); // key → { total, expiresAt } const countCache = new Map(); // key → { total, expiresAt }
function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj }) { function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger }) {
return JSON.stringify([ return JSON.stringify([
modequery, modequery,
tag ?? '', tag ?? '',
@@ -30,7 +30,8 @@ function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, ex
excludedTags.slice().sort().join(','), excludedTags.slice().sort().join(','),
newerThan ?? '', newerThan ?? '',
minXd, minXd,
userHallObj?.id ?? '' userHallObj?.id ?? '',
tagger ?? ''
]); ]);
} }
@@ -136,7 +137,7 @@ const xdScoreMeta = (score) => {
}; };
export default { export default {
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore } = {}) => { getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => {
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null; const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
// --- title: prefix — search items.title instead of the tags table --- // --- title: prefix — search items.title instead of the tags table ---
@@ -189,7 +190,8 @@ export default {
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : []; const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
const isStrict = strictParams.length > 0; const isStrict = strictParams.length > 0;
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner }; const tagger = rawTagger ? lib.escapeLike(rawTagger) : null;
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger };
// Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment // Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null; const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0); const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
@@ -200,6 +202,21 @@ export default {
if (isTitleSearch && titleQuery) { if (isTitleSearch && titleQuery) {
// Title search: match items.title ILIKE '%query%' // Title search: match items.title ILIKE '%query%'
titleFilter = db`and items.title ILIKE ${'%' + titleQuery + '%'} and items.title IS NOT NULL`; titleFilter = db`and items.title ILIKE ${'%' + titleQuery + '%'} and items.title IS NOT NULL`;
} else if (tagger && tag) {
// Tagger+tag filter: items where the specific user applied this specific tag
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) {
const conditions = terms.map(term => {
return db`and items.id in (
select ta.item_id from tags_assign ta
join tags t on t.id = ta.tag_id
join "user" u on u.id = ta.user_id
where t.normalized like '%' || slugify(${term}) || '%'
and u.user ilike ${tagger}
)`;
});
tagFilter = db`${conditions}`;
}
} else if (tag) { } else if (tag) {
const terms = tag.split(',').map(t => t.trim()).filter(Boolean); const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) { if (terms.length > 0) {
@@ -233,7 +250,7 @@ export default {
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`; userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
} }
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj }); const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
let total = getCachedCount(cacheKey); let total = getCachedCount(cacheKey);
if (total === null) { if (total === null) {
@@ -372,7 +389,7 @@ export default {
for (let i = Math.max(1, act_page - range); i <= Math.min(act_page + range, pages); i++) for (let i = Math.max(1, act_page - range); i <= Math.min(act_page + range, pages); i++)
cheat.push(i); cheat.push(i);
const link = lib.genLink({ user, tag, hall: hallObj ? hallObj.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: 'p/', strict: strict }); const link = lib.genLink({ user, tag, hall: hallObj ? hallObj.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: 'p/', strict: strict, tagger });
// Override link for title searches — pagination must use the /tag/title:... prefix // Override link for title searches — pagination must use the /tag/title:... prefix
if (isTitleSearch && titleQuery) { if (isTitleSearch && titleQuery) {

View File

@@ -226,7 +226,8 @@ export default (router, tpl) => {
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode, strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true', explicitStrict: query.strict === '1' || query.strict === 'true',
newer: query.newer || null, newer: query.newer || null,
minXdScore: req.session?.min_xd_score || 0 minXdScore: req.session?.min_xd_score || 0,
tagger: query.tagger || null
}); });
if (!data.success) { if (!data.success) {

View File

@@ -486,7 +486,7 @@ export default router => {
// F-023 Security: Validate font against actual files on disk // F-023 Security: Validate font against actual files on disk
// The font value is rendered into CSS url() in header.html, so it must be a real filename // The font value is rendered into CSS url() in header.html, so it must be a real filename
if (font) { if (font) {
const fontsDir = path.join(path.resolve(), 'public/s/fonts'); const fontsDir = cfg.paths.fonts;
try { try {
const available = (await fs.readdir(fontsDir)).filter(f => /\.(ttf|otf|woff2?)$/i.test(f)); const available = (await fs.readdir(fontsDir)).filter(f => /\.(ttf|otf|woff2?)$/i.test(f));
if (!available.includes(font)) { if (!available.includes(font)) {

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

@@ -336,7 +336,16 @@ export default router => {
} }
} }
const url = inputUrl.trim(); let url = inputUrl.trim();
try {
const parsed = new URL(url);
if (parsed.searchParams.has('igsh')) {
parsed.searchParams.delete('igsh');
url = parsed.toString();
}
} catch (e) {
// Ignore malformed URL, keep original string
}
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i; const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
const ytMatch = url.match(ytRegex); const ytMatch = url.match(ytRegex);

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

@@ -244,7 +244,8 @@ export default (router, tpl) => {
explicitStrict: !!(req.query?.strict || req.url.qs?.strict), explicitStrict: !!(req.query?.strict || req.url.qs?.strict),
random: req.cookies.random_mode === '1', random: req.cookies.random_mode === '1',
minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0)), minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0)),
lang: req.lang lang: req.lang,
tagger: req.url.qs?.tagger || null
}); });
console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`); console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`);
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`); console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);

View File

@@ -23,6 +23,11 @@ export default (router, tpl) => {
route: /^\/s\/koepfe\// route: /^\/s\/koepfe\//
}); });
router.static({
dir: cfg.paths.fonts,
route: /^\/s\/fonts\//
});
router.static({ router.static({
dir: path.join(path.resolve(), 'node_modules/@ruffle-rs/ruffle'), dir: path.join(path.resolve(), 'node_modules/@ruffle-rs/ruffle'),
route: /^\/s\/ruffle\// route: /^\/s\/ruffle\//

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',

View File

@@ -120,7 +120,7 @@ export default (router, tpl) => {
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: { user: userName }, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
currentPage: page, currentPage: page,
pagination: paginationHtml, pagination: paginationHtml,
hasMore: tags.length === TAGS_PER_PAGE hasMore: tags.length === TAGS_PER_PAGE

View File

@@ -505,6 +505,45 @@ process.on('uncaughtException', err => {
} }
}); });
// ─── Session cache ────────────────────────────────────────────────────────
// The session middleware runs in parallel with all other app.use() handlers
// (flummpress uses Promise.all). With many concurrent requests each doing a
// DB SELECT, the pool (max:50) drains and nginx sees 502s. Caching the
// session row for 5s and the upload-count for 30s eliminates the hot path.
const SESSION_CACHE_TTL = 5_000; // ms — short enough to pick up bans quickly
const UPLOAD_COUNT_CACHE_TTL = 30_000; // ms — upload window is 12h, stale for 30s is fine
const _sessionCache = new Map(); // sha256(cookie) -> { data: row, exp: number }
const _uploadCountCache = new Map(); // username -> { count: number, exp: number }
const _scGet = (hash) => {
const e = _sessionCache.get(hash);
if (e && e.exp > Date.now()) return e.data;
_sessionCache.delete(hash);
return null;
};
const _scSet = (hash, data) => {
_sessionCache.set(hash, { data, exp: Date.now() + SESSION_CACHE_TTL });
if (_sessionCache.size > 2000) {
const now = Date.now();
for (const [k, v] of _sessionCache) if (v.exp <= now) _sessionCache.delete(k);
}
};
// Exposed so logout/ban routes can force-evict immediately
global._invalidateSessionCache = (hash) => _sessionCache.delete(hash);
const _ucGet = (username) => {
const e = _uploadCountCache.get(username);
if (e && e.exp > Date.now()) return e.count;
_uploadCountCache.delete(username);
return null;
};
const _ucSet = (username, count) => {
_uploadCountCache.set(username, { count, exp: Date.now() + UPLOAD_COUNT_CACHE_TTL });
};
// Allow upload handler to bust the cache after a successful upload
global._invalidateUploadCountCache = (username) => _uploadCountCache.delete(username);
// ──────────────────────────────────────────────────────────────────────────
app.use(async (req, res) => { app.use(async (req, res) => {
// This can be used to annoy people on discord sending links to your site lmao, shouldnt be used though since it sucks ass // This can be used to annoy people on discord sending links to your site lmao, shouldnt be used though since it sucks ass
// if (cfg.main.development && req.method === 'POST') console.error(`[BOOT] [DEBUG_POST] ${req.method} ${req.url.pathname}`); // if (cfg.main.development && req.method === 'POST') console.error(`[BOOT] [DEBUG_POST] ${req.method} ${req.url.pathname}`);
@@ -571,14 +610,22 @@ process.on('uncaughtException', err => {
req.fullscreen = req.cookies.fullscreen || 0; req.fullscreen = req.cookies.fullscreen || 0;
if (req.cookies.session) { if (req.cookies.session) {
const user = await db` const _sessionHash = lib.sha256(req.cookies.session);
let _cachedRow = _scGet(_sessionHash);
let user;
if (_cachedRow) {
user = [_cachedRow];
} else {
user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
from "user_sessions" from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id left join "user_options" on "user_options".user_id = "user_sessions".user_id
where "user_sessions".session = ${lib.sha256(req.cookies.session)} where "user_sessions".session = ${_sessionHash}
limit 1 limit 1
`; `;
if (user.length > 0) _scSet(_sessionHash, user[0]);
}
if (user.length === 0) { if (user.length === 0) {
res.writeHead(307, { // delete session res.writeHead(307, { // delete session
@@ -651,8 +698,10 @@ process.on('uncaughtException', err => {
req.session.pending_count = pending[0].c; req.session.pending_count = pending[0].c;
} }
// Calculate uploads remaining globally for the modal // Calculate uploads remaining globally for the modal (cached 30s per user)
if (!req.session.admin && !req.session.is_moderator) { if (!req.session.admin && !req.session.is_moderator) {
let cachedUploadCount = _ucGet(req.session.user);
if (cachedUploadCount === null) {
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600); const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
const uploadCount = await db` const uploadCount = await db`
SELECT count(*) as count SELECT count(*) as count
@@ -661,7 +710,10 @@ process.on('uncaughtException', err => {
AND stamp > ${twelveHoursAgo} AND stamp > ${twelveHoursAgo}
AND is_deleted = false AND is_deleted = false
`; `;
req.session.uploads_remaining = Math.max(0, cfg.main.upload_limit - parseInt(uploadCount[0].count)); cachedUploadCount = parseInt(uploadCount[0].count);
_ucSet(req.session.user, cachedUploadCount);
}
req.session.uploads_remaining = Math.max(0, cfg.main.upload_limit - cachedUploadCount);
} else { } else {
req.session.uploads_remaining = undefined; // Unlimited for admins/mods req.session.uploads_remaining = undefined; // Unlimited for admins/mods
} }
@@ -1248,6 +1300,7 @@ process.on('uncaughtException', err => {
custom_favicon: cfg.websrv.custom_favicon || "", custom_favicon: cfg.websrv.custom_favicon || "",
custom_brand_image: Array.isArray(cfg.websrv.custom_brand_image) ? cfg.websrv.custom_brand_image[0] : (cfg.websrv.custom_brand_image || ""), custom_brand_image: Array.isArray(cfg.websrv.custom_brand_image) ? cfg.websrv.custom_brand_image[0] : (cfg.websrv.custom_brand_image || ""),
custom_navbar_brand_text: cfg.websrv.custom_navbar_brand_text || "", custom_navbar_brand_text: cfg.websrv.custom_navbar_brand_text || "",
default_font: cfg.websrv.default_font || "",
site_description: cfg.websrv.description || "The webs dumpster", site_description: cfg.websrv.description || "The webs dumpster",
enable_nsfl: !!cfg.enable_nsfl, enable_nsfl: !!cfg.enable_nsfl,
nsfl_tag_id: cfg.nsfl_tag_id || 3, nsfl_tag_id: cfg.nsfl_tag_id || 3,
@@ -1267,6 +1320,7 @@ process.on('uncaughtException', err => {
ts: Date.now(), ts: Date.now(),
get default_layout() { return getDefaultLayout(); }, get default_layout() { return getDefaultLayout(); },
show_koepfe: !!cfg.websrv.show_koepfe, show_koepfe: !!cfg.websrv.show_koepfe,
hide_sidebar_default: !!cfg.websrv.hide_sidebar_default,
allow_language_change: cfg.websrv.allow_language_change !== false, allow_language_change: cfg.websrv.allow_language_change !== false,
enable_xd_score: !!cfg.websrv.enable_xd_score, enable_xd_score: !!cfg.websrv.enable_xd_score,
enable_dynamic_thumbs: !!cfg.websrv.enable_dynamic_thumbs, enable_dynamic_thumbs: !!cfg.websrv.enable_dynamic_thumbs,
@@ -1302,7 +1356,7 @@ process.on('uncaughtException', err => {
get fonts() { get fonts() {
try { try {
const fontsDir = path.join(path.resolve(), 'public/s/fonts'); const fontsDir = cfg.paths.fonts;
if (!fs.existsSync(fontsDir)) return []; if (!fs.existsSync(fontsDir)) return [];
return fs.readdirSync(fontsDir).filter(f => /\.(ttf|otf|woff2?)$/i.test(f)).map(f => ({ return fs.readdirSync(fontsDir).filter(f => /\.(ttf|otf|woff2?)$/i.test(f)).map(f => ({
name: f.split('.').shift(), name: f.split('.').shift(),

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

@@ -39,7 +39,6 @@
@endif @endif
<div class="sidebar-tags-container"> <div class="sidebar-tags-container">
<div style="margin-bottom: 8px; font-weight: bold; color: var(--white); font-size: 0.9em; text-transform: uppercase;"></div>
<span class="badge badge-dark" id="tags" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;"> <span class="badge badge-dark" id="tags" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;">
@if(typeof item.tags !== "undefined") @if(typeof item.tags !== "undefined")
@each(item.tags as tag) @each(item.tags as tag)

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

@@ -127,7 +127,7 @@
@endif @endif
@if(private_society && !session) @if(private_society && !session)
<script> <script>
window.f0ckSession = { logged_in: false, enable_xd_score: @if(enable_xd_score) true @else false @endif, default_theme: "{{ default_theme }}", show_content_warning: @if(show_content_warning) true @else false @endif, use_new_layout: @if(default_layout === 'legacy')false @else true @endif, comment_display_mode: {{ comment_display_mode }}, comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }}, development: @if(development) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif }; window.f0ckSession = { logged_in: false, enable_xd_score: @if(enable_xd_score) true @else false @endif, default_theme: "{{ default_theme }}", show_content_warning: @if(show_content_warning) true @else false @endif, use_new_layout: @if(default_layout === 'legacy')false @else true @endif, comment_display_mode: {{ comment_display_mode }}, comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }}, development: @if(development) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif, hide_sidebar_default: @if(hide_sidebar_default) true @else false @endif };
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {}; window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
(() => { (() => {
const loginModal = document.getElementById('login-modal'); const loginModal = document.getElementById('login-modal');
@@ -406,6 +406,7 @@
dm_unencrypted: @if(dm_unencrypted) true @else false @endif, dm_unencrypted: @if(dm_unencrypted) true @else false @endif,
allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif,
enable_comment_polls: @if(enable_comment_polls) true @else false @endif, enable_comment_polls: @if(enable_comment_polls) true @else false @endif,
hide_sidebar_default: @if(hide_sidebar_default) true @else false @endif,
mode: {{ mode !== undefined ? mode : 0 }} mode: {{ mode !== undefined ? mode : 0 }}
}; };
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {}; window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};

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)
@@ -15,10 +16,18 @@
font-family: 'CustomUserFont'; font-family: 'CustomUserFont';
src: url('/s/fonts/{{ session.font }}'); src: url('/s/fonts/{{ session.font }}');
} }
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
{{ "@" }}font-face {
font-family: 'SiteDefaultFont';
src: url('/s/fonts/{{ default_font }}');
}
@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)
--font: 'SiteDefaultFont', monospace !important;
@endif @endif
} }
@if(session && session.font) @if(session && session.font)
@@ -30,6 +39,15 @@
.fas::before, .far::before, .fab::before, .fa::before { .fas::before, .far::before, .fab::before, .fa::before {
font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands" !important; font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands" !important;
} }
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
*:not(canvas):not(.meme-title):not(.force-impact):not(.fa-solid):not(.fa-regular):not(.fa-brands):not(.fa-light):not(.fa-thin):not(.fa-duotone):not([class*=" fa-"]):not([class^="fa-"]) {
font-family: 'SiteDefaultFont', monospace !important;
}
/* Explicitly protect FA pseudo-elements */
.fa-solid::before, .fa-regular::before, .fa-brands::before,
.fas::before, .far::before, .fab::before, .fa::before {
font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands" !important;
}
@endif @endif
</style> </style>
<link rel="preload" href="/s/vcr.ttf" as="font" type="font/ttf" crossorigin> <link rel="preload" href="/s/vcr.ttf" as="font" type="font/ttf" crossorigin>
@@ -78,7 +96,7 @@
</head> </head>
<body class="@if(session)@if(session.use_new_layout)layout-modern@else layout-legacy @endif@else @if(default_layout === 'legacy')layout-legacy@else layout-modern@endif @endif @if(private_society && !session)private-gate-active@endif"> <body class="@if(session)@if(session.use_new_layout)layout-modern@else layout-legacy @endif@else @if(default_layout === 'legacy')layout-legacy@else layout-modern@endif @endif @if(private_society && !session)private-gate-active@endif">
<script>if(localStorage.getItem('sidebarRightHidden')==='true')document.body.classList.add('sidebar-right-hidden');if(localStorage.getItem('comments_hidden')==='true')document.body.classList.add('sidebar-left-hidden');(function(){var d=window.devicePixelRatio||1;console.log('[DPR] devicePixelRatio='+d);var t='dpr-1x';if(d>=1.55)t='dpr-high';else if(d>=1.39)t='dpr-150';else if(d>=1.26)t='dpr-130';else if(d>=1.20)t='dpr-120';else if(d>=1.05)t='dpr-110';document.documentElement.setAttribute('data-dpr',t);})();</script> <script>var sRH = localStorage.getItem('sidebarRightHidden'); if(sRH==='true' || (sRH===null && @if(hide_sidebar_default) true @else false @endif)) document.body.classList.add('sidebar-right-hidden'); if(localStorage.getItem('comments_hidden')==='true')document.body.classList.add('sidebar-left-hidden');(function(){var d=window.devicePixelRatio||1;console.log('[DPR] devicePixelRatio='+d);var t='dpr-1x';if(d>=1.55)t='dpr-high';else if(d>=1.39)t='dpr-150';else if(d>=1.26)t='dpr-130';else if(d>=1.20)t='dpr-120';else if(d>=1.05)t='dpr-110';document.documentElement.setAttribute('data-dpr',t);})();</script>
@if(!private_society || session) @if(!private_society || session)
<canvas class="hidden-xs" id="bg"></canvas> <canvas class="hidden-xs" id="bg"></canvas>
@endif @endif

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

View File

@@ -1,5 +1,5 @@
@each(toptags as toptag) @each(toptags as toptag)
<a href="/tag/{{ toptag.safe_tag }}" class="tag-card"> <a href="/tag/{{ toptag.safe_tag }}@if(user && user.user)?tagger={!! encodeURIComponent(user.user) !!}@endif" class="tag-card">
<div class="tag-card-image"> <div class="tag-card-image">
<img src="/tag_image/{{ toptag.encoded_tag }}?m={{ session.mode }}" loading="lazy" alt="{!! toptag.tag !!}"> <img src="/tag_image/{{ toptag.encoded_tag }}?m={{ session.mode }}" loading="lazy" alt="{!! toptag.tag !!}">
</div> </div>