updating from dev
This commit is contained in:
@@ -20,11 +20,17 @@
|
||||
"development": true
|
||||
},
|
||||
"allowedModes": [ "sfw", "nsfw", "untagged", "all", "nsfl" ],
|
||||
"enable_pdf": true,
|
||||
"enable_nsfl": true,
|
||||
"nsfl_tag_id": 1234,
|
||||
"allowedMimes": [ "audio", "image", "video" ],
|
||||
"allowedMimes": [
|
||||
"audio",
|
||||
"image",
|
||||
"video",
|
||||
"pdf"
|
||||
],
|
||||
"nsfp": [
|
||||
2, 3
|
||||
1, 2, 3
|
||||
],
|
||||
"websrv": {
|
||||
"port": "1337",
|
||||
@@ -46,9 +52,9 @@
|
||||
"enable_global_chat": true,
|
||||
"enable_danmaku": true,
|
||||
"private_messages": true,
|
||||
"halls_enabled": false,
|
||||
"userhalls_enabled": false,
|
||||
"abyss_enabled": false,
|
||||
"halls_enabled": true,
|
||||
"userhalls_enabled": true,
|
||||
"abyss_enabled": true,
|
||||
"meme_creator": true,
|
||||
|
||||
"web_url_upload": true,
|
||||
@@ -65,15 +71,16 @@
|
||||
|
||||
"embed_youtube_in_comments": true,
|
||||
"show_content_warning": true,
|
||||
"default_comment_display_mode": 0,
|
||||
"phrases": [
|
||||
"Hello World"
|
||||
],
|
||||
"ban_video": "",
|
||||
"enable_xd_score": false,
|
||||
"ban_video": "/b/17fd9881.mp4",
|
||||
"enable_xd_score": true,
|
||||
"enable_autoplay": false,
|
||||
"enable_swiping": true,
|
||||
"enable_profile_description": true,
|
||||
"user_alternative_infobox": false,
|
||||
"use_ententeich": true,
|
||||
"enable_swf": true,
|
||||
"swf_thumb": "/s/img/swf.png",
|
||||
|
||||
@@ -153,7 +160,8 @@
|
||||
"video/x-m4v": "mp4",
|
||||
"video/x-matroska": "mkv",
|
||||
"application/x-shockwave-flash": "swf",
|
||||
"application/vnd.adobe.flash.movie": "swf"
|
||||
"application/vnd.adobe.flash.movie": "swf",
|
||||
"application/pdf": "pdf"
|
||||
},
|
||||
"apis": {},
|
||||
"smtp": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,17 +19,19 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-container h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-title {
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
/* Upload Limit Info */
|
||||
.upload-limit-info {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -56,7 +58,6 @@
|
||||
.upload-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 1rem;
|
||||
border-radius: 0;
|
||||
@@ -82,7 +83,7 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@@ -282,6 +283,7 @@
|
||||
position: relative;
|
||||
gap: 0.5rem;
|
||||
z-index: 10000 !important;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
@@ -337,6 +339,7 @@
|
||||
/* Upload Comment */
|
||||
.upload-comment-input {
|
||||
position: relative;
|
||||
margin: 0px 0px 5px 0px;
|
||||
}
|
||||
|
||||
.upload-comment {
|
||||
@@ -734,14 +737,6 @@
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.meta-suggestion:hover {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(88, 101, 242, 0.3);
|
||||
}
|
||||
|
||||
.meta-suggestion i {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
|
||||
BIN
public/s/img/pdf.webp
Normal file
BIN
public/s/img/pdf.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 4.6 KiB |
@@ -290,7 +290,7 @@
|
||||
old.innerText = res.tag.trim();
|
||||
break;
|
||||
default:
|
||||
console.log(res);
|
||||
window.f0ckDebug(res);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ class CommentSystem {
|
||||
this.user = this.container ? this.container.dataset.user : null; // logged in user?
|
||||
this.isAdmin = this.container ? this.container.dataset.isAdmin === 'true' : false;
|
||||
this.isLocked = this.container ? this.container.dataset.isLocked === 'true' : false;
|
||||
this.displayMode = window.f0ckSession?.comment_display_mode || 0; // 0=Tree, 1=Linear
|
||||
this.sort = (document.body.classList.contains('layout-legacy') || document.body.classList.contains('legacy-view')) ? 'old' : 'new';
|
||||
|
||||
// Linear mode usually implies chronological order (4chan style)
|
||||
if (this.displayMode === 1) this.sort = 'old';
|
||||
|
||||
this.customEmojis = CommentSystem.emojiCache || {};
|
||||
|
||||
this.icons = {
|
||||
@@ -40,6 +45,11 @@ class CommentSystem {
|
||||
this.pendingSubmissions = new Set();
|
||||
this.isMainSubmitting = false;
|
||||
this.scrollListenerAdded = false;
|
||||
this.commentCache = new Map();
|
||||
this._anchorScrollDone = false; // true after the first hash-anchor scroll on initial load
|
||||
|
||||
this.loadEmojis(); // Always load emojis for previews
|
||||
this.setupHoverPreviews();
|
||||
|
||||
if (this.itemId) {
|
||||
this.init();
|
||||
@@ -52,13 +62,12 @@ class CommentSystem {
|
||||
return;
|
||||
}
|
||||
if (this.container.dataset.commentSystemInit) {
|
||||
console.log('[CommentSystem] Already initialized for this container');
|
||||
window.f0ckDebug('[CommentSystem] Already initialized for this container');
|
||||
return;
|
||||
}
|
||||
this.container.dataset.commentSystemInit = 'true';
|
||||
console.log('[CommentSystem] Initializing for item:', this.itemId);
|
||||
window.f0ckDebug('[CommentSystem] Initializing for item:', this.itemId);
|
||||
|
||||
this.loadEmojis(); // Don't await
|
||||
this.loadComments();
|
||||
this.setupGlobalListeners();
|
||||
this.setupDelegatedEvents();
|
||||
@@ -181,7 +190,7 @@ class CommentSystem {
|
||||
this.stabilizationObserver.disconnect();
|
||||
}
|
||||
this.stopStabilization();
|
||||
console.log('[CommentSystem] Instance destroyed');
|
||||
window.f0ckDebug('[CommentSystem] Instance destroyed');
|
||||
}
|
||||
|
||||
async loadEmojis() {
|
||||
@@ -203,7 +212,7 @@ class CommentSystem {
|
||||
this.customEmojis[e.name] = e.url;
|
||||
});
|
||||
CommentSystem.emojiCache = this.customEmojis;
|
||||
console.log('Loaded Emojis:', this.customEmojis);
|
||||
window.f0ckDebug('Loaded Emojis:', this.customEmojis);
|
||||
|
||||
// Preload images to prevent NS Binding Aborted errors
|
||||
this.preloadEmojiImages();
|
||||
@@ -221,9 +230,10 @@ class CommentSystem {
|
||||
// even when emojis load late.
|
||||
this.reconcile(this.lastData, this.lastUserId, this.lastIsSubscribed);
|
||||
this.restoreState(state);
|
||||
// Only scroll to anchor if we're not in a preserve-scroll refresh
|
||||
if (!this.preservingScroll && window.location.hash && window.location.hash.startsWith('#c')) {
|
||||
// Only scroll to anchor on the very first load — never on async emoji reloads
|
||||
if (!this._anchorScrollDone && !this.preservingScroll && window.location.hash && window.location.hash.startsWith('#c')) {
|
||||
const hashId = window.location.hash.substring(2);
|
||||
this._anchorScrollDone = true;
|
||||
this.scrollToComment(hashId);
|
||||
}
|
||||
}
|
||||
@@ -255,7 +265,7 @@ class CommentSystem {
|
||||
// ...
|
||||
|
||||
renderEmoji(match, name) {
|
||||
// console.log('Rendering Emoji:', name, this.customEmojis ? this.customEmojis[name] : 'No list');
|
||||
// window.f0ckDebug('Rendering Emoji:', name, this.customEmojis ? this.customEmojis[name] : 'No list');
|
||||
if (this.customEmojis && this.customEmojis[name]) {
|
||||
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
|
||||
}
|
||||
@@ -364,7 +374,7 @@ class CommentSystem {
|
||||
|
||||
// However, if we ARE currently loading comments from server, skip re-render.
|
||||
if (this.initialLoadDone === false && this.lastData.length === 0) {
|
||||
console.log('[CommentSystem] Live comment skipped - initial comments load in progress.');
|
||||
window.f0ckDebug('[CommentSystem] Live comment skipped - initial comments load in progress.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -399,6 +409,17 @@ class CommentSystem {
|
||||
);
|
||||
}
|
||||
|
||||
// Update backlinks for live comment
|
||||
if (data.body) {
|
||||
const matches = data.body.matchAll(/(?<!\w)>>(\d+)/g);
|
||||
for (const match of matches) {
|
||||
this.updateCommentBacklinks(match[1], data.id || data.comment_id);
|
||||
}
|
||||
}
|
||||
if (data.parent_id) {
|
||||
this.updateCommentBacklinks(data.parent_id, data.id || data.comment_id);
|
||||
}
|
||||
|
||||
if (!this.lastData) this.lastData = [];
|
||||
|
||||
// 4. Save state before re-render
|
||||
@@ -503,6 +524,9 @@ class CommentSystem {
|
||||
|
||||
// Check for server-side preloaded comments
|
||||
const dataEl = document.getElementById('initial-comments');
|
||||
const subEl = document.getElementById('initial-subscription');
|
||||
const initialIsSubscribed = subEl ? (subEl.textContent.trim() === 'true') : false;
|
||||
|
||||
if (dataEl) {
|
||||
try {
|
||||
// Decode Base64 for safe template transfer
|
||||
@@ -510,22 +534,21 @@ class CommentSystem {
|
||||
const json = decodeURIComponent(escape(atob(raw)));
|
||||
const comments = JSON.parse(json);
|
||||
|
||||
const subEl = document.getElementById('initial-subscription');
|
||||
const isSubscribed = subEl && (subEl.textContent.trim() === 'true');
|
||||
|
||||
// Consume
|
||||
dataEl.remove();
|
||||
if (subEl) subEl.remove();
|
||||
|
||||
this.render(comments, this.user, isSubscribed);
|
||||
this.render(comments, this.user, initialIsSubscribed);
|
||||
this.initialLoadDone = true;
|
||||
this.restoreState(state);
|
||||
this._loadDanmaku(comments);
|
||||
|
||||
if (scrollToId) {
|
||||
this._anchorScrollDone = true;
|
||||
this.scrollToComment(scrollToId);
|
||||
} else if (!preserveScroll && window.location.hash && window.location.hash.startsWith('#c')) {
|
||||
} else if (!this._anchorScrollDone && !preserveScroll && window.location.hash && window.location.hash.startsWith('#c')) {
|
||||
const hashId = window.location.hash.substring(2);
|
||||
this._anchorScrollDone = true;
|
||||
this.scrollToComment(hashId);
|
||||
}
|
||||
return;
|
||||
@@ -538,7 +561,7 @@ class CommentSystem {
|
||||
// Skip when preserveScroll=true (tab re-focus refresh): the user already sees comments,
|
||||
// so wiping the DOM causes the browser to lose the #c anchor element and auto-scroll to top.
|
||||
if (!scrollToId && !preserveScroll) {
|
||||
this.render([], this.user, false);
|
||||
this.render([], this.user, initialIsSubscribed);
|
||||
this.restoreState(state);
|
||||
}
|
||||
|
||||
@@ -576,7 +599,7 @@ class CommentSystem {
|
||||
// 1. Early Bail-out: If data is bit-for-bit identical, do nothing.
|
||||
// This is the primary defense against tab-switch reloads when nothing changed.
|
||||
if (preserveScroll && this._isDeeplyIdentical(data.comments, data.user_id, data.is_subscribed)) {
|
||||
console.log('[CommentSystem] Sync: Data identical, bailing early to protect media.');
|
||||
window.f0ckDebug('[CommentSystem] Sync: Data identical, bailing early to protect media.');
|
||||
this.restoreState(state);
|
||||
this.preservingScroll = false;
|
||||
return;
|
||||
@@ -584,7 +607,7 @@ class CommentSystem {
|
||||
|
||||
// 2. Reconciliation: If data changed but we want to preserve media.
|
||||
if (preserveScroll && this.lastData && this.lastData.length > 0) {
|
||||
console.log('[CommentSystem] Sync: Data changed, reconciling DOM.');
|
||||
window.f0ckDebug('[CommentSystem] Sync: Data changed, reconciling DOM.');
|
||||
this.reconcile(data.comments, data.user_id, data.is_subscribed);
|
||||
this.initialLoadDone = true;
|
||||
this.restoreState(state);
|
||||
@@ -643,8 +666,9 @@ class CommentSystem {
|
||||
this.preservingScroll = false;
|
||||
if (savedHash) history.replaceState(null, '', window.location.pathname + window.location.search + savedHash);
|
||||
// Only jump to anchor on first load — never on tab re-focus (preserveScroll).
|
||||
if (!preserveScroll && window.location.hash && window.location.hash.startsWith('#c')) {
|
||||
if (!this._anchorScrollDone && !preserveScroll && window.location.hash && window.location.hash.startsWith('#c')) {
|
||||
const hashId = window.location.hash.substring(2);
|
||||
this._anchorScrollDone = true;
|
||||
this.scrollToComment(hashId);
|
||||
}
|
||||
}
|
||||
@@ -691,7 +715,7 @@ class CommentSystem {
|
||||
this.startStabilization(id);
|
||||
}
|
||||
} else if (retries > 0) {
|
||||
console.log(`[CommentSystem] Scroll target #c${id} not found, retrying... (${retries} left)`);
|
||||
window.f0ckDebug(`[CommentSystem] Scroll target #c${id} not found, retrying... (${retries} left)`);
|
||||
setTimeout(() => this.scrollToComment(id, retries - 1), 200);
|
||||
}
|
||||
};
|
||||
@@ -715,7 +739,7 @@ class CommentSystem {
|
||||
const scrollKeys = ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', ' ', 'Home', 'End'];
|
||||
if (!scrollKeys.includes(e.key)) return;
|
||||
}
|
||||
console.log(`[CommentSystem] Stabilization aborted due to ${e.type}`);
|
||||
window.f0ckDebug(`[CommentSystem] Stabilization aborted due to ${e.type}`);
|
||||
this.isUserInteracting = true;
|
||||
this.stopStabilization();
|
||||
};
|
||||
@@ -738,7 +762,7 @@ class CommentSystem {
|
||||
|
||||
// If it shifted more than 10px (e.g. media loaded), re-scroll
|
||||
if (diff > 10 && checks < maxChecks) {
|
||||
console.log(`[CommentSystem] Layout shift detected (${Math.round(diff)}px), re-stabilizing scroll...`);
|
||||
window.f0ckDebug(`[CommentSystem] Layout shift detected (${Math.round(diff)}px), re-stabilizing scroll...`);
|
||||
this.scrollToComment(id, 0, true);
|
||||
lastTop = currentEl.getBoundingClientRect().top;
|
||||
}
|
||||
@@ -787,6 +811,177 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
|
||||
async showCommentPreview(link, event) {
|
||||
if (this.previewCloseTimer) {
|
||||
clearTimeout(this.previewCloseTimer);
|
||||
this.previewCloseTimer = null;
|
||||
}
|
||||
|
||||
const targetId = link.dataset.id;
|
||||
let targetEl = document.getElementById('c' + targetId);
|
||||
|
||||
const parentPopup = link.closest('.comment-preview-popup');
|
||||
const level = parentPopup ? parseInt(parentPopup.dataset.level || 0) + 1 : 0;
|
||||
|
||||
if (!targetEl) {
|
||||
const cached = this.commentCache.get(targetId);
|
||||
if (cached) {
|
||||
targetEl = this.createTemporaryCommentNode(cached);
|
||||
} else {
|
||||
// Fetch from API
|
||||
this.fetchCommentForPreview(targetId, link, level);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Close any previews at this level or higher (replacing the current branch)
|
||||
this.closePreviewsAboveLevel(level - 1);
|
||||
|
||||
// If this exact ID is already the immediate child of this parent, don't re-open
|
||||
const existing = document.querySelector(`.comment-preview-popup[data-id="${targetId}"][data-level="${level}"]`);
|
||||
if (existing) return;
|
||||
|
||||
// Clone the target comment
|
||||
const preview = targetEl.cloneNode(true);
|
||||
preview.id = 'comment-preview-' + Date.now();
|
||||
preview.classList.add('comment-preview-popup');
|
||||
preview.dataset.level = level;
|
||||
preview.dataset.id = targetId;
|
||||
|
||||
// Remove temporary animation/highlight classes
|
||||
preview.classList.remove('new-item-fade', 'comment-highlighted', 'comment-entering');
|
||||
|
||||
// Remove input forms or action buttons
|
||||
const actions = preview.querySelector('.comment-actions');
|
||||
if (actions) actions.remove();
|
||||
const footer = preview.querySelector('.comment-footer');
|
||||
if (footer) footer.remove();
|
||||
|
||||
// Position the preview
|
||||
const rect = link.getBoundingClientRect();
|
||||
preview.style.position = 'fixed';
|
||||
preview.style.zIndex = (100000 + level).toString();
|
||||
|
||||
// Try to place it to the right of the link, or top/bottom if needed
|
||||
let left = rect.right + 10;
|
||||
let top = rect.top;
|
||||
|
||||
document.body.appendChild(preview);
|
||||
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
|
||||
// Boundary checks
|
||||
if (left + previewRect.width > window.innerWidth) {
|
||||
left = rect.left - previewRect.width - 10;
|
||||
}
|
||||
if (left < 0) left = 10;
|
||||
|
||||
if (top + previewRect.height > window.innerHeight) {
|
||||
top = window.innerHeight - previewRect.height - 10;
|
||||
}
|
||||
if (top < 0) top = 10;
|
||||
|
||||
preview.style.left = left + 'px';
|
||||
preview.style.top = top + 'px';
|
||||
}
|
||||
|
||||
async fetchCommentForPreview(id, link, level) {
|
||||
if (this.commentCache.has(id)) return; // Already fetching or fetched
|
||||
|
||||
// Prevent double fetches
|
||||
this.commentCache.set(id, { loading: true });
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/comment/${id}`);
|
||||
const json = await res.json();
|
||||
if (json.success && json.comment) {
|
||||
this.commentCache.set(id, json.comment);
|
||||
// If the link is still being hovered (or we are in the middle of a dwell), show it
|
||||
if (this.currentHoverLink === link) {
|
||||
this.showCommentPreview(link);
|
||||
}
|
||||
} else {
|
||||
this.commentCache.delete(id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[CommentSystem] Failed to fetch comment for preview:', e);
|
||||
this.commentCache.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
createTemporaryCommentNode(comment) {
|
||||
if (comment.loading) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'comment loading-comment';
|
||||
div.innerHTML = '<div class="comment-body"><i class="fa-solid fa-spinner fa-spin"></i> Loading...</div>';
|
||||
return div;
|
||||
}
|
||||
const html = this.renderComment(comment, window.f0ckSession?.user, false, true);
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
return div.firstElementChild;
|
||||
}
|
||||
|
||||
closePreviewsAboveLevel(level) {
|
||||
document.querySelectorAll('.comment-preview-popup').forEach(p => {
|
||||
if (parseInt(p.dataset.level || 0) > level) {
|
||||
p.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hideCommentPreview(force = false) {
|
||||
if (force) {
|
||||
this.closePreviewsAboveLevel(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.previewCloseTimer) clearTimeout(this.previewCloseTimer);
|
||||
this.previewCloseTimer = setTimeout(() => {
|
||||
this.closePreviewsAboveLevel(-1);
|
||||
}, 400); // 400ms grace period to move mouse between link and popup
|
||||
}
|
||||
|
||||
quoteComment(id, openerEl, body) {
|
||||
// If no reply input open anywhere, open the local one
|
||||
let textarea = document.querySelector('.comment-input.reply-input textarea');
|
||||
let isNew = false;
|
||||
if (!textarea && !body.querySelector('.reply-input')) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = this.renderInput(id);
|
||||
body.appendChild(div.firstElementChild);
|
||||
const newForm = body.querySelector('.reply-input');
|
||||
this.setupEmojiPicker(newForm);
|
||||
textarea = newForm.querySelector('textarea');
|
||||
newForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
isNew = true;
|
||||
} else if (!textarea) {
|
||||
textarea = body.querySelector('.reply-input textarea');
|
||||
}
|
||||
|
||||
if (textarea) {
|
||||
const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
|
||||
const contentEl = body.querySelector('.comment-content');
|
||||
if (contentEl) {
|
||||
const rawText = (contentEl.dataset.raw || '').trim();
|
||||
const lines = rawText.split('\n');
|
||||
const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||
|
||||
if (isNew) {
|
||||
textarea.value = quote;
|
||||
} else {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const val = textarea.value;
|
||||
textarea.value = val.substring(0, start) + quote + val.substring(end);
|
||||
}
|
||||
textarea.focus({ preventScroll: true });
|
||||
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveMediaState() {
|
||||
// Snapshot currently playing or paused-mid-way media elements
|
||||
const state = [];
|
||||
@@ -825,7 +1020,20 @@ class CommentSystem {
|
||||
this.lastUserId = currentUserId;
|
||||
this.lastIsSubscribed = isSubscribed;
|
||||
|
||||
// Build two-level tree: top-level comments + all replies at one level
|
||||
// Build map of who replied to whom for back-references (>>ID)
|
||||
this.buildBacklinkMap(comments);
|
||||
|
||||
// Render logic based on display mode
|
||||
let renderedHtml = '';
|
||||
if (this.displayMode === 1) {
|
||||
// Linear Mode: Just sort all comments by date and render them flat
|
||||
const sortedComments = [...comments].sort((a, b) => {
|
||||
if (this.sort === 'old') return new Date(a.created_at) - new Date(b.created_at);
|
||||
return new Date(b.created_at) - new Date(a.created_at);
|
||||
});
|
||||
renderedHtml = sortedComments.map(c => this.renderComment(c, currentUserId, false, true)).join('');
|
||||
} else {
|
||||
// Tree Mode: Group by roots and replies
|
||||
const map = new Map();
|
||||
const roots = [];
|
||||
|
||||
@@ -847,20 +1055,16 @@ class CommentSystem {
|
||||
|
||||
comments.forEach(c => {
|
||||
if (!c.parent_id) {
|
||||
// Top-level comment
|
||||
roots.push(c);
|
||||
} else {
|
||||
// It's a reply - find root and attach there
|
||||
const root = findRoot(c);
|
||||
if (root && root !== c) {
|
||||
// If replying to a non-root, capture the username for @mention
|
||||
const directParent = map.get(c.parent_id);
|
||||
if (directParent && directParent.id !== root.id) {
|
||||
c.replyTo = directParent.username;
|
||||
}
|
||||
root.replies.push(c);
|
||||
} else {
|
||||
// Orphaned reply (parent deleted?) - show as root
|
||||
roots.push(c);
|
||||
}
|
||||
}
|
||||
@@ -873,6 +1077,9 @@ class CommentSystem {
|
||||
}
|
||||
});
|
||||
|
||||
renderedHtml = roots.map(c => this.renderComment(c, currentUserId)).join('');
|
||||
}
|
||||
|
||||
// Determine what to show for input
|
||||
let inputSection = '';
|
||||
if (this.isLocked && !this.isAdmin) {
|
||||
@@ -896,23 +1103,36 @@ class CommentSystem {
|
||||
`;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div class="comments-list">
|
||||
let html = '';
|
||||
if (isLegacy) {
|
||||
html = `
|
||||
<div class="comments-list ${this.displayMode === 1 ? 'linear-view' : ''}">
|
||||
${scrollToBottomBtn}
|
||||
${roots.map(c => this.renderComment(c, currentUserId)).join('')}
|
||||
${renderedHtml}
|
||||
</div>
|
||||
${inputSection}
|
||||
`;
|
||||
} else {
|
||||
html = `
|
||||
${inputSection}
|
||||
<div class="comments-list ${this.displayMode === 1 ? 'linear-view' : ''}">
|
||||
${scrollToBottomBtn}
|
||||
${renderedHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
const mediaState = this.saveMediaState();
|
||||
this.container.innerHTML = Sanitizer.clean(html);
|
||||
this.container.innerHTML = html;
|
||||
this.restoreMediaState(mediaState);
|
||||
this.syncSubscribeButton(isSubscribed);
|
||||
|
||||
// Attach media load listeners to re-stabilize scroll if a hash is active.
|
||||
// Skipped during a preserveScroll refresh (tab re-focus) to avoid fighting the
|
||||
// position restoration and causing the visible jump-then-snap behaviour.
|
||||
if (!this.preservingScroll && window.location.hash && window.location.hash.startsWith('#c')) {
|
||||
// Only during the initial anchor scroll — never on subsequent renders (tab re-focus,
|
||||
// emoji reloads, live comment updates). _anchorScrollDone is set after the first
|
||||
// scrollToComment call, so this block only runs on the initial page load.
|
||||
if (!this._anchorScrollDone && !this.preservingScroll && window.location.hash && window.location.hash.startsWith('#c')) {
|
||||
const hashId = window.location.hash.substring(2);
|
||||
this.container.querySelectorAll('img, video, audio').forEach(media => {
|
||||
const handler = () => {
|
||||
@@ -990,6 +1210,9 @@ class CommentSystem {
|
||||
this.lastUserId = currentUserId;
|
||||
this.lastIsSubscribed = isSubscribed;
|
||||
|
||||
// Build map of who replied to whom for back-references (>>ID)
|
||||
this.buildBacklinkMap(comments);
|
||||
|
||||
const incomingMap = new Map();
|
||||
comments.forEach(c => incomingMap.set(String(c.id), c));
|
||||
|
||||
@@ -1013,8 +1236,8 @@ class CommentSystem {
|
||||
// Check for edits or state changes using robust data-attributes
|
||||
const contentEl = el.querySelector('.comment-content');
|
||||
if (contentEl && contentEl.dataset.raw !== incoming.content) {
|
||||
console.log(`[CommentSystem] Reconcile: Updating content for #c${id}`);
|
||||
contentEl.innerHTML = Sanitizer.clean(this.renderCommentContent(incoming.content));
|
||||
window.f0ckDebug(`[CommentSystem] Reconcile: Updating content for #c${id}`);
|
||||
contentEl.innerHTML = this.renderCommentContent(incoming.content);
|
||||
contentEl.dataset.raw = incoming.content;
|
||||
}
|
||||
|
||||
@@ -1026,7 +1249,39 @@ class CommentSystem {
|
||||
});
|
||||
|
||||
// 2. Insert new comments
|
||||
// We re-run the tree logic to determine correct order
|
||||
if (this.displayMode === 1) {
|
||||
// Linear Mode Insertion
|
||||
const sortedComments = [...comments].sort((a, b) => {
|
||||
if (this.sort === 'old') return new Date(a.created_at) - new Date(b.created_at);
|
||||
return new Date(b.created_at) - new Date(a.created_at);
|
||||
});
|
||||
|
||||
sortedComments.forEach((c, idx) => {
|
||||
const idStr = String(c.id);
|
||||
if (document.getElementById('c' + idStr)) return;
|
||||
|
||||
window.f0ckDebug(`[CommentSystem] Reconcile: Injecting new flat comment #c${idStr}`);
|
||||
const html = this.renderComment(c, currentUserId, false, true);
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
const newEl = tmp.firstElementChild;
|
||||
|
||||
if (idx === 0) {
|
||||
const nav = list.querySelector('.scroll-nav-wrapper');
|
||||
if (nav) nav.insertAdjacentElement('afterend', newEl);
|
||||
else list.prepend(newEl);
|
||||
} else {
|
||||
const prevId = String(sortedComments[idx - 1].id);
|
||||
const prevEl = document.getElementById('c' + prevId);
|
||||
if (prevEl) prevEl.insertAdjacentElement('afterend', newEl);
|
||||
else list.appendChild(newEl);
|
||||
}
|
||||
newEl.classList.add('comment-entering', 'new-item-fade');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Tree Mode Insertion (Existing Logic)
|
||||
const map = new Map();
|
||||
const roots = [];
|
||||
comments.forEach(c => {
|
||||
@@ -1067,8 +1322,8 @@ class CommentSystem {
|
||||
let el = document.getElementById('c' + idStr);
|
||||
|
||||
if (!el) {
|
||||
console.log(`[CommentSystem] Reconcile: Injecting new comment #c${idStr}`);
|
||||
const html = Sanitizer.clean(this.renderComment(c, currentUserId, isReply));
|
||||
window.f0ckDebug(`[CommentSystem] Reconcile: Injecting new comment #c${idStr}`);
|
||||
const html = this.renderComment(c, currentUserId, isReply);
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
const newEl = tmp.firstElementChild;
|
||||
@@ -1235,7 +1490,9 @@ class CommentSystem {
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
// 1. Manual Greentext/Quote handling (avoids recursive blockquote parsing)
|
||||
if (trimmed.startsWith('>')) {
|
||||
// Exclude only the numeric context links (>>ID) so they can be handled as interactive links.
|
||||
// Multiple chevrons (>>text, >>>text) should still be greentext.
|
||||
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||
const renderedContent = quoteEmojis
|
||||
@@ -1258,6 +1515,11 @@ class CommentSystem {
|
||||
return `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
|
||||
});
|
||||
|
||||
// Handle Comment Context Links (>>ID)
|
||||
processedLine = processedLine.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
|
||||
return `<a href="#c${id}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
|
||||
});
|
||||
|
||||
// Handle Image Embeds
|
||||
processedLine = processedLine.replace(imageRegex, (match, url) => {
|
||||
let fullUrl = url;
|
||||
@@ -1308,6 +1570,15 @@ class CommentSystem {
|
||||
);
|
||||
}
|
||||
|
||||
// Vocaroo embed
|
||||
md = md.replace(
|
||||
/<a\s[^>]*href="https?:\/\/(?:www\.)?(?:voca\.ro|vocaroo\.com)\/([a-zA-Z0-9_-]+)[^"]*"[^>]*>([\s\S]*?)<\/a>/gi,
|
||||
(match, vocarooId) => {
|
||||
if (['upload', 'contact', 'privacy', 'tos', 'about'].includes(vocarooId.toLowerCase())) return match;
|
||||
return `<span class="vocaroo-embed-wrap"><iframe src="https://vocaroo.com/embed/${vocarooId}?autoplay=0" width="300" height="60" frameborder="0" allow="autoplay"></iframe></span>`;
|
||||
}
|
||||
);
|
||||
|
||||
// Build regex for allowed media hosters (video/audio)
|
||||
const mediaHosts = [escapedSiteHost];
|
||||
if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) {
|
||||
@@ -1359,6 +1630,9 @@ class CommentSystem {
|
||||
return codeBlocks[index] || '';
|
||||
});
|
||||
|
||||
if (window.Sanitizer && typeof Sanitizer.clean === 'function') {
|
||||
md = Sanitizer.clean(md);
|
||||
}
|
||||
return md;
|
||||
} catch (e) {
|
||||
console.error('Markdown error:', e);
|
||||
@@ -1366,6 +1640,34 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
|
||||
buildBacklinkMap(comments) {
|
||||
this.backlinkMap = {};
|
||||
const process = (c) => {
|
||||
if (!c.content) return;
|
||||
// Scan for >>ID patterns
|
||||
const matches = c.content.matchAll(/(?<!\w)>>(\d+)/g);
|
||||
for (const match of matches) {
|
||||
const targetId = match[1];
|
||||
if (!this.backlinkMap[targetId]) this.backlinkMap[targetId] = new Set();
|
||||
this.backlinkMap[targetId].add(c.id);
|
||||
}
|
||||
// Also treat parent_id as a direct reply
|
||||
if (c.parent_id) {
|
||||
const targetId = String(c.parent_id);
|
||||
if (!this.backlinkMap[targetId]) this.backlinkMap[targetId] = new Set();
|
||||
this.backlinkMap[targetId].add(c.id);
|
||||
}
|
||||
};
|
||||
|
||||
const scan = (list) => {
|
||||
list.forEach(c => {
|
||||
process(c);
|
||||
if (c.replies && c.replies.length > 0) scan(c.replies);
|
||||
});
|
||||
};
|
||||
scan(comments);
|
||||
}
|
||||
|
||||
renderEmoji(match, name) {
|
||||
if (this.customEmojis && this.customEmojis[name]) {
|
||||
return `<img src="${this.customEmojis[name]}" class="emoji" alt="${match}" title="${match}">`;
|
||||
@@ -1386,7 +1688,36 @@ class CommentSystem {
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
renderComment(comment, currentUserId, isReply = false) {
|
||||
updateCommentBacklinks(targetId, replierId) {
|
||||
if (!this.backlinkMap) this.backlinkMap = {};
|
||||
if (!this.backlinkMap[targetId]) this.backlinkMap[targetId] = new Set();
|
||||
this.backlinkMap[targetId].add(replierId);
|
||||
|
||||
const targetEl = document.getElementById('c' + targetId);
|
||||
if (targetEl) {
|
||||
const headerLeft = targetEl.querySelector('.comment-header-left');
|
||||
if (headerLeft) {
|
||||
let span = headerLeft.querySelector('.comment-backlinks');
|
||||
if (!span) {
|
||||
span = document.createElement('span');
|
||||
span.className = 'comment-backlinks';
|
||||
headerLeft.appendChild(span);
|
||||
}
|
||||
// Check if already present
|
||||
if (!span.querySelector(`a[data-id="${replierId}"]`)) {
|
||||
const link = document.createElement('a');
|
||||
link.href = `#c${replierId}`;
|
||||
link.className = 'comment-context-link';
|
||||
link.dataset.id = replierId;
|
||||
link.textContent = `>>${replierId}`;
|
||||
span.appendChild(document.createTextNode(' '));
|
||||
span.appendChild(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderComment(comment, currentUserId, isReply = false, isLinear = false) {
|
||||
const isDeleted = comment.is_deleted;
|
||||
const isPinned = comment.is_pinned;
|
||||
|
||||
@@ -1406,13 +1737,25 @@ class CommentSystem {
|
||||
|
||||
// Build replies HTML (only for root comments, max 1 level deep)
|
||||
let repliesHtml = '';
|
||||
if (!isReply && comment.replies && comment.replies.length > 0) {
|
||||
if (!isReply && !isLinear && comment.replies && comment.replies.length > 0) {
|
||||
repliesHtml = `<div class="comment-replies">${comment.replies.map(r => this.renderComment(r, currentUserId, true)).join('')}</div>`;
|
||||
}
|
||||
|
||||
const timeAgo = this.timeAgo(comment.created_at);
|
||||
const fullDate = new Date(comment.created_at).toISOString();
|
||||
|
||||
// Parent context marker removed (redundant with back-references)
|
||||
let contextMarker = '';
|
||||
|
||||
// Back-references (replies to this comment)
|
||||
let backlinkHtml = '';
|
||||
if (this.backlinkMap && this.backlinkMap[comment.id]) {
|
||||
const repliers = Array.from(this.backlinkMap[comment.id]);
|
||||
if (repliers.length > 0) {
|
||||
backlinkHtml = `<span class="comment-backlinks">${repliers.map(rid => `<a href="#c${rid}" class="comment-context-link" data-id="${rid}">>>${rid}</a>`).join(' ')}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}">
|
||||
<div class="comment-avatar">
|
||||
@@ -1424,20 +1767,22 @@ class CommentSystem {
|
||||
<div class="comment-header">
|
||||
<div class="comment-header-left">
|
||||
${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${comment.user_id}" ${comment.username_color ? `style="color: ${comment.username_color}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}
|
||||
${contextMarker}
|
||||
${backlinkHtml}
|
||||
</div>
|
||||
<a href="#c${comment.id}" class="comment-time timeago" title="${fullDate}">${timeAgo}</a>
|
||||
<a href="#c${comment.id}" class="comment-time timeago" title="${fullDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a>
|
||||
</div>
|
||||
<div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>
|
||||
<div class="comment-footer">
|
||||
<div class="comment-footer-right">
|
||||
<div class="comment-actions">
|
||||
${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}"><i class="fa-solid fa-reply"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}
|
||||
${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}
|
||||
${adminButtons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="#c${comment.id}" class="comment-permalink" title="Permalink">#${comment.id}</a>
|
||||
<a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a>
|
||||
</div>
|
||||
${repliesHtml}
|
||||
`;
|
||||
@@ -1488,8 +1833,117 @@ class CommentSystem {
|
||||
`;
|
||||
}
|
||||
|
||||
setupHoverPreviews() {
|
||||
if (CommentSystem.hoverPreviewsAttached) return;
|
||||
CommentSystem.hoverPreviewsAttached = true;
|
||||
|
||||
// Hover for Comment Context Links (>>ID) - Global delegation for nested previews (inception)
|
||||
this.mouseCurrentLevel = -1;
|
||||
this.currentHoverLink = null;
|
||||
document.addEventListener('mouseover', (e) => {
|
||||
const contextLink = e.target.closest('.comment-context-link');
|
||||
const popup = e.target.closest('.comment-preview-popup');
|
||||
|
||||
if (contextLink || popup) {
|
||||
if (this.previewCloseTimer) {
|
||||
clearTimeout(this.previewCloseTimer);
|
||||
this.previewCloseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (contextLink) {
|
||||
// Ignore mouseover for previews on mobile touch devices to prevent tap-to-preview
|
||||
// We handle mobile previews via the touchstart timer instead.
|
||||
if (window.matchMedia('(pointer: coarse)').matches) return;
|
||||
|
||||
if (this.currentHoverLink === contextLink) return;
|
||||
this.currentHoverLink = contextLink;
|
||||
|
||||
if (this.previewOpenTimer) clearTimeout(this.previewOpenTimer);
|
||||
this.previewOpenTimer = setTimeout(() => {
|
||||
this.showCommentPreview(contextLink, e);
|
||||
}, 150); // 150ms dwell time to prevent flickering while moving mouse
|
||||
} else {
|
||||
if (this.previewOpenTimer) {
|
||||
clearTimeout(this.previewOpenTimer);
|
||||
this.previewOpenTimer = null;
|
||||
}
|
||||
this.currentHoverLink = null;
|
||||
|
||||
const level = popup ? parseInt(popup.dataset.level || 0) : -1;
|
||||
|
||||
// If we move back to a parent level or blank area of a popup, close its children
|
||||
// but use a delay so the user can reach the child popup if they are moving towards it.
|
||||
if (popup && !contextLink) {
|
||||
this.previewCloseTimer = setTimeout(() => {
|
||||
this.closePreviewsAboveLevel(level);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
this.mouseCurrentLevel = level;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseout', (e) => {
|
||||
const contextLink = e.target.closest('.comment-context-link');
|
||||
const popup = e.target.closest('.comment-preview-popup');
|
||||
|
||||
if (contextLink) {
|
||||
if (this.previewOpenTimer) {
|
||||
clearTimeout(this.previewOpenTimer);
|
||||
this.previewOpenTimer = null;
|
||||
}
|
||||
this.currentHoverLink = null;
|
||||
}
|
||||
|
||||
if (contextLink || popup) {
|
||||
if (this.previewCloseTimer) clearTimeout(this.previewCloseTimer);
|
||||
this.previewCloseTimer = setTimeout(() => {
|
||||
this.closePreviewsAboveLevel(-1);
|
||||
this.mouseCurrentLevel = -1;
|
||||
}, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile Touch Support: Touch-and-hold to preview
|
||||
let touchPreviewTimer = null;
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
const contextLink = e.target.closest('.comment-context-link');
|
||||
if (contextLink) {
|
||||
if (touchPreviewTimer) clearTimeout(touchPreviewTimer);
|
||||
touchPreviewTimer = setTimeout(() => {
|
||||
this.showCommentPreview(contextLink, e);
|
||||
}, 150); // 150ms hold to trigger preview on mobile
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchmove', () => {
|
||||
if (touchPreviewTimer) {
|
||||
clearTimeout(touchPreviewTimer);
|
||||
touchPreviewTimer = null;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', () => {
|
||||
if (touchPreviewTimer) {
|
||||
clearTimeout(touchPreviewTimer);
|
||||
touchPreviewTimer = null;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// Global click listener to close popups (useful for mobile dismissal)
|
||||
document.addEventListener('click', (e) => {
|
||||
const isLink = e.target.closest('.comment-context-link');
|
||||
const isPopup = e.target.closest('.comment-preview-popup');
|
||||
|
||||
if (!isLink && !isPopup) {
|
||||
this.closePreviewsAboveLevel(-1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupDelegatedEvents() {
|
||||
console.log('[DEBUG] Setting up delegated events for container:', this.container);
|
||||
window.f0ckDebug('[DEBUG] Setting up delegated events for container:', this.container);
|
||||
if (!this.container) return;
|
||||
|
||||
// Ctrl+Enter to submit comment
|
||||
@@ -1517,13 +1971,9 @@ class CommentSystem {
|
||||
|
||||
// Single Click Listener for Everything
|
||||
this.container.addEventListener('click', async (e) => {
|
||||
console.log('[DEBUG] Click on container:', e.target);
|
||||
window.f0ckDebug('[DEBUG] Click on container:', e.target);
|
||||
const target = e.target;
|
||||
|
||||
// Handle legacy layout focus scroll
|
||||
if (target.matches('textarea') && (document.body.classList.contains('legacy-view') || document.body.classList.contains('layout-legacy'))) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
// Toggling Scroll Action
|
||||
const scrollBtn = target.closest('.scroll-to-bottom');
|
||||
@@ -1559,6 +2009,22 @@ class CommentSystem {
|
||||
return;
|
||||
}
|
||||
|
||||
// Comment Context Link (>>ID)
|
||||
const contextLink = target.closest('.comment-context-link');
|
||||
if (contextLink) {
|
||||
e.preventDefault();
|
||||
const targetId = contextLink.dataset.id;
|
||||
this.scrollToComment(targetId, 0, true);
|
||||
|
||||
// Highlight effect
|
||||
const targetEl = document.getElementById('c' + targetId);
|
||||
if (targetEl) {
|
||||
targetEl.classList.add('highlight-comment');
|
||||
setTimeout(() => targetEl.classList.remove('highlight-comment'), 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// User Delete
|
||||
const delBtn = target.closest('.delete-btn');
|
||||
if (delBtn) {
|
||||
@@ -1678,39 +2144,51 @@ class CommentSystem {
|
||||
}
|
||||
|
||||
// Reply
|
||||
// Reply Button (ID only)
|
||||
const replyBtn = target.closest('.reply-btn');
|
||||
if (replyBtn) {
|
||||
const id = replyBtn.dataset.id;
|
||||
const body = replyBtn.closest('.comment-body');
|
||||
const commentEl = replyBtn.closest('[id^="c"]');
|
||||
const body = commentEl ? commentEl.querySelector('.comment-body') : null;
|
||||
|
||||
if (body.querySelector('.reply-input')) return;
|
||||
if (body) {
|
||||
// Check if any reply input is ALREADY open
|
||||
let textarea = document.querySelector('.comment-input.reply-input textarea');
|
||||
|
||||
// If none open, open the local one for this comment
|
||||
if (!textarea && !body.querySelector('.reply-input')) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = this.renderInput(id);
|
||||
body.appendChild(div.firstElementChild);
|
||||
const newForm = body.querySelector('.reply-input');
|
||||
this.setupEmojiPicker(newForm);
|
||||
|
||||
// Add quote content — use data-raw to get the original unrendered text,
|
||||
// which always has :emojicode:, [spoiler] tags, etc. intact.
|
||||
const author = replyBtn.dataset.display || replyBtn.dataset.username || 'System';
|
||||
const contentEl = body.querySelector('.comment-content');
|
||||
const textarea = newForm.querySelector('textarea');
|
||||
if (contentEl && textarea) {
|
||||
const rawText = (contentEl.dataset.raw || '').trim();
|
||||
const lines = rawText.split('\n');
|
||||
const quote = `>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||
textarea.value = quote;
|
||||
|
||||
// Focus and move cursor to end
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
textarea = newForm.querySelector('textarea');
|
||||
} else if (!textarea) {
|
||||
textarea = body.querySelector('.reply-input textarea');
|
||||
}
|
||||
|
||||
// Smoothly scroll the reply form into view
|
||||
newForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
if (textarea) {
|
||||
const quote = `>>${id} `;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const val = textarea.value;
|
||||
textarea.value = val.substring(0, start) + quote + val.substring(end);
|
||||
textarea.focus({ preventScroll: true });
|
||||
textarea.selectionStart = textarea.selectionEnd = start + quote.length;
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Buttons in newForm are handled by delegation (.submit-comment, .cancel-reply)
|
||||
// Quote Button (Full Text Quote - Old Style)
|
||||
const quoteBtn = target.closest('.quote-btn');
|
||||
if (quoteBtn) {
|
||||
const id = quoteBtn.dataset.id;
|
||||
const body = quoteBtn.closest('.comment-body');
|
||||
if (body) {
|
||||
this.quoteComment(id, quoteBtn, body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1760,25 +2238,19 @@ class CommentSystem {
|
||||
return;
|
||||
}
|
||||
|
||||
// Permalinks
|
||||
if (target.classList.contains('comment-permalink')) {
|
||||
const href = target.getAttribute('href');
|
||||
if (href && href.startsWith('#c')) {
|
||||
// Permalinks & Timestamp clicks
|
||||
if (target.classList.contains('comment-permalink') || target.closest('.comment-time')) {
|
||||
const el = target.closest('.comment-permalink, .comment-time');
|
||||
if (el) {
|
||||
const id = el.dataset.id;
|
||||
const commentEl = el.closest('[id^="c"]');
|
||||
const body = commentEl ? commentEl.querySelector('.comment-body') : null;
|
||||
if (body) {
|
||||
e.preventDefault();
|
||||
const id = href.substring(2);
|
||||
history.pushState(null, null, href);
|
||||
this.scrollToComment(id);
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
this.quoteComment(id, el, body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Timestamp click → open reply
|
||||
if (target.closest('.comment-time')) {
|
||||
e.preventDefault();
|
||||
const commentEl = target.closest('[id^="c"]');
|
||||
if (commentEl) {
|
||||
const replyBtn = commentEl.querySelector(':scope > .comment-body .reply-btn');
|
||||
if (replyBtn) replyBtn.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1943,7 +2415,7 @@ class CommentSystem {
|
||||
repliesEl.className = 'comment-replies';
|
||||
parentEl.insertAdjacentElement('afterend', repliesEl);
|
||||
}
|
||||
const commentHtml = Sanitizer.clean(this.renderComment(newComment, this.lastUserId, true));
|
||||
const commentHtml = this.renderComment(newComment, this.lastUserId, true);
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = commentHtml;
|
||||
const commentEl = tmp.firstElementChild;
|
||||
@@ -1966,7 +2438,7 @@ class CommentSystem {
|
||||
} else {
|
||||
const list = this.container.querySelector('.comments-list');
|
||||
if (list) {
|
||||
const commentHtml = Sanitizer.clean(this.renderComment(newComment, this.lastUserId, false));
|
||||
const commentHtml = this.renderComment(newComment, this.lastUserId, false);
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = commentHtml;
|
||||
const commentEl = tmp.firstElementChild;
|
||||
@@ -2004,7 +2476,7 @@ class CommentSystem {
|
||||
retryCount++;
|
||||
// Randomized exponential backoff
|
||||
const delay = Math.min(1000 * Math.pow(1.5, retryCount) + (Math.random() * 1000), 10000);
|
||||
console.log(`[CommentSystem] Retrying in ${Math.round(delay)}ms...`);
|
||||
window.f0ckDebug(`[CommentSystem] Retrying in ${Math.round(delay)}ms...`);
|
||||
setTimeout(attemptSubmit, delay);
|
||||
} else {
|
||||
alert('Failed to send comment after multiple attempts. Please check your connection.');
|
||||
@@ -2198,6 +2670,7 @@ class CommentSystem {
|
||||
|
||||
setupEmojiPicker(container) {
|
||||
const textarea = container.querySelector('textarea');
|
||||
if (!textarea) return;
|
||||
if (container.querySelector('.emoji-trigger')) return;
|
||||
|
||||
// Attach mentions
|
||||
@@ -2376,7 +2849,7 @@ class CommentSystem {
|
||||
if (actions) {
|
||||
// Add spoiler button
|
||||
const spoilerBtn = document.createElement('button');
|
||||
spoilerBtn.innerText = '[spoiler]';
|
||||
spoilerBtn.innerText = '[S]';
|
||||
spoilerBtn.className = 'spoiler-trigger';
|
||||
spoilerBtn.title = 'Insert spoiler tag';
|
||||
spoilerBtn.addEventListener('click', (e) => {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
const showModal = () => {
|
||||
if (!dragModal) return;
|
||||
dragModal.classList.add('show');
|
||||
document.body.classList.add('modal-open');
|
||||
// Reset scroll position so it always starts at the top
|
||||
dragModal.scrollTop = 0;
|
||||
const modalContent = dragModal.querySelector('.modal-content');
|
||||
@@ -89,6 +90,7 @@
|
||||
// Modal Close
|
||||
dragModalClose.onclick = () => {
|
||||
dragModal.classList.remove('show');
|
||||
document.body.classList.remove('modal-open');
|
||||
if (uploader && uploader.reset) {
|
||||
uploader.reset();
|
||||
}
|
||||
|
||||
@@ -40,12 +40,92 @@ window.cancelAnimFrame = (function () {
|
||||
} catch(e) { console.error('Visit tracking error:', e); }
|
||||
};
|
||||
|
||||
window.applyThumbCacheBust = (bgUrlStr) => {
|
||||
if (!bgUrlStr) return bgUrlStr;
|
||||
try {
|
||||
const bustedStr = localStorage.getItem('bustedThumbs');
|
||||
if (!bustedStr) return bgUrlStr;
|
||||
const busted = JSON.parse(bustedStr);
|
||||
const match = bgUrlStr.match(/\/t\/(\d+)(?:_blur)?\.webp/);
|
||||
if (match) {
|
||||
const id = match[1];
|
||||
if (busted[id]) {
|
||||
const url = new URL(bgUrlStr, window.location.origin);
|
||||
url.searchParams.set('t', busted[id]);
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
return bgUrlStr;
|
||||
};
|
||||
|
||||
/**
|
||||
* Forcefully refreshes all thumbnail occurrences for a specific item in the DOM.
|
||||
* Handles grid items (data-bg), images (src), and the background canvas.
|
||||
*/
|
||||
window.refreshItemThumbnails = (itemId, timestamp = Date.now()) => {
|
||||
if (!itemId) return;
|
||||
const idStr = String(itemId);
|
||||
|
||||
// Update localStorage so future navigations use the new timestamp
|
||||
try {
|
||||
const bustedStr = localStorage.getItem('bustedThumbs');
|
||||
const busted = bustedStr ? JSON.parse(bustedStr) : {};
|
||||
busted[idStr] = timestamp;
|
||||
const keys = Object.keys(busted);
|
||||
if (keys.length > 50) delete busted[keys[0]];
|
||||
localStorage.setItem('bustedThumbs', JSON.stringify(busted));
|
||||
} catch(e) {}
|
||||
|
||||
// Clear grid cache to force fresh render on next navigation
|
||||
if (typeof gridCacheMap !== 'undefined') gridCacheMap.clear();
|
||||
|
||||
// Update elements with data-bg (grid items).
|
||||
// We look for any data-bg or inline style containing the thumbnail path for this ID.
|
||||
document.querySelectorAll(`[data-bg*="/t/${idStr}.webp"], [data-bg*="/t/${idStr}_blur.webp"], [style*="/t/${idStr}.webp"], [style*="/t/${idStr}_blur.webp"]`).forEach(el => {
|
||||
// If it has data-bg, update it (this handles lazy-thumb logic)
|
||||
if (el.dataset.bg) {
|
||||
el.dataset.bg = window.applyThumbCacheBust(el.dataset.bg);
|
||||
}
|
||||
// If it's already showing the background, update the style directly
|
||||
if (el.style.backgroundImage || el.getAttribute('style')?.includes('background-image')) {
|
||||
const currentStyle = el.getAttribute('style') || '';
|
||||
// Match url(...) contents
|
||||
const newStyle = currentStyle.replace(/url\(['"]?([^'"]+)['"]?\)/g, (match, p1) => {
|
||||
if (p1.includes(`/t/${idStr}.webp`) || p1.includes(`/t/${idStr}_blur.webp`)) {
|
||||
return `url('${window.applyThumbCacheBust(p1)}')`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
el.setAttribute('style', newStyle);
|
||||
}
|
||||
});
|
||||
|
||||
// Update actual img tags
|
||||
document.querySelectorAll(`img[src*="/t/${idStr}.webp"], img[src*="/t/${idStr}_blur.webp"]`).forEach(el => {
|
||||
try {
|
||||
const url = new URL(el.src, window.location.origin);
|
||||
url.searchParams.set('t', timestamp);
|
||||
el.src = url.pathname + url.search;
|
||||
} catch(e) {}
|
||||
});
|
||||
|
||||
// Refresh background canvas if it matches the current item
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const numParts = pathParts.filter(s => /^\d+$/.test(s));
|
||||
const currentId = numParts.length > 0 ? numParts[numParts.length - 1] : null;
|
||||
if (currentId === idStr && window.initBackground) {
|
||||
window.initBackground();
|
||||
}
|
||||
};
|
||||
|
||||
let lazyObserver;
|
||||
window.initLazyLoading = () => {
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
document.querySelectorAll('.lazy-thumb').forEach(thumb => {
|
||||
if (thumb.dataset.bg) {
|
||||
thumb.style.backgroundImage = `url('${thumb.dataset.bg}')`;
|
||||
const finalBg = window.applyThumbCacheBust(thumb.dataset.bg);
|
||||
thumb.style.backgroundImage = `url('${finalBg}')`;
|
||||
thumb.classList.remove('lazy-thumb');
|
||||
}
|
||||
});
|
||||
@@ -57,8 +137,9 @@ window.cancelAnimFrame = (function () {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const thumb = entry.target;
|
||||
const bg = thumb.dataset.bg;
|
||||
let bg = thumb.dataset.bg;
|
||||
if (bg && !thumb.classList.contains('loaded')) {
|
||||
bg = window.applyThumbCacheBust(bg);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
thumb.style.backgroundImage = `url('${bg}')`;
|
||||
@@ -293,11 +374,61 @@ window.cancelAnimFrame = (function () {
|
||||
if (!modal) return;
|
||||
if (modal === loginModal) switchModalView(view);
|
||||
modal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
if (visitorMenu) visitorMenu.classList.remove('show');
|
||||
};
|
||||
|
||||
const closeModal = (modal) => {
|
||||
if (modal) modal.style.display = 'none';
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Surgical cleanup of scroll-lock state and modal visibility.
|
||||
* Used during AJAX navigation to ensure the UI remains interactive.
|
||||
*/
|
||||
window.resetGlobalScrollState = () => {
|
||||
document.body.classList.remove('modal-open');
|
||||
document.documentElement.classList.remove('modal-open');
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.height = '';
|
||||
document.documentElement.style.overflow = '';
|
||||
document.documentElement.style.height = '';
|
||||
const pw = document.querySelector('.pagewrapper');
|
||||
if (pw) {
|
||||
pw.style.overflow = '';
|
||||
pw.style.height = '';
|
||||
}
|
||||
};
|
||||
|
||||
window.hideAllModals = () => {
|
||||
const modalIds = [
|
||||
'login-modal', 'register-modal', 'forgot-modal', 'reset-modal',
|
||||
'report-modal', 'halls-modal', 'metadata-modal', 'warning-modal',
|
||||
'shortcuts-modal', 'upload-drag-modal', 'excluded-tags-overlay',
|
||||
'content-warning-modal', 'gchat-img-modal', 'image-modal'
|
||||
];
|
||||
modalIds.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.classList.remove('show', 'visible');
|
||||
// If the modal uses CSS classes for visibility, we must clear the inline display
|
||||
// to allow those classes to work later. For others, we force display: none.
|
||||
if (['upload-drag-modal', 'image-modal', 'gchat-img-modal', 'excluded-tags-overlay'].includes(id)) {
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
// Also handle class-based modals if any
|
||||
document.querySelectorAll('.modal-overlay, .modal-backdrop').forEach(el => {
|
||||
el.classList.remove('show', 'visible');
|
||||
// Do NOT set display: none here as it might override CSS-based visibility
|
||||
// for modals that use the classes we just removed.
|
||||
});
|
||||
};
|
||||
|
||||
if (loginModal) {
|
||||
@@ -512,6 +643,8 @@ window.cancelAnimFrame = (function () {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (registerBtn && registerModal) {
|
||||
registerBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
@@ -844,7 +977,9 @@ window.cancelAnimFrame = (function () {
|
||||
}
|
||||
// For audio-only items with no thumbnail, canvas stays blank (nothing to draw)
|
||||
};
|
||||
thumb.src = `/t/${itemId}.webp`;
|
||||
let newSrc = `/t/${itemId}.webp`;
|
||||
if (window.applyThumbCacheBust) newSrc = window.applyThumbCacheBust(newSrc);
|
||||
thumb.src = newSrc;
|
||||
} else if (isDrawable) {
|
||||
// No item ID — fall back to waiting for the main image
|
||||
if (elem.complete) {
|
||||
@@ -1016,8 +1151,7 @@ window.cancelAnimFrame = (function () {
|
||||
if (cwModal) {
|
||||
if (!localStorage.getItem('content_warning_accepted')) {
|
||||
cwModal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden'; // Prevent scrolling on body
|
||||
document.documentElement.style.overflow = 'hidden'; // Prevent scrolling on html
|
||||
document.body.classList.add('modal-open');
|
||||
}
|
||||
|
||||
const acceptBtn = document.getElementById('cw-accept');
|
||||
@@ -1027,8 +1161,7 @@ window.cancelAnimFrame = (function () {
|
||||
acceptBtn.addEventListener('click', () => {
|
||||
localStorage.setItem('content_warning_accepted', 'true');
|
||||
cwModal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
document.documentElement.style.overflow = '';
|
||||
document.body.classList.remove('modal-open');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1079,7 +1212,7 @@ window.cancelAnimFrame = (function () {
|
||||
const applyRuffleKeepAlive = () => {
|
||||
if (ruffleKeepAliveApplied) return;
|
||||
|
||||
console.log("[Ruffle] Registering background keep-alive patches (Browser Level)...");
|
||||
window.f0ckDebug("[Ruffle] Registering background keep-alive patches (Browser Level)...");
|
||||
|
||||
try {
|
||||
const docProto = Object.getPrototypeOf(document);
|
||||
@@ -1422,6 +1555,11 @@ window.cancelAnimFrame = (function () {
|
||||
|
||||
const loadPageAjax = async (url, replace = true, options = {}) => {
|
||||
if (isNavigating) return;
|
||||
|
||||
// Immediately restore scrollability and hide modals
|
||||
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
|
||||
if (window.hideAllModals) window.hideAllModals();
|
||||
|
||||
isNavigating = true;
|
||||
|
||||
// ── Scroller-active cleanup ──────────────────────────────────────────────
|
||||
@@ -1793,7 +1931,7 @@ window.cancelAnimFrame = (function () {
|
||||
document.body.style.height = '';
|
||||
document.body.style.minHeight = '';
|
||||
|
||||
console.log("[loadPageAjax] State synced for " + (isFullPage ? "full page" : "partial"));
|
||||
window.f0ckDebug("[loadPageAjax] State synced for " + (isFullPage ? "full page" : "partial"));
|
||||
if (window.updateMimeLabel) window.updateMimeLabel();
|
||||
}
|
||||
|
||||
@@ -2287,7 +2425,7 @@ window.cancelAnimFrame = (function () {
|
||||
m.removeAttribute('src');
|
||||
m.preload = 'none'; // Prevent further buffering
|
||||
// m.load(); // Intentionally removed: calling load() with no src can fetch current page URL
|
||||
console.log("Media aborted:", m);
|
||||
window.f0ckDebug("Media aborted:", m);
|
||||
} catch (e) { console.error("Error stopping media:", e); }
|
||||
});
|
||||
};
|
||||
@@ -2309,7 +2447,7 @@ window.cancelAnimFrame = (function () {
|
||||
|
||||
try {
|
||||
const fetchUrl = `/ajax/item/${itemid}?${params.toString()}`;
|
||||
console.log(`[updateNavForMode] mode=${mode} itemid=${itemid} → ${fetchUrl}`);
|
||||
window.f0ckDebug(`[updateNavForMode] mode=${mode} itemid=${itemid} → ${fetchUrl}`);
|
||||
const resp = await fetch(fetchUrl, { credentials: 'include' });
|
||||
if (!resp.ok) { console.warn('[updateNavForMode] bad response:', resp.status); return; }
|
||||
const data = await resp.json();
|
||||
@@ -2326,14 +2464,14 @@ window.cancelAnimFrame = (function () {
|
||||
if (livePrev && newPrev) {
|
||||
const h = newPrev.getAttribute('href');
|
||||
livePrev.setAttribute('href', h);
|
||||
console.log(`[updateNavForMode] #prev → ${h}`);
|
||||
window.f0ckDebug(`[updateNavForMode] #prev → ${h}`);
|
||||
} else {
|
||||
console.warn(`[updateNavForMode] #prev missing: live=${!!livePrev} new=${!!newPrev}`);
|
||||
}
|
||||
if (liveNext && newNext) {
|
||||
const h = newNext.getAttribute('href');
|
||||
liveNext.setAttribute('href', h);
|
||||
console.log(`[updateNavForMode] #next → ${h}`);
|
||||
window.f0ckDebug(`[updateNavForMode] #next → ${h}`);
|
||||
} else {
|
||||
console.warn(`[updateNavForMode] #next missing: live=${!!liveNext} new=${!!newNext}`);
|
||||
}
|
||||
@@ -2353,6 +2491,11 @@ window.cancelAnimFrame = (function () {
|
||||
const loadItemAjax = async (url, inheritContext = true, options = {}) => {
|
||||
|
||||
if (isNavigating) return;
|
||||
|
||||
// Immediately restore scrollability and hide modals
|
||||
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
|
||||
if (window.hideAllModals) window.hideAllModals();
|
||||
|
||||
isNavigating = true;
|
||||
|
||||
// ── Scroller-active cleanup (same as loadPageAjax) ───────────────────
|
||||
@@ -2534,7 +2677,7 @@ window.cancelAnimFrame = (function () {
|
||||
|
||||
if (window.randomizeLogo) window.randomizeLogo();
|
||||
|
||||
console.log("Fetching:", ajaxUrl);
|
||||
window.f0ckDebug("Fetching:", ajaxUrl);
|
||||
const tStart = performance.now();
|
||||
const response = await fetch(ajaxUrl, { credentials: 'include' });
|
||||
const tHeaders = performance.now();
|
||||
@@ -2544,7 +2687,7 @@ window.cancelAnimFrame = (function () {
|
||||
const rawText = await response.text();
|
||||
const tBody = performance.now();
|
||||
|
||||
console.log(`[CLIENT_DEBUG] Fetch timing for ${ajaxUrl}:
|
||||
window.f0ckDebug(`[CLIENT_DEBUG] Fetch timing for ${ajaxUrl}:
|
||||
- TTFB (Headers): ${(tHeaders - tStart).toFixed(2)}ms
|
||||
- Content Download: ${(tBody - tHeaders).toFixed(2)}ms
|
||||
- Total Network: ${(tBody - tStart).toFixed(2)}ms
|
||||
@@ -2705,7 +2848,7 @@ window.cancelAnimFrame = (function () {
|
||||
// Try to extract ID from response if possible or just use itemid
|
||||
document.title = `${window.f0ckDomain} - ${itemid}`;
|
||||
if (navbar) navbar.classList.remove("pbwork");
|
||||
console.log("AJAX load complete");
|
||||
window.f0ckDebug("AJAX load complete");
|
||||
|
||||
// Notify extensions — also triggers CommentSystem init which renders comments
|
||||
document.dispatchEvent(new Event('f0ck:contentLoaded'));
|
||||
@@ -2956,8 +3099,10 @@ window.cancelAnimFrame = (function () {
|
||||
if (window.f0ckSession && window.f0ckSession.logged_in) {
|
||||
window.showFlash('Already logged in lol', 'error');
|
||||
} else {
|
||||
loadPageAjax(anyLink.href, true);
|
||||
if (pathname === '/login') openModal(loginModal, 'login');
|
||||
else openModal(registerModal);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3067,6 +3212,9 @@ window.cancelAnimFrame = (function () {
|
||||
fileInput.value = '';
|
||||
if (data.success) {
|
||||
window.flashMessage(data.msg);
|
||||
if (window.refreshItemThumbnails) {
|
||||
window.refreshItemThumbnails(currentItemId);
|
||||
}
|
||||
} else {
|
||||
window.flashError(data.msg || 'Upload failed');
|
||||
}
|
||||
@@ -3163,7 +3311,7 @@ window.cancelAnimFrame = (function () {
|
||||
const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking');
|
||||
const isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/';
|
||||
|
||||
console.log("[popstate] Navigation to:", url, { isItem, isSpecial, isGridLike });
|
||||
window.f0ckDebug("[popstate] Navigation to:", url, { isItem, isSpecial, isGridLike });
|
||||
|
||||
if (isItem) {
|
||||
loadItemAjax(url, true, { skipPush: true });
|
||||
@@ -4280,6 +4428,7 @@ window.cancelAnimFrame = (function () {
|
||||
const tag = (e.target.tagName || '').toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
|
||||
if (window.location.pathname.startsWith('/abyss')) return;
|
||||
e.preventDefault();
|
||||
toggleModal(!overlay.classList.contains('visible'));
|
||||
}
|
||||
});
|
||||
@@ -4510,6 +4659,7 @@ window.cancelAnimFrame = (function () {
|
||||
};
|
||||
|
||||
window.updateXdBadgeFromScore = (itemId, score) => {
|
||||
if (window.f0ckSession && window.f0ckSession.enable_xd_score === false) return;
|
||||
updateItemPageXdBadge(itemId, score);
|
||||
updateThumbXdIndicator(itemId, score);
|
||||
};
|
||||
@@ -5143,7 +5293,7 @@ class NotificationSystem {
|
||||
fetch(`/api/notifications/active?tabId=${this.tabId}`).catch(() => {});
|
||||
// If SSE is closed, reconnect
|
||||
if (!this.es || this.es.readyState === 2) {
|
||||
console.log("[NotificationSystem] SSE was closed, reconnecting as active tab...");
|
||||
window.f0ckDebug("[NotificationSystem] SSE was closed, reconnecting as active tab...");
|
||||
this.initSSE();
|
||||
}
|
||||
};
|
||||
@@ -5195,17 +5345,19 @@ class NotificationSystem {
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[NotificationSystem] Tab visible, signaling active...");
|
||||
window.f0ckDebug("[NotificationSystem] Tab visible, signaling active...");
|
||||
// If SSE died while hidden, restart it now
|
||||
if (!this.es) {
|
||||
console.log("[NotificationSystem] SSE was dead, restarting on tab visible.");
|
||||
window.f0ckDebug("[NotificationSystem] SSE was dead, restarting on tab visible.");
|
||||
this.retryCount = 0;
|
||||
this.initSSE();
|
||||
}
|
||||
if (this.pollDebounced) this.pollDebounced();
|
||||
if (this.checkForNewItems) this.checkForNewItems();
|
||||
// Catch-up on emojis if they were updated while this tab was pruned/backgrounded
|
||||
window.dispatchEvent(new CustomEvent('f0ck:emojis_updated'));
|
||||
// Note: emojis_updated dispatch was removed from here — it caused emoji cache flush +
|
||||
// async re-fetch + re-render that could fight the scroll-position restoration.
|
||||
// Emojis rarely change while a tab is hidden; SSE will deliver a targeted emojis_updated
|
||||
// event if they actually changed.
|
||||
signalActive();
|
||||
// Sync display name in case it was changed while this tab was inactive
|
||||
if (window.f0ckSession?.logged_in) this.syncDisplayName();
|
||||
@@ -5241,11 +5393,11 @@ class NotificationSystem {
|
||||
this.es.close();
|
||||
}
|
||||
|
||||
console.log(`[NotificationSystem] Initializing SSE connection (tabId: ${this.tabId})...`);
|
||||
window.f0ckDebug(`[NotificationSystem] Initializing SSE connection (tabId: ${this.tabId})...`);
|
||||
this.es = new EventSource(`/api/notifications/stream?tabId=${this.tabId}`);
|
||||
|
||||
this.es.onopen = () => {
|
||||
console.log("[NotificationSystem] SSE connection established");
|
||||
window.f0ckDebug("[NotificationSystem] SSE connection established");
|
||||
this.retryCount = 0;
|
||||
document.documentElement.dataset.sseReady = '1';
|
||||
document.dispatchEvent(new CustomEvent('f0ck:sse_ready'));
|
||||
@@ -5254,16 +5406,17 @@ class NotificationSystem {
|
||||
this.es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
console.log(`[SSE] Received message:`, data.type);
|
||||
window.f0ckDebug(`[SSE] Received message:`, data.type);
|
||||
if (data.type === 'notify') {
|
||||
this.pollDebounced();
|
||||
const dnd = window.f0ckSession?.do_not_disturb === true;
|
||||
// Haptic feedback on mobile (supported: Chrome for Android, not iOS)
|
||||
if (navigator.vibrate) navigator.vibrate([200, 80, 200]);
|
||||
if (!dnd && navigator.vibrate) navigator.vibrate([200, 80, 200]);
|
||||
// Live Grid Highlight
|
||||
if (data.data && data.data.item_id) {
|
||||
const itemId = data.data.item_id;
|
||||
const notifType = data.data.type;
|
||||
console.log(`[SSE] Live notification for item ${itemId} (type: ${notifType})`);
|
||||
window.f0ckDebug(`[SSE] Live notification for item ${itemId} (type: ${notifType})`);
|
||||
|
||||
// System notifications (deletion, deny, reports) require explicit acknowledgment —
|
||||
// never auto-mark them as read just because the user is viewing that item.
|
||||
@@ -5273,7 +5426,7 @@ class NotificationSystem {
|
||||
// (they are live on the thread, so no need to show a badge/highlight)
|
||||
const currentPath = window.location.pathname;
|
||||
if (!isSystemNotif && (currentPath === `/${itemId}` || currentPath === `/${itemId}/`)) {
|
||||
console.log(`[SSE] Notification for current item ${itemId} — auto-marking as read`);
|
||||
window.f0ckDebug(`[SSE] Notification for current item ${itemId} — auto-marking as read`);
|
||||
fetch(`/api/notifications/item/${itemId}/read`, {
|
||||
method: 'POST',
|
||||
keepalive: true
|
||||
@@ -5307,13 +5460,13 @@ class NotificationSystem {
|
||||
}
|
||||
this.handleActivity(data.data);
|
||||
} else if (data.type === 'tags') {
|
||||
console.log(`[SSE] Tag update received for item ${data.data?.item_id}`);
|
||||
window.f0ckDebug(`[SSE] Tag update received for item ${data.data?.item_id}`);
|
||||
this.handleTagsUpdate(data.data);
|
||||
} else if (data.type === 'favorites') {
|
||||
console.log(`[SSE] Favorite update received for item ${data.data?.item_id}`);
|
||||
window.f0ckDebug(`[SSE] Favorite update received for item ${data.data?.item_id}`);
|
||||
this.handleFavoritesUpdate(data.data);
|
||||
} else if (data.type === 'comments') {
|
||||
console.log(`[SSE] Comment update received:`, data.data);
|
||||
window.f0ckDebug(`[SSE] Comment update received:`, data.data);
|
||||
if (data.data.type === 'comment') {
|
||||
// New comment posted — update xD badge from server-authoritative score
|
||||
if (typeof data.data.xd_score === 'number') {
|
||||
@@ -5339,21 +5492,26 @@ class NotificationSystem {
|
||||
window.dispatchEvent(new CustomEvent('f0ck:comment_edited', { detail: data.data }));
|
||||
}
|
||||
} else if (data.type === 'emojis_updated') {
|
||||
console.log("[SSE] Emojis updated, refreshing caches...");
|
||||
window.f0ckDebug("[SSE] Emojis updated, refreshing caches...");
|
||||
this.loadEmojis();
|
||||
window.dispatchEvent(new CustomEvent('f0ck:emojis_updated'));
|
||||
} else if (data.type === 'motd') {
|
||||
console.log(`[SSE] MOTD update received:`, data.data.motd);
|
||||
window.f0ckDebug(`[SSE] MOTD update received:`, data.data.motd);
|
||||
if (typeof window.updateMotdUI === 'function') {
|
||||
window.updateMotdUI(data.data.motd);
|
||||
}
|
||||
} else if (data.type === 'rethumb') {
|
||||
window.f0ckDebug(`[SSE] Rethumb update received for item ${data.data?.item_id}`);
|
||||
if (data.data && data.data.item_id && window.refreshItemThumbnails) {
|
||||
window.refreshItemThumbnails(data.data.item_id);
|
||||
}
|
||||
} else if (data.type === 'new_item') {
|
||||
console.log(`[SSE] New item received:`, data.data);
|
||||
window.f0ckDebug(`[SSE] New item received:`, data.data);
|
||||
this.handleNewItem(data.data);
|
||||
} else if (data.type === 'delete_item') {
|
||||
const delId = data.data?.id;
|
||||
if (!delId) return;
|
||||
console.log(`[SSE] Item deleted: ${delId}`);
|
||||
window.f0ckDebug(`[SSE] Item deleted: ${delId}`);
|
||||
|
||||
// Remove from main grid — a.thumb is the anchor, li is its parent card
|
||||
const thumb = document.querySelector(`a.thumb[href$="/${delId}"], a.lazy-thumb[href$="/${delId}"]`);
|
||||
@@ -5389,22 +5547,23 @@ class NotificationSystem {
|
||||
// Remove from sidebar activity if present
|
||||
document.querySelectorAll(`#sidebar-activity-container [data-item="${delId}"]`).forEach(el => el.remove());
|
||||
} else if (data.type === 'emojis_updated') {
|
||||
console.log(`[SSE] Emoji update event received`);
|
||||
window.f0ckDebug(`[SSE] Emoji update event received`);
|
||||
if (window.commentSystem && typeof window.commentSystem.loadEmojis === 'function') {
|
||||
window.commentSystem.loadEmojis();
|
||||
}
|
||||
// Global dispatch for other listeners (e.g. Admin Dashboard)
|
||||
document.dispatchEvent(new Event('f0ck:emojis_updated'));
|
||||
} else if (data.type === 'warning') {
|
||||
console.log(`[SSE] Warning received:`, data.data);
|
||||
window.f0ckDebug(`[SSE] Warning received:`, data.data);
|
||||
const warningModal = document.getElementById('warning-modal');
|
||||
if (warningModal) {
|
||||
document.getElementById('warning-reason').textContent = data.data.reason;
|
||||
document.getElementById('warning-id').value = data.data.warning_id;
|
||||
warningModal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
}
|
||||
} else if (data.type === 'private_message') {
|
||||
console.log(`[SSE] Private message received from user ${data.data?.sender_id}`);
|
||||
window.f0ckDebug(`[SSE] Private message received from user ${data.data?.sender_id}`);
|
||||
// Haptic feedback for DMs (distinct double-pulse pattern)
|
||||
if (navigator.vibrate) navigator.vibrate([120, 60, 120]);
|
||||
// Dispatch event for messages.js thread live-update
|
||||
@@ -5432,6 +5591,12 @@ class NotificationSystem {
|
||||
} else if (data.type === 'profile_update') {
|
||||
const { display_name, user } = data.data;
|
||||
this.applyDisplayNameUpdate(display_name, user);
|
||||
// Sync preferences to global session object for client-side gating (like DND)
|
||||
if (window.f0ckSession && data.data.user_id === window.f0ckSession.id) {
|
||||
for (const key in data.data) {
|
||||
if (key !== 'user_id') window.f0ckSession[key] = data.data[key];
|
||||
}
|
||||
}
|
||||
} else if (data.type === 'global_chat') {
|
||||
document.dispatchEvent(new CustomEvent('f0ck:global_chat', { detail: data.data }));
|
||||
} else if (data.type === 'global_chat_clear') {
|
||||
@@ -5505,14 +5670,14 @@ class NotificationSystem {
|
||||
|
||||
// If tab is hidden, don't retry now — visibilitychange will restart SSE when visible again
|
||||
if (document.hidden) {
|
||||
console.log("[NotificationSystem] Tab hidden, deferring SSE retry until visible.");
|
||||
window.f0ckDebug("[NotificationSystem] Tab hidden, deferring SSE retry until visible.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Exponential backoff, capped at 30s
|
||||
const delay = Math.min(Math.pow(2, this.retryCount) * 1000, 30000);
|
||||
if (this.retryCount < this.maxRetries) {
|
||||
console.log(`[NotificationSystem] Retrying SSE in ${delay}ms... (attempt ${this.retryCount + 1}/${this.maxRetries})`);
|
||||
window.f0ckDebug(`[NotificationSystem] Retrying SSE in ${delay}ms... (attempt ${this.retryCount + 1}/${this.maxRetries})`);
|
||||
setTimeout(() => this.initSSE(), delay);
|
||||
this.retryCount++;
|
||||
} else {
|
||||
@@ -5618,7 +5783,7 @@ class NotificationSystem {
|
||||
|
||||
// Handle "Mark as Read"
|
||||
if (link.dataset.id && link.classList.contains('unread')) {
|
||||
console.log(`[NotificationSystem] Marking ${link.dataset.id} as read...`);
|
||||
window.f0ckDebug(`[NotificationSystem] Marking ${link.dataset.id} as read...`);
|
||||
// Fire and forget (keepalive ensures it survives navigation)
|
||||
fetch(`/api/notifications/${link.dataset.id}/read`, {
|
||||
method: 'POST',
|
||||
@@ -5664,6 +5829,10 @@ class NotificationSystem {
|
||||
if (href && href !== '#') {
|
||||
e.preventDefault();
|
||||
|
||||
// Immediately restore scrollability and hide modals for better UX
|
||||
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
|
||||
if (window.hideAllModals) window.hideAllModals();
|
||||
|
||||
// Close dropdown
|
||||
if (this.isOpen) this.close();
|
||||
|
||||
@@ -5766,7 +5935,7 @@ class NotificationSystem {
|
||||
if (ids.length === 0) return;
|
||||
const maxId = Math.max(...ids);
|
||||
|
||||
console.log(`[NotificationSystem] Checking for items newer than ${maxId}...`);
|
||||
window.f0ckDebug(`[NotificationSystem] Checking for items newer than ${maxId}...`);
|
||||
|
||||
try {
|
||||
// Build filters from URL
|
||||
@@ -5806,7 +5975,7 @@ class NotificationSystem {
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.html) {
|
||||
console.log(`[NotificationSystem] Loaded new items for grid.`);
|
||||
window.f0ckDebug(`[NotificationSystem] Loaded new items for grid.`);
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = Sanitizer.clean(data.html);
|
||||
|
||||
@@ -5911,7 +6080,7 @@ class NotificationSystem {
|
||||
tabNotifs.forEach(n => {
|
||||
const existing = historyContainer.querySelector(`.notif-item[data-id="${n.id}"]`);
|
||||
if (!existing) {
|
||||
console.log("[NotificationSystem] Adding new item to history:", n.id);
|
||||
window.f0ckDebug("[NotificationSystem] Adding new item to history:", n.id);
|
||||
const html = this.renderHistoryItem(n);
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = Sanitizer.clean(html);
|
||||
@@ -5919,7 +6088,7 @@ class NotificationSystem {
|
||||
node.classList.add('new-item-fade');
|
||||
historyContainer.prepend(node);
|
||||
} else {
|
||||
console.log("[NotificationSystem] Item already exists:", n.id);
|
||||
window.f0ckDebug("[NotificationSystem] Item already exists:", n.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -6177,10 +6346,10 @@ class NotificationSystem {
|
||||
const idLink = document.querySelector('.id-link');
|
||||
const currentId = idLink ? parseInt(idLink.innerText) : null;
|
||||
|
||||
console.log(`[NotificationSystem] Processing tag update for #${data.item_id}. Current view is #${currentId}`);
|
||||
window.f0ckDebug(`[NotificationSystem] Processing tag update for #${data.item_id}. Current view is #${currentId}`);
|
||||
|
||||
if (currentId !== parseInt(data.item_id)) {
|
||||
console.log("[NotificationSystem] Item ID mismatch, ignoring update.");
|
||||
window.f0ckDebug("[NotificationSystem] Item ID mismatch, ignoring update.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6192,11 +6361,11 @@ class NotificationSystem {
|
||||
|
||||
// DO NOT re-render if the user is currently typing a new tag (has an active input)
|
||||
if (tagsContainer.querySelector('input')) {
|
||||
console.log("[NotificationSystem] Live Tag Update deferred - User is currently typing.");
|
||||
window.f0ckDebug("[NotificationSystem] Live Tag Update deferred - User is currently typing.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[NotificationSystem] Re-rendering tags for item:", data.item_id);
|
||||
window.f0ckDebug("[NotificationSystem] Re-rendering tags for item:", data.item_id);
|
||||
|
||||
const inner = tagsContainer.querySelector('.tags-inner') || tagsContainer;
|
||||
|
||||
@@ -6216,7 +6385,7 @@ class NotificationSystem {
|
||||
const isAdminBySession = !!(window.f0ckSession?.is_admin || window.f0ckSession?.is_moderator);
|
||||
const hasSession = !!window.f0ckSession;
|
||||
|
||||
console.log(`[NotificationSystem] Rendering ${data.tags.length} tags. isAdmin: ${isAdminBySession}, hasSession: ${hasSession}`);
|
||||
window.f0ckDebug(`[NotificationSystem] Rendering ${data.tags.length} tags. isAdmin: ${isAdminBySession}, hasSession: ${hasSession}`);
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
data.tags.forEach(tag => {
|
||||
@@ -6256,7 +6425,7 @@ class NotificationSystem {
|
||||
const favsContainer = document.querySelector('#favs');
|
||||
if (!favsContainer) return;
|
||||
|
||||
console.log("[NotificationSystem] Live Favorite Update for item:", data.item_id);
|
||||
window.f0ckDebug("[NotificationSystem] Live Favorite Update for item:", data.item_id);
|
||||
|
||||
// Sync the heart icon for the current user
|
||||
const currentUser = window.f0ckSession?.user?.toLowerCase();
|
||||
@@ -6384,7 +6553,7 @@ class NotificationSystem {
|
||||
// 3. Handle Activity Feed
|
||||
const activityContainer = document.getElementById('activity-container');
|
||||
if (activityContainer) {
|
||||
console.log("[NotificationSystem] New Activity:", data);
|
||||
window.f0ckDebug("[NotificationSystem] New Activity:", data);
|
||||
const html = this.renderActivityItem(data);
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = Sanitizer.clean(html);
|
||||
@@ -6965,6 +7134,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const intervals = [
|
||||
{ unit: 'year', seconds: 31536000 },
|
||||
{ unit: 'month', seconds: 2592000 },
|
||||
{ unit: 'week', seconds: 604800 },
|
||||
{ unit: 'day', seconds: 86400 },
|
||||
{ unit: 'hour', seconds: 3600 },
|
||||
{ unit: 'minute', seconds: 60 },
|
||||
@@ -6975,13 +7145,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const count = Math.floor(seconds / interval.seconds);
|
||||
if (count >= 1) {
|
||||
try {
|
||||
// Force fallback for custom/unrecognized locales like 'zange'
|
||||
if (lang === 'zange') throw new Error('Force fallback');
|
||||
const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' });
|
||||
return rtf.format(-count, interval.unit);
|
||||
} catch (e) {
|
||||
// Intl not available — fall back to i18n strings
|
||||
const key = count === 1 ? `timeago_${interval.unit}` : `timeago_${interval.unit}s`;
|
||||
const tpl = i18n[key] || `{n} ${interval.unit}${count !== 1 ? 's' : ''}`;
|
||||
const timeStr = tpl.replace('{n}', count);
|
||||
const timeStr = tpl.replace('{n}', count).replace('{s}', count !== 1 ? 's' : '');
|
||||
const agoTpl = i18n.timeago_ago || '{t} ago';
|
||||
return agoTpl.replace('{t}', timeStr);
|
||||
}
|
||||
@@ -7307,6 +7479,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
reportReason.value = '';
|
||||
reportError.textContent = '';
|
||||
reportModal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
}
|
||||
|
||||
const commentBtn = e.target.closest('.report-comment-btn');
|
||||
@@ -7318,6 +7491,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
reportReason.value = '';
|
||||
reportError.textContent = '';
|
||||
reportModal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
}
|
||||
|
||||
const userBtn = e.target.closest('.report-user-btn'); // for future
|
||||
@@ -7329,11 +7503,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
reportReason.value = '';
|
||||
reportError.textContent = '';
|
||||
reportModal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
}
|
||||
|
||||
// Close logic
|
||||
if (e.target.matches('#report-cancel')) {
|
||||
reportModal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
|
||||
// Submit logic
|
||||
@@ -7392,6 +7568,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('warning-reason').textContent = warning.reason;
|
||||
document.getElementById('warning-id').value = warning.id;
|
||||
warningModal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('Error fetching warnings', e); }
|
||||
@@ -7414,6 +7591,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.getElementById('warning-modal').style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
// Check for more warnings
|
||||
checkWarnings();
|
||||
} else {
|
||||
@@ -8128,6 +8306,7 @@ if (navigator.vibrate) {
|
||||
const noResults = document.getElementById('metadata-no-results');
|
||||
|
||||
modal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
loading.style.display = 'block';
|
||||
resultsCont.style.display = 'none';
|
||||
error.style.display = 'none';
|
||||
@@ -8136,6 +8315,7 @@ if (navigator.vibrate) {
|
||||
|
||||
const close = () => {
|
||||
modal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
document.removeEventListener('keydown', onEsc);
|
||||
window.removeEventListener('pjax:start', onNav);
|
||||
document.removeEventListener('f0ck:contentLoaded', onContentLoaded);
|
||||
@@ -8365,4 +8545,9 @@ if (navigator.vibrate) {
|
||||
}
|
||||
}
|
||||
});
|
||||
// Ensure any navigation event restores the scroll state
|
||||
window.addEventListener('pjax:start', () => {
|
||||
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
|
||||
if (window.hideAllModals) window.hideAllModals();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -692,7 +692,7 @@
|
||||
// firefox mobile check
|
||||
const isFirefoxMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent) && /firefox/i.test(navigator.userAgent);
|
||||
if (isFirefoxMobile) {
|
||||
console.log("Firefox Mobile detected, disabling Flash Yank script.");
|
||||
window.f0ckDebug("Firefox Mobile detected, disabling Flash Yank script.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
const MAX_VISIBLE_MSGS = 100;
|
||||
const RATE_LIMIT_MS = 800;
|
||||
|
||||
let isMinimized = localStorage.getItem('f0ck_chat_minimized') === '1';
|
||||
let isMinimized = localStorage.getItem('f0ck_chat_minimized') !== '0';
|
||||
let isClosed = localStorage.getItem('f0ck_chat_closed') === '1';
|
||||
let lastSent = 0;
|
||||
let customEmojis = null; // name → url
|
||||
@@ -225,6 +225,13 @@
|
||||
`</a>`;
|
||||
});
|
||||
|
||||
// 6d.1 Vocaroo URLs → iframe embed
|
||||
const vocarooRegex = /https?:\/\/(?:www\.)?(?:voca\.ro|vocaroo\.com)\/([a-zA-Z0-9_-]+)[^\s]*/gi;
|
||||
html = html.replace(vocarooRegex, (match, vocarooId) => {
|
||||
if (['upload', 'contact', 'privacy', 'tos', 'about'].includes(vocarooId.toLowerCase())) return match;
|
||||
return `<span class="vocaroo-embed-wrap gchat-embed-audio"><iframe src="https://vocaroo.com/embed/${vocarooId}?autoplay=0" width="300" height="60" frameborder="0" allow="autoplay"></iframe></span>`;
|
||||
});
|
||||
|
||||
// 6d.5 Same-site item page links → post preview card (resolved async)
|
||||
// Only catches /digits paths — direct media file URLs are handled by 6a-6c & 6e.
|
||||
const siteHostEsc = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@@ -1133,8 +1140,18 @@
|
||||
panel.style.top = newY + 'px';
|
||||
};
|
||||
const onEnd = () => {
|
||||
const curY = parseInt(panel.style.top);
|
||||
localStorage.setItem('f0ck_chat_float_x', parseInt(panel.style.left));
|
||||
localStorage.setItem('f0ck_chat_float_y', parseInt(panel.style.top));
|
||||
localStorage.setItem('f0ck_chat_float_y', curY);
|
||||
|
||||
if (isMinimized) {
|
||||
const topBound = getTopBound();
|
||||
const bottomBound = window.innerHeight;
|
||||
const distToTop = Math.abs(curY - topBound);
|
||||
const distToBottom = Math.abs(bottomBound - (curY + 42));
|
||||
localStorage.setItem('f0ck_chat_anchor_top', distToTop < distToBottom ? '1' : '0');
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', mouseMove);
|
||||
document.removeEventListener('mouseup', mouseEnd);
|
||||
document.removeEventListener('touchmove', touchMove);
|
||||
@@ -1169,29 +1186,60 @@
|
||||
// Minimize toggle — in float mode anchor to bottom edge in both directions
|
||||
function toggleMinimized() {
|
||||
const willExpand = isMinimized; // about to expand
|
||||
if (!willExpand && isFloating) {
|
||||
// About to minimize: capture bottom edge BEFORE shrinking
|
||||
if (isFloating) {
|
||||
const r = panel.getBoundingClientRect();
|
||||
const topBound = getTopBound();
|
||||
const bottomBound = window.innerHeight;
|
||||
|
||||
if (!willExpand) {
|
||||
// About to minimize: decide anchor based on proximity
|
||||
const distToTop = Math.abs(r.top - topBound);
|
||||
const distToBottom = Math.abs(bottomBound - (r.top + r.height));
|
||||
const anchorTop = distToTop < distToBottom;
|
||||
localStorage.setItem('f0ck_chat_anchor_top', anchorTop ? '1' : '0');
|
||||
|
||||
const curTop = r.top;
|
||||
const curBottom = r.top + r.height;
|
||||
setMinimized(true);
|
||||
// After CSS applies 42px height, shift top so bottom edge is preserved
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const newTop = Math.min(window.innerHeight - 42, Math.max(getTopBound(), curBottom - 42));
|
||||
let newTop;
|
||||
if (anchorTop) {
|
||||
// Anchor to top: keep current top
|
||||
newTop = Math.max(topBound, curTop);
|
||||
} else {
|
||||
// Anchor to bottom: keep current bottom (existing behavior)
|
||||
newTop = Math.min(bottomBound - 42, Math.max(topBound, curBottom - 42));
|
||||
}
|
||||
panel.style.top = newTop + 'px';
|
||||
localStorage.setItem('f0ck_chat_float_y', newTop);
|
||||
});
|
||||
} else {
|
||||
setMinimized(!isMinimized);
|
||||
if (willExpand && isFloating) {
|
||||
// Expanding: shift top UP to maintain bottom edge
|
||||
// Expanding: use saved anchor or default to bottom if not set
|
||||
const wasAnchorTop = localStorage.getItem('f0ck_chat_anchor_top') === '1';
|
||||
setMinimized(false);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const fullH = panel.getBoundingClientRect().height;
|
||||
const curTop = parseInt(panel.style.top) || 0;
|
||||
const newTop = Math.max(getTopBound(), curTop - (fullH - 42));
|
||||
let newTop;
|
||||
if (wasAnchorTop) {
|
||||
// Expanding from top anchor: top stays same
|
||||
newTop = Math.max(topBound, curTop);
|
||||
} else {
|
||||
// Expanding from bottom anchor: shift top UP (existing behavior)
|
||||
newTop = Math.max(topBound, curTop - (fullH - 42));
|
||||
}
|
||||
panel.style.top = newTop + 'px';
|
||||
localStorage.setItem('f0ck_chat_float_y', newTop);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setMinimized(!isMinimized);
|
||||
if (!isMinimized) {
|
||||
// Normal docked expand
|
||||
loadHistory();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1341,10 +1389,10 @@
|
||||
});
|
||||
|
||||
// ── Online users bar ─────────────────────────────────────────────────
|
||||
function renderOnline(users) {
|
||||
function renderOnline(users, guestCount = 0) {
|
||||
const el = document.getElementById('gchat-online');
|
||||
if (!el) return;
|
||||
if (!users || users.length === 0) {
|
||||
if ((!users || users.length === 0) && guestCount === 0) {
|
||||
el.innerHTML = '';
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
@@ -1361,11 +1409,16 @@
|
||||
return `<img src="${src}" class="gchat-online-avatar" title="${name}" alt="${name}" loading="lazy" ${colorStyle}>`;
|
||||
}).join('');
|
||||
const extraPill = extra > 0 ? `<span class="gchat-online-extra">+${extra}</span>` : '';
|
||||
const countLabel = `<span class="gchat-online-count">${users.length} online</span>`;
|
||||
|
||||
let countText = `${users.length} online`;
|
||||
if (guestCount > 0 && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) {
|
||||
countText += ` (${guestCount} guests)`;
|
||||
}
|
||||
const countLabel = `<span class="gchat-online-count">${countText}</span>`;
|
||||
el.innerHTML = `<div class="gchat-online-inner">${countLabel}<div class="gchat-online-avatars">${avatarHTML}${extraPill}</div></div>`;
|
||||
}
|
||||
document.addEventListener('f0ck:global_chat_presence', (e) => {
|
||||
renderOnline(e.detail?.users || []);
|
||||
renderOnline(e.detail?.users || [], e.detail?.guestCount || 0);
|
||||
});
|
||||
|
||||
// Event delegation: reply + admin delete buttons inside #gchat-messages
|
||||
|
||||
@@ -328,6 +328,13 @@ if (window.__dmLoaded) {
|
||||
currentOtherId = parseInt(thread.dataset.otherId, 10);
|
||||
myId = parseInt(thread.dataset.myId, 10);
|
||||
|
||||
// Reset state for a fresh load (essential if key just changed or multiple inits ran)
|
||||
renderedIds.clear();
|
||||
threadMessages = [];
|
||||
latestMsgId = 0;
|
||||
oldestMsgId = null;
|
||||
threadHasMore = false;
|
||||
|
||||
// Update page title to reflect the conversation
|
||||
const otherName = thread.dataset.otherName || '';
|
||||
document.title = otherName ? `DM with ${otherName}` : 'Messages';
|
||||
@@ -601,7 +608,7 @@ if (window.__dmLoaded) {
|
||||
// 6. Line-by-line rendering to avoid paragraph collapsing and recursion
|
||||
const renderedLines = escaped.split('\n').map(line => {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith('>')) {
|
||||
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||
return `<span class="greentext">>${quoteContent}</span>`;
|
||||
}
|
||||
@@ -660,6 +667,13 @@ if (window.__dmLoaded) {
|
||||
return `<span class="yt-embed-wrap"><iframe src="https://www.youtube.com/embed/${videoId}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe></span>`;
|
||||
});
|
||||
|
||||
// 7.5 Vocaroo embed logic
|
||||
const vocarooEmbedRegex = /(?:<p>)?\s*<a\s+[^>]*href="https?:\/\/(?:www\.)?(?:voca\.ro|vocaroo\.com)\/([a-zA-Z0-9_-]+)[^"]*"[^>]*>([\s\S]*?)<\/a>\s*(?:<\/p>)?/gi;
|
||||
html = html.replace(vocarooEmbedRegex, (match, vocarooId) => {
|
||||
if (['upload', 'contact', 'privacy', 'tos', 'about'].includes(vocarooId.toLowerCase())) return match;
|
||||
return `<span class="vocaroo-embed-wrap"><iframe src="https://vocaroo.com/embed/${vocarooId}?autoplay=0" width="300" height="60" frameborder="0" allow="autoplay"></iframe></span>`;
|
||||
});
|
||||
|
||||
// 8. Same-site video embed logic
|
||||
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const mediaDomainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?${escapedSiteHost}|(?=\\/[a-zA-Z0-9_\\-]))`;
|
||||
@@ -1298,6 +1312,8 @@ if (window.__dmLoaded) {
|
||||
localStorage.removeItem(DM_KEY_VERSION);
|
||||
_privateKey = null; _publicKeyJwk = null;
|
||||
await showRecoveryModal();
|
||||
// REFRESH UI after recovery
|
||||
if (typeof initMessagesPage === 'function') await initMessagesPage();
|
||||
resolve();
|
||||
};
|
||||
|
||||
@@ -1476,6 +1492,10 @@ if (window.__dmLoaded) {
|
||||
modal.querySelector('#dm-recover-phrase-btn').onclick = async () => {
|
||||
modal.style.display = 'none';
|
||||
await showRecoveryModal();
|
||||
|
||||
// REFRESH UI after recovery
|
||||
if (typeof initMessagesPage === 'function') await initMessagesPage();
|
||||
|
||||
// Refresh status label on re-open
|
||||
const statusEl = modal.querySelector('#dm-key-status');
|
||||
if (statusEl) statusEl.textContent = hasKey() ? '✅ Key loaded and backed up.' : '❌ No key found.';
|
||||
@@ -1494,6 +1514,8 @@ if (window.__dmLoaded) {
|
||||
const status = await loadOrCreateKeyPair();
|
||||
uploadPublicKey().catch(() => {});
|
||||
if (status === 'new') await showSeedSetupModal();
|
||||
// REFRESH UI after regen/setup
|
||||
if (typeof initMessagesPage === 'function') await initMessagesPage();
|
||||
} catch (e) {
|
||||
setMsg(msg, '❌ Error: ' + e.message, 'err');
|
||||
}
|
||||
@@ -1595,7 +1617,7 @@ if (window.__dmLoaded) {
|
||||
|
||||
const thread = document.getElementById('dm-thread');
|
||||
if (thread && currentOtherId && parseInt(thread.dataset.otherId) === currentOtherId) {
|
||||
console.log('[DM] Tab active: catching up on conversation...');
|
||||
window.f0ckDebug('[DM] Tab active: catching up on conversation...');
|
||||
appendNewMessages(thread).then(() => {
|
||||
dmFetch('POST', `/api/dm/read/${currentOtherId}`)
|
||||
.then(() => refreshDmBadge()) // refreshDmBadge clears title via updateDmBadge(0)
|
||||
@@ -1644,7 +1666,7 @@ if (window.__dmLoaded) {
|
||||
const thread = document.getElementById('dm-thread');
|
||||
if (!thread || !threadMessages.length) return;
|
||||
|
||||
console.log('[DM] Emojis ready, re-rendering thread...');
|
||||
window.f0ckDebug('[DM] Emojis ready, re-rendering thread...');
|
||||
const isAtBottom = (thread.scrollHeight - thread.scrollTop - thread.clientHeight) < 50;
|
||||
|
||||
// Clear and re-render from cache
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
* Protects against XSS by stripping disallowed tags and attributes.
|
||||
*/
|
||||
class Sanitizer {
|
||||
static ALLOWED_TAGS = ['span', 'img', 'a', 'br', 'b', 'i', 'strong', 'em', 'blockquote', 'pre', 'code', 'div', 'p', 'hr', 'ul', 'ol', 'li', 'textarea', 'button', 'input', 'label', 'select', 'option', 'svg', 'polyline', 'path', 'line', 'rect', 'circle', 'g', 'defs', 'symbol', 'use', 'polygon', 'ellipse', 'lineargradient', 'radialgradient', 'stop', 'clippath', 'mask', 'iframe', 'video', 'audio'];
|
||||
// F-009 Security: Removed form elements (textarea, button, input, label, select, option)
|
||||
// to prevent phishing via user-generated content (comments, DMs, chat).
|
||||
// Style attribute is kept for admin-authored MOTD content.
|
||||
static ALLOWED_TAGS = ['span', 'img', 'a', 'br', 'b', 'i', 'strong', 'em', 'blockquote', 'pre', 'code', 'div', 'p', 'hr', 'ul', 'ol', 'li', 'svg', 'polyline', 'path', 'line', 'rect', 'circle', 'g', 'defs', 'symbol', 'use', 'polygon', 'ellipse', 'lineargradient', 'radialgradient', 'stop', 'clippath', 'mask', 'iframe', 'video', 'audio'];
|
||||
static ALLOWED_ATTRS = ['class', 'style', 'src', 'href', 'alt', 'title', 'target', 'width', 'height', 'placeholder', 'readonly', 'disabled', 'value', 'name', 'id', 'type', 'data-parent', 'data-id', 'data-username', 'xmlns', 'viewbox', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'points', 'x1', 'y1', 'x2', 'y2', 'd', 'transform', 'rx', 'ry', 'x', 'y', 'offset', 'stop-color', 'stop-opacity', 'fill-rule', 'clip-rule', 'cx', 'cy', 'r', 'fill-opacity', 'stroke-opacity', 'preserveaspectratio', 'vector-effect', 'pointer-events', 'allowfullscreen', 'frameborder', 'allow', 'referrerpolicy', 'rel', 'controls', 'loop', 'muted', 'playsinline', 'preload', 'tooltip', 'flow'];
|
||||
static DISALLOWED_URL_SCHEMES = ['javascript:', 'data:', 'vbscript:'];
|
||||
|
||||
@@ -56,9 +59,11 @@ class Sanitizer {
|
||||
if (this.DISALLOWED_URL_SCHEMES.some(scheme => val.startsWith(scheme))) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
// Iframes: only allow YouTube embed URLs
|
||||
// Iframes: allow YouTube and Vocaroo embed URLs
|
||||
if (attrName === 'src' && tagName === 'iframe') {
|
||||
if (!val.startsWith('https://www.youtube.com/embed/')) {
|
||||
const isYouTube = val.startsWith('https://www.youtube.com/embed/');
|
||||
const isVocaroo = val.startsWith('https://vocaroo.com/embed/');
|
||||
if (!isYouTube && !isVocaroo) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
@@ -71,7 +76,14 @@ class Sanitizer {
|
||||
const styleParts = attr.value.split(';').filter(p => p.trim().length > 0);
|
||||
const cleanStyles = styleParts.filter(part => {
|
||||
const prop = part.split(':')[0].trim().toLowerCase();
|
||||
return safeStyles.includes(prop);
|
||||
if (!safeStyles.includes(prop)) return false;
|
||||
// F-009 Security: Strip url() from background/background-image
|
||||
// to prevent CSS-based tracking (e.g. background-image: url(https://evil.com/track))
|
||||
const val = part.split(':').slice(1).join(':').trim().toLowerCase();
|
||||
if ((prop === 'background-image' || prop === 'background') && /url\s*\(/i.test(val)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (cleanStyles.length > 0) {
|
||||
node.setAttribute(attr.name, cleanStyles.join('; '));
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
|
||||
// Close popup if click outside
|
||||
document.addEventListener('click', e => {
|
||||
if (!document.body.classList.contains('scroller-active')) return;
|
||||
if (!volumePopup.contains(e.target) && e.target !== muteBtn && !muteBtn.contains(e.target)) {
|
||||
volumePopup.classList.remove('open');
|
||||
}
|
||||
@@ -369,6 +370,11 @@
|
||||
closePanel(filterPanel, filterBackdrop);
|
||||
closePanel(commentsPanel, commentsBackdrop);
|
||||
closePanel(settingsPanel, settingsBackdrop);
|
||||
if (typeof closeTagBar === 'function') closeTagBar();
|
||||
if (typeof closeSharePanel === 'function') closeSharePanel();
|
||||
if (typeof closeChanPanel === 'function') closeChanPanel();
|
||||
const active = document.activeElement;
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) active.blur();
|
||||
}
|
||||
function addSwipeClose(panel, backdrop) {
|
||||
let startY = 0;
|
||||
@@ -690,10 +696,22 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Spoiler/blur reveal — delegated click on the comments list
|
||||
// Spoiler/blur reveal & context links — delegated click on the comments list
|
||||
commentsList.addEventListener('click', e => {
|
||||
const sp = e.target.closest('.scroller-spoiler, .scroller-blur');
|
||||
if (sp) sp.classList.toggle('revealed');
|
||||
|
||||
const contextLink = e.target.closest('.comment-context-link');
|
||||
if (contextLink) {
|
||||
e.preventDefault();
|
||||
const id = contextLink.dataset.id;
|
||||
const target = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.add('highlight-comment');
|
||||
setTimeout(() => target.classList.remove('highlight-comment'), 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Slide activation ──────────────────────────────────────────────────────
|
||||
@@ -1593,6 +1611,7 @@
|
||||
is_video: isVideo,
|
||||
is_image: isImage,
|
||||
is_audio: false,
|
||||
comment_count: p.replies || 0,
|
||||
rating_label: isWsg ? 'SFW' : (isGif ? 'NSFW' : 'External'),
|
||||
rating_class: isWsg ? 'sfw' : (isGif ? 'nsfw' : 'untagged')
|
||||
};
|
||||
@@ -1618,6 +1637,7 @@
|
||||
item.username = m.username;
|
||||
item.display_name = m.display_name;
|
||||
item.avatar = m.avatar;
|
||||
if (m.comment_count != null) item.comment_count = m.comment_count;
|
||||
if (m.rating_class) { item.rating_class = m.rating_class; item.rating_label = m.rating_label; }
|
||||
|
||||
}
|
||||
@@ -1931,10 +1951,38 @@
|
||||
indicator.style.display = 'flex';
|
||||
}
|
||||
if (commentInput) {
|
||||
const quote = `>>${commentId} `;
|
||||
const start = commentInput.selectionStart;
|
||||
const end = commentInput.selectionEnd;
|
||||
const val = commentInput.value;
|
||||
commentInput.value = val.substring(0, start) + quote + val.substring(end);
|
||||
commentInput.focus();
|
||||
commentInput.selectionStart = commentInput.selectionEnd = start + quote.length;
|
||||
commentInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function quoteComment(id, username) {
|
||||
if (!commentInput) return;
|
||||
const commentEl = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`);
|
||||
if (!commentEl) return;
|
||||
const contentEl = commentEl.querySelector('.comment-content');
|
||||
if (!contentEl) return;
|
||||
|
||||
const raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
|
||||
const lines = raw.split('\n');
|
||||
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||
|
||||
const start = commentInput.selectionStart;
|
||||
const end = commentInput.selectionEnd;
|
||||
const val = commentInput.value;
|
||||
|
||||
commentInput.value = val.substring(0, start) + quote + val.substring(end);
|
||||
commentInput.focus();
|
||||
commentInput.selectionStart = commentInput.selectionEnd = start + quote.length;
|
||||
commentInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function clearReply() {
|
||||
replyToCommentId = null;
|
||||
replyToUsername = null;
|
||||
@@ -1956,19 +2004,28 @@
|
||||
<img class="comment-avatar" src="${esc(av)}" alt="" loading="lazy" onerror="this.src='/a/default.png'">
|
||||
<div class="comment-body">
|
||||
<div class="comment-username" style="${nc}">${esc(uname)}</div>
|
||||
<div class="comment-content">${renderCommentContent(c.content || '')}</div>
|
||||
<div class="comment-content" data-raw="${esc(c.content || '')}">${renderCommentContent(c.content || '')}</div>
|
||||
<div class="comment-meta">
|
||||
<span class="comment-time">${c.created_at ? timeAgo(c.created_at) : (_i.ta_just_now || _i.just_now || 'just now')}</span>
|
||||
${canReply ? `<button class="comment-reply-btn" data-id="${c.id}" data-user="${esc(c.username || uname)}">${_i.reply || 'Reply'}</button>` : ''}
|
||||
${canReply ? `
|
||||
<button class="comment-reply-btn" data-id="${c.id}" data-user="${esc(c.username || uname)}">${_i.reply || 'Reply'}</button>
|
||||
<button class="comment-quote-btn" data-id="${c.id}" data-user="${esc(c.username || uname)}">${_i.quote || 'Quote'}</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
// Wire reply button
|
||||
// Wire buttons
|
||||
const replyBtn = el.querySelector('.comment-reply-btn');
|
||||
if (replyBtn) {
|
||||
replyBtn.addEventListener('click', () => {
|
||||
setReplyTo(replyBtn.dataset.id, replyBtn.dataset.user);
|
||||
});
|
||||
}
|
||||
const quoteBtn = el.querySelector('.comment-quote-btn');
|
||||
if (quoteBtn) {
|
||||
quoteBtn.addEventListener('click', () => {
|
||||
quoteComment(quoteBtn.dataset.id, quoteBtn.dataset.user);
|
||||
});
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -2009,6 +2066,7 @@
|
||||
const tagBarClose = document.getElementById('tag-bar-close-btn');
|
||||
|
||||
function openTagBar(itemId) {
|
||||
closeAllPanels();
|
||||
tagBarItemId = itemId;
|
||||
if (!tagBar) return;
|
||||
tagBar.classList.add('open');
|
||||
@@ -2022,11 +2080,9 @@
|
||||
closeSugg();
|
||||
tagBarItemId = null;
|
||||
const inp = document.getElementById('scroll-tag-input');
|
||||
if (inp) inp.value = '';
|
||||
if (inp) { inp.value = ''; inp.blur(); }
|
||||
}
|
||||
if (tagBarClose) tagBarClose.addEventListener('click', closeTagBar);
|
||||
// Close on Escape key
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape' && tagBar?.classList.contains('open')) closeTagBar(); });
|
||||
|
||||
// ── Share panel ────────────────────────────────────────────────────────────
|
||||
const sharePanel = document.getElementById('share-panel');
|
||||
@@ -2263,11 +2319,13 @@
|
||||
);
|
||||
|
||||
// Escape HTML first, then process line by line
|
||||
const escaped = esc(text);
|
||||
const normalized = text.replace(/<br\s*\/?>/gi, '\n');
|
||||
const escaped = esc(normalized);
|
||||
const lines = escaped.split('\n').map(line => {
|
||||
const trimmed = line.trimStart();
|
||||
// Greentext / quote: lines starting with >
|
||||
if (trimmed.startsWith('>')) {
|
||||
// Exclude only the numeric context links (>>ID) so they can be handled as interactive links.
|
||||
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
||||
const after = line.substring(line.indexOf('>') + 4);
|
||||
const withEmoji = after.replace(/:([a-z0-9_]+):/g, (m, name) => {
|
||||
const url = customEmojis[name];
|
||||
@@ -2289,9 +2347,19 @@
|
||||
const url = customEmojis[name];
|
||||
return url ? `<img src="${esc(url)}" alt=":${esc(name)}:" title=":${esc(name)}:" style="height:1.8em;vertical-align:middle;display:inline-block;margin:0 2px">` : m;
|
||||
});
|
||||
|
||||
// 3. Replace >>ID patterns with context links
|
||||
out = out.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
|
||||
return `<a href="#c${id}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
|
||||
});
|
||||
|
||||
return out;
|
||||
});
|
||||
let html = lines.join('<br>');
|
||||
let html = lines.map((line, i) => {
|
||||
if (i === lines.length - 1) return line;
|
||||
if (line.includes('scroller-greentext') || line.includes('display:block')) return line;
|
||||
return line + '<br>';
|
||||
}).join('');
|
||||
// [spoiler]…[/spoiler] — iterative to handle nesting
|
||||
const spoilerRe = /\[spoiler\]((?:(?!\[spoiler\])[\s\S])*?)\[\/spoiler\]/gi;
|
||||
let prev, n = 0;
|
||||
@@ -2783,12 +2851,12 @@
|
||||
chanHashPending = fetch(`/api/v2/scroller/external/4chan/${board}/find/${postno}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
console.log('[CHAN] Find result:', data);
|
||||
window.f0ckDebug('[CHAN] Find result:', data);
|
||||
if (data.success && data.tid) {
|
||||
applied.externalUrl = `https://boards.4chan.org/${board}/thread/${data.tid}`;
|
||||
applied.order = 'oldest';
|
||||
unlock4chan();
|
||||
console.log('[CHAN] Set externalUrl:', applied.externalUrl);
|
||||
window.f0ckDebug('[CHAN] Set externalUrl:', applied.externalUrl);
|
||||
}
|
||||
})
|
||||
.catch(err => { console.error('[CHAN] Find error:', err); });
|
||||
@@ -2887,11 +2955,13 @@
|
||||
|
||||
// ── Keyboard ──────────────────────────────────────────────────────────────
|
||||
document.addEventListener('keydown', e => {
|
||||
if (!document.body.classList.contains('scroller-active')) return;
|
||||
const active = document.activeElement;
|
||||
const isTyping = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable);
|
||||
|
||||
// 'c' toggles comments — only when NOT typing (so it never fires inside the comment input)
|
||||
if ((e.key === 'c' || e.key === 'C') && !isTyping && !tagBar?.classList.contains('open')) {
|
||||
e.preventDefault();
|
||||
if (commentsPanel.classList.contains('open')) closeAllPanels();
|
||||
else if (currentSlide) openComments(currentSlide.dataset.localId || currentSlide.dataset.id);
|
||||
return;
|
||||
@@ -2915,10 +2985,10 @@
|
||||
const idx = currentSlide ? slides.indexOf(currentSlide) : 0;
|
||||
if (e.key === 'ArrowDown' || e.key === 'j') { e.preventDefault(); const n = slides[idx + 1]; if (n) n.scrollIntoView({ behavior: 'smooth' }); }
|
||||
else if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault(); const p = slides[idx - 1]; if (p) p.scrollIntoView({ behavior: 'smooth' }); }
|
||||
else if (e.key === 'm') { isMuted = !isMuted; if (!isMuted && volume === 0) volume = 0.5; syncVolumeUI(); applyVolumeToAll(); saveVolume(); prefs.startUnmuted = !isMuted; savePrefs(prefs); applyStartUnmuted(!isMuted); showVolumePopup(); }
|
||||
else if (e.key === 'm') { e.preventDefault(); isMuted = !isMuted; if (!isMuted && volume === 0) volume = 0.5; syncVolumeUI(); applyVolumeToAll(); saveVolume(); prefs.startUnmuted = !isMuted; savePrefs(prefs); applyStartUnmuted(!isMuted); showVolumePopup(); }
|
||||
else if (e.key === ' ') { e.preventDefault(); if (currentMedia) currentMedia.paused ? currentMedia.play().catch(() => {}) : currentMedia.pause(); }
|
||||
else if (e.key === 'f' || e.key === 'F') { pending = { ...applied, tags: [...applied.tags] }; syncPanelUI(); renderPresets(); openPanel(filterPanel, filterBackdrop); }
|
||||
else if (e.key === 'g' || e.key === 'G') { if (applied.externalUrl && chanGalleryBtn) toggleGallery(); }
|
||||
else if (e.key === 'f' || e.key === 'F') { e.preventDefault(); pending = { ...applied, tags: [...applied.tags] }; syncPanelUI(); renderPresets(); openPanel(filterPanel, filterBackdrop); }
|
||||
else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); if (applied.externalUrl && chanGalleryBtn) toggleGallery(); }
|
||||
else if (e.key === 'r' || e.key === 'R') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation(); // prevent f0ckm.js global 'r' random shortcut from firing
|
||||
@@ -2982,7 +3052,7 @@
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
else if (e.key === 'l' || e.key === 'L') { if (currentSlide) toggleFav(currentSlide); }
|
||||
else if (e.key === 'l' || e.key === 'L') { e.preventDefault(); if (currentSlide) toggleFav(currentSlide); }
|
||||
else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); if (currentSlide) openTagBar(currentSlide.dataset.id); }
|
||||
else if (e.key === 'e' || e.key === 'E') { e.preventDefault(); e.stopImmediatePropagation(); } // suppress upload modal shortcut in abyss
|
||||
else if (e.key === 'Escape') { window.location.href = '/'; }
|
||||
@@ -3000,6 +3070,7 @@
|
||||
let fourCount = 0;
|
||||
let fourTimer = null;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!document.body.classList.contains('scroller-active')) return;
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
if (e.key === '4') {
|
||||
fourCount++;
|
||||
@@ -3407,6 +3478,7 @@
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!document.body.classList.contains('scroller-active')) return;
|
||||
if (sNotifOpen && !sNotifDropdown.contains(e.target) && !sNotifBtn.contains(e.target)) {
|
||||
sNotifOpen = false;
|
||||
sNotifDropdown.classList.remove('visible');
|
||||
|
||||
@@ -612,6 +612,97 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Comment Display Mode Toggle
|
||||
const commentDisplayModeSelect = document.getElementById('comment_display_mode_select');
|
||||
if (commentDisplayModeSelect) {
|
||||
commentDisplayModeSelect.addEventListener('change', async () => {
|
||||
const mode = parseInt(commentDisplayModeSelect.value, 10);
|
||||
try {
|
||||
const res = await fetch('/api/v2/settings/comment_display_mode', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: JSON.stringify({ mode })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showStatus('Comment display mode updated!', 'success');
|
||||
if (window.f0ckSession) window.f0ckSession.comment_display_mode = mode;
|
||||
} else {
|
||||
alert(data.msg || 'Error saving preference');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to save preference');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Alternative Infobox Toggle (legacy layout only)
|
||||
const alternativeInfoboxToggle = document.getElementById('alternative_infobox_toggle');
|
||||
if (alternativeInfoboxToggle) {
|
||||
alternativeInfoboxToggle.addEventListener('change', async () => {
|
||||
const use_alternative_infobox = alternativeInfoboxToggle.checked;
|
||||
try {
|
||||
const res = await fetch('/api/v2/settings/alternative_infobox', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: new URLSearchParams({ use_alternative_infobox })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showStatus('Infobox preference updated!', 'success');
|
||||
if (window.f0ckSession) window.f0ckSession.use_alternative_infobox = use_alternative_infobox;
|
||||
} else {
|
||||
alert(data.msg || 'Error saving preference');
|
||||
alternativeInfoboxToggle.checked = !use_alternative_infobox;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to save infobox preference');
|
||||
alternativeInfoboxToggle.checked = !use_alternative_infobox;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Notification Preferences Toggles
|
||||
const setupPreferenceToggle = (id, sessionKey) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('change', async () => {
|
||||
const enabled = el.checked;
|
||||
try {
|
||||
const res = await fetch('/api/v2/settings/notifications', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: new URLSearchParams({ key: sessionKey, value: enabled })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
if (window.f0ckSession) window.f0ckSession[sessionKey] = enabled;
|
||||
} else {
|
||||
alert(data.msg || 'Error saving preference');
|
||||
el.checked = !enabled;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
el.checked = !enabled;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setupPreferenceToggle('chk-receive-system-notifications', 'receive_system_notifications');
|
||||
setupPreferenceToggle('chk-receive-user-notifications', 'receive_user_notifications');
|
||||
setupPreferenceToggle('chk-do-not-disturb', 'do_not_disturb');
|
||||
|
||||
const wheelToggle = document.getElementById('wheel_nav_toggle');
|
||||
if (wheelToggle) {
|
||||
wheelToggle.checked = localStorage.getItem('wheelNavEnabled') === 'true';
|
||||
@@ -1255,26 +1346,16 @@
|
||||
}
|
||||
|
||||
// ==== Ruffle (Flash) Settings ====
|
||||
const ruffleVolumeInput = document.getElementById('ruffle_volume_input');
|
||||
const ruffleVolumeVal = document.getElementById('ruffle_volume_val');
|
||||
const ruffleBackToggle = document.getElementById('ruffle_background_toggle');
|
||||
const ruffleSaveBtn = document.getElementById('btn-save-ruffle-settings');
|
||||
const ruffleStatus = document.getElementById('ruffle-settings-status');
|
||||
|
||||
if (ruffleVolumeInput && ruffleVolumeVal) {
|
||||
ruffleVolumeInput.addEventListener('input', () => {
|
||||
ruffleVolumeVal.textContent = Math.round(ruffleVolumeInput.value * 100) + '%';
|
||||
});
|
||||
}
|
||||
|
||||
if (ruffleSaveBtn) {
|
||||
ruffleSaveBtn.addEventListener('click', async () => {
|
||||
const ruffle_volume = parseFloat(ruffleVolumeInput.value);
|
||||
|
||||
if (ruffleBackToggle) {
|
||||
ruffleBackToggle.addEventListener('change', async () => {
|
||||
const ruffle_background = ruffleBackToggle.checked;
|
||||
|
||||
ruffleSaveBtn.disabled = true;
|
||||
ruffleSaveBtn.textContent = i18n.saving || 'Saving...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v2/settings/ruffle', {
|
||||
method: 'PUT',
|
||||
@@ -1282,13 +1363,12 @@
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: JSON.stringify({ ruffle_volume, ruffle_background })
|
||||
body: JSON.stringify({ ruffle_background })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showAccountStatus(ruffleStatus, 'Flash settings updated correctly!', 'success');
|
||||
showAccountStatus(ruffleStatus, 'Flash settings updated!', 'success');
|
||||
if (window.f0ckSession) {
|
||||
window.f0ckSession.ruffle_volume = ruffle_volume;
|
||||
window.f0ckSession.ruffle_background = ruffle_background;
|
||||
|
||||
// Apply to the active Ruffle player if it exists so user doesn't need to refresh
|
||||
@@ -1296,7 +1376,6 @@
|
||||
if (ruffleContainer) {
|
||||
const player = ruffleContainer.querySelector('ruffle-player') || ruffleContainer.querySelector('ruffle-object');
|
||||
if (player) {
|
||||
player.volume = ruffle_volume;
|
||||
// Ruffle doesn't dynamically toggle pageVisibility well without recreation,
|
||||
// but we can update the config for subsequent initializations
|
||||
if (window.RufflePlayer && window.RufflePlayer.config) {
|
||||
@@ -1308,13 +1387,12 @@
|
||||
}
|
||||
} else {
|
||||
showAccountStatus(ruffleStatus, data.msg || 'Failed to update Flash settings', 'error');
|
||||
ruffleBackToggle.checked = !ruffle_background; // Revert on failure
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showAccountStatus(ruffleStatus, 'Request failed', 'error');
|
||||
} finally {
|
||||
ruffleSaveBtn.disabled = false;
|
||||
ruffleSaveBtn.textContent = 'Save Flash Settings';
|
||||
ruffleBackToggle.checked = !ruffle_background; // Revert on error
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,7 +37,56 @@
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
const renderCommentContent = (content) => {
|
||||
const ytOembedCache = new Map(); // videoId -> meta object
|
||||
const ytOembedPending = new Map(); // videoId -> Promise
|
||||
|
||||
const fetchSidebarYoutubeTitles = async (container) => {
|
||||
const links = container.querySelectorAll('.sidebar-video-link[data-yt-id]');
|
||||
if (links.length === 0) return;
|
||||
|
||||
for (const link of links) {
|
||||
const videoId = link.dataset.ytId;
|
||||
if (!videoId) continue;
|
||||
|
||||
const titleSpan = link.querySelector('.yt-title');
|
||||
if (!titleSpan || titleSpan.dataset.loaded === 'true') continue;
|
||||
|
||||
let meta = ytOembedCache.get(videoId);
|
||||
if (!meta) {
|
||||
if (ytOembedPending.has(videoId)) {
|
||||
meta = await ytOembedPending.get(videoId);
|
||||
} else {
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const ytUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`;
|
||||
const r = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(ytUrl)}`);
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
if (data.success && data.meta) {
|
||||
ytOembedCache.set(videoId, data.meta);
|
||||
return data.meta;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return null;
|
||||
})();
|
||||
ytOembedPending.set(videoId, promise);
|
||||
meta = await promise;
|
||||
ytOembedPending.delete(videoId);
|
||||
}
|
||||
}
|
||||
|
||||
if (meta && meta.title) {
|
||||
titleSpan.textContent = meta.title;
|
||||
} else {
|
||||
// If title fails, just leave it blank or use a generic label
|
||||
titleSpan.textContent = 'YouTube Video';
|
||||
}
|
||||
titleSpan.dataset.loaded = 'true';
|
||||
}
|
||||
};
|
||||
|
||||
const renderCommentContent = (content, commentId = null, itemId = null) => {
|
||||
if (!content) return '';
|
||||
|
||||
// Anti-recursion / Performance safeguard for extremely long comments
|
||||
@@ -149,7 +198,7 @@
|
||||
// Line-by-line rendering to avoid paragraph collapsing and recursion
|
||||
const renderedLines = escaped.split('\n').map(line => {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith('>')) {
|
||||
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
||||
// Manual greentext handling — apply emoji if the user preference allows it
|
||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||
@@ -166,10 +215,19 @@
|
||||
|
||||
// Perform replacements on the single line
|
||||
let processedLine = line;
|
||||
|
||||
// Handle Mentions
|
||||
processedLine = processedLine.replace(mentionRegex, (match, g1, g2) => {
|
||||
const user = g1 || g2;
|
||||
return `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
|
||||
});
|
||||
|
||||
// Handle Comment Context Links (>>ID)
|
||||
processedLine = processedLine.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
|
||||
const targetHref = itemId ? `/${itemId}#c${id}` : `#c${id}`;
|
||||
return `<a href="${targetHref}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
|
||||
});
|
||||
|
||||
processedLine = processedLine.replace(imageRegex, (match, url) => {
|
||||
let fullUrl = url;
|
||||
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) {
|
||||
@@ -196,10 +254,25 @@
|
||||
// YouTube label replacement: show icon + labeled link
|
||||
md = md.replace(
|
||||
/<a\s[^>]*href="https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?(?:[^"]*&(?:amp;)?)?v=|youtu\.be\/)([a-zA-Z0-9_\-]{11})[^"]*"[^>]*>([\s\S]*?)<\/a>/gi,
|
||||
(match) => {
|
||||
(match, videoId) => {
|
||||
const hrefMatch = match.match(/href="([^"]+)"/i);
|
||||
const href = hrefMatch ? hrefMatch[1] : '#';
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="sidebar-video-link"><i class="fa-brands fa-youtube"></i></a>`;
|
||||
const ytHref = hrefMatch ? hrefMatch[1] : '#';
|
||||
const targetHref = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : ytHref);
|
||||
const externalAttr = (itemId && commentId) || commentId ? '' : ' target="_blank" rel="noopener noreferrer"';
|
||||
return `<a href="${targetHref}"${externalAttr} class="sidebar-video-link" data-yt-id="${videoId}"><i class="fa-brands fa-youtube"></i> <span class="yt-title"></span></a>`;
|
||||
}
|
||||
);
|
||||
|
||||
// Vocaroo label replacement
|
||||
md = md.replace(
|
||||
/<a\s[^>]*href="https?:\/\/(?:www\.)?(?:voca\.ro|vocaroo\.com)\/([a-zA-Z0-9_-]+)[^"]*"[^>]*>([\s\S]*?)<\/a>/gi,
|
||||
(match, vocarooId) => {
|
||||
if (['upload', 'contact', 'privacy', 'tos', 'about'].includes(vocarooId.toLowerCase())) return match;
|
||||
const hrefMatch = match.match(/href="([^"]+)"/i);
|
||||
const vocaHref = hrefMatch ? hrefMatch[1] : '#';
|
||||
const targetHref = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : vocaHref);
|
||||
const externalAttr = (itemId && commentId) || commentId ? '' : ' target="_blank" rel="noopener noreferrer"';
|
||||
return `<a href="${targetHref}"${externalAttr} class="sidebar-video-link"><i class="fa-solid fa-microphone"></i> <span>Vocaroo Audio</span></a>`;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -258,6 +331,10 @@
|
||||
return codeBlocks[index] || '';
|
||||
});
|
||||
|
||||
if (window.Sanitizer && typeof window.Sanitizer.clean === 'function') {
|
||||
md = window.Sanitizer.clean(md);
|
||||
}
|
||||
|
||||
return md;
|
||||
} catch (e) {
|
||||
return content;
|
||||
@@ -269,7 +346,7 @@
|
||||
|
||||
const renderActivityItem = (c) => {
|
||||
const rawContent = c.content || c.body || '';
|
||||
const displayContent = renderCommentContent(rawContent);
|
||||
const displayContent = renderCommentContent(rawContent, c.id, c.item_id);
|
||||
|
||||
// Build avatar URL — same priority as the rest of the app
|
||||
let avatarSrc = '/a/default.png';
|
||||
@@ -277,6 +354,7 @@
|
||||
avatarSrc = `/a/${c.avatar_file}`;
|
||||
} else if (c.avatar) {
|
||||
avatarSrc = `/t/${c.avatar}.webp`;
|
||||
if (window.applyThumbCacheBust) avatarSrc = window.applyThumbCacheBust(avatarSrc);
|
||||
}
|
||||
|
||||
const timeStr = c.created_at
|
||||
@@ -287,7 +365,9 @@
|
||||
let itemPreview = '';
|
||||
if (c.item_id) {
|
||||
let mediaHtml = '';
|
||||
mediaHtml = `<img src="/t/${c.item_id}.webp" style="width: 32px; height: 32px; object-fit: cover; border-radius: 2px;" onerror="this.style.display='none'" />`;
|
||||
let thumbUrl = `/t/${c.item_id}.webp`;
|
||||
if (window.applyThumbCacheBust) thumbUrl = window.applyThumbCacheBust(thumbUrl);
|
||||
mediaHtml = `<img src="${thumbUrl}" style="width: 32px; height: 32px; object-fit: cover; border-radius: 2px;" onerror="this.style.display='none'" />`;
|
||||
|
||||
itemPreview = `
|
||||
<div class="item-preview">
|
||||
@@ -308,7 +388,7 @@
|
||||
</div>
|
||||
<span class="comment-time timeago" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
|
||||
</div>
|
||||
<div class="comment-content" style="font-size: 0.85em; line-height: 1.3;"><div class="comment-content-inner">${displayContent}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
|
||||
<div class="comment-content"><div class="comment-content-inner">${displayContent}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
|
||||
${itemPreview}
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -361,16 +441,13 @@
|
||||
html += renderActivityItem(c);
|
||||
});
|
||||
|
||||
if (window.Sanitizer) {
|
||||
container.innerHTML = window.Sanitizer.clean(html);
|
||||
} else {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
// Re-append IO sentinel so the scroll observer keeps working after re-renders
|
||||
if (ioSentinel) {
|
||||
container.appendChild(ioSentinel);
|
||||
}
|
||||
checkOverflow();
|
||||
fetchSidebarYoutubeTitles(container);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -462,17 +539,14 @@
|
||||
newComments.forEach(c => { html += renderActivityItem(c); });
|
||||
if (html) {
|
||||
const temp = document.createElement('div');
|
||||
if (window.Sanitizer) {
|
||||
temp.innerHTML = window.Sanitizer.clean(html);
|
||||
} else {
|
||||
temp.innerHTML = html;
|
||||
}
|
||||
while (temp.firstElementChild) {
|
||||
container.appendChild(temp.firstElementChild);
|
||||
}
|
||||
// Keep the IO sentinel at the very end so it triggers on the next scroll
|
||||
if (ioSentinel) container.appendChild(ioSentinel);
|
||||
checkOverflow();
|
||||
fetchSidebarYoutubeTitles(container);
|
||||
}
|
||||
} else {
|
||||
hasMore = false;
|
||||
@@ -496,7 +570,7 @@
|
||||
|
||||
// 1. Deduplicate: check if this comment ID is already in the cache
|
||||
if (window._sidebarActivityCache.some(c => parseInt(c.id) === parseInt(data.id))) {
|
||||
console.log("Sidebar Activity: Duplicate comment ignored", data.id);
|
||||
window.f0ckDebug("Sidebar Activity: Duplicate comment ignored", data.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -512,16 +586,13 @@
|
||||
if (container) {
|
||||
const html = renderActivityItem(newItem);
|
||||
const temp = document.createElement('div');
|
||||
if (window.Sanitizer) {
|
||||
temp.innerHTML = window.Sanitizer.clean(html);
|
||||
} else {
|
||||
temp.innerHTML = html;
|
||||
}
|
||||
const node = temp.firstElementChild;
|
||||
if (node) {
|
||||
node.classList.add('new-item-fade');
|
||||
container.prepend(node);
|
||||
checkOverflow();
|
||||
fetchSidebarYoutubeTitles(container);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -533,7 +604,7 @@
|
||||
|
||||
// Listen for live activity from f0ckm.js
|
||||
document.addEventListener('f0ck:activityReceived', (e) => {
|
||||
console.log("Sidebar Activity: Live update received", e.detail);
|
||||
window.f0ckDebug("Sidebar Activity: Live update received", e.detail);
|
||||
handleNewActivity(e.detail);
|
||||
});
|
||||
|
||||
@@ -555,18 +626,20 @@
|
||||
if (el) {
|
||||
const inner = el.querySelector('.comment-content-inner');
|
||||
if (inner) {
|
||||
inner.innerHTML = renderCommentContent(data.content);
|
||||
const comment = window._sidebarActivityCache.find(c => String(c.id) === String(data.comment_id));
|
||||
inner.innerHTML = renderCommentContent(data.content, data.comment_id, comment ? comment.item_id : null);
|
||||
el.classList.remove('new-item-fade');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('new-item-fade');
|
||||
checkOverflow();
|
||||
fetchSidebarYoutubeTitles(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('f0ck:comment_edited', (e) => {
|
||||
console.log("Sidebar Activity: Live edit received", e.detail);
|
||||
window.f0ckDebug("Sidebar Activity: Live edit received", e.detail);
|
||||
handleLiveEdit(e.detail);
|
||||
});
|
||||
|
||||
@@ -578,7 +651,7 @@
|
||||
const modeChanged = lastBoundMode !== null && lastBoundMode !== currentMode;
|
||||
lastBoundMode = currentMode;
|
||||
|
||||
console.log("Sidebar Activity: Page transition detected", modeChanged ? "(Mode changed)" : "");
|
||||
window.f0ckDebug("Sidebar Activity: Page transition detected", modeChanged ? "(Mode changed)" : "");
|
||||
|
||||
if (modeChanged) {
|
||||
window._sidebarActivityCache = [];
|
||||
@@ -600,7 +673,7 @@
|
||||
|
||||
// Handle explicit mode changes (e.g. from item page where full transition doesn't occur)
|
||||
document.addEventListener('f0ck:modeChanged', (e) => {
|
||||
console.log("Sidebar Activity: Mode change detected", e.detail.mode);
|
||||
window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode);
|
||||
lastBoundMode = e.detail.mode;
|
||||
window._sidebarActivityCache = [];
|
||||
currentPage = 1;
|
||||
@@ -610,7 +683,7 @@
|
||||
|
||||
// When the current user posts a comment, silently refresh sidebar to show it
|
||||
document.addEventListener('f0ck:commentPosted', () => {
|
||||
console.log("Sidebar Activity: Own comment posted, refreshing...");
|
||||
window.f0ckDebug("Sidebar Activity: Own comment posted, refreshing...");
|
||||
loadActivity(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -122,6 +122,17 @@ window.F0ckUpload = class {
|
||||
try {
|
||||
const res = JSON.parse(xhr.responseText);
|
||||
if (res.success) {
|
||||
if (res.itemid) {
|
||||
try {
|
||||
const ts = Date.now();
|
||||
const bustedStr = localStorage.getItem('bustedThumbs');
|
||||
const busted = bustedStr ? JSON.parse(bustedStr) : {};
|
||||
busted[res.itemid] = ts;
|
||||
const keys = Object.keys(busted);
|
||||
if (keys.length > 50) delete busted[keys[0]];
|
||||
localStorage.setItem('bustedThumbs', JSON.stringify(busted));
|
||||
} catch(e) {}
|
||||
}
|
||||
this.onComplete(res);
|
||||
resolve(res);
|
||||
} else {
|
||||
|
||||
@@ -40,6 +40,7 @@ window.initUploadForm = (selector) => {
|
||||
const dragModal = form.closest('#upload-drag-modal');
|
||||
if (dragModal) {
|
||||
dragModal.classList.remove('show');
|
||||
document.body.classList.remove('modal-open');
|
||||
if (form._f0ckUploader && typeof form._f0ckUploader.reset === 'function') {
|
||||
form._f0ckUploader.reset();
|
||||
}
|
||||
@@ -59,8 +60,7 @@ window.initUploadForm = (selector) => {
|
||||
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
|
||||
|
||||
// Dynamically get min tags requirement from DOM
|
||||
const minTagsRaw = tagCount?.textContent.match(/\/(\d+)/);
|
||||
const minTags = minTagsRaw ? parseInt(minTagsRaw[1]) : 3;
|
||||
const minTags = parseInt(form.getAttribute('data-min-tags') || '3');
|
||||
|
||||
let tags = [];
|
||||
let autoTags = []; // Track tags suggested from metadata
|
||||
@@ -465,8 +465,12 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
|
||||
if (tagCount) {
|
||||
if (minTags > 0) {
|
||||
tagCount.textContent = '(' + tags.length + '/' + minTags + ' minimum)';
|
||||
tagCount.classList.toggle('valid', tags.length >= minTags);
|
||||
} else {
|
||||
tagCount.style.display = 'none';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -567,6 +571,10 @@ window.initUploadForm = (selector) => {
|
||||
previewElem = document.createElement('div');
|
||||
previewElem.className = 'generic-file-icon swf-preview-icon';
|
||||
previewElem.innerHTML = '<span style="font-size:2.5em;">⚡</span><br><span style="font-size:0.85em;letter-spacing:0.1em;color:#e040fb;font-weight:bold;">SWF</span>';
|
||||
} else if (file.type === 'application/pdf' || fileExt === 'pdf') {
|
||||
previewElem = document.createElement('div');
|
||||
previewElem.className = 'generic-file-icon pdf-preview-icon';
|
||||
previewElem.innerHTML = '<span style="font-size:2.5em;"><i class="fa-solid fa-file-pdf"></i></span><br><span style="font-size:0.85em;letter-spacing:0.1em;color:#ef5350;font-weight:bold;"></span>';
|
||||
} else {
|
||||
previewElem = document.createElement('div');
|
||||
previewElem.className = 'generic-file-icon';
|
||||
@@ -951,7 +959,7 @@ window.initUploadForm = (selector) => {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
console.log('[UPLOAD] Rendering ' + filtered.length + ' suggestions');
|
||||
window.f0ckDebug('[UPLOAD] Rendering ' + filtered.length + ' suggestions');
|
||||
if (tagSuggestions) {
|
||||
tagSuggestions.innerHTML = html;
|
||||
tagSuggestions.style.display = 'block';
|
||||
@@ -1087,11 +1095,26 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
|
||||
if (dragModal) dragModal.classList.remove('show');
|
||||
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
|
||||
if (window.hideAllModals) window.hideAllModals();
|
||||
form._f0ckUploader.reset();
|
||||
if (!dragModal) {
|
||||
statusDiv.innerHTML = '✓ ' + data.msg;
|
||||
statusDiv.className = 'upload-status success';
|
||||
}
|
||||
|
||||
if (data.itemid) {
|
||||
try {
|
||||
const ts = Date.now();
|
||||
const bustedStr = localStorage.getItem('bustedThumbs');
|
||||
const busted = bustedStr ? JSON.parse(bustedStr) : {};
|
||||
busted[data.itemid] = ts;
|
||||
const keys = Object.keys(busted);
|
||||
if (keys.length > 50) delete busted[keys[0]];
|
||||
localStorage.setItem('bustedThumbs', JSON.stringify(busted));
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
if (data.manual_approval && typeof window.showFlash === 'function') {
|
||||
window.showFlash('Upload awaits approval, please be patient', 'info');
|
||||
}
|
||||
@@ -1190,6 +1213,19 @@ window.initUploadForm = (selector) => {
|
||||
statusDiv.innerHTML = '✓ ' + res.msg;
|
||||
statusDiv.className = 'upload-status success';
|
||||
}
|
||||
|
||||
if (res.itemid) {
|
||||
try {
|
||||
const ts = Date.now();
|
||||
const bustedStr = localStorage.getItem('bustedThumbs');
|
||||
const busted = bustedStr ? JSON.parse(bustedStr) : {};
|
||||
busted[res.itemid] = ts;
|
||||
const keys = Object.keys(busted);
|
||||
if (keys.length > 50) delete busted[keys[0]];
|
||||
localStorage.setItem('bustedThumbs', JSON.stringify(busted));
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
if (res.manual_approval && typeof window.showFlash === 'function') {
|
||||
window.showFlash('Upload awaits approval', 'info');
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class UserCommentSystem {
|
||||
if (el && this.container.contains(el)) {
|
||||
const contentEl = el.querySelector('.comment-content');
|
||||
if (contentEl) {
|
||||
contentEl.innerHTML = this.renderCommentContent(data.content);
|
||||
contentEl.innerHTML = this.renderCommentContent(data.content, data.item_id);
|
||||
el.classList.remove('new-item-fade');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('new-item-fade');
|
||||
@@ -77,7 +77,7 @@ class UserCommentSystem {
|
||||
// Check if this instance is still active
|
||||
if (!document.body.contains(this.container)) return;
|
||||
|
||||
console.log('Mode changed, reloading comments...');
|
||||
window.f0ckDebug('Mode changed, reloading comments...');
|
||||
this.container.innerHTML = '';
|
||||
this.page = 1;
|
||||
this.finished = false;
|
||||
@@ -108,7 +108,7 @@ class UserCommentSystem {
|
||||
this.userColor = json.user.username_color;
|
||||
}
|
||||
json.comments.forEach(c => {
|
||||
console.log('Raw Comment Content (ID ' + c.id + '):', c.content);
|
||||
window.f0ckDebug('Raw Comment Content (ID ' + c.id + '):', c.content);
|
||||
const html = this.renderComment(c);
|
||||
this.container.insertAdjacentHTML('beforeend', html);
|
||||
});
|
||||
@@ -134,7 +134,7 @@ class UserCommentSystem {
|
||||
return match;
|
||||
}
|
||||
|
||||
renderCommentContent(content) {
|
||||
renderCommentContent(content, itemId = null) {
|
||||
if (!content) return '';
|
||||
|
||||
// Anti-recursion / Performance safeguard for extremely long comments
|
||||
@@ -194,7 +194,7 @@ class UserCommentSystem {
|
||||
// 3. Line-by-line rendering to avoid paragraph collapsing and recursion
|
||||
const renderedLines = escaped.split('\n').map(line => {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith('>')) {
|
||||
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||
return `<span class="greentext">>${quoteContent}</span>`;
|
||||
}
|
||||
@@ -204,6 +204,13 @@ class UserCommentSystem {
|
||||
if (!line.trim()) return ' ';
|
||||
|
||||
let processedLine = line.replace(mentionRegex, '[@$1](/user/$1)');
|
||||
|
||||
// Handle Comment Context Links (>>ID)
|
||||
processedLine = processedLine.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
|
||||
const targetHref = itemId ? `/${itemId}#c${id}` : `#c${id}`;
|
||||
return `<a href="${targetHref}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
|
||||
});
|
||||
|
||||
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
|
||||
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
||||
|
||||
@@ -241,6 +248,10 @@ class UserCommentSystem {
|
||||
iterations++;
|
||||
} while (html !== prevMd && iterations < 10);
|
||||
|
||||
if (window.Sanitizer && typeof Sanitizer.clean === 'function') {
|
||||
html = Sanitizer.clean(html);
|
||||
}
|
||||
|
||||
return html;
|
||||
} catch (e) {
|
||||
console.error('UserCommentSystem Markdown Render Error:', e);
|
||||
@@ -251,7 +262,7 @@ class UserCommentSystem {
|
||||
renderComment(c) {
|
||||
const timeAgo = this.timeAgo(c.created_at);
|
||||
const fullDate = new Date(c.created_at).toISOString();
|
||||
const content = this.renderCommentContent(c.content);
|
||||
const content = this.renderCommentContent(c.content, c.item_id);
|
||||
|
||||
// Replicating the structure of comments.js but adapting for the list view
|
||||
// We add a header indicating which item this comment belongs to
|
||||
|
||||
@@ -96,7 +96,7 @@ class v0ck {
|
||||
if (["video", "audio"].includes(tagName)) {
|
||||
const parent = elem.parentElement;
|
||||
if (parent.querySelector('.v0ck_player_controls')) {
|
||||
console.log("[v0ck] Player controls already exist, skipping injection and init");
|
||||
window.f0ckDebug("[v0ck] Player controls already exist, skipping injection and init");
|
||||
return elem; // Return the video element as the constructor result
|
||||
} else {
|
||||
parent.classList.add("v0ck", "paused");
|
||||
@@ -123,7 +123,7 @@ class v0ck {
|
||||
// Use absolute path for reliable asset loading
|
||||
const size = elem.getAttribute('data-size');
|
||||
elem.insertAdjacentHTML("afterend", tpl_player(`/s/img/v0ck.svg`, size));
|
||||
console.log("[v0ck] Player initialized for", tagName);
|
||||
window.f0ckDebug("[v0ck] Player initialized for", tagName);
|
||||
}
|
||||
|
||||
if (tagName === "audio" && elem.hasAttribute('poster')) { // set cover
|
||||
@@ -430,8 +430,12 @@ class v0ck {
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize switch state (defaults to ON if not explicitly 'false')
|
||||
if (toggleBgSwitch && localStorage.getItem('background') !== 'false') {
|
||||
// Initialize switch state
|
||||
const bgEnabled = (window.f0ckSession && window.f0ckSession.show_background !== undefined)
|
||||
? !!window.f0ckSession.show_background
|
||||
: (localStorage.getItem('background') !== 'false');
|
||||
|
||||
if (toggleBgSwitch && bgEnabled) {
|
||||
toggleBgSwitch.classList.add('active');
|
||||
}
|
||||
|
||||
|
||||
@@ -85,9 +85,9 @@ export default new class {
|
||||
};
|
||||
genLink(env) {
|
||||
const link = [];
|
||||
if (env.tag) link.push("tag", env.tag);
|
||||
if (env.hall) link.push("h", env.hall);
|
||||
if (env.user) link.push("user", env.user, env.type ?? 'uploads');
|
||||
if (env.tag) link.push("tag", encodeURIComponent(env.tag));
|
||||
if (env.hall) link.push("h", encodeURIComponent(env.hall));
|
||||
if (env.user) link.push("user", encodeURIComponent(env.user), env.type ?? 'uploads');
|
||||
|
||||
let tmp = link.length === 0 ? '/' : link.join('/');
|
||||
if (!tmp.endsWith('/'))
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"my_halls": "Meine Hallen",
|
||||
"favs": "Favoriten",
|
||||
"admin": "Admin",
|
||||
"mod": "mod",
|
||||
"mod": "Mod",
|
||||
"settings": "Einstellungen",
|
||||
"logout": "Abmelden",
|
||||
"notifications": "Nuttis",
|
||||
@@ -29,7 +29,8 @@
|
||||
"overview": "Übersicht",
|
||||
"prev": "zurück",
|
||||
"next": "weiter",
|
||||
"random_nav": "Zufall"
|
||||
"random_nav": "Zufall",
|
||||
"abyss": "Abgrund"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Hochladen",
|
||||
@@ -124,6 +125,7 @@
|
||||
"clear": "Löschen",
|
||||
"preferences": "Einstellungen",
|
||||
"ui_section": "Benutzeroberfläche",
|
||||
"appearance_section": "Erscheinungsbild",
|
||||
"show_motd": "Nachricht des Tages (MOTD) anzeigen",
|
||||
"modern_layout": "Modernes Layout",
|
||||
"modern_layout_hint": "3-Spalten-Layout",
|
||||
@@ -141,6 +143,11 @@
|
||||
"embed_yt_hint": "YouTube-Videos durch eingebettete Videoplayer ersetzen",
|
||||
"hide_koepfe": "Köpfe ausblenden",
|
||||
"hide_koepfe_hint": "Die Köpfe deaktivieren",
|
||||
"comment_display_mode": "Kommentar-Anzeigemodus",
|
||||
"comment_display_tree": "Antwort-Baum (Standard)",
|
||||
"comment_display_linear": "Linear / Flach (4chan-Stil)",
|
||||
"comment_display_mode_hint": "Wähle aus, wie Kommentare angezeigt werden sollen.",
|
||||
"forced_mode_notice": "Diese Einstellung wird von einem Administrator verwaltet.",
|
||||
"language": "Sprache",
|
||||
"language_hint": "Seitensprache ändern. Lädt die Seite zur Übernahme neu.",
|
||||
"language_default": "Standard (Seite)",
|
||||
@@ -158,10 +165,17 @@
|
||||
"font_default": "Standard",
|
||||
"theme": "Thema",
|
||||
"flash_section": "Flash",
|
||||
"flash_volume": "Standard-Flash-Lautstärke",
|
||||
|
||||
"flash_bg": "Flash im Hintergrund abspielen",
|
||||
"flash_bg_hint": "Verhindert, dass Ruffle pausiert, wenn der Tab verlassen wird",
|
||||
"save_flash": "Flash-Einstellungen speichern",
|
||||
|
||||
"notifications_section": "Benachrichtigungseinstellungen",
|
||||
"receive_system_notifications": "Systembenachrichtigungen erhalten",
|
||||
"receive_system_notifications_hint": "Benachrichtigungen über Erfolg/Fehler bei Hintergrund-Uploads umschalten. Kritische Moderationsaktionen werden immer angezeigt.",
|
||||
"receive_user_notifications": "Benutzerbenachrichtigungen erhalten",
|
||||
"receive_user_notifications_hint": "Benachrichtigungen für Kommentarantworten, Erwähnungen und Thread-Aktivitäten umschalten.",
|
||||
"do_not_disturb": "Bitte nicht stören",
|
||||
"do_not_disturb_hint": "Unterdrückt einfach alle Benachrichtigungen und haptisches Feedback.",
|
||||
"content_filters": "Inhaltsfilter",
|
||||
"min_xd_score": "Mindest-xD-Score",
|
||||
"min_xd_score_hint": "Nur Beiträge mit mindestens diesem xD-Score anzeigen. Auf 0 setzen zum Deaktivieren.",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"nav": {
|
||||
"upload": "upload",
|
||||
"meme": "meme",
|
||||
"upload": "Upload",
|
||||
"meme": "Meme",
|
||||
"halls": "Halls",
|
||||
"tags": "Tags",
|
||||
"search": "Search",
|
||||
"random": "Random",
|
||||
"profile": "profile",
|
||||
"profile": "Profile",
|
||||
"my_halls": "My Halls",
|
||||
"favs": "favs",
|
||||
"favs": "Favs",
|
||||
"admin": "Admin",
|
||||
"mod": "mod",
|
||||
"settings": "settings",
|
||||
"logout": "logout",
|
||||
"mod": "Mod",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout",
|
||||
"notifications": "Notifications",
|
||||
"mark_all_read": "Mark all read",
|
||||
"no_notifications": "No new notifications",
|
||||
@@ -29,7 +29,8 @@
|
||||
"overview": "Overview",
|
||||
"prev": "prev",
|
||||
"next": "next",
|
||||
"random_nav": "random"
|
||||
"random_nav": "random",
|
||||
"abyss": "Abyss"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Upload Content",
|
||||
@@ -124,6 +125,7 @@
|
||||
"clear": "Clear",
|
||||
"preferences": "Preferences",
|
||||
"ui_section": "User Interface",
|
||||
"appearance_section": "Appearance",
|
||||
"show_motd": "Show Message of the Day (MOTD)",
|
||||
"modern_layout": "Modern layout",
|
||||
"modern_layout_hint": "3 Column Layout",
|
||||
@@ -141,6 +143,11 @@
|
||||
"embed_yt_hint": "Replace YouTube links with inline video players",
|
||||
"hide_koepfe": "Hide Köpfe",
|
||||
"hide_koepfe_hint": "Disable the Köpfe",
|
||||
"comment_display_mode": "Comment Display Mode",
|
||||
"comment_display_tree": "Reply Tree (Default)",
|
||||
"comment_display_linear": "Linear / Flat (4chan style)",
|
||||
"comment_display_mode_hint": "Choose how you want comments to be displayed.",
|
||||
"forced_mode_notice": "This setting is managed by an administrator.",
|
||||
"language": "Language",
|
||||
"language_hint": "Change the site language. Reloads the page to apply.",
|
||||
"language_default": "Default (site)",
|
||||
@@ -158,10 +165,17 @@
|
||||
"font_default": "Default",
|
||||
"theme": "Theme",
|
||||
"flash_section": "Flash",
|
||||
"flash_volume": "Default Flash Volume",
|
||||
|
||||
"flash_bg": "Keep Flash Playing in Background",
|
||||
"flash_bg_hint": "Prevents Ruffle from pausing when leaving the tab",
|
||||
"save_flash": "Save Flash Settings",
|
||||
|
||||
"notifications_section": "Notification Preferences",
|
||||
"receive_system_notifications": "Receive System Notifications",
|
||||
"receive_system_notifications_hint": "Toggle background upload success/error notifications. Critical mod actions will always be shown.",
|
||||
"receive_user_notifications": "Receive User Notifications",
|
||||
"receive_user_notifications_hint": "Toggle notifications for comment replies, mentions, and thread activity.",
|
||||
"do_not_disturb": "Do Not Disturb",
|
||||
"do_not_disturb_hint": "Simply suppresses all notifications and haptic feedback.",
|
||||
"content_filters": "Content Filters",
|
||||
"min_xd_score": "Minimum xD Score",
|
||||
"min_xd_score_hint": "Only show posts with at least this xD score. Set to 0 to disable.",
|
||||
@@ -418,14 +432,14 @@
|
||||
},
|
||||
"messages": {
|
||||
"page_title": "MESSAGES",
|
||||
"manage_keys": "🔑 Manage Keys",
|
||||
"manage_keys": "Manage Keys",
|
||||
"loading": "Loading conversations…",
|
||||
"decrypting": "Decrypting messages…",
|
||||
"input_placeholder": "Write a message…",
|
||||
"send": "Send"
|
||||
},
|
||||
"profile": {
|
||||
"message_btn": "✉ Message",
|
||||
"message_btn": "Message",
|
||||
"legacy_record": "Legacy Record – First Upload:",
|
||||
"joined": "Joined:",
|
||||
"age_days": "{n} days",
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"overview": "Overzicht",
|
||||
"prev": "vorige",
|
||||
"next": "volgende",
|
||||
"random_nav": "willekeurig"
|
||||
"random_nav": "willekeurig",
|
||||
"abyss": "Afgrond"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Inhoud Uploaden",
|
||||
@@ -124,6 +125,7 @@
|
||||
"clear": "Wissen",
|
||||
"preferences": "Voorkeuren",
|
||||
"ui_section": "Gebruikersinterface",
|
||||
"appearance_section": "Uiterlijk",
|
||||
"show_motd": "Toon Bericht van de Dag (MOTD)",
|
||||
"modern_layout": "Moderne layout",
|
||||
"modern_layout_hint": "Indeling met 3 kolommen",
|
||||
@@ -141,6 +143,11 @@
|
||||
"embed_yt_hint": "YouTube-links vervangen door inline videospelers",
|
||||
"hide_koepfe": "Köpfe verbergen",
|
||||
"hide_koepfe_hint": "De Köpfe uitschakelen",
|
||||
"comment_display_mode": "Reactie-weergavemodus",
|
||||
"comment_display_tree": "Antwoordboom (Standaard)",
|
||||
"comment_display_linear": "Lineair / Vlak (4chan-stijl)",
|
||||
"comment_display_mode_hint": "Kies hoe je reacties wilt laten weergeven.",
|
||||
"forced_mode_notice": "Deze instelling wordt beheerd door een beheerder.",
|
||||
"language": "Taal",
|
||||
"language_hint": "Wijzig de sitetaal. Pagina wordt herladen om toe te passen.",
|
||||
"language_default": "Standaard (site)",
|
||||
@@ -158,10 +165,17 @@
|
||||
"font_default": "Standaard",
|
||||
"theme": "Thema",
|
||||
"flash_section": "Flash",
|
||||
"flash_volume": "Standaard Flash Volume",
|
||||
|
||||
"flash_bg": "Flash in de achtergrond blijven afspelen",
|
||||
"flash_bg_hint": "Prevents Ruffle from pausing when leaving the tab",
|
||||
"save_flash": "Flash Instellingen Opslaan",
|
||||
|
||||
"notifications_section": "Meldingvoorkeuren",
|
||||
"receive_system_notifications": "Systeemmeldingen ontvangen",
|
||||
"receive_system_notifications_hint": "Schakel meldingen over succes/fout bij achtergronduploads in/uit. Kritieke moderatie-acties worden altijd getoond.",
|
||||
"receive_user_notifications": "Gebruikersmeldingen ontvangen",
|
||||
"receive_user_notifications_hint": "Schakel meldingen voor reacties, vermeldingen en thread-activiteit in/uit.",
|
||||
"do_not_disturb": "Niet storen",
|
||||
"do_not_disturb_hint": "Onderdrukt gewoon alle meldingen en haptische feedback.",
|
||||
"content_filters": "Inhoudsfilters",
|
||||
"min_xd_score": "Minimale xD-score",
|
||||
"min_xd_score_hint": "Only show posts with at least this xD score. Set to 0 to disable.",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"profile": "Profil",
|
||||
"my_halls": "Meine Hallen",
|
||||
"favs": "Favoriten",
|
||||
"admin": "Administrator",
|
||||
"mod": "Moderator",
|
||||
"admin": "Administration",
|
||||
"mod": "Moderation",
|
||||
"settings": "Einstellungen",
|
||||
"logout": "Abmeldung",
|
||||
"notifications": "Hinweise",
|
||||
@@ -29,7 +29,8 @@
|
||||
"overview": "Überblick",
|
||||
"prev": "zurück",
|
||||
"next": "weiter",
|
||||
"random_nav": "Zufall"
|
||||
"random_nav": "Zufall",
|
||||
"abyss": "Abgrund"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Aufladieren",
|
||||
@@ -124,6 +125,7 @@
|
||||
"clear": "Leeren",
|
||||
"preferences": "Präferenzen",
|
||||
"ui_section": "Benutzeroberfläche",
|
||||
"appearance_section": "Erscheinungsbild",
|
||||
"show_motd": "Nachricht des Tages (NdT) anzeigen",
|
||||
"modern_layout": "Modernes Layout",
|
||||
"modern_layout_hint": "3-Spalten-Layout",
|
||||
@@ -137,10 +139,15 @@
|
||||
"enable_bg_blur_hint": "Gefalteten Hintergrund auf Elementen anzeigen",
|
||||
"render_emojis": "Emojis in Zitatantworten darstellen",
|
||||
"render_emojis_hint": ":emoji:-Bilder innerhalb von >zitierten Zeilen anzeigen",
|
||||
"embed_yt": "DuRöhre-Verknüpfungen in Kommentaren einbetten",
|
||||
"embed_yt_hint": "Ersetzen Sie DuRöhre-Verknüpfungen durch integrierte Videospieler",
|
||||
"embed_yt": "Röhrenelfen in Kommentaren einbetten",
|
||||
"embed_yt_hint": "Röhrenelfen durch integrierten Röhrenspieler",
|
||||
"hide_koepfe": "Köpfe verbergen",
|
||||
"hide_koepfe_hint": "Die Köpfe deaktivieren",
|
||||
"comment_display_mode": "Kommentar-Anzeigemodus",
|
||||
"comment_display_tree": "Antwort-Baum (Standard)",
|
||||
"comment_display_linear": "Linear / Flach (Vierkanal-Stil)",
|
||||
"comment_display_mode_hint": "Wähle aus, wie Kommentare angezeigt werden sollen.",
|
||||
"forced_mode_notice": "Diese Einstellung wird für Sie verwaltet.",
|
||||
"language": "Sprache",
|
||||
"language_hint": "Ändern Sie die Seitensprache. Lädt die Seite neu, um die Änderungen anzuwenden.",
|
||||
"language_default": "Standard (Seite)",
|
||||
@@ -150,24 +157,30 @@
|
||||
"language_zange": "Zangendeutsch",
|
||||
"scroll_nav": "Mausrad-Navigation auf Elementen",
|
||||
"scroll_nav_hint": "Navigieren Sie zum nächsten/vorherigen Element durch Scrollen auf dem Medienbereich",
|
||||
"username_color": "Benutzerdefinierte Benutzername-Farbe",
|
||||
"username_color": "Namensfarbe",
|
||||
"username_color_hint": "Wählen Sie eine Farbe oder geben Sie einen Hex-Code für Ihren Benutzernamen auf Elementen und Kommentaren ein.",
|
||||
"save_color": "Farbe speichern",
|
||||
"reset": "Zurücksetzen",
|
||||
"website_font": "Schriftart der Weltnetzpräsenz",
|
||||
"font_default": "Standard",
|
||||
"theme": "Thema",
|
||||
"flash_section": "Flash",
|
||||
"flash_volume": "Standard-Flash-Lautstärke",
|
||||
"flash_bg": "Flash im Hintergrund weiterlaufen lassen",
|
||||
"flash_bg_hint": "Verhindert, dass Ruffle pausiert, wenn der Reiter verlassen wird",
|
||||
"save_flash": "Flash-Einstellungen speichern",
|
||||
"flash_section": "Blitz",
|
||||
"flash_bg": "Blitz im Hintergrund weiterlaufen lassen",
|
||||
"flash_bg_hint": "Verhindert, dass ein Blitz pausiert, wenn der Reiter verlassen wird",
|
||||
|
||||
"notifications_section": "Hinweis-Präferenzen",
|
||||
"receive_system_notifications": "Systemhinweise erhalten",
|
||||
"receive_system_notifications_hint": "Hinweise über Erfolg/Abbruch bei Hintergrund-Aufladierungen umschalten. Kritische Moderations-Eingriffe werden stets kundgetan.",
|
||||
"receive_user_notifications": "Benutzerhinweise erhalten",
|
||||
"receive_user_notifications_hint": "Hinweise für Kommentarantworten, Erwähnungen und Faden-Aktivitäten umschalten.",
|
||||
"do_not_disturb": "Ich wünsche keine Störung",
|
||||
"do_not_disturb_hint": "Unterdrückt jegliche Hinweise und haptisches Beben.",
|
||||
"content_filters": "Inhaltsfilter",
|
||||
"min_xd_score": "Minimaler xD-Punktestand",
|
||||
"min_xd_score_hint": "Zeige nur Pfosten mit mindestens diesem xD-Punktestand an. Auf 0 setzen zum Deaktivieren.",
|
||||
"save": "Speichern",
|
||||
"account": "Konto",
|
||||
"user_id": "Benutzeridentifikation",
|
||||
"user_id": "ID",
|
||||
"username": "Benutzername",
|
||||
"display_name": "Anzeigename",
|
||||
"display_name_placeholder": "Anzeigename",
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "fs";
|
||||
import db from "./sql.mjs";
|
||||
import cfg from "./config.mjs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
export default new class queue {
|
||||
|
||||
@@ -62,7 +63,12 @@ export default new class queue {
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated Use queue.spawn() instead — exec() invokes a shell and is vulnerable to injection if passed user input. */
|
||||
exec(cmd, options = {}) {
|
||||
if (!this._execDeprecated) {
|
||||
console.warn('[DEPRECATED] queue.exec() is deprecated — use queue.spawn() to avoid shell injection risks');
|
||||
this._execDeprecated = true;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
_exec(cmd, { maxBuffer: 5e3 * 1024, ...options }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
@@ -84,6 +90,11 @@ export default new class queue {
|
||||
// 3. Generate dHash for each.
|
||||
// 4. Return combined hash "hash1_hash2_hash3".
|
||||
|
||||
// Skip ffprobe for PDFs (which would fail with "Invalid data")
|
||||
if (source.toLowerCase().endsWith('.pdf')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationStr = (await this.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', source])).stdout.trim();
|
||||
const duration = parseFloat(durationStr);
|
||||
if (isNaN(duration) || duration <= 0) return null;
|
||||
@@ -239,10 +250,25 @@ export default new class queue {
|
||||
const bDir = pending ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
|
||||
const tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
const cDir = pending ? path.join(cfg.paths.pending, 'ca') : cfg.paths.ca;
|
||||
const tmpFile = path.join(cfg.paths.tmp, itemid + '.png');
|
||||
const tmpJpg = path.join(cfg.paths.tmp, itemid + '.jpg');
|
||||
const tmpFile = path.join(os.tmpdir(), itemid + '.png');
|
||||
const tmpJpg = path.join(os.tmpdir(), itemid + '.jpg');
|
||||
|
||||
if (mime.startsWith('video/') || mime == 'image/gif') {
|
||||
if (mime === 'video/youtube') {
|
||||
const videoId = filename.startsWith('yt:') ? filename.split(':')[1] : null;
|
||||
if (videoId) {
|
||||
const thumbUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
||||
try {
|
||||
const curlArgs = ['-s', '-L', thumbUrl, '-o', tmpFile];
|
||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||
curlArgs.push('--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`);
|
||||
}
|
||||
await this.spawn('curl', curlArgs);
|
||||
} catch (err) {
|
||||
console.error(`[QUEUE] YouTube thumbnail extraction failed for ${itemid}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (mime.startsWith('video/') || mime == 'image/gif') {
|
||||
const seeks = ['20%', '40%', '60%', '80%'];
|
||||
for (const seek of seeks) {
|
||||
await this.spawn('ffmpegthumbnailer', ['-i', path.join(bDir, filename), '-s', '1024', '-t', seek, '-o', tmpFile]);
|
||||
@@ -257,11 +283,14 @@ export default new class queue {
|
||||
else if (mime.startsWith('audio/')) {
|
||||
let coverExtracted = false;
|
||||
if (link.match(/soundcloud/)) {
|
||||
let cover = (await this.spawn('yt-dlp', ['-f', 'bv*[height<=720]+ba/b[height<=720] / wv*+ba/w', '--get-thumbnail', link])).stdout.trim();
|
||||
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') ? ['--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`] : [];
|
||||
let cover = (await this.spawn('yt-dlp', [...proxyArgs, '-f', 'bv*[height<=720]+ba/b[height<=720] / wv*+ba/w', '--get-thumbnail', link])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
if (!cover.match(/default_avatar/)) {
|
||||
cover = cover.replace(/-(large|original)\./, '-t500x500.');
|
||||
try {
|
||||
await this.spawn('wget', [cover, '-O', tmpJpg]);
|
||||
const curlArgs = ['-s', '-L', cover, '-o', tmpJpg];
|
||||
if (proxyArgs.length > 0) curlArgs.push(...proxyArgs);
|
||||
await this.spawn('curl', curlArgs);
|
||||
const size = (await fs.promises.stat(tmpJpg)).size;
|
||||
if (size >= 0) {
|
||||
await this.spawn('magick', [tmpJpg, tmpFile]);
|
||||
@@ -306,7 +335,6 @@ export default new class queue {
|
||||
}
|
||||
else if (mime === 'application/x-shockwave-flash' || mime === 'application/vnd.adobe.flash.movie') {
|
||||
let customThumb = cfg.websrv.swf_thumb;
|
||||
// Resolve web paths (/s/...) to the filesystem (public/s/...)
|
||||
if (customThumb && customThumb.startsWith('/')) {
|
||||
customThumb = path.join(path.resolve(), 'public', customThumb);
|
||||
}
|
||||
@@ -331,6 +359,32 @@ export default new class queue {
|
||||
]).catch(() => {});
|
||||
}
|
||||
}
|
||||
else if (mime === 'application/pdf') {
|
||||
try {
|
||||
await this.spawn('gs', [
|
||||
'-q', '-dNOPAUSE', '-dBATCH', '-sDEVICE=png16m',
|
||||
'-r150',
|
||||
'-dTextAlphaBits=4', '-dGraphicsAlphaBits=4',
|
||||
'-dLastPage=1',
|
||||
'-sOutputFile=' + tmpFile,
|
||||
path.join(bDir, filename)
|
||||
]);
|
||||
} catch (err) {
|
||||
console.warn(`[QUEUE] PDF extraction failed for ${itemid}, using fallback icon.`);
|
||||
const pdfFallback = path.join(cfg.paths.s, 'img', 'pdf.webp');
|
||||
await fs.promises.copyFile(pdfFallback, tmpFile).catch(async () => {
|
||||
// If the asset is missing, generate a red PDF-style placeholder matching the user's reference
|
||||
await this.spawn('magick', [
|
||||
'-size', '256x256', 'xc:#d32f2f', // Professional PDF Red
|
||||
'-gravity', 'center',
|
||||
'-fill', 'white',
|
||||
'-pointsize', '60',
|
||||
'-annotate', '0', 'PDF',
|
||||
tmpFile
|
||||
]).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.spawn('magick', [tmpFile, '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', path.join(tDir, itemid + '.webp')]);
|
||||
await fs.promises.unlink(tmpFile).catch(_ => { });
|
||||
|
||||
@@ -124,13 +124,13 @@ export default {
|
||||
const actPage = +(page ?? 1);
|
||||
|
||||
// Support multiple MIME types (comma separated)
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m));
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
||||
const mimeSQL = mimeParts.length > 0
|
||||
? db`and (${mimeParts.map(m => m === 'flash'
|
||||
? (flashMimes.length > 0
|
||||
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
|
||||
: db`false`)
|
||||
: db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} or ${b}`)})`
|
||||
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
|
||||
: db``;
|
||||
const eps = limit ?? cfg.websrv.eps;
|
||||
const excludedTags = session && exclude ? (exclude || []) : [];
|
||||
@@ -155,16 +155,15 @@ export default {
|
||||
select ta.item_id
|
||||
from tags_assign ta
|
||||
join tags t on t.id = ta.tag_id
|
||||
where lower(t.normalized) = ANY(${strictParams}::text[])
|
||||
where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
|
||||
group by ta.item_id
|
||||
having count(distinct lower(t.normalized)) = ${strictParams.length}
|
||||
having count(distinct t.normalized) = ${terms.length}
|
||||
)`;
|
||||
} else {
|
||||
// Non-strict intersection Logic (AND for partials)
|
||||
// For each term, ensure there is AT LEAST one matching tag assigned to the item
|
||||
const conditions = terms.map(term => {
|
||||
const q = '%' + lib.slugify(term) + '%';
|
||||
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where lower(t.normalized) ilike ${q})`;
|
||||
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`;
|
||||
});
|
||||
tagFilter = db`${conditions}`;
|
||||
}
|
||||
@@ -317,13 +316,13 @@ export default {
|
||||
}
|
||||
const mime = (rawMime ?? "");
|
||||
const itemid = rawItemid ? +rawItemid : null;
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m));
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
||||
const mimeSQL = mimeParts.length > 0
|
||||
? db`and (${mimeParts.map(m => m === 'flash'
|
||||
? (flashMimes.length > 0
|
||||
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
|
||||
: db`false`)
|
||||
: db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} or ${b}`)})`
|
||||
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
|
||||
: db``;
|
||||
const excludedTags = exclude || [];
|
||||
|
||||
@@ -351,14 +350,13 @@ export default {
|
||||
select ta.item_id
|
||||
from tags_assign ta
|
||||
join tags t on t.id = ta.tag_id
|
||||
where lower(t.normalized) = ANY(${strictParams}::text[])
|
||||
where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
|
||||
group by ta.item_id
|
||||
having count(distinct lower(t.normalized)) = ${strictParams.length}
|
||||
having count(distinct t.normalized) = ${terms.length}
|
||||
)`;
|
||||
} else {
|
||||
const conditions = terms.map(term => {
|
||||
const q = '%' + lib.slugify(term) + '%';
|
||||
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where lower(t.normalized) ilike ${q})`;
|
||||
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`;
|
||||
});
|
||||
tagFilter = db`${conditions}`;
|
||||
}
|
||||
@@ -665,13 +663,13 @@ export default {
|
||||
}
|
||||
|
||||
// Support multiple MIME types (comma separated)
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash'].includes(m));
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
||||
const mimeSQL = mimeParts.length > 0
|
||||
? db`and (${mimeParts.map(m => m === 'flash'
|
||||
? (flashMimes.length > 0
|
||||
? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`)
|
||||
: db`false`)
|
||||
: db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} or ${b}`)})`
|
||||
: (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})`
|
||||
: db``;
|
||||
const excludedTags = session && exclude ? (exclude || []) : [];
|
||||
|
||||
@@ -714,14 +712,13 @@ export default {
|
||||
select ta.item_id
|
||||
from tags_assign ta
|
||||
join tags t on t.id = ta.tag_id
|
||||
where lower(t.normalized) = ANY(${strictParams}::text[])
|
||||
where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
|
||||
group by ta.item_id
|
||||
having count(distinct lower(t.normalized)) = ${strictParams.length}
|
||||
having count(distinct t.normalized) = ${terms.length}
|
||||
)`;
|
||||
} else {
|
||||
const conditions = terms.map(term => {
|
||||
const q = '%' + lib.slugify(term) + '%';
|
||||
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where lower(t.normalized) ilike ${q})`;
|
||||
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`;
|
||||
});
|
||||
tagFilter = db`${conditions}`;
|
||||
}
|
||||
@@ -850,6 +847,28 @@ export default {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
getComment: async (id, process = true) => {
|
||||
if (!id) return null;
|
||||
try {
|
||||
const comment = await db`
|
||||
SELECT
|
||||
c.id, c.parent_id, c.item_id, c.content, c.created_at, c.vote_score, c.is_deleted,
|
||||
COALESCE(c.is_pinned, false) as is_pinned,
|
||||
c.video_time,
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name
|
||||
FROM comments c
|
||||
JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
WHERE c.id = ${id} AND c.is_deleted = false
|
||||
LIMIT 1
|
||||
`;
|
||||
if (!comment.length) return null;
|
||||
return process ? (await processMentions(comment))[0] : comment[0];
|
||||
} catch (e) {
|
||||
console.error('[F0CKLIB] Error fetching comment:', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getSubscriptionStatus: async (userId, itemId) => {
|
||||
if (!userId || !itemId) return false;
|
||||
const tStart = Date.now();
|
||||
|
||||
@@ -4,27 +4,29 @@ export default (obj, word) => {
|
||||
return obj.map(tmp => {
|
||||
let rscore = 0
|
||||
, startat = 0
|
||||
, string = tmp.tag
|
||||
, cscore
|
||||
, score;
|
||||
for(let i = 0; i < word.length; i++) {
|
||||
const idxOf = string.toLowerCase().indexOf(word.toLowerCase()[i], startat);
|
||||
const stringNorm = (tmp.tag || "").normalize('NFC').toLowerCase();
|
||||
const wordNorm = (word || "").normalize('NFC').toLowerCase();
|
||||
|
||||
for(let i = 0; i < wordNorm.length; i++) {
|
||||
const idxOf = stringNorm.indexOf(wordNorm[i], startat);
|
||||
if(-1 === idxOf)
|
||||
return { score: 0 };
|
||||
if(startat === idxOf)
|
||||
cscore = 0.7;
|
||||
else {
|
||||
cscore = 0.1;
|
||||
if(string[idxOf - 1] === ' ')
|
||||
if(stringNorm[idxOf - 1] === ' ')
|
||||
cscore += 0.8;
|
||||
}
|
||||
if(string[idxOf] === word[i])
|
||||
if(stringNorm[idxOf] === wordNorm[i])
|
||||
cscore += 0.1;
|
||||
rscore += cscore;
|
||||
startat = idxOf + 1;
|
||||
}
|
||||
score = 0.5 * (rscore / string.length + rscore / word.length);
|
||||
if(word.toLowerCase()[0] === string.toLowerCase()[0] && score < 0.85)
|
||||
score = 0.5 * (rscore / stringNorm.length + rscore / wordNorm.length);
|
||||
if(wordNorm[0] === stringNorm[0] && score < 0.85)
|
||||
score += 0.15;
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,7 @@ import cfg from "../config.mjs";
|
||||
import security from "../security.mjs";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads } from "../settings.mjs";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf } from "../settings.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/login(\/)?$/, async (req, res) => {
|
||||
@@ -84,9 +84,10 @@ export default (router, tpl) => {
|
||||
|
||||
const stamp = ~~(Date.now() / 1e3);
|
||||
|
||||
// F-015: Clean up stale non-KMSI sessions unused for 7 days (on login)
|
||||
await db`
|
||||
delete from user_sessions
|
||||
where last_action <= ${(Date.now() - 6048e5)}
|
||||
where last_used <= ${stamp - 604800}
|
||||
and kmsi = 0
|
||||
`;
|
||||
|
||||
@@ -578,7 +579,7 @@ export default (router, tpl) => {
|
||||
router.post(/^\/admin\/settings\/?$/, lib.auth, async (req, res) => {
|
||||
const manual_approval = req.post.manual_approval === 'on' ? 'true' : 'false';
|
||||
const registration_open = req.post.registration_open === 'on' ? 'true' : 'false';
|
||||
const min_tags = parseInt(req.post.min_tags) || 3;
|
||||
const min_tags = isNaN(parseInt(req.post.min_tags)) ? 3 : Math.max(0, parseInt(req.post.min_tags));
|
||||
const trusted_uploads = Math.max(0, parseInt(req.post.trusted_uploads) ?? 3);
|
||||
|
||||
await db`INSERT INTO site_settings (key, value) VALUES ('manual_approval', ${manual_approval}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
|
||||
@@ -622,7 +623,7 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
||||
WITH filtered_users AS (
|
||||
SELECT
|
||||
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated,
|
||||
uo.avatar_file, uo.display_name,
|
||||
uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode,
|
||||
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
|
||||
FROM "user" u
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
@@ -632,7 +633,7 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
||||
SELECT
|
||||
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
|
||||
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated,
|
||||
NULL::text as avatar_file, NULL::varchar as display_name, 'Legacy' as reg_method
|
||||
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
|
||||
FROM items i
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
||||
${q ? db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` : db``}
|
||||
@@ -761,6 +762,40 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
||||
}
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/users\/lock-layout\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
const { user_id, mode, lock } = req.post;
|
||||
if (!user_id) throw new Error('Missing user_id');
|
||||
|
||||
const isLocked = lock === true || lock === 'true' || lock === 1;
|
||||
const targetMode = parseInt(mode, 10);
|
||||
|
||||
const updateData = { force_comment_display_mode: isLocked ? 1 : 0 };
|
||||
if (!isNaN(targetMode)) updateData.comment_display_mode = targetMode;
|
||||
|
||||
const result = await db`
|
||||
UPDATE user_options
|
||||
SET ${db(updateData)}
|
||||
WHERE user_id = ${+user_id}
|
||||
RETURNING user_id
|
||||
`;
|
||||
|
||||
if (!result.length) throw new Error('User options not found');
|
||||
|
||||
// Log it in audit
|
||||
await audit.log(req.session.id, isLocked ? 'lock_user_layout' : 'unlock_user_layout', 'user', +user_id, { mode: targetMode });
|
||||
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||
success: true,
|
||||
msg: 'User layout ' + (isLocked ? 'locked' : 'unlocked') + '.',
|
||||
force_comment_display_mode: isLocked ? 1 : 0,
|
||||
comment_display_mode: targetMode
|
||||
}));
|
||||
} catch (err) {
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/users\/delete\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
const { user_id } = req.post;
|
||||
@@ -815,8 +850,11 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
||||
ghostSlugs.add(finalSlug);
|
||||
|
||||
if (hall.custom_image) {
|
||||
const oldPath = path.join(CUSTOM_DIR, `u_${targetId}_${hall.slug}.webp`);
|
||||
const newPath = path.join(CUSTOM_DIR, `u_${ghostId}_${finalSlug}.webp`);
|
||||
// F-004 Security: Sanitize slugs before constructing file paths
|
||||
const safeSlug = path.basename(hall.slug);
|
||||
const safeFinalSlug = path.basename(finalSlug);
|
||||
const oldPath = path.join(CUSTOM_DIR, `u_${targetId}_${safeSlug}.webp`);
|
||||
const newPath = path.join(CUSTOM_DIR, `u_${ghostId}_${safeFinalSlug}.webp`);
|
||||
try {
|
||||
await fs.rename(oldPath, newPath);
|
||||
} catch (e) {
|
||||
@@ -1192,5 +1230,16 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
||||
});
|
||||
});
|
||||
|
||||
// Chat Manager
|
||||
router.get(/^\/admin\/chat\/?$/, lib.auth, async (req, res) => {
|
||||
res.reply({
|
||||
body: tpl.render('admin/chat', {
|
||||
session: req.session,
|
||||
totals: await lib.countf0cks(),
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default (router, tpl) => {
|
||||
contextUrl = contextUrl.replace(new RegExp(`/${req.params.itemid}$`), `/${query.mime}/${req.params.itemid}`);
|
||||
}
|
||||
|
||||
console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
|
||||
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
|
||||
|
||||
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
||||
|
||||
@@ -145,7 +145,7 @@ export default (router, tpl) => {
|
||||
const paginationHtml = tpl.render('snippets/pagination', data, req);
|
||||
const tAjaxRender = Date.now();
|
||||
|
||||
console.log(`[${new Date().toISOString()}] [AJAX] Complete request for ${req.params.itemid} in ${tAjaxRender - tAjaxStart}ms
|
||||
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Complete request for ${req.params.itemid} in ${tAjaxRender - tAjaxStart}ms
|
||||
- getf0ck: ${tAjaxFetch - tAjaxStart}ms
|
||||
- Comments/Sub: ${tAjaxAux - tAjaxFetch}ms
|
||||
- Render: ${tAjaxRender - tAjaxAux}ms`);
|
||||
|
||||
@@ -11,8 +11,17 @@ import { parseMultipart, collectBody } from '../../multipart.mjs';
|
||||
|
||||
const allowedMimes = ["audio", "image", "video", "%"];
|
||||
const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
const metaCache = new Map();
|
||||
const MAX_META_CACHE = 2000;
|
||||
|
||||
export default router => {
|
||||
// Ensure cache table exists
|
||||
db`CREATE TABLE IF NOT EXISTS meta_cache (
|
||||
url TEXT PRIMARY KEY,
|
||||
data JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`.catch(err => console.error('[META-CACHE] Table creation failed:', err));
|
||||
|
||||
router.group(/^\/api\/v2/, group => {
|
||||
|
||||
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
|
||||
@@ -282,6 +291,8 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
// F-002 Security: Require authentication to prevent SSRF via arbitrary URL fetching.
|
||||
// Guests use cached entries from DB (populated by authenticated user requests).
|
||||
group.get(/\/meta\/fetch$/, lib.loggedin, async (req, res) => {
|
||||
if (!cfg.websrv.web_meta_extraction) {
|
||||
return res.json({ success: false, msg: 'Metadata extraction is disabled' }, 403);
|
||||
@@ -290,6 +301,38 @@ export default router => {
|
||||
const url = req.url.qs.url;
|
||||
if (!url) return res.json({ success: false, msg: 'URL required' }, 400);
|
||||
|
||||
if (metaCache.has(url)) {
|
||||
return res.json({ success: true, meta: metaCache.get(url) });
|
||||
}
|
||||
|
||||
// Check DB cache for persistence across restarts
|
||||
try {
|
||||
const cached = await db`SELECT data FROM meta_cache WHERE url = ${url} LIMIT 1`;
|
||||
if (cached.length > 0) {
|
||||
const meta = cached[0].data;
|
||||
metaCache.set(url, meta); // update in-memory cache
|
||||
return res.json({ success: true, meta });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[META-CACHE] DB lookup failed:', err);
|
||||
}
|
||||
|
||||
const setCache = async (u, m) => {
|
||||
if (!m || !m.title) return;
|
||||
metaCache.set(u, m);
|
||||
if (metaCache.size > MAX_META_CACHE) {
|
||||
const first = metaCache.keys().next().value;
|
||||
metaCache.delete(first);
|
||||
}
|
||||
// Persist to DB
|
||||
try {
|
||||
await db`INSERT INTO meta_cache (url, data) VALUES (${u}, ${m})
|
||||
ON CONFLICT (url) DO UPDATE SET data = EXCLUDED.data, created_at = CURRENT_TIMESTAMP`;
|
||||
} catch (err) {
|
||||
console.error('[META-CACHE] DB save failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (/\.(mp4|webm|mp3|ogg|opus|flac|m4a|mkv|jpg|jpeg|png|gif|webp|swf)$/i.test(url)) {
|
||||
return res.json({ success: false, msg: 'Metadata extraction skipped for direct media URLs' }, 400);
|
||||
}
|
||||
@@ -314,13 +357,15 @@ export default router => {
|
||||
if (oembedOut && oembedOut.trim()) {
|
||||
const data = JSON.parse(oembedOut);
|
||||
if (data.title) {
|
||||
return res.json({
|
||||
success: true,
|
||||
meta: {
|
||||
const meta = {
|
||||
title: data.title,
|
||||
site_name: 'youtube.com',
|
||||
author: data.author_name || 'Unknown'
|
||||
}
|
||||
};
|
||||
await setCache(url, meta);
|
||||
return res.json({
|
||||
success: true,
|
||||
meta
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -354,13 +399,15 @@ export default router => {
|
||||
}
|
||||
|
||||
if (title) {
|
||||
return res.json({
|
||||
success: true,
|
||||
meta: {
|
||||
const meta = {
|
||||
title: title,
|
||||
site_name: lines[2] ? lines[2].trim() : 'Media Site',
|
||||
author: lines[1] ? lines[1].trim() : 'Unknown'
|
||||
}
|
||||
};
|
||||
await setCache(url, meta);
|
||||
return res.json({
|
||||
success: true,
|
||||
meta
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -402,6 +449,7 @@ export default router => {
|
||||
return res.json({ success: false, msg: 'Reddit bot protection encountered' }, 403);
|
||||
}
|
||||
|
||||
await setCache(url, meta);
|
||||
return res.json({ success: true, meta });
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -663,7 +711,7 @@ export default router => {
|
||||
reply.success = true;
|
||||
reply.suggestions = search(q, searchString);
|
||||
} catch (err) {
|
||||
reply.error = err.msg;
|
||||
reply.error = 'Tag suggestion error';
|
||||
}
|
||||
|
||||
return res.json(reply);
|
||||
@@ -688,7 +736,7 @@ export default router => {
|
||||
`;
|
||||
return res.json({ success: true, suggestions: users });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, error: err.message, suggestions: [] });
|
||||
return res.json({ success: false, error: 'User suggestion error', suggestions: [] });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
|
||||
// These routes remain for other settings API endpoints
|
||||
@@ -443,6 +445,20 @@ export default router => {
|
||||
group.put(/\/font/, lib.loggedin, async (req, res) => {
|
||||
const { font } = req.post;
|
||||
|
||||
// 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
|
||||
if (font) {
|
||||
const fontsDir = path.join(path.resolve(), 'public/s/fonts');
|
||||
try {
|
||||
const available = (await fs.readdir(fontsDir)).filter(f => /\.(ttf|otf|woff2?)$/i.test(f));
|
||||
if (!available.includes(font)) {
|
||||
return res.json({ success: false, msg: 'Invalid font selection' }, 400);
|
||||
}
|
||||
} catch {
|
||||
return res.json({ success: false, msg: 'Font directory unavailable' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
@@ -517,23 +533,25 @@ export default router => {
|
||||
|
||||
// Update Ruffle (Flash) preferences
|
||||
group.put(/\/ruffle/, lib.loggedin, async (req, res) => {
|
||||
const ruffle_volume = parseFloat(req.post.ruffle_volume);
|
||||
const ruffle_background = req.post.ruffle_background === 'true' || req.post.ruffle_background === true;
|
||||
const ruffle_volume = req.post.ruffle_volume !== undefined ? parseFloat(req.post.ruffle_volume) : undefined;
|
||||
|
||||
if (isNaN(ruffle_volume) || ruffle_volume < 0 || ruffle_volume > 1) {
|
||||
if (ruffle_volume !== undefined && (isNaN(ruffle_volume) || ruffle_volume < 0 || ruffle_volume > 1)) {
|
||||
return res.json({ success: false, msg: 'Invalid volume: must be 0-1' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData = { ruffle_background };
|
||||
if (ruffle_volume !== undefined) updateData.ruffle_volume = ruffle_volume;
|
||||
|
||||
await db`
|
||||
update user_options
|
||||
set ruffle_volume = ${ruffle_volume},
|
||||
ruffle_background = ${ruffle_background}
|
||||
set ${db(updateData)}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) {
|
||||
req.session.ruffle_volume = ruffle_volume;
|
||||
req.session.ruffle_background = ruffle_background;
|
||||
if (ruffle_volume !== undefined) req.session.ruffle_volume = ruffle_volume;
|
||||
}
|
||||
return res.json({ success: true, ruffle_volume, ruffle_background }, 200);
|
||||
} catch (e) {
|
||||
@@ -639,6 +657,62 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update comment display mode preference
|
||||
group.put(/\/comment_display_mode/, lib.loggedin, async (req, res) => {
|
||||
const mode = parseInt(req.post.mode, 10);
|
||||
if (isNaN(mode) || (mode !== 0 && mode !== 1)) {
|
||||
return res.json({ success: false, msg: 'Invalid mode' }, 400);
|
||||
}
|
||||
|
||||
// Check if mode is forced
|
||||
const forced = (await db`select force_comment_display_mode from user_options where user_id = ${+req.session.id}`)[0]?.force_comment_display_mode;
|
||||
if (forced) {
|
||||
return res.json({ success: false, msg: 'Comment layout is locked for your account.' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set comment_display_mode = ${mode}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) req.session.comment_display_mode = mode;
|
||||
return res.json({ success: true, mode }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update comment_display_mode error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update notification preferences (Consolidated Endpoint)
|
||||
group.post('/notifications', lib.loggedin, async (req, res) => {
|
||||
const { key, value } = req.post;
|
||||
const allowedKeys = ['receive_system_notifications', 'receive_user_notifications', 'do_not_disturb'];
|
||||
|
||||
if (!allowedKeys.includes(key)) {
|
||||
return res.json({ success: false, msg: 'Invalid preference key' }, 400);
|
||||
}
|
||||
|
||||
const boolValue = value === true || value === 'true';
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set ${db({ [key]: boolValue }, key)}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
|
||||
if (req.session) req.session[key] = boolValue;
|
||||
|
||||
await db`SELECT pg_notify('profile_update', ${JSON.stringify({ user_id: req.session.id, [key]: boolValue })})`;
|
||||
|
||||
return res.json({ success: true, [key]: boolValue }, 200);
|
||||
} catch (e) {
|
||||
console.error(`Update notification preference (${key}) error:`, e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return group;
|
||||
});
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export default router => {
|
||||
const isDuplicate = err.code === '23505' || err.constraint?.includes('tags_assign');
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: isDuplicate ? 'Tag already exists' : err.message,
|
||||
msg: isDuplicate ? 'Tag already exists' : 'Failed to add tag',
|
||||
tags: await lib.getTags(postid)
|
||||
});
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export default router => {
|
||||
|
||||
return res.json({ success: true, rating_tag_id: nextTagId, rating_label: label, rating_class: cls });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: err.message });
|
||||
return res.json({ success: false, msg: 'Failed to update rating' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -54,14 +54,13 @@ import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../se
|
||||
// Collect request body as buffer with debug logging
|
||||
const collectBody = (req) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('[UPLOAD DEBUG] collectBody started');
|
||||
if (cfg.main.development) console.log('[UPLOAD DEBUG] collectBody started');
|
||||
const chunks = [];
|
||||
req.on('data', chunk => {
|
||||
// console.log(`[UPLOAD DEBUG] chunk received: ${chunk.length} bytes`);
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
console.log(`[UPLOAD DEBUG] Stream ended. Total size: ${chunks.reduce((acc, c) => acc + c.length, 0)}`);
|
||||
if (cfg.main.development) console.log(`[UPLOAD DEBUG] Stream ended. Total size: ${chunks.reduce((acc, c) => acc + c.length, 0)}`);
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
req.on('error', err => {
|
||||
@@ -71,7 +70,7 @@ const collectBody = (req) => {
|
||||
|
||||
// Ensure stream is flowing
|
||||
if (req.isPaused()) {
|
||||
console.log('[UPLOAD DEBUG] Stream was paused, resuming...');
|
||||
if (cfg.main.development) console.log('[UPLOAD DEBUG] Stream was paused, resuming...');
|
||||
req.resume();
|
||||
}
|
||||
});
|
||||
@@ -230,16 +229,11 @@ export default router => {
|
||||
|
||||
// Download YouTube thumbnail as our thumbnail
|
||||
try {
|
||||
const thumbUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
||||
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
const tmpThumb = path.join(cfg.paths.tmp, `${itemid}_yt.jpg`);
|
||||
await queue.spawn('wget', ['-q', thumbUrl, '-O', tmpThumb]);
|
||||
await queue.spawn('magick', [tmpThumb, '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', path.join(tDir, `${itemid}.webp`)]);
|
||||
await fs.unlink(tmpThumb).catch(() => {});
|
||||
await queue.genThumbnail(filename, 'video/youtube', itemid, ytUrl, isApprovalRequired);
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL] YouTube thumbnail error:', err);
|
||||
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
|
||||
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '20', '-annotate', '0', 'YouTube', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
|
||||
}
|
||||
|
||||
// Assign rating tag
|
||||
@@ -317,8 +311,8 @@ export default router => {
|
||||
|
||||
// Priority 2: Extract HTTP codes
|
||||
const httpCode = msg.match(/HTTP Error (\d+)/i)?.[1]
|
||||
|| msg.match(/\b(4\d{2}|5\d{2})\b/)?.[1]
|
||||
|| null;
|
||||
|| msg.match(/status code (\d{3})/i)?.[1]
|
||||
|| (msg.match(/\b(4\d{2}|5\d{2})\b/)?.[1] !== '537' ? msg.match(/\b(4\d{2}|5\d{2})\b/)?.[1] : null);
|
||||
if (httpCode) return `Download/Process failed (HTTP ${httpCode})`;
|
||||
|
||||
// Priority 3: Sanitize raw queue.spawn errors
|
||||
@@ -355,7 +349,7 @@ export default router => {
|
||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||
'--print', 'after_move:filepath',
|
||||
'--merge-output-format', 'mp4'
|
||||
])).stdout.trim();
|
||||
])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err) {
|
||||
console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`);
|
||||
if (isInstagram) throw new Error(sanitizeError(err));
|
||||
@@ -367,9 +361,10 @@ export default router => {
|
||||
'--max-filesize', `${maxfilesize / 1024}k`,
|
||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||
'--print', 'after_move:filepath'
|
||||
])).stdout.trim();
|
||||
])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err2) {
|
||||
console.warn(`[UPLOAD-URL-ASYNC] Stage 2 failed: ${err2.message}`);
|
||||
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 3 (curl) fallback for ${url}`);
|
||||
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
||||
let referer = url;
|
||||
try {
|
||||
@@ -380,7 +375,7 @@ export default router => {
|
||||
} catch (e) {}
|
||||
|
||||
const curlArgs = [
|
||||
'-s', '-f', '-L', url, '-o', fallbackTmp,
|
||||
'-s', '-S', '-f', '-L', url, '-o', fallbackTmp,
|
||||
'--max-filesize', `${maxfilesize}`,
|
||||
'--connect-timeout', '30',
|
||||
'--max-time', '300',
|
||||
|
||||
@@ -84,6 +84,10 @@ export default (router, tpl) => {
|
||||
if (!req.session) {
|
||||
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Login required' }) });
|
||||
}
|
||||
// F-007 Security: Block banned users from chatting
|
||||
if (req.session.banned) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'You are banned' }) });
|
||||
}
|
||||
|
||||
const message = (req.post?.message || '').trim();
|
||||
if (!message || message.length > MAX_MSG_LEN) {
|
||||
|
||||
@@ -61,6 +61,43 @@ export default (router, tpl) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single comment by ID
|
||||
router.get(/\/api\/comment\/(?<id>\d+)/, async (req, res) => {
|
||||
const id = req.params.id;
|
||||
|
||||
// Require login unless comments are public
|
||||
if (!req.session && cfg.main.hide_comments_from_public) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: false, message: "Unauthorized" })
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const comment = await f0cklib.getComment(id);
|
||||
if (!comment) {
|
||||
return res.reply({
|
||||
code: 404,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: false, message: "Comment not found" })
|
||||
});
|
||||
}
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true, comment })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: false, message: "Database error" })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Browse User Comments
|
||||
router.get(/\/user\/(?<user>[^\/]+)\/comments/, async (req, res) => {
|
||||
const user = decodeURIComponent(req.params.user);
|
||||
@@ -207,7 +244,7 @@ export default (router, tpl) => {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("DEBUG: POST /api/comments");
|
||||
if (cfg.main.development) console.log("DEBUG: POST /api/comments");
|
||||
|
||||
// Use standard framework parsing
|
||||
const body = req.post || {};
|
||||
@@ -218,7 +255,7 @@ export default (router, tpl) => {
|
||||
? parseFloat(body.video_time)
|
||||
: null;
|
||||
|
||||
console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) });
|
||||
if (cfg.main.development) console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) });
|
||||
|
||||
if (!content || !content.trim()) {
|
||||
return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) });
|
||||
@@ -444,7 +481,7 @@ export default (router, tpl) => {
|
||||
router.post(/\/api\/comments\/(?<id>\d+)\/delete/, async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
const commentId = req.params.id;
|
||||
console.log(`[DEBUG] Attempting to delete comment ${commentId} by user ${req.session.id} (mod: ${req.session.is_moderator})`);
|
||||
if (cfg.main.development) console.log(`[DEBUG] Attempting to delete comment ${commentId} by user ${req.session.id} (mod: ${req.session.is_moderator})`);
|
||||
|
||||
try {
|
||||
const comment = await db`SELECT content, item_id, user_id FROM comments WHERE id = ${commentId}`;
|
||||
|
||||
@@ -32,6 +32,11 @@ export default (router, tpl) => {
|
||||
if (!req.session || !req.session.admin) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
}
|
||||
// F-031 Security: CSRF validation for destructive admin action
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
|
||||
}
|
||||
const id = req.params.id;
|
||||
|
||||
try {
|
||||
|
||||
@@ -11,6 +11,34 @@ import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs";
|
||||
*/
|
||||
export default (router) => {
|
||||
|
||||
// --- F-001 Security: Per-user rate limiter for proxy routes ---
|
||||
const proxyRateMap = new Map();
|
||||
const PROXY_RATE_LIMIT = 5000; // max requests per window
|
||||
const PROXY_RATE_WINDOW = 600000; // 10 minute window
|
||||
const proxyRateLimit = (req, res) => {
|
||||
if (!req.session) return true; // loggedin middleware handles auth; this is just a guard
|
||||
const key = req.session.id;
|
||||
const now = Date.now();
|
||||
let entry = proxyRateMap.get(key);
|
||||
if (!entry || now - entry.start > PROXY_RATE_WINDOW) {
|
||||
entry = { start: now, count: 0 };
|
||||
proxyRateMap.set(key, entry);
|
||||
}
|
||||
entry.count++;
|
||||
if (entry.count > PROXY_RATE_LIMIT) {
|
||||
res.reply({ code: 429, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, msg: 'Rate limit exceeded' }) });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
// Periodic cleanup to prevent memory leak
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of proxyRateMap) {
|
||||
if (now - v.start > PROXY_RATE_WINDOW * 2) proxyRateMap.delete(k);
|
||||
}
|
||||
}, PROXY_RATE_WINDOW * 2);
|
||||
|
||||
/**
|
||||
* Helper to fetch data (JSON or Buffer) using curl if a proxy is configured.
|
||||
* This ensures we respect the SOCKS5 proxy for all external 4chan requests.
|
||||
@@ -39,7 +67,8 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/:tid
|
||||
// Proxies 4chan thread JSON
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/(?<tid>\d+)\/?$/, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/(?<tid>\d+)\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, tid } = req.params || {};
|
||||
|
||||
if (!board || !tid) {
|
||||
@@ -84,7 +113,7 @@ export default (router) => {
|
||||
|
||||
// POST /api/v2/scroller/external/rehost-meta
|
||||
// Given item IDs, return their metadata (username, avatar, timestamp)
|
||||
router.post(/^\/api\/v2\/scroller\/external\/rehost-meta\/?$/, async (req, res) => {
|
||||
router.post(/^\/api\/v2\/scroller\/external\/rehost-meta\/?$/, lib.loggedin, async (req, res) => {
|
||||
const ids = (req.post?.ids || '').split(',').map(Number).filter(n => n > 0);
|
||||
if (!ids.length) return res.reply({ headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||
|
||||
@@ -96,7 +125,8 @@ export default (router) => {
|
||||
uo.avatar_file, uo.avatar,
|
||||
(SELECT ta.tag_id FROM tags_assign ta
|
||||
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${ratingTagIds}::int[])
|
||||
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id
|
||||
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id,
|
||||
(SELECT COUNT(*) FROM comments WHERE comments.item_id = i.id AND comments.is_deleted = false) AS comment_count
|
||||
FROM items i
|
||||
LEFT JOIN "user" u ON u."user" = i.username
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
@@ -113,7 +143,8 @@ export default (router) => {
|
||||
avatar: r.avatar_file ? `/a/${r.avatar_file}` : (r.avatar ? `/t/${r.avatar}.webp` : '/a/default.png'),
|
||||
stamp: r.stamp,
|
||||
rating_class,
|
||||
rating_label
|
||||
rating_label,
|
||||
comment_count: +r.comment_count || 0
|
||||
};
|
||||
});
|
||||
return res.reply({
|
||||
@@ -128,7 +159,8 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/catalog
|
||||
// Proxies 4chan board catalog JSON
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/catalog\/?$/, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/catalog\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board } = req.params || {};
|
||||
if (!board) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
|
||||
|
||||
@@ -165,7 +197,8 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/find/:postno
|
||||
// Resolves a post number to its parent thread ID
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/find\/(?<postno>\d+)\/?$/, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/find\/(?<postno>\d+)\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, postno } = req.params || {};
|
||||
if (!board || !postno) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
|
||||
|
||||
@@ -223,11 +256,25 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/media/:file
|
||||
// Proxies 4chan media — streams directly to client for fast playback start
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, async (req, res) => {
|
||||
const { board, file } = req.params || {};
|
||||
const url = `https://i.4cdn.org/${board}/${file}`;
|
||||
// F-001: Allowed file extensions for the media proxy (prevents abuse as generic proxy)
|
||||
const ALLOWED_MEDIA_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'webm', 'mp4'];
|
||||
|
||||
const ext = file.split('.').pop();
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, lib.loggedin, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, file } = req.params || {};
|
||||
|
||||
// Validate file extension against whitelist
|
||||
const ext = (file.split('.').pop() || '').toLowerCase();
|
||||
if (!ALLOWED_MEDIA_EXTS.includes(ext)) {
|
||||
return res.reply({ code: 400, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, msg: 'Disallowed file type' }) });
|
||||
}
|
||||
|
||||
// Validate filename doesn't contain path traversal
|
||||
if (file.includes('..') || file.includes('/') || file.includes('\\')) {
|
||||
return res.reply({ code: 400, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, msg: 'Invalid filename' }) });
|
||||
}
|
||||
|
||||
const url = `https://i.4cdn.org/${board}/${file}`;
|
||||
const mimes = {
|
||||
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
|
||||
'gif': 'image/gif', 'webp': 'image/webp',
|
||||
@@ -276,6 +323,13 @@ export default (router) => {
|
||||
|
||||
if (!url) return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL is required' }) });
|
||||
|
||||
// F-014 Security: Restrict rehost to 4chan media URLs only
|
||||
const is4chanUrl = /^https?:\/\/(i\.4cdn\.org|boards\.4cdn\.org)\//i.test(url)
|
||||
|| /\/api\/v2\/scroller\/external\/4chan\/[a-z0-9]+\/media\//i.test(url);
|
||||
if (!is4chanUrl) {
|
||||
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Only 4chan media URLs are supported for rehosting' }) });
|
||||
}
|
||||
|
||||
const board = url.match(/boards\.4cdn\.org\/([a-z0-9]+)\//)?.[1]
|
||||
|| url.match(/i\.4cdn\.org\/([a-z0-9]+)\//)?.[1]
|
||||
|| url.match(/\/4chan\/([a-z0-9]+)\/media\//)?.[1]
|
||||
@@ -327,6 +381,11 @@ export default (router) => {
|
||||
const repost = await queue.checkrepostsum(checksum);
|
||||
if (repost) {
|
||||
await fs.unlink(finalTmp).catch(() => {});
|
||||
// Auto-subscribe user to the existing item they attempted to rehost
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${repost}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
|
||||
} catch (e) { console.error('[REHOST] Auto-subscribe (repost) error:', e); }
|
||||
|
||||
return res.reply({
|
||||
code: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -342,6 +401,11 @@ export default (router) => {
|
||||
const phashMatch = await queue.checkrepostphash(phash);
|
||||
if (phashMatch) {
|
||||
await fs.unlink(finalTmp).catch(() => {});
|
||||
// Auto-subscribe user to the existing item they attempted to rehost (visual match)
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${phashMatch}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
|
||||
} catch (e) { console.error('[REHOST] Auto-subscribe (phash repost) error:', e); }
|
||||
|
||||
return res.reply({
|
||||
code: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -377,6 +441,11 @@ export default (router) => {
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
// Automatically subscribe user to the new item
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
|
||||
} catch (e) { console.error('[REHOST] Auto-subscribe (new item) error:', e); }
|
||||
|
||||
// Process thumbnail
|
||||
try {
|
||||
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
|
||||
@@ -458,7 +527,7 @@ export default (router) => {
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, msg: err.message })
|
||||
body: JSON.stringify({ success: false, msg: 'Rehost failed' })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export default (router, tpl) => {
|
||||
|
||||
// Hall Thumbnail Route
|
||||
router.get(/^\/hall_image\/(?<hallSlug>.+)$/, async (req, res) => {
|
||||
const hallSlug = decodeURIComponent(req.params.hallSlug);
|
||||
const hallSlug = path.basename(decodeURIComponent(req.params.hallSlug));
|
||||
const mode = +(req.url.qs?.m ?? 0);
|
||||
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
|
||||
|
||||
|
||||
@@ -241,8 +241,8 @@ export default (router, tpl) => {
|
||||
data.total = 0;
|
||||
data.success = true;
|
||||
if (!data.link) {
|
||||
if (req.params.hall) data.link = { main: '/h/' + req.params.hall + '/', path: 'p/', suffix: '' };
|
||||
else if (req.params.tag) data.link = { main: '/tag/' + req.params.tag + '/', path: 'p/', suffix: '' };
|
||||
if (req.params.hall) data.link = { main: '/h/' + encodeURIComponent(req.params.hall) + '/', path: 'p/', suffix: '' };
|
||||
else if (req.params.tag) data.link = { main: '/tag/' + encodeURIComponent(req.params.tag) + '/', path: 'p/', suffix: '' };
|
||||
else data.link = { main: '/', path: 'p/', suffix: '' };
|
||||
}
|
||||
data.tmp = data.tmp || {};
|
||||
|
||||
@@ -35,6 +35,11 @@ export default (router, tpl) => {
|
||||
if (!req.session || !req.session.admin) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
}
|
||||
// F-031 Security: CSRF validation for destructive admin action
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
|
||||
}
|
||||
const id = req.params.id;
|
||||
|
||||
try {
|
||||
|
||||
@@ -38,11 +38,81 @@ export default (router, tpl) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Approval Queue (Ported/Shared from Admin)
|
||||
// Approval Queue (View only — GET is safe, no state change)
|
||||
router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
|
||||
// Quick Approve Action
|
||||
if (req.url.qs?.id) {
|
||||
const id = +req.url.qs.id;
|
||||
// View Queue
|
||||
const page = +req.url.qs.page || 1;
|
||||
const limit = 20;
|
||||
// Fetch Pending (not deleted)
|
||||
const pending = await db`
|
||||
select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
|
||||
from "items" i
|
||||
left join "tags_assign" ta on ta.item_id = i.id
|
||||
left join "tags" t on t.id = ta.tag_id
|
||||
where i.active = false and i.is_deleted = false
|
||||
group by i.id
|
||||
order by i.id desc
|
||||
limit ${limit} offset ${(page - 1) * limit}
|
||||
`;
|
||||
|
||||
// Fetch Trash (deleted)
|
||||
const trash = await db`
|
||||
select i.id, i.mime, i.username, i.dest,
|
||||
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags,
|
||||
(select details->>'reason' from audit_log where target_id = i.id::text and action = 'delete_item' order by created_at desc limit 1) as delete_reason
|
||||
from "items" i
|
||||
left join "tags_assign" ta on ta.item_id = i.id
|
||||
left join "tags" t on t.id = ta.tag_id
|
||||
where i.active = false and i.is_deleted = true and i.is_purged = false
|
||||
group by i.id
|
||||
order by i.id desc
|
||||
limit 20
|
||||
`;
|
||||
|
||||
const processItems = (items) => {
|
||||
return items.map(p => {
|
||||
const tags = (p.tags || [])
|
||||
.filter(t => t.tag !== null)
|
||||
.map(t => {
|
||||
let badge = "badge-light";
|
||||
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
|
||||
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
|
||||
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
|
||||
else if (t.normalized === "german") badge = "badge-german badge-light";
|
||||
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
|
||||
else if (t.normalized === "sfw") badge = "badge-success";
|
||||
else if (t.normalized === "nsfw") badge = "badge-danger";
|
||||
|
||||
return { ...t, badge };
|
||||
});
|
||||
|
||||
return {
|
||||
...p,
|
||||
tags
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('mod/approve', {
|
||||
pending: processItems(pending),
|
||||
trash: processItems(trash),
|
||||
page,
|
||||
stats: { total: pending.length + trash.length },
|
||||
session: req.session,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
// F-005 Security: Approve action — POST with CSRF protection
|
||||
router.post(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
|
||||
const id = +(req.post?.id || 0);
|
||||
if (!id) {
|
||||
const body = JSON.stringify({ success: false, msg: 'No ID provided' });
|
||||
return res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
const f0ck = await db`
|
||||
select i.dest, i.mime, i.username, i.id, ta.tag_id
|
||||
from "items" i
|
||||
@@ -52,7 +122,8 @@ export default (router, tpl) => {
|
||||
`;
|
||||
|
||||
if (f0ck.length === 0) {
|
||||
return res.reply({ body: `f0ck ${id}: f0ck not found` });
|
||||
const body = JSON.stringify({ success: false, msg: `f0ck ${id}: f0ck not found` });
|
||||
return res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
// Fetch uploader details for audit log
|
||||
@@ -153,9 +224,14 @@ export default (router, tpl) => {
|
||||
{ b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
|
||||
];
|
||||
|
||||
const isYouTube = f0ck[0].mime === 'video/youtube';
|
||||
for (const p of movePaths) {
|
||||
try {
|
||||
if (isYouTube) {
|
||||
await fs.access(p.t);
|
||||
} else {
|
||||
await fs.access(p.b);
|
||||
}
|
||||
console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
|
||||
|
||||
const moveSafe = async (src, dst) => {
|
||||
@@ -172,8 +248,10 @@ export default (router, tpl) => {
|
||||
await fs.unlink(src).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
console.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const bDst = path.join(cfg.paths.b, f0ck[0].dest);
|
||||
@@ -181,7 +259,9 @@ export default (router, tpl) => {
|
||||
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
|
||||
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
|
||||
|
||||
if (!isYouTube) {
|
||||
await moveSafe(p.b, bDst);
|
||||
}
|
||||
await moveSafe(p.t, tDst);
|
||||
|
||||
const blurSrc = p.t.replace('.webp', '_blur.webp');
|
||||
@@ -200,78 +280,16 @@ export default (router, tpl) => {
|
||||
}
|
||||
|
||||
return res.writeHead(302, { "Location": `/${id}` }).end();
|
||||
});
|
||||
|
||||
// F-005 Security: Deny action — POST with CSRF protection
|
||||
router.post(/^\/mod\/deny\/?/, lib.modAuth, async (req, res) => {
|
||||
const id = +(req.post?.id || 0);
|
||||
if (!id) {
|
||||
const body = JSON.stringify({ success: false, msg: 'No ID provided' });
|
||||
return res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
// View Queue
|
||||
const page = +req.url.qs.page || 1;
|
||||
const limit = 20;
|
||||
// Fetch Pending (not deleted)
|
||||
const pending = await db`
|
||||
select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
|
||||
from "items" i
|
||||
left join "tags_assign" ta on ta.item_id = i.id
|
||||
left join "tags" t on t.id = ta.tag_id
|
||||
where i.active = false and i.is_deleted = false
|
||||
group by i.id
|
||||
order by i.id desc
|
||||
limit ${limit} offset ${(page - 1) * limit}
|
||||
`;
|
||||
|
||||
// Fetch Trash (deleted)
|
||||
const trash = await db`
|
||||
select i.id, i.mime, i.username, i.dest,
|
||||
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags,
|
||||
(select details->>'reason' from audit_log where target_id = i.id::text and action = 'delete_item' order by created_at desc limit 1) as delete_reason
|
||||
from "items" i
|
||||
left join "tags_assign" ta on ta.item_id = i.id
|
||||
left join "tags" t on t.id = ta.tag_id
|
||||
where i.active = false and i.is_deleted = true and i.is_purged = false
|
||||
group by i.id
|
||||
order by i.id desc
|
||||
limit 20
|
||||
`;
|
||||
|
||||
const processItems = (items) => {
|
||||
return items.map(p => {
|
||||
const tags = (p.tags || [])
|
||||
.filter(t => t.tag !== null)
|
||||
.map(t => {
|
||||
let badge = "badge-light";
|
||||
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
|
||||
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
|
||||
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
|
||||
else if (t.normalized === "german") badge = "badge-german badge-light";
|
||||
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
|
||||
else if (t.normalized === "sfw") badge = "badge-success";
|
||||
else if (t.normalized === "nsfw") badge = "badge-danger";
|
||||
|
||||
return { ...t, badge };
|
||||
});
|
||||
|
||||
return {
|
||||
...p,
|
||||
tags
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('mod/approve', {
|
||||
pending: processItems(pending),
|
||||
trash: processItems(trash),
|
||||
page,
|
||||
stats: { total: pending.length + trash.length },
|
||||
session: req.session,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
// Deny / Delete Item
|
||||
router.get(/^\/mod\/deny\/?/, lib.modAuth, async (req, res) => {
|
||||
if (!req.url.qs?.id) return res.reply({ success: false, msg: "No ID provided" });
|
||||
const id = +req.url.qs.id;
|
||||
|
||||
const f0ck = await db`select id, dest, mime, is_deleted, active, username from "items" where id = ${id} limit 1`;
|
||||
if (f0ck.length > 0) {
|
||||
const item = f0ck[0];
|
||||
@@ -339,7 +357,7 @@ export default (router, tpl) => {
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
const reason = req.url.qs?.reason || "Denied by moderator";
|
||||
const reason = req.post?.reason || "Denied by moderator";
|
||||
|
||||
await db`update "items" set is_deleted = true, active = false where id = ${id}`;
|
||||
|
||||
@@ -541,8 +559,14 @@ export default (router, tpl) => {
|
||||
// Supports /mod/pending/b/filename.ext (Binaries)
|
||||
// Supports /mod/pending/t/id.webp (Thumbnails)
|
||||
router.get(/^\/mod\/pending\/(?<type>[btca])\/(?<file>.+)/, lib.modAuth, async (req, res) => {
|
||||
const { type, file } = req.params;
|
||||
const filePath = path.join(cfg.paths.pending, type, file);
|
||||
const { type } = req.params;
|
||||
// F-003 Security: Sanitize file parameter to prevent path traversal
|
||||
const file = path.basename(req.params.file);
|
||||
const baseDir = path.resolve(cfg.paths.pending, type);
|
||||
const filePath = path.resolve(baseDir, file);
|
||||
if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) {
|
||||
return res.writeHead(403).end('Forbidden');
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
@@ -552,7 +576,8 @@ export default (router, tpl) => {
|
||||
const mimeType = {
|
||||
'mp4': 'video/mp4', 'webm': 'video/webm',
|
||||
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
|
||||
'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp'
|
||||
'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp',
|
||||
'pdf': 'application/pdf'
|
||||
}[ext] || 'application/octet-stream';
|
||||
|
||||
if (range) {
|
||||
@@ -577,7 +602,7 @@ export default (router, tpl) => {
|
||||
(await import('fs')).createReadStream(filePath).pipe(res);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err.code !== 'ENOENT') console.error(err);
|
||||
res.writeHead(404).end('File not found');
|
||||
}
|
||||
});
|
||||
@@ -586,10 +611,15 @@ export default (router, tpl) => {
|
||||
// Supports /mod/deleted/b/filename.ext (Binaries)
|
||||
// Supports /mod/deleted/t/id.webp (Thumbnails)
|
||||
router.get(/^\/mod\/deleted\/(?<type>[bt])\/(?<file>.+)/, lib.modAuth, async (req, res) => {
|
||||
const file = decodeURIComponent(req.params.file);
|
||||
// F-003 Security: Sanitize file parameter to prevent path traversal
|
||||
const file = path.basename(decodeURIComponent(req.params.file));
|
||||
const type = req.params.type; // 'b' or 't'
|
||||
console.log(`[MOD_STREAM] Request: type=${type}, file=${file}, range=${req.headers.range || 'none'}`);
|
||||
const filePath = path.join(cfg.paths.deleted, type, file);
|
||||
const baseDir = path.resolve(cfg.paths.deleted, type);
|
||||
const filePath = path.resolve(baseDir, file);
|
||||
if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) {
|
||||
return res.writeHead(403).end('Forbidden');
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
@@ -599,7 +629,8 @@ export default (router, tpl) => {
|
||||
const mimeType = {
|
||||
'mp4': 'video/mp4', 'webm': 'video/webm',
|
||||
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
|
||||
'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp'
|
||||
'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp',
|
||||
'pdf': 'application/pdf'
|
||||
}[ext] || 'application/octet-stream';
|
||||
|
||||
if (range) {
|
||||
@@ -624,7 +655,7 @@ export default (router, tpl) => {
|
||||
(await import('fs')).createReadStream(filePath).pipe(res);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err.code !== 'ENOENT') console.error(err);
|
||||
res.writeHead(404).end('File not found');
|
||||
}
|
||||
});
|
||||
@@ -656,7 +687,7 @@ export default (router, tpl) => {
|
||||
const body = JSON.stringify({ success: true, count });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
} catch (err) {
|
||||
const body = JSON.stringify({ success: false, msg: err.message });
|
||||
const body = JSON.stringify({ success: false, msg: 'Purge failed' });
|
||||
return res.writeHead(500, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,8 +10,10 @@ const activeTabs = new Map(); // sessionId -> tabId
|
||||
function broadcastChatPresence() {
|
||||
const seen = new Set();
|
||||
const users = [];
|
||||
const guestIps = new Set();
|
||||
for (const client of clients) {
|
||||
if (client.userId && !seen.has(client.userId)) {
|
||||
if (client.userId) {
|
||||
if (!seen.has(client.userId)) {
|
||||
seen.add(client.userId);
|
||||
users.push({
|
||||
username: client.username,
|
||||
@@ -21,9 +23,13 @@ function broadcastChatPresence() {
|
||||
username_color: client.username_color
|
||||
});
|
||||
}
|
||||
} else if (client.ip) {
|
||||
guestIps.add(client.ip);
|
||||
}
|
||||
}
|
||||
const guestCount = guestIps.size;
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'global_chat_presence', data: { users } });
|
||||
client.send({ type: 'global_chat_presence', data: { users, guestCount } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +47,16 @@ db.listen('notifications', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
const userId = data.user_id;
|
||||
const SYSTEM_TYPES = ['upload_success', 'upload_error'];
|
||||
const USER_TYPES = ['comment', 'comment_reply', 'mention', 'subscription', 'upload_comment'];
|
||||
|
||||
for (const client of clients) {
|
||||
if (client.userId === userId) {
|
||||
// Do Not Disturb takes absolute priority for standard notifications
|
||||
if (client.do_not_disturb === true) continue;
|
||||
|
||||
if (SYSTEM_TYPES.includes(data.type) && client.receive_system_notifications === false) continue;
|
||||
if (USER_TYPES.includes(data.type) && client.receive_user_notifications === false) continue;
|
||||
client.send({ type: 'notify', data });
|
||||
}
|
||||
}
|
||||
@@ -73,6 +86,11 @@ db.listen('profile_update', (payload) => {
|
||||
const data = JSON.parse(payload);
|
||||
for (const client of clients) {
|
||||
if (client.userId === data.user_id) {
|
||||
// Sync notification preferences to client object for real-time filtering
|
||||
if (data.receive_system_notifications !== undefined) client.receive_system_notifications = data.receive_system_notifications;
|
||||
if (data.receive_user_notifications !== undefined) client.receive_user_notifications = data.receive_user_notifications;
|
||||
if (data.do_not_disturb !== undefined) client.do_not_disturb = data.do_not_disturb;
|
||||
|
||||
client.send({ type: 'profile_update', data });
|
||||
}
|
||||
}
|
||||
@@ -217,6 +235,9 @@ db.listen('private_message', (payload) => {
|
||||
// Only send to the recipient — sender already knows they sent it
|
||||
for (const client of clients) {
|
||||
if (client.userId === data.recipient_id) {
|
||||
// Silenced by DND
|
||||
if (client.do_not_disturb === true) continue;
|
||||
|
||||
client.send({ type: 'private_message', data: {
|
||||
id: data.id,
|
||||
sender_id: data.sender_id,
|
||||
@@ -293,6 +314,19 @@ db.listen('global_chat_background', (payload) => {
|
||||
}
|
||||
}).catch(err => console.error('DB Listen global_chat_background error:', err));
|
||||
|
||||
// Global listener for rethumb live updates
|
||||
db.listen('rethumb', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Broadcasting rethumb (id: ${data.item_id}) to ${clients.size} clients`);
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'rethumb', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Rethumb broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen rethumb error:', err));
|
||||
|
||||
// Global listener for chat topic changes
|
||||
db.listen('global_chat_topic', (payload) => {
|
||||
try {
|
||||
@@ -391,7 +425,15 @@ export default (router, tpl) => {
|
||||
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||
LEFT JOIN items i ON n.item_id = i.id
|
||||
WHERE n.user_id = ${req.session.id} AND n.is_read = false
|
||||
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
|
||||
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve')
|
||||
OR (
|
||||
${req.session.do_not_disturb !== true} AND (
|
||||
(n.type IN ('upload_success', 'upload_error') AND ${req.session.receive_system_notifications !== false})
|
||||
OR (n.type IN ('comment', 'comment_reply', 'mention', 'subscription', 'upload_comment') AND ${req.session.receive_user_notifications !== false})
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report'))
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT 1000
|
||||
`;
|
||||
@@ -510,8 +552,12 @@ export default (router, tpl) => {
|
||||
avatar_file: req.session?.avatar_file || null,
|
||||
avatar: req.session?.avatar || null,
|
||||
username_color: req.session?.username_color || null,
|
||||
receive_system_notifications: req.session?.receive_system_notifications !== false,
|
||||
receive_user_notifications: req.session?.receive_user_notifications !== false,
|
||||
do_not_disturb: req.session?.do_not_disturb === true,
|
||||
sessionId,
|
||||
tabId,
|
||||
ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
|
||||
send: (data) => {
|
||||
try {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
|
||||
@@ -315,7 +315,7 @@ export default (router, tpl) => {
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, items: [], error: e.message })
|
||||
body: JSON.stringify({ success: false, items: [], error: 'Feed error' })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,10 +68,10 @@ export default (router, tpl) => {
|
||||
from "items"
|
||||
join "tags_assign" on "tags_assign".item_id = "items".id
|
||||
join "tags" on "tags".id = "tags_assign".tag_id
|
||||
where lower("tags".tag) in (${db(lowerTags)})
|
||||
where "tags".normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${tags}::text[]) AS x))
|
||||
and "items".active = true
|
||||
group by "items".id
|
||||
having count(distinct lower("tags".tag)) = ${lowerTags.length}
|
||||
having count(distinct "tags".normalized) = ${tags.length}
|
||||
) sub
|
||||
`;
|
||||
total = countResult.length > 0 ? countResult[0].total : 0;
|
||||
@@ -85,10 +85,10 @@ export default (router, tpl) => {
|
||||
from "items"
|
||||
join "tags_assign" on "tags_assign".item_id = "items".id
|
||||
join "tags" on "tags".id = "tags_assign".tag_id
|
||||
where lower("tags".tag) in (${db(lowerTags)})
|
||||
where "tags".normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${tags}::text[]) AS x))
|
||||
and "items".active = true
|
||||
group by "items".id
|
||||
having count(distinct lower("tags".tag)) = ${lowerTags.length}
|
||||
having count(distinct "tags".normalized) = ${tags.length}
|
||||
order by "items".id desc
|
||||
offset ${offset}
|
||||
limit ${_eps}
|
||||
@@ -119,26 +119,34 @@ export default (router, tpl) => {
|
||||
}
|
||||
}
|
||||
else {
|
||||
total = (await db`
|
||||
select count(*) as total
|
||||
from "tags"
|
||||
left join "tags_assign" on "tags_assign".tag_id = "tags".id
|
||||
left join "items" on "items".id = "tags_assign".item_id
|
||||
where "tags".tag ilike ${'%' + tag + '%'}
|
||||
group by "items".id, "tags".tag
|
||||
`).length;
|
||||
const q = '%' + tag + '%';
|
||||
|
||||
const countResult = await db`
|
||||
select count(*) as total from (
|
||||
select 1
|
||||
from "items"
|
||||
join "tags_assign" on "tags_assign".item_id = "items".id
|
||||
join "tags" on "tags".id = "tags_assign".tag_id
|
||||
where ("tags".tag ilike ${q} or "tags".normalized like '%' || slugify(${tag}) || '%')
|
||||
and "items".active = true
|
||||
group by "items".id
|
||||
) sub
|
||||
`;
|
||||
total = countResult.length > 0 ? parseInt(countResult[0].total) : 0;
|
||||
|
||||
const pages = +Math.ceil(total / _eps);
|
||||
const act_page = Math.min(pages, page || 1);
|
||||
const offset = Math.max(0, (act_page - 1) * _eps);
|
||||
|
||||
const rows = await db`
|
||||
select "items".id, "items".username, "items".mime, "tags".tag
|
||||
from "tags"
|
||||
left join "tags_assign" on "tags_assign".tag_id = "tags".id
|
||||
left join "items" on "items".id = "tags_assign".item_id
|
||||
where "tags".tag ilike ${'%' + tag + '%'} and "items".active = true
|
||||
group by "items".id, "tags".tag
|
||||
select "items".id, "items".username, "items".mime, min("tags".tag) as tag
|
||||
from "items"
|
||||
join "tags_assign" on "tags_assign".item_id = "items".id
|
||||
join "tags" on "tags".id = "tags_assign".tag_id
|
||||
where ("tags".tag ilike ${q} or "tags".normalized like '%' || slugify(${tag}) || '%')
|
||||
and "items".active = true
|
||||
group by "items".id
|
||||
order by "items".id desc
|
||||
offset ${offset}
|
||||
limit ${_eps}
|
||||
`;
|
||||
|
||||
@@ -38,7 +38,7 @@ export default (router, tpl) => {
|
||||
res.setHeader('Expires', '0');
|
||||
res.setHeader('Surrogate-Control', 'no-store');
|
||||
|
||||
console.log('Rendering settings. Excluded tags:', excluded_tags);
|
||||
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('settings', {
|
||||
|
||||
@@ -21,7 +21,7 @@ export default (router, tpl) => {
|
||||
const offset = (page - 1) * eps;
|
||||
|
||||
try {
|
||||
console.log('[DEBUG SUB] Fetching subscriptions for user', req.session.id, 'page', page);
|
||||
if (cfg.main.development) console.log('[DEBUG SUB] Fetching subscriptions for user', req.session.id, 'page', page);
|
||||
|
||||
const countRes = await db`
|
||||
SELECT count(*) as total
|
||||
@@ -41,7 +41,7 @@ export default (router, tpl) => {
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT ${eps} OFFSET ${offset}
|
||||
`;
|
||||
console.log('[DEBUG SUB] Found', subs.length, 'subscriptions out of', total);
|
||||
if (cfg.main.development) console.log('[DEBUG SUB] Found', subs.length, 'subscriptions out of', total);
|
||||
|
||||
const items = subs.map(i => ({
|
||||
id: i.id,
|
||||
|
||||
@@ -157,6 +157,25 @@ export default (router, tpl) => {
|
||||
data.hidePagination = true;
|
||||
data.session = req.session ? { ...req.session } : false;
|
||||
|
||||
// Precompute boolean helpers for template @if() — must match index.mjs pattern
|
||||
if (data.item) {
|
||||
const session = data.session;
|
||||
const item = data.item;
|
||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
|
||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && item.mime.indexOf('youtube') === -1);
|
||||
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
|
||||
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
|
||||
data.item_rating_class = item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged'));
|
||||
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
|
||||
data.item_username_lower = (item.username || '').toLowerCase();
|
||||
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
|
||||
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
|
||||
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
||||
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
||||
}
|
||||
|
||||
// Precompute hall display
|
||||
if (data.item?.halls?.length) {
|
||||
data.item.primaryHall = data.item.halls[0];
|
||||
@@ -168,17 +187,26 @@ export default (router, tpl) => {
|
||||
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
if (req.session?.id) f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {});
|
||||
const useLegacy = req.session
|
||||
? (req.session.use_new_layout === false)
|
||||
: (cfg.websrv.default_layout === 'legacy');
|
||||
const sort = useLegacy ? 'old' : 'new';
|
||||
data.comments = await f0cklib.getComments(req.params.itemid, sort, false);
|
||||
data.isSubscribed = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
|
||||
data.commentsJSON = Buffer.from(JSON.stringify(data.comments || [])).toString('base64');
|
||||
|
||||
// xD Score
|
||||
const commentsForScore = await f0cklib.getComments(req.params.itemid, 'old', false);
|
||||
const xdScore = f0cklib.computeXdScore(commentsForScore);
|
||||
const xdMeta = f0cklib.xdScoreMeta(xdScore);
|
||||
data.item.xd_score = xdScore;
|
||||
data.item.xd_tier = xdMeta.tier;
|
||||
data.item.xd_label = xdMeta.label;
|
||||
|
||||
// Comments loaded async by client
|
||||
data.commentsJSON = null;
|
||||
data.comments = [];
|
||||
} else {
|
||||
data.comments = [];
|
||||
data.isSubscribed = false;
|
||||
data.commentsJSON = Buffer.from('[]').toString('base64');
|
||||
data.commentsJSON = null;
|
||||
data.item.xd_score = 0;
|
||||
data.item.xd_tier = 0;
|
||||
data.item.xd_label = '';
|
||||
}
|
||||
|
||||
return res.reply({ body: tpl.render('item', data, req) });
|
||||
@@ -188,11 +216,13 @@ export default (router, tpl) => {
|
||||
router.get(/^\/user_hall_image\/(?<userId>\d+)\/(?<slug>.+)$/, async (req, res) => {
|
||||
const userId = +req.params.userId;
|
||||
const slug = decodeURIComponent(req.params.slug);
|
||||
// F-016 Security: Sanitize slug to prevent path traversal
|
||||
const safeSlug = path.basename(slug);
|
||||
const mode = +(req.url.qs?.m ?? 0);
|
||||
|
||||
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
|
||||
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
|
||||
const customPath = path.join(CUSTOM_DIR, `u_${userId}_${slug}.webp`);
|
||||
const customPath = path.join(CUSTOM_DIR, `u_${userId}_${safeSlug}.webp`);
|
||||
|
||||
try {
|
||||
// 1. Serve custom image if present
|
||||
@@ -207,7 +237,7 @@ export default (router, tpl) => {
|
||||
} catch (_) { /* no custom image */ }
|
||||
|
||||
// 2. Check mosaic cache
|
||||
const hash = createHash('md5').update(`uh_${userId}_${slug}_${mode}`).digest('hex');
|
||||
const hash = createHash('md5').update(`uh_${userId}_${safeSlug}_${mode}`).digest('hex');
|
||||
const cachePath = path.join(CACHE_DIR, `${hash}.webp`);
|
||||
try {
|
||||
await fs.access(cachePath);
|
||||
@@ -316,8 +346,10 @@ export default (router, tpl) => {
|
||||
const result = await f0cklib.deleteUserHall(targetUserId, slug);
|
||||
|
||||
// Clean up custom image if it exists
|
||||
// F-016 Security: Sanitize slug to prevent path traversal in file deletion
|
||||
const safeSlug = path.basename(slug);
|
||||
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
|
||||
fs.unlink(path.join(CUSTOM_DIR, `u_${targetUserId}_${slug}.webp`)).catch(() => {});
|
||||
fs.unlink(path.join(CUSTOM_DIR, `u_${targetUserId}_${safeSlug}.webp`)).catch(() => {});
|
||||
|
||||
return res.writeHead(result.success ? 200 : 404, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify(result));
|
||||
@@ -380,12 +412,14 @@ export default (router, tpl) => {
|
||||
.end(JSON.stringify({ success: false, msg: 'Hall not found' }));
|
||||
}
|
||||
|
||||
// F-016 Security: Sanitize slug to prevent path traversal in file deletion
|
||||
const safeSlug = path.basename(slug);
|
||||
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
|
||||
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
|
||||
await fs.unlink(path.join(CUSTOM_DIR, `u_${req.session.id}_${slug}.webp`)).catch(() => {});
|
||||
await fs.unlink(path.join(CUSTOM_DIR, `u_${req.session.id}_${safeSlug}.webp`)).catch(() => {});
|
||||
// Clear mosaic cache entries for all modes
|
||||
for (const m of [0, 1, 2]) {
|
||||
const h = createHash('md5').update(`uh_${req.session.id}_${slug}_${m}`).digest('hex');
|
||||
const h = createHash('md5').update(`uh_${req.session.id}_${safeSlug}_${m}`).digest('hex');
|
||||
await fs.unlink(path.join(CACHE_DIR, `${h}.webp`)).catch(() => {});
|
||||
}
|
||||
await db`UPDATE user_halls SET custom_image = false WHERE id = ${hall.id}`;
|
||||
|
||||
@@ -52,7 +52,8 @@ export default new class {
|
||||
*/
|
||||
async recordAttempt(ip, username, type, success) {
|
||||
const ip_hash = this.hashIP(ip);
|
||||
console.log(`[SECURITY] Recording ${type} attempt: user=${username}, success=${success}, ip_hash=${ip_hash}`);
|
||||
if (!success) console.warn(`[SECURITY] Failed ${type} attempt: user=${username}, ip_hash=${ip_hash}`);
|
||||
else if (cfg.main.development) console.log(`[SECURITY] Recording ${type} attempt: user=${username}, success=${success}, ip_hash=${ip_hash}`);
|
||||
await db`
|
||||
insert into login_attempts (ip_hash, username, type, success)
|
||||
values (${ip_hash}, ${username?.toLowerCase() || null}, ${type}, ${success})
|
||||
@@ -66,7 +67,7 @@ export default new class {
|
||||
*/
|
||||
async clearAttempts(ip, username) {
|
||||
const ip_hash = this.hashIP(ip);
|
||||
console.log(`[SECURITY] Clearing attempts for user=${username}, ip_hash=${ip_hash}`);
|
||||
if (cfg.main.development) console.log(`[SECURITY] Clearing attempts for user=${username}, ip_hash=${ip_hash}`);
|
||||
await db`
|
||||
delete from login_attempts
|
||||
where (ip_hash = ${ip_hash} OR username = ${username?.toLowerCase() || ''})
|
||||
@@ -92,7 +93,7 @@ export default new class {
|
||||
|
||||
const windowStart = new Date(Date.now() - windowMinutes * 60000);
|
||||
|
||||
console.log(`[SECURITY] Checking rate limit for ${type}: user=${username}, ip_hash=${ip_hash}`);
|
||||
if (cfg.main.development) console.log(`[SECURITY] Checking rate limit for ${type}: user=${username}, ip_hash=${ip_hash}`);
|
||||
|
||||
// Check attempts by IP
|
||||
const ipAttempts = await db`
|
||||
@@ -105,7 +106,10 @@ export default new class {
|
||||
`;
|
||||
|
||||
const ipCount = +ipAttempts[0].count;
|
||||
if (ipCount >= maxAttempts) return true;
|
||||
if (ipCount >= maxAttempts) {
|
||||
console.warn(`[SECURITY] Rate limit hit for ${type}: ip_hash=${ip_hash}, attempts=${ipCount}/${maxAttempts}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check attempts by username (if provided)
|
||||
if (username) {
|
||||
@@ -118,7 +122,10 @@ export default new class {
|
||||
and attempted_at > ${windowStart}
|
||||
`;
|
||||
const userCount = +userAttempts[0].count;
|
||||
if (userCount >= maxAttempts) return true;
|
||||
if (userCount >= maxAttempts) {
|
||||
console.warn(`[SECURITY] Rate limit hit for ${type}: user=${username}, attempts=${userCount}/${maxAttempts}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -8,12 +8,19 @@ let bypass_duplicate_check = false;
|
||||
let protect_files = false;
|
||||
let private_messages = true;
|
||||
let default_layout = 'modern';
|
||||
let enable_pdf = false;
|
||||
|
||||
export const getEnablePdf = () => enable_pdf;
|
||||
export const setEnablePdf = (val) => enable_pdf = !!val;
|
||||
|
||||
export const getManualApproval = () => manual_approval;
|
||||
export const setManualApproval = (val) => manual_approval = !!val;
|
||||
|
||||
export const getMinTags = () => min_tags;
|
||||
export const setMinTags = (val) => min_tags = parseInt(val) || 3;
|
||||
export const setMinTags = (val) => {
|
||||
const parsed = parseInt(val);
|
||||
min_tags = isNaN(parsed) ? 3 : Math.max(0, parsed);
|
||||
};
|
||||
|
||||
export const getRegistrationOpen = () => {
|
||||
if (cfg.websrv.open_registration_web_toggle === false) {
|
||||
|
||||
@@ -16,7 +16,8 @@ const regex = {
|
||||
imgur: /(?:https?:)?\/\/(\w+\.)?imgur\.com\/\S+/i,
|
||||
fourchan: /https?:\/\/i\.4cdn\.org\/(\w+)\/(\d+)\.(\w{3,4})/i,
|
||||
instagram: /(?:https?:\/\/www\.)?instagram\.com\S*?\/(?:p|reel)\/(\w{11})\/?/im,
|
||||
ph: /(?:https?:\/\/)?(?:\w+\.)?pornhub\.(?:com|org)\/view_video\.php\?viewkey=([\w-]+)/i
|
||||
ph: /(?:https?:\/\/)?(?:\w+\.)?pornhub\.(?:com|org)\/view_video\.php\?viewkey=([\w-]+)/i,
|
||||
vocaroo: /(?:https?:\/\/)?(?:www\.)?(?:vocaroo\.com|voca\.ro)\/([a-zA-Z0-9_-]+)/i
|
||||
};
|
||||
const pcUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36";
|
||||
const mediagroupids = new Set();
|
||||
@@ -444,7 +445,7 @@ export default async bot => {
|
||||
else if (link.match(regex.ph)) {
|
||||
try {
|
||||
// Added referer to fix fragment 404 errors, removed -vU to avoid exit code 100
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, '--no-playlist', '--referer', 'https://www.pornhub.com', '--user-agent', pcUA, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, '--no-playlist', '--referer', 'https://www.pornhub.com', '--user-agent', pcUA, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err) {
|
||||
console.error('Pornhub dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
@@ -455,7 +456,8 @@ export default async bot => {
|
||||
}
|
||||
else if (link.match(regex.instagram)) {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Instagram dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
@@ -466,7 +468,7 @@ export default async bot => {
|
||||
}
|
||||
else if (link.match(regex.imgur)) {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / b[height<=1080]', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / b[height<=1080]', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err) {
|
||||
console.warn(`[PARSER] Imgur Stage 1 (yt-dlp) failed: ${err.message}. Retrying with curl...`);
|
||||
|
||||
@@ -528,7 +530,7 @@ export default async bot => {
|
||||
}
|
||||
else if (link.match(regex.yt)) {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '-I', '1', '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '-I', '1', '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err) {
|
||||
console.error('YouTube dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
@@ -539,7 +541,7 @@ export default async bot => {
|
||||
}
|
||||
else if (link.match(regex.fourchan)) {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err) {
|
||||
console.error('4chan dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
@@ -550,7 +552,7 @@ export default async bot => {
|
||||
}
|
||||
else {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
source = (await queue.spawn('yt-dlp', [...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err) {
|
||||
console.error('General dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { handleEmojiUpload } from "./emoji_upload_handler.mjs";
|
||||
import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs";
|
||||
import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
||||
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout } from "./inc/settings.mjs";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf } from "./inc/settings.mjs";
|
||||
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
||||
import { createI18n } from "./inc/i18n.mjs";
|
||||
|
||||
@@ -234,7 +234,7 @@ process.on('uncaughtException', err => {
|
||||
|
||||
if (req.cookies.session) {
|
||||
const 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
|
||||
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".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"
|
||||
left join "user" on "user".id = "user_sessions".user_id
|
||||
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||
@@ -352,8 +352,10 @@ process.on('uncaughtException', err => {
|
||||
embed_youtube_in_comments: user[0].embed_youtube_in_comments ?? (cfg.websrv.embed_youtube_in_comments !== false),
|
||||
hide_koepfe: user[0].hide_koepfe ?? false,
|
||||
language: (user[0].language && user[0].language.trim()) ? user[0].language.trim() : null,
|
||||
use_alternative_infobox: user[0].use_alternative_infobox ?? (cfg.websrv.user_alternative_infobox !== false)
|
||||
}, 'user_id', 'mode', 'theme', 'fullscreen', 'excluded_tags', 'font', 'disable_autoplay', 'disable_swiping', 'show_background', 'ruffle_volume', 'ruffle_background', 'quote_emojis', 'embed_youtube_in_comments', 'hide_koepfe', 'language', 'use_alternative_infobox')
|
||||
use_alternative_infobox: user[0].use_alternative_infobox ?? (cfg.websrv.user_alternative_infobox !== false),
|
||||
comment_display_mode: user[0].comment_display_mode ?? (cfg.websrv.default_comment_display_mode || 0),
|
||||
force_comment_display_mode: user[0].force_comment_display_mode ?? 0
|
||||
}, 'user_id', 'mode', 'theme', 'fullscreen', 'excluded_tags', 'font', 'disable_autoplay', 'disable_swiping', 'show_background', 'ruffle_volume', 'ruffle_background', 'quote_emojis', 'embed_youtube_in_comments', 'hide_koepfe', 'language', 'use_alternative_infobox', 'comment_display_mode', 'force_comment_display_mode')
|
||||
}
|
||||
on conflict ("user_id") do update set
|
||||
theme = excluded.theme,
|
||||
@@ -370,6 +372,8 @@ process.on('uncaughtException', err => {
|
||||
hide_koepfe = excluded.hide_koepfe,
|
||||
language = excluded.language,
|
||||
use_alternative_infobox = excluded.use_alternative_infobox,
|
||||
comment_display_mode = excluded.comment_display_mode,
|
||||
force_comment_display_mode = excluded.force_comment_display_mode,
|
||||
user_id = excluded.user_id
|
||||
`.catch(e => console.error('[MIDDLEWARE] Options sync failed:', e));
|
||||
}
|
||||
@@ -635,6 +639,10 @@ process.on('uncaughtException', err => {
|
||||
console.warn(`[BOOT] Trusted Uploads fetch failed:`, e.message);
|
||||
}
|
||||
|
||||
// Set enable_pdf from config (pure config setting)
|
||||
setEnablePdf(!!cfg.enable_pdf);
|
||||
console.log(`[BOOT] Enable PDF setting: ${getEnablePdf()}`);
|
||||
|
||||
// Load bypass_duplicate_check from config.json (static — not a DB setting)
|
||||
if (cfg.websrv.bypass_duplicate_check === true) {
|
||||
setBypassDuplicateCheck(true);
|
||||
@@ -736,6 +744,7 @@ process.on('uncaughtException', err => {
|
||||
themes_json: JSON.stringify(cfg.websrv.themes || []),
|
||||
enable_profile_description: !!cfg.websrv.enable_profile_description,
|
||||
get private_messages() { return getPrivateMessages(); },
|
||||
get enable_pdf() { return getEnablePdf(); },
|
||||
matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false,
|
||||
ts: Date.now(),
|
||||
get default_layout() { return getDefaultLayout(); },
|
||||
@@ -751,6 +760,7 @@ process.on('uncaughtException', err => {
|
||||
allowed_comment_images: cfg.websrv.allowed_comment_images || [],
|
||||
allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []),
|
||||
paths_images: cfg.websrv.paths?.images || '/b',
|
||||
default_comment_display_mode: cfg.websrv.default_comment_display_mode || 0,
|
||||
|
||||
get fonts() {
|
||||
try {
|
||||
@@ -818,7 +828,12 @@ process.on('uncaughtException', err => {
|
||||
data = Object.assign({}, globals, data || {}, {
|
||||
t: perRequestT,
|
||||
lang: perRequestLang,
|
||||
user_alternative_infobox: useAltInfobox
|
||||
user_alternative_infobox: useAltInfobox,
|
||||
comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number')
|
||||
? req.session.comment_display_mode
|
||||
: (data && typeof data.comment_display_mode === 'number'
|
||||
? data.comment_display_mode
|
||||
: (cfg.websrv.default_comment_display_mode || 0))
|
||||
});
|
||||
|
||||
// Random brand image per-render
|
||||
@@ -853,4 +868,24 @@ process.on('uncaughtException', err => {
|
||||
|
||||
app.listen(cfg.websrv.port);
|
||||
|
||||
// F-015 Security: Periodic session cleanup — purge sessions unused for 30 days
|
||||
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
||||
const CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // every 6 hours
|
||||
|
||||
const cleanupStaleSessions = async () => {
|
||||
try {
|
||||
const cutoff = ~~(Date.now() / 1e3) - SESSION_TTL_SECONDS;
|
||||
const result = await db`DELETE FROM user_sessions WHERE last_used <= ${cutoff}`;
|
||||
if (result.count > 0) {
|
||||
console.log(`[SESSION CLEANUP] Purged ${result.count} stale sessions (unused >30 days)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SESSION CLEANUP] Failed:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Run once after startup (30s delay to let DB settle), then every 6 hours
|
||||
setTimeout(cleanupStaleSessions, 30_000);
|
||||
setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
||||
|
||||
})();
|
||||
|
||||
@@ -148,6 +148,12 @@ export const handleRethumbUpload = async (req, res, itemId) => {
|
||||
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
|
||||
try {
|
||||
await db`SELECT pg_notify('rethumb', ${JSON.stringify({ item_id: item.id })})`;
|
||||
} catch (err) {
|
||||
console.error('[RETHUMB HANDLER] SSE notify error:', err);
|
||||
}
|
||||
|
||||
console.log('[RETHUMB HANDLER] Custom thumbnail applied to item', item.id);
|
||||
return sendJson(res, {
|
||||
success: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ import cfg from "./inc/config.mjs";
|
||||
import queue from "./inc/queue.mjs";
|
||||
import path from "path";
|
||||
import https from "https";
|
||||
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck } from "./inc/settings.mjs";
|
||||
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf } from "./inc/settings.mjs";
|
||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
|
||||
// Helper for JSON response
|
||||
@@ -171,6 +171,11 @@ export const handleUpload = async (req, res, self) => {
|
||||
return sendJson(res, { success: false, msg: `Invalid file type detected: ${actualMime}` }, 400);
|
||||
}
|
||||
|
||||
if (actualMime === 'application/pdf' && !getEnablePdf()) {
|
||||
await fs.unlink(tmpPath).catch(() => { });
|
||||
return sendJson(res, { success: false, msg: 'PDF uploads are currently disabled.' }, 403);
|
||||
}
|
||||
|
||||
// Reclassify audio-only MP4 containers (e.g. .m4a files detected as video/mp4)
|
||||
if (actualMime === 'video/mp4' || actualMime === 'video/quicktime') {
|
||||
const origExt = file.filename.split('.').pop().toLowerCase();
|
||||
@@ -347,6 +352,7 @@ export const handleUpload = async (req, res, self) => {
|
||||
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[UPLOAD WARNING] genThumbnail failed for item ${itemid} (falling back to placeholder):`, err.message);
|
||||
// Fallback to placeholder for thumbnail ONLY if it hasn't been processed yet
|
||||
if (!thumbProcessed) {
|
||||
const tPath = !isPending
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
<li><a href="/admin/motd">MOTD Manager</a></li>
|
||||
<li><a href="/admin/about">About Page</a></li>
|
||||
<li><a href="/admin/rules">Rules Page</a></li>
|
||||
<li><a href="/admin/chat">Global Chat Manager</a></li>
|
||||
</ul>
|
||||
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
|
||||
|
||||
|
||||
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
|
||||
|
||||
@@ -55,7 +58,7 @@
|
||||
<label style="display: block; font-weight: bold; color: var(--accent);">Minimum Tags</label>
|
||||
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Minimum number of tags required per upload.</p>
|
||||
</div>
|
||||
<input type="number" id="min_tags_input" value="{{ min_tags }}" min="1" max="20" style="width: 60px; background: #333; border: 1px solid #444; color: #fff; padding: 5px; border-radius: 4px; text-align: center;" onchange="saveAdminSettings()">
|
||||
<input type="number" id="min_tags_input" value="{{ min_tags }}" min="0" max="20" style="width: 60px; background: #333; border: 1px solid #444; color: #fff; padding: 5px; border-radius: 4px; text-align: center;" onchange="saveAdminSettings()">
|
||||
</div>
|
||||
|
||||
<div class="settings-item" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
|
||||
@@ -90,6 +93,7 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
async function saveAdminSettings() {
|
||||
const status = document.getElementById('settings-status');
|
||||
const approvalToggle = document.getElementById('manual_approval_toggle');
|
||||
@@ -171,6 +175,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
223
views/admin/chat.html
Normal file
223
views/admin/chat.html
Normal file
@@ -0,0 +1,223 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main">
|
||||
<div class="container">
|
||||
<h1>ADMINBEREICH</h1>
|
||||
<h5>Global Chat Management</h5>
|
||||
<a href="/admin" style="font-size: 0.8em; color: var(--accent); text-decoration: none;"><i class="fa-solid fa-arrow-left"></i> Back to Dashboard</a>
|
||||
<hr>
|
||||
|
||||
<div class="chat-cheatsheet" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; margin-bottom: 20px;">
|
||||
<label style="display: block; font-weight: bold; color: var(--accent); margin-bottom: 10px;">Global Chat Cheat Sheet</label>
|
||||
<p style="font-size: 0.85em; color: #ccc; margin-bottom: 10px;">Admin commands for the global chat widget.</p>
|
||||
<table style="width: 100%; font-size: 0.85em; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">
|
||||
<th style="text-align: left; padding: 8px 0; color: #fff;">Command</th>
|
||||
<th style="text-align: left; padding: 8px 0; color: #fff;">Description</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0;"><code style="color: var(--accent);">/clear</code></td>
|
||||
<td style="padding: 8px 0;">Deletes all messages and restarts history.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0;"><code style="color: var(--accent);">/setbackground <url> [opts]</code></td>
|
||||
<td style="padding: 8px 0;">Sets a background image. Opts: <code style="color: #aaa;">center / cover no-repeat</code> etc.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0;"><code style="color: var(--accent);">/clearbg</code></td>
|
||||
<td style="padding: 8px 0;">Removes the chat background.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0;"><code style="color: var(--accent);">/settopic <text></code></td>
|
||||
<td style="padding: 8px 0;">Sets the pinned topic at the top of the chat.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0;"><code style="color: var(--accent);">/cleartopic</code></td>
|
||||
<td style="padding: 8px 0;">Removes the pinned topic.</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="chat-bg-tool" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; margin-bottom: 20px;">
|
||||
<label style="display: block; font-weight: bold; color: var(--accent); margin-bottom: 10px;">Chat Background Builder</label>
|
||||
<p style="font-size: 0.85em; color: #ccc; margin-bottom: 10px;">Craft the perfect background command or apply it instantly.</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<input type="text" id="bg_url" placeholder="Image URL (must be from allowed host)" style="width: 100%; background: #333; border: 1px solid #444; color: #fff; padding: 8px; border-radius: 4px; outline: none;">
|
||||
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<select id="bg_pos" style="background: #333; border: 1px solid #444; color: #fff; padding: 8px; border-radius: 4px; cursor: pointer; outline: none;">
|
||||
<option value="center">Position: Center</option>
|
||||
<option value="top">Position: Top</option>
|
||||
<option value="bottom">Position: Bottom</option>
|
||||
<option value="left">Position: Left</option>
|
||||
<option value="right">Position: Right</option>
|
||||
<option value="top left">Position: Top Left</option>
|
||||
<option value="top right">Position: Top Right</option>
|
||||
<option value="bottom left">Position: Bottom Left</option>
|
||||
<option value="bottom right">Position: Bottom Right</option>
|
||||
</select>
|
||||
|
||||
<div style="display: flex; gap: 5px; flex-grow: 1; min-width: 150px;">
|
||||
<select id="bg_size_presets" onchange="document.getElementById('bg_size').value = this.value; updateBgCommand();" style="background: #333; border: 1px solid #444; color: #fff; padding: 8px; border-radius: 4px 0 0 4px; cursor: pointer; outline: none; flex-shrink: 0;">
|
||||
<option value="cover">Size: Cover</option>
|
||||
<option value="contain">Size: Contain</option>
|
||||
<option value="auto">Size: Auto</option>
|
||||
<option value="100% 100%">Size: Stretch</option>
|
||||
<option value="">Custom...</option>
|
||||
</select>
|
||||
<input type="text" id="bg_size" value="cover" placeholder="e.g. 50% or 200px" style="background: #333; border: 1px solid #444; border-left: 0; color: #fff; padding: 8px; border-radius: 0 4px 4px 0; outline: none; flex-grow: 1;" oninput="updateBgCommand()">
|
||||
</div>
|
||||
|
||||
<select id="bg_repeat" style="background: #333; border: 1px solid #444; color: #fff; padding: 8px; border-radius: 4px; cursor: pointer; outline: none;">
|
||||
<option value="no-repeat">Repeat: No</option>
|
||||
<option value="repeat">Repeat: Yes</option>
|
||||
<option value="repeat-x">Repeat: X only</option>
|
||||
<option value="repeat-y">Repeat: Y only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 10px; background: rgba(0,0,0,0.3); padding: 10px; border-radius: 4px; font-family: monospace; font-size: 0.9em;">
|
||||
<span style="color: #888; white-space: nowrap;">Command:</span>
|
||||
<code id="bg_command_output" style="color: var(--accent); white-space: nowrap; overflow-x: auto; flex-grow: 1;">/setbackground center / cover no-repeat</code>
|
||||
<button onclick="copyBgCommand()" style="background: #444; border: 0; color: #fff; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 0.8em; font-weight: bold; transition: background 0.2s;" onmouseover="this.style.background='#555'" onmouseout="this.style.background='#444'">Copy</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button onclick="applyBgFromAdmin()" id="apply_bg_btn" style="flex-grow: 1; background: var(--accent); border: 0; color: #000; padding: 12px; border-radius: 4px; cursor: pointer; font-weight: bold; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.9'" onmouseout="this.style.opacity='1'">Apply Instantly</button>
|
||||
<button onclick="clearBgFromAdmin()" style="background: #d9534f; border: 0; color: #fff; padding: 12px 20px; border-radius: 4px; cursor: pointer; font-weight: bold; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.9'" onmouseout="this.style.opacity='1'">Clear Background</button>
|
||||
</div>
|
||||
</div>
|
||||
<span id="chat-status" style="display: block; margin-top: 10px; font-size: 0.8em; font-weight: bold; text-align: right;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Chat Background Builder Logic
|
||||
window.builder_url = "";
|
||||
window.builder_pos = "";
|
||||
window.builder_size = "";
|
||||
window.builder_repeat = "";
|
||||
window.builder_opts = "";
|
||||
window.builder_cmd = "";
|
||||
|
||||
function updateBgCommand() {
|
||||
var builder_uEl = document.getElementById('bg_url');
|
||||
var builder_pEl = document.getElementById('bg_pos');
|
||||
var builder_sEl = document.getElementById('bg_size');
|
||||
var builder_rEl = document.getElementById('bg_repeat');
|
||||
var builder_oEl = document.getElementById('bg_command_output');
|
||||
|
||||
if (!builder_uEl || !builder_pEl || !builder_sEl || !builder_rEl || !builder_oEl) return { url: '', opts: '' };
|
||||
|
||||
window.builder_url = builder_uEl.value.trim();
|
||||
window.builder_pos = builder_pEl.value;
|
||||
window.builder_size = builder_sEl.value;
|
||||
window.builder_repeat = builder_rEl.value;
|
||||
|
||||
window.builder_opts = window.builder_pos + " / " + window.builder_size + " " + window.builder_repeat;
|
||||
window.builder_cmd = "/setbackground " + window.builder_url + " " + window.builder_opts;
|
||||
builder_oEl.textContent = window.builder_cmd;
|
||||
return { url: window.builder_url, opts: window.builder_opts };
|
||||
}
|
||||
|
||||
if (document.getElementById('bg_url')) {
|
||||
document.getElementById('bg_url').addEventListener('input', updateBgCommand);
|
||||
document.getElementById('bg_pos').addEventListener('change', updateBgCommand);
|
||||
document.getElementById('bg_size').addEventListener('change', updateBgCommand);
|
||||
document.getElementById('bg_repeat').addEventListener('change', updateBgCommand);
|
||||
}
|
||||
|
||||
function copyBgCommand() {
|
||||
const outputElem = document.getElementById('bg_command_output');
|
||||
if (!outputElem) return;
|
||||
const cmd = outputElem.textContent;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(cmd);
|
||||
const status = document.getElementById('chat-status');
|
||||
if (status) {
|
||||
status.textContent = 'Command copied to clipboard!';
|
||||
status.style.color = 'var(--accent)';
|
||||
setTimeout(() => { status.textContent = ''; }, 2000);
|
||||
}
|
||||
} else {
|
||||
alert('Clipboard access denied or not supported.');
|
||||
}
|
||||
}
|
||||
|
||||
async function applyBgFromAdmin() {
|
||||
const { url, opts } = updateBgCommand();
|
||||
if (!url) return alert('Please enter an image URL first.');
|
||||
|
||||
const btn = document.getElementById('apply_bg_btn');
|
||||
const status = document.getElementById('chat-status');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Applying...';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/chat/background', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': '{{ csrf_token }}'
|
||||
},
|
||||
body: new URLSearchParams({ url, opts }).toString()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
if (status) {
|
||||
status.textContent = 'Background applied successfully!';
|
||||
status.style.color = '#28a745';
|
||||
}
|
||||
if (btn) btn.textContent = 'Applied!';
|
||||
setTimeout(() => {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Apply Instantly';
|
||||
}
|
||||
if (status) status.textContent = '';
|
||||
}, 3000);
|
||||
} else {
|
||||
throw new Error(data.msg || 'Failed to apply background');
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Apply Instantly';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function clearBgFromAdmin() {
|
||||
if (!confirm('Are you sure you want to clear the chat background?')) return;
|
||||
const status = document.getElementById('chat-status');
|
||||
try {
|
||||
const res = await fetch('/api/chat/background', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': '{{ csrf_token }}'
|
||||
},
|
||||
body: new URLSearchParams({}).toString()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
if (status) {
|
||||
status.textContent = 'Background cleared!';
|
||||
status.style.color = '#28a745';
|
||||
}
|
||||
const urlInput = document.getElementById('bg_url');
|
||||
if (urlInput) urlInput.value = '';
|
||||
updateBgCommand();
|
||||
setTimeout(() => { if (status) status.textContent = ''; }, 3000);
|
||||
}
|
||||
} catch (err) { alert(err.message); }
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
@@ -39,7 +39,7 @@
|
||||
<script>
|
||||
(() => {
|
||||
var i18n = window.f0ckI18n || {};
|
||||
console.log('[MEME_ADMIN] Initializing');
|
||||
window.f0ckDebug('[MEME_ADMIN] Initializing');
|
||||
const esc = (s) => (s || '').toString().replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
const loadMemes = async () => {
|
||||
try {
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
const addMeme = async (e) => {
|
||||
if (e) e.preventDefault();
|
||||
console.log('[MEME_ADMIN] addMeme triggered');
|
||||
window.f0ckDebug('[MEME_ADMIN] addMeme triggered');
|
||||
|
||||
const template_id = document.getElementById('meme-id').value;
|
||||
const name = document.getElementById('meme-name').value;
|
||||
@@ -145,7 +145,7 @@
|
||||
|
||||
const btnAddMeme = document.getElementById('add-meme');
|
||||
if (btnAddMeme) {
|
||||
console.log('[MEME_ADMIN] Registering click listener');
|
||||
window.f0ckDebug('[MEME_ADMIN] Registering click listener');
|
||||
btnAddMeme.addEventListener('click', addMeme);
|
||||
} else {
|
||||
console.error('[MEME_ADMIN] Add button not found!');
|
||||
|
||||
@@ -27,10 +27,10 @@
|
||||
<script>
|
||||
const loadTokens = async () => {
|
||||
try {
|
||||
console.log('Loading tokens...');
|
||||
window.f0ckDebug('Loading tokens...');
|
||||
const res = await fetch('/api/v2/admin/tokens');
|
||||
const data = await res.json();
|
||||
console.log('Tokens data:', data);
|
||||
window.f0ckDebug('Tokens data:', data);
|
||||
if (data.success) {
|
||||
const tbody = document.getElementById('token-list');
|
||||
if (!tbody) return;
|
||||
@@ -57,14 +57,14 @@
|
||||
};
|
||||
|
||||
const generateToken = async () => {
|
||||
console.log('Generating...');
|
||||
window.f0ckDebug('Generating...');
|
||||
try {
|
||||
const res = await fetch('/api/v2/admin/tokens/create', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
|
||||
});
|
||||
const data = await res.json();
|
||||
console.log('Gen result:', data);
|
||||
window.f0ckDebug('Gen result:', data);
|
||||
if (data.success) {
|
||||
loadTokens();
|
||||
} else {
|
||||
|
||||
@@ -313,6 +313,57 @@
|
||||
}, { hideReason: false, allowEmpty: true, confirmText: 'Set Nick', placeholder: currentDisplay || 'e.g. F.O.O' });
|
||||
}
|
||||
|
||||
async function adminLockLayout(btn) {
|
||||
var id = btn.dataset.id;
|
||||
var userName = btn.dataset.name;
|
||||
var isLocked = btn.dataset.locked === '1';
|
||||
var currentMode = btn.dataset.mode || '0';
|
||||
|
||||
if (isLocked) {
|
||||
ModAction.confirm('Unlock Layout', 'Unlock comment layout for <strong>' + escHTML(userName) + '</strong>? They will be able to change it again.', async () => {
|
||||
var res = await fetch('/api/v2/admin/users/lock-layout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: id, lock: false })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success) {
|
||||
showFlash('Layout unlocked for ' + escHTML(userName), 'success');
|
||||
btn.dataset.locked = '0';
|
||||
btn.innerHTML = '<i class="fa fa-lock"></i> Lock';
|
||||
btn.title = 'Lock Layout';
|
||||
} else {
|
||||
throw new Error(data.msg || 'Failed to unlock layout');
|
||||
}
|
||||
}, { hideReason: true });
|
||||
} else {
|
||||
var hint = 'Select comment display mode to force for <strong>' + escHTML(userName) + '</strong>:<br><br>' +
|
||||
'<select id="force-mode-select" class="input" style="width: 100%; padding: 8px;">' +
|
||||
'<option value="0" ' + (currentMode == '0' ? 'selected' : '') + '>Tree</option>' +
|
||||
'<option value="1" ' + (currentMode == '1' ? 'selected' : '') + '>Linear</option>' +
|
||||
'</select>';
|
||||
|
||||
ModAction.confirm('Lock Layout', hint, async () => {
|
||||
var mode = document.getElementById('force-mode-select').value;
|
||||
var res = await fetch('/api/v2/admin/users/lock-layout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: id, lock: true, mode: mode })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success) {
|
||||
showFlash('Layout locked to ' + (mode == '0' ? 'Tree' : 'Linear') + ' for ' + escHTML(userName), 'success');
|
||||
btn.dataset.locked = '1';
|
||||
btn.dataset.mode = mode;
|
||||
btn.innerHTML = '<i class="fa fa-lock-open"></i> Unlock';
|
||||
btn.title = 'Unlock Layout';
|
||||
} else {
|
||||
throw new Error(data.msg || 'Failed to lock layout');
|
||||
}
|
||||
}, { hideReason: true, confirmText: 'Lock & Apply' });
|
||||
}
|
||||
}
|
||||
|
||||
var currentPage = {!! page !!};
|
||||
var hasMore = {!! hasMore ? 'true' : 'false' !!};
|
||||
var isLoading = false;
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
@if(u.id && u.login !== 'deleted_user')
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" data-display="{!! u.display_name || '' !!}" onclick="adminSetDisplayName(this)" class="btn-modern btn-nick" style="background: rgba(100, 200, 100, 0.1); color: #64c864; border: 1px solid rgba(100, 200, 100, 0.2);" title="Set stylized display name">✏ Nick</button>
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminSetPassword(this)" class="btn-modern btn-pw" style="background: rgba(var(--accent-rgb, 0, 150, 255), 0.1); color: var(--accent, #0096ff); border: 1px solid rgba(var(--accent-rgb, 0, 150, 255), 0.2);">Set PW</button>
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" data-locked="{{ u.force_comment_display_mode }}" data-mode="{{ u.comment_display_mode }}" onclick="adminLockLayout(this)" class="btn-modern" style="background: rgba(255, 100, 0, 0.1); color: #ff6400; border: 1px solid rgba(255, 100, 0, 0.2);" title="{{ u.force_comment_display_mode ? 'Unlock Layout' : 'Lock Layout' }}"><i class="fa fa-{{ u.force_comment_display_mode ? 'lock-open' : 'lock' }}"></i> {{ u.force_comment_display_mode ? 'Unlock' : 'Lock' }}</button>
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminDeleteUser(this)" class="btn-modern btn-delete" style="background: rgba(217, 83, 79, 0.1); color: #d9534f; border: 1px solid rgba(217, 83, 79, 0.2);">Delete</button>
|
||||
@elseif(u.login === 'deleted_user')
|
||||
<span style="font-size: 0.8rem; color: #666; font-style: italic; padding: 5px 10px;">Protected System Account</span>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
<div class="blahlol">
|
||||
@if(user_alternative_infobox)
|
||||
<div class="user-infobox-block" style="--author-accent: @if(item.author_color){{ item.author_color }}@else var(--acent) @endif; --author-border: @if(item.author_color){{ item.author_color }}@else var(--accent) @endif;">
|
||||
<div class="user-infobox-block" style="--author-accent: @if(item.author_color){{ item.author_color }}@else var(--accent) @endif; --author-border: @if(item.author_color){{ item.author_color }}@else var(--accent) @endif;">
|
||||
|
||||
<div class="user-infobox-avatar">
|
||||
<a href="/user/{{ (item.username || '').toLowerCase() }}">
|
||||
|
||||
@@ -13,11 +13,19 @@
|
||||
@each(pending as post)
|
||||
<div class="approval-card">
|
||||
<div class="approval-card-media">
|
||||
@if(post.mime.startsWith('video'))
|
||||
@if(post.mime === 'video/youtube')
|
||||
<div class="embed-responsive embed-responsive-16by9" style="width: 100%;">
|
||||
<iframe class="embed-responsive-item" src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
@elseif(post.mime.startsWith('video'))
|
||||
<video controls loop muted preload="metadata">
|
||||
<source src="/mod/pending/b/{!! post.dest !!}" type="{!! post.mime !!}">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
@elseif(post.mime === 'application/pdf')
|
||||
<div class="embed-responsive embed-responsive-16by9" style="width: 100%;">
|
||||
<iframe class="embed-responsive-item" src="/mod/pending/b/{!! post.dest !!}#toolbar=0" loading="lazy" style="border: none;"></iframe>
|
||||
</div>
|
||||
@else
|
||||
<img src="/mod/pending/t/{!! post.id !!}.webp" alt="Preview">
|
||||
@endif
|
||||
@@ -34,8 +42,8 @@
|
||||
@endeach
|
||||
</div>
|
||||
<div class="approval-card-actions" style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||||
<a href="/mod/approve/?id={!! post.id !!}" class="badge badge-success btn-approve-async" style="margin: 0; text-align: center;">Approve</a>
|
||||
<a href="/mod/deny/?id={!! post.id !!}" class="badge badge-danger btn-deny-async" style="margin: 0; text-align: center;">Deny / Delete</a>
|
||||
<button data-id="{!! post.id !!}" class="badge badge-success btn-approve-async" style="margin: 0; text-align: center; border: none; cursor: pointer;">Approve</button>
|
||||
<button data-id="{!! post.id !!}" class="badge badge-danger btn-deny-async" style="margin: 0; text-align: center; border: none; cursor: pointer;">Deny / Delete</button>
|
||||
<a href="/api/v2/tags/{!! post.id !!}/toggle" class="badge btn-rating-toggle-async" style="grid-column: span 2; background: #444; color: #ccc; margin: 0; text-align: center;">Rating</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,11 +64,19 @@
|
||||
@each(trash as post)
|
||||
<div class="approval-card">
|
||||
<div class="approval-card-media">
|
||||
@if(post.mime.startsWith('video'))
|
||||
@if(post.mime === 'video/youtube')
|
||||
<div class="embed-responsive embed-responsive-16by9" style="width: 100%;">
|
||||
<iframe class="embed-responsive-item" src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen></iframe>
|
||||
</div>
|
||||
@elseif(post.mime.startsWith('video'))
|
||||
<video controls loop muted preload="metadata">
|
||||
<source src="/mod/deleted/b/{!! post.dest !!}" type="{!! post.mime !!}">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
@elseif(post.mime === 'application/pdf')
|
||||
<div class="embed-responsive embed-responsive-16by9" style="width: 100%;">
|
||||
<iframe class="embed-responsive-item" src="/mod/deleted/b/{!! post.dest !!}#toolbar=0" loading="lazy" style="border: none;"></iframe>
|
||||
</div>
|
||||
@else
|
||||
<img src="/mod/deleted/t/{!! post.id !!}.webp" style="filter: grayscale(50%);" alt="Preview">
|
||||
@endif
|
||||
@@ -80,9 +96,9 @@
|
||||
@endeach
|
||||
</div>
|
||||
<div class="approval-card-actions" style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||||
<a href="/mod/approve/?id={!! post.id !!}" class="badge badge-warning btn-approve-async" style="margin: 0; text-align: center;">Restore</a>
|
||||
<button data-id="{!! post.id !!}" class="badge badge-warning btn-approve-async" style="margin: 0; text-align: center; border: none; cursor: pointer;">Restore</button>
|
||||
@if(session.admin)
|
||||
<a href="/mod/deny/?id={!! post.id !!}" class="badge badge-danger btn-deny-async" style="margin: 0; text-align: center;">Purge</a>
|
||||
<button data-id="{!! post.id !!}" class="badge badge-danger btn-deny-async" style="margin: 0; text-align: center; border: none; cursor: pointer;">Purge</button>
|
||||
@else
|
||||
<span></span>
|
||||
@endif
|
||||
@@ -195,14 +211,18 @@
|
||||
document.querySelectorAll('.btn-deny-async').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const url = btn.getAttribute('href');
|
||||
const itemId = btn.getAttribute('data-id');
|
||||
const card = btn.closest('.approval-card');
|
||||
showModal('{!! t('mod.confirm_action') !!}', '{!! t('mod.confirm_action') !!}?', async (reason) => {
|
||||
const res = await fetch(url + (url.indexOf('?') > -1 ? '&' : '?') + 'reason=' + encodeURIComponent(reason), {
|
||||
const res = await fetch('/mod/deny', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token || ''
|
||||
},
|
||||
body: JSON.stringify({ id: itemId, reason })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
@@ -221,15 +241,19 @@
|
||||
document.querySelectorAll('.btn-approve-async').forEach(btn => {
|
||||
btn.addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
const url = btn.getAttribute('href');
|
||||
const card = btn.closest('.approval-card'); // Updated selector
|
||||
const itemId = btn.getAttribute('data-id');
|
||||
const card = btn.closest('.approval-card');
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
const res = await fetch('/mod/approve', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token || ''
|
||||
},
|
||||
body: JSON.stringify({ id: itemId })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
@@ -303,7 +327,12 @@
|
||||
if (btnPurgeTrash) {
|
||||
btnPurgeTrash.addEventListener('click', () => {
|
||||
showModal('Purge Trash', 'Permanently delete ALL items in the trash? This cannot be undone.', async () => {
|
||||
const res = await fetch('/mod/purge-trash-all', { method: 'POST' });
|
||||
const res = await fetch('/mod/purge-trash-all', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token || ''
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
|
||||
@@ -166,7 +166,12 @@ window.expandItem = function(e, id) {
|
||||
const mime = r.resolved_item_mime || '';
|
||||
const src = '/b/' + r.resolved_item_dest;
|
||||
const baseStyle = 'max-height: 250px; border: 1px solid #333; border-radius: 4px;';
|
||||
if (mime.startsWith('image/')) {
|
||||
if (mime === 'video/youtube') {
|
||||
const ytId = r.resolved_item_dest.replace('yt:', '');
|
||||
previewHtml = '<div><iframe width="444" height="250" src="https://www.youtube.com/embed/' + ytId + '" frameborder="0" allowfullscreen style="' + baseStyle + '"></iframe></div>';
|
||||
} else if (mime === 'application/pdf') {
|
||||
previewHtml = '<div><iframe src="' + src + '#toolbar=0" style="' + baseStyle + ' width: 444px; height: 250px;" frameborder="0" allowfullscreen></iframe></div>';
|
||||
} else if (mime.startsWith('image/')) {
|
||||
previewHtml = '<div><img src="' + src + '" style="' + baseStyle + ' background: #000;"></div>';
|
||||
} else if (mime.startsWith('audio/')) {
|
||||
previewHtml = '<div><audio src="' + src + '" controls style="' + baseStyle + '"></audio></div>';
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
|
||||
// Load first page for this tab
|
||||
try {
|
||||
const res = await fetch(`/ajax/notifications?page=1&tab=${tabName}`);
|
||||
const res = await fetch('/ajax/notifications?page=1&tab=' + tabName);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
container.innerHTML = data.html || `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
|
||||
container.innerHTML = data.html || '<div class="notif-empty">' + (window.f0ckI18n?.no_notifications || 'No new notifications') + '</div>';
|
||||
const footbar = document.getElementById('footbar');
|
||||
if (footbar) {
|
||||
footbar.style.display = data.hasMore ? '' : 'none';
|
||||
|
||||
@@ -593,11 +593,18 @@
|
||||
.scroller-blur.revealed, .scroller-blur:hover { filter: none; }
|
||||
.comment-meta { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
|
||||
.comment-time { font-size: .67rem; color: rgba(255,255,255,.38); }
|
||||
.comment-reply-btn {
|
||||
.comment-reply-btn, .comment-quote-btn {
|
||||
background: none; border: none; color: rgba(255,255,255,.45); font-size: .67rem;
|
||||
font-weight: 700; cursor: pointer; padding: 0; text-transform: none;
|
||||
}
|
||||
.comment-reply-btn:hover { color: var(--accent, #fff); }
|
||||
.comment-reply-btn:hover, .comment-quote-btn:hover { color: var(--accent, #fff); }
|
||||
.comment-context-link { color: var(--accent); text-decoration: none; font-family: 'VCR', monospace; }
|
||||
.comment-context-link:hover { text-decoration: underline; }
|
||||
@keyframes comment-highlight {
|
||||
0% { background: rgba(255,255,255,.15); border-color: var(--accent); }
|
||||
100% { background: rgba(255,255,255,.03); border-color: rgba(255,255,255,.05); }
|
||||
}
|
||||
.highlight-comment { animation: comment-highlight 2.5s cubic-bezier(0.2, 0, 0, 1); }
|
||||
#reply-indicator {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; background: rgba(255,255,255,.05);
|
||||
|
||||
@@ -50,7 +50,8 @@
|
||||
<div class="profile-settings-wrapper">
|
||||
<div class="setting-item">
|
||||
<label for="profile_description">{{ t('settings.custom_description') }}</label>
|
||||
<textarea id="profile_description" class="input" placeholder="{{ t('settings.description_placeholder') }}" maxlength="255">{!! session.description || '' !!}</textarea>
|
||||
<textarea id="profile_description" class="input" placeholder="{{ t('settings.description_placeholder') }}"
|
||||
maxlength="255">{!! session.description || '' !!}</textarea>
|
||||
<div class="profile-settings-actions">
|
||||
<button type="button" id="btn-save-description" class="button">{{ t('settings.save_description') }}</button>
|
||||
<button type="button" id="btn-clear-description" class="button button-danger">{{ t('settings.clear') }}</button>
|
||||
@@ -59,9 +60,25 @@
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<div class="setting-item" style="margin-top: 20px;">
|
||||
<label for="username_color_picker" style="display: block; margin-bottom: 5px;">{{ t('settings.username_color') }}</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<input type="color" id="username_color_picker" value="{{ session.username_color || '#ffffff' }}"
|
||||
style="width: 50px; height: 30px; padding: 0; border: 1px solid var(--nav-border-color); cursor: pointer; background: none;">
|
||||
<input type="text" id="username_color_hex" value="{{ session.username_color || '#ffffff' }}" maxlength="7"
|
||||
placeholder="#ffffff" class="input"
|
||||
style="width: 90px; font-family: monospace; font-size: 0.9em; padding: 4px 8px; height: 30px;">
|
||||
<button type="button" id="btn-save-username-color" class="button"
|
||||
style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.save_color') }}</button>
|
||||
<button type="button" id="btn-reset-username-color" class="button button-secondary"
|
||||
style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.reset') }}</button>
|
||||
</div>
|
||||
<small class="text-muted">{{ t('settings.username_color_hint') }}</small>
|
||||
</div>
|
||||
<h2>{{ t('settings.preferences') }}</h2>
|
||||
<div class="preferences-settings-wrapper">
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.ui_section') }}</legend>
|
||||
<div class="setting-item" style="margin-bottom: 15px;">
|
||||
<label for="show_motd_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
@@ -78,15 +95,24 @@
|
||||
</div>
|
||||
@if(!session.use_new_layout)
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="alternative_infobox_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<label for="alternative_infobox_toggle"
|
||||
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="alternative_infobox_toggle" @if(session.use_alternative_infobox===true) checked @endif>
|
||||
<span>{{ t('settings.alternative_infobox') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.alternative_infobox_hint') }}</small>
|
||||
</div>
|
||||
@endif
|
||||
<div class="setting-item" style="margin-bottom: 15px;">
|
||||
<label for="wheel_nav_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="wheel_nav_toggle">
|
||||
<span>{{ t('settings.scroll_nav') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.scroll_nav_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="disable_autoplay_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<label for="disable_autoplay_toggle"
|
||||
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="disable_autoplay_toggle" @if(session.disable_autoplay===true) checked @endif>
|
||||
<span>{{ t('settings.disable_autoplay') }}</span>
|
||||
</label>
|
||||
@@ -114,7 +140,8 @@
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.render_emojis_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="embed_youtube_in_comments_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<label for="embed_youtube_in_comments_toggle"
|
||||
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="embed_youtube_in_comments_toggle" @if(session.embed_youtube_in_comments !==false) checked @endif>
|
||||
<span>{{ t('settings.embed_yt') }}</span>
|
||||
</label>
|
||||
@@ -137,28 +164,58 @@
|
||||
<option value="en" @if(session.language==='en' ) selected @endif>{{ t('settings.language_en') }}</option>
|
||||
<option value="de" @if(session.language==='de' ) selected @endif>{{ t('settings.language_de') }}</option>
|
||||
<option value="nl" @if(session.language==='nl' ) selected @endif>{{ t('settings.language_nl') }}</option>
|
||||
<option value="zange" @if(session.language === 'zange') selected @endif>{{ t('settings.language_zange') }}</option>
|
||||
<option value="zange" @if(session.language==='zange' ) selected @endif>{{ t('settings.language_zange') }}
|
||||
</option>
|
||||
</select>
|
||||
<br><small class="text-muted">{{ t('settings.language_hint') }}</small>
|
||||
</div>
|
||||
|
||||
@endif
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="wheel_nav_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="wheel_nav_toggle">
|
||||
<span>{{ t('settings.scroll_nav') }}</span>
|
||||
<label for="comment_display_mode_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.comment_display_mode') }}</label>
|
||||
<select id="comment_display_mode_select" class="input" style="padding: 6px 10px; max-width: 220px;" @if(session.force_comment_display_mode) disabled @endif>
|
||||
<option value="0" @if(session.comment_display_mode==0) selected @endif>{{ t('settings.comment_display_tree') }}</option>
|
||||
<option value="1" @if(session.comment_display_mode==1) selected @endif>{{ t('settings.comment_display_linear') }}</option>
|
||||
</select>
|
||||
<br><small class="text-muted">
|
||||
@if(session.force_comment_display_mode)
|
||||
<strong>{{ t('settings.forced_mode_notice') || 'This setting is managed by an administrator.' }}</strong>
|
||||
@else
|
||||
{{ t('settings.comment_display_mode_hint') }}
|
||||
@endif
|
||||
</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.notifications_section') }}</legend>
|
||||
<div class="setting-item">
|
||||
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="chk-receive-system-notifications" @if(session.receive_system_notifications !==false) checked @endif>
|
||||
<span>{{ t('settings.receive_system_notifications') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.scroll_nav_hint') }}</small>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_system_notifications_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 20px;">
|
||||
<label for="username_color_picker" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.username_color') }}</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<input type="color" id="username_color_picker" value="{{ session.username_color || '#ffffff' }}" style="width: 50px; height: 30px; padding: 0; border: 1px solid var(--nav-border-color); cursor: pointer; background: none;">
|
||||
<input type="text" id="username_color_hex" value="{{ session.username_color || '#ffffff' }}" maxlength="7" placeholder="#ffffff" class="input" style="width: 90px; font-family: monospace; font-size: 0.9em; padding: 4px 8px; height: 30px;">
|
||||
<button type="button" id="btn-save-username-color" class="button" style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.save_color') }}</button>
|
||||
<button type="button" id="btn-reset-username-color" class="button button-secondary" style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.reset') }}</button>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="chk-receive-user-notifications" @if(session.receive_user_notifications !==false) checked @endif>
|
||||
<span>{{ t('settings.receive_user_notifications') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_user_notifications_hint') }}</small>
|
||||
</div>
|
||||
<small class="text-muted">{{ t('settings.username_color_hint') }}</small>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="chk-do-not-disturb" @if(session.do_not_disturb===true) checked @endif>
|
||||
<span>{{ t('settings.do_not_disturb') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.do_not_disturb_hint') }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.appearance_section') }}</legend>
|
||||
<div class="setting-item" style="margin-top: 20px;">
|
||||
<label for="website_font_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.website_font') }}</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
@@ -181,34 +238,30 @@
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@if(enable_swf)
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.flash_section') }}</legend>
|
||||
<div class="setting-item" style="margin-bottom: 15px;">
|
||||
<label for="ruffle_volume_input" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.flash_volume') }}</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<input type="range" id="ruffle_volume_input" min="0" max="1" step="0.01" value="{{ session.ruffle_volume !== undefined && session.ruffle_volume !== null ? session.ruffle_volume : 0.5 }}" class="xd-slider" style="flex: 1; min-width: 140px;">
|
||||
<span id="ruffle_volume_val" class="xd-slider-val">{{ session.ruffle_volume !== undefined && session.ruffle_volume !== null ? Math.round(session.ruffle_volume * 100) : 50 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="ruffle_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<label for="ruffle_background_toggle"
|
||||
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="ruffle_background_toggle" @if(session.ruffle_background !==false) checked @endif>
|
||||
<span>{{ t('settings.flash_bg') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.flash_bg_hint') }}</small>
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<button type="button" id="btn-save-ruffle-settings" class="button" style="padding: 5px 15px;">{{ t('settings.save_flash') }}</button>
|
||||
<div id="ruffle-settings-status" class="avatar-status"></div>
|
||||
</div>
|
||||
<div id="ruffle-settings-status" class="avatar-status" style="margin-top: 10px;"></div>
|
||||
</fieldset>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
<h2>{{ t('settings.account') }}</h2>
|
||||
<div class="account-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<table class="table account-info-table" style="margin-bottom: 30px; border-collapse: separate; border-spacing: 0 5px;">
|
||||
<div class="account-settings-wrapper"
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<table class="table account-info-table"
|
||||
style="margin-bottom: 30px; border-collapse: separate; border-spacing: 0 5px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 150px; font-weight: bold; color: var(--text-muted); border: none;">{{ t('settings.user_id') }}</td>
|
||||
@@ -219,10 +272,14 @@
|
||||
<td style="border: none;">{!! session.user !!}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: bold; color: var(--text-muted); border: none;">{{ t('settings.display_name') }}</td>
|
||||
<td style="font-weight: bold; color: var(--text-muted); border: none;">{{ t('settings.display_name') }}
|
||||
</td>
|
||||
<td style="border: none; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="text" id="display_name_input" class="input" placeholder="{{ t('settings.display_name_placeholder') }}" value="{!! session.display_name || '' !!}" maxlength="32" style="max-width: 200px; height: 30px; font-size: 0.9em;">
|
||||
<button type="button" id="btn-update-display-name" class="button" style="padding: 2px 10px; font-size: 0.8em; height: 30px;">{{ t('settings.save') }}</button>
|
||||
<input type="text" id="display_name_input" class="input"
|
||||
placeholder="{{ t('settings.display_name_placeholder') }}" value="{!! session.display_name || '' !!}"
|
||||
maxlength="32" style="max-width: 200px; height: 30px; font-size: 0.9em;">
|
||||
<button type="button" id="btn-update-display-name" class="button"
|
||||
style="padding: 2px 10px; font-size: 0.8em; height: 30px;">{{ t('settings.save') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -237,13 +294,18 @@
|
||||
</table>
|
||||
<div id="display-name-status" class="avatar-status" style="margin-bottom: 20px;"></div>
|
||||
|
||||
<div class="account-actions-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px;">
|
||||
<div class="account-actions-grid"
|
||||
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px;">
|
||||
<div class="password-change-section" style="display: flex; flex-direction: column; gap: 15px;">
|
||||
<h4 style="margin-top: 0; color: var(--accent);">{{ t('settings.change_password') }}</h4>
|
||||
<form id="password-change-form" style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<input type="password" id="current_password" class="input" placeholder="{{ t('settings.current_password') }}" required autocomplete="current-password">
|
||||
<input type="password" id="new_password" class="input" placeholder="{{ t('settings.new_password') }}" required minlength="20" autocomplete="new-password">
|
||||
<input type="password" id="new_password_confirm" class="input" placeholder="{{ t('settings.confirm_new_password') }}" required minlength="20" autocomplete="new-password">
|
||||
<input type="password" id="current_password" class="input"
|
||||
placeholder="{{ t('settings.current_password') }}" required autocomplete="current-password">
|
||||
<input type="password" id="new_password" class="input" placeholder="{{ t('settings.new_password') }}"
|
||||
required minlength="20" autocomplete="new-password">
|
||||
<input type="password" id="new_password_confirm" class="input"
|
||||
placeholder="{{ t('settings.confirm_new_password') }}" required minlength="20"
|
||||
autocomplete="new-password">
|
||||
<button type="submit" id="btn-update-password" class="button">{{ t('settings.update_password') }}</button>
|
||||
</form>
|
||||
<div id="password-status" class="avatar-status"></div>
|
||||
@@ -252,7 +314,8 @@
|
||||
<div class="email-update-section" style="display: flex; flex-direction: column; gap: 15px;">
|
||||
<h4 style="margin-top: 0; color: var(--accent);">{{ t('settings.update_email') }}</h4>
|
||||
<form id="email-update-form" style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<input type="email" id="email_input" class="input" placeholder="{{ t('settings.new_email') }}" value="{{ email }}" required>
|
||||
<input type="email" id="email_input" class="input" placeholder="{{ t('settings.new_email') }}"
|
||||
value="{{ email }}" required>
|
||||
<button type="submit" id="btn-update-email" class="button">{{ t('settings.update_email_btn') }}</button>
|
||||
</form>
|
||||
@if(smtp_enabled)
|
||||
@@ -284,7 +347,8 @@
|
||||
|
||||
<div id="token-display-area" style="display: none; margin-bottom: 15px;">
|
||||
<div class="alert alert-info">
|
||||
<strong>{{ t('settings.your_token') }}</strong> <code id="generated-token-code" style="font-size: 1.2em; user-select: all; margin-left: 10px;"></code>
|
||||
<strong>{{ t('settings.your_token') }}</strong> <code id="generated-token-code"
|
||||
style="font-size: 1.2em; user-select: all; margin-left: 10px;"></code>
|
||||
<br><small>{{ t('settings.one_time_use') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,7 +124,8 @@
|
||||
@endif
|
||||
@if(private_society && !session)
|
||||
<script>
|
||||
window.f0ckSession = { logged_in: false, 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 };
|
||||
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 }}, development: @if(development) true @else false @endif };
|
||||
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
|
||||
(() => {
|
||||
const loginModal = document.getElementById('login-modal');
|
||||
const registerModal = document.getElementById('register-modal');
|
||||
@@ -379,13 +380,20 @@
|
||||
enable_swf: @if(enable_swf) true @else false @endif,
|
||||
enable_danmaku: @if(enable_danmaku) true @else false @endif,
|
||||
enable_global_chat: @if(enable_global_chat) true @else false @endif,
|
||||
enable_xd_score: @if(enable_xd_score) true @else false @endif,
|
||||
ruffle_volume: @if(session && session.ruffle_volume !== undefined && session.ruffle_volume !== null) {{ session.ruffle_volume }} @else 0.5 @endif,
|
||||
ruffle_background: @if(session && session.ruffle_background !== false) true @else false @endif,
|
||||
quote_emojis: @if(session && session.quote_emojis !== false) true @else false @endif,
|
||||
embed_youtube_in_comments: @if(session && session.embed_youtube_in_comments !== false) true @else false @endif,
|
||||
avatar: @if(session && session.avatar) {{ session.avatar }} @else null @endif,
|
||||
avatar_file: @if(session && session.avatar_file) "{{ session.avatar_file }}" @else null @endif
|
||||
avatar_file: @if(session && session.avatar_file) "{{ session.avatar_file }}" @else null @endif,
|
||||
receive_system_notifications: @if(session)@if(session.receive_system_notifications !== false) true @else false @endif@else true @endif,
|
||||
receive_user_notifications: @if(session)@if(session.receive_user_notifications !== false) true @else false @endif@else true @endif,
|
||||
do_not_disturb: @if(session && session.do_not_disturb) true @else false @endif,
|
||||
comment_display_mode: {{ comment_display_mode }},
|
||||
development: @if(development) true @else false @endif
|
||||
};
|
||||
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
|
||||
window.f0ckI18n = {
|
||||
write_comment: "{{ t('comments.write_comment') }}",
|
||||
post: "{{ t('comments.post') }}",
|
||||
@@ -409,6 +417,8 @@
|
||||
timeago_years: "{{ t('timeago.years') }}",
|
||||
timeago_month: "{{ t('timeago.month') }}",
|
||||
timeago_months: "{{ t('timeago.months') }}",
|
||||
timeago_week: "{{ t('timeago.week') }}",
|
||||
timeago_weeks: "{{ t('timeago.weeks') }}",
|
||||
timeago_day: "{{ t('timeago.day') }}",
|
||||
timeago_days: "{{ t('timeago.days') }}",
|
||||
timeago_hour: "{{ t('timeago.hour') }}",
|
||||
@@ -539,9 +549,9 @@
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').then((registration) => {
|
||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
window.f0ckDebug('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
}, (err) => {
|
||||
console.log('ServiceWorker registration failed: ', err);
|
||||
window.f0ckDebug('ServiceWorker registration failed: ', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
<div class="embed-responsive embed-responsive-16by9">
|
||||
<div id="ruffle-container" class="embed-responsive-item" data-swf="{{ item.dest }}"></div>
|
||||
</div>
|
||||
@elseif(item.mime === 'application/pdf' && enable_pdf)
|
||||
<div class="embed-responsive embed-responsive-16by9" style="background: #fff;border: 1px solid #444; overflow: hidden;">
|
||||
<iframe class="embed-responsive-item" src="{{ item.dest }}#toolbar=0" sandbox="allow-scripts allow-same-origin" loading="lazy" style="border: none; overscroll-behavior: contain !important;"></iframe>
|
||||
</div>
|
||||
@else
|
||||
<h1>404 - Not f0cked</h1>
|
||||
@endif
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@each(items as item)
|
||||
<a href="{{ link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" style="background-image: url('/t/{{ item.id }}.webp')">
|
||||
<a href="{{ link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
|
||||
<div class="thumb-indicators">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
|
||||
@@ -14,18 +14,18 @@
|
||||
<!-- Nav links: desktop always-on, mobile hidden until toggled -->
|
||||
<div class="nav-collapse" id="navbarContent">
|
||||
<div class="nav-links">
|
||||
<a id="nav-upload-link" style="cursor:pointer;"><i class="fa-solid fa-upload"></i> {{ t('nav.upload') }}</a>
|
||||
<a id="nav-upload-link" style="cursor:pointer;"><i class="fa-solid fa-angle-up"></i> {{ t('nav.upload') }}</a>
|
||||
@if(meme_creator)
|
||||
<a href="/meme">{{ t('nav.meme') }}</a>
|
||||
<a id="nav-meme-link" href="/meme"><i class="fa-regular fa-image"></i> {{ t('nav.meme') }}</a>
|
||||
@endif
|
||||
@if(halls_enabled)
|
||||
<div class="nav-user-dropdown nav-halls-dropdown">
|
||||
<a href="/halls" class="nav-halls-btn" title="{{ t('nav.halls') }}">
|
||||
<i class="fa-solid fa-building-columns"></i>
|
||||
<i class="fa-solid fa-layer-group"></i>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
<a href="/tags" title="{{ t('nav.tags') }}"><i class="fa-solid fa-tag"></i></a>
|
||||
<a href="/tags" title="{{ t('nav.tags') }}"><i class="fa-solid fa-tags"></i></a>
|
||||
@if(abyss_enabled)
|
||||
<a href="/abyss" title="{{ t('nav.abyss') }}"><i class="fa-solid fa-dice-d6"></i></a>
|
||||
@endif
|
||||
@@ -59,14 +59,14 @@
|
||||
@endif
|
||||
<a href="/user/{{ session.user.toLowerCase() }}/favs" class="mobile-only">{{ t('nav.favs') }}</a>
|
||||
@if(session.admin)
|
||||
<a href="/admin">Admin
|
||||
<a href="/admin">{{ t('nav.admin') }}
|
||||
@if(typeof session.pending_count !== 'undefined' && session.pending_count > 0)
|
||||
<span class="notification-dot" title="{{ session.pending_count }} Pending" onclick="event.preventDefault(); window.location.href='/admin/approve';"></span>
|
||||
@endif
|
||||
</a>
|
||||
@endif
|
||||
@if(session.admin || session.is_moderator)
|
||||
<a href="/mod">mod
|
||||
<a href="/mod">{{ t('nav.mod') }}
|
||||
@if(typeof session.pending_count !== 'undefined' && session.pending_count > 0)
|
||||
<span class="notification-dot" title="{{ session.pending_count }} Pending" onclick="event.preventDefault(); window.location.href='/mod/approve';"></span>
|
||||
@endif
|
||||
@@ -121,7 +121,7 @@
|
||||
<a href="/settings" title="Settings" class="desktop-only"><i class="fa-solid fa-gear"></i></a>
|
||||
|
||||
<!-- Filter -->
|
||||
<a href="#" id="nav-filter-btn" title="Excluded Tags"><i class="fa-solid fa-filter"></i></a>
|
||||
<a href="#" id="nav-filter-btn" title="Filter"><i class="fa-solid fa-filter"></i></a>
|
||||
|
||||
<!-- Logout -->
|
||||
<a href="/logout" title="Logout" class="desktop-only"><i class="fa-solid fa-right-from-bracket"></i></a>
|
||||
@@ -157,11 +157,11 @@
|
||||
|
||||
<div class="nav-collapse" id="navbarContent">
|
||||
<div class="nav-links">
|
||||
<a href="/tags" title="Tags"><i class="fa-solid fa-tag"></i></a>
|
||||
<a href="/tags" title="Tags"><i class="fa-solid fa-tags"></i></a>
|
||||
@if(halls_enabled)
|
||||
<div class="">
|
||||
<a href="/halls" class="nav-halls-btn" title="Halls">
|
||||
<i class="fa-solid fa-building-columns"></i>
|
||||
<i class="fa-solid fa-layer-group"></i>
|
||||
</a>
|
||||
<div class="nav-user-menu">
|
||||
<a href="/halls" style="font-weight: bold; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 5px;"><i class="fa-solid fa-building-columns"></i> Overview</a>
|
||||
@@ -357,7 +357,7 @@
|
||||
<button class="modal-close" id="drag-modal-close" title="Cancel Upload">×</button>
|
||||
<div class="modal-body">
|
||||
<div class="upload-container" style="padding: 0; animation: none; opacity: 1;">
|
||||
<h2>{{ t('upload.title') }}</h2>
|
||||
<div class="upload-title">{{ t('upload.title') }}</div>
|
||||
|
||||
<div class="upload-limit-info">
|
||||
@if(session.uploads_remaining === undefined)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<form id="upload-form" class="upload-form" enctype="multipart/form-data" data-mimes='{!! mimes_json !!}' data-max-bytes="{{ max_file_size_bytes }}">
|
||||
<form id="upload-form" class="upload-form" enctype="multipart/form-data" data-mimes='{!! mimes_json !!}' data-max-bytes="{{ max_file_size_bytes }}" data-min-tags="{{ min_tags }}">
|
||||
<div class="form-section">
|
||||
@if(web_url_upload)
|
||||
<div class="upload-mode-tabs">
|
||||
@@ -18,7 +18,6 @@
|
||||
<div class="drop-zone" id="upload-form-drop-zone">
|
||||
<input type="file" class="file-input" name="file" accept="{{ allowed_mimes }}">
|
||||
<div class="drop-zone-prompt">
|
||||
<i class="fa-solid fa-cloud-arrow-up" style="font-size: 4rem; opacity: 0.7; margin-bottom: 1rem;"></i>
|
||||
<p style="font-size: 1.1rem; font-weight: 500;">{{ t('upload.drop_here') }}</p>
|
||||
<p style="font-size: 0.9rem; opacity: 0.6;">(max {{ max_file_size }})@if(session.admin) <span style="color: var(--accent);">{{ t('upload.admin_boost') }}</span>@endif</p>
|
||||
</div>
|
||||
@@ -77,14 +76,21 @@
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label class="oc-option">
|
||||
<div class="oc-option">
|
||||
<input type="checkbox" name="is_oc" id="upload-oc-checkbox">
|
||||
<span class="oc-label">{{ t('upload.original_content') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label>{{ t('upload.tags') }} <span class="required">*</span> <span class="tag-count">(0/{{ min_tags }} {{ t('upload.tags_minimum') }})</span></label>
|
||||
<label>
|
||||
{{ t('upload.tags') }}
|
||||
@if(min_tags > 0)
|
||||
<span class="required">*</span> <span class="tag-count">(0/{{ min_tags }} {{ t('upload.tags_minimum') }})</span>
|
||||
@else
|
||||
<span style="opacity: 0.5; font-weight: normal;">{{ t('upload.comment_optional') }}</span>
|
||||
@endif
|
||||
</label>
|
||||
<div class="tag-input-container">
|
||||
<div class="sync-spinner">
|
||||
<span class="spinner-icon"></span>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div id="main">
|
||||
<link rel="stylesheet" href="/s/css/upload.css">
|
||||
<div class="upload-container" style="opacity: 0;" data-mimes='{!! mimes_json !!}'>
|
||||
<h2>{{ t('upload_page.title') }}</h2>
|
||||
<div class="upload-title">{{ t('upload.title') }}</div>
|
||||
|
||||
<div class="upload-limit-info">
|
||||
@if(uploads_remaining === null)
|
||||
|
||||
Reference in New Issue
Block a user