/* Suggestions tab — Discord-forum-style board. - Overview = just the list of suggestions (full width). Click one → it opens the discussion beside it; "← Back" returns to the full-width overview. - Agree (upvote) on posts only, as a clear pill button. - Mod actions via right-click context menu (delete / set status; delete comment). - Live for everyone on the tab via SSE (posts, comments, votes, status, deletes) — applied incrementally so sending/receiving never scroll-jumps to the top. */ (function(){ 'use strict'; const STATUS = { open: {label:'Open', color:'#5fc8ff'}, planned: {label:'Planned', color:'#b060ff'}, implemented: {label:'Implemented', color:'#22c55e'}, declined: {label:'Declined', color:'#ff6b6b'}, closed: {label:'Closed', color:'#8aa0b8'}, }; let MOD=false, sort='top', posts=[], selectedId=null, total=0, loading=false, _es=null; function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); } function uid(){ let v=''; try{ v=localStorage.getItem('sm_fb_uid')||''; }catch(e){} if(!v){ v='u'+Math.random().toString(36).slice(2)+Date.now().toString(36); try{ localStorage.setItem('sm_fb_uid',v); }catch(e){} } return v; } function savedName(){ try{ return localStorage.getItem('sm_fb_name')||''; }catch(e){ return ''; } } function rememberName(n){ try{ localStorage.setItem('sm_fb_name', n||''); }catch(e){} } function nameField(){ const el=document.getElementById('fb-name'); const n=el?el.value.trim():''; rememberName(n); return n; } function timeAgo(ts){ const s=Math.max(0,(Date.now()/1000)-(Number(ts)||0)); if(s<60)return 'just now'; const m=s/60; if(m<60)return Math.floor(m)+'m ago'; const h=m/60; if(h<24)return Math.floor(h)+'h ago'; const d=h/24; if(d<30)return Math.floor(d)+'d ago'; try{ return new Date((Number(ts)||0)*1000).toLocaleDateString(); }catch(e){ return Math.floor(d)+'d ago'; } } async function api(path,opts){ const r=await fetch(path,opts); let data=null; try{ data=await r.json(); }catch(e){} if(!r.ok){ throw new Error((data&&(data.detail||data.error))||('HTTP '+r.status)); } return data; } function setMod(v){ if(v!=null) MOD = !!v || !!window.SM_IS_MOD; } const RANK={open:0,planned:1,implemented:2,declined:3,closed:4}; function rank(p){ const r=RANK[p&&p.status]; return r!=null?r:5; } function cmp(a,b){ const ra=rank(a), rb=rank(b); if(ra!==rb)return ra-rb; // open, then planned, then implemented/declined/closed return sort==='new' ? (b.created||0)-(a.created||0) : ((b.score-a.score)||((b.created||0)-(a.created||0))); } function setCount(){ const c=document.getElementById('fb-count'); if(c)c.textContent=total+' suggestion'+(total===1?'':'s'); } function splitEl(){ return document.querySelector('#tab-feedback .fb-split'); } function badge(status){ const s=STATUS[status]||STATUS.open; return `${esc(s.label)}`; } function agreeBtn(id,score,voted,big){ return ``; } function preview(body){ const t=String(body||'').replace(/\s+/g,' ').trim(); return t.length>90?esc(t.slice(0,90))+'…':esc(t); } // ── list (overview) ────────────────────────────────────────────────────────── function listRow(p){ return `
${esc(p.title)}${badge(p.status)}
${preview(p.body)}
${esc(p.author)} · ${timeAgo(p.created)} · 💬 ${p.comments}
${agreeBtn(p.id,p.score,p.voted,false)}
`; } function renderList(){ const host=document.getElementById('fb-list'); if(!host)return; if(loading){ host.innerHTML='
Loading…
'; return; } if(!posts.length){ host.innerHTML='
No suggestions yet — be the first!
'; return; } host.innerHTML=posts.map(listRow).join(''); } function fbRerenderRow(id){ const el=document.getElementById('fb-row-'+id); const p=posts.find(x=>x.id===id); if(el&&p)el.outerHTML=listRow(p); } // ── detail (discussion) ────────────────────────────────────────────────────── function commentCard(c){ return `
${esc(c.author)}${c.own?' you':''} · ${timeAgo(c.created)}
${esc(c.body)}
`; } async function renderDetail(){ const host=document.getElementById('fb-detail'); if(!host)return; if(selectedId==null){ host.innerHTML=''; return; } const want=selectedId; host.innerHTML='
Loading…
'; let d; try{ d=await api('/feedback/post/'+want+'?uid='+encodeURIComponent(uid())); } catch(e){ if(selectedId===want)host.innerHTML=`
Error: ${esc(e.message)}
`; return; } if(selectedId!==want)return; // user switched/closed while loading setMod(d.is_mod); // open UND planned bleiben kommentierbar (aktive Ideen, an denen man weiterdenkt); // erst implemented/declined/closed sperren fuer Nicht-Mods (Server erzwingt dasselbe). const locked=(d.status!=='open' && d.status!=='planned'), comments=d.commentList||[]; const composer=(locked && !MOD) ? `
🔒 This suggestion is closed for new comments.
` : `
`; host.innerHTML=`
${MOD?'right-click a suggestion or comment for mod actions':''}
${agreeBtn(d.id,d.score,d.voted,true)}
${esc(d.title)}${badge(d.status)}
by ${esc(d.author)}${d.own?' you':''} · ${timeAgo(d.created)}
${esc(d.body)}
${comments.length} comment${comments.length===1?'':'s'}
${comments.map(commentCard).join('')||'
No comments yet — start the discussion.
'}
${composer}
`; const ta=document.getElementById('fb-c-body'); if(ta)ta.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); fbAddComment(); } }); } function fbUpdateCCount(){ const list=document.getElementById('fb-clist'), wrap=document.getElementById('fb-ccount-wrap'); if(!list||!wrap)return; const n=list.querySelectorAll('.fb-c').length; wrap.textContent=n+' comment'+(n===1?'':'s'); } function fbAppendComment(c, own){ // append a node in place — no full re-render, so no scroll jump if(selectedId==null||c.post_id!==selectedId)return; const list=document.getElementById('fb-clist'); if(!list)return; if(document.getElementById('fb-c-'+c.id))return; // dedupe (own optimistic + SSE echo) const sc=document.querySelector('#tab-feedback .fb-d-scroll'); const near = sc ? (sc.scrollHeight - sc.scrollTop - sc.clientHeight < 90) : false; const empty=list.querySelector('.fb-empty'); if(empty)empty.remove(); const tmp=document.createElement('div'); tmp.innerHTML=commentCard(c).trim(); if(tmp.firstChild)list.appendChild(tmp.firstChild); fbUpdateCCount(); // follow to the newest when YOU sent it, or when you're already at the bottom; // the composer itself stays pinned — only the message area scrolls if(sc && (own||near)) sc.scrollTop=sc.scrollHeight; } function fbUpdateScore(id,score){ document.querySelectorAll('[data-vk="post-'+id+'"] .fb-agree-n').forEach(n=>n.textContent=score); } function syncAgree(id,score,voted){ document.querySelectorAll('[data-vk="post-'+id+'"]').forEach(b=>{ b.classList.toggle('agreed',!!voted); const n=b.querySelector('.fb-agree-n'); if(n)n.textContent=score; const t=b.querySelector('.fb-agree-txt'); if(t)t.textContent=voted?'Agreed':'Agree'; }); } function fbBumpRowComments(postId,delta){ const p=posts.find(x=>x.id===postId); if(p){ p.comments=Math.max(0,(p.comments||0)+delta); fbRerenderRow(postId); } } // ── mod right-click context menu ───────────────────────────────────────────── function fbHideMenu(){ const m=document.getElementById('fb-ctx'); if(m)m.remove(); } function fbShowMenu(x,y,items){ fbHideMenu(); const m=document.createElement('div'); m.id='fb-ctx'; m.style.cssText='position:fixed;z-index:99999;background:var(--panel,#0c1626);border:1px solid var(--border,#22304c);border-radius:10px;padding:5px;box-shadow:0 10px 30px rgba(0,0,0,.55);min-width:196px;font-size:13px'; m.innerHTML=items.map((it,i)=>it.sep?'
': `
${esc(it.label)}
`).join(''); document.body.appendChild(m); const r=m.getBoundingClientRect(); m.style.left=Math.max(6,Math.min(x,window.innerWidth-r.width-8))+'px'; m.style.top=Math.max(6,Math.min(y,window.innerHeight-r.height-8))+'px'; m.querySelectorAll('[data-i]').forEach(el=>{ el.onclick=e=>{ e.stopPropagation(); const it=items[+el.dataset.i]; fbHideMenu(); if(it&&it.fn)it.fn(); }; }); } window.fbRowMenu=function(ev,id){ if(!MOD)return true; // non-mods keep the normal browser menu ev.preventDefault(); ev.stopPropagation(); const p=posts.find(x=>x.id===id)||{}; const items=[{label:'🗑 Delete suggestion',danger:true,fn:()=>fbModDelete(id)},{sep:true}]; Object.keys(STATUS).forEach(k=>items.push({label:(p.status===k?'● ':'○ ')+'Status: '+STATUS[k].label,fn:()=>fbModStatus(id,k)})); fbShowMenu(ev.clientX,ev.clientY,items); return false; }; window.fbCommentMenu=function(ev,id){ if(!MOD)return true; ev.preventDefault(); ev.stopPropagation(); fbShowMenu(ev.clientX,ev.clientY,[{label:'🗑 Delete comment',danger:true,fn:()=>fbModDeleteComment(id)}]); return false; }; document.addEventListener('click',fbHideMenu); window.addEventListener('scroll',fbHideMenu,true); window.addEventListener('resize',fbHideMenu); // ── actions ────────────────────────────────────────────────────────────────── window.fbSelect=function(id){ selectedId=id; const sp=splitEl(); if(sp)sp.classList.toggle('has-sel',id!=null); renderList(); renderDetail(); }; window.fbClose=function(){ selectedId=null; const sp=splitEl(); if(sp)sp.classList.remove('has-sel'); const d=document.getElementById('fb-detail'); if(d)d.innerHTML=''; renderList(); }; window.fbSetSort=function(s){ sort=(s==='new')?'new':'top'; const t=document.getElementById('fb-sort-top'), n=document.getElementById('fb-sort-new'); if(t)t.classList.toggle('active',sort==='top'); if(n)n.classList.toggle('active',sort==='new'); loadFeedback(); }; window.loadFeedback=async function(){ fbOpenStream(); loading=true; renderList(); try{ const data=await api('/feedback/posts?sort='+sort+'&uid='+encodeURIComponent(uid())); posts=data.posts||[]; posts.sort(cmp); total=(data.total!=null?data.total:posts.length); setMod(data.is_mod); setCount(); }catch(e){ loading=false; posts=[]; const host=document.getElementById('fb-list'); if(host)host.innerHTML=`
Error: ${esc(e.message)}
`; return; } loading=false; const sp=splitEl(); if(sp)sp.classList.toggle('has-sel',selectedId!=null); renderList(); if(selectedId!=null)renderDetail(); }; window.fbCreatePost=async function(){ const st=document.getElementById('fb-form-status'); const title=(document.getElementById('fb-title')||{}).value||''; const body=(document.getElementById('fb-body')||{}).value||''; if(title.trim().length<3){ if(st){st.style.color='#ffb454';st.textContent='Title needs at least 3 characters.';} return; } if(!body.trim()){ if(st){st.style.color='#ffb454';st.textContent='Please add some detail.';} return; } if(st){ st.style.color='var(--text2)'; st.textContent='Posting…'; } let created; try{ created=await api('/feedback/post',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({title,body,author:nameField(),uid:uid()})}); } catch(e){ if(st){st.style.color='#ffb454';st.textContent='Error: '+e.message;} return; } document.getElementById('fb-title').value=''; document.getElementById('fb-body').value=''; if(st){ st.style.color='var(--green)'; st.textContent='Posted!'; setTimeout(()=>{if(st)st.textContent='';},2000); } if(created&&created.id!=null){ if(!posts.some(p=>p.id===created.id)){ posts.push(created); total++; setCount(); } posts.sort(cmp); fbSelect(created.id); } }; window.fbVote=async function(ev,id){ if(ev){ ev.stopPropagation(); ev.preventDefault(); } // don't also open/select the row let res; try{ res=await api('/feedback/vote',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({kind:'post',target_id:id,uid:uid()})}); } catch(e){ return; } syncAgree(id,res.score,res.voted); const p=posts.find(x=>x.id===id); if(p){ p.score=res.score; p.voted=res.voted; } }; window.fbAddComment=async function(){ if(selectedId==null)return; const ta=document.getElementById('fb-c-body'); const body=ta?ta.value:''; if(!body.trim())return; let c; try{ c=await api('/feedback/comment',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({post_id:selectedId,body,author:nameField(),uid:uid()})}); } catch(e){ alert('Error: '+e.message); return; } if(ta){ ta.value=''; ta.focus(); } fbAppendComment(c, true); fbBumpRowComments(selectedId,+1); // own send -> follow to newest; SSE echo dedupes }; window.fbModStatus=async function(postId,status){ try{ await api('/feedback/mod/status',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({post_id:postId,status})}); } catch(e){ alert('Error: '+e.message); return; } const p=posts.find(x=>x.id===postId); if(p)p.status=status; posts.sort(cmp); renderList(); // may move it into the grayed group at the bottom if(selectedId===postId){ const b=document.getElementById('fb-dbadge'); if(b)b.innerHTML=badge(status); } }; window.fbModDelete=async function(postId){ if(!confirm('Delete this suggestion and all its comments? This cannot be undone.'))return; try{ await api('/feedback/mod/delete',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({post_id:postId})}); } catch(e){ alert('Error: '+e.message); return; } posts=posts.filter(x=>x.id!==postId); total=Math.max(0,total-1); setCount(); if(selectedId===postId) fbClose(); else renderList(); }; window.fbModDeleteComment=async function(commentId){ if(!confirm('Delete this comment?'))return; try{ await api('/feedback/mod/delete',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({comment_id:commentId})}); } catch(e){ alert('Error: '+e.message); return; } const el=document.getElementById('fb-c-'+commentId); if(el)el.remove(); fbUpdateCCount(); if(selectedId!=null) fbBumpRowComments(selectedId,-1); }; // ── live stream (SSE) ──────────────────────────────────────────────────────── function fbOpenStream(){ if(_es || typeof EventSource==='undefined')return; try{ _es=new EventSource('/feedback/stream'); }catch(e){ _es=null; return; } _es.addEventListener('fb',ev=>{ let d; try{ d=JSON.parse(ev.data); }catch(e){ return; } fbApplyEvent(d); }); } window.fbCloseStream=function(){ if(_es){ try{ _es.close(); }catch(e){} _es=null; } }; function fbApplyEvent(d){ if(!d||!d.type)return; if(d.type==='post'){ const p=d.post; if(!p||posts.some(x=>x.id===p.id))return; posts.push(p); posts.sort(cmp); total++; setCount(); if(!loading)renderList(); } else if(d.type==='comment'){ const c=d.comment; if(!c)return; if(selectedId===c.post_id) fbAppendComment(c); fbBumpRowComments(c.post_id,+1); } else if(d.type==='vote'){ if(d.kind!=='post')return; fbUpdateScore(d.target_id,d.score); const p=posts.find(x=>x.id===d.target_id); if(p)p.score=d.score; } else if(d.type==='status'){ const p=posts.find(x=>x.id===d.post_id); if(p)p.status=d.status; posts.sort(cmp); renderList(); if(selectedId===d.post_id){ const b=document.getElementById('fb-dbadge'); if(b)b.innerHTML=badge(d.status); } } else if(d.type==='delete'){ posts=posts.filter(x=>x.id!==d.post_id); total=Math.max(0,total-1); setCount(); if(selectedId===d.post_id) fbClose(); else renderList(); } else if(d.type==='comment_delete'){ const el=document.getElementById('fb-c-'+d.comment_id); if(el){ el.remove(); fbUpdateCCount(); } if(d.post_id!=null) fbBumpRowComments(d.post_id,-1); } } document.addEventListener('DOMContentLoaded',()=>{ const el=document.getElementById('fb-name'); if(el)el.value=savedName(); }); })();