xgit simple git

nexus

nexus

clone git clone https://kb.hax.al/nexus

public/js/app.js

1 /* ================================================================
2    Nexus Forum — app.js  v2
3    ================================================================ */
4 'use strict';
5 
6 /* ── Escape HTML ──────────────────────────────────────────── */
7 function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
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(/^(?:&gt;|>) ?(.*)/);
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 &ldquo;'+esc(q)+'&rdquo;</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, '![' + file.name + '](' + data.url + ')');
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 }