swordmasters-optimizer / static /feedback_tab.js
oettaM123's picture
Claude Opus 4.8
Launch mob-kill leaderboard UI + allow comments on 'planned' suggestions
162cd71
Raw History Blame Contribute Delete
18.8 kB
/* 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 `<span class="fb-badge" style="--fb-badge:${s.color}">${esc(s.label)}</span>`; }
function agreeBtn(id,score,voted,big){
return `<button class="fb-agree${voted?' agreed':''}${big?' big':''}" data-vk="post-${id}" onclick="fbVote(event,${id})"
title="${voted?'You agree — click to undo':'Agree with this suggestion'}">
<span class="fb-agree-ic">👍</span><span class="fb-agree-txt">${voted?'Agreed':'Agree'}</span><span class="fb-agree-n">${score}</span></button>`;
}
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 `<div class="fb-row${p.id===selectedId?' sel':''}${rank(p)>=2?' dimmed':''}" id="fb-row-${p.id}" onclick="fbSelect(${p.id})" oncontextmenu="return fbRowMenu(event,${p.id})">
<div class="fb-row-main">
<div class="fb-row-head"><span class="fb-row-title">${esc(p.title)}</span>${badge(p.status)}</div>
<div class="fb-row-prev">${preview(p.body)}</div>
<div class="fb-row-meta">${esc(p.author)} · ${timeAgo(p.created)} · 💬 ${p.comments}</div>
</div>
${agreeBtn(p.id,p.score,p.voted,false)}
</div>`;
}
function renderList(){
const host=document.getElementById('fb-list'); if(!host)return;
if(loading){ host.innerHTML='<div class="fb-empty">Loading…</div>'; return; }
if(!posts.length){ host.innerHTML='<div class="fb-empty">No suggestions yet — be the first!</div>'; 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 `<div class="fb-c${MOD?' mod':''}" id="fb-c-${c.id}"${MOD?` oncontextmenu="return fbCommentMenu(event,${c.id})"`:''}>
<div class="fb-c-meta">${esc(c.author)}${c.own?' <span class="fb-you">you</span>':''} · ${timeAgo(c.created)}</div>
<div class="fb-text">${esc(c.body)}</div>
</div>`;
}
async function renderDetail(){
const host=document.getElementById('fb-detail'); if(!host)return;
if(selectedId==null){ host.innerHTML=''; return; }
const want=selectedId;
host.innerHTML='<div class="fb-empty">Loading…</div>';
let d;
try{ d=await api('/feedback/post/'+want+'?uid='+encodeURIComponent(uid())); }
catch(e){ if(selectedId===want)host.innerHTML=`<div class="fb-empty">Error: ${esc(e.message)}</div>`; 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)
? `<div class="fb-locked">🔒 This suggestion is closed for new comments.</div>`
: `<div class="fb-c-new">
<textarea id="fb-c-body" class="fb-input" rows="2" maxlength="2000" placeholder="Add a comment… (Enter to send · Shift+Enter = new line)"></textarea>
<div style="text-align:right"><button class="fb-post-btn" onclick="fbAddComment()">Comment</button></div>
</div>`;
host.innerHTML=`
<div class="fb-d-scroll">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
<button class="fb-back" onclick="fbClose()">← Back</button>
${MOD?'<span class="fb-modhint">right-click a suggestion or comment for mod actions</span>':''}
</div>
<div class="fb-d-head" oncontextmenu="return fbRowMenu(event,${d.id})">
${agreeBtn(d.id,d.score,d.voted,true)}
<div style="min-width:0;flex:1">
<div class="fb-d-title-row"><span class="fb-d-title">${esc(d.title)}</span><span id="fb-dbadge">${badge(d.status)}</span></div>
<div class="fb-meta">by <b>${esc(d.author)}</b>${d.own?' <span class="fb-you">you</span>':''} · ${timeAgo(d.created)}</div>
</div>
</div>
<div class="fb-text fb-d-body" oncontextmenu="return fbRowMenu(event,${d.id})">${esc(d.body)}</div>
<div class="fb-c-wrap">
<div class="fb-c-count" id="fb-ccount-wrap">${comments.length} comment${comments.length===1?'':'s'}</div>
<div id="fb-clist">${comments.map(commentCard).join('')||'<div class="fb-empty small">No comments yet — start the discussion.</div>'}</div>
</div>
</div>
<div class="fb-composer">${composer}</div>`;
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?'<div style="height:1px;background:var(--border,#22304c);margin:5px 4px"></div>':
`<div class="fb-ctx-item" data-i="${i}" style="padding:7px 12px;border-radius:6px;cursor:pointer;color:${it.danger?'var(--red,#ff6b6b)':'var(--text)'}">${esc(it.label)}</div>`).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=`<div class="fb-empty">Error: ${esc(e.message)}</div>`; 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(); });
})();