1 /* ================================================================ 2 Nexus Forum — app.js v2 3 ================================================================ */ 4 'use strict'; 5 6 /* ── Escape HTML ──────────────────────────────────────────── */ 7 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');} 8 9 /* ── Media embed detection ────────────────────────────────── */ 10 /* Called on bare URLs that end up in <p> tags after md() rendering */ 11 function tryEmbed(url) { 12 var m; 13 // YouTube full 14 m = url.match(/youtube\.com\/watch\?.*v=([a-zA-Z0-9_\-]{11})/); 15 if (m) return ytEmbed(m[1]); 16 // YouTube short 17 m = url.match(/youtu\.be\/([a-zA-Z0-9_\-]{11})/); 18 if (m) return ytEmbed(m[1]); 19 // YouTube Shorts 20 m = url.match(/youtube\.com\/shorts\/([a-zA-Z0-9_\-]{11})/); 21 if (m) return ytEmbed(m[1], true); 22 // Vimeo 23 m = url.match(/vimeo\.com\/(\d{5,12})/); 24 if (m) return embedIframe('https://player.vimeo.com/video/'+m[1]+'?dnt=1', 'Vimeo'); 25 // Spotify 26 m = url.match(/open\.spotify\.com\/(track|album|playlist|episode|artist)\/([a-zA-Z0-9]+)/); 27 if (m) { 28 var h = (m[1]==='track'||m[1]==='episode') ? '152' : '352'; 29 return '<div class="embed-spotify"><iframe src="https://open.spotify.com/embed/'+m[1]+'/'+m[2]+'" width="100%" height="'+h+'" frameborder="0" allow="autoplay;clipboard-write;encrypted-media;fullscreen" loading="lazy"></iframe></div>'; 30 } 31 // SoundCloud 32 if (/soundcloud\.com\/[a-zA-Z0-9\-_]+\/[a-zA-Z0-9\-_]+/.test(url)) { 33 return '<div class="embed-sc"><iframe width="100%" height="166" scrolling="no" frameborder="no" src="https://w.soundcloud.com/player/?url='+encodeURIComponent(url)+'&color=%233b82f6&auto_play=false"></iframe></div>'; 34 } 35 // Twitch 36 m = url.match(/twitch\.tv\/videos\/(\d+)/); 37 if (m) return embedIframe('https://player.twitch.tv/?video=v'+m[1]+'&parent='+location.hostname+'&autoplay=false', 'Twitch VOD'); 38 m = url.match(/twitch\.tv\/([a-zA-Z0-9_]{4,25})(?:\/|$|\?)/); 39 if (m) return embedIframe('https://player.twitch.tv/?channel='+m[1]+'&parent='+location.hostname+'&autoplay=false', 'Twitch'); 40 // Twitter/X 41 m = url.match(/(?:twitter|x)\.com\/[a-zA-Z0-9_]+\/status\/(\d+)/); 42 if (m) return '<div class="embed-tweet" data-tweet-id="'+m[1]+'"><a href="'+esc(url)+'" target="_blank" rel="noopener" class="tweet-fallback">🐦 View on Twitter/X →</a></div>'; 43 // Streamable 44 m = url.match(/streamable\.com\/([a-zA-Z0-9]+)/); 45 if (m) return embedIframe('https://streamable.com/e/'+m[1], 'Streamable'); 46 // Dailymotion 47 m = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/); 48 if (m) return embedIframe('https://www.dailymotion.com/embed/video/'+m[1], 'Dailymotion'); 49 // Loom 50 m = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/); 51 if (m) return embedIframe('https://www.loom.com/embed/'+m[1], 'Loom'); 52 // CodePen 53 m = url.match(/codepen\.io\/([a-zA-Z0-9\-_]+)\/pen\/([a-zA-Z0-9]+)/); 54 if (m) return embedIframeTall('https://codepen.io/'+m[1]+'/embed/'+m[2]+'?default-tab=result', 'CodePen', 420); 55 // JSFiddle 56 m = url.match(/jsfiddle\.net\/([a-zA-Z0-9\/]+)/); 57 if (m) return embedIframeTall('https://jsfiddle.net/'+m[1].replace(/\/$/,'')+'/embedded/result', 'JSFiddle', 380); 58 // TED 59 m = url.match(/ted\.com\/talks\/([a-zA-Z0-9_]+)/); 60 if (m) return embedIframe('https://embed.ted.com/talks/'+m[1], 'TED Talk'); 61 return null; 62 } 63 64 function ytEmbed(id, isShort) { 65 var pad = isShort ? 'padding-bottom:177.78%;max-width:360px' : 'padding-bottom:56.25%'; 66 return '<div class="embed-wrap" style="'+pad+'"><iframe class="embed-yt" src="https://www.youtube-nocookie.com/embed/'+esc(id)+'?rel=0&modestbranding=1" allowfullscreen loading="lazy" title="YouTube"></iframe></div>'; 67 } 68 function embedIframe(src, label) { 69 return '<div class="embed-wrap"><iframe class="embed-yt" src="'+esc(src)+'" allowfullscreen loading="lazy" title="'+esc(label||'')+'"></iframe></div>'; 70 } 71 function embedIframeTall(src, label, h) { 72 return '<div class="embed-wrap" style="padding-bottom:0;height:'+(h||400)+'px"><iframe class="embed-yt" src="'+esc(src)+'" allowfullscreen loading="lazy" title="'+esc(label||'')+'"></iframe></div>'; 73 } 74 function processEmbeds(html) { 75 return html.replace(/<p>\s*(https?:\/\/[^\s<>"']+)\s*<\/p>/gi, function(match, url) { 76 var em = tryEmbed(url); 77 return em !== null ? em : match; 78 }); 79 } 80 81 82 /* ── Markdown renderer ────────────────────────────────────── */ 83 function md(raw) { 84 if (!raw) return ''; 85 var s = raw; 86 var fences = [], quotes = [], inlines = []; 87 88 // ── PASS 1: Extract code fences (state machine) ───────────── 89 var fenceLines = s.split('\n'); 90 var fenceOut = [], inFence = false, fLang = '', fType = '', fBuf = []; 91 92 var buildFence = function(lang, lines) { 93 var code = lines.join('\n'); 94 var ll = lang ? lang.toLowerCase() : ''; 95 var copyBtn = '<button class="cb-copy" onclick="cbCopy(this)" title="Copy" aria-label="Copy code">' 96 + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' 97 + 'stroke-linecap="round" stroke-linejoin="round" width="13" height="13">' 98 + '<rect x="9" y="9" width="13" height="13" rx="2"/>' 99 + '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>' 100 + '</svg><span>Copy</span></button>'; 101 var hdr, blk; 102 if (ll) { 103 hdr = '<div class="cb-header"><span class="cb-lang">' + esc(ll) + '</span>' + copyBtn + '</div>'; 104 blk = '<pre class="code-block" data-lang="' + esc(ll) + '">' 105 + '<code class="language-' + esc(ll) + '">' + esc(code) + '</code></pre>'; 106 } else { 107 hdr = '<div class="cb-header cb-header-nolang">' + copyBtn + '</div>'; 108 blk = '<pre class="code-block"><code>' + esc(code) + '</code></pre>'; 109 } 110 var i = fences.length; 111 fences.push('<div class="code-block-wrap">' + hdr + blk + '</div>'); 112 return '\x02FENCE' + i + 'FNCE\x03'; 113 }; 114 115 for (var fi = 0; fi < fenceLines.length; fi++) { 116 var fl = fenceLines[fi]; 117 if (!inFence) { 118 var tm = fl.match(/^```([ \t]*\w*)[ \t]*$/); 119 if (tm) { inFence = true; fType = 'triple'; fLang = tm[1].trim(); fBuf = []; continue; } 120 if (/^`[ \t]*$/.test(fl)) { inFence = true; fType = 'single'; fLang = ''; fBuf = []; continue; } 121 fenceOut.push(fl); 122 } else { 123 var isClose = (fType === 'triple' && /^```[ \t]*$/.test(fl)) 124 || (fType === 'single' && /^`[ \t]*$/.test(fl)); 125 if (isClose) { fenceOut.push(buildFence(fLang, fBuf)); inFence = false; fBuf = []; } 126 else { fBuf.push(fl); } 127 } 128 } 129 if (inFence && fBuf.length) fenceOut.push(buildFence(fLang, fBuf)); 130 s = fenceOut.join('\n'); 131 132 // ── PASS 2: Extract blockquotes (state machine) ────────────── 133 var bqLines = s.split('\n'), bqOut = [], bqBuf = []; 134 var flushBq = function() { 135 if (!bqBuf.length) return; 136 var inner = bqBuf.join('\n'); 137 inner = inner.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>'); 138 inner = inner.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 139 inner = inner.replace(/\*([^\*\n]+)\*/g, '<em>$1</em>'); 140 inner = inner.replace(/~~(.+?)~~/g, '<del>$1</del>'); 141 var bqParts = inner.split('\n').filter(function(l){return l.trim();}); 142 var content = bqParts.length > 1 143 ? bqParts.map(function(l){return '<p>'+l.trim()+'</p>';}).join('') 144 : inner.trim(); 145 var i = quotes.length; 146 quotes.push('<blockquote class="post-quote">' + content + '</blockquote>'); 147 bqOut.push('\x02BQUOT' + i + 'BQUT\x03'); 148 bqBuf = []; 149 }; 150 for (var bi = 0; bi < bqLines.length; bi++) { 151 var bm = bqLines[bi].match(/^(?:>|>) ?(.*)/); 152 if (bm) { bqBuf.push(bm[1]); } 153 else { flushBq(); bqOut.push(bqLines[bi]); } 154 } 155 flushBq(); 156 s = bqOut.join('\n'); 157 158 // ── PASS 3: Inline code ────────────────────────────────────── 159 s = s.replace(/`([^`\n]+)`/g, function(_, code) { 160 var i = inlines.length; 161 inlines.push('<code class="inline-code">' + esc(code) + '</code>'); 162 return '\x02INLIN' + i + 'INLN\x03'; 163 }); 164 165 // ── PASS 4: Block markdown ─────────────────────────────────── 166 s = s.replace(/^#{6} (.+)$/gm, '<h6>$1</h6>'); 167 s = s.replace(/^#{5} (.+)$/gm, '<h5>$1</h5>'); 168 s = s.replace(/^#{4} (.+)$/gm, '<h4>$1</h4>'); 169 s = s.replace(/^#{3} (.+)$/gm, '<h3>$1</h3>'); 170 s = s.replace(/^#{2} (.+)$/gm, '<h2>$1</h2>'); 171 s = s.replace(/^# (.+)$/gm, '<h1>$1</h1>'); 172 s = s.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr>'); 173 174 s = s.replace(/^[ \t]*[*\-+] (.+)$/gm, '<li>$1</li>'); 175 s = s.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>'); 176 s = s.replace(/<\/ul>\s*<ul>/g, ''); 177 178 s = s.replace(/^[ \t]*\d+\. (.+)$/gm, '<oli>$1</oli>'); 179 s = s.replace(/((?:<oli>.*<\/oli>\n?)+)/g, '<ol>$1</ol>'); 180 s = s.replace(/<\/ol>\s*<ol>/g, ''); 181 s = s.replace(/<oli>/g, '<li>').replace(/<\/oli>/g, '</li>'); 182 183 // ── PASS 5: Inline markdown ────────────────────────────────── 184 s = s.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>'); 185 s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 186 s = s.replace(/\*([^\*\n]+)\*/g, '<em>$1</em>'); 187 s = s.replace(/___(.+?)___/g, '<strong><em>$1</em></strong>'); 188 s = s.replace(/__(.+?)__/g, '<strong>$1</strong>'); 189 s = s.replace(/_([^_\n]+)_/g, '<em>$1</em>'); 190 s = s.replace(/~~(.+?)~~/g, '<del>$1</del>'); 191 192 s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(_,alt,src){ 193 if (!/^https?:\/\/|^\//.test(src)) return esc(_); 194 return '<img src="'+esc(src)+'" alt="'+esc(alt)+'" loading="lazy" onclick="lightbox(this)">'; 195 }); 196 s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_,txt,url){ 197 if (!/^https?:\/\/|^\//.test(url)) return esc(txt); 198 return '<a href="'+esc(url)+'" target="_blank" rel="noopener noreferrer nofollow">'+esc(txt)+'</a>'; 199 }); 200 201 // ── PASS 6: Embeds + auto-link (ALL bare URLs) ─────────────── 202 s = s.replace(/(^|[ \t]|<p>)(https?:\/\/[^\s<>"']+)/gm, function(_, pre, url) { 203 if (/^<(div|iframe|a|pre)/.test(url)) return pre + url; 204 var em = tryEmbed(url); 205 if (em) return pre + em; 206 return pre + '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer nofollow">' + esc(url) + '</a>'; 207 }); 208 209 // ── PASS 7: Paragraph wrapping ─────────────────────────────── 210 var pLines = s.split('\n'), pOut = [], para = ''; 211 var blockRe = /^(<(h[1-6]|ul|ol|blockquote|pre|table|hr|img|div|figure|p)|\x02FENCE|\x02BQUOT)/; 212 function flush(){ if(para.trim()){ pOut.push('<p>'+para.trim()+'</p>'); para=''; } } 213 for (var pi = 0; pi < pLines.length; pi++) { 214 var pl = pLines[pi]; 215 if (!pl.trim()) { flush(); } 216 else if (blockRe.test(pl.trim())) { flush(); pOut.push(pl); } 217 else { para += (para ? ' ' : '') + pl; } 218 } 219 flush(); 220 s = pOut.join('\n'); 221 222 // ── PASS 8: Restore placeholders ───────────────────────────── 223 fences.forEach(function(b,i){ s = s.replace('\x02FENCE' + i + 'FNCE\x03', b); }); 224 quotes.forEach(function(b,i){ s = s.replace('\x02BQUOT' + i + 'BQUT\x03', b); }); 225 inlines.forEach(function(b,i){ s = s.replace('\x02INLIN' + i + 'INLN\x03', b); }); 226 227 return s; 228 } 229 230 231 /* ── Render all .md elements ──────────────────────────────── */ 232 function renderAllMd() { 233 document.querySelectorAll('.md[data-raw]').forEach(function(el) { 234 el.innerHTML = md(atob(el.getAttribute('data-raw'))); 235 el.removeAttribute('data-raw'); 236 }); 237 loadTwitterWidgets(); 238 } 239 240 /* ── Lightbox ─────────────────────────────────────────────── */ 241 function lightbox(img) { 242 var ov = document.createElement('div'); 243 ov.className = 'lightbox'; 244 var im = document.createElement('img'); 245 im.src = img.src; im.alt = img.alt; 246 ov.appendChild(im); 247 ov.onclick = function(){ov.remove();}; 248 document.body.appendChild(ov); 249 } 250 251 /* ── Time-ago ─────────────────────────────────────────────── */ 252 function timeAgo(dt) { 253 var diff = Math.floor((Date.now() - new Date(dt)) / 1000); 254 if (diff < 60) return 'just now'; 255 if (diff < 3600) return Math.floor(diff/60) + 'm ago'; 256 if (diff < 86400) return Math.floor(diff/3600) + 'h ago'; 257 if (diff < 604800) return Math.floor(diff/86400) + 'd ago'; 258 return new Date(dt).toLocaleDateString(); 259 } 260 function renderTimeAgo() { 261 document.querySelectorAll('.ago[data-ts]').forEach(function(el) { 262 el.textContent = timeAgo(el.dataset.ts); 263 el.title = new Date(el.dataset.ts).toLocaleString(); 264 }); 265 } 266 267 /* ── Sidebar toggle ───────────────────────────────────────── */ 268 var burger = document.getElementById('burgerBtn'); 269 var sidebar = document.getElementById('sidebar'); 270 var sbOv = document.getElementById('sbOverlay'); 271 if (burger && sidebar) { 272 burger.addEventListener('click', function(){ 273 var open = sidebar.classList.toggle('open'); 274 burger.classList.toggle('open', open); 275 if (sbOv) sbOv.classList.toggle('show', open); 276 document.body.style.overflow = open ? 'hidden' : ''; 277 }); 278 if (sbOv) sbOv.addEventListener('click', function(){ 279 sidebar.classList.remove('open'); 280 burger.classList.remove('open'); 281 sbOv.classList.remove('show'); 282 document.body.style.overflow = ''; 283 }); 284 } 285 286 /* ── Dropdowns ────────────────────────────────────────────── */ 287 function setupDrop(btnId, dropId, onOpen) { 288 var btn = document.getElementById(btnId); 289 var drop = document.getElementById(dropId); 290 if (!btn || !drop) return; 291 btn.addEventListener('click', function(e) { 292 e.stopPropagation(); 293 var opening = !drop.classList.contains('show'); 294 document.querySelectorAll('.notif-drop.show,.user-drop.show').forEach(function(d){d.classList.remove('show');}); 295 if (opening) { drop.classList.add('show'); if (onOpen) onOpen(); } 296 }); 297 } 298 document.addEventListener('click', function(){ 299 document.querySelectorAll('.notif-drop.show,.user-drop.show').forEach(function(d){d.classList.remove('show');}); 300 }); 301 setupDrop('notifBtn', 'notifDrop', loadNotifs); 302 setupDrop('userBtn', 'userDrop'); 303 304 /* ── Notifications ────────────────────────────────────────── */ 305 function loadNotifs() { 306 if (!window.NX || !NX.user) return; 307 var list = document.getElementById('notifList'); 308 if (!list) return; 309 fetch(NX.base + '/api/notifications.php') 310 .then(function(r){ return r.json(); }) 311 .then(function(rows) { 312 if (!rows.length) { list.innerHTML = '<p class="notif-empty">Nothing new 🎉</p>'; return; } 313 list.innerHTML = rows.map(function(n) { 314 var d = n.payload || {}; 315 var text = (n.type === 'reply' && d.title) 316 ? '<strong>@'+esc(d.from)+'</strong> replied in <em>'+esc(d.title)+'</em>' 317 : esc(n.type); 318 var href = (n.type === 'reply' && d.slug) ? NX.base+'/forum/topic.php?slug='+encodeURIComponent(d.slug) : '#'; 319 return '<a href="'+href+'" class="notif-item'+(n.read?'':' unread')+'">' 320 +'<div class="notif-item-text">'+text+'</div>' 321 +'<div class="notif-item-time">'+timeAgo(n.created_at)+'</div>' 322 +'</a>'; 323 }).join(''); 324 }) 325 .catch(function(){ if (list) list.innerHTML = '<p class="notif-empty">Failed to load</p>'; }); 326 } 327 var readAllBtn = document.getElementById('readAllBtn'); 328 if (readAllBtn) { 329 readAllBtn.addEventListener('click', function(){ 330 fetch(NX.base + '/api/notifications.php?action=read').then(function(){ 331 var dot = document.querySelector('.badge-dot'); 332 if (dot) dot.remove(); 333 loadNotifs(); 334 }); 335 }); 336 } 337 338 /* ── Header search ────────────────────────────────────────── */ 339 var searchInput = document.getElementById('searchInput'); 340 var searchResults = document.getElementById('searchResults'); 341 var searchTimer = null; 342 if (searchInput && searchResults) { 343 searchInput.addEventListener('input', function(){ 344 clearTimeout(searchTimer); 345 var q = searchInput.value.trim(); 346 if (q.length < 2) { searchResults.classList.remove('show'); return; } 347 searchTimer = setTimeout(function(){ 348 fetch(NX.base + '/api/search.php?q=' + encodeURIComponent(q)) 349 .then(function(r){ return r.json(); }) 350 .then(function(d){ 351 var topics = Array.isArray(d) ? d : (d.topics || []); 352 var posts = Array.isArray(d) ? [] : (d.posts || []); 353 if (!topics.length && !posts.length) { 354 searchResults.classList.remove('show'); return; 355 } 356 var html = ''; 357 if (topics.length) { 358 html += '<div class="sr-section-lbl">Topics</div>'; 359 topics.forEach(function(r){ 360 html += '<a class="sr-item" href="'+NX.base+'/forum/topic.php?slug='+encodeURIComponent(r.slug)+'">' 361 + '<span class="sr-dot" style="background:'+(r.cat_color||'#3b82f6')+'"></span>' 362 + '<span class="sr-text"><strong>'+esc(r.title)+'</strong>' 363 + '<small>'+esc(r.cat)+'</small></span></a>'; 364 }); 365 } 366 if (posts.length) { 367 html += '<div class="sr-section-lbl">Matching Posts</div>'; 368 posts.forEach(function(r){ 369 var preview = (r.content||'').substring(0,65).replace(/\n/g,' '); 370 var url = NX.base+'/forum/topic.php?slug='+encodeURIComponent(r.topic_slug)+'&goto='+r.post_id+'#post-'+r.post_id; 371 html += '<a class="sr-item sr-post" href="'+url+'">' 372 + '<span class="sr-text">' 373 + '<strong>'+esc(r.topic_title)+'</strong>' 374 + '<small>Post #'+r.post_num+' by @'+esc(r.username)+': '+esc(preview)+'…</small>' 375 + '</span>' 376 + '<span class="sr-goto-badge">→</span></a>'; 377 }); 378 } 379 html += '<a class="sr-item sr-all" href="'+NX.base+'/forum/search.php?q='+encodeURIComponent(q)+'&type=posts">' 380 + '🔍 See all results for “'+esc(q)+'”</a>'; 381 searchResults.innerHTML = html; 382 searchResults.classList.add('show'); 383 }); 384 }, 280); 385 }); 386 searchInput.addEventListener('keydown', function(e){ 387 if (e.key === 'Enter') window.location = NX.base + '/forum/search.php?q=' + encodeURIComponent(searchInput.value); 388 if (e.key === 'Escape') searchResults.classList.remove('show'); 389 }); 390 document.addEventListener('click', function(e){ 391 if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) searchResults.classList.remove('show'); 392 }); 393 } 394 395 /* ── Editor commands ──────────────────────────────────────── */ 396 function fmt(cmd, taId) { 397 var ta = document.getElementById(taId || 'replyTa'); 398 if (!ta) return; 399 var s = ta.selectionStart, e = ta.selectionEnd; 400 var sel = ta.value.slice(s, e); 401 var pre = ta.value.slice(0, s); 402 var post = ta.value.slice(e); 403 404 // ── Link ────────────────────────────────────────────────────── 405 if (cmd === 'link') { 406 var url = prompt('Enter URL:'); 407 if (!url) return; 408 var txt = sel || 'link text'; 409 ta.value = pre + '[' + txt + '](' + url + ')' + post; 410 ta.selectionStart = s + txt.length + url.length + 4; 411 ta.selectionEnd = ta.selectionStart; 412 ta.focus(); ta.dispatchEvent(new Event('input')); return; 413 } 414 415 // ── Quote — prefix EVERY selected line with "> " ────────────── 416 // Mirrors Discourse / GitHub behaviour: 417 // • each line of the selection gets its own "> " prefix 418 // • trailing newline in the selection is stripped so no ghost blank line 419 // • if nothing is selected, inserts a placeholder quote line 420 // • a blank separator line is added before/after so the block renders correctly 421 if (cmd === 'quote') { 422 var text = sel ? sel.replace(/\n+$/, '') : ''; // strip trailing newlines 423 var lines = text ? text.split('\n') : ['quoted text']; 424 var quoted = lines.map(function(l) { return '> ' + l; }).join('\n'); 425 426 // Ensure there's a blank line before the quote block (so renderer sees it as a block) 427 var needPre = pre.length > 0 && !/\n\n$/.test(pre) && !pre.endsWith('\n'); 428 var before = needPre ? '\n\n' : (pre.length > 0 && !pre.endsWith('\n') ? '\n' : ''); 429 430 // Ensure there's a blank line after so the next paragraph starts clean 431 var needPost = post.length > 0 && !post.startsWith('\n\n') && !post.startsWith('\n'); 432 var after = needPost ? '\n\n' : '\n'; 433 434 ta.value = pre + before + quoted + after + post; 435 436 // Select the quoted lines so the user can see exactly what was quoted 437 var qStart = s + before.length; 438 var qEnd = qStart + quoted.length; 439 ta.selectionStart = qStart; 440 ta.selectionEnd = qEnd; 441 ta.focus(); 442 ta.dispatchEvent(new Event('input')); 443 return; 444 } 445 446 // ── All other commands ───────────────────────────────────────── 447 var map = { 448 bold: ['**', '**', 'bold text'], 449 italic: ['*', '*', 'italic text'], 450 strike: ['~~', '~~', 'strikethrough'], 451 code: ['```\n', '\n```', 'code here'], 452 codeblock: ['```\n', '\n```', 'code here'], 453 heading: ['## ', '', 'Heading'], 454 ul: ['- ', '', 'list item'], 455 }; 456 var c = map[cmd]; 457 if (!c) return; 458 var text = sel || c[2]; 459 var newText = c[0] + text + c[1]; 460 ta.value = pre + newText + post; 461 ta.selectionStart = s + c[0].length; 462 ta.selectionEnd = s + c[0].length + text.length; 463 ta.focus(); ta.dispatchEvent(new Event('input')); 464 } 465 466 /* ── Preview toggle ───────────────────────────────────────── */ 467 function togglePreview() { 468 var ta = document.getElementById('replyTa'); 469 var prev = document.getElementById('replyPreview'); 470 var btn = document.getElementById('prevBtn'); 471 if (!ta || !prev) return; 472 if (prev.classList.contains('hidden')) { 473 prev.innerHTML = md(ta.value); 474 loadTwitterWidgets(); 475 prev.classList.remove('hidden'); 476 ta.classList.add('hidden'); 477 if (btn) btn.classList.add('on'); 478 } else { 479 prev.classList.add('hidden'); 480 ta.classList.remove('hidden'); 481 if (btn) btn.classList.remove('on'); 482 } 483 } 484 485 /* ── Reply submit ─────────────────────────────────────────── */ 486 function sendReply(slug) { 487 var ta = document.getElementById('replyTa'); 488 if (!ta) return; 489 var content = ta.value.trim(); 490 if (!content) { toast('Reply cannot be empty', 'err'); return; } 491 492 var btn = document.getElementById('replyBtn'); 493 if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; } 494 495 var fd = new FormData(); 496 fd.append('slug', slug); 497 fd.append('content', content); 498 fd.append('csrf', NX.csrf); 499 500 // Attach post captcha if present 501 var capInp = document.getElementById('postCaptchaInput'); 502 if (capInp) fd.append('post_captcha', capInp.value); 503 504 fetch(NX.base + '/api/reply.php', {method:'POST', body:fd}) 505 .then(function(r){ return r.json().then(function(d){ d._status=r.status; return d; }); }) 506 .then(function(data){ 507 if (data.ok) { 508 // Clear editor 509 ta.value = ''; 510 var prev = document.getElementById('replyPreview'); 511 if (prev) prev.classList.add('hidden'); 512 ta.classList.remove('hidden'); 513 var prevBtn = document.getElementById('prevBtn'); 514 if (prevBtn) prevBtn.classList.remove('on'); 515 var cnt = document.getElementById('charCnt'); 516 if (cnt) { cnt.textContent = '0'; cnt.style.color = ''; } 517 518 // Update captcha if server sent a new one 519 if (data.new_captcha && capInp) { 520 var ql = capInp.closest('.post-captcha-row')&&capInp.closest('.post-captcha-row').querySelector('.captcha-q'); 521 if (ql) ql.textContent = data.new_captcha.q; 522 capInp.value = ''; 523 } 524 525 // Append post to list 526 var list = document.getElementById('postsList'); 527 if (list && data.post) { 528 // Server-rendered post via reload or AJAX HTML 529 list.insertAdjacentHTML('beforeend', buildPost(data.post)); 530 var np = list.lastElementChild; 531 renderTimeAgo(); 532 loadTwitterWidgets(); 533 np.scrollIntoView({behavior:'smooth',block:'center'}); 534 } else { location.reload(); } 535 toast('Reply posted!', 'ok'); 536 537 } else if (data.rate_limited) { 538 // Rate limit — show countdown 539 startRateCountdown(data.wait || 30); 540 toast(data.error, 'warn'); 541 542 } else if (data.captcha_failed) { 543 toast(data.error, 'err'); 544 // Reload page to get new captcha 545 setTimeout(function(){ location.reload(); }, 1500); 546 547 } else { 548 toast(data.error || 'Failed to post', 'err'); 549 } 550 }) 551 .catch(function(){ toast('Network error — please try again', 'err'); }) 552 .finally(function(){ if (btn) { btn.disabled=false; btn.textContent='Post Reply'; } }); 553 } 554 555 /* Rate limit countdown */ 556 function startRateCountdown(seconds) { 557 var info = document.getElementById('rateLimitInfo'); 558 var btn = document.getElementById('replyBtn'); 559 var msg = document.getElementById('rateLimitMsg'); 560 var cd = document.getElementById('rateCountdown'); 561 if (info) info.style.display = ''; 562 if (btn) btn.disabled = true; 563 var remaining = seconds; 564 function tick() { 565 if (msg) msg.textContent = 'Please wait before posting again:'; 566 if (cd) cd.textContent = remaining + 's'; 567 if (remaining <= 0) { 568 if (info) info.style.display = 'none'; 569 if (btn) btn.disabled = false; 570 if (cd) cd.textContent = ''; 571 return; 572 } 573 remaining--; 574 setTimeout(tick, 1000); 575 } 576 tick(); 577 } 578 579 function buildPost(p) { 580 var av = p.avatar 581 ? '<img src="'+esc(p.avatar)+'" class="av-lg" alt="">' 582 : '<span class="av-lg av-init">'+esc(p.username[0].toUpperCase())+'</span>'; 583 var flair = p.role==='admin' ? '<span class="role-flair admin">Admin</span>' 584 : p.role==='moderator' ? '<span class="role-flair mod">Mod</span>' : ''; 585 return '<div class="post" id="post-'+p.id+'">' 586 +'<div class="post-side">'+av 587 +'<a href="'+NX.base+'/users/profile.php?u='+encodeURIComponent(p.username)+'" class="post-name">@'+esc(p.username)+'</a>' 588 +flair+'<span class="post-pcnt">'+p.post_count+' posts</span></div>' 589 +'<div class="post-body"><div class="post-meta-bar"><span class="pnum">#'+p.post_num+'</span>' 590 +'<time class="ago" data-ts="'+esc(p.created_at)+'"></time>' 591 +'<div class="post-acts">' 592 +'<button class="pa-btn" onclick="doLike('+p.id+',this)">♥ <span class="lc">0</span></button>' 593 +'<button class="pa-btn" onclick="doQuote('+p.id+',\''+esc(p.username)+'\')">↩ Reply</button>' 594 +'</div></div>' 595 +'<div class="post-content rendered-post" id="pc-'+p.id+'" data-raw="'+btoa(unescape(encodeURIComponent(p.content)))+'">' 596 +md(p.content)+'</div></div></div>'; 597 } 598 599 /* ── Like ─────────────────────────────────────────────────── */ 600 function doLike(pid, btn) { 601 if (!NX.user) { toast('Log in to like posts', 'warn'); return; } 602 var fd = new FormData(); fd.append('post_id', pid); fd.append('csrf', NX.csrf); 603 fetch(NX.base + '/api/like.php', {method:'POST', body:fd}) 604 .then(function(r){ return r.json(); }) 605 .then(function(data){ 606 if (data.ok) { 607 var lc = btn.querySelector('.lc'); 608 btn.classList.toggle('liked', data.liked); 609 if (lc) lc.textContent = data.count; 610 } 611 }); 612 } 613 614 /* ── Quote reply ──────────────────────────────────────────── */ 615 function doQuote(pid, uname) { 616 var bodyEl = document.getElementById('pc-'+pid); 617 var text = bodyEl ? bodyEl.innerText.trim().slice(0,300) : ''; 618 var ta = document.getElementById('replyTa'); 619 if (!ta) return; 620 ta.value = '> **@'+uname+'** wrote:\n> '+text.replace(/\n/g,'\n> ')+'\n\n' + ta.value; 621 ta.focus(); 622 ta.scrollIntoView({behavior:'smooth'}); 623 } 624 625 /* ── Edit post ────────────────────────────────────────────── */ 626 function doEdit(pid) { 627 var box = document.getElementById('eb-'+pid); 628 var body = document.getElementById('pc-'+pid); 629 var ta = document.getElementById('et-'+pid); 630 if (!box||!body||!ta) return; 631 // Use innerText for plaintext content (already sanitised, we edit raw) 632 var rawAttr = body.getAttribute('data-raw'); 633 ta.value = rawAttr ? atob(rawAttr) : body.innerText; 634 box.classList.add('visible'); 635 box.classList.remove('hidden'); 636 body.classList.add('hidden'); 637 ta.focus(); 638 } 639 function cancelEdit(pid) { 640 var box = document.getElementById('eb-'+pid); 641 var body = document.getElementById('pc-'+pid); 642 if (box) { box.classList.remove('visible'); box.classList.add('hidden'); } 643 if (body) body.classList.remove('hidden'); 644 } 645 function saveEdit(pid) { 646 var ta = document.getElementById('et-'+pid); 647 var ri = document.getElementById('er-'+pid); 648 if (!ta) return; 649 var fd = new FormData(); 650 fd.append('post_id',pid); 651 fd.append('content', ta.value); 652 fd.append('reason', ri ? ri.value : ''); 653 fd.append('csrf', NX.csrf); 654 fetch(NX.base+'/api/edit.php', {method:'POST',body:fd}) 655 .then(function(r){ return r.json(); }) 656 .then(function(data){ 657 if (data.ok) { 658 var body = document.getElementById('pc-'+pid); 659 if (body) { 660 // Re-render using client-side md() + mention rendering 661 body.innerHTML = renderMentions(md(data.content)); 662 // Update data-raw so next edit reads correct value 663 try { body.setAttribute('data-raw', btoa(unescape(encodeURIComponent(data.content)))); } catch(e){} 664 body.classList.remove('hidden'); 665 loadTwitterWidgets(); 666 } 667 cancelEdit(pid); 668 toast('Post updated!','ok'); 669 var meta = document.querySelector('#post-'+pid+' .post-meta-bar'); 670 if (meta && !meta.querySelector('.edit-lbl')) { 671 var em = document.createElement('em'); 672 em.className = 'edit-lbl'; 673 em.textContent = ' (edited)'; 674 var t = meta.querySelector('time'); 675 if (t) t.after(em); 676 } 677 } else { 678 toast(data.error || 'Failed to save', 'err'); 679 } 680 }) 681 .catch(function(){ toast('Network error', 'err'); }); 682 } 683 684 /* ── Delete post ──────────────────────────────────────────── */ 685 function doDelete(pid) { 686 if (!confirm('Delete this post?')) return; 687 var fd = new FormData(); fd.append('post_id',pid); fd.append('csrf',NX.csrf); 688 fetch(NX.base+'/api/delete.php', {method:'POST',body:fd}) 689 .then(function(r){ return r.json(); }) 690 .then(function(data){ 691 if (data.ok) { 692 var el = document.getElementById('post-'+pid); 693 if (el) { el.style.opacity='.3'; el.style.pointerEvents='none'; 694 var b = document.getElementById('pc-'+pid); 695 if (b) b.innerHTML='<em style="color:#94a3b8">This post has been deleted.</em>'; } 696 toast('Deleted','ok'); 697 } 698 }); 699 } 700 701 /* ── Image upload ─────────────────────────────────────────── */ 702 function pickImg(inputId, taId) { 703 var inp = document.getElementById(inputId); 704 if (inp) { inp._taId = taId; inp.click(); } 705 } 706 function uploadImg(inp, taId) { 707 if (inp.files && inp.files[0]) uploadFileToEditor(inp.files[0], taId || inp._taId || 'replyTa'); 708 } 709 function uploadFileToEditor(file, taId) { 710 var ta = document.getElementById(taId || 'replyTa'); 711 if (!ta) return; 712 713 // Client-side size check — max from server setting (NX.maxUploadMb, default 5) 714 var maxMb = (NX.maxUploadMb || 5); 715 if (file.size > maxMb * 1024 * 1024) { 716 toast('Image too large — max ' + maxMb + ' MB', 'err'); 717 return; 718 } 719 720 var ph = '![Uploading ' + file.name + '…]()'; 721 var cur = ta.selectionStart; 722 ta.value = ta.value.slice(0, cur) + ph + ta.value.slice(cur); 723 toast('Uploading…', 'warn'); 724 725 var fd = new FormData(); 726 fd.append('file', file); 727 fd.append('csrf', NX.csrf); // ← CSRF token (was missing — caused 403) 728 729 fetch(NX.base + '/api/upload.php', { method: 'POST', body: fd }) 730 .then(function(r) { 731 // Always try to parse JSON — even error responses are JSON 732 return r.json().then(function(data) { 733 return { ok: r.ok, data: data }; 734 }); 735 }) 736 .then(function(result) { 737 var data = result.data; 738 if (data.ok) { 739 ta.value = ta.value.replace(ph, ''); 740 toast('Image uploaded!', 'ok'); 741 } else { 742 ta.value = ta.value.replace(ph, ''); 743 toast(data.error || 'Upload failed', 'err'); 744 } 745 ta.dispatchEvent(new Event('input')); 746 }) 747 .catch(function(err) { 748 ta.value = ta.value.replace(ph, ''); 749 toast('Upload failed — check console for details', 'err'); 750 console.error('Upload error:', err); 751 }); 752 } 753 754 /* ── DOMContentLoaded ─────────────────────────────────────── */ 755 document.addEventListener('DOMContentLoaded', function(){ 756 var ta = document.getElementById('replyTa'); 757 var cnt = document.getElementById('charCnt'); 758 if (ta) { 759 if (cnt) { ta.addEventListener('input', function(){ var n=ta.value.length; cnt.textContent=n; cnt.style.color=n>19000?'var(--red)':n>15000?'var(--amber)':''; }); } 760 ta.addEventListener('paste', function(e){ 761 var items = (e.clipboardData || e.originalEvent.clipboardData).items; 762 for (var i=0; i<items.length; i++) { 763 if (items[i].type.indexOf('image')!==-1) { e.preventDefault(); uploadFileToEditor(items[i].getAsFile(),'replyTa'); break; } 764 } 765 }); 766 ta.addEventListener('dragover', function(e){ e.preventDefault(); ta.classList.add('dragging'); }); 767 ta.addEventListener('dragleave', function(){ ta.classList.remove('dragging'); }); 768 ta.addEventListener('drop', function(e){ 769 e.preventDefault(); ta.classList.remove('dragging'); 770 var files = e.dataTransfer.files; 771 for (var i=0; i<files.length; i++) { if (files[i].type.startsWith('image/')) uploadFileToEditor(files[i],'replyTa'); } 772 }); 773 } 774 renderAllMd(); 775 renderTimeAgo(); 776 setInterval(renderTimeAgo, 60000); 777 }); 778 779 /* ── Toast ────────────────────────────────────────────────── */ 780 var _tc = 0; 781 function toast(msg, type) { 782 var col = {ok:'#22c55e',err:'#ef4444',warn:'#f59e0b'}[type||'ok'] || '#3b82f6'; 783 var t = document.createElement('div'); 784 t.style.cssText = 'position:fixed;bottom:'+(20+_tc*56)+'px;right:20px;' 785 +'background:'+col+';color:#fff;padding:11px 16px;border-radius:8px;' 786 +'font-size:14px;font-family:var(--font,sans-serif);box-shadow:0 4px 16px rgba(0,0,0,.2);' 787 +'z-index:9999;max-width:320px;line-height:1.4;animation:fadeIn .25s ease'; 788 t.textContent = msg; 789 document.body.appendChild(t); 790 _tc++; 791 setTimeout(function(){ t.style.transition='opacity .3s'; t.style.opacity='0'; setTimeout(function(){ t.remove(); _tc=Math.max(0,_tc-1); },300); }, 3400); 792 } 793 794 /* ================================================================ 795 @mention autocomplete 796 ================================================================ */ 797 (function () { 798 var popup = null; 799 var popupItems= []; 800 var popupIdx = -1; 801 var mentionStart = -1; 802 var mentionTA = null; 803 804 function createPopup() { 805 if (popup) return; 806 popup = document.createElement('div'); 807 popup.className = 'mention-popup'; 808 popup.id = 'mentionPopup'; 809 document.body.appendChild(popup); 810 } 811 812 function showPopup(ta, users) { 813 createPopup(); 814 popupItems = users; 815 popupIdx = -1; 816 if (!users.length) { hidePopup(); return; } 817 popup.innerHTML = users.map(function(u, i){ 818 var av = u.avatar 819 ? '<img src="'+esc(u.avatar)+'" class="av-xs" alt="">' 820 : '<span class="av-xs">'+esc(u.username[0].toUpperCase())+'</span>'; 821 return '<div class="mention-item" data-i="'+i+'" onclick="insertMention('+i+')">'+av+'@'+esc(u.username)+'</div>'; 822 }).join(''); 823 popup.classList.add('show'); 824 825 // Position popup below cursor 826 var rect = ta.getBoundingClientRect(); 827 var coords = getCaretCoords(ta, ta.selectionStart); 828 var top = rect.top + window.scrollY + coords.top + 20; 829 var left = rect.left + window.scrollX + coords.left; 830 popup.style.top = top + 'px'; 831 popup.style.left = left + 'px'; 832 popup.style.position = 'absolute'; 833 } 834 835 function hidePopup() { 836 if (popup) { popup.classList.remove('show'); popup.innerHTML = ''; } 837 mentionStart = -1; mentionTA = null; popupItems = []; popupIdx = -1; 838 } 839 840 window.insertMention = function(idx) { 841 if (!mentionTA || idx < 0 || idx >= popupItems.length) return; 842 var u = popupItems[idx]; 843 var val = mentionTA.value; 844 var pre = val.slice(0, mentionStart); 845 var post = val.slice(mentionTA.selectionStart); 846 var ins = '@' + u.username + ' '; 847 mentionTA.value = pre + ins + post; 848 var pos = (pre + ins).length; 849 mentionTA.selectionStart = mentionTA.selectionEnd = pos; 850 mentionTA.focus(); 851 hidePopup(); 852 }; 853 854 function handleMentionKey(e) { 855 if (!popup || !popup.classList.contains('show')) return; 856 if (e.key === 'ArrowDown') { 857 e.preventDefault(); 858 popupIdx = (popupIdx + 1) % popupItems.length; 859 updateSelected(); 860 } else if (e.key === 'ArrowUp') { 861 e.preventDefault(); 862 popupIdx = (popupIdx - 1 + popupItems.length) % popupItems.length; 863 updateSelected(); 864 } else if (e.key === 'Enter' || e.key === 'Tab') { 865 if (popupIdx >= 0) { e.preventDefault(); insertMention(popupIdx); } 866 else hidePopup(); 867 } else if (e.key === 'Escape') { 868 hidePopup(); 869 } 870 } 871 872 function updateSelected() { 873 popup.querySelectorAll('.mention-item').forEach(function(el, i){ 874 el.classList.toggle('selected', i === popupIdx); 875 if (i === popupIdx) el.scrollIntoView({block:'nearest'}); 876 }); 877 } 878 879 var mentionTimer; 880 function handleMentionInput(ta) { 881 var val = ta.value; 882 var caret = ta.selectionStart; 883 // Find the @ that triggers a mention (word char follows) 884 var before = val.slice(0, caret); 885 var match = before.match(/@([a-zA-Z0-9_\-]*)$/); 886 if (!match) { hidePopup(); return; } 887 var query = match[1]; 888 mentionStart = caret - match[0].length; 889 mentionTA = ta; 890 if (query.length < 1) { hidePopup(); return; } 891 clearTimeout(mentionTimer); 892 mentionTimer = setTimeout(function(){ 893 fetch(NX.base + '/api/search_users.php?q=' + encodeURIComponent(query)) 894 .then(function(r){ return r.json(); }) 895 .then(function(users){ showPopup(ta, users); }) 896 .catch(function(){ hidePopup(); }); 897 }, 200); 898 } 899 900 // Attach to all textareas present or added 901 function attachMention(ta) { 902 if (ta.dataset.mentionBound) return; 903 ta.dataset.mentionBound = '1'; 904 ta.addEventListener('input', function(){ handleMentionInput(ta); }); 905 ta.addEventListener('keydown', handleMentionKey); 906 ta.addEventListener('blur', function(){ setTimeout(hidePopup, 200); }); 907 } 908 909 document.addEventListener('DOMContentLoaded', function(){ 910 document.querySelectorAll('textarea.reply-ta, textarea.edit-ta').forEach(attachMention); 911 // Also attach to dynamically added textareas via MutationObserver 912 new MutationObserver(function(muts){ 913 muts.forEach(function(m){ 914 m.addedNodes.forEach(function(n){ 915 if (n.nodeType!==1) return; 916 n.querySelectorAll && n.querySelectorAll('textarea.reply-ta,textarea.edit-ta').forEach(attachMention); 917 if (n.matches && (n.matches('textarea.reply-ta')||n.matches('textarea.edit-ta'))) attachMention(n); 918 }); 919 }); 920 }).observe(document.body, {childList:true,subtree:true}); 921 }); 922 923 /* Simple caret coordinate helper */ 924 function getCaretCoords(el, pos) { 925 var div = document.createElement('div'); 926 var style = getComputedStyle(el); 927 ['fontFamily','fontSize','fontWeight','lineHeight','padding','border','whiteSpace','wordWrap'].forEach(function(p){ 928 div.style[p] = style[p]; 929 }); 930 div.style.position = 'absolute'; div.style.visibility = 'hidden'; 931 div.style.overflow = 'auto'; div.style.width = el.offsetWidth + 'px'; 932 var text = el.value.slice(0, pos); 933 div.textContent = text; 934 var span = document.createElement('span'); 935 span.textContent = '|'; 936 div.appendChild(span); 937 document.body.appendChild(div); 938 var coords = { top: span.offsetTop, left: span.offsetLeft }; 939 document.body.removeChild(div); 940 return coords; 941 } 942 })(); 943 944 /* ================================================================ 945 @mention rendering in post content 946 Convert @username text → clickable mention links 947 ================================================================ */ 948 function renderMentions(html) { 949 return html.replace(/@([a-zA-Z0-9_\-]{3,30})/g, function(_, uname) { 950 return '<a href="' + NX.base + '/users/profile.php?u=' + encodeURIComponent(uname) + 951 '" class="mention-tag">@' + esc(uname) + '</a>'; 952 }); 953 } 954 955 /* Patch md() to run renderMentions after rendering */ 956 var _origMd = md; 957 md = function(raw) { 958 return renderMentions(_origMd(raw)); 959 }; 960 961 /* ================================================================ 962 Extended notification renderer (friend requests, mentions, messages) 963 ================================================================ */ 964 var _origLoadNotifs = loadNotifs; 965 loadNotifs = function() { 966 if (!window.NX || !NX.user) return; 967 var list = document.getElementById('notifList'); 968 if (!list) return; 969 fetch(NX.base + '/api/notifications.php') 970 .then(function(r){ return r.json(); }) 971 .then(function(rows){ 972 if (!rows.length) { list.innerHTML = '<p class="notif-empty">Nothing new 🎉</p>'; return; } 973 list.innerHTML = rows.map(function(n) { 974 var d = n.payload || {}; 975 var text = ''; 976 var href = '#'; 977 switch (n.type) { 978 case 'reply': 979 text = '<strong>@'+esc(d.from||'')+'</strong> replied in <em>'+esc(d.title||'')+'</em>'; 980 if (d.slug) href = NX.base+'/forum/topic.php?slug='+encodeURIComponent(d.slug); 981 break; 982 case 'mention': 983 text = '<strong>@'+esc(d.from||'')+'</strong> mentioned you in a post'; 984 if (d.topicSlug) href = NX.base+'/forum/topic.php?slug='+encodeURIComponent(d.topicSlug)+'#post-'+(d.postId||''); 985 break; 986 case 'friend_request': 987 text = '<strong>@'+esc(d.from||'')+'</strong> sent you a friend request'; 988 href = NX.base+'/users/profile.php?u='+encodeURIComponent(d.from||''); 989 break; 990 case 'friend_accepted': 991 text = '<strong>@'+esc(d.from||'')+'</strong> accepted your friend request 🎉'; 992 href = NX.base+'/users/profile.php?u='+encodeURIComponent(d.from||''); 993 break; 994 case 'message': 995 text = '<strong>@'+esc(d.from||'')+'</strong> sent you a message: <em>'+esc(d.subject||'')+'</em>'; 996 href = NX.base+'/messages/'; 997 break; 998 case 'karma_admin': 999 var diff = d.change||0; 1000 var sign = diff >= 0 ? '+' : ''; 1001 text = 'Your karma was adjusted by an admin: <strong>'+sign+diff+'</strong>' 1002 + (d.new ? ' (now '+d.new+')' : '') 1003 + (d.reason ? ' — <em>'+esc(d.reason)+'</em>' : ''); 1004 href = NX.base+'/users/profile.php?u='+encodeURIComponent(NX.user.name||''); 1005 break; 1006 default: 1007 text = esc(n.type); 1008 } 1009 return '<a href="'+href+'" class="notif-item'+(n.read?'':' unread')+'">' 1010 +'<div class="notif-item-text">'+text+'</div>' 1011 +'<div class="notif-item-time">'+timeAgo(n.created_at)+'</div>' 1012 +'</a>'; 1013 }).join(''); 1014 }) 1015 .catch(function(){ if (list) list.innerHTML = '<p class="notif-empty">Failed to load</p>'; }); 1016 }; 1017 1018 /* ================================================================ 1019 Edit toggle — show edit form only when Edit button clicked 1020 (edit button itself is always visible; form is hidden by default) 1021 ================================================================ */ 1022 /* doEdit() already exists above — we just make sure edit-box starts hidden via CSS */ 1023 1024 /* ================================================================ 1025 Twitter/X widget loader 1026 ================================================================ */ 1027 function loadTwitterWidgets() { 1028 var tweets = document.querySelectorAll('.embed-tweet[data-tweet-id]:not([data-loaded])'); 1029 if (!tweets.length) return; 1030 function doLoad() { 1031 tweets.forEach(function(el) { 1032 el.setAttribute('data-loaded','1'); 1033 var id = el.dataset.tweetId; 1034 if (id && window.twttr && window.twttr.widgets) { 1035 window.twttr.widgets.createTweet(id, el, {theme:'light',dnt:true,align:'left'}); 1036 } 1037 }); 1038 } 1039 if (window.twttr && window.twttr.widgets) { doLoad(); return; } 1040 if (document.querySelector('script[src*="platform.twitter.com"]')) { 1041 // Already loading 1042 var interval = setInterval(function(){ 1043 if (window.twttr && window.twttr.widgets) { clearInterval(interval); doLoad(); } 1044 },300); 1045 return; 1046 } 1047 var s = document.createElement('script'); 1048 s.src = 'https://platform.twitter.com/widgets.js'; 1049 s.async = true; 1050 s.onload = doLoad; 1051 document.head.appendChild(s); 1052 } 1053 1054 /* ================================================================ 1055 Lazy load embeds — for heavy iframes (only load when in viewport) 1056 ================================================================ */ 1057 document.addEventListener('DOMContentLoaded', function(){ 1058 loadTwitterWidgets(); 1059 1060 // IntersectionObserver for lazy embed loading 1061 if ('IntersectionObserver' in window) { 1062 var obs = new IntersectionObserver(function(entries){ 1063 entries.forEach(function(entry){ 1064 if (entry.isIntersecting) { 1065 var iframe = entry.target; 1066 if (iframe.dataset.src) { 1067 iframe.src = iframe.dataset.src; 1068 iframe.removeAttribute('data-src'); 1069 obs.unobserve(iframe); 1070 } 1071 } 1072 }); 1073 }, {rootMargin:'200px'}); 1074 1075 document.querySelectorAll('iframe.embed-yt[data-src]').forEach(function(el){ 1076 obs.observe(el); 1077 }); 1078 } 1079 }); 1080 1081 /* ================================================================ 1082 Post content — use server-rendered HTML, add edit toggle via CSS 1083 edit-box already hidden in CSS (display:none on .edit-box.hidden) 1084 ================================================================ */ 1085 /* buildPost is used for AJAX-appended posts (no server rendering there) */ 1086 /* so we keep client-side md() for those */ 1087 1088 /* ── Post permalink copy ─────────────────────────────────── */ 1089 function copyPostLink(postId, btn) { 1090 var baseUrl = btn.getAttribute('data-url'); 1091 var full = window.location.protocol + '//' + window.location.host + baseUrl; 1092 var icon_link = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'; 1093 var icon_check = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>'; 1094 function showCopied() { 1095 btn.innerHTML = icon_check; 1096 btn.title = 'Copied!'; 1097 setTimeout(function(){ btn.innerHTML = icon_link; btn.title = 'Copy link to this post'; }, 2000); 1098 toast('Post link copied!', 'ok'); 1099 } 1100 if (navigator.clipboard && navigator.clipboard.writeText) { 1101 navigator.clipboard.writeText(full).then(showCopied).catch(function() { 1102 prompt('Copy this link:', full); 1103 }); 1104 } else { 1105 prompt('Copy this link:', full); 1106 } 1107 } 1108 1109 /* ── Code block copy ─────────────────────────────────────── */ 1110 function cbCopy(btn) { 1111 // Walk up to .code-block-wrap, then find the <code> element 1112 var wrap = btn.closest('.code-block-wrap'); 1113 if (!wrap) return; 1114 var code = wrap.querySelector('pre.code-block code'); 1115 if (!code) return; 1116 1117 var text = code.innerText !== undefined ? code.innerText : code.textContent; 1118 1119 var ok = function() { 1120 var orig = btn.innerHTML; 1121 btn.classList.add('copied'); 1122 btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" ' 1123 + 'stroke-linecap="round" stroke-linejoin="round" width="14" height="14">' 1124 + '<polyline points="20 6 9 17 4 12"/></svg><span>Copied!</span>'; 1125 setTimeout(function() { 1126 btn.classList.remove('copied'); 1127 btn.innerHTML = orig; 1128 }, 2000); 1129 }; 1130 1131 if (navigator.clipboard && navigator.clipboard.writeText) { 1132 navigator.clipboard.writeText(text).then(ok).catch(function() { 1133 fallbackCopy(text, ok); 1134 }); 1135 } else { 1136 fallbackCopy(text, ok); 1137 } 1138 } 1139 1140 function fallbackCopy(text, cb) { 1141 var ta = document.createElement('textarea'); 1142 ta.value = text; 1143 ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'; 1144 document.body.appendChild(ta); 1145 ta.focus(); 1146 ta.select(); 1147 try { document.execCommand('copy'); if (cb) cb(); } 1148 catch(e) {} 1149 document.body.removeChild(ta); 1150 } 1151 1152 1153 1154 /* ── Content security: warn on raw HTML/PHP injection attempt ──── */ 1155 (function () { 1156 // Patterns that suggest someone is typing raw code to inject 1157 var DANGEROUS = [ 1158 /<script/i, 1159 /<iframe/i, 1160 /<object/i, 1161 /<embed/i, 1162 /<form/i, 1163 /<base\s/i, 1164 /<link\s/i, 1165 /<meta\s/i, 1166 /<svg[\s>]/i, 1167 /<\?php/i, 1168 /<\?=/, 1169 /javascript\s*:/i, 1170 /vbscript\s*:/i, 1171 /on\w+\s*=/i, // onerror=, onclick=, onload= etc. 1172 /data\s*:\s*text\/html/i, 1173 ]; 1174 1175 function checkContent(ta) { 1176 var val = ta.value; 1177 for (var i = 0; i < DANGEROUS.length; i++) { 1178 if (DANGEROUS[i].test(val)) { 1179 showHtmlWarning(ta); 1180 return; 1181 } 1182 } 1183 hideHtmlWarning(ta); 1184 } 1185 1186 function showHtmlWarning(ta) { 1187 var id = 'sec-warn-' + ta.id; 1188 if (document.getElementById(id)) return; 1189 var box = document.createElement('div'); 1190 box.id = id; 1191 box.className = 'sec-warning'; 1192 box.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' 1193 + 'stroke-linecap="round" stroke-linejoin="round" width="15" height="15">' 1194 + '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>' 1195 + '<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>' 1196 + '<span>Raw HTML/code detected. For security, HTML is stripped on save. ' 1197 + 'Use ``` code blocks ``` to display code.</span>'; 1198 ta.parentNode.insertBefore(box, ta.nextSibling); 1199 } 1200 1201 function hideHtmlWarning(ta) { 1202 var id = 'sec-warn-' + ta.id; 1203 var el = document.getElementById(id); 1204 if (el) el.remove(); 1205 } 1206 1207 function attachTo(taId) { 1208 var ta = document.getElementById(taId); 1209 if (!ta) return; 1210 ta.addEventListener('input', function () { checkContent(ta); }); 1211 ta.addEventListener('paste', function () { 1212 setTimeout(function () { checkContent(ta); }, 10); 1213 }); 1214 } 1215 1216 // Attach to both editors on page load and after AJAX reply renders 1217 document.addEventListener('DOMContentLoaded', function () { 1218 attachTo('replyTa'); 1219 }); 1220 1221 // Expose for dynamic attachment 1222 window.attachSecCheck = attachTo; 1223 })(); 1224 1225 /* ── Welcome Guide addon toggle ──────────────────────────── */ 1226 function wgToggle() { 1227 var b = document.querySelector('.wg-body'); 1228 var btn = document.querySelector('.wg-toggle'); 1229 if (!b) return; 1230 var open = b.style.display === 'none'; 1231 b.style.display = open ? '' : 'none'; 1232 if (btn) { 1233 btn.setAttribute('aria-expanded', open ? 'true' : 'false'); 1234 btn.classList.toggle('wg-open', open); 1235 } 1236 }