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 ──────────────────────────────────────────── */
7function 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 */
11function 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 
64function 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}
68function 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}
71function 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}
74function 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 ────────────────────────────────────── */
83function 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 ──────────────────────────────── */
232function 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 ─────────────────────────────────────────────── */
241function 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 ─────────────────────────────────────────────── */
252function 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}
260function 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 ───────────────────────────────────────── */
268var burger  = document.getElementById('burgerBtn');
269var sidebar = document.getElementById('sidebar');
270var sbOv    = document.getElementById('sbOverlay');
271if (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 ────────────────────────────────────────────── */
287function 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}
298document.addEventListener('click', function(){
299  document.querySelectorAll('.notif-drop.show,.user-drop.show').forEach(function(d){d.classList.remove('show');});
300});
301setupDrop('notifBtn', 'notifDrop', loadNotifs);
302setupDrop('userBtn',  'userDrop');
303 
304/* ── Notifications ────────────────────────────────────────── */
305function 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}
327var readAllBtn = document.getElementById('readAllBtn');
328if (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 ────────────────────────────────────────── */
339var searchInput   = document.getElementById('searchInput');
340var searchResults = document.getElementById('searchResults');
341var searchTimer   = null;
342if (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 ──────────────────────────────────────── */
396function 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 ───────────────────────────────────────── */
467function 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 ─────────────────────────────────────────── */
486function 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 */
556function 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 
579function 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 ─────────────────────────────────────────────────── */
600function 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 ──────────────────────────────────────────── */
615function 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 ────────────────────────────────────────────── */
626function 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}
639function 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}
645function 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 ──────────────────────────────────────────── */
685function 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 ─────────────────────────────────────────── */
702function pickImg(inputId, taId) {
703  var inp = document.getElementById(inputId);
704  if (inp) { inp._taId = taId; inp.click(); }
705}
706function uploadImg(inp, taId) {
707  if (inp.files && inp.files[0]) uploadFileToEditor(inp.files[0], taId || inp._taId || 'replyTa');
708}
709function 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 ─────────────────────────────────────── */
755document.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 ────────────────────────────────────────────────── */
780var _tc = 0;
781function 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   ================================================================ */
948function 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 */
956var _origMd = md;
957md = function(raw) {
958  return renderMentions(_origMd(raw));
959};
960 
961/* ================================================================
962   Extended notification renderer (friend requests, mentions, messages)
963   ================================================================ */
964var _origLoadNotifs = loadNotifs;
965loadNotifs = 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   ================================================================ */
1027function 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   ================================================================ */
1057document.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 ─────────────────────────────────── */
1089function 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 ─────────────────────────────────────── */
1110function 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 
1140function 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 ──────────────────────────── */
1226function 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}