bvcx
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
main:
|
||||
url:
|
||||
full: https://example.com
|
||||
domain: example.com
|
||||
regex: example\.com
|
||||
onion: http://your-onion-address.onion
|
||||
socks: socks5://127.0.0.1:9050
|
||||
mail: admin@example.com
|
||||
maxfilesize: 104857600
|
||||
adminmultiplier: 3.5
|
||||
upload_limit: 300
|
||||
ignored:
|
||||
- example.net
|
||||
- example.org
|
||||
invite_secret: YOUR_SECRET_HERE
|
||||
hide_comments_from_public: false
|
||||
guest_anonymize: false
|
||||
anon_anonymize: false
|
||||
timezone: UTC
|
||||
development: true
|
||||
allowedModes:
|
||||
- sfw
|
||||
- nsfw
|
||||
- untagged
|
||||
- all
|
||||
- nsfl
|
||||
enable_pdf: false
|
||||
enable_nsfl: false
|
||||
enable_comments: true
|
||||
enable_private_uploads: true
|
||||
enable_expiring_uploads: true
|
||||
default_upload_visibility: 0
|
||||
allow_user_upload_visibility: true
|
||||
enable_item_slugs: true
|
||||
enable_anonymous_access: true
|
||||
anonymous_permissions:
|
||||
upload: false
|
||||
comment: true
|
||||
comment_attachments: false
|
||||
comment_vote: true
|
||||
poll_vote: true
|
||||
tag: true
|
||||
tag_vote: true
|
||||
favorite: true
|
||||
rate_item: false
|
||||
filter: true
|
||||
exclude_tags: true
|
||||
anonymize_users: false
|
||||
allowed_modes:
|
||||
- sfw
|
||||
- nsfw
|
||||
- untagged
|
||||
- all
|
||||
- nsfl
|
||||
allowed_mimes:
|
||||
- image
|
||||
- video
|
||||
- audio
|
||||
- flash
|
||||
- pdf
|
||||
onara: false
|
||||
nsfl_tag_id: 4
|
||||
allowedMimes:
|
||||
- audio
|
||||
- image
|
||||
- video
|
||||
- application/x-shockwave-flash
|
||||
- application/vnd.adobe.flash.movie
|
||||
- application/zip
|
||||
- application/x-zip-compressed
|
||||
- application/x-rar-compressed
|
||||
- application/vnd.rar
|
||||
- application/x-rar
|
||||
- application/rar
|
||||
- application/x-rar-archive
|
||||
- application/x-7z-compressed
|
||||
- application/x-tar
|
||||
- application/gzip
|
||||
- application/x-bzip2
|
||||
- application/x-xz
|
||||
nsfp:
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
websrv:
|
||||
port: "1337"
|
||||
language: en
|
||||
allow_language_change: true
|
||||
cache: false
|
||||
eps: 155
|
||||
default_thumb_size: m
|
||||
background: true
|
||||
log_user_ips: false
|
||||
hash_user_ips: true
|
||||
description: Example Description
|
||||
themes:
|
||||
- amoled
|
||||
theme: amoled
|
||||
default_font: ""
|
||||
default_layout: legacy
|
||||
custom_favicon: /s/img/favicon.gif
|
||||
custom_brand_image: []
|
||||
custom_navbar_brand_text: ""
|
||||
show_koepfe: false
|
||||
hide_sidebar_default: true
|
||||
koepfe: []
|
||||
enable_tor_hs: true
|
||||
enable_global_chat: true
|
||||
enable_danmaku: true
|
||||
private_messages: true
|
||||
dm_attachments: true
|
||||
dm_unencrypted: false
|
||||
dm_attachment_expiry_days: 90
|
||||
halls_enabled: true
|
||||
userhalls_enabled: true
|
||||
enable_userhall_image_upload: true
|
||||
enable_oc: true
|
||||
abyss_enabled: true
|
||||
meme_creator: true
|
||||
enable_cleanup: false
|
||||
enable_data_export: true
|
||||
inactivity_ban_days: 60
|
||||
enable_user_api_keys: true
|
||||
enable_user_invites: true
|
||||
user_invite_slots: 2
|
||||
invite_criteria:
|
||||
uploads: 10
|
||||
age_days: 10
|
||||
comments: 10
|
||||
tags: 10
|
||||
cleanup_timeframe_days: 30
|
||||
web_url_upload: true
|
||||
enable_youtube_upload: true
|
||||
web_meta_extraction: true
|
||||
bypass_duplicate_check: true
|
||||
shitpost_mode: false
|
||||
shitpost_require_rating: false
|
||||
shitpost_min_tags: 0
|
||||
protect_files: false
|
||||
enable_dynamic_thumbs: false
|
||||
allowed_comment_images:
|
||||
- i.imgur.com
|
||||
- tenor.com
|
||||
- giphy.com
|
||||
show_mime_picker: true
|
||||
embed_youtube_in_comments: true
|
||||
allow_fileupload_comments: true
|
||||
allow_comment_deletion: false
|
||||
enable_comment_polls: false
|
||||
fileupload_comments_multifile: true
|
||||
fileupload_comments_size: 104857600
|
||||
fileupload_comments_max: 5
|
||||
fileupload_comments_mode: attachment
|
||||
fileupload_comments_mimes:
|
||||
- image
|
||||
- video
|
||||
- audio
|
||||
show_content_warning: true
|
||||
default_comment_display_mode: 1
|
||||
phrases:
|
||||
- Hello World
|
||||
ban_video: ""
|
||||
enable_xd_score: false
|
||||
enable_autoplay: false
|
||||
enable_swiping: true
|
||||
enable_profile_description: true
|
||||
user_alternative_infobox: false
|
||||
user_banner_enabled: true
|
||||
comment_banner_enabled: true
|
||||
comment_banner_max_height: 300
|
||||
user_alternative_steuerung: false
|
||||
expose_repost_links_to_guests: false
|
||||
enable_swf: false
|
||||
swf_thumb: /s/img/swf.png
|
||||
enable_archive: true
|
||||
archive_thumb: /s/img/archive.webp
|
||||
enable_item_title: true
|
||||
open_registration: true
|
||||
open_registration_web_toggle: false
|
||||
open_registration_require_mail_andor_token: false
|
||||
private_society: false
|
||||
private_society_gate: cloudflare
|
||||
private_society_gate_location: Frankfurt
|
||||
private_society_gate_template: _gate_template
|
||||
public_nsfw: false
|
||||
public_untagged: false
|
||||
paths:
|
||||
images: /b
|
||||
thumbnails: /t
|
||||
coverarts: /ca
|
||||
emojis: /emojis
|
||||
memes: /memes
|
||||
clients:
|
||||
- type: tg
|
||||
enabled: false
|
||||
token: ""
|
||||
pollrate: 1001
|
||||
- type: matrix
|
||||
enabled: false
|
||||
baseUrl: https://matrix.org
|
||||
token: ""
|
||||
userId: "@user:matrix.org"
|
||||
channels: []
|
||||
upload_channel_id: ""
|
||||
notification_channel_id: ""
|
||||
- type: irc
|
||||
enabled: false
|
||||
network: example
|
||||
host: irc.example.net
|
||||
port: 6697
|
||||
ssl: true
|
||||
selfSigned: true
|
||||
sasl: false
|
||||
nickname: bot
|
||||
username: bot
|
||||
password: ""
|
||||
realname: bot
|
||||
channels:
|
||||
- "#example"
|
||||
sql:
|
||||
host: localhost
|
||||
port: 5432
|
||||
user: f0ckm
|
||||
password: f0ckm
|
||||
database: f0ckm
|
||||
multipleStatements: true
|
||||
max: 50
|
||||
admins:
|
||||
- {}
|
||||
mimes:
|
||||
image/png: png
|
||||
video/webm: webm
|
||||
image/gif: gif
|
||||
image/jpg: jpg
|
||||
image/jpeg: jpeg
|
||||
image/webp: webp
|
||||
video/mp4: mp4
|
||||
video/quicktime: mp4
|
||||
audio/mpeg: mpg
|
||||
audio/mp3: mp3
|
||||
audio/ogg: ogg
|
||||
audio/opus: opus
|
||||
audio/flac: flac
|
||||
audio/x-flac: flac
|
||||
audio/mp4: m4a
|
||||
audio/x-m4a: m4a
|
||||
audio/aac: m4a
|
||||
video/x-m4v: mp4
|
||||
video/x-matroska: mkv
|
||||
application/x-shockwave-flash: swf
|
||||
application/vnd.adobe.flash.movie: swf
|
||||
application/pdf: pdf
|
||||
application/zip: zip
|
||||
application/x-zip-compressed: zip
|
||||
application/x-rar-compressed: rar
|
||||
application/vnd.rar: rar
|
||||
application/x-rar: rar
|
||||
application/rar: rar
|
||||
application/x-rar-archive: rar
|
||||
application/x-7z-compressed: 7z
|
||||
application/x-tar: tar
|
||||
application/gzip: gz
|
||||
application/x-bzip2: bz2
|
||||
application/x-xz: xz
|
||||
apis: {}
|
||||
smtp:
|
||||
enabled: false
|
||||
host: smtp.example.com
|
||||
port: 465
|
||||
secure: true
|
||||
user: smtp_user
|
||||
password: smtp_password
|
||||
from: admin@example.com
|
||||
mail_reset_password: false
|
||||
recaptcha:
|
||||
enabled: false
|
||||
site_key: YOUR_RECAPTCHA_V2_SITE_KEY
|
||||
secret_key: YOUR_RECAPTCHA_V2_SECRET_KEY
|
||||
@@ -7317,6 +7317,20 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.location a,
|
||||
.location .location-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.location a:hover,
|
||||
.location .location-link:hover {
|
||||
color: #4ade80;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.item_title,
|
||||
.item-title,
|
||||
.item-itle {
|
||||
@@ -22425,3 +22439,159 @@ div#flash,
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chan Rehost Details Modal */
|
||||
.chan-rehost-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 100200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.chan-rehost-modal-content {
|
||||
background: #18181b;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 12px;
|
||||
width: 92%;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.7);
|
||||
padding: 22px 24px;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chan-rehost-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
.chan-rehost-modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.chan-rehost-modal-post-link {
|
||||
font-size: 0.85rem;
|
||||
color: #4ade80;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.chan-rehost-modal-post-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.chan-rehost-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 1.4rem;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
.chan-rehost-modal-close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
.chan-rehost-form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.chan-rehost-form-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
margin-bottom: 7px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.chan-rehost-ratings {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.chan-rehost-pill {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.chan-rehost-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
.chan-rehost-pill.active.untagged {
|
||||
background: #3f3f46;
|
||||
border-color: #71717a;
|
||||
color: #fff;
|
||||
}
|
||||
.chan-rehost-pill.active.sfw {
|
||||
background: #15803d;
|
||||
border-color: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
.chan-rehost-pill.active.nsfw {
|
||||
background: #b91c1c;
|
||||
border-color: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
.chan-rehost-pill.active.nsfl {
|
||||
background: #450a0a;
|
||||
border-color: #991b1b;
|
||||
color: #fca5a5;
|
||||
}
|
||||
.chan-rehost-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 9px 12px;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.88rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.chan-rehost-input:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.chan-rehost-textarea {
|
||||
resize: vertical;
|
||||
min-height: 56px;
|
||||
}
|
||||
.chan-rehost-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.chan-rehost-edit-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.chan-rehost-edit-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
+67
-5
@@ -6,7 +6,7 @@
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
const idLinkEl = document.querySelector("a.id-link");
|
||||
|
||||
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
const rawId = favoEl?.dataset?.localId || commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
if (!rawId) return null;
|
||||
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
@@ -292,17 +292,79 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
const favoBtns = document.querySelectorAll("#a_favo");
|
||||
const firstFavoBtn = favoBtns[0];
|
||||
const chanRehostBtn = document.querySelector('#chan-item-rehost-btn');
|
||||
const isChan = firstFavoBtn?.dataset?.isChan === 'true' || !!chanRehostBtn;
|
||||
const chanUrl = firstFavoBtn?.dataset?.chanUrl || chanRehostBtn?.dataset?.url;
|
||||
let localId = firstFavoBtn?.dataset?.localId;
|
||||
|
||||
// If viewing un-rehosted 4chan post, auto-rehost first
|
||||
if (isChan && !localId && chanUrl) {
|
||||
favoBtns.forEach(btn => {
|
||||
btn.className = 'iconset fa-solid fa-spinner fa-spin';
|
||||
});
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||
const rehostResp = await fetch('/api/v2/scroller/rehost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: chanUrl,
|
||||
original_filename: firstFavoBtn?.dataset?.filename || chanRehostBtn?.dataset?.filename || '',
|
||||
width: firstFavoBtn?.dataset?.width || chanRehostBtn?.dataset?.width || '',
|
||||
height: firstFavoBtn?.dataset?.height || chanRehostBtn?.dataset?.height || ''
|
||||
})
|
||||
});
|
||||
const rehostData = await rehostResp.json();
|
||||
if (rehostData.success && rehostData.item_id) {
|
||||
localId = rehostData.item_id;
|
||||
favoBtns.forEach(btn => {
|
||||
btn.dataset.itemId = localId;
|
||||
btn.dataset.localId = localId;
|
||||
});
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
if (commentsEl) commentsEl.dataset.itemId = localId;
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
if (infoEl) infoEl.dataset.itemId = localId;
|
||||
|
||||
if (chanRehostBtn) {
|
||||
chanRehostBtn.classList.add('rehosted');
|
||||
chanRehostBtn.outerHTML = `
|
||||
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
|
||||
<a href="/${localId}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #${localId})" style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;"><i class="fa-solid fa-check"></i> #${localId}</a>
|
||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="${localId}" title="Edit rating, tags & comment" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
throw new Error(rehostData.msg || 'Rehost failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CHAN-FAV-ADMIN] Auto-rehost error:', err);
|
||||
favoBtns.forEach(btn => {
|
||||
btn.className = 'iconset fa-regular fa-heart';
|
||||
});
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('Failed to favorite post', 3000, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
const postid = localId ? Number(localId) : ctx.postid;
|
||||
|
||||
// Read state BEFORE the API call so we have a fallback
|
||||
const favoBtns = document.querySelectorAll("#a_favo");
|
||||
const wasAlreadyFav = favoBtns.length > 0 && favoBtns[0].classList.contains('fa-solid');
|
||||
|
||||
try {
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: postid
|
||||
postid: postid,
|
||||
chan_url: chanUrl
|
||||
});
|
||||
if (res && res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
|
||||
+313
-21
@@ -1438,7 +1438,7 @@ window.cancelAnimFrame = (function () {
|
||||
gridCacheMap.clear();
|
||||
const isOnaraOpen = document.body.classList.contains('onara-modal-open');
|
||||
const isGridView = document.querySelector('.posts, .tags-grid');
|
||||
const isItemView = document.getElementById('prev') || document.getElementById('next') || /^\/\d+/.test(window.location.pathname);
|
||||
const isItemView = document.getElementById('prev') || document.getElementById('next') || (typeof isItemPath === 'function' ? isItemPath(window.location.pathname) : (!window.location.pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(window.location.pathname)));
|
||||
let reloadPromise = null;
|
||||
if (isOnaraOpen) {
|
||||
const bgUrl = window._onaraReturnUrl || getOnaraBaseUrl();
|
||||
@@ -1502,7 +1502,7 @@ window.cancelAnimFrame = (function () {
|
||||
gridCacheMap.clear();
|
||||
const isOnaraOpen = document.body.classList.contains('onara-modal-open');
|
||||
const isGridView = document.querySelector('.posts, .tags-grid');
|
||||
const isItemView = document.getElementById('prev') || document.getElementById('next') || /^\/\d+/.test(window.location.pathname);
|
||||
const isItemView = document.getElementById('prev') || document.getElementById('next') || (typeof isItemPath === 'function' ? isItemPath(window.location.pathname) : (!window.location.pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(window.location.pathname)));
|
||||
let reloadPromise = null;
|
||||
if (isOnaraOpen) {
|
||||
const bgUrl = window._onaraReturnUrl || getOnaraBaseUrl();
|
||||
@@ -2331,8 +2331,11 @@ window.cancelAnimFrame = (function () {
|
||||
try {
|
||||
const p = new URL(pathOrUrl, window.location.origin).pathname;
|
||||
const parts = p.split('/').filter(Boolean);
|
||||
if (parts[0] === '4') {
|
||||
return parts.length >= 4 && /^\d+$/.test(parts[3]);
|
||||
}
|
||||
return !p.match(/\/p\//) && (
|
||||
/^\/\d+/.test(p) ||
|
||||
/^\/\d+(?:[?#]|$)/.test(p) ||
|
||||
/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/.test(p) ||
|
||||
(parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
|
||||
(parts.length >= 2 && parts[0] === 'favs' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
|
||||
@@ -3334,7 +3337,16 @@ window.cancelAnimFrame = (function () {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(ajaxUrl, { credentials: 'include', priority: 'low', signal })
|
||||
fetch(ajaxUrl, {
|
||||
credentials: 'include',
|
||||
priority: 'low',
|
||||
headers: {
|
||||
'X-Purpose': 'prefetch',
|
||||
'Purpose': 'prefetch',
|
||||
'Sec-Purpose': 'prefetch'
|
||||
},
|
||||
signal
|
||||
})
|
||||
.then(r => r.ok ? r.text() : null)
|
||||
.then(rawText => {
|
||||
if (!rawText || signal.aborted) return;
|
||||
@@ -11084,6 +11096,11 @@ window.cancelAnimFrame = (function () {
|
||||
// Immediately restore scrollability and hide modals
|
||||
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
|
||||
if (window.hideAllModals) window.hideAllModals();
|
||||
const targetPath = new URL(url, window.location.origin).pathname;
|
||||
if (targetPath.startsWith('/4/')) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
const willNavigateToItem = typeof isItemPath === 'function' ? isItemPath(url) : false;
|
||||
if (!isOnaraActive() || !willNavigateToItem) {
|
||||
if (window.closeOnaraModal) window.closeOnaraModal({ skipHistory: true });
|
||||
@@ -11132,10 +11149,10 @@ window.cancelAnimFrame = (function () {
|
||||
if (window.closeImageModal) window.closeImageModal();
|
||||
|
||||
// Auto-clear notification highlight if opening an item
|
||||
const itemIdMatch = url.match(/\/(\d+)(\?.*|#.*)?$/);
|
||||
const itemIdMatch = url.match(/\/([a-zA-Z0-9_-]{11}|\d+)(?:\?.*|#.*)?$/);
|
||||
if (itemIdMatch) {
|
||||
const itemId = itemIdMatch[1];
|
||||
document.querySelectorAll(`a.thumb.has-notif[href$="/${itemId}"]`).forEach(el => {
|
||||
document.querySelectorAll(`a.thumb.has-notif[href$="/${itemId}"], a.thumb.has-notif[data-item-id="${itemId}"]`).forEach(el => {
|
||||
el.classList.remove('has-notif');
|
||||
});
|
||||
}
|
||||
@@ -11174,12 +11191,7 @@ window.cancelAnimFrame = (function () {
|
||||
const isSettings = pathname.match(/\/settings\/?(?:$|\?)/);
|
||||
const isStatic = pathname.match(/\/(about|rules|terms|upload|subscriptions|stats|docs|discord|ranking|meme|memes)($|\/|\?)/);
|
||||
const isUpload = pathname.match(/\/upload\/?(?:$|\?)/);
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
const isItem = !pathname.match(/\/p\//) && (
|
||||
pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
|
||||
(parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
|
||||
(parts.length >= 2 && parts[0] === 'favs' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
|
||||
);
|
||||
const isItem = typeof isItemPath === 'function' ? isItemPath(pathname) : (!pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(pathname));
|
||||
const isMessages = !!pathname.match(/^\/messages(\/|$)/);
|
||||
const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss';
|
||||
const isGrid = !isProfile && !isUserHall && !isUserHalls && !isHall && !isHalls && !isTags && !isComments && !isNotifs && !isItem && !isAdmin && !isMod && !isSettings && !isStatic && !isUpload && !isMessages && !isAbyss;
|
||||
@@ -12197,6 +12209,11 @@ window.cancelAnimFrame = (function () {
|
||||
|
||||
// Extract item ID from URL. Use the last numeric segment to avoid matching context IDs (like tag/1/...)
|
||||
// Split path, filter numeric, pop last.
|
||||
const isChanUrl = /^\/4\/([a-z0-9_]+)\/(\d+)\/(\d+)/i.test(new URL(url, window.location.origin).pathname);
|
||||
if (isChanUrl) {
|
||||
inheritContext = false;
|
||||
}
|
||||
|
||||
const pathSegments = new URL(url, window.location.origin).pathname.split('/');
|
||||
const keySegments = pathSegments.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s));
|
||||
|
||||
@@ -12265,7 +12282,7 @@ window.cancelAnimFrame = (function () {
|
||||
// Prev/next arrows in item view always use explicit /h/foo/<id> or /tag/foo/<id> hrefs
|
||||
// (via link.main), so they are unaffected by this guard.
|
||||
// Note: Notification links call loadItemAjax(href, false) to skip inheritance directly.
|
||||
if (inheritContext) {
|
||||
if (inheritContext && !isChanUrl) {
|
||||
if (!tag) {
|
||||
const wTagMatch = window.location.href.match(/\/tag\/([^/?]+)/);
|
||||
if (wTagMatch) tag = decodeURIComponent(wTagMatch[1]);
|
||||
@@ -12300,7 +12317,10 @@ window.cancelAnimFrame = (function () {
|
||||
try {
|
||||
// Construct AJAX URL
|
||||
let ajaxUrl = `/ajax/item/${itemid}`;
|
||||
|
||||
if (isChanUrl) {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
ajaxUrl = urlObj.pathname + (urlObj.search ? urlObj.search + '&ajax=1' : '?ajax=1');
|
||||
} else {
|
||||
const params = new URLSearchParams();
|
||||
params.append('mode', window.activeMode);
|
||||
const targetHash = new URL(url, window.location.origin).hash.replace(/^#/, '').trim();
|
||||
@@ -12352,9 +12372,13 @@ window.cancelAnimFrame = (function () {
|
||||
if (params.toString() !== '') {
|
||||
ajaxUrl += (ajaxUrl.includes('?') ? '&' : '?') + params.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stale-while-revalidate item cache ──────────────────────────────────
|
||||
// cacheKey is the stable ajaxUrl (no _t), scoped to mode+context.
|
||||
if (isChanUrl) {
|
||||
options.skipCache = true;
|
||||
}
|
||||
const _itemCacheKey = ajaxUrl;
|
||||
const _cachedItem = !options.skipCache ? itemCacheMap.get(_itemCacheKey) : null;
|
||||
|
||||
@@ -12524,7 +12548,7 @@ window.cancelAnimFrame = (function () {
|
||||
window.f0ckDebug('[itemCache] Cache hit render complete, revalidating in background...');
|
||||
|
||||
// ── Background revalidation — silently refresh cache for next visit ──
|
||||
fetch(ajaxUrl, { credentials: 'include' })
|
||||
fetch(ajaxUrl, { credentials: 'include', keepalive: true })
|
||||
.then(r => r.ok ? r.text() : null)
|
||||
.then(freshText => {
|
||||
if (!freshText) return;
|
||||
@@ -12578,7 +12602,10 @@ window.cancelAnimFrame = (function () {
|
||||
window.f0ckDebug(`[CLIENT_DEBUG] Using pre-fetched data (skipped network fetch)`);
|
||||
} else {
|
||||
// ── Normal fetch path ────────────────────────────────────────────
|
||||
const response = await fetch(ajaxUrl, { credentials: 'include' });
|
||||
const response = await fetch(ajaxUrl, {
|
||||
credentials: 'include',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
|
||||
});
|
||||
const tHeaders = performance.now();
|
||||
|
||||
if (!response.ok) throw new Error(`Network response was not ok: ${response.status}`);
|
||||
@@ -12802,7 +12829,10 @@ window.cancelAnimFrame = (function () {
|
||||
const itemKey = (window.f0ckSession?.enable_item_slugs !== false && responseSlug) ? responseSlug : (responseSlug || itemid);
|
||||
let pushUrl = `/${itemKey}`;
|
||||
// Logic from ajax.mjs context reconstruction:
|
||||
if (userHall && userHallOwner) {
|
||||
if (isChanUrl) {
|
||||
const u = new URL(url, window.location.origin);
|
||||
pushUrl = u.pathname + u.search + u.hash;
|
||||
} else if (userHall && userHallOwner) {
|
||||
pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`;
|
||||
} else if (user) {
|
||||
pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`;
|
||||
@@ -12815,13 +12845,13 @@ window.cancelAnimFrame = (function () {
|
||||
// Append tagger filter so item nav stays in tagger context
|
||||
if (tagger && tag) pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
|
||||
|
||||
if (mime) {
|
||||
if (!isChanUrl && mime) {
|
||||
// If it already has itemKey at the end, insert mime before it
|
||||
pushUrl = pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`);
|
||||
}
|
||||
|
||||
// Re-append hash if present
|
||||
if (hash) pushUrl += hash;
|
||||
if (hash && !isChanUrl) pushUrl += hash;
|
||||
|
||||
if (!options.keepMedia && !options.skipPush) {
|
||||
// We overwrite proper URL even if the link clicked was "naked"
|
||||
@@ -12844,7 +12874,16 @@ window.cancelAnimFrame = (function () {
|
||||
if (window.initVisualizer) window.initVisualizer();
|
||||
}
|
||||
// Try to extract ID from response if possible or just use itemid
|
||||
if (isChanUrl) {
|
||||
const chanM = new URL(url, window.location.origin).pathname.match(/^\/4\/([a-z0-9_]+)\/(\d+)\/(\d+)/i);
|
||||
if (chanM) {
|
||||
document.title = `/4/${chanM[1]}/${chanM[2]} #${chanM[3]} - ${window.f0ckDomain || 'f0ck'}`;
|
||||
} else {
|
||||
document.title = `${window.f0ckDomain} - #${itemid}`;
|
||||
}
|
||||
} else {
|
||||
document.title = `${window.f0ckDomain} - ${itemKey}`;
|
||||
}
|
||||
window.f0ckDebug("AJAX load complete");
|
||||
|
||||
// Notify extensions — also triggers CommentSystem init which renders comments
|
||||
@@ -13324,7 +13363,23 @@ window.cancelAnimFrame = (function () {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) {
|
||||
if (!isSpecialLink && (
|
||||
(!pathname.startsWith('/4/') && (
|
||||
pathname === '/' ||
|
||||
pathname.startsWith('/halls') ||
|
||||
pathname.startsWith('/h/') ||
|
||||
pathname.startsWith('/notifications') ||
|
||||
pathname.startsWith('/tag') ||
|
||||
pathname.startsWith('/user/') ||
|
||||
pathname.match(/^\/(image|video|audio)/) ||
|
||||
pathname.match(/\/p\/\d+/) ||
|
||||
pathname.match(/^\/\d+(?:[?#]|$)/) ||
|
||||
pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
|
||||
pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) ||
|
||||
pathname.startsWith('/abyss')
|
||||
)) ||
|
||||
(pathname.startsWith('/4/') && isItemPath(pathname))
|
||||
)) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
@@ -13348,7 +13403,244 @@ window.cancelAnimFrame = (function () {
|
||||
}
|
||||
}
|
||||
|
||||
if (target.closest('#togglebg')) {
|
||||
window.openChanRehostModal = function({ itemId, isPlayerView = false, onSave = null }) {
|
||||
if (!itemId) return;
|
||||
|
||||
let modal = document.getElementById('chan-rehost-modal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'chan-rehost-modal';
|
||||
modal.className = 'modal-overlay chan-rehost-modal';
|
||||
modal.style.display = 'none';
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content chan-rehost-modal-content">
|
||||
<div class="chan-rehost-modal-header">
|
||||
<h3 class="chan-rehost-modal-title">
|
||||
<i class="fa-solid fa-cloud-arrow-up" style="color: #4ade80;"></i>
|
||||
<span>Rehost Settings</span>
|
||||
<a href="/${itemId}" target="_blank" class="chan-rehost-modal-post-link" title="Open post in new tab">#${itemId} <i class="fa-solid fa-arrow-up-right-from-square"></i></a>
|
||||
</h3>
|
||||
<button type="button" class="chan-rehost-modal-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="chan-rehost-modal-body">
|
||||
<div class="chan-rehost-form-group">
|
||||
<label><i class="fa-solid fa-shield-halved"></i> Rating</label>
|
||||
<div class="chan-rehost-ratings" id="chan-rehost-rating-group">
|
||||
<button type="button" class="chan-rehost-pill untagged active" data-rating="untagged"><i class="fa-solid fa-question"></i> Untagged</button>
|
||||
<button type="button" class="chan-rehost-pill sfw" data-rating="sfw"><i class="fa-solid fa-shield"></i> SFW</button>
|
||||
<button type="button" class="chan-rehost-pill nsfw" data-rating="nsfw"><i class="fa-solid fa-eye-slash"></i> NSFW</button>
|
||||
<button type="button" class="chan-rehost-pill nsfl" data-rating="nsfl"><i class="fa-solid fa-skull"></i> NSFL</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chan-rehost-form-group">
|
||||
<label><i class="fa-solid fa-tags"></i> Tags (optional)</label>
|
||||
<input type="text" id="chan-rehost-tags-input" class="chan-rehost-input" placeholder="e.g. music, webm, anime (comma-separated)" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="chan-rehost-form-group">
|
||||
<label><i class="fa-solid fa-comment"></i> First Comment (optional)</label>
|
||||
<textarea id="chan-rehost-comment-input" class="chan-rehost-input chan-rehost-textarea" placeholder="Add a comment on this post..." rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chan-rehost-modal-actions">
|
||||
<button type="button" class="btn btn-secondary chan-rehost-modal-cancel">Close</button>
|
||||
${isPlayerView ? `<button type="button" class="btn btn-info chan-rehost-modal-save-view" style="background:#0284c7;color:#fff;border:none;border-radius:6px;padding:8px 14px;cursor:pointer;font-weight:600;"><i class="fa-solid fa-play"></i> Save & View Post</button>` : ''}
|
||||
<button type="button" class="btn btn-primary chan-rehost-modal-save" style="background:#16a34a;color:#fff;border:none;border-radius:6px;padding:8px 14px;cursor:pointer;font-weight:600;"><i class="fa-solid fa-check"></i> Save Details</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
const ratingGroup = modal.querySelector('#chan-rehost-rating-group');
|
||||
let selectedRating = 'untagged';
|
||||
|
||||
ratingGroup.addEventListener('click', (e) => {
|
||||
const pill = e.target.closest('.chan-rehost-pill');
|
||||
if (!pill) return;
|
||||
ratingGroup.querySelectorAll('.chan-rehost-pill').forEach(b => b.classList.remove('active'));
|
||||
pill.classList.add('active');
|
||||
selectedRating = pill.dataset.rating;
|
||||
});
|
||||
|
||||
const tagsInput = modal.querySelector('#chan-rehost-tags-input');
|
||||
const commentInput = modal.querySelector('#chan-rehost-comment-input');
|
||||
|
||||
// Pre-fill current rating and tags if available
|
||||
fetch(`/api/v2/scroller/rehost/details/${itemId}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data && data.success) {
|
||||
if (data.rating) {
|
||||
selectedRating = data.rating;
|
||||
ratingGroup.querySelectorAll('.chan-rehost-pill').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.rating === data.rating);
|
||||
});
|
||||
}
|
||||
if (data.tags && data.tags.length) {
|
||||
tagsInput.value = data.tags.join(', ');
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
setTimeout(() => tagsInput.focus(), 100);
|
||||
|
||||
const closeModal = () => {
|
||||
modal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
|
||||
modal.querySelector('.chan-rehost-modal-close').onclick = closeModal;
|
||||
modal.querySelector('.chan-rehost-modal-cancel').onclick = closeModal;
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) closeModal();
|
||||
};
|
||||
|
||||
const saveDetails = async (andView = false) => {
|
||||
const saveBtn = modal.querySelector('.chan-rehost-modal-save');
|
||||
const saveViewBtn = modal.querySelector('.chan-rehost-modal-save-view');
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
if (saveViewBtn) saveViewBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||
const resp = await fetch('/api/v2/scroller/rehost/details', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
item_id: itemId,
|
||||
rating: selectedRating,
|
||||
tags: tagsInput.value.trim(),
|
||||
comment: commentInput.value.trim()
|
||||
})
|
||||
});
|
||||
|
||||
const resData = await resp.json();
|
||||
if (resData.success) {
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast('Details saved for #' + itemId + '!');
|
||||
}
|
||||
closeModal();
|
||||
if (typeof onSave === 'function') {
|
||||
onSave(resData);
|
||||
}
|
||||
if (andView) {
|
||||
if (typeof window.loadItemAjax === 'function') {
|
||||
window.loadItemAjax('/' + itemId);
|
||||
} else {
|
||||
window.location.href = '/' + itemId;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert(resData.msg || 'Failed to save details');
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
if (saveViewBtn) saveViewBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[REHOST-MODAL] Save error:', err);
|
||||
alert('Error saving details: network error');
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
if (saveViewBtn) saveViewBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
modal.querySelector('.chan-rehost-modal-save').onclick = () => saveDetails(false);
|
||||
if (isPlayerView && modal.querySelector('.chan-rehost-modal-save-view')) {
|
||||
modal.querySelector('.chan-rehost-modal-save-view').onclick = () => saveDetails(true);
|
||||
}
|
||||
|
||||
tagsInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
saveDetails(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (target.closest('#chan-item-rehost-btn')) {
|
||||
e.preventDefault();
|
||||
const btn = target.closest('#chan-item-rehost-btn');
|
||||
if (btn && !btn.classList.contains('loading') && !btn.classList.contains('rehosted')) {
|
||||
const mediaUrl = btn.dataset.url;
|
||||
if (mediaUrl) {
|
||||
btn.classList.add('loading');
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Rehosting…';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||
const resp = await fetch('/api/v2/scroller/rehost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: mediaUrl,
|
||||
original_filename: btn.dataset.filename || btn.dataset.title || '',
|
||||
width: btn.dataset.width || '',
|
||||
height: btn.dataset.height || ''
|
||||
})
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
if (data.success && data.item_id) {
|
||||
btn.classList.remove('loading');
|
||||
btn.classList.add('rehosted');
|
||||
btn.outerHTML = `
|
||||
<span class="chan-rehost-btn-group" style="display:inline-flex;gap:4px;align-items:center;">
|
||||
<a href="/${data.item_id}" class="chan-rehost-action-btn rehosted" title="View Post #${data.item_id}" style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;">
|
||||
<i class="fa-solid fa-check"></i> #${data.item_id}
|
||||
</a>
|
||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="${data.item_id}" title="Edit rating, tags & comment" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
</span>
|
||||
`;
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast(data.repost ? 'Already on site — linked to existing post' : 'Rehosted successfully!');
|
||||
}
|
||||
if (typeof window.openChanRehostModal === 'function') {
|
||||
window.openChanRehostModal({ itemId: data.item_id, isPlayerView: true });
|
||||
}
|
||||
} else {
|
||||
btn.classList.remove('loading');
|
||||
btn.innerHTML = origHtml;
|
||||
alert(data.msg || data.message || 'Rehost failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CHAN-REHOST] Error:', err);
|
||||
btn.classList.remove('loading');
|
||||
btn.innerHTML = origHtml;
|
||||
alert('Rehost failed due to network error');
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
} else if (target.closest('.chan-rehost-edit-btn')) {
|
||||
e.preventDefault();
|
||||
const editBtn = target.closest('.chan-rehost-edit-btn');
|
||||
const itemId = editBtn?.dataset?.itemId;
|
||||
if (itemId && typeof window.openChanRehostModal === 'function') {
|
||||
const isPlayer = !!document.querySelector('.media-object');
|
||||
window.openChanRehostModal({ itemId: Number(itemId), isPlayerView: isPlayer });
|
||||
}
|
||||
} else if (target.closest('#togglebg')) {
|
||||
e.preventDefault();
|
||||
window.toggleBackground();
|
||||
} else if (target.closest('#toggleautoplay')) {
|
||||
|
||||
@@ -1936,10 +1936,6 @@
|
||||
const origClass = icon.className;
|
||||
icon.className = 'fa-solid fa-spinner';
|
||||
|
||||
let rating = applied.mode === 0 ? 'sfw' : (applied.mode === 1 ? 'nsfw' : (applied.mode === 4 ? 'nsfl' : 'sfw'));
|
||||
if (item.external_board === 'wsg') rating = 'sfw';
|
||||
else if (item.external_board === 'gif') rating = 'nsfw';
|
||||
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch('/api/v2/scroller/rehost', {
|
||||
@@ -1950,9 +1946,6 @@
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: item.external_media_url || item.dest,
|
||||
rating: rating,
|
||||
tags: '',
|
||||
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`,
|
||||
...(item.original_filename ? { original_filename: item.original_filename } : {})
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1559,9 +1559,11 @@
|
||||
if (txt) ids.add(txt);
|
||||
}
|
||||
|
||||
// 2. From URL pathname (/tag/:tag/:slugOrId, /user/:user/favs/:slugOrId, /user/:user/:slugOrId, /:slugOrId)
|
||||
// 2. From URL pathname (/tag/:tag/:slugOrId, /user/:user/favs/:slugOrId, /user/:user/:slugOrId, /:slugOrId, /4/:board/:thread/:post)
|
||||
const segments = window.location.pathname.split('/').filter(Boolean);
|
||||
if (segments.length >= 2 && segments[0] === 'tag') {
|
||||
if (segments.length >= 4 && segments[0] === '4') {
|
||||
ids.add(decodeURIComponent(segments[3]).trim());
|
||||
} else if (segments.length >= 2 && segments[0] === 'tag') {
|
||||
if (segments[2]) {
|
||||
ids.add(decodeURIComponent(segments[2]).trim());
|
||||
}
|
||||
@@ -1576,7 +1578,7 @@
|
||||
ids.add(decodeURIComponent(segments[2]).trim());
|
||||
}
|
||||
} else if (segments.length === 1) {
|
||||
const forbidden = ['s', 'b', 't', 'ca', 'a', 'login', 'register', 'settings', 'about', 'terms', 'rules', 'api', 'logout', 'auth', 'admin', 'mod', 'comments', 'notifications', 'feed', 'upload', 'tags', 'halls', 'ranking', 'abyss', 'random', 'scroller', 'p'];
|
||||
const forbidden = ['s', 'b', 't', 'ca', 'a', '4', 'login', 'register', 'settings', 'about', 'terms', 'rules', 'api', 'logout', 'auth', 'admin', 'mod', 'comments', 'notifications', 'feed', 'upload', 'tags', 'halls', 'ranking', 'abyss', 'random', 'scroller', 'p'];
|
||||
if (!forbidden.includes(segments[0])) {
|
||||
ids.add(decodeURIComponent(segments[0]).trim());
|
||||
}
|
||||
@@ -2089,20 +2091,232 @@
|
||||
}, { passive: true });
|
||||
};
|
||||
|
||||
// ── Dedicated 4chan Thread Feed (mirrors Tag/Favs Feed for /4/:board/:thread context) ─────
|
||||
let currentChanBoard = null;
|
||||
let currentChanThread = null;
|
||||
let chanFeedLoaded = false;
|
||||
let chanFeedLoading = false;
|
||||
|
||||
const getCurrentChanContext = () => {
|
||||
const m = window.location.pathname.match(/^\/4\/([a-z0-9_]+)\/(\d+)(?:\/(\d+))?/i);
|
||||
if (m) {
|
||||
return {
|
||||
board: m[1].toLowerCase(),
|
||||
thread: m[2],
|
||||
post: m[3] || null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const highlightActiveChanCard = (scrollIntoView = false) => {
|
||||
const chanCtx = getCurrentChanContext();
|
||||
const container = document.getElementById('sidebar-chan-container');
|
||||
if (!container || !chanCtx) return;
|
||||
|
||||
const currentPost = chanCtx.post || '';
|
||||
let matchedCard = null;
|
||||
|
||||
container.querySelectorAll('.sidebar-video-card').forEach(card => {
|
||||
const cardId = card.dataset.id ? String(card.dataset.id).trim() : '';
|
||||
const isMatch = (currentPost && cardId === currentPost);
|
||||
if (isMatch) {
|
||||
card.classList.add('active-tag-item');
|
||||
if (!matchedCard) matchedCard = card;
|
||||
} else {
|
||||
card.classList.remove('active-tag-item');
|
||||
}
|
||||
});
|
||||
|
||||
if (matchedCard && scrollIntoView) {
|
||||
const isChanTabActive = document.querySelector('.sidebar-tab.active')?.dataset.tab === 'chan' &&
|
||||
container.style.display !== 'none';
|
||||
if (isChanTabActive) {
|
||||
try {
|
||||
const cr = container.getBoundingClientRect();
|
||||
const mr = matchedCard.getBoundingClientRect();
|
||||
const targetScrollTop = container.scrollTop + (mr.top - cr.top) - (container.clientHeight / 2) + (mr.height / 2);
|
||||
container.scrollTo({ top: Math.max(0, targetScrollTop), behavior: 'smooth' });
|
||||
} catch (_) {
|
||||
matchedCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadChanFeed = async (reset = false) => {
|
||||
const chanCtx = getCurrentChanContext();
|
||||
const container = document.getElementById('sidebar-chan-container');
|
||||
const itemsContainer = container?.querySelector('.sidebar-chan-items-container');
|
||||
if (!container || !itemsContainer || chanFeedLoading) return;
|
||||
if (!chanCtx) return;
|
||||
|
||||
chanFeedLoading = true;
|
||||
|
||||
if (reset) {
|
||||
itemsContainer.innerHTML = `
|
||||
<div class="sidebar-loading-state">
|
||||
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
<span>Loading thread media...</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v2/chan/${encodeURIComponent(chanCtx.board)}/${encodeURIComponent(chanCtx.thread)}/media`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
const items = data.items || data.media || [];
|
||||
|
||||
if (data.success && items.length > 0) {
|
||||
chanFeedLoaded = true;
|
||||
|
||||
// Update header
|
||||
const countEl = container.querySelector('.sidebar-chan-count');
|
||||
if (countEl) countEl.textContent = `(${items.length})`;
|
||||
|
||||
const nameEl = container.querySelector('.sidebar-chan-name');
|
||||
if (nameEl) nameEl.textContent = `/${chanCtx.board}/${chanCtx.thread}`;
|
||||
|
||||
const overviewBtn = container.querySelector('.sidebar-chan-overview-btn');
|
||||
if (overviewBtn) {
|
||||
overviewBtn.href = `/4/${chanCtx.board}/${chanCtx.thread}`;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
items.forEach(v => {
|
||||
const isActive = chanCtx.post && String(v.no) === String(chanCtx.post);
|
||||
const activeClass = isActive ? ' active-tag-item' : '';
|
||||
const targetHref = `/4/${chanCtx.board}/${chanCtx.thread}/${v.no}`;
|
||||
const formatBadge = v.ext ? `<span class="sidebar-media-format-badge">${escapeHtml(v.ext.replace('.', '').toUpperCase())}</span>` : '';
|
||||
const displayTitle = v.filename ? `${v.filename}` : `#${v.no}`;
|
||||
const overlayIcon = v.is_video ? 'fa-solid fa-play' : 'fa-solid fa-image';
|
||||
const fsizeStr = v.fsize ? `${(v.fsize / 1024 / 1024).toFixed(1)} MB` : '';
|
||||
|
||||
html += `
|
||||
<div class="sidebar-video-card${activeClass}" data-id="${v.no}">
|
||||
<a href="${targetHref}" class="sidebar-video-link" data-inherit-context="false">
|
||||
<div class="sidebar-video-thumb-wrap">
|
||||
<img src="${escapeHtml(v.thumb)}" class="sidebar-video-thumb" alt="${escapeHtml(displayTitle)}" loading="lazy" draggable="false" onerror="this.style.display='none'; if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');" />
|
||||
<div class="sidebar-media-placeholder hidden">
|
||||
<i class="${overlayIcon}"></i>
|
||||
</div>
|
||||
<div class="sidebar-video-badges">
|
||||
${formatBadge}
|
||||
<span class="sidebar-video-badge" style="background: rgba(74, 222, 128, 0.2); color: #4ade80;">4CHAN</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-video-details">
|
||||
<div class="sidebar-video-info">
|
||||
<div class="sidebar-video-title" title="${escapeHtml(displayTitle)}">${escapeHtml(displayTitle)}</div>
|
||||
<div class="sidebar-video-channel">
|
||||
<span class="sidebar-video-user" style="color: #4ade80;">#${v.no}</span>
|
||||
</div>
|
||||
<div class="sidebar-video-meta">
|
||||
${fsizeStr ? `<span class="sidebar-video-time">${escapeHtml(fsizeStr)}</span>` : ''}
|
||||
${v.w && v.h ? `<span>${fsizeStr ? '• ' : ''}${v.w}x${v.h}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
itemsContainer.innerHTML = html;
|
||||
setTimeout(() => highlightActiveChanCard(true), 60);
|
||||
} else if (reset) {
|
||||
itemsContainer.innerHTML = `
|
||||
<div class="sidebar-empty-state" style="text-align:center;padding:30px 15px;color:var(--text-muted,#888);font-size:0.88em;">
|
||||
<i class="fa-solid fa-clover" style="font-size:1.8em;opacity:0.4;margin-bottom:8px;display:block;color:#4ade80;"></i>
|
||||
No media found in thread
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CHAN-FEED] Failed:', err);
|
||||
if (reset) {
|
||||
itemsContainer.innerHTML = `<div class="sidebar-empty-state" style="text-align:center;padding:25px;color:var(--danger,#ff4444);font-size:0.88em;">Failed to load thread media</div>`;
|
||||
}
|
||||
} finally {
|
||||
chanFeedLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateChanTabVisibility = (forceSwitchToChan = false) => {
|
||||
const chanCtx = getCurrentChanContext();
|
||||
const chanBtn = document.getElementById('sidebar-tab-chan');
|
||||
if (!chanBtn) return;
|
||||
|
||||
if (chanCtx) {
|
||||
chanBtn.style.display = 'inline-flex';
|
||||
chanBtn.title = `/${chanCtx.board}/${chanCtx.thread}`;
|
||||
|
||||
if (currentChanBoard !== chanCtx.board || currentChanThread !== chanCtx.thread) {
|
||||
currentChanBoard = chanCtx.board;
|
||||
currentChanThread = chanCtx.thread;
|
||||
chanFeedLoaded = false;
|
||||
userManuallySelectedNonTagTab = false;
|
||||
switchSidebarTab('chan');
|
||||
} else {
|
||||
if (forceSwitchToChan || (!userManuallySelectedNonTagTab && document.querySelector('.sidebar-tab.active')?.dataset.tab !== 'chan')) {
|
||||
switchSidebarTab('chan');
|
||||
} else {
|
||||
highlightActiveChanCard(true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
chanBtn.style.display = 'none';
|
||||
currentChanBoard = null;
|
||||
currentChanThread = null;
|
||||
chanFeedLoaded = false;
|
||||
|
||||
const activeTab = document.querySelector('.sidebar-tab.active')?.dataset.tab;
|
||||
if (activeTab === 'chan') {
|
||||
if (getCurrentTag()) {
|
||||
switchSidebarTab('tag');
|
||||
} else {
|
||||
switchSidebarTab('recommendations');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const bindChanContainerEvents = () => {
|
||||
const container = document.getElementById('sidebar-chan-container');
|
||||
if (!container || container.dataset.eventsBound === 'true') return;
|
||||
container.dataset.eventsBound = 'true';
|
||||
container.addEventListener('click', (e) => {
|
||||
const link = e.target.closest('a.sidebar-video-link');
|
||||
if (link && link.href) {
|
||||
e.preventDefault();
|
||||
container.querySelectorAll('.sidebar-video-card').forEach(c => c.classList.remove('active-tag-item'));
|
||||
const card = link.closest('.sidebar-video-card');
|
||||
if (card) card.classList.add('active-tag-item');
|
||||
if (typeof window.loadItemAjax === 'function') {
|
||||
window.loadItemAjax(link.href, false);
|
||||
} else {
|
||||
window.location.href = link.href;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const switchSidebarTab = (tabName) => {
|
||||
if (window.clearHoverPreview) {
|
||||
window.clearHoverPreview();
|
||||
}
|
||||
const normalizedTab = (tabName === 'videos' || tabName === 'recommendations')
|
||||
? 'recommendations'
|
||||
: (tabName === 'tag' ? 'tag' : (tabName === 'favs' ? 'favs' : tabName));
|
||||
: (tabName === 'tag' ? 'tag' : (tabName === 'favs' ? 'favs' : (tabName === 'chan' ? 'chan' : tabName)));
|
||||
const tabs = document.querySelectorAll('.sidebar-tab');
|
||||
const contents = document.querySelectorAll('.sidebar-tab-content');
|
||||
|
||||
tabs.forEach(t => {
|
||||
const tTab = (t.dataset.tab === 'videos' || t.dataset.tab === 'recommendations')
|
||||
? 'recommendations'
|
||||
: (t.dataset.tab === 'tag' ? 'tag' : (t.dataset.tab === 'favs' ? 'favs' : t.dataset.tab));
|
||||
: (t.dataset.tab === 'tag' ? 'tag' : (t.dataset.tab === 'favs' ? 'favs' : (t.dataset.tab === 'chan' ? 'chan' : t.dataset.tab)));
|
||||
if (tTab === normalizedTab) {
|
||||
t.classList.add('active');
|
||||
} else {
|
||||
@@ -2113,7 +2327,7 @@
|
||||
contents.forEach(c => {
|
||||
const cTab = (c.dataset.tabContent === 'videos' || c.dataset.tabContent === 'recommendations')
|
||||
? 'recommendations'
|
||||
: (c.dataset.tabContent === 'tag' ? 'tag' : (c.dataset.tabContent === 'favs' ? 'favs' : c.dataset.tabContent));
|
||||
: (c.dataset.tabContent === 'tag' ? 'tag' : (c.dataset.tabContent === 'favs' ? 'favs' : (c.dataset.tabContent === 'chan' ? 'chan' : c.dataset.tabContent)));
|
||||
if (cTab === normalizedTab) {
|
||||
c.classList.add('active');
|
||||
c.style.display = 'block';
|
||||
@@ -2143,6 +2357,12 @@
|
||||
} else {
|
||||
setTimeout(() => highlightActiveFavsCard(true), 60);
|
||||
}
|
||||
} else if (normalizedTab === 'chan') {
|
||||
if (!chanFeedLoaded) {
|
||||
loadChanFeed(true);
|
||||
} else {
|
||||
setTimeout(() => highlightActiveChanCard(true), 60);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.switchSidebarTab = switchSidebarTab;
|
||||
@@ -2154,7 +2374,7 @@
|
||||
const btn = e.target.closest('.sidebar-tab');
|
||||
if (btn && btn.dataset.tab) {
|
||||
e.preventDefault();
|
||||
if (btn.dataset.tab !== 'tag' && btn.dataset.tab !== 'favs') {
|
||||
if (btn.dataset.tab !== 'tag' && btn.dataset.tab !== 'favs' && btn.dataset.tab !== 'chan') {
|
||||
userManuallySelectedNonTagTab = true;
|
||||
} else {
|
||||
userManuallySelectedNonTagTab = false;
|
||||
@@ -2170,7 +2390,9 @@
|
||||
savedTab = localStorage.getItem('sidebar_active_tab') || 'comments';
|
||||
} catch (_) {}
|
||||
|
||||
if (getCurrentFavsUser()) {
|
||||
if (getCurrentChanContext()) {
|
||||
switchSidebarTab('chan');
|
||||
} else if (getCurrentFavsUser()) {
|
||||
switchSidebarTab('favs');
|
||||
} else if (getCurrentTag()) {
|
||||
switchSidebarTab('tag');
|
||||
@@ -2410,22 +2632,28 @@
|
||||
initTagOrderButton();
|
||||
bindTagContainerEvents();
|
||||
bindFavsContainerEvents();
|
||||
bindChanContainerEvents();
|
||||
updateTagTabVisibility();
|
||||
updateFavsTabVisibility();
|
||||
updateChanTabVisibility();
|
||||
document.addEventListener('f0ck:contentLoaded', () => {
|
||||
updateTagTabVisibility();
|
||||
updateFavsTabVisibility();
|
||||
updateChanTabVisibility();
|
||||
setTimeout(() => {
|
||||
highlightActiveTagCard(true);
|
||||
highlightActiveFavsCard(true);
|
||||
highlightActiveChanCard(true);
|
||||
}, 60);
|
||||
});
|
||||
window.addEventListener('popstate', () => {
|
||||
setTimeout(() => {
|
||||
updateTagTabVisibility();
|
||||
updateFavsTabVisibility();
|
||||
updateChanTabVisibility();
|
||||
highlightActiveTagCard(true);
|
||||
highlightActiveFavsCard(true);
|
||||
highlightActiveChanCard(true);
|
||||
}, 60);
|
||||
});
|
||||
bindRecommendationEvents();
|
||||
|
||||
+67
-5
@@ -6,7 +6,7 @@
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
const idLinkEl = document.querySelector("a.id-link");
|
||||
|
||||
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
const rawId = favoEl?.dataset?.localId || commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
if (!rawId) return null;
|
||||
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
@@ -256,17 +256,79 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
const favoBtns = document.querySelectorAll("#a_favo");
|
||||
const firstFavoBtn = favoBtns[0];
|
||||
const chanRehostBtn = document.querySelector('#chan-item-rehost-btn');
|
||||
const isChan = firstFavoBtn?.dataset?.isChan === 'true' || !!chanRehostBtn;
|
||||
const chanUrl = firstFavoBtn?.dataset?.chanUrl || chanRehostBtn?.dataset?.url;
|
||||
let localId = firstFavoBtn?.dataset?.localId;
|
||||
|
||||
// If viewing un-rehosted 4chan post, auto-rehost first
|
||||
if (isChan && !localId && chanUrl) {
|
||||
favoBtns.forEach(btn => {
|
||||
btn.className = 'iconset fa-solid fa-spinner fa-spin';
|
||||
});
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||
const rehostResp = await fetch('/api/v2/scroller/rehost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: chanUrl,
|
||||
original_filename: firstFavoBtn?.dataset?.filename || chanRehostBtn?.dataset?.filename || '',
|
||||
width: firstFavoBtn?.dataset?.width || chanRehostBtn?.dataset?.width || '',
|
||||
height: firstFavoBtn?.dataset?.height || chanRehostBtn?.dataset?.height || ''
|
||||
})
|
||||
});
|
||||
const rehostData = await rehostResp.json();
|
||||
if (rehostData.success && rehostData.item_id) {
|
||||
localId = rehostData.item_id;
|
||||
favoBtns.forEach(btn => {
|
||||
btn.dataset.itemId = localId;
|
||||
btn.dataset.localId = localId;
|
||||
});
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
if (commentsEl) commentsEl.dataset.itemId = localId;
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
if (infoEl) infoEl.dataset.itemId = localId;
|
||||
|
||||
if (chanRehostBtn) {
|
||||
chanRehostBtn.classList.add('rehosted');
|
||||
chanRehostBtn.outerHTML = `
|
||||
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
|
||||
<a href="/${localId}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #${localId})" style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;"><i class="fa-solid fa-check"></i> #${localId}</a>
|
||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="${localId}" title="Edit rating, tags & comment" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
throw new Error(rehostData.msg || 'Rehost failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CHAN-FAV] Auto-rehost error:', err);
|
||||
favoBtns.forEach(btn => {
|
||||
btn.className = 'iconset fa-regular fa-heart';
|
||||
});
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('Failed to favorite post', 3000, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
const postid = localId ? Number(localId) : ctx.postid;
|
||||
|
||||
// Read state BEFORE the API call so we have a fallback
|
||||
const favoBtns = document.querySelectorAll("#a_favo");
|
||||
const wasAlreadyFav = favoBtns.length > 0 && favoBtns[0].classList.contains('fa-solid');
|
||||
|
||||
try {
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: postid
|
||||
postid: postid,
|
||||
chan_url: chanUrl
|
||||
});
|
||||
if (res && res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
|
||||
@@ -544,6 +544,26 @@ export default new class {
|
||||
return next();
|
||||
};
|
||||
|
||||
async chanAuth(req, res, next) {
|
||||
if (!req.session || !req.session.user) {
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: "Unauthorized" }), type: 'application/json' });
|
||||
}
|
||||
return res.redirect('/login');
|
||||
}
|
||||
const hasGroup = req.session.admin || (Array.isArray(req.session.groups) && req.session.groups.includes('4chan'));
|
||||
if (!hasGroup) {
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "4chan group required" }), type: 'application/json' });
|
||||
}
|
||||
return res.reply({
|
||||
code: 403,
|
||||
body: `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Access Denied</title><link rel="stylesheet" href="/s/css/f0ck.css"></head><body style="background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;font-family:sans-serif;"><div style="text-align:center;padding:30px;background:#1a1a1a;border-radius:12px;border:1px solid #333;max-width:460px;"><div style="font-size:3rem;color:#4ade80;margin-bottom:15px;">🍀</div><h2 style="margin:0 0 10px;">Access Restricted</h2><p style="color:#aaa;line-height:1.5;">This 4chan viewer is only accessible to users with the <strong>4chan</strong> group.</p><a href="/" style="display:inline-block;margin-top:15px;color:#4ade80;text-decoration:none;">← Return to Home</a></div></body></html>`
|
||||
});
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
// Middleware: authenticate via X-Api-Key header (upload-only)
|
||||
async apiKeyAuth(req, res, next) {
|
||||
const key = req.headers['x-api-key'];
|
||||
|
||||
@@ -1165,7 +1165,7 @@ export default (router, tpl) => {
|
||||
WITH ghost_users AS (
|
||||
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,
|
||||
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, ARRAY[]::text[] as groups, true as activated,
|
||||
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 i.username IS NOT NULL AND i.username != ''
|
||||
@@ -1240,7 +1240,7 @@ export default (router, tpl) => {
|
||||
users = await db`
|
||||
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,
|
||||
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.groups, u.activated,
|
||||
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
|
||||
@@ -1446,6 +1446,45 @@ export default (router, tpl) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/users\/set-groups\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
const { user_id, groups } = req.post;
|
||||
if (!user_id) throw new Error('Missing user_id');
|
||||
const target = await db`SELECT id, login FROM "user" WHERE id = ${+user_id} LIMIT 1`;
|
||||
if (!target.length) throw new Error('User not found.');
|
||||
|
||||
let groupsArr = [];
|
||||
if (Array.isArray(groups)) {
|
||||
groupsArr = groups.map(s => String(s).trim().toLowerCase()).filter(Boolean);
|
||||
} else if (typeof groups === 'string') {
|
||||
groupsArr = groups.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
await db`
|
||||
UPDATE "user"
|
||||
SET groups = ${groupsArr}
|
||||
WHERE id = ${+user_id}
|
||||
`;
|
||||
|
||||
// Invalidate target user's session cache and session table so new groups load immediately
|
||||
await db`DELETE FROM user_sessions WHERE user_id = ${+user_id}`;
|
||||
|
||||
await audit.log(req.session.id, 'admin_set_groups', 'user', +user_id, {
|
||||
target_login: target[0].login,
|
||||
groups: groupsArr
|
||||
});
|
||||
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||
success: true,
|
||||
groups: groupsArr,
|
||||
msg: `Groups for "${target[0].login}" updated to: ${groupsArr.join(', ') || 'none'}`
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN] Set groups failed:', err);
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/users\/lock-layout\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
|
||||
+19
-3
@@ -17,6 +17,14 @@ export default (router, tpl) => {
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
const isPrefetch = !!(
|
||||
query.prefetch === '1' ||
|
||||
req.url?.qs?.prefetch === '1' ||
|
||||
req.headers?.['x-purpose'] === 'prefetch' ||
|
||||
req.headers?.['purpose'] === 'prefetch' ||
|
||||
req.headers?.['sec-purpose'] === 'prefetch'
|
||||
);
|
||||
|
||||
const isGuest = !req.session || !req.session.user;
|
||||
const reqMode = isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode);
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
@@ -122,7 +130,7 @@ export default (router, tpl) => {
|
||||
|
||||
// xD Score + comments — parallelize subscription + comments fetch
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
if (req.session?.id) {
|
||||
if (req.session?.id && !isPrefetch) {
|
||||
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
|
||||
}
|
||||
const [sub, commentsForScore] = await Promise.all([
|
||||
@@ -296,6 +304,14 @@ export default (router, tpl) => {
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
const isPrefetch = !!(
|
||||
query.prefetch === '1' ||
|
||||
req.url?.qs?.prefetch === '1' ||
|
||||
req.headers?.['x-purpose'] === 'prefetch' ||
|
||||
req.headers?.['purpose'] === 'prefetch' ||
|
||||
req.headers?.['sec-purpose'] === 'prefetch'
|
||||
);
|
||||
|
||||
let contextUrl = `/${req.params.itemid}`;
|
||||
if (query.tag) contextUrl = `/tag/${encodeURIComponent(query.tag)}/${req.params.itemid}`;
|
||||
if (query.hall) contextUrl = `/h/${encodeURIComponent(query.hall)}/${req.params.itemid}`;
|
||||
@@ -372,8 +388,8 @@ export default (router, tpl) => {
|
||||
// Comments are always loaded async by the client via /api/comments/:id to avoid
|
||||
// blocking the browser's main thread on posts with huge comment payloads.
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
// Mark notifications as read
|
||||
if (req.session?.id) {
|
||||
// Mark notifications as read (only when actually viewed, not on prefetch)
|
||||
if (req.session?.id && !isPrefetch) {
|
||||
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
|
||||
}
|
||||
const sub = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
|
||||
|
||||
@@ -1866,12 +1866,30 @@ export default router => {
|
||||
|
||||
// Support both numeric item ID and string slug
|
||||
const isNumeric = /^\d+$/.test(String(rawPostid));
|
||||
const itemRow = await db`
|
||||
let itemRow = await db`
|
||||
SELECT id FROM items
|
||||
WHERE ${isNumeric ? db`id = ${+rawPostid}` : db`slug = ${String(rawPostid)}`} AND active = true AND is_deleted = false
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (!itemRow.length) {
|
||||
const chanUrl = req.post?.chan_url ?? req.body?.chan_url ?? req.post?.url ?? req.body?.url;
|
||||
if (chanUrl) {
|
||||
itemRow = await db`
|
||||
SELECT id FROM items
|
||||
WHERE src = ${chanUrl} AND active = true AND is_deleted = false
|
||||
LIMIT 1
|
||||
`;
|
||||
}
|
||||
if (!itemRow.length && isNumeric) {
|
||||
itemRow = await db`
|
||||
SELECT id FROM items
|
||||
WHERE (src LIKE ${'%/' + rawPostid + '.%'} OR src LIKE ${'%/' + rawPostid + 's.%'}) AND active = true AND is_deleted = false
|
||||
LIMIT 1
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!itemRow.length) {
|
||||
return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
import cfg from "../config.mjs";
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import queue from "../queue.mjs";
|
||||
|
||||
/**
|
||||
* chan.mjs — 4chan thread viewer & catalogue routes
|
||||
* Restricted to users with the '4chan' group (and admins).
|
||||
*/
|
||||
export default (router, tpl) => {
|
||||
|
||||
const COMMON_BOARDS = [
|
||||
{ id: 'wsg', name: 'Worksafe GIF', icon: 'fa-film' },
|
||||
{ id: 'gif', name: 'Adult GIF', icon: 'fa-video' },
|
||||
{ id: 'b', name: 'Random', icon: 'fa-dice' },
|
||||
{ id: 'v', name: 'Video Games', icon: 'fa-gamepad' },
|
||||
{ id: 'vg', name: 'Video Game Generals', icon: 'fa-gamepad' },
|
||||
{ id: 'a', name: 'Anime & Manga', icon: 'fa-tv' },
|
||||
{ id: 'g', name: 'Technology', icon: 'fa-microchip' },
|
||||
{ id: 'pol', name: 'Politically Incorrect', icon: 'fa-globe' },
|
||||
{ id: 'tv', name: 'Television & Film', icon: 'fa-tv' },
|
||||
{ id: 'mu', name: 'Music', icon: 'fa-music' },
|
||||
{ id: 'fit', name: 'Fitness', icon: 'fa-dumbbell' },
|
||||
{ id: 'ck', name: 'Food & Cooking', icon: 'fa-utensils' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Helper to fetch data via curl respecting SOCKS5 proxy if configured.
|
||||
*/
|
||||
async function fetchWithProxy(url) {
|
||||
const curlArgs = [
|
||||
'-s', '-f', '-L',
|
||||
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'--max-time', '30',
|
||||
url
|
||||
];
|
||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||
curlArgs.push('--socks5-hostname', proxyHost);
|
||||
}
|
||||
const { stdout } = await queue.spawn('curl', curlArgs, { encoding: 'utf8' });
|
||||
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
|
||||
if (!text.startsWith('{') && !text.startsWith('[')) {
|
||||
throw new Error(`Expected JSON from ${url}, got: ${text.slice(0, 100)}`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function formatComment(com) {
|
||||
if (!com) return '';
|
||||
// Format quote lines
|
||||
let formatted = com.replace(/(?:^|<br\s*\/?>)((?:>|>)((?!>|>)[^\n<]+))/g, '$1<span class="chan-quote">>$2</span>');
|
||||
// Format post cross-references >>123456
|
||||
formatted = formatted.replace(/(?:>|>)>(\d+)/g, '<a href="#p$1" class="chan-ref" data-post="$1">>>$1</a>');
|
||||
return formatted;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 0. GET /4 -> redirect to default board catalogue
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/4\/?$/, lib.chanAuth, (req, res) => res.redirect('/4/wsg/catalogue'));
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1. GET /4/:board/catalogue (and alias /4/:board/catalog)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/4\/(?<board>[a-z0-9]+)\/(?:catalogue|catalog)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const board = req.params.board.toLowerCase();
|
||||
|
||||
try {
|
||||
const pages = await fetchWithProxy(`https://a.4cdn.org/${board}/catalog.json`);
|
||||
const threads = [];
|
||||
for (const page of pages) {
|
||||
for (const t of (page.threads || [])) {
|
||||
threads.push({
|
||||
no: t.no,
|
||||
sub: t.sub || '',
|
||||
com: t.com ? t.com.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]+>/g, '').slice(0, 180) : '',
|
||||
replies: t.replies || 0,
|
||||
images: t.images || 0,
|
||||
tim: t.tim,
|
||||
ext: t.ext,
|
||||
sticky: !!t.sticky,
|
||||
closed: !!t.closed,
|
||||
time: t.time,
|
||||
thumb: t.tim ? `/api/v2/scroller/external/4chan/${board}/media/${t.tim}s.jpg` : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const opUrls = [];
|
||||
threads.forEach(t => {
|
||||
if (t.tim && t.ext) {
|
||||
const ext = (t.ext || '').toLowerCase();
|
||||
opUrls.push(`https://i.4cdn.org/${board}/${t.tim}${t.ext}`);
|
||||
opUrls.push(`https://i.4cdn.org/${board}/${t.tim}${ext}`);
|
||||
opUrls.push(`http://i.4cdn.org/${board}/${t.tim}${t.ext}`);
|
||||
opUrls.push(`http://i.4cdn.org/${board}/${t.tim}${ext}`);
|
||||
opUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${t.tim}${ext}`);
|
||||
}
|
||||
});
|
||||
const rehosts = {};
|
||||
if (opUrls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${opUrls})`;
|
||||
rows.forEach(r => {
|
||||
rehosts[r.src] = r.id;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) rehosts[m[1]] = r.id;
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
threads.forEach(t => {
|
||||
t.local_id = (t.tim && rehosts[t.tim]) || (t.tim && t.ext && rehosts[`https://i.4cdn.org/${board}/${t.tim}${t.ext}`]) || null;
|
||||
});
|
||||
|
||||
const data = {
|
||||
board,
|
||||
threads,
|
||||
common_boards: COMMON_BOARDS,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
page_meta: {
|
||||
title: `/${board}/ - Catalogue`,
|
||||
description: `4chan /${board}/ thread catalogue`,
|
||||
url: `https://${cfg.main.url.domain}/4/${board}/catalogue`
|
||||
}
|
||||
};
|
||||
|
||||
return res.reply({
|
||||
body: tpl.render('chan/catalogue', data, req)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[CHAN] Failed to fetch catalogue for /${board}/:`, err.message);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
body: tpl.render('error', {
|
||||
message: `Could not load catalogue for /${board}/. The board may not exist or 4chan is currently unreachable.`,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2. GET /4/:board/:thread — Overview of all posts in that thread
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/4\/(?<board>[a-z0-9]+)\/(?<thread>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const board = req.params.board.toLowerCase();
|
||||
const tid = Number(req.params.thread);
|
||||
|
||||
try {
|
||||
const data = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`);
|
||||
const posts = data.posts || [];
|
||||
if (!posts.length) throw new Error('Empty thread data');
|
||||
|
||||
const op = posts[0];
|
||||
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||
|
||||
// Check which media URLs are already rehosted on this platform
|
||||
const rehosts = {};
|
||||
const cdnUrls = [];
|
||||
mediaPosts.forEach(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||
});
|
||||
if (cdnUrls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`;
|
||||
rows.forEach(r => {
|
||||
rehosts[r.src] = r.id;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) rehosts[m[1]] = r.id;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[CHAN] Rehost check error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Find which rehosted items the current user has favorited
|
||||
const userFavSet = new Set();
|
||||
const rehostedIdList = Object.values(rehosts).map(Number).filter(Boolean);
|
||||
if (req.session?.id && rehostedIdList.length > 0) {
|
||||
try {
|
||||
const userFavs = await db`SELECT item_id FROM favorites WHERE user_id = ${req.session.id} AND item_id = ANY(${rehostedIdList})`;
|
||||
userFavs.forEach(f => userFavSet.add(Number(f.item_id)));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Format posts for template
|
||||
const formattedPosts = posts.map(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
const isVideo = ['.webm', '.mp4'].includes(ext);
|
||||
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
|
||||
const externalMediaUrl = p.tim ? `https://i.4cdn.org/${board}/${p.tim}${ext}` : null;
|
||||
const localId = (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null;
|
||||
|
||||
return {
|
||||
no: p.no,
|
||||
sub: p.sub || '',
|
||||
com_raw: p.com || '',
|
||||
com_formatted: formatComment(p.com || ''),
|
||||
name: p.name || 'Anonymous',
|
||||
trip: p.trip || '',
|
||||
time: p.time,
|
||||
timeago: lib.timeAgo(p.time * 1000, req.lang || 'en'),
|
||||
date_str: p.now || new Date(p.time * 1000).toLocaleString(),
|
||||
has_media: !!(p.tim && p.ext),
|
||||
tim: p.tim,
|
||||
ext: ext,
|
||||
filename: p.filename ? `${p.filename}${ext}` : null,
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
fsize: p.fsize ? lib.formatSize(p.fsize) : null,
|
||||
dest: p.tim ? `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}` : null,
|
||||
thumb: p.tim ? `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg` : null,
|
||||
external_media_url: externalMediaUrl,
|
||||
is_video: isVideo,
|
||||
is_image: isImage,
|
||||
local_id: localId,
|
||||
rehosted: !!localId,
|
||||
user_has_favorited: localId ? userFavSet.has(Number(localId)) : false
|
||||
};
|
||||
});
|
||||
|
||||
const firstMediaPost = formattedPosts.find(p => p.has_media);
|
||||
|
||||
const viewData = {
|
||||
board,
|
||||
tid,
|
||||
op,
|
||||
posts: formattedPosts,
|
||||
media_count: mediaPosts.length,
|
||||
first_media_no: firstMediaPost ? firstMediaPost.no : null,
|
||||
rehosts_count: Object.keys(rehosts).length,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
page_meta: {
|
||||
title: `/${board}/${tid} - ${op.sub || 'Thread Overview'}`,
|
||||
description: op.sub || (op.com ? op.com.replace(/<[^>]+>/g, '').slice(0, 160) : `4chan /${board}/ thread ${tid}`),
|
||||
url: `https://${cfg.main.url.domain}/4/${board}/${tid}`
|
||||
}
|
||||
};
|
||||
|
||||
return res.reply({
|
||||
body: tpl.render('chan/thread', viewData, req)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[CHAN] Failed to load thread /${board}/${tid}:`, err.message);
|
||||
return res.reply({
|
||||
code: 404,
|
||||
body: tpl.render('error', {
|
||||
message: `Could not load thread /${board}/${tid}. It may be archived or 4chan is currently unreachable.`,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 3. GET /4/:board/:thread/:post — Show media item in normal item view
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/4\/(?<board>[a-z0-9]+)\/(?<thread>\d+)\/(?<post>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const board = req.params.board.toLowerCase();
|
||||
const tid = Number(req.params.thread);
|
||||
const postNo = Number(req.params.post);
|
||||
|
||||
try {
|
||||
const threadData = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`);
|
||||
const posts = threadData.posts || [];
|
||||
if (!posts.length) throw new Error('Empty thread data');
|
||||
|
||||
const op = posts[0];
|
||||
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||
if (!mediaPosts.length) {
|
||||
// No media at all in thread, redirect to thread overview
|
||||
return res.redirect(`/4/${board}/${tid}`);
|
||||
}
|
||||
|
||||
// Find target post
|
||||
let targetPost = mediaPosts.find(p => p.no === postNo);
|
||||
if (!targetPost) {
|
||||
// Post has no media or doesn't exist; pick closest or first media post
|
||||
targetPost = mediaPosts[0];
|
||||
}
|
||||
|
||||
// Check rehost status in DB
|
||||
const cdnUrls = [];
|
||||
mediaPosts.forEach(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||
});
|
||||
const rehosts = {};
|
||||
if (cdnUrls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`;
|
||||
rows.forEach(r => {
|
||||
rehosts[r.src] = r.id;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) rehosts[m[1]] = r.id;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[CHAN] Rehost check error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const mediaIndex = mediaPosts.findIndex(p => p.no === targetPost.no);
|
||||
const prevMedia = mediaIndex > 0 ? mediaPosts[mediaIndex - 1] : null;
|
||||
const nextMedia = mediaIndex < mediaPosts.length - 1 ? mediaPosts[mediaIndex + 1] : null;
|
||||
|
||||
const ext = (targetPost.ext || '').toLowerCase();
|
||||
const isVideo = ['.webm', '.mp4'].includes(ext);
|
||||
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
|
||||
const externalMediaUrl = targetPost.tim ? `https://i.4cdn.org/${board}/${targetPost.tim}${ext}` : null;
|
||||
const localId = (targetPost.tim && rehosts[targetPost.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null;
|
||||
|
||||
let itemRating = null;
|
||||
let itemTags = [];
|
||||
let canManage = false;
|
||||
let localDest = null;
|
||||
let localThumb = null;
|
||||
let localTitle = null;
|
||||
let itemFavorites = [];
|
||||
let userHasFavorited = false;
|
||||
if (localId) {
|
||||
try {
|
||||
const localItem = await db`SELECT id, username, dest, title, mime, width, height FROM items WHERE id = ${localId} LIMIT 1`;
|
||||
if (localItem.length > 0) {
|
||||
const li = localItem[0];
|
||||
if (li.title) localTitle = li.title;
|
||||
if (li.dest) {
|
||||
localDest = `/b/${li.dest}`;
|
||||
localThumb = `/t/${li.id}.webp`;
|
||||
}
|
||||
if (req.session) {
|
||||
canManage = !!((li.username && req.session.user && li.username.toLowerCase() === req.session.user.toLowerCase()) ||
|
||||
req.session.admin || req.session.is_moderator);
|
||||
}
|
||||
}
|
||||
itemTags = await lib.getTags(localId, req.session);
|
||||
const ratingTag = itemTags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||
if (ratingTag) itemRating = ratingTag.normalized;
|
||||
itemFavorites = await db`
|
||||
select "favorites".user_id, "user".user, "user".login, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color, "user_options".hide_fav_badge, "anon_identities".fingerprint as anon_fingerprint
|
||||
from "favorites"
|
||||
left join "user" on "user".id = "favorites".user_id
|
||||
left join "user_options" on "user_options".user_id = "favorites".user_id
|
||||
left join "anon_identities" on "anon_identities".user_id = "favorites".user_id
|
||||
where "favorites".item_id = ${localId}
|
||||
`;
|
||||
userHasFavorited = lib.userHasFavorited(req.session, itemFavorites);
|
||||
} catch (e) {
|
||||
console.error('[CHAN] Failed to fetch tags for rehosted item:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct item matching f0ckm's standard item view expectations
|
||||
const item = {
|
||||
id: targetPost.no,
|
||||
slug: null,
|
||||
title: localTitle || targetPost.sub || op.sub || `/${board}/${tid} #${targetPost.no}`,
|
||||
dest: localDest || `/api/v2/scroller/external/4chan/${board}/media/${targetPost.tim}${ext}`,
|
||||
thumbnail: localThumb || `/api/v2/scroller/external/4chan/${board}/media/${targetPost.tim}s.jpg`,
|
||||
mime: isVideo ? (ext === '.mp4' ? 'video/mp4' : 'video/webm') : (ext === '.gif' ? 'image/gif' : (ext === '.png' ? 'image/png' : 'image/jpeg')),
|
||||
width: targetPost.w || null,
|
||||
height: targetPost.h || null,
|
||||
size: targetPost.fsize ? lib.formatSize(targetPost.fsize) : '0 B',
|
||||
username: targetPost.name || 'Anonymous',
|
||||
stamp: targetPost.time,
|
||||
timestamp: {
|
||||
timeago: lib.timeAgo(targetPost.time * 1000, req.lang || 'en'),
|
||||
timefull: new Date(targetPost.time * 1000).toISOString()
|
||||
},
|
||||
comment: targetPost.com ? targetPost.com.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '') : '',
|
||||
is_chan: true,
|
||||
external_board: board,
|
||||
external_tid: tid,
|
||||
external_id: targetPost.no,
|
||||
external_media_url: externalMediaUrl,
|
||||
original_filename: targetPost.filename ? `${targetPost.filename}${ext}` : null,
|
||||
local_id: localId,
|
||||
rehosted: !!localId,
|
||||
is_sfw: itemRating === 'sfw',
|
||||
is_nsfw: itemRating === 'nsfw',
|
||||
is_nsfl: itemRating === 'nsfl',
|
||||
is_untagged: !itemRating,
|
||||
favorites: itemFavorites,
|
||||
user_has_favorited: userHasFavorited,
|
||||
halls: [],
|
||||
user_halls: [],
|
||||
tags: itemTags
|
||||
};
|
||||
|
||||
// F0ckm convention: Next post (right arrow / D) corresponds to pagination.prev,
|
||||
// Previous post (left arrow / A) corresponds to pagination.next.
|
||||
const pagination = {
|
||||
prev: nextMedia ? nextMedia.no : null,
|
||||
next: prevMedia ? prevMedia.no : null
|
||||
};
|
||||
|
||||
const link = {
|
||||
main: `/4/${board}/${tid}/`,
|
||||
mainDisplay: `/4/${board}/${tid}/`,
|
||||
suffix: ''
|
||||
};
|
||||
|
||||
// Find which rehosted items the current user has favorited
|
||||
const userFavSet = new Set();
|
||||
const rehostedIdList = Object.values(rehosts).map(Number).filter(Boolean);
|
||||
if (req.session?.id && rehostedIdList.length > 0) {
|
||||
try {
|
||||
const userFavs = await db`SELECT item_id FROM favorites WHERE user_id = ${req.session.id} AND item_id = ANY(${rehostedIdList})`;
|
||||
userFavs.forEach(f => userFavSet.add(Number(f.item_id)));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const chanMediaList = mediaPosts.map((p, idx) => {
|
||||
const pExt = (p.ext || '').toLowerCase();
|
||||
const pVid = ['.webm', '.mp4'].includes(pExt);
|
||||
const pUrl = `https://i.4cdn.org/${board}/${p.tim}${pExt}`;
|
||||
const pLocalId = (p.tim && rehosts[p.tim]) || (pUrl && rehosts[pUrl]) || null;
|
||||
return {
|
||||
no: p.no,
|
||||
tim: p.tim,
|
||||
ext: pExt,
|
||||
dest: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${pExt}`,
|
||||
thumb: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg`,
|
||||
is_video: pVid,
|
||||
is_image: !pVid,
|
||||
is_active: p.no === targetPost.no,
|
||||
index: idx + 1,
|
||||
local_id: pLocalId,
|
||||
rehosted: !!pLocalId,
|
||||
user_has_favorited: pLocalId ? userFavSet.has(Number(pLocalId)) : false,
|
||||
width: p.w || null,
|
||||
height: p.h || null
|
||||
};
|
||||
});
|
||||
|
||||
const chanThreadMeta = {
|
||||
board,
|
||||
tid,
|
||||
subject: op.sub || `Thread #${tid}`,
|
||||
active_post: targetPost.no,
|
||||
active_index: mediaIndex + 1,
|
||||
media_count: mediaPosts.length,
|
||||
media_posts: chanMediaList
|
||||
};
|
||||
|
||||
const data = {
|
||||
item,
|
||||
pagination,
|
||||
link,
|
||||
chan_thread: chanThreadMeta,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
hidePagination: true,
|
||||
enable_item_title: true,
|
||||
enable_item_slugs: false,
|
||||
user_alternative_steuerung: req.session?.user_alternative_steuerung,
|
||||
user_alternative_infobox: req.session?.user_alternative_infobox,
|
||||
can_manage_item: canManage,
|
||||
can_extract_meta: !!(canManage && localId && item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))),
|
||||
user_has_favorited: userHasFavorited,
|
||||
isSubscribed: false,
|
||||
item_username_lower: (item.username || '').toLowerCase(),
|
||||
item_rating_class: item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')),
|
||||
item_rating_label: item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?')),
|
||||
is_flash_item: false,
|
||||
is_archive_item: false,
|
||||
item_has_dimensions: !!(item.width && item.height),
|
||||
is_mod_or_admin: !!(req.session && (req.session.admin || req.session.is_moderator)),
|
||||
halls_enabled: false,
|
||||
default_layout: cfg.websrv?.default_layout || 'legacy',
|
||||
page_meta: {
|
||||
title: `/${board}/${tid}/${targetPost.no} - ${item.title}`,
|
||||
description: item.comment || `4chan media post #${targetPost.no} in /${board}/${tid}`,
|
||||
url: `https://${cfg.main.url.domain}/4/${board}/${tid}/${targetPost.no}`
|
||||
}
|
||||
};
|
||||
|
||||
// Handle AJAX requests from loadItemAjax
|
||||
const isAjax = req.headers['x-requested-with'] === 'XMLHttpRequest' ||
|
||||
req.url.qs?.ajax === '1' ||
|
||||
(req.headers.accept && req.headers.accept.includes('application/json'));
|
||||
|
||||
if (isAjax) {
|
||||
const isModern = req.session?.use_new_layout;
|
||||
const partialTpl = isModern ? 'item-partial-modern' : 'item-partial-legacy';
|
||||
const html = tpl.render(partialTpl, data, req);
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
html,
|
||||
item,
|
||||
pagination,
|
||||
chan_thread: chanThreadMeta
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Standard full page render
|
||||
return res.reply({
|
||||
body: tpl.render('item', data, req)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[CHAN] Failed to load item /4/${board}/${tid}/${postNo}:`, err.message);
|
||||
const isAjax = req.headers['x-requested-with'] === 'XMLHttpRequest' ||
|
||||
req.url.qs?.ajax === '1' ||
|
||||
(req.headers.accept && req.headers.accept.includes('application/json'));
|
||||
if (isAjax) {
|
||||
return res.reply({
|
||||
code: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
message: `Could not load media post #${postNo} in /${board}/${tid}: ${err.message}`
|
||||
})
|
||||
});
|
||||
}
|
||||
return res.reply({
|
||||
code: 404,
|
||||
body: tpl.render('error', {
|
||||
message: `Could not load media post #${postNo} in /${board}/${tid}.`,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 4. GET /api/v2/chan/:board/:thread/media — Media list for thread (sidebar)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/api\/v2\/chan\/(?<board>[a-z0-9]+)\/(?<thread>\d+)\/media\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const board = req.params.board.toLowerCase();
|
||||
const tid = Number(req.params.thread);
|
||||
|
||||
try {
|
||||
const threadData = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`);
|
||||
const posts = threadData.posts || [];
|
||||
const op = posts[0] || {};
|
||||
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||
|
||||
const cdnUrls = [];
|
||||
mediaPosts.forEach(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||
});
|
||||
const rehosts = {};
|
||||
if (cdnUrls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`;
|
||||
rows.forEach(r => {
|
||||
rehosts[r.src] = r.id;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) rehosts[m[1]] = r.id;
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const media = mediaPosts.map((p, idx) => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
const isVideo = ['.webm', '.mp4'].includes(ext);
|
||||
const externalMediaUrl = `https://i.4cdn.org/${board}/${p.tim}${ext}`;
|
||||
return {
|
||||
no: p.no,
|
||||
tim: p.tim,
|
||||
ext,
|
||||
filename: p.filename ? `${p.filename}${ext}` : null,
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
dest: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`,
|
||||
thumb: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg`,
|
||||
is_video: isVideo,
|
||||
is_image: !isVideo,
|
||||
index: idx + 1,
|
||||
local_id: (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null,
|
||||
rehosted: !!((p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]))
|
||||
};
|
||||
});
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
board,
|
||||
tid,
|
||||
subject: op.sub || `Thread #${tid}`,
|
||||
total: media.length,
|
||||
items: media,
|
||||
media
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[CHAN] Media API error for /${board}/${tid}:`, err.message);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, msg: 'Failed to fetch thread media' })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
+196
-28
@@ -5,6 +5,7 @@ import queue from "../queue.mjs";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs";
|
||||
import { applyWordFilter } from "../wordfilter.mjs";
|
||||
|
||||
/**
|
||||
* external.mjs — External source handlers (4chan threads, etc.)
|
||||
@@ -67,7 +68,7 @@ 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+)\/?$/, lib.loggedin, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/(?<tid>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, tid } = req.params || {};
|
||||
|
||||
@@ -86,11 +87,23 @@ export default (router) => {
|
||||
// Check which media URLs are already rehosted on this platform
|
||||
const rehosts = {};
|
||||
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||
const cdn4Urls = mediaPosts.map(p => `https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
const cdn4Urls = [];
|
||||
mediaPosts.forEach(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
cdn4Urls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdn4Urls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdn4Urls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdn4Urls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdn4Urls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||
});
|
||||
if (cdn4Urls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src FROM items WHERE src IN (${cdn4Urls})`;
|
||||
rows.forEach(r => { rehosts[r.src] = r.id; });
|
||||
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdn4Urls})`;
|
||||
rows.forEach(r => {
|
||||
rehosts[r.src] = r.id;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) rehosts[m[1]] = r.id;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[EXTERNAL] DB src check error:', e.message);
|
||||
}
|
||||
@@ -159,7 +172,7 @@ 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\/?$/, lib.loggedin, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/catalog\/?$/, lib.chanAuth, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board } = req.params || {};
|
||||
if (!board) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
|
||||
@@ -197,7 +210,7 @@ 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+)\/?$/, lib.loggedin, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/find\/(?<postno>\d+)\/?$/, lib.chanAuth, 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 }) });
|
||||
@@ -259,7 +272,7 @@ export default (router) => {
|
||||
// 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'];
|
||||
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, lib.loggedin, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, lib.chanAuth, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, file } = req.params || {};
|
||||
|
||||
@@ -318,8 +331,8 @@ export default (router) => {
|
||||
|
||||
// POST /api/v2/scroller/rehost
|
||||
// Downloads an external item and adds it to the platform
|
||||
router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.loggedin, async (req, res) => {
|
||||
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc, original_filename } = req.post || {};
|
||||
router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc, original_filename, width: postWidth, height: postHeight } = req.post || {};
|
||||
|
||||
if (!url) return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL is required' }) });
|
||||
|
||||
@@ -335,13 +348,11 @@ export default (router) => {
|
||||
|| url.match(/\/4chan\/([a-z0-9]+)\/media\//)?.[1]
|
||||
|| null;
|
||||
|
||||
let rating = initialRating;
|
||||
if (board === 'gif') rating = 'nsfw';
|
||||
else if (board === 'wsg') rating = 'sfw';
|
||||
|
||||
if (!rating || !['sfw', 'nsfw', 'nsfl'].includes(rating)) {
|
||||
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Rating is required' }) });
|
||||
}
|
||||
// Rating is optional: do not force sfw/nsfw based on board
|
||||
const validRatings = ['sfw', 'nsfw', 'nsfl'];
|
||||
const rating = (initialRating && validRatings.includes(String(initialRating).toLowerCase()))
|
||||
? String(initialRating).toLowerCase()
|
||||
: null;
|
||||
|
||||
const session = req.session;
|
||||
|
||||
@@ -418,6 +429,29 @@ export default (router) => {
|
||||
}
|
||||
}
|
||||
|
||||
let itemWidth = Number(postWidth) || null;
|
||||
let itemHeight = Number(postHeight) || null;
|
||||
if (!itemWidth || !itemHeight) {
|
||||
try {
|
||||
if (mime.startsWith('image/')) {
|
||||
const { stdout: magickOut } = await queue.spawn('magick', [
|
||||
'identify', '-format', '%wx%h\n', finalTmp + '[0]'
|
||||
], { quiet: true, ignoreExitCode: true });
|
||||
const m = magickOut.trim().split('\n')[0].match(/^(\d+)x(\d+)$/);
|
||||
if (m) { itemWidth = parseInt(m[1], 10); itemHeight = parseInt(m[2], 10); }
|
||||
} else if (mime.startsWith('video/')) {
|
||||
const { stdout: probeOut } = await queue.spawn('ffprobe', [
|
||||
'-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', finalTmp
|
||||
], { quiet: true, ignoreExitCode: true });
|
||||
const parts = probeOut.trim().split(',');
|
||||
if (parts.length >= 2) {
|
||||
const w = parseInt(parts[0], 10), h = parseInt(parts[1], 10);
|
||||
if (w > 0 && h > 0) { itemWidth = w; itemHeight = h; }
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const filename = `${uuid}.${ext}`;
|
||||
const isApprovalRequired = getManualApproval();
|
||||
const destDir = isApprovalRequired ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
|
||||
@@ -442,8 +476,10 @@ export default (router) => {
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc,
|
||||
original_filename: original_filename || null,
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
slug: lib.generateSlug(11)
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'slug')}
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'width', 'height', 'slug')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
@@ -462,16 +498,14 @@ export default (router) => {
|
||||
console.error('[REHOST] Thumbnail error:', err);
|
||||
}
|
||||
|
||||
// Tags
|
||||
// Tags: Only assign rating tag if explicitly specified; do NOT auto-tag board or sfw/nsfw
|
||||
if (rating) {
|
||||
const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: session.id })} on conflict do nothing`;
|
||||
}
|
||||
|
||||
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
// Board tag in chan-style format e.g. /gif/, /wsg/
|
||||
if (board) tags.push(`/${board}/`);
|
||||
// Auto-tag rating based on board
|
||||
if (board === 'wsg') tags.push('sfw');
|
||||
else if (board === 'gif') tags.push('nsfw');
|
||||
const rawList = Array.isArray(tagsRaw) ? tagsRaw : (typeof tagsRaw === 'string' ? tagsRaw.split(',') : []);
|
||||
const tags = rawList.map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
|
||||
for (const tagName of tags) {
|
||||
let tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
|
||||
if (tagRow.length === 0) {
|
||||
@@ -483,9 +517,21 @@ export default (router) => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// Insert optional first comment if provided
|
||||
if (comment && typeof comment === 'string' && comment.trim().length > 0) {
|
||||
try {
|
||||
const filteredComment = await applyWordFilter(comment.trim());
|
||||
await db`
|
||||
INSERT INTO comments ${db({
|
||||
item_id: itemid,
|
||||
user_id: session.id,
|
||||
content: filteredComment
|
||||
})}
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error('[REHOST] Comment insert error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
await db`INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${session.id}, 'upload_success', 0, ${itemid})`;
|
||||
|
||||
@@ -498,7 +544,7 @@ export default (router) => {
|
||||
mime: mime,
|
||||
username: session.user,
|
||||
display_name: session.display_name || null,
|
||||
tag_id: rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)),
|
||||
tag_id: rating ? (rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: false,
|
||||
is_album: false,
|
||||
album_count: 0
|
||||
@@ -542,5 +588,127 @@ export default (router) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/v2/scroller/rehost/details/:id
|
||||
// Retrieve current rating and tags for a rehosted item
|
||||
router.get(/^\/api\/v2\/scroller\/rehost\/details\/(?<id>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const itemId = Number(req.params.id);
|
||||
try {
|
||||
const tags = await lib.getTags(itemId, req.session);
|
||||
const ratingTag = tags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||
const rating = ratingTag ? ratingTag.normalized : 'untagged';
|
||||
const userTags = tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => t.tag);
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, item_id: itemId, rating, tags: userTags })
|
||||
});
|
||||
} catch (err) {
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, msg: err.message })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v2/scroller/rehost/details
|
||||
// Set rating, add tags, and or post a comment directly on a rehosted item
|
||||
router.post(/^\/api\/v2\/scroller\/rehost\/details\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const session = req.session;
|
||||
if (!session || !session.user) {
|
||||
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) });
|
||||
}
|
||||
|
||||
const { item_id, rating, tags, comment } = req.post || {};
|
||||
const itemId = parseInt(item_id, 10);
|
||||
if (!itemId || isNaN(itemId)) {
|
||||
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Valid item_id is required' }) });
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await db`SELECT id, username FROM items WHERE id = ${itemId} AND active = true AND is_deleted = false LIMIT 1`;
|
||||
if (!rows.length) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false, msg: 'Item not found' }) });
|
||||
}
|
||||
|
||||
const isOwner = !!(rows[0].username && session.user && rows[0].username.toLowerCase() === session.user.toLowerCase());
|
||||
const isAdmin = !!(session.admin || session.is_moderator);
|
||||
if (!isOwner && !isAdmin) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) });
|
||||
}
|
||||
|
||||
// 1. Update Rating if provided
|
||||
if (rating !== undefined && rating !== null && rating !== '') {
|
||||
const r = String(rating).toLowerCase().trim();
|
||||
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
|
||||
const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
|
||||
|
||||
await db`
|
||||
DELETE FROM tags_assign
|
||||
WHERE item_id = ${itemId}
|
||||
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
|
||||
`;
|
||||
|
||||
if (r === 'sfw') {
|
||||
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: 1, user_id: session.id })} ON CONFLICT DO NOTHING`;
|
||||
} else if (r === 'nsfw') {
|
||||
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: 2, user_id: session.id })} ON CONFLICT DO NOTHING`;
|
||||
} else if (r === 'nsfl') {
|
||||
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: nsflId, user_id: session.id })} ON CONFLICT DO NOTHING`;
|
||||
}
|
||||
// If r === 'untagged', no rating tag is inserted
|
||||
}
|
||||
|
||||
// 2. Add Tags if provided
|
||||
const rawList = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(',') : []);
|
||||
const cleanTags = rawList.map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
|
||||
for (const tagName of cleanTags) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagName }, 'tag')} ON CONFLICT DO NOTHING`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
}
|
||||
if (tagRow.length) {
|
||||
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: tagRow[0].id, user_id: session.id })} ON CONFLICT DO NOTHING`;
|
||||
}
|
||||
}
|
||||
if (cleanTags.length > 0) {
|
||||
const freshTags = await lib.getTags(itemId, session);
|
||||
await db.notify('tags', JSON.stringify({ item_id: itemId, fresh: true, tags: freshTags }));
|
||||
}
|
||||
|
||||
// 3. Add Comment if provided
|
||||
if (comment && typeof comment === 'string' && comment.trim().length > 0) {
|
||||
const filteredComment = await applyWordFilter(comment.trim());
|
||||
await db`
|
||||
INSERT INTO comments ${db({
|
||||
item_id: itemId,
|
||||
user_id: session.id,
|
||||
content: filteredComment
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
const freshTags = await lib.getTags(itemId, session);
|
||||
const ratingTag = freshTags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
item_id: itemId,
|
||||
rating: ratingTag ? ratingTag.normalized : 'untagged',
|
||||
tags: freshTags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => t.tag)
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[REHOST-DETAILS] Error:', err);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, msg: 'Failed to save details' })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -371,8 +371,13 @@ export default (router, tpl) => {
|
||||
data.item.otherHalls = [];
|
||||
}
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
// Mark notifications as read
|
||||
if (req.session?.id) {
|
||||
const isPrefetch = !!(
|
||||
req.headers?.['x-purpose'] === 'prefetch' ||
|
||||
req.headers?.['purpose'] === 'prefetch' ||
|
||||
req.headers?.['sec-purpose'] === 'prefetch'
|
||||
);
|
||||
// Mark notifications as read (only when actually viewed, not on prefetch)
|
||||
if (req.session?.id && !isPrefetch) {
|
||||
f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {});
|
||||
}
|
||||
// Subscription status — just a boolean, cheap to embed
|
||||
@@ -652,7 +657,7 @@ export default (router, tpl) => {
|
||||
|
||||
// Generic router for everything else (Index, Tags, standard User Grids)
|
||||
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them.
|
||||
router.get(/^(?!\/(s|b|t|ca|a|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
|
||||
router.get(/^(?!\/(s|b|t|ca|a|4|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
|
||||
/* </routing-refactor> */
|
||||
|
||||
router.get(/^\/(about)$/, (req, res) => {
|
||||
|
||||
@@ -235,7 +235,12 @@ 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 isPrefetch = !!(
|
||||
req.headers?.['x-purpose'] === 'prefetch' ||
|
||||
req.headers?.['purpose'] === 'prefetch' ||
|
||||
req.headers?.['sec-purpose'] === 'prefetch'
|
||||
);
|
||||
if (req.session?.id && !isPrefetch) f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {});
|
||||
data.isSubscribed = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
|
||||
|
||||
// xD Score
|
||||
|
||||
+16
-2
@@ -604,6 +604,7 @@ process.on('uncaughtException', err => {
|
||||
const runMigration = async (query) => {
|
||||
try { await query; } catch (e) { /* ignore if table doesn't exist yet */ }
|
||||
};
|
||||
await runMigration(db`ALTER TABLE "user" ADD COLUMN IF NOT EXISTS groups text[] DEFAULT '{}'`);
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL`);
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center'`);
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover'`);
|
||||
@@ -989,7 +990,7 @@ process.on('uncaughtException', err => {
|
||||
user = [_cachedRow];
|
||||
} else {
|
||||
user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".banner_file, "user_options".banner_position, "user_options".banner_size, "user_options".banner_repeat, "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".favorites_private, "user_options".hide_fav_badge, "user_options".default_upload_visibility, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode, "anon_identities".fingerprint as anon_fingerprint
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".groups, "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".banner_file, "user_options".banner_position, "user_options".banner_size, "user_options".banner_repeat, "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".favorites_private, "user_options".hide_fav_badge, "user_options".default_upload_visibility, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode, "anon_identities".fingerprint as anon_fingerprint
|
||||
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
|
||||
@@ -1024,6 +1025,9 @@ process.on('uncaughtException', err => {
|
||||
}
|
||||
}
|
||||
// csrf_token is loaded from user_sessions table via the session query above
|
||||
if (req.session) {
|
||||
req.session.can_chan = !!(req.session.admin || (Array.isArray(req.session.groups) && req.session.groups.includes('4chan')));
|
||||
}
|
||||
|
||||
// Ban check (Session)
|
||||
if (req.session && req.session.banned && !req.url.pathname.match(/^\/(banned|logout)(\/)?$/)) {
|
||||
@@ -1114,7 +1118,7 @@ process.on('uncaughtException', err => {
|
||||
// Validate that the original session matches the current admin cookie
|
||||
if (impData.orig && impData.orig === lib.sha256(req.cookies.session)) {
|
||||
const targetRow = 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,
|
||||
SELECT "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".groups, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change,
|
||||
"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".favorites_private, "user_options".hide_fav_badge,
|
||||
@@ -1956,6 +1960,7 @@ process.on('uncaughtException', err => {
|
||||
return (c !== undefined && c !== null) ? !!c : null;
|
||||
},
|
||||
is_onara_item: false,
|
||||
can_extract_meta: false,
|
||||
enable_private_uploads: cfg.enable_private_uploads !== false,
|
||||
get enable_expiring_uploads() { return getEnableExpiringUploads(); },
|
||||
get enable_item_slugs() { return getEnableItemSlugs(); },
|
||||
@@ -2115,6 +2120,13 @@ process.on('uncaughtException', err => {
|
||||
const activeSession = activeReq?.session || data?.session || null;
|
||||
const isAnonymized = isAnonymizeSession(activeSession);
|
||||
const anonAnonymize = getAnonAnonymize();
|
||||
const canChan = !!(activeSession && (activeSession.admin || (Array.isArray(activeSession.groups) && activeSession.groups.includes('4chan'))));
|
||||
if (activeSession && typeof activeSession === 'object') {
|
||||
activeSession.can_chan = canChan;
|
||||
}
|
||||
if (data && data.item && typeof data.item.size === 'number') {
|
||||
data.item.size = lib.formatSize(data.item.size);
|
||||
}
|
||||
|
||||
data = Object.assign({}, globals, data || {}, {
|
||||
t: perRequestT,
|
||||
@@ -2122,6 +2134,8 @@ process.on('uncaughtException', err => {
|
||||
recaptcha_enabled: perRequestRecaptcha,
|
||||
is_anonymized: isAnonymized,
|
||||
anon_anonymize: anonAnonymize,
|
||||
can_chan: canChan,
|
||||
item_has_dimensions: !!(data && data.item && data.item.width && data.item.height),
|
||||
user_alternative_infobox: useAltInfobox,
|
||||
user_alternative_steuerung: useAltSteuerung,
|
||||
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||
|
||||
@@ -926,6 +926,40 @@
|
||||
}
|
||||
}, { hideReason: true, confirmText: 'Apply Role', unsafeContent: true });
|
||||
}
|
||||
|
||||
async function adminSetGroups(btn) {
|
||||
var id = btn.dataset.id;
|
||||
var userName = btn.dataset.name;
|
||||
var currentGroups = (btn.dataset.groups || '').split(',').map(function(s) { return s.trim().toLowerCase(); }).filter(Boolean);
|
||||
var is4chan = currentGroups.indexOf('4chan') !== -1;
|
||||
|
||||
var hint = 'Manage groups for <strong>' + escHTML(userName) + '</strong>:<br><br>' +
|
||||
'<label style="display:flex;align-items:center;gap:10px;cursor:pointer;padding:8px 12px;background:rgba(255,255,255,0.05);border-radius:6px;border:1px solid rgba(255,255,255,0.1);">' +
|
||||
'<input type="checkbox" id="group-4chan-checkbox" value="4chan"' + (is4chan ? ' checked' : '') + ' style="width:18px;height:18px;accent-color:#4ade80;cursor:pointer;">' +
|
||||
'<span><strong style="color:#4ade80;">🍀 4chan</strong> — Grants access to the 4chan catalogue, thread viewer, and rehosting</span>' +
|
||||
'</label>';
|
||||
|
||||
ModAction.confirm('Manage Groups', hint, async () => {
|
||||
var chk = document.getElementById('group-4chan-checkbox') || document.querySelector('#mod-action-content input[type="checkbox"]');
|
||||
var newGroups = [];
|
||||
if (chk && chk.checked) newGroups.push('4chan');
|
||||
|
||||
var res = await fetch('/api/v2/admin/users/set-groups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||
body: JSON.stringify({ user_id: id, groups: newGroups })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success) {
|
||||
showFlash(data.msg, 'success');
|
||||
btn.dataset.groups = newGroups.join(',');
|
||||
var labelEl = document.querySelector('.groups-label-' + id);
|
||||
if (labelEl) labelEl.textContent = newGroups.indexOf('4chan') !== -1 ? '4chan' : 'Groups';
|
||||
} else {
|
||||
throw new Error(data.msg || 'Failed to update groups');
|
||||
}
|
||||
}, { hideReason: true, confirmText: 'Save Groups', unsafeContent: true });
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
<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 }}" data-role="{{ u.admin ? 'admin' : (u.is_moderator ? 'mod' : 'user') }}" onclick="adminSetRole(this)" class="btn-modern btn-role" id="role-btn-{{ u.id }}" title="Change user role"><i class="fa fa-shield-halved"></i> <span class="role-label-{{ u.id }}">{{ u.admin ? 'Admin' : (u.is_moderator ? 'Mod' : 'User') }}</span></button>
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" data-groups="{{ (u.groups || []).join(',') }}" onclick="adminSetGroups(this)" class="btn-modern btn-groups" id="groups-btn-{{ u.id }}" style="background: rgba(74, 222, 128, 0.1); color: #4ade80; border: 1px solid rgba(74, 222, 128, 0.2);" title="Manage user groups"><i class="fa-solid fa-clover"></i> <span class="groups-label-{{ u.id }}">{{ (u.groups && u.groups.includes('4chan')) ? '4chan' : 'Groups' }}</span></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>
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main" class="chan-page chan-catalogue-page">
|
||||
<div class="chan-header-bar">
|
||||
<div class="chan-brand-title">
|
||||
<span class="chan-clover-icon"><i class="fa-solid fa-clover"></i></span>
|
||||
<h1>/{{ board }}/ <span class="chan-subtitle">Catalogue</span></h1>
|
||||
<span class="chan-thread-count-badge">{{ threads.length }} threads</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick board selector -->
|
||||
<div class="chan-board-nav">
|
||||
@each(common_boards as b)
|
||||
<a href="/4/{{ b.id }}/catalogue" class="chan-board-pill {{ b.id === board ? 'active' : '' }}" title="{{ b.name }}">
|
||||
/{{ b.id }}/
|
||||
</a>
|
||||
@endeach
|
||||
<form class="chan-board-custom-form" onsubmit="event.preventDefault(); const b = document.getElementById('custom-board-input').value.trim().toLowerCase(); if(b) window.location.href='/4/' + b + '/catalogue';">
|
||||
<input type="text" id="custom-board-input" placeholder="board…" maxlength="12" class="chan-custom-board-input" />
|
||||
<button type="submit" class="chan-custom-board-btn" title="Go to board"><i class="fa-solid fa-arrow-right"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Controls: Filter & Sort -->
|
||||
<div class="chan-controls-bar">
|
||||
<div class="chan-search-wrap">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
<input type="text" id="chan-catalog-search" class="chan-search-input" placeholder="Filter threads by subject, text, or post #…" autocomplete="off" />
|
||||
<button type="button" id="chan-search-clear" style="display:none;" title="Clear search"><i class="fa-solid fa-xmark"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="chan-sort-wrap">
|
||||
<label for="chan-sort-select"><i class="fa-solid fa-arrow-down-wide-short"></i> Sort:</label>
|
||||
<select id="chan-sort-select" class="chan-select">
|
||||
<option value="bump">Bump Order</option>
|
||||
<option value="replies">Most Replies</option>
|
||||
<option value="images">Most Images</option>
|
||||
<option value="date-new">Newest First</option>
|
||||
<option value="date-old">Oldest First</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Catalogue Thread Grid -->
|
||||
<div class="chan-catalogue-grid" id="chan-catalogue-grid">
|
||||
@each(threads as t)
|
||||
<div class="chan-thread-card" data-no="{{ t.no }}" data-replies="{{ t.replies }}" data-images="{{ t.images }}" data-time="{{ t.time }}" data-sticky="{{ t.sticky ? 1 : 0 }}">
|
||||
<a href="/4/{{ board }}/{{ t.no }}" class="chan-thread-thumb-link">
|
||||
@if(t.thumb)
|
||||
<img src="{{ t.thumb }}" alt="Thread #{{ t.no }}" loading="lazy" class="chan-thread-thumb" onerror="this.style.opacity='0.2';" />
|
||||
@else
|
||||
<div class="chan-thread-thumb-empty"><i class="fa-solid fa-file"></i></div>
|
||||
@endif
|
||||
|
||||
<div class="chan-card-badges">
|
||||
@if(t.sticky)
|
||||
<span class="chan-card-badge chan-badge-sticky" title="Sticky"><i class="fa-solid fa-thumbtack"></i></span>
|
||||
@endif
|
||||
@if(t.closed)
|
||||
<span class="chan-card-badge chan-badge-closed" title="Closed"><i class="fa-solid fa-lock"></i></span>
|
||||
@endif
|
||||
@if(t.local_id)
|
||||
<span class="chan-card-badge chan-badge-rehosted" title="OP Rehosted on f0ckm as Post #{{ t.local_id }}" style="background: rgba(16, 185, 129, 0.9); color: #fff; font-weight: 700;"><i class="fa-solid fa-check"></i> #{{ t.local_id }}</span>
|
||||
@endif
|
||||
<span class="chan-card-badge chan-badge-stats">
|
||||
<span title="Replies"><i class="fa-solid fa-comment"></i> {{ t.replies }}</span>
|
||||
<span title="Images"><i class="fa-solid fa-image"></i> {{ t.images }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="chan-thread-info">
|
||||
<a href="/4/{{ board }}/{{ t.no }}" class="chan-thread-title">
|
||||
@if(t.sub)
|
||||
<strong class="chan-subject">{{ t.sub }}</strong>
|
||||
@else
|
||||
<span class="chan-no-sub">No. {{ t.no }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@if(t.com)
|
||||
<p class="chan-teaser">{{ t.com }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endeach
|
||||
</div>
|
||||
|
||||
<div id="chan-no-results" class="chan-empty-state" style="display: none;">
|
||||
<i class="fa-solid fa-search"></i>
|
||||
<p>No threads match your search filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chan-catalogue-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 15px 60px;
|
||||
}
|
||||
.chan-header-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
margin-bottom: 25px;
|
||||
background: var(--bg-card, rgba(25, 25, 25, 0.85));
|
||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.chan-brand-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chan-clover-icon {
|
||||
font-size: 1.8rem;
|
||||
color: #4ade80;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.chan-brand-title h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
color: var(--text-main, #fff);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.chan-subtitle {
|
||||
font-weight: 400;
|
||||
opacity: 0.6;
|
||||
font-size: 1.2rem;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.chan-thread-count-badge {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
|
||||
/* Board nav */
|
||||
.chan-board-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chan-board-pill {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted, #aaa);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.chan-board-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.chan-board-pill.active {
|
||||
background: #4ade80;
|
||||
color: #111;
|
||||
border-color: #4ade80;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chan-board-custom-form {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.chan-custom-board-input {
|
||||
width: 75px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
}
|
||||
.chan-custom-board-input:focus {
|
||||
border-color: #4ade80;
|
||||
}
|
||||
.chan-custom-board-btn {
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.chan-custom-board-btn:hover {
|
||||
background: #4ade80;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.chan-controls-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.chan-search-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.chan-search-wrap i.fa-magnifying-glass {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: #777;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chan-search-input {
|
||||
width: 100%;
|
||||
padding: 8px 34px 8px 34px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.chan-search-input:focus {
|
||||
border-color: #4ade80;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
#chan-search-clear {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
.chan-sort-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #aaa;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.chan-select {
|
||||
padding: 6px 12px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chan-select option {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.chan-catalogue-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.chan-thread-card {
|
||||
background: var(--bg-card, rgba(25, 25, 25, 0.85));
|
||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.chan-thread-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(74, 222, 128, 0.4);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.chan-thread-thumb-link {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
background: #0d0d0d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
}
|
||||
.chan-thread-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.chan-thread-card:hover .chan-thread-thumb {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.chan-thread-thumb-empty {
|
||||
font-size: 2rem;
|
||||
color: #333;
|
||||
}
|
||||
.chan-card-badges {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chan-card-badge {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.chan-badge-sticky {
|
||||
color: #4ade80;
|
||||
border-color: rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
.chan-badge-closed {
|
||||
color: #ff6b6b;
|
||||
border-color: rgba(255, 107, 107, 0.3);
|
||||
}
|
||||
.chan-badge-stats {
|
||||
margin-left: auto;
|
||||
}
|
||||
.chan-thread-info {
|
||||
padding: 10px 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
.chan-thread-title {
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chan-subject {
|
||||
color: #4ade80;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chan-no-sub {
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.chan-teaser {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: #888;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
.chan-empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #777;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.chan-empty-state i {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.4;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const searchInput = document.getElementById('chan-catalog-search');
|
||||
const searchClear = document.getElementById('chan-search-clear');
|
||||
const sortSelect = document.getElementById('chan-sort-select');
|
||||
const grid = document.getElementById('chan-catalogue-grid');
|
||||
const noResults = document.getElementById('chan-no-results');
|
||||
if (!grid) return;
|
||||
|
||||
const originalCards = Array.from(grid.querySelectorAll('.chan-thread-card'));
|
||||
|
||||
function filterAndSort() {
|
||||
const q = (searchInput?.value || '').trim().toLowerCase();
|
||||
const sort = sortSelect?.value || 'bump';
|
||||
|
||||
if (searchClear) {
|
||||
searchClear.style.display = q ? 'block' : 'none';
|
||||
}
|
||||
|
||||
let visibleCards = originalCards.filter(card => {
|
||||
if (!q) return true;
|
||||
const no = card.dataset.no || '';
|
||||
const text = card.textContent.toLowerCase();
|
||||
return no.includes(q) || text.includes(q);
|
||||
});
|
||||
|
||||
// Sort
|
||||
visibleCards.sort((a, b) => {
|
||||
const stickyA = parseInt(a.dataset.sticky || 0, 10);
|
||||
const stickyB = parseInt(b.dataset.sticky || 0, 10);
|
||||
if (stickyA !== stickyB) return stickyB - stickyA; // Stickies always first
|
||||
|
||||
if (sort === 'replies') {
|
||||
return parseInt(b.dataset.replies || 0, 10) - parseInt(a.dataset.replies || 0, 10);
|
||||
} else if (sort === 'images') {
|
||||
return parseInt(b.dataset.images || 0, 10) - parseInt(a.dataset.images || 0, 10);
|
||||
} else if (sort === 'date-new') {
|
||||
return parseInt(b.dataset.time || 0, 10) - parseInt(a.dataset.time || 0, 10);
|
||||
} else if (sort === 'date-old') {
|
||||
return parseInt(a.dataset.time || 0, 10) - parseInt(b.dataset.time || 0, 10);
|
||||
}
|
||||
// Bump order: preserve original order
|
||||
return originalCards.indexOf(a) - originalCards.indexOf(b);
|
||||
});
|
||||
|
||||
originalCards.forEach(c => c.style.display = 'none');
|
||||
visibleCards.forEach(c => {
|
||||
c.style.display = 'flex';
|
||||
grid.appendChild(c);
|
||||
});
|
||||
|
||||
if (noResults) {
|
||||
noResults.style.display = visibleCards.length === 0 ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', filterAndSort);
|
||||
}
|
||||
if (searchClear) {
|
||||
searchClear.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
filterAndSort();
|
||||
searchInput.focus();
|
||||
});
|
||||
}
|
||||
if (sortSelect) {
|
||||
sortSelect.addEventListener('change', filterAndSort);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@include(snippets/footer)
|
||||
@@ -0,0 +1,855 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main" class="chan-page chan-thread-page">
|
||||
|
||||
<!-- Top Header Navigation -->
|
||||
<div class="chan-header-bar chan-thread-header">
|
||||
<div class="chan-thread-nav-row">
|
||||
<a href="/4/{{ board }}/catalogue" class="chan-back-btn">
|
||||
<i class="fa-solid fa-arrow-left"></i> /{{ board }}/ Catalogue
|
||||
</a>
|
||||
<div class="chan-thread-actions">
|
||||
@if(first_media_no)
|
||||
<a href="/4/{{ board }}/{{ tid }}/{{ first_media_no }}" class="chan-action-btn primary" title="Open thread media in player view">
|
||||
<i class="fa-solid fa-play"></i> Player View
|
||||
</a>
|
||||
@endif
|
||||
<a href="/scroller#{{ board }}/{{ tid }}" class="chan-action-btn" title="Open in vertical video scroller">
|
||||
<i class="fa-solid fa-mobile-screen"></i> Scroller
|
||||
</a>
|
||||
<a href="https://boards.4chan.org/{{ board }}/thread/{{ tid }}" target="_blank" rel="noopener noreferrer" class="chan-action-btn" title="View original thread on 4chan">
|
||||
<i class="fa-solid fa-arrow-up-right-from-square"></i> 4chan.org
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chan-thread-title-row">
|
||||
<span class="chan-clover-icon"><i class="fa-solid fa-clover"></i></span>
|
||||
<div class="chan-thread-title-info">
|
||||
<h2>
|
||||
@if(op.sub)
|
||||
{{ op.sub }}
|
||||
@else
|
||||
Thread #{{ tid }}
|
||||
@endif
|
||||
</h2>
|
||||
<div class="chan-thread-meta">
|
||||
<span class="chan-meta-item"><i class="fa-solid fa-comments"></i> {{ posts.length }} posts</span>
|
||||
<span class="chan-meta-item"><i class="fa-solid fa-image"></i> {{ media_count }} media files</span>
|
||||
@if(rehosts_count > 0)
|
||||
<span class="chan-meta-item" style="color: #4ade80;"><i class="fa-solid fa-check"></i> {{ rehosts_count }} rehosted</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Toggle: All vs Media Only vs Rehosted -->
|
||||
<div class="chan-filter-toggle">
|
||||
<button type="button" class="chan-toggle-btn active" id="filter-all-btn">All Posts ({{ posts.length }})</button>
|
||||
<button type="button" class="chan-toggle-btn" id="filter-media-btn">Media Only ({{ media_count }})</button>
|
||||
@if(rehosts_count > 0)
|
||||
<button type="button" class="chan-toggle-btn" id="filter-rehosted-btn" style="color: #4ade80;"><i class="fa-solid fa-check"></i> Rehosted ({{ rehosts_count }})</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts Stream -->
|
||||
<div class="chan-posts-stream" id="chan-posts-stream">
|
||||
@each(posts as p)
|
||||
<article class="chan-post-card {{ p.no === tid ? 'is-op' : 'is-reply' }} {{ p.has_media ? 'has-media' : 'no-media' }} {{ p.local_id ? 'is-rehosted' : '' }}" id="p{{ p.no }}" data-no="{{ p.no }}" data-has-media="{{ p.has_media ? 1 : 0 }}" data-is-rehosted="{{ p.local_id ? 1 : 0 }}">
|
||||
|
||||
<!-- Post Header -->
|
||||
<div class="chan-post-header">
|
||||
<span class="chan-post-name">{{ p.name }}</span>
|
||||
@if(p.trip)
|
||||
<span class="chan-post-trip">{{ p.trip }}</span>
|
||||
@endif
|
||||
<time class="chan-post-time" title="{{ p.date_str }}">{{ p.timeago }}</time>
|
||||
<a href="#p{{ p.no }}" class="chan-post-no">No. {{ p.no }}</a>
|
||||
@if(p.local_id)
|
||||
<a href="/{{ p.local_id }}" target="_blank" class="chan-post-rehosted-pill" title="Already rehosted on f0ckm as Post #{{ p.local_id }}">
|
||||
<i class="fa-solid fa-check"></i> #{{ p.local_id }}
|
||||
</a>
|
||||
@endif
|
||||
@if(p.sub && p.no !== tid)
|
||||
<span class="chan-reply-sub">{{ p.sub }}</span>
|
||||
@endif
|
||||
@if(p.has_media)
|
||||
<div class="chan-post-media-badge">
|
||||
<i class="fa-solid fa-paperclip"></i>
|
||||
<span>{{ p.ext.toUpperCase().replace('.', '') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Post Content (Media + Comment) -->
|
||||
<div class="chan-post-content">
|
||||
@if(p.has_media)
|
||||
<div class="chan-media-wrapper">
|
||||
<div class="chan-thumb-box">
|
||||
<a href="/4/{{ board }}/{{ tid }}/{{ p.no }}" class="chan-thumb-link" title="Open in player view">
|
||||
@if(p.thumb)
|
||||
<img src="{{ p.thumb }}" alt="Media #{{ p.no }}" class="chan-thumb-img" loading="lazy" />
|
||||
@else
|
||||
<div class="chan-no-thumb"><i class="fa-solid fa-file"></i></div>
|
||||
@endif
|
||||
@if(p.is_video)
|
||||
<span class="chan-video-indicator"><i class="fa-solid fa-play"></i></span>
|
||||
@endif
|
||||
@if(p.local_id)
|
||||
<span class="chan-thumb-rehost-badge" title="Rehosted on f0ckm as Post #{{ p.local_id }}"><i class="fa-solid fa-check"></i> #{{ p.local_id }}</span>
|
||||
@endif
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="chan-file-info">
|
||||
<div class="chan-filename-row">
|
||||
<a href="{{ p.external_media_url }}" target="_blank" rel="noopener noreferrer" class="chan-file-download" title="Open full media in new tab">
|
||||
<i class="fa-solid fa-arrow-up-right-from-square"></i>
|
||||
<span class="chan-file-title">{{ p.filename || ('File ' + p.tim + p.ext) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="chan-file-meta-row">
|
||||
@if(p.fsize)<span>{{ p.fsize }}</span>@endif
|
||||
@if(p.w && p.h)<span>{{ p.w }}×{{ p.h }}</span>@endif
|
||||
</div>
|
||||
|
||||
<!-- Rehost & Favorite actions -->
|
||||
<div class="chan-rehost-container" id="rehost-box-{{ p.no }}">
|
||||
@if(p.local_id)
|
||||
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;flex:1;">
|
||||
<a href="/{{ p.local_id }}" class="chan-rehost-btn rehosted" target="_blank">
|
||||
<i class="fa-solid fa-check"></i> Rehosted #{{ p.local_id }}
|
||||
</a>
|
||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="{{ p.local_id }}" title="Edit rating, tags & comment" style="padding:4px 7px;border-radius:4px;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
</span>
|
||||
@else
|
||||
<button type="button" class="chan-rehost-btn" data-url="{{ p.external_media_url }}" data-board="{{ board }}" data-tid="{{ tid }}" data-post="{{ p.no }}" data-filename="{{ p.filename || '' }}" data-width="{{ p.w || '' }}" data-height="{{ p.h || '' }}">
|
||||
<i class="fa-solid fa-cloud-arrow-up"></i> Rehost
|
||||
</button>
|
||||
@endif
|
||||
<button type="button" class="chan-card-fav-btn {{ p.user_has_favorited ? 'favorited' : '' }}" data-post="{{ p.no }}" data-local-id="{{ p.local_id || '' }}" data-url="{{ p.external_media_url }}" data-board="{{ board }}" data-tid="{{ tid }}" data-filename="{{ p.filename || '' }}" data-width="{{ p.w || '' }}" data-height="{{ p.h || '' }}" title="{{ p.user_has_favorited ? 'Remove from favorites' : 'Favorite' }}">
|
||||
<i class="fa-{{ p.user_has_favorited ? 'solid' : 'regular' }} fa-heart"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Post Comment -->
|
||||
@if(p.com_raw)
|
||||
<div class="chan-post-comment">
|
||||
{{ p.com_formatted }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</article>
|
||||
@endeach
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chan-thread-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 15px 80px;
|
||||
}
|
||||
.chan-thread-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 25px;
|
||||
background: var(--bg-card, rgba(25, 25, 25, 0.85));
|
||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.chan-thread-nav-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.chan-back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-main, #fff);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.chan-back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.chan-thread-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chan-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-muted, #aaa);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.chan-action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
.chan-action-btn.primary {
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
color: #4ade80;
|
||||
border-color: rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
.chan-action-btn.primary:hover {
|
||||
background: #4ade80;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.chan-thread-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.chan-thread-title-info h2 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 4px;
|
||||
color: #fff;
|
||||
}
|
||||
.chan-thread-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.82rem;
|
||||
color: #888;
|
||||
}
|
||||
.chan-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* Filter toggle */
|
||||
.chan-filter-toggle {
|
||||
display: inline-flex;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
gap: 3px;
|
||||
}
|
||||
.chan-toggle-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.chan-toggle-btn.active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Posts stream */
|
||||
.chan-posts-stream {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.chan-post-card {
|
||||
background: var(--bg-card, rgba(25, 25, 25, 0.85));
|
||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
.chan-post-card.is-rehosted {
|
||||
border-color: rgba(74, 222, 128, 0.3);
|
||||
background: rgba(74, 222, 128, 0.03);
|
||||
}
|
||||
.chan-post-card.is-op {
|
||||
border-left: 4px solid #4ade80;
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
}
|
||||
.chan-post-card:target {
|
||||
border-color: #4ade80;
|
||||
background: rgba(74, 222, 128, 0.05);
|
||||
}
|
||||
|
||||
.chan-post-rehosted-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(74, 222, 128, 0.4);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.chan-post-rehosted-pill:hover {
|
||||
background: #4ade80;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.chan-thumb-rehost-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
background: rgba(16, 185, 129, 0.92);
|
||||
color: #fff;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(4px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.5);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.chan-post-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.chan-post-name {
|
||||
font-weight: 700;
|
||||
color: #10b981;
|
||||
}
|
||||
.chan-post-trip {
|
||||
color: #888;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chan-post-time {
|
||||
color: #777;
|
||||
}
|
||||
.chan-post-no {
|
||||
color: #aaa;
|
||||
text-decoration: none;
|
||||
font-family: monospace;
|
||||
}
|
||||
.chan-post-no:hover {
|
||||
color: #4ade80;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.chan-reply-sub {
|
||||
color: #fbbf24;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chan-post-media-badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chan-post-body {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.chan-post-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Media Box */
|
||||
.chan-media-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chan-media-thumb-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.chan-media-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.chan-media-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.chan-media-thumb-wrap:hover .chan-media-thumb {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.chan-media-play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
color: #fff;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.chan-media-thumb-wrap:hover .chan-media-play-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
.chan-media-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.chan-media-filename {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chan-media-filename a {
|
||||
color: #aaa;
|
||||
text-decoration: none;
|
||||
}
|
||||
.chan-media-filename a:hover {
|
||||
color: #4ade80;
|
||||
}
|
||||
.chan-media-meta {
|
||||
color: #777;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Rehost button */
|
||||
.chan-rehost-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chan-rehost-btn:hover {
|
||||
background: #4ade80;
|
||||
color: #111;
|
||||
}
|
||||
.chan-rehost-btn.rehosted {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
color: #4ade80;
|
||||
cursor: default;
|
||||
}
|
||||
.chan-rehost-btn.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chan-rehost-container {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.chan-card-fav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.chan-card-fav-btn:hover {
|
||||
background: rgba(255, 75, 75, 0.15);
|
||||
border-color: rgba(255, 75, 75, 0.4);
|
||||
color: #ff4b4b;
|
||||
}
|
||||
.chan-card-fav-btn.favorited {
|
||||
background: rgba(255, 75, 75, 0.2);
|
||||
border-color: rgba(255, 75, 75, 0.5);
|
||||
color: #ff4b4b;
|
||||
}
|
||||
.chan-card-fav-btn.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Comments & Greentext */
|
||||
.chan-post-comment {
|
||||
color: #ddd;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
}
|
||||
.chan-quote,
|
||||
.chan-post-comment .quote {
|
||||
color: #789922;
|
||||
}
|
||||
.chan-ref,
|
||||
.chan-post-comment .quotelink {
|
||||
color: #d00;
|
||||
text-decoration: underline;
|
||||
font-family: monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chan-ref:hover,
|
||||
.chan-post-comment .quotelink:hover {
|
||||
color: #ff3333;
|
||||
}
|
||||
.chan-post-comment s,
|
||||
.chan-post-comment .spoiler {
|
||||
background: #333;
|
||||
color: #333;
|
||||
padding: 0 3px;
|
||||
border-radius: 2px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.chan-post-comment s:hover,
|
||||
.chan-post-comment .spoiler:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Quote preview popup */
|
||||
.chan-quote-preview-popup {
|
||||
position: fixed;
|
||||
z-index: 99999;
|
||||
max-width: 480px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #4ade80;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.7);
|
||||
pointer-events: none;
|
||||
color: #eee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
// Toggle filter: All vs Media Only
|
||||
const filterAllBtn = document.getElementById('filter-all-btn');
|
||||
const filterMediaBtn = document.getElementById('filter-media-btn');
|
||||
const filterRehostedBtn = document.getElementById('filter-rehosted-btn');
|
||||
const posts = document.querySelectorAll('.chan-post-card');
|
||||
|
||||
if (filterAllBtn && filterMediaBtn) {
|
||||
filterAllBtn.addEventListener('click', () => {
|
||||
filterAllBtn.classList.add('active');
|
||||
filterMediaBtn.classList.remove('active');
|
||||
if (filterRehostedBtn) filterRehostedBtn.classList.remove('active');
|
||||
posts.forEach(p => p.style.display = 'block');
|
||||
});
|
||||
|
||||
filterMediaBtn.addEventListener('click', () => {
|
||||
filterMediaBtn.classList.add('active');
|
||||
filterAllBtn.classList.remove('active');
|
||||
if (filterRehostedBtn) filterRehostedBtn.classList.remove('active');
|
||||
posts.forEach(p => {
|
||||
p.style.display = p.dataset.hasMedia === '1' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
if (filterRehostedBtn) {
|
||||
filterRehostedBtn.addEventListener('click', () => {
|
||||
filterRehostedBtn.classList.add('active');
|
||||
filterAllBtn.classList.remove('active');
|
||||
filterMediaBtn.classList.remove('active');
|
||||
posts.forEach(p => {
|
||||
p.style.display = p.dataset.isRehosted === '1' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Inline Rehosting
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.chan-rehost-btn:not(.rehosted)');
|
||||
if (!btn) return;
|
||||
|
||||
e.preventDefault();
|
||||
const url = btn.dataset.url;
|
||||
const board = btn.dataset.board;
|
||||
const tid = btn.dataset.tid;
|
||||
const postNo = btn.dataset.post;
|
||||
const filename = btn.dataset.filename;
|
||||
if (!url) return;
|
||||
|
||||
btn.classList.add('loading');
|
||||
btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Rehosting…';
|
||||
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||
const resp = await fetch('/api/v2/scroller/rehost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url,
|
||||
original_filename: filename || '',
|
||||
width: btn.dataset.width || '',
|
||||
height: btn.dataset.height || ''
|
||||
})
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
if (data.success && data.item_id) {
|
||||
btn.classList.remove('loading');
|
||||
btn.classList.add('rehosted');
|
||||
btn.outerHTML = '<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">' +
|
||||
'<a href="/' + data.item_id + '" class="chan-rehost-btn rehosted" target="_blank">' +
|
||||
'<i class="fa-solid fa-check"></i> Rehosted #' + data.item_id + '</a>' +
|
||||
'<button type="button" class="chan-rehost-edit-btn" data-item-id="' + data.item_id + '" title="Edit rating, tags & comment" style="padding:4px 7px;border-radius:4px;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">' +
|
||||
'<i class="fa-solid fa-pen"></i></button></span>';
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast(data.repost ? 'Already on site — linked to existing post' : 'Rehosted successfully!');
|
||||
}
|
||||
if (typeof window.openChanRehostModal === 'function') {
|
||||
window.openChanRehostModal({ itemId: data.item_id, isPlayerView: false });
|
||||
}
|
||||
} else {
|
||||
btn.classList.remove('loading');
|
||||
btn.innerHTML = '<i class="fa-solid fa-cloud-arrow-up"></i> Retry';
|
||||
alert(data.msg || data.message || 'Rehosting failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CHAN] Rehost error:', err);
|
||||
btn.classList.remove('loading');
|
||||
btn.innerHTML = '<i class="fa-solid fa-cloud-arrow-up"></i> Error';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const editBtn = e.target.closest('.chan-rehost-edit-btn');
|
||||
if (editBtn) {
|
||||
e.preventDefault();
|
||||
const itemId = editBtn.dataset.itemId;
|
||||
if (itemId && typeof window.openChanRehostModal === 'function') {
|
||||
window.openChanRehostModal({ itemId: Number(itemId), isPlayerView: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Favorite from Thread Card (Auto-rehost if needed, then toggle favorite)
|
||||
document.addEventListener('click', async (e) => {
|
||||
const favBtn = e.target.closest('.chan-card-fav-btn');
|
||||
if (!favBtn || favBtn.classList.contains('loading')) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) {
|
||||
if (typeof window.flashMessage === 'function') window.flashMessage('Anonymous favoriting is disabled.', 3000, 'error');
|
||||
return;
|
||||
}
|
||||
if (!window.f0ckSession || !window.f0ckSession.user) {
|
||||
if (typeof window.flashMessage === 'function') window.flashMessage('Login to favorite posts', 3000, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const postNo = favBtn.dataset.post;
|
||||
let localId = favBtn.dataset.localId;
|
||||
const mediaUrl = favBtn.dataset.url;
|
||||
|
||||
favBtn.classList.add('loading');
|
||||
const origHtml = favBtn.innerHTML;
|
||||
favBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
// If not yet rehosted, auto-rehost first
|
||||
if (!localId && mediaUrl) {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||
const rehostResp = await fetch('/api/v2/scroller/rehost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: mediaUrl,
|
||||
original_filename: favBtn.dataset.filename || '',
|
||||
width: favBtn.dataset.width || '',
|
||||
height: favBtn.dataset.height || ''
|
||||
})
|
||||
});
|
||||
const rehostData = await rehostResp.json();
|
||||
if (rehostData.success && rehostData.item_id) {
|
||||
localId = rehostData.item_id;
|
||||
favBtn.dataset.localId = localId;
|
||||
|
||||
// Update rehost box
|
||||
const rehostBox = document.getElementById('rehost-box-' + postNo);
|
||||
if (rehostBox) {
|
||||
const rehostBtn = rehostBox.querySelector('.chan-rehost-btn');
|
||||
if (rehostBtn) {
|
||||
rehostBtn.outerHTML = '<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;flex:1;">' +
|
||||
'<a href="/' + localId + '" class="chan-rehost-btn rehosted" target="_blank">' +
|
||||
'<i class="fa-solid fa-check"></i> Rehosted #' + localId + '</a>' +
|
||||
'<button type="button" class="chan-rehost-edit-btn" data-item-id="' + localId + '" title="Edit rating, tags & comment" style="padding:4px 7px;border-radius:4px;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">' +
|
||||
'<i class="fa-solid fa-pen"></i></button></span>';
|
||||
}
|
||||
}
|
||||
// Add thumb badge if missing
|
||||
const postCard = favBtn.closest('.chan-post');
|
||||
const thumbLink = postCard?.querySelector('.chan-thumb-link');
|
||||
if (thumbLink && !thumbLink.querySelector('.chan-thumb-rehost-badge')) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'chan-thumb-rehost-badge';
|
||||
badge.title = 'Rehosted on f0ckm as Post #' + localId;
|
||||
badge.innerHTML = '<i class="fa-solid fa-check"></i> #' + localId;
|
||||
thumbLink.appendChild(badge);
|
||||
}
|
||||
} else {
|
||||
throw new Error(rehostData.msg || 'Rehost failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Now toggle favorite
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||
const favResp = await fetch('/api/v2/togglefav', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: JSON.stringify({ postid: Number(localId), chan_url: mediaUrl })
|
||||
});
|
||||
const favData = await favResp.json();
|
||||
favBtn.classList.remove('loading');
|
||||
|
||||
if (favData.success) {
|
||||
const isNowFav = !!favData.favorited;
|
||||
favBtn.classList.toggle('favorited', isNowFav);
|
||||
favBtn.innerHTML = '<i class="fa-' + (isNowFav ? 'solid' : 'regular') + ' fa-heart"></i>';
|
||||
favBtn.title = isNowFav ? 'Remove from favorites' : 'Favorite';
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES');
|
||||
}
|
||||
} else {
|
||||
favBtn.innerHTML = origHtml;
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(favData.msg || 'Failed to update favorite', 3000, 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CHAN-CARD-FAV] Error:', err);
|
||||
favBtn.classList.remove('loading');
|
||||
favBtn.innerHTML = origHtml;
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('Failed to update favorite', 3000, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Quote hover preview
|
||||
let previewEl = null;
|
||||
document.addEventListener('mouseover', (e) => {
|
||||
const ref = e.target.closest('.chan-ref, .quotelink');
|
||||
if (!ref) return;
|
||||
|
||||
const targetPostNo = ref.dataset.post || ref.getAttribute('href')?.replace(/^#p?/, '');
|
||||
if (!targetPostNo) return;
|
||||
const targetCard = document.getElementById('p' + targetPostNo);
|
||||
if (!targetCard) return;
|
||||
|
||||
if (!previewEl) {
|
||||
previewEl = document.createElement('div');
|
||||
previewEl.className = 'chan-quote-preview-popup';
|
||||
document.body.appendChild(previewEl);
|
||||
}
|
||||
|
||||
previewEl.innerHTML = targetCard.innerHTML;
|
||||
previewEl.style.display = 'block';
|
||||
|
||||
const rect = ref.getBoundingClientRect();
|
||||
let top = rect.bottom + 8;
|
||||
let left = rect.left;
|
||||
if (left + 480 > window.innerWidth) left = window.innerWidth - 490;
|
||||
if (top + 200 > window.innerHeight) top = rect.top - 210;
|
||||
|
||||
previewEl.style.top = Math.max(10, top) + 'px';
|
||||
previewEl.style.left = Math.max(10, left) + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseout', (e) => {
|
||||
const ref = e.target.closest('.chan-ref, .quotelink');
|
||||
if (ref && previewEl) {
|
||||
previewEl.style.display = 'none';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@include(snippets/footer)
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="item-main-content">
|
||||
|
||||
<div class="_204863">
|
||||
<div class="location">{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}</div>
|
||||
<div class="location">@if(item.is_chan)<a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/4</a><a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/{{ item.external_board }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}" class="location-link" title="Thread #{{ item.external_tid }} overview">/{{ item.external_tid }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}/{{ item.external_id }}" class="location-link" data-inherit-context="false" title="Post #{{ item.external_id }}">/{{ item.external_id }}</a>@if(item.local_id)<span class="location-sep" style="opacity: 0.6; margin: 0 4px;">→</span><a href="/{{ item.local_id }}" class="location-link is-rehosted" title="View rehosted post #{{ item.local_id }} on f0ckm" style="color: #4ade80; font-weight: 700;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a>@endif@else{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}@endif</div>
|
||||
<div class="gapLeft"></div>
|
||||
</div>
|
||||
@if(enable_item_title)
|
||||
@@ -126,11 +126,21 @@
|
||||
<div class="user-infobox-body">
|
||||
<div class="user-infobox-actions">
|
||||
<div class="gapRight">
|
||||
@if(item.is_chan)
|
||||
@if(item.local_id)
|
||||
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
|
||||
<a href="/{{ item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #{{ item.local_id }})" style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a>
|
||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="{{ item.local_id }}" title="Edit rating, tags & comment" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
|
||||
</span>
|
||||
@else
|
||||
<button type="button" class="chan-rehost-action-btn" id="chan-item-rehost-btn" data-url="{{ item.external_media_url }}" data-board="{{ item.external_board }}" data-tid="{{ item.external_tid }}" data-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" title="Rehost to f0ckm" style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.15);color:#4ade80;border:1px solid rgba(74,222,128,0.3);cursor:pointer;font-weight:600;"><i class="fa-solid fa-cloud-arrow-up"></i> Rehost</button>
|
||||
@endif
|
||||
@endif
|
||||
@if(session)
|
||||
@if(user_has_favorited)
|
||||
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.local_id || item.id }}" @if(item.is_chan) data-is-chan="true" data-local-id="{{ item.local_id || '' }}" data-chan-url="{{ item.external_media_url }}" data-chan-board="{{ item.external_board }}" data-chan-tid="{{ item.external_tid }}" data-chan-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" @endif title="Favorite"></i>
|
||||
@else
|
||||
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.local_id || item.id }}" @if(item.is_chan) data-is-chan="true" data-local-id="{{ item.local_id || '' }}" data-chan-url="{{ item.external_media_url }}" data-chan-board="{{ item.external_board }}" data-chan-tid="{{ item.external_tid }}" data-chan-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" @endif title="Favorite"></i>
|
||||
@endif
|
||||
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
|
||||
@if(enable_comments)
|
||||
@@ -144,7 +154,7 @@
|
||||
@if(enable_oc)
|
||||
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
|
||||
@endif
|
||||
@if(can_extract_meta)
|
||||
@if(typeof can_extract_meta !== 'undefined' && can_extract_meta)
|
||||
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif title="Extract Metadata"></i>
|
||||
@endif
|
||||
@if(item.mime === 'application/x-shockwave-flash' || item.mime === 'application/vnd.adobe.flash.movie')
|
||||
@@ -177,6 +187,14 @@
|
||||
@endif
|
||||
</span>
|
||||
@if(!user_alternative_infobox) — <span class="badge badge-dark"><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>@if(item.expires_at) — <span class="badge badge-warning" style="background: rgba(255, 170, 0, 0.2); color: #ffaa00; border: 1px solid rgba(255, 170, 0, 0.4);" tooltip="Self-destructs {{ item.expires_in }}" flow="up"><i class="fa-solid fa-clock"></i> Expiring ({{ item.expires_in }})</span>@endif @endif
|
||||
@if(!user_alternative_infobox && item.is_chan) — <span class="badge badge-dark chan-post-badge" style="background: rgba(74, 222, 128, 0.15); border: 1px solid rgba(74, 222, 128, 0.3); display: inline-flex; align-items: center; gap: 6px;">
|
||||
<a href="/4/{{ item.external_board }}/{{ item.external_tid }}" style="color: #4ade80; text-decoration: none; font-weight: 600;" title="View thread overview"><i class="fa-solid fa-clover"></i> /{{ item.external_board }}/{{ item.external_tid }}</a>
|
||||
@if(item.local_id)
|
||||
<a href="/{{ item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #{{ item.local_id }})" style="display:inline-flex;align-items:center;gap:4px;padding:2px 6px;border-radius:4px;font-size:0.75rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a>
|
||||
@else
|
||||
<button type="button" class="chan-rehost-action-btn" id="chan-item-rehost-btn" data-url="{{ item.external_media_url }}" data-board="{{ item.external_board }}" data-tid="{{ item.external_tid }}" data-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" title="Rehost to f0ckm" style="display:inline-flex;align-items:center;gap:4px;padding:2px 6px;border-radius:4px;font-size:0.75rem;background:rgba(74,222,128,0.15);color:#4ade80;border:1px solid rgba(74,222,128,0.3);cursor:pointer;font-weight:600;"><i class="fa-solid fa-cloud-arrow-up"></i> Rehost</button>
|
||||
@endif
|
||||
</span>@endif
|
||||
|
||||
<span id="hall-badge-container">@if(halls_enabled && item.primaryHall)<span class="badge hall-badge-wrap">
|
||||
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<div class="item-main-content">
|
||||
|
||||
<div class="_204863">
|
||||
<div class="location">{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}</div>
|
||||
<div class="location">@if(item.is_chan)<a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/4</a><a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/{{ item.external_board }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}" class="location-link" title="Thread #{{ item.external_tid }} overview">/{{ item.external_tid }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}/{{ item.external_id }}" class="location-link" data-inherit-context="false" title="Post #{{ item.external_id }}">/{{ item.external_id }}</a>@if(item.local_id)<span class="location-sep" style="opacity: 0.6; margin: 0 4px;">→</span><a href="/{{ item.local_id }}" class="location-link is-rehosted" title="View rehosted post #{{ item.local_id }} on f0ckm" style="color: #4ade80; font-weight: 700;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a>@endif@else{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}@endif</div>
|
||||
<div class="gapLeft"></div>
|
||||
</div>
|
||||
@if(enable_item_title)
|
||||
@@ -150,14 +150,29 @@
|
||||
@if(item.expires_at)
|
||||
— <span class="badge badge-warning" style="background: rgba(255, 170, 0, 0.2); color: #ffaa00; border: 1px solid rgba(255, 170, 0, 0.4);" tooltip="Self-destructs {{ item.expires_in }}" flow="up"><i class="fa-solid fa-clock"></i> Expiring ({{ item.expires_in }})</span>
|
||||
@endif
|
||||
@if(item.is_chan)
|
||||
<span class="badge badge-dark" style="background: rgba(74, 222, 128, 0.15); border: 1px solid rgba(74, 222, 128, 0.3);">
|
||||
<a href="/4/{{ item.external_board }}/{{ item.external_tid }}" style="color: #4ade80; text-decoration: none; font-weight: 600;" title="View thread overview"><i class="fa-solid fa-clover"></i> /{{ item.external_board }}/{{ item.external_tid }}</a>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="gapRight">
|
||||
@if(item.is_chan)
|
||||
@if(item.local_id)
|
||||
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
|
||||
<a href="/{{ item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #{{ item.local_id }})" style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a>
|
||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="{{ item.local_id }}" title="Edit rating, tags & comment" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
|
||||
</span>
|
||||
@else
|
||||
<button type="button" class="chan-rehost-action-btn" id="chan-item-rehost-btn" data-url="{{ item.external_media_url }}" data-board="{{ item.external_board }}" data-tid="{{ item.external_tid }}" data-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" title="Rehost to f0ckm" style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.15);color:#4ade80;border:1px solid rgba(74,222,128,0.3);cursor:pointer;font-weight:600;"><i class="fa-solid fa-cloud-arrow-up"></i> Rehost</button>
|
||||
@endif
|
||||
@endif
|
||||
@if(session)
|
||||
@if(user_has_favorited)
|
||||
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.local_id || item.id }}" @if(item.is_chan) data-is-chan="true" data-local-id="{{ item.local_id || '' }}" data-chan-url="{{ item.external_media_url }}" data-chan-board="{{ item.external_board }}" data-chan-tid="{{ item.external_tid }}" data-chan-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" @endif title="Favorite"></i>
|
||||
@else
|
||||
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.local_id || item.id }}" @if(item.is_chan) data-is-chan="true" data-local-id="{{ item.local_id || '' }}" data-chan-url="{{ item.external_media_url }}" data-chan-board="{{ item.external_board }}" data-chan-tid="{{ item.external_tid }}" data-chan-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" @endif title="Favorite"></i>
|
||||
@endif
|
||||
@endif
|
||||
@if(session)
|
||||
@@ -170,7 +185,9 @@
|
||||
@if(enable_oc)
|
||||
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
|
||||
@endif
|
||||
@if(typeof can_extract_meta !== 'undefined' && can_extract_meta)
|
||||
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif title="Extract Metadata"></i>
|
||||
@endif
|
||||
@if(is_flash_item)
|
||||
<i class="iconset fa-solid fa-image" id="a_rethumb" data-item-id="{{ item.id }}" title="Re-upload Thumbnail"></i>
|
||||
@endif
|
||||
|
||||
@@ -159,6 +159,9 @@
|
||||
<button type="button" class="sidebar-tab" id="sidebar-tab-tag" data-tab="tag" title="Tag Feed" aria-label="Tag Feed" style="display: none;">
|
||||
<i class="fa-solid fa-hashtag"></i>
|
||||
</button>
|
||||
<button type="button" class="sidebar-tab" id="sidebar-tab-chan" data-tab="chan" title="4chan Thread" aria-label="4chan Thread" style="display: none;">
|
||||
<i class="fa-solid fa-clover"></i>
|
||||
</button>
|
||||
<button type="button" class="sidebar-tab" id="sidebar-tab-favs" data-tab="favs" title="Favorites" aria-label="Favorites" style="display: none;">
|
||||
<i class="fa-solid fa-heart"></i>
|
||||
</button>
|
||||
@@ -198,6 +201,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sidebar-chan-container" class="sidebar-recommendations-list sidebar-tab-content" data-tab-content="chan" style="display: none;">
|
||||
<div class="sidebar-tag-header">
|
||||
<div class="sidebar-tag-title">
|
||||
<i class="fa-solid fa-clover" style="color: #4ade80;"></i>
|
||||
<span class="sidebar-chan-name"></span>
|
||||
<span class="sidebar-chan-count"></span>
|
||||
</div>
|
||||
<a href="#" class="sidebar-chan-overview-btn" title="View thread overview" style="display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 6px; background: rgba(255,255,255,0.06); color: #aaa; text-decoration: none;">
|
||||
<i class="fa-solid fa-list"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="sidebar-chan-items-container">
|
||||
<div class="sidebar-loading-state">
|
||||
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
<span>Loading thread media...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sidebar-favs-container" class="sidebar-recommendations-list sidebar-tab-content" data-tab-content="favs" style="display: none;">
|
||||
<div class="sidebar-tag-header">
|
||||
<div class="sidebar-tag-title">
|
||||
|
||||
@@ -94,9 +94,9 @@
|
||||
<div class="infobox-card-value font-mono" id="info-file-size">{{ item.size }}</div>
|
||||
</div>
|
||||
|
||||
<div class="infobox-card" id="info-card-dimensions" @if(!item_has_dimensions)style="display: none;"@endif>
|
||||
<div class="infobox-card" id="info-card-dimensions" @if(!item.width || !item.height)style="display: none;"@endif>
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-ruler-combined"></i> Dimensions</label>
|
||||
<div class="infobox-card-value font-mono" id="info-file-dimensions">@if(item_has_dimensions){{ item.width }} × {{ item.height }} px@endif</div>
|
||||
<div class="infobox-card-value font-mono" id="info-file-dimensions">@if(item.width && item.height){{ item.width }} × {{ item.height }} px@endif</div>
|
||||
</div>
|
||||
|
||||
<div class="infobox-card" id="info-card-mime">
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
</div>
|
||||
@elseif(item.mime.startsWith("video"))
|
||||
<div class="embed-responsive embed-responsive-16by9">
|
||||
<video id="my-video" class="embed-responsive-item" width="640" height="360" src="{{ item.dest }}" preload="auto" data-size="{{ item.size }}" loop playsinline @if(!session || session.disable_autoplay !== true) autoplay @endif></video>
|
||||
<video id="my-video" class="embed-responsive-item" width="{{ item.width || 640 }}" height="{{ item.height || 360 }}" src="{{ item.dest }}" preload="auto" data-size="{{ item.size }}" loop playsinline @if(!session || session.disable_autoplay !== true) autoplay @endif></video>
|
||||
</div>
|
||||
@elseif(item.mime.startsWith("audio"))
|
||||
<div class="embed-responsive embed-responsive-16by9" style="background: #000;">
|
||||
@@ -90,7 +90,7 @@
|
||||
@elseif(item.mime.startsWith("image"))
|
||||
<div class="embed-responsive embed-responsive-16by9">
|
||||
<div class="embed-responsive-image" id="image-scroll">
|
||||
<a href="{{ item.dest }}" id="elfe" target="_blank"><img id="f0ck-image" class="img-fluid" src="{{ item.dest }}" loading="lazy" decoding="async" /></a>
|
||||
<a href="{{ item.dest }}" id="elfe" target="_blank"><img id="f0ck-image" class="img-fluid" src="{{ item.dest }}" loading="lazy" decoding="async" @if(item.width)width="{{ item.width }}"@endif @if(item.height)height="{{ item.height }}"@endif /></a>
|
||||
</div>
|
||||
<button class="image-modal-btn" title="Open in modal"><i class="fa-solid fa-plus"></i></button>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
</div>
|
||||
@endif
|
||||
<a href="/tags" title="{{ t('nav.tags') }}"><i class="fa-solid fa-tags"></i></a>
|
||||
@if(session.can_chan)
|
||||
<a href="/4/wsg/catalogue" id="nav-chan-link" title="4chan Viewer & Rehost" style="color: #4ade80;"><i class="fa-solid fa-clover"></i></a>
|
||||
@endif
|
||||
@if(abyss_enabled)
|
||||
<a href="/abyss" title="{{ t('nav.abyss') }}"><i class="fa-solid fa-dice-d6"></i></a>
|
||||
@endif
|
||||
|
||||
Reference in New Issue
Block a user