Download static/feedback_tab.js from oettaM123/swordmasters-optimizer: direct link, hf CLI and curl.
- Browser
- Download file 18.8 kB
-
https://huggingface.co/spaces/oettaM123/swordmasters-optimizer/resolve/main/static/feedback_tab.js
- Command line
-
hf download hf://spaces/oettaM123/swordmasters-optimizer/static/feedback_tab.js
-
curl -L -o feedback_tab.js https://huggingface.co/spaces/oettaM123/swordmasters-optimizer/resolve/main/static/feedback_tab.js
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(){ | |
| ; | |
| 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 `<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(); }); | |
| })(); | |