Files
Streamer-app/public/index.html
Vlastislav Svatek 153c83f7fa first commit
2026-04-26 02:23:11 +02:00

1539 lines
88 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NaughtyBulldogs — Hodnocení streamerů</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#f5f5f3;--card:#fff;--border:#e5e5e2;--text:#1a1a1a;--muted:#888;--purple:#7F77DD;--purple-d:#534AB7;--purple-l:#EEEDFE;--green:#1D9E75;--orange:#EF9F27;--red:#D85A30;--red-l:#FAECE7;--red-d:#993C1D}
@media(prefers-color-scheme:dark){:root{--bg:#1a1a1a;--card:#242424;--border:#333;--text:#f0f0ee;--muted:#666;--purple-l:#2a2840}}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:16px;color:var(--text);background:var(--bg);min-height:100vh}
input,select,textarea{font-family:inherit;font-size:15px;border:0.5px solid var(--border);border-radius:8px;padding:8px 12px;width:100%;background:var(--card);color:var(--text);outline:none}
input:focus,select:focus,textarea:focus{border-color:#aaa}
button{font-family:inherit;cursor:pointer}
.btn{padding:9px 18px;border-radius:8px;border:0.5px solid var(--border);background:var(--card);color:var(--text);font-size:15px;transition:background .15s}
.btn:hover{background:var(--bg)}
.btn-primary{background:var(--purple);border-color:var(--purple-d);color:#fff}
.btn-primary:hover{background:var(--purple-d)}
.btn-sm{padding:7px 14px;font-size:14px}
.btn-ghost{border:none;background:none;color:var(--muted);font-size:15px;padding:7px 13px;border-radius:8px}
.btn-ghost:hover{background:var(--bg);color:var(--text)}
.btn-danger{background:#fff0ee;border-color:#f09595;color:#a32d2d}
.badge-live{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:20px;font-size:13px;font-weight:500;background:var(--red-l);color:var(--red-d);border:0.5px solid var(--red)}
.badge-off{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:20px;font-size:13px;font-weight:500;background:var(--bg);color:var(--muted);border:0.5px solid var(--border)}
.dot{width:7px;height:7px;border-radius:50%;background:var(--red);animation:pulse 1.4s infinite;flex-shrink:0}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
nav{background:var(--card);border-bottom:0.5px solid var(--border);padding:0 1.5rem;display:flex;align-items:center;gap:10px;height:52px;position:sticky;top:0;z-index:100}
.logo{font-size:16px;font-weight:600;flex:1}
.ntab{padding:7px 15px;border-radius:8px;border:none;background:none;font-size:15px;color:var(--muted);cursor:pointer;transition:background .15s;font-family:inherit}
.ntab.on{background:var(--bg);color:var(--text);font-weight:500}
.ndiv{width:0.5px;height:22px;background:var(--border)}
main{padding:1.5rem;max-width:1100px;margin:0 auto}
.live-bar{background:var(--card);border:0.5px solid var(--border);border-radius:12px;padding:1rem 1.25rem;margin-bottom:1.25rem}
.live-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:.75rem}
.lchip{display:flex;align-items:center;gap:6px;padding:6px 14px;border-radius:20px;background:var(--red-l);border:0.5px solid var(--red);font-size:14px;text-decoration:none;color:var(--red-d)}
.lchip:hover{opacity:.85}
.suggest-bar{background:var(--bg);border:0.5px solid var(--border);border-radius:10px;padding:.75rem 1.25rem;display:flex;align-items:center;gap:10px;margin-bottom:1.25rem;flex-wrap:wrap}
.suggest-bar p{font-size:14px;color:var(--muted);flex:1}
.sbar{display:flex;gap:10px;margin-bottom:1.25rem}
.sbar input{flex:1}
.frow{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:1.25rem;align-items:center}
.fchip{padding:6px 14px;border-radius:20px;border:0.5px solid var(--border);background:var(--card);font-size:14px;cursor:pointer;color:var(--muted);transition:all .15s;font-family:inherit}
.fchip.on{border-color:var(--purple);color:var(--purple-d);background:var(--purple-l)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}
.card{border-radius:12px;border:0.5px solid var(--border);background:var(--card);overflow:hidden;transition:border-color .15s}
.card:hover{border-color:#bbb}
.card-pub{cursor:pointer}
.ch{padding:1rem 1.25rem;display:flex;align-items:flex-start;gap:12px}
.ava{width:46px;height:46px;border-radius:50%;background:var(--purple-l);display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:500;color:var(--purple-d);flex-shrink:0;text-transform:uppercase}
.cn{font-size:16px;font-weight:500}
.cs{font-size:13px;color:var(--muted)}
.cb{padding:0 1.25rem .75rem;border-top:0.5px solid var(--border)}
.srow{display:flex;flex-wrap:wrap;gap:4px;padding:8px 0}
.si{display:flex;flex-direction:column;align-items:center;gap:2px;flex:1;min-width:46px}
.si .sl{font-size:11px;color:var(--muted);text-align:center;line-height:1.2}
.sn{font-size:18px;font-weight:500}
.cf{padding:8px 1.25rem;border-top:0.5px solid var(--border);display:flex;gap:8px;align-items:center}
.ts{font-size:21px;font-weight:500}
.lrow{display:flex;gap:6px;flex-wrap:wrap;margin-top:5px}
.lb{padding:4px 10px;border-radius:6px;border:0.5px solid var(--border);font-size:12px;text-decoration:none;color:var(--muted);background:var(--bg)}
.lb:hover{color:var(--text)}
.tag{display:inline-block;padding:2px 8px;border-radius:6px;font-size:12px;font-weight:500;background:var(--purple-l);color:var(--purple-d)}
.mbg{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:200;display:flex;align-items:flex-start;justify-content:center;padding:1rem;overflow-y:auto}
.modal{background:var(--card);border-radius:16px;width:100%;max-width:680px;margin:auto;border:0.5px solid var(--border)}
.modal-sm{max-width:400px}
.mh{padding:1.25rem 1.5rem;border-bottom:0.5px solid var(--border);display:flex;align-items:center;gap:12px}
.mb{padding:1.5rem;max-height:72vh;overflow-y:auto}
.mf{padding:1rem 1.5rem;border-top:0.5px solid var(--border);display:flex;gap:8px;justify-content:flex-end}
.fg{margin-bottom:1rem}
.fl{font-size:14px;color:var(--muted);margin-bottom:5px;display:block}
.fgr{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.st{font-size:12px;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin:1.25rem 0 .6rem;border-bottom:0.5px solid var(--border);padding-bottom:5px}
.ri{display:flex;align-items:center;gap:12px}
.ri input[type=range]{flex:1}
.rv{min-width:24px;text-align:right;font-weight:500;font-size:16px}
.bw{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.bl{font-size:13px;color:var(--muted);width:110px;flex-shrink:0}
.bb{flex:1;height:6px;background:var(--bg);border-radius:3px;overflow:hidden}
.bf{height:100%;border-radius:3px}
.bv{font-size:13px;font-weight:500;min-width:32px;text-align:right}
.tv{display:flex;border:0.5px solid var(--border);border-radius:8px;overflow:hidden}
.tv button{border:none;padding:6px 14px;font-size:14px;cursor:pointer;background:none;color:var(--muted);font-family:inherit}
.tv button.on{background:var(--bg);color:var(--text);font-weight:500}
table{width:100%;border-collapse:collapse;font-size:14px}
th{text-align:left;padding:8px 9px;font-size:12px;font-weight:500;color:var(--muted);border-bottom:0.5px solid var(--border)}
td{padding:8px 9px;border-bottom:0.5px solid var(--border)}
tr:last-child td{border-bottom:none}
.adchip{display:inline-flex;align-items:center;padding:4px 11px;border-radius:20px;background:var(--purple-l);border:0.5px solid #AFA9EC;font-size:13px;color:var(--purple-d)}
.empty{text-align:center;padding:3rem;color:var(--muted);font-size:16px}
.cbadge{font-size:13px;color:var(--muted)}
.spinner{display:inline-block;width:16px;height:16px;border:2px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .7s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
.toast{position:fixed;bottom:1.5rem;right:1.5rem;background:var(--card);border:0.5px solid var(--border);border-radius:10px;padding:.75rem 1.25rem;font-size:14px;z-index:300;box-shadow:0 4px 16px rgba(0,0,0,.12);animation:fadeIn .2s ease}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.comment{padding:.75rem 0;border-bottom:0.5px solid var(--border)}
.comment:last-child{border-bottom:none}
.comment-head{display:flex;align-items:center;gap:8px;margin-bottom:4px}
.comment-author{font-size:14px;font-weight:500}
.comment-time{font-size:12px;color:var(--muted)}
.comment-admin-badge{font-size:11px;font-weight:500;padding:1px 6px;border-radius:4px;background:var(--purple-l);color:var(--purple-d)}
.comment-body{font-size:15px;line-height:1.6;color:var(--text)}
.comment-del{margin-left:auto;border:none;background:none;color:var(--muted);cursor:pointer;font-size:16px;padding:0 4px;line-height:1}
.comment-del:hover{color:var(--red)}
.comment-form{display:flex;flex-direction:column;gap:8px;margin-top:1rem}
.comment-form input,.comment-form textarea{font-size:14px}
</style>
</head>
<body>
<div id="R"></div>
<script>
const CATS=[
{k:'s', l:'Statistiky'}, {k:'g', l:'Grafika'}, {k:'a', l:'Alerty'},
{k:'v', l:'Vychytávky'}, {k:'n', l:'Náhled'}, {k:'ns',l:'Nastavení'},
{k:'o', l:'Odlišnost'}
];
const MAX=70;
// ── State ──────────────────────────────────────────────────────────
let ST={
admin:false, isMod:false, banned:false, db:[], loading:true,
tab:'pub', search:'', fil:'all', av:'cards',
admSection:'streamers', // 'streamers' | 'users' | 'teams' | 'settings'
loginM:false, editM:false, editId:null,
detailId:null, sugM:false, newM:false,
comments:{}, communityRatings:{},
oauthUser:null, myGroups:[],
settings:{auth_enabled:'false', auth_twitch_enabled:'true'},
pendingUsers:[], currentMods:[], allUsers:[],
raterGroups:[],
teamM:false, teamId:null, // open team management modal for a group
rateM:false, rateStreamerId:null, myRating:null,
activeTeamId:null, // currently-selected team in rater view (admin: any; member: from myGroups)
teamRatings:{}, // map streamer_id -> rating, fetched per active team
detailCRTab:'overview', // 'overview' | group_id (number) — which community-rating tab is open in detail
detailCmtFilter:'all', // 'all' | group_id — filter comments by team
};
// ── API helpers ────────────────────────────────────────────────────
async function api(method, path, body){
const opts={method, headers:{'Content-Type':'application/json'},credentials:'include'};
if(body) opts.body=JSON.stringify(body);
const r=await fetch(path,opts);
const j=await r.json();
if(!r.ok) throw new Error(j.error||'API error');
return j;
}
async function GET(p){return api('GET',p);}
async function POST(p,b){return api('POST',p,b);}
async function PUT(p,b){return api('PUT',p,b);}
async function DEL(p){return api('DELETE',p);}
// ── Boot ───────────────────────────────────────────────────────────
async function boot(){
// Check for auth errors from OAuth redirect
const params=new URLSearchParams(location.search);
if(params.get('auth_error')){
toast('Přihlášení se nezdařilo: '+params.get('auth_error'),false);
history.replaceState({},'','/');
}
try{
const [sess, streamers, settings, me]=await Promise.all([
GET('/api/auth?action=check'),
GET('/api/streamers'),
GET('/api/settings'),
GET('/api/oauth?action=me'),
]);
ST.admin = sess.admin;
ST.db = streamers;
ST.settings = settings;
ST.oauthUser= me.user;
ST.isMod = sess.admin || me.is_mod;
ST.banned = me.banned || false;
if(ST.isMod){
const all=await GET('/api/streamers?all=1');
ST.db=all;
}
// Load rater groups for OAuth users (also admins, so they could see their own team membership)
if(ST.oauthUser){
try{ ST.myGroups = await GET('/api/rater_groups?my=1') || []; }catch(e){ST.myGroups=[];}
}
}catch(e){console.error(e);}
ST.loading=false;
re();
setInterval(autoLiveRefresh, 3*60*1000);
}
async function autoLiveRefresh(){
try{
const rows=await GET('/api/live');
let changed=false;
rows.forEach(r=>{
const s=ST.db.find(x=>x.id===r.id);
if(s&&(s.status!==r.status||s.game!==r.game||s.title!==r.title)){
s.status=r.status;s.game=r.game;s.title=r.title;changed=true;
}
});
if(changed)re();
}catch(e){console.error('Live refresh failed:',e);}
}
async function loadComments(sid){
try{
const list=await GET('/api/comments?streamer_id='+sid);
ST.comments[sid]=list;
// Re-render just the comments section without full re-render
const el=document.getElementById('comments-list');
if(el)el.innerHTML=renderCommentsList(sid);
const btn=document.getElementById('comments-load');
if(btn)btn.style.display='none';
}catch(e){console.error('Failed to load comments:',e);}
}
function renderCommentsList(sid){
const all=ST.comments[sid]||[];
if(!all.length)return`<div style="font-size:14px;color:var(--muted);padding:.5rem 0">Zatím žádné komentáře</div>`;
// Build set of team ids present in comments (for filter chips)
const presentTeamIds=new Set();
all.forEach(c=>(c.team_ids||[]).forEach(t=>presentTeamIds.add(t)));
const teamLookup={};
(ST.raterGroups||[]).concat(ST.myGroups||[]).forEach(g=>{teamLookup[g.id]=g.name;});
// Also pull team names from the current streamer's community ratings (works for guests)
(ST.communityRatings[sid]?.ratings||[]).forEach(r=>{teamLookup[r.group_id]=r.group_name;});
// Filter
const f=ST.detailCmtFilter||'all';
const list = f==='all' ? all : all.filter(c=>(c.team_ids||[]).includes(+f));
// Filter chips (only if we have any team-affiliated comments)
let chips='';
if(presentTeamIds.size){
chips=`<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:.6rem;align-items:center">
<span style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.4px;margin-right:4px">Filtr:</span>
<button class="fchip ${f==='all'?'on':''}" data-cmtfilter="all" style="font-size:12px;padding:3px 10px">Všechny (${all.length})</button>
${[...presentTeamIds].map(tid=>{
const cnt=all.filter(c=>(c.team_ids||[]).includes(tid)).length;
const nm=teamLookup[tid]||`Tým #${tid}`;
return `<button class="fchip ${f==tid?'on':''}" data-cmtfilter="${tid}" style="font-size:12px;padding:3px 10px">${escHtml(nm)} (${cnt})</button>`;
}).join('')}
</div>`;
}
const items = list.length===0
? `<div style="font-size:14px;color:var(--muted);padding:.5rem 0">Žádné komentáře pro tento filtr</div>`
: list.map(c=>{
const tIds=c.team_ids||[];
const teamBadges = tIds.map(tid=>{
const nm=teamLookup[tid]||`Tým #${tid}`;
return `<span style="font-size:10px;padding:1px 6px;background:var(--purple-l);color:var(--purple-d);border-radius:8px">${escHtml(nm)}</span>`;
}).join('');
return `<div class="comment">
<div class="comment-head">
${c.user_avatar?`<img src="${c.user_avatar}" style="width:22px;height:22px;border-radius:50%;object-fit:cover" />`:''}
<span class="comment-author">${escHtml(c.user_display||c.author)}</span>
${c.user_provider?`<span style="font-size:11px;color:var(--muted);padding:1px 6px;background:var(--bg);border-radius:8px;border:0.5px solid var(--border)">${c.user_provider}</span>`:''}
${c.is_admin?'<span class="comment-admin-badge">crew</span>':''}
${teamBadges}
<span class="comment-time">${formatDate(c.created_at)}</span>
${ST.admin?`<button class="comment-del" data-cid="${c.id}" data-sid="${sid}">×</button>`:''}
</div>
<div class="comment-body">${escHtml(c.body)}</div>
</div>`;
}).join('');
return chips + items;
}
function escHtml(s){return(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function formatDate(iso){
const d=new Date(iso);
return d.toLocaleDateString('cs-CZ',{day:'numeric',month:'numeric',year:'numeric'})+' '+d.toLocaleTimeString('cs-CZ',{hour:'2-digit',minute:'2-digit'});
}
// ── Utility ────────────────────────────────────────────────────────
function tot(r){return CATS.reduce((a,c)=>a+(+r[c.k]||0),0);}
function gc(v,m){const p=v/m;return p>=.7?'#1D9E75':p>=.4?'#EF9F27':'#D85A30';}
function av(n){return(n||'?').slice(0,2).toUpperCase();}
function toast(msg,ok=true){
const t=document.createElement('div');
t.className='toast';t.style.borderColor=ok?'#1D9E75':'#D85A30';
t.textContent=msg;document.body.appendChild(t);
setTimeout(()=>t.remove(),3000);
}
// ── Render ─────────────────────────────────────────────────────────
function re(){document.getElementById('R').innerHTML=html();bind();}
function html(){
if(ST.loading) return `<nav><div class="logo">🐾 NaughtyBulldogs</div></nav><main><div class="empty"><span class="spinner"></span>Načítám…</div></main>`;
// Ban screen
if(ST.banned) return `
<nav><div class="logo">🐾 NaughtyBulldogs — Hodnocení streamerů</div></nav>
<main style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<div style="text-align:center;max-width:480px;padding:2rem">
<div style="font-size:48px;margin-bottom:1rem">🚫</div>
<div style="font-size:20px;font-weight:500;margin-bottom:.75rem">Váš účet byl zabanován</div>
<div style="font-size:15px;color:var(--muted);line-height:1.7;margin-bottom:1.5rem">
Pro odbanování nás kontaktujte přes:<br>
<strong>Instagram</strong> · <strong>Discord</strong> · nebo jiným způsobem přes<br>
<strong>NaughtyBulldogs</strong>
</div>
<a href="/api/oauth?action=logout" class="btn" style="text-decoration:none">Odhlásit se</a>
</div>
</main>`;
const liveEv=ST.db.filter(x=>x.status==='live'&&x.evaluated);
const u=ST.oauthUser;
return `<nav>
<div class="logo">🐾 NaughtyBulldogs — Hodnocení streamerů</div>
<button class="ntab ${ST.tab==='pub'?'on':''}" data-tab="pub">Přehled</button>
${(ST.admin||ST.myGroups?.length)?`<button class="ntab ${ST.tab==='rate'?'on':''}" data-tab="rate">Hodnotit</button>`:''}
${ST.isMod&&!ST.admin?`<button class="ntab ${ST.tab==='mod'?'on':''}" data-tab="mod">Hodnocení</button>`:''}
${ST.admin?`<button class="ntab ${ST.tab==='adm'?'on':''}" data-tab="adm">Správa</button>`:''}
<div class="ndiv"></div>
${u?`
<div style="display:flex;align-items:center;gap:8px">
${u.avatar?`<img src="${u.avatar}" style="width:28px;height:28px;border-radius:50%;object-fit:cover" />`:''}
<span style="font-size:14px;font-weight:500">${u.display_name}</span>
<span style="font-size:11px;color:var(--muted);padding:2px 7px;background:var(--bg);border-radius:10px;border:0.5px solid var(--border)">${u.provider}</span>
${ST.isMod&&!ST.admin?`<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:#EAF3DE;color:#3B6D11;border:0.5px solid #639922">mod</span>`:''}
<a class="btn-ghost btn-sm" href="/api/oauth?action=logout" style="text-decoration:none">Odhlásit</a>
</div>
`:`
${ST.settings.auth_twitch_enabled==='true'?`<a class="btn btn-sm" href="/api/oauth?provider=twitch" style="text-decoration:none;display:inline-flex;align-items:center;gap:6px"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z"/></svg> Twitch</a>`:``}
${ST.settings.auth_kick_enabled==='true'?`<a class="btn btn-sm" href="/api/oauth?provider=kick" style="text-decoration:none;display:inline-flex;align-items:center;gap:6px;background:#53fc18;border-color:#3dd10f;color:#000"><span style="font-weight:700">K</span> Kick</a>`:``}
`}
${ST.admin?`<span class="adchip">⚙ Admin</span><button class="btn-ghost btn-sm" id="lo">Odhlásit</button>`:`<button class="btn-ghost btn-sm" id="li">Admin</button>`}
</nav>
<main>
${liveEv.length?`<div class="live-bar">
<div style="display:flex;align-items:center;gap:8px"><div class="dot"></div><span style="font-size:14px;font-weight:500">Právě live (${liveEv.length})</span></div>
<div class="live-chips">${liveEv.map(s=>`<a class="lchip" href="https://${s.platform==='kick'?'kick.com':'twitch.tv'}/${s.platform==='kick'?s.kick:s.name}" target="_blank"><div class="dot" style="width:6px;height:6px"></div><strong>${s.name}</strong>${s.game?`<span style="opacity:.6;font-size:11px">· ${s.game}</span>`:''}</a>`).join('')}</div>
</div>`:''}
${ST.tab==='adm'&&ST.admin?admView():ST.tab==='mod'&&ST.isMod&&!ST.admin?modView():ST.tab==='rate'&&(ST.admin||ST.myGroups?.length)?raterView():pubView()}
</main>
${ST.loginM?loginModal():''}
${ST.editM?editModal():''}
${ST.detailId?detailModal():''}
${ST.sugM?sugModal():''}
${ST.newM?newModal():''}
${ST.teamM?teamModal():''}
${ST.rateM?communityRateModal():''}`;
}
// ── Public view ────────────────────────────────────────────────────
function pubList(){
let l=ST.db.filter(x=>x.evaluated);
if(ST.search){const q=ST.search.toLowerCase();l=l.filter(x=>x.name.toLowerCase().includes(q));}
if(ST.fil==='live')l=l.filter(x=>x.status==='live');
else if(ST.fil==='twitch')l=l.filter(x=>x.platform==='twitch');
else if(ST.fil==='kick')l=l.filter(x=>x.platform==='kick');
return l;
}
function pubView(){
const list=pubList(),tot_ev=ST.db.filter(x=>x.evaluated).length;
return `<div>
<div class="suggest-bar"><p>Znáte zajímavého českého streamera? Navrhněte ho k hodnocení.</p><button class="btn btn-sm" id="sugbtn">+ Navrhnout streamera</button></div>
<div class="sbar"><input id="si" placeholder="Hledat ohodnoceného streamera…" value="${ST.search}"></div>
<div class="frow">
<span style="font-size:12px;color:var(--muted)">Filtr:</span>
${[['all','Všichni'],['live','🔴 Live'],['twitch','Twitch'],['kick','Kick']].map(([f,l])=>`<button class="fchip ${ST.fil===f?'on':''}" data-f="${f}">${l}</button>`).join('')}
<span class="cbadge">${list.length} z ${tot_ev}</span>
</div>
${list.length?`<div class="grid">${list.map(pubCard).join('')}</div>`:`<div class="empty">${ST.search?'Žádný streamer nenalezen':'Zatím žádní ohodnocení streamers'}</div>`}
</div>`;
}
function pubCard(s){
const t=tot(s.r),c=gc(t,MAX);
const cr=ST.communityRatings[s.id];
const crAvg=cr?.avg;
return `<div class="card card-pub" data-det="${s.id}">
<div class="ch"><div class="ava">${av(s.name)}</div>
<div style="flex:1;min-width:0"><div class="cn">${s.name}</div><div class="cs">${s.platform==='kick'?'Kick':'Twitch'}${s.game?` · ${s.game}`:''}</div></div>
${s.status==='live'?`<span class="badge-live"><div class="dot" style="width:6px;height:6px"></div>LIVE</span>`:`<span class="badge-off">Offline</span>`}
</div>
<div class="cb"><div class="srow">${CATS.map(c=>ms(c.l,s.r[c.k],10)).join('')}</div></div>
<div class="cf">
<div><div style="font-size:11px;color:var(--muted)">Celkem</div><div class="ts" style="color:${c}">${t}<span style="font-size:12px;opacity:.4">/${MAX}</span></div></div>
<div style="margin-left:auto;text-align:right"><div style="font-size:11px;color:var(--muted)">Odměna</div><div style="font-size:15px;font-weight:500">${s.reward?s.reward+' Kč':'—'}</div></div>
</div>
${crAvg?`<div style="padding:6px 1.25rem;border-top:0.5px solid var(--border);background:var(--bg);display:flex;align-items:center;gap:8px">
<span style="font-size:11px;color:var(--muted)">Komunita (${crAvg.count}×)</span>
<span style="font-size:14px;font-weight:500;color:${gc(crAvg.total,MAX)}">${crAvg.total}/${MAX}</span>
</div>`:''}
</div>`;
}
function ms(lbl,v,m){return `<div class="si"><div class="sn" style="color:${gc(v,m)}">${v}</div><div class="sl">${lbl}</div></div>`;}
// ── Admin view ─────────────────────────────────────────────────────
function admList(){
let l=[...ST.db];
if(ST.search){const q=ST.search.toLowerCase();l=l.filter(x=>x.name.toLowerCase().includes(q));}
if(ST.fil==='live')l=l.filter(x=>x.status==='live');
else if(ST.fil==='ev')l=l.filter(x=>x.evaluated);
else if(ST.fil==='pend')l=l.filter(x=>!x.evaluated);
else if(ST.fil==='sug')l=l.filter(x=>x.added_by==='viewer');
return l;
}
function admView(){
const sec=ST.admSection||'streamers';
return `<div>
<div style="display:flex;border-bottom:0.5px solid var(--border);margin-bottom:1.25rem;gap:4px;flex-wrap:wrap">
<button class="ntab ${sec==='streamers'?'on':''}" data-asec="streamers" style="border-radius:0">Streameři</button>
<button class="ntab ${sec==='users'?'on':''}" data-asec="users" style="border-radius:0">Uživatelé${ST.allUsers?.length?` <span style="font-size:11px;color:var(--muted)">(${ST.allUsers.length})</span>`:''}</button>
<button class="ntab ${sec==='teams'?'on':''}" data-asec="teams" style="border-radius:0">Týmy${ST.raterGroups?.length?` <span style="font-size:11px;color:var(--muted)">(${ST.raterGroups.length})</span>`:''}</button>
<button class="ntab ${sec==='settings'?'on':''}" data-asec="settings" style="border-radius:0">Nastavení</button>
</div>
${sec==='streamers'?admStreamers():sec==='users'?admUsers():sec==='teams'?admTeams():admSettings()}
</div>`;
}
function admStreamers(){
const list=admList(),pend=ST.db.filter(x=>!x.evaluated).length,sug=ST.db.filter(x=>x.added_by==='viewer').length;
return `<div>
<div style="display:flex;gap:10px;margin-bottom:1.25rem;align-items:center;flex-wrap:wrap">
<button class="btn btn-primary btn-sm" id="newbtn">+ Přidat streamera</button>
<button class="btn btn-sm" id="rfbtn">↻ Live refresh</button>
${pend?`<span style="font-size:13px;color:var(--muted)">${pend} čeká</span>`:''}
${sug?`<span style="font-size:13px;color:var(--purple-d)">${sug} návrhů</span>`:''}
<div style="margin-left:auto"><div class="tv"><button class="${ST.av==='cards'?'on':''}" data-av="cards">Karty</button><button class="${ST.av==='table'?'on':''}" data-av="table">Tabulka</button></div></div>
</div>
<div class="sbar"><input id="si" placeholder="Hledat streamera…" value="${ST.search}"></div>
<div class="frow">
${[['all','Všichni'],['live','🔴 Live'],['ev','Ohodnocení'],['pend','Čekají'],['sug','Návrhy']].map(([f,l])=>`<button class="fchip ${ST.fil===f?'on':''}" data-f="${f}">${l}</button>`).join('')}
<span class="cbadge">${list.length}</span>
</div>
${ST.av==='table'?admTable(list):admCards(list)}
</div>`;
}
function admUsers(){
const mods=ST.currentMods||[];
const users=ST.allUsers||[];
return `<div>
<div class="st">Přidat moderátora</div>
<div style="display:flex;gap:8px;align-items:flex-end;margin-bottom:.5rem;flex-wrap:wrap">
<div style="flex:1;min-width:200px"><div class="fl">Uživatelské jméno</div><input id="mod-login" placeholder="twitch_username"></div>
<div style="width:130px"><div class="fl">Platforma</div><select id="mod-provider"><option value="twitch">Twitch</option><option value="kick">Kick</option></select></div>
<button class="btn btn-primary btn-sm" id="mod-add">Přidat</button>
</div>
<div id="mod-err" style="color:var(--red);font-size:13px;display:none;margin-bottom:.5rem"></div>
<div class="st" style="margin-top:1.5rem">Aktuální moderátoři (${mods.length})</div>
${mods.length===0?`<div style="font-size:14px;color:var(--muted);padding:.4rem 0">Žádní moderátoři</div>`:''}
${mods.map(m=>`<div style="display:flex;align-items:center;gap:10px;padding:.6rem 0;border-bottom:0.5px solid var(--border)">
${m.avatar?`<img src="${m.avatar}" style="width:32px;height:32px;border-radius:50%" />`:`<div class="ava" style="width:32px;height:32px;font-size:12px">${av(m.display_name)}</div>`}
<div style="flex:1"><div style="font-size:14px;font-weight:500">${m.display_name}</div><div style="font-size:12px;color:var(--muted)">${m.provider} · ${m.login}</div></div>
<button class="btn btn-sm btn-danger" data-delmod="${m.mod_id}">Odebrat</button>
</div>`).join('')}
<div class="st" style="margin-top:1.5rem">Všichni přihlášení uživatelé (${users.length})</div>
${users.length===0?`<div style="font-size:14px;color:var(--muted);padding:.4rem 0">Žádní uživatelé</div>`:''}
${users.map(u=>`<div style="display:flex;align-items:center;gap:10px;padding:.6rem 0;border-bottom:0.5px solid var(--border);${u.banned?'opacity:.5':''}">
${u.avatar?`<img src="${u.avatar}" style="width:32px;height:32px;border-radius:50%" />`:`<div class="ava" style="width:32px;height:32px;font-size:12px">${av(u.display_name)}</div>`}
<div style="flex:1">
<div style="font-size:14px;font-weight:500">${u.display_name} ${u.is_mod?'<span style="font-size:11px;padding:1px 7px;background:#EAF3DE;color:#3B6D11;border-radius:8px">mod</span>':''} ${u.banned?'<span style="font-size:11px;padding:1px 7px;background:#FAECE7;color:#993C1D;border-radius:8px">ban</span>':''}</div>
<div style="font-size:12px;color:var(--muted)">${u.provider} · ${u.login} · naposledy ${formatDate(u.last_seen)}</div>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
${u.is_mod
?`<button class="btn btn-sm" data-revmod="${u.id}">Odebrat mod</button>`
:`<button class="btn btn-sm btn-primary" data-givemod="${u.id}" data-login="${u.login}" data-provider="${u.provider}" ${u.banned?'disabled':''}>+Mod</button>`}
${u.banned
?`<button class="btn btn-sm" data-unban="${u.id}">Odbanovat</button>`
:`<button class="btn btn-sm btn-danger" data-ban="${u.id}">Ban</button>`}
</div>
</div>`).join('')}
</div>`;
}
function admTeams(){
const groups=ST.raterGroups||[];
// Streamers already linked to a team — exclude from create dropdown
const linkedSids=new Set(groups.map(g=>g.streamer_id).filter(Boolean));
const availableStreamers=ST.db.filter(s=>!linkedSids.has(s.id));
return `<div>
<div style="font-size:13px;color:var(--muted);margin-bottom:1rem">Tým hodnotitelů reprezentuje konkrétního streamera — každý jeho člen může hodnotit ostatní streamery (ne sebe). Průměr týmu se zobrazí jako komunitní hodnocení.</div>
<div class="st">Vytvořit tým</div>
<div style="display:grid;gap:8px;grid-template-columns:1fr 1fr auto;margin-bottom:.5rem;align-items:end">
<div><div class="fl">Název týmu</div><input id="rg-name" placeholder="např. NaughtyBulldogs"></div>
<div><div class="fl">Streamer (volitelný)</div>
<select id="rg-streamer">
<option value="">— bez vazby na streamera —</option>
${availableStreamers.map(s=>`<option value="${s.id}">${escHtml(s.name)} (${s.platform})</option>`).join('')}
</select>
</div>
<button class="btn btn-primary btn-sm" id="rg-create">Vytvořit</button>
</div>
<div id="rg-err" style="color:var(--red);font-size:13px;display:none;margin-bottom:.5rem"></div>
<div class="st" style="margin-top:1.5rem">Týmy (${groups.length})</div>
${groups.length===0?`<div style="font-size:14px;color:var(--muted);padding:.4rem 0">Žádné týmy</div>`:''}
${groups.map(g=>`<div style="border:0.5px solid var(--border);border-radius:10px;padding:.85rem 1rem;margin-bottom:.6rem;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<div style="flex:1;min-width:160px">
<div style="font-size:15px;font-weight:500">${escHtml(g.name)}${g.streamer_name?` <span style="font-size:12px;color:var(--purple-d);background:var(--purple-l);padding:1px 8px;border-radius:8px;font-weight:400">→ ${escHtml(g.streamer_name)}</span>`:''}</div>
<div style="font-size:12px;color:var(--muted)">${g.members.length} členů · ${g.ratings_count} hodnocení</div>
</div>
<button class="btn btn-sm" data-edteam="${g.id}" title="Upravit vazbu">🔗</button>
<button class="btn btn-sm" data-team="${g.id}">👥 Spravovat</button>
<button class="btn btn-sm btn-danger" data-delrg="${g.id}">×</button>
</div>`).join('')}
</div>`;
}
function admSettings(){
const s=ST.settings;
return `<div style="max-width:560px">
<div class="st">Komentáře — autorizace</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:.5rem 0;border-bottom:0.5px solid var(--border)">
<div>
<div style="font-size:15px;font-weight:500">Vyžadovat přihlášení pro komentáře</div>
<div style="font-size:13px;color:var(--muted)">Pokud vypnuto, stačí zadat přezdívku</div>
</div>
<input type="checkbox" id="set-auth" ${s.auth_enabled==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:.5rem 0;border-bottom:0.5px solid var(--border)">
<div>
<div style="font-size:15px;font-weight:500">Povolit přihlášení přes Twitch</div>
<div style="font-size:13px;color:var(--muted)">Zobrazí tlačítko "Přihlásit přes Twitch"</div>
</div>
<input type="checkbox" id="set-twitch" ${s.auth_twitch_enabled==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:.5rem 0">
<div>
<div style="font-size:15px;font-weight:500">Povolit přihlášení přes Kick</div>
<div style="font-size:13px;color:var(--muted)">Vyžaduje Kick OAuth app na kick.com/settings/developer</div>
</div>
<input type="checkbox" id="set-kick" ${s.auth_kick_enabled==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
</div>
<div style="margin-top:1.25rem;display:flex;gap:8px">
<button class="btn btn-primary btn-sm" id="sett-save">Uložit nastavení</button>
<span id="sett-msg" style="font-size:13px;color:var(--muted);align-self:center"></span>
</div>
</div>`;
}
function admCards(list){
if(!list.length)return`<div class="empty">Žádný streamer</div>`;
return `<div class="grid">${list.map(admCard).join('')}</div>`;
}
function admCard(s){
const t=tot(s.r),c=gc(t,MAX);
return `<div class="card">
<div class="ch"><div class="ava">${av(s.name)}</div>
<div style="flex:1;min-width:0">
<div class="cn">${s.name} ${s.added_by==='viewer'?'<span class="tag">návrh</span>':''}</div>
<div class="cs">${s.platform==='kick'?'Kick':'Twitch'}${s.game?` · ${s.game}`:''}</div>
<div class="lrow">
${s.platform==='kick'
?`<a class="lb" href="https://kick.com/${s.kick||s.name}" target="_blank">Kick</a>
<a class="lb" href="https://streamscharts.com/channels/${s.kick||s.name}?platform=kick" target="_blank">StreamsCharts</a>
<a class="lb" href="https://streamelements.com/${s.kick||s.name}" target="_blank">StreamElements</a>`
:`<a class="lb" href="https://twitch.tv/${s.name}" target="_blank">Twitch</a>
<a class="lb" href="https://streamscharts.com/channels/${s.name}" target="_blank">StreamsCharts</a>
<a class="lb" href="https://streamelements.com/${s.name}" target="_blank">StreamElements</a>
${s.kick?`<a class="lb" href="https://kick.com/${s.kick}" target="_blank">Kick</a>`:''}`}
</div>
</div>
${s.status==='live'?`<span class="badge-live"><div class="dot" style="width:6px;height:6px"></div>LIVE</span>`:`<span class="badge-off">Offline</span>`}
</div>
${s.evaluated?`<div class="cb"><div class="srow">${CATS.map(c=>ms(c.l,s.r[c.k],10)).join('')}</div></div>
<div class="cf">
<div><div style="font-size:11px;color:var(--muted)">Skóre</div><div class="ts" style="color:${c}">${t}<span style="font-size:12px;opacity:.4">/${MAX}</span></div></div>
<div style="margin-left:auto;text-align:right"><div style="font-size:11px;color:var(--muted)">Odměna</div><div style="font-size:15px;font-weight:500">${s.reward?s.reward+' Kč':'—'}</div></div>
<div style="display:flex;gap:5px;margin-left:10px"><button class="btn btn-sm" data-ed="${s.id}">Upravit</button><button class="btn btn-sm" data-rst="${s.id}" title="Resetovat hodnocení" style="border-color:#f09595;color:#a32d2d">↺</button><button class="btn btn-sm" data-lock="${s.id}" data-locked="${s.community_locked}" title="${s.community_locked?'Odemknout':'Zamknout'} komunitní hodnocení" style="border-color:#aaa">${s.community_locked?'🔒':'🔓'}</button><button class="btn btn-sm btn-danger" data-dl="${s.id}">×</button></div>
</div>`:`<div class="cf" style="justify-content:space-between">
<span style="font-size:13px;color:var(--muted)">Čeká na hodnocení</span>
<div style="display:flex;gap:5px"><button class="btn btn-sm btn-primary" data-ed="${s.id}">+ Hodnotit</button><button class="btn btn-sm btn-danger" data-dl="${s.id}">×</button></div>
</div>`}
</div>`;
}
function admTable(list){
return `<div style="overflow-x:auto"><table>
<thead><tr><th>Streamer</th>${CATS.map(c=>`<th>${c.l.slice(0,5)}.</th>`).join('')}<th>Celkem</th><th>Odměna</th><th></th></tr></thead>
<tbody>${list.map(s=>{const t=tot(s.r);return`<tr>
<td><strong>${s.name}</strong>${s.added_by==='viewer'?' <span class="tag" style="font-size:10px">návrh</span>':''}<br><span style="font-size:11px;color:var(--muted)">${s.status==='live'?'🔴 live':'offline'}</span></td>
${s.evaluated?CATS.map(c=>`<td style="color:${gc(s.r[c.k],10)};font-weight:500">${s.r[c.k]}</td>`).join(''):`<td colspan="${CATS.length}" style="color:var(--muted)">—</td>`}
<td style="font-weight:500;color:${gc(t,MAX)}">${s.evaluated?`${t}/${MAX}`:'—'}</td>
<td>${s.reward?s.reward+' Kč':'—'}</td>
<td><div style="display:flex;gap:4px"><button class="btn btn-sm" data-ed="${s.id}">Upravit</button><button class="btn btn-sm" data-rst="${s.id}" title="Reset" style="border-color:#f09595;color:#a32d2d">↺</button><button class="btn btn-sm btn-danger" data-dl="${s.id}">×</button></div></td>
</tr>`;}).join('')}</tbody>
</table></div>`;
}
// ── Modals ─────────────────────────────────────────────────────────
function loginModal(){return `<div class="mbg" id="lmbg">
<div class="modal modal-sm">
<div class="mh"><div style="flex:1"><div style="font-size:16px;font-weight:500">Admin přihlášení</div><div style="font-size:13px;color:var(--muted)">Správa hodnocení a streamerů</div></div><button class="btn-ghost" id="lmx">✕</button></div>
<div class="mb"><div class="fg"><div class="fl">Heslo</div><input id="lpw" type="password" placeholder="Admin heslo"></div><div id="lerr" style="color:var(--red);font-size:13px;display:none;margin-top:-6px">Nesprávné heslo</div></div>
<div class="mf"><button class="btn" id="lmcancel">Zrušit</button><button class="btn btn-primary" id="lmok">Přihlásit</button></div>
</div></div>`;}
function editModal(){
const s=ST.editId?ST.db.find(x=>x.id===ST.editId):null;
const r=s?{...s.r}:{s:0,g:0,a:0,v:0,n:0,ns:0,o:0};
return `<div class="mbg" id="embg">
<div class="modal">
<div class="mh"><div class="ava" style="width:38px;height:38px;font-size:13px">${av(s?s.name:'?')}</div>
<div style="flex:1"><div style="font-size:16px;font-weight:500">${s?s.name:'—'}</div><div style="font-size:13px;color:var(--muted)">Hodnocení (010 bodů per kategorie)</div></div>
<button class="btn-ghost" id="emx">✕</button></div>
<div class="mb"><form id="ef">
<div class="st">Platforma &amp; Status</div>
<div class="fgr">
${s&&s.platform==='kick'
? `<div class="fg"><div class="fl" style="color:var(--muted);font-size:12px">Kick streamer — jméno nelze změnit zde</div><input name="kick" value="${s?s.kick||s.name:''}" type="hidden"><div style="font-size:14px;padding:7px 0;color:var(--muted)">${s?s.kick||s.name:''}</div></div>`
: `<div class="fg"><div class="fl">Kick jméno (pokud má i Kick kanál)</div><input name="kick" value="${s?s.kick||'':''}" placeholder="kick_username"></div>`}
<div class="fg"><div class="fl">Status</div><select name="status"><option value="offline" ${!s||s.status==='offline'?'selected':''}>Offline</option><option value="live" ${s&&s.status==='live'?'selected':''}>LIVE</option></select></div>
</div>
<div class="fgr">
<div class="fg"><div class="fl">Hra</div><input name="game" value="${s?s.game||'':''}" placeholder="Just Chatting…"></div>
<div class="fg"><div class="fl">Titulek</div><input name="title" value="${s?s.title||'':''}" placeholder="Titulek streamu"></div>
</div>
<div class="st">Hodnocení</div>
${CATS.map(c=>sl(c.k,`${c.l} (010)`,r[c.k]||0,10)).join('')}
<div class="st">Finanční odměna &amp; Poznámky</div>
<div class="fg">
<div class="fl">Odměna (automaticky dle skóre: 30130 Kč)</div>
<div style="display:flex;align-items:center;gap:16px;padding:8px 0">
<div style="font-size:28px;font-weight:600;color:var(--purple)" id="auto-reward">${autoReward(r)} Kč</div>
<div style="font-size:13px;color:var(--muted)">= 30 + (skóre / 70) × 100, zaokrouhleno</div>
</div>
<input type="hidden" name="reward" id="rw" value="${autoReward(r)}">
</div>
<div class="fg"><div class="fl">Poznámky</div><textarea name="notes" rows="3" style="resize:vertical">${s?s.notes||'':''}</textarea></div>
</form></div>
<div class="mf"><button class="btn" id="emcancel">Zrušit</button><button class="btn btn-primary" id="emsave">Uložit</button></div>
</div></div>`;
}
function sl(nm,lbl,val,max){const c=gc(val,max);return `<div class="fg"><div class="fl">${lbl}</div><div class="ri"><input type="range" name="${nm}" min="0" max="${max}" step="1" value="${val}" id="r${nm}"><div class="rv" id="rv${nm}" style="color:${c}">${val}</div></div></div>`;}
function detailModal(){
const s=ST.db.find(x=>x.id===ST.detailId);if(!s)return'';
const t=tot(s.r),c=gc(t,MAX);
const cr=ST.communityRatings[s.id];
const crAvg=cr?.avg;
const crRatings=cr?.ratings||[];
const authOn=ST.settings.auth_enabled==='true';
const u=ST.oauthUser;
return `<div class="mbg" id="dmbg">
<div class="modal">
<div class="mh">
<div class="ava" style="width:48px;height:48px;font-size:16px">${av(s.name)}</div>
<div style="flex:1">
<div style="font-size:17px;font-weight:500">${s.name}</div>
<div style="font-size:13px;color:var(--muted)">${s.platform==='kick'?'Kick':'Twitch'} · ${s.status==='live'?'<span style="color:var(--red)">🔴 LIVE</span>':'Offline'}</div>
<div class="lrow">
${s.platform==='kick'
?`<a class="lb" href="https://kick.com/${s.kick||s.name}" target="_blank">Kick</a>
<a class="lb" href="https://streamscharts.com/channels/${s.kick||s.name}?platform=kick" target="_blank">StreamsCharts</a>
<a class="lb" href="https://streamelements.com/${s.kick||s.name}" target="_blank">StreamElements</a>`
:`<a class="lb" href="https://twitch.tv/${s.name}" target="_blank">Twitch</a>
<a class="lb" href="https://streamscharts.com/channels/${s.name}" target="_blank">StreamsCharts</a>
<a class="lb" href="https://streamelements.com/${s.name}" target="_blank">StreamElements</a>
${s.kick?`<a class="lb" href="https://kick.com/${s.kick}" target="_blank">Kick</a>`:''}`}
</div>
</div>
<button class="btn-ghost" id="dmx">✕</button>
</div>
<div class="mb">
<!-- Official rating -->
<div style="font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:.5rem">Oficiální hodnocení</div>
<div style="display:flex;align-items:flex-end;gap:2rem;margin-bottom:1.5rem">
<div><div style="font-size:12px;color:var(--muted)">Celkové skóre</div><div style="font-size:40px;font-weight:600;color:${c};line-height:1">${t}<span style="font-size:17px;opacity:.4">/${MAX}</span></div></div>
${s.reward?`<div><div style="font-size:12px;color:var(--muted)">Finanční odměna</div><div style="font-size:26px;font-weight:500">${s.reward} Kč</div></div>`:''}
</div>
${CATS.map(cat=>{const v=s.r[cat.k]||0;return`<div class="bw"><div class="bl">${cat.l}</div><div class="bb"><div class="bf" style="width:${Math.round(v/10*100)}%;background:${gc(v,10)}"></div></div><div class="bv" style="color:${gc(v,10)}">${v}/10</div></div>`;}).join('')}
${s.notes?`<div style="margin-top:1rem;padding-top:1rem;border-top:0.5px solid var(--border)"><div style="font-size:12px;color:var(--muted);margin-bottom:.5rem">Poznámky</div><div style="font-size:14px;line-height:1.6;color:var(--muted)">${s.notes}</div></div>`:''}
<!-- Community ratings -->
<div style="margin-top:1.5rem;padding-top:1rem;border-top:0.5px solid var(--border)">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:.75rem">
<div style="font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)">Hodnocení komunit${crAvg?` (${crAvg.count})`:''}</div>
${s.community_locked?`<span style="font-size:11px;padding:2px 8px;border-radius:8px;background:#FAECE7;color:#993C1D;border:0.5px solid var(--red)">🔒 Uzamčeno</span>`:''}
</div>
${crAvg?(()=>{
// Validate active CR tab — fall back to overview if it points to a removed team
let curTab=ST.detailCRTab;
if(curTab!=='overview' && !crRatings.find(r=>r.group_id===curTab)) curTab='overview';
return `
<div style="display:flex;gap:4px;border-bottom:0.5px solid var(--border);margin-bottom:1rem;flex-wrap:wrap;overflow-x:auto">
<button class="ntab ${curTab==='overview'?'on':''}" data-crtab="overview" style="border-radius:0;padding:.5rem .75rem;font-size:13px">Průměr</button>
${crRatings.map(r=>{const rt=CATS.reduce((a,c)=>a+(+r[c.k]||0),0);return `<button class="ntab ${curTab===r.group_id?'on':''}" data-crtab="${r.group_id}" style="border-radius:0;padding:.5rem .75rem;font-size:13px">${escHtml(r.group_name)} <span style="opacity:.6;margin-left:4px">${rt}/${MAX}</span></button>`;}).join('')}
</div>
${curTab==='overview'?`
<div style="display:flex;align-items:center;gap:1.5rem;margin-bottom:1rem">
<div><div style="font-size:12px;color:var(--muted)">Průměr ze všech týmů</div><div style="font-size:28px;font-weight:600;color:${gc(crAvg.total,MAX)};line-height:1">${crAvg.total}<span style="font-size:14px;opacity:.4">/${MAX}</span></div></div>
</div>
${CATS.map(cat=>{const v=crAvg[cat.k]||0;return`<div class="bw"><div class="bl">${cat.l}</div><div class="bb"><div class="bf" style="width:${Math.round(v/10*100)}%;background:${gc(v,10)};opacity:.7"></div></div><div class="bv" style="color:${gc(v,10)}">${v}/10</div></div>`;}).join('')}
`:(()=>{
const r=crRatings.find(x=>x.group_id===curTab);if(!r)return '';
const rt=CATS.reduce((a,c)=>a+(+r[c.k]||0),0);
return `
<div style="display:flex;align-items:center;gap:1.5rem;margin-bottom:1rem">
<div><div style="font-size:12px;color:var(--muted)">Hodnocení od týmu <strong>${escHtml(r.group_name)}</strong></div><div style="font-size:28px;font-weight:600;color:${gc(rt,MAX)};line-height:1">${rt}<span style="font-size:14px;opacity:.4">/${MAX}</span></div></div>
${ST.admin?`<button class="btn btn-sm btn-danger" data-delcr="${r.id}" style="margin-left:auto">× Smazat</button>`:''}
</div>
${CATS.map(cat=>{
const v=r[cat.k]||0;
const _key={s:'statistiky',g:'grafika',a:'alerty',v:'vychytavky',n:'nahled',ns:'nastaveni',o:'odlisnost'}[cat.k];
const v2=r[_key]??v;
return `<div class="bw"><div class="bl">${cat.l}</div><div class="bb"><div class="bf" style="width:${Math.round(v2/10*100)}%;background:${gc(v2,10)};opacity:.85"></div></div><div class="bv" style="color:${gc(v2,10)}">${v2}/10</div></div>`;
}).join('')}
${r.notes?`<div style="margin-top:.85rem;padding:.7rem .9rem;background:var(--purple-l);border-left:3px solid var(--purple-d);border-radius:6px"><div style="font-size:11px;color:var(--purple-d);font-weight:500;text-transform:uppercase;letter-spacing:.4px;margin-bottom:.3rem">Poznámka týmu</div><div style="font-size:14px;line-height:1.5;white-space:pre-wrap">${escHtml(r.notes)}</div></div>`:''}
`;
})()}
`;})():`<div style="font-size:14px;color:var(--muted)">Zatím žádná komunitní hodnocení</div>`}
</div>
<!-- Comments -->
<div style="margin-top:1.5rem;padding-top:1rem;border-top:0.5px solid var(--border)">
<div style="font-size:13px;font-weight:500;margin-bottom:.75rem">Komentáře</div>
<div id="comments-list">
${ST.comments[s.id]!=null?renderCommentsList(s.id):`<button class="btn btn-sm" id="comments-load" data-sid="${s.id}">Načíst komentáře</button>`}
</div>
<div class="comment-form" id="comment-form">
${authOn&&!u&&!ST.admin?`<div style="font-size:14px;color:var(--muted);padding:.5rem 0">
Pro komentování je vyžadováno přihlášení.
${ST.settings.auth_twitch_enabled==='true'?`<a class="btn btn-sm btn-primary" href="/api/oauth?provider=twitch" style="text-decoration:none;display:inline-flex;align-items:center;gap:6px;margin-left:8px"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z"/></svg> Twitch</a>`:``}
${ST.settings.auth_kick_enabled==='true'?`<a class="btn btn-sm" href="/api/oauth?provider=kick" style="text-decoration:none;display:inline-flex;align-items:center;gap:6px;margin-left:4px;background:#53fc18;border-color:#3dd10f;color:#000"><span style="font-weight:700">K</span> Kick</a>`:``}
</div>`:u?`<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
${u.avatar?`<img src="${u.avatar}" style="width:24px;height:24px;border-radius:50%" />`:''}
<span style="font-size:14px;font-weight:500">${u.display_name}</span>
<span style="font-size:11px;color:var(--muted)">${u.provider}</span>
</div>
<textarea id="c-body" rows="2" placeholder="Napište komentář…" style="resize:vertical"></textarea>
<div><button class="btn btn-sm btn-primary" id="c-submit" data-sid="${s.id}">Odeslat</button></div>
`:`<input id="c-author" placeholder="Vaše přezdívka (nepovinné)" style="max-width:220px">
<textarea id="c-body" rows="2" placeholder="Napište komentář…" style="resize:vertical"></textarea>
<div><button class="btn btn-sm btn-primary" id="c-submit" data-sid="${s.id}">Odeslat</button></div>`}
</div>
</div>
</div>
</div></div>`;
}
function modView(){
// Moderator view — same as admin cards but without add/delete/settings
const list=[...ST.db];
const filtered=list.filter(s=>{
if(ST.search){const q=ST.search.toLowerCase();if(!s.name.toLowerCase().includes(q))return false;}
if(ST.fil==='live')return s.status==='live';
if(ST.fil==='ev')return s.evaluated;
if(ST.fil==='pend')return !s.evaluated;
return true;
});
return `<div>
<div style="padding:.6rem 1rem;background:#EAF3DE;border:0.5px solid #639922;border-radius:10px;margin-bottom:1.25rem;font-size:14px;color:#3B6D11">
Přihlášen jako moderátor — můžeš hodnotit streamery a spravovat komentáře.
</div>
<div class="sbar"><input id="si" placeholder="Hledat streamera…" value="${ST.search}"></div>
<div class="frow">
${[['all','Všichni'],['live','🔴 Live'],['ev','Ohodnocení'],['pend','Čekají']].map(([f,l])=>`<button class="fchip ${ST.fil===f?'on':''}" data-f="${f}">${l}</button>`).join('')}
<span class="cbadge">${filtered.length} streamerů</span>
</div>
<div class="grid">${filtered.map(s=>modCard(s)).join('')}</div>
</div>`;
}
function modCard(s){
const t=tot(s.r),c=gc(t,MAX);
return `<div class="card" style="cursor:default">
<div class="ch"><div class="ava">${av(s.name)}</div>
<div style="flex:1;min-width:0">
<div class="cn">${s.name}</div>
<div class="cs">${s.platform==='kick'?'Kick':'Twitch'}${s.game?` · ${s.game}`:''}</div>
<div class="lrow">
${s.platform==='kick'
?`<a class="lb" href="https://kick.com/${s.kick||s.name}" target="_blank">Kick</a>
<a class="lb" href="https://streamscharts.com/channels/${s.kick||s.name}?platform=kick" target="_blank">StreamsCharts</a>
<a class="lb" href="https://streamelements.com/${s.kick||s.name}" target="_blank">StreamElements</a>`
:`<a class="lb" href="https://twitch.tv/${s.name}" target="_blank">Twitch</a>
<a class="lb" href="https://streamscharts.com/channels/${s.name}" target="_blank">StreamsCharts</a>
<a class="lb" href="https://streamelements.com/${s.name}" target="_blank">StreamElements</a>
${s.kick?`<a class="lb" href="https://kick.com/${s.kick}" target="_blank">Kick</a>`:''}`}
</div>
</div>
${s.status==='live'?`<span class="badge-live"><div class="dot" style="width:6px;height:6px"></div>LIVE</span>`:`<span class="badge-off">Offline</span>`}
</div>
${s.evaluated?`<div class="cb"><div class="srow">${CATS.map(c=>ms(c.l,s.r[c.k],10)).join('')}</div></div>
<div class="cf">
<div><div style="font-size:11px;color:var(--muted)">Skóre</div><div class="ts" style="color:${c}">${t}<span style="font-size:12px;opacity:.4">/${MAX}</span></div></div>
<div style="margin-left:auto;text-align:right"><div style="font-size:11px;color:var(--muted)">Odměna</div><div style="font-size:15px;font-weight:500">${s.reward?s.reward+' Kč':'—'}</div></div>
<div style="margin-left:10px"><button class="btn btn-sm" data-ed="${s.id}">Upravit</button></div>
</div>`:`<div class="cf" style="justify-content:space-between">
<span style="font-size:13px;color:var(--muted)">Čeká na hodnocení</span>
<button class="btn btn-sm btn-primary" data-ed="${s.id}">+ Hodnotit</button>
</div>`}
</div>`;
}
// ── Rater view (members + admin) ──────────────────────────────────
function raterView(){
// Available teams to rate as
// - admin: all teams (ST.raterGroups)
// - member: only teams they belong to (ST.myGroups)
const teams = ST.admin ? (ST.raterGroups||[]) : (ST.myGroups||[]);
if(!teams.length){
return `<div class="empty">
${ST.admin
? 'Zatím nejsou žádné týmy. Vytvoř tým ve Správa → Týmy.'
: 'Nejste členem žádného týmu. Požádejte admina o přidání.'}
</div>`;
}
// Default-pick a team if none selected (or selected one no longer exists)
if(!ST.activeTeamId || !teams.find(t=>t.id===ST.activeTeamId)){
ST.activeTeamId = teams[0].id;
}
const team = teams.find(t=>t.id===ST.activeTeamId);
const linkedSid = team?.streamer_id || null;
// Show all streamers, exclude the team's own streamer
const list = ST.db.filter(s=>{
if(linkedSid && s.id===linkedSid) return false;
if(ST.search){const q=ST.search.toLowerCase();if(!s.name.toLowerCase().includes(q))return false;}
return true;
});
const teamPicker = teams.length>1
? `<select id="team-picker" style="max-width:260px">
${teams.map(t=>`<option value="${t.id}" ${t.id===ST.activeTeamId?'selected':''}>${escHtml(t.name)}${t.streamer_id?' · '+escHtml(t.streamer_name||'#'+t.streamer_id):''}</option>`).join('')}
</select>`
: `<strong>${escHtml(team.name)}</strong>${team.streamer_id?` · <span style="opacity:.7">${escHtml(team.streamer_name||'')}</span>`:''}`;
return `<div>
<div style="padding:.7rem 1rem;background:#EAF3DE;border:0.5px solid #639922;border-radius:10px;margin-bottom:1rem;font-size:14px;color:#3B6D11;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<span>${ST.admin?'Hodnotíš jako tým:':'Hodnotíš za tým:'}</span>
${teamPicker}
${linkedSid?`<span style="font-size:12px;opacity:.7">(svého streamera nelze hodnotit)</span>`:''}
</div>
<div class="sbar"><input id="si" placeholder="Hledat streamera…" value="${ST.search}"></div>
${list.length===0?`<div class="empty">Žádný streamer k hodnocení.</div>`:`
<div class="grid">${list.map(s=>{
const myR=ST.teamRatings?.[s.id];
const myTotal=myR?CATS.reduce((a,c)=>{
const map={s:'statistiky',g:'grafika',a:'alerty',v:'vychytavky',n:'nahled',ns:'nastaveni',o:'odlisnost'};
return a+(+myR[map[c.k]]||0);
},0):null;
return `<div class="card" style="cursor:default">
<div class="ch"><div class="ava">${av(s.name)}</div>
<div style="flex:1;min-width:0"><div class="cn">${s.name}${!s.evaluated?' <span class="tag" style="font-size:10px">bez ofic. hodnocení</span>':''}</div><div class="cs">${s.platform==='kick'?'Kick':'Twitch'}${s.game?` · ${s.game}`:''}</div></div>
${s.status==='live'?`<span class="badge-live"><div class="dot" style="width:6px;height:6px"></div>LIVE</span>`:`<span class="badge-off">Offline</span>`}
</div>
<div class="cf" style="justify-content:space-between">
<div>
${myTotal!==null?`<div style="font-size:11px;color:var(--muted)">Hodnocení týmu</div><div style="font-size:18px;font-weight:500;color:${gc(myTotal,MAX)}">${myTotal}/${MAX}</div>`:`<span style="font-size:13px;color:var(--muted)">Zatím nehodnoceno</span>`}
</div>
<div style="display:flex;gap:6px;align-items:center">
${s.community_locked&&!ST.admin?`<span style="font-size:12px;color:var(--red)">🔒 Uzamčeno</span>`:`<button class="btn btn-sm btn-primary" data-rate="${s.id}">${myTotal!==null?'Upravit':'+ Hodnotit'}</button>`}
</div>
</div>
</div>`;
}).join('')}
</div>`}
</div>`;
}
function communityRateModal(){
const sid=ST.rateStreamerId;
const s=ST.db.find(x=>x.id===sid);
if(!s)return'';
const teams = ST.admin ? (ST.raterGroups||[]) : (ST.myGroups||[]);
const team = teams.find(t=>t.id===ST.activeTeamId);
const myR=ST.myRating;
const r=myR?{s:myR.statistiky,g:myR.grafika,a:myR.alerty,v:myR.vychytavky,n:myR.nahled,ns:myR.nastaveni,o:myR.odlisnost}:{s:0,g:0,a:0,v:0,n:0,ns:0,o:0};
return `<div class="mbg" id="crate-bg">
<div class="modal">
<div class="mh">
<div class="ava" style="width:38px;height:38px;font-size:13px">${av(s.name)}</div>
<div style="flex:1"><div style="font-size:16px;font-weight:500">${s.name}</div><div style="font-size:13px;color:var(--muted)">Hodnocení za tým <strong>${escHtml(team?.name||'?')}</strong>${ST.admin?' <span style="opacity:.6">(admin)</span>':''}</div></div>
<button class="btn-ghost" id="crate-close">✕</button>
</div>
<div class="mb"><form id="crf">
<div class="st">Hodnocení (010 bodů)</div>
${CATS.map(c=>sl(c.k,`${c.l} (010)`,r[c.k]||0,10)).join('')}
<div class="fg" style="margin-top:1rem"><div class="fl">Poznámky (nepovinné)</div>
<textarea name="notes" rows="2" style="resize:vertical">${myR?.notes||''}</textarea>
</div>
</form></div>
<div class="mf">
<button class="btn" id="crate-cancel">Zrušit</button>
<button class="btn btn-primary" id="crate-save">Uložit hodnocení</button>
</div>
</div></div>`;
}
function teamModal(){
const g=(ST.raterGroups||[]).find(x=>x.id===ST.teamId);
if(!g)return'';
return `<div class="mbg" id="team-bg">
<div class="modal" style="max-width:640px">
<div class="mh">
<div style="flex:1">
<div style="font-size:16px;font-weight:500">👥 ${escHtml(g.name)}${g.streamer_name?` <span style="font-size:12px;color:var(--purple-d);background:var(--purple-l);padding:1px 8px;border-radius:8px;font-weight:400">→ ${escHtml(g.streamer_name)}</span>`:''}</div>
<div style="font-size:13px;color:var(--muted)">${g.members.length} členů · ${g.ratings_count} hodnocení</div>
</div>
<button class="btn-ghost" id="team-close">✕</button>
</div>
<div class="mb">
<div class="st">Přidat / promote člena</div>
<div style="display:grid;gap:6px;grid-template-columns:1fr 100px 110px auto;margin-bottom:.4rem;align-items:center">
<input id="tm-login" placeholder="twitch_username">
<select id="tm-provider"><option value="twitch">Twitch</option><option value="kick">Kick</option></select>
<select id="tm-role"><option value="rater">Rater</option><option value="owner">Owner (streamer)</option></select>
<button class="btn btn-primary btn-sm" id="tm-add">+ Přidat</button>
</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:.4rem">
<strong>Owner</strong> = sám streamer (zástupce týmu, jen jeden). <strong>Rater</strong> = člen týmu, který hodnotí ostatní streamery.
</div>
<div id="tm-err" style="color:var(--red);font-size:12px;display:none;margin-bottom:.4rem"></div>
<div class="st" style="margin-top:1.25rem">Členové (${g.members.length})</div>
${g.members.length===0?`<div style="font-size:14px;color:var(--muted);padding:.4rem 0">Zatím nikdo</div>`:''}
${g.members.map(m=>{
const isOwner=m.role==='owner';
return `<div style="display:flex;align-items:center;gap:10px;padding:.5rem 0;border-bottom:0.5px solid var(--border)">
${m.avatar?`<img src="${m.avatar}" style="width:28px;height:28px;border-radius:50%" />`:`<div class="ava" style="width:28px;height:28px;font-size:11px">${av(m.display_name)}</div>`}
<div style="flex:1">
<div style="font-size:14px;font-weight:500">${m.display_name}
${isOwner
?'<span style="font-size:10px;padding:1px 7px;background:var(--purple-l);color:var(--purple-d);border-radius:8px;margin-left:6px">OWNER</span>'
:'<span style="font-size:10px;padding:1px 7px;background:#EAF3DE;color:#3B6D11;border-radius:8px;margin-left:6px">rater</span>'}
</div>
<div style="font-size:12px;color:var(--muted)">${m.provider} · ${m.login}</div>
</div>
<button class="btn btn-sm btn-danger" data-delmember="${m.member_id}" style="padding:4px 10px">×</button>
</div>`;
}).join('')}
</div>
<div class="mf">
<button class="btn" id="team-cancel">Zavřít</button>
</div>
</div></div>`;
}
function sugModal(){return `<div class="mbg" id="sgbg">
<div class="modal modal-sm">
<div class="mh"><div style="flex:1"><div style="font-size:16px;font-weight:500">Navrhnout streamera</div><div style="font-size:13px;color:var(--muted)">Váš návrh zobrazíme jako podklad</div></div><button class="btn-ghost" id="sgx">✕</button></div>
<div class="mb">
<div class="fg"><div class="fl">Twitch / Kick jméno *</div><input id="sgn" placeholder="username"></div>
<div class="fg"><div class="fl">Platforma</div><select id="sgp"><option value="twitch">Twitch</option><option value="kick">Kick</option></select></div>
<div class="fg"><div class="fl">Vaše přezdívka</div><input id="sgs" placeholder="nepovinné"></div>
<div id="sgerr" style="color:var(--red);font-size:13px;display:none">Vyplňte jméno streamera</div>
</div>
<div class="mf"><button class="btn" id="sgcancel">Zrušit</button><button class="btn btn-primary" id="sgok">Odeslat návrh</button></div>
</div></div>`;}
function newModal(){return `<div class="mbg" id="nmbg">
<div class="modal modal-sm">
<div class="mh"><div style="flex:1"><div style="font-size:16px;font-weight:500">Přidat streamera</div></div><button class="btn-ghost" id="nmx">✕</button></div>
<div class="mb">
<div class="fg"><div class="fl">Platforma</div><select id="nmp" onchange="
const isTwitch=this.value==='twitch';
document.getElementById('nm-name-label').textContent=isTwitch?'Twitch jméno *':'Kick jméno *';
document.getElementById('nmn').placeholder=isTwitch?'twitch_username':'kick_username';
document.getElementById('nm-kick-row').style.display=isTwitch?'block':'none';
"><option value="twitch">Twitch</option><option value="kick">Kick</option></select></div>
<div class="fg"><div class="fl" id="nm-name-label">Twitch jméno *</div><input id="nmn" placeholder="twitch_username"></div>
<div class="fg" id="nm-kick-row"><div class="fl">Kick jméno (pokud se liší od Twitch)</div><input id="nmk" placeholder="nepovinné"></div>
<div id="nmerr" style="color:var(--red);font-size:13px;display:none">Jméno povinné nebo již existuje</div>
</div>
<div class="mf"><button class="btn" id="nmcancel">Zrušit</button><button class="btn btn-primary" id="nmok">Přidat</button></div>
</div></div>`;}
// ── Event binding ──────────────────────────────────────────────────
function on(id,fn){document.getElementById(id)?.addEventListener('click',fn);}
function qa(sel,fn){document.querySelectorAll(sel).forEach(fn);}
function bind(){
qa('[data-tab]',el=>el.addEventListener('click',async()=>{
const t=el.dataset.tab;
ST.tab=t;ST.fil='all';ST.search='';
if(t==='rate'){
try{
if(ST.admin && !ST.raterGroups?.length){
ST.raterGroups=await GET('/api/rater_groups');
}
// Decide active team
const teams = ST.admin ? (ST.raterGroups||[]) : (ST.myGroups||[]);
if(teams.length && (!ST.activeTeamId || !teams.find(t2=>t2.id===ST.activeTeamId))){
ST.activeTeamId = teams[0].id;
}
// Load this team's ratings map
if(ST.activeTeamId){
try{ST.teamRatings=await GET('/api/community_ratings?team_ratings=1&group_id='+ST.activeTeamId);}
catch(e){ST.teamRatings={};}
}
}catch(e){toast('Chyba načítání: '+e.message,false);}
}
re();
}));
// Team picker change (rater view) — switch active team, refetch its ratings
const tp=document.getElementById('team-picker');
if(tp)tp.addEventListener('change',async()=>{
ST.activeTeamId=+tp.value;
try{ST.teamRatings=await GET('/api/community_ratings?team_ratings=1&group_id='+ST.activeTeamId);}
catch(e){ST.teamRatings={};}
re();
});
qa('[data-f]',el=>el.addEventListener('click',()=>{ST.fil=el.dataset.f;re();}));
qa('[data-av]',el=>el.addEventListener('click',()=>{ST.av=el.dataset.av;re();}));
// Admin section navigation — lazy-load data on first visit
qa('[data-asec]',el=>el.addEventListener('click',async()=>{
const sec=el.dataset.asec;
ST.admSection=sec;
try{
if(sec==='users'){
const [mods,users]=await Promise.all([GET('/api/moderators'),GET('/api/moderators?users=1')]);
ST.currentMods=mods;ST.allUsers=users;
}else if(sec==='teams'){
ST.raterGroups=await GET('/api/rater_groups');
}
}catch(e){toast('Chyba načítání: '+e.message,false);}
re();
}));
const si=document.getElementById('si');
if(si)si.addEventListener('input',e=>{ST.search=e.target.value;re();});
on('li',()=>{ST.loginM=true;re();setTimeout(()=>document.getElementById('lpw')?.focus(),30);});
on('lo',async()=>{await POST('/api/auth?action=logout');ST.admin=false;ST.tab='pub';ST.db=await GET('/api/streamers');re();});
on('sugbtn',()=>{ST.sugM=true;re();});
on('newbtn',()=>{ST.newM=true;re();});
on('rfbtn',async()=>{
try{
const r=await GET('/api/live?refresh=1');
ST.db=await GET('/api/streamers?all=1');
toast(`Live refresh hotov — ${r.results?.filter(x=>x.status==='live').length||0} live`);
re();
}catch(e){toast('Chyba při live refresh: '+e.message,false);}
});
// Open team management modal for a single team
qa('[data-team]',el=>el.addEventListener('click',e=>{
e.stopPropagation();
ST.teamId=+el.dataset.team;ST.teamM=true;re();
}));
qa('[data-ed]',el=>el.addEventListener('click',e=>{e.stopPropagation();ST.editId=+el.dataset.ed;ST.editM=true;re();bsliders();}));
qa('[data-dl]',el=>el.addEventListener('click',async e=>{
e.stopPropagation();
if(!confirm('Smazat streamera?'))return;
try{await DEL('/api/streamers?id='+el.dataset.dl);ST.db=ST.db.filter(x=>x.id!=el.dataset.dl);toast('Smazáno');re();}
catch(e){toast('Chyba: '+e.message,false);}
}));
qa('[data-rst]',el=>el.addEventListener('click',async e=>{
e.stopPropagation();
const s=ST.db.find(x=>x.id===+el.dataset.rst);if(!s)return;
const wipeCommunity=confirm(
'Resetovat oficiální hodnocení streamera "'+s.name+'"?\n\n'+
'OK = také smaže VŠECHNA komunitní hodnocení tohoto streamera.\n'+
'Zrušit = jen oficiální skóre, komunitní zůstanou.'
);
if(!confirm('Pokračovat? Tato akce je nevratná.'))return;
try{
await PUT('/api/streamers?id='+s.id,{
kick:s.kick||'',status:s.status,game:s.game||'',title:s.title||'',
r:{s:0,g:0,a:0,v:0,n:0,ns:0,o:0},reward:0,notes:''
});
Object.assign(s,{r:{s:0,g:0,a:0,v:0,n:0,ns:0,o:0},reward:0,evaluated:false});
if(wipeCommunity){
await DEL('/api/community_ratings?wipe_streamer='+s.id);
// Refresh teams (counts changed) and ratings cache
if(ST.raterGroups?.length)ST.raterGroups=await GET('/api/rater_groups');
delete ST.communityRatings[s.id];
}
toast(wipeCommunity?'Resetováno (vč. komunitních hodnocení)':'Oficiální hodnocení resetováno');re();
}catch(e){toast('Chyba: '+e.message,false);}
}));
// Lock/unlock community ratings
qa('[data-lock]',el=>el.addEventListener('click',async e=>{
e.stopPropagation();
const sid=+el.dataset.lock;
const locked=el.dataset.locked==='true';
try{
await PUT('/api/streamers?id='+sid+'&lock=1',{community_locked:!locked});
const s=ST.db.find(x=>x.id===sid);
if(s)s.community_locked=!locked;
toast(locked?'Hodnocení odemčeno':'Hodnocení uzamčeno');re();
}catch(e){toast('Chyba: '+e.message,false);}
}));
// Open rate modal
qa('[data-rate]',el=>el.addEventListener('click',async e=>{
e.stopPropagation();
const sid=+el.dataset.rate;
ST.rateStreamerId=sid;
// Pre-fill from active team's ratings map (loaded on tab/team switch)
ST.myRating = ST.teamRatings?.[sid] || null;
ST.rateM=true;re();setTimeout(()=>bcrSliders(),30);
}));
// Delete community rating (admin)
qa('[data-delcr]',el=>el.addEventListener('click',async e=>{
e.stopPropagation();
if(!confirm('Smazat toto komunitní hodnocení?'))return;
try{
await DEL('/api/community_ratings?id='+el.dataset.delcr);
// Reload community ratings for current detail
if(ST.detailId)ST.communityRatings[ST.detailId]=await GET('/api/community_ratings?streamer_id='+ST.detailId);
toast('Hodnocení smazáno');re();
}catch(e){toast('Chyba: '+e.message,false);}
}));
// Detail community-rating tab switch (Průměr / per-team)
qa('[data-crtab]',el=>el.addEventListener('click',e=>{
e.stopPropagation();
const v=el.dataset.crtab;
ST.detailCRTab = (v==='overview')?'overview':+v;
re();
}));
// Detail comment filter (all / per-team)
qa('[data-cmtfilter]',el=>el.addEventListener('click',e=>{
e.stopPropagation();
ST.detailCmtFilter = el.dataset.cmtfilter;
re();
}));
qa('[data-det]',el=>el.addEventListener('click',async()=>{
const sid=+el.dataset.det;
ST.detailId=sid;
// Reset detail-modal local state when opening a new streamer
ST.detailCRTab='overview';
ST.detailCmtFilter='all';
// Make sure team list is loaded for admins (used for badges in renderCommentsList)
if(ST.admin && !ST.raterGroups?.length){
try{ST.raterGroups=await GET('/api/rater_groups');}catch(e){}
}
// Load community ratings for this streamer
try{ST.communityRatings[sid]=await GET('/api/community_ratings?streamer_id='+sid);}catch(e){}
re();
}));
bLogin();bEdit();bDetail();bSug();bNew();
bAdmUsers();bAdmTeams();bAdmSettings();bTeam();bCommunityRate();
}
function bLogin(){
if(!ST.loginM)return;
const cl=()=>{ST.loginM=false;re();};
on('lmx',cl);on('lmcancel',cl);
on('lmbg',e=>{if(e.target.id==='lmbg')cl();});
const doL=async()=>{
const pw=document.getElementById('lpw')?.value;
try{
await POST('/api/auth?action=login',{password:pw});
ST.admin=true;ST.loginM=false;
ST.db=await GET('/api/streamers?all=1');
re();
}catch(e){const err=document.getElementById('lerr');if(err)err.style.display='block';}
};
on('lmok',doL);
document.getElementById('lpw')?.addEventListener('keydown',e=>{if(e.key==='Enter')doL();});
}
function bEdit(){
if(!ST.editM)return;
const cl=()=>{ST.editM=false;ST.editId=null;re();};
on('emx',cl);on('emcancel',cl);
on('embg',e=>{if(e.target.id==='embg')cl();});
on('emsave',async()=>{
const f=document.getElementById('ef');if(!f)return;
const fd=new FormData(f);
const s=ST.db.find(x=>x.id===ST.editId);if(!s)return;
const body={
kick:fd.get('kick')||'', status:fd.get('status'),
game:fd.get('game')||'', title:fd.get('title')||'',
r:Object.fromEntries(CATS.map(c=>[c.k,+(fd.get(c.k)||0)])),
reward:Math.round(+(document.getElementById('rw')?.value||0)),
notes:fd.get('notes')||''
};
try{
await PUT('/api/streamers?id='+ST.editId,body);
// Update local state
Object.assign(s,{kick:body.kick,status:body.status,game:body.game,title:body.title,r:body.r,reward:body.reward,notes:body.notes,evaluated:Object.values(body.r).some(v=>v>0)});
toast('Uloženo');ST.editM=false;ST.editId=null;re();
}catch(e){toast('Chyba: '+e.message,false);}
});
bsliders();
}
// Auto reward: 30 Kč base + up to 100 Kč based on score (0-70), rounded to whole crowns
function autoReward(r){
const score=CATS.reduce((a,c)=>a+(+r[c.k]||0),0);
return Math.round(30+(score/MAX)*100);
}
function bsliders(){
CATS.forEach(c=>{
const i=document.getElementById('r'+c.k),v=document.getElementById('rv'+c.k);
if(i&&v)i.addEventListener('input',()=>{
v.textContent=i.value;
v.style.color=gc(+i.value,10);
updateAutoReward();
});
});
updateAutoReward();
}
function updateAutoReward(){
const r={};
CATS.forEach(c=>{
const i=document.getElementById('r'+c.k);
r[c.k]=i?+i.value:0;
});
const reward=autoReward(r);
const el=document.getElementById('auto-reward');
const hw=document.getElementById('rw');
if(el)el.textContent=reward+' Kč';
if(hw)hw.value=reward;
}
function bDetail(){
if(!ST.detailId)return;
on('dmx',()=>{ST.detailId=null;re();});
on('dmbg',e=>{if(e.target.id==='dmbg'){ST.detailId=null;re();}});
// Load comments button
on('comments-load',()=>loadComments(ST.detailId));
// Auto-load if already cached
if(ST.comments[ST.detailId]==null){
loadComments(ST.detailId);
}
// Submit comment
on('c-submit',async()=>{
const sid=ST.detailId;
const body=document.getElementById('c-body')?.value.trim();
const author=document.getElementById('c-author')?.value.trim()||'Anonym';
if(!body||body.length<2){toast('Komentář je příliš krátký',false);return;}
try{
const c=await POST('/api/comments',{streamer_id:sid,author,body});
if(!ST.comments[sid])ST.comments[sid]=[];
ST.comments[sid].push(c);
const el=document.getElementById('comments-list');
if(el)el.innerHTML=renderCommentsList(sid);
const bodyEl = document.getElementById('c-body');
const authorEl = document.getElementById('c-author');
if(bodyEl) bodyEl.value='';
if(authorEl) authorEl.value='';
toast('Komentář přidán');
}catch(e){toast('Chyba: '+e.message,false);}
});
// Delete comment (admin)
document.querySelectorAll('[data-cid]').forEach(btn=>{
btn.addEventListener('click',async()=>{
const cid=+btn.dataset.cid;
const sid=+btn.dataset.sid;
if(!confirm('Smazat komentář?'))return;
try{
await DEL('/api/comments?id='+cid);
ST.comments[sid]=(ST.comments[sid]||[]).filter(c=>c.id!==cid);
const el=document.getElementById('comments-list');
if(el)el.innerHTML=renderCommentsList(sid);
toast('Smazáno');
}catch(e){toast('Chyba: '+e.message,false);}
});
});
}
function bSug(){
if(!ST.sugM)return;
const cl=()=>{ST.sugM=false;re();};
on('sgx',cl);on('sgcancel',cl);
on('sgbg',e=>{if(e.target.id==='sgbg')cl();});
on('sgok',async()=>{
const name=document.getElementById('sgn').value.trim().toLowerCase();
const err=document.getElementById('sgerr');
if(!name){err.style.display='block';return;}
try{
await POST('/api/streamers',{name,platform:document.getElementById('sgp').value,submitter:document.getElementById('sgs').value.trim()});
ST.sugM=false;toast(`Díky! Návrh na "${name}" byl přidán.`);re();
}catch(e){err.textContent=e.message;err.style.display='block';}
});
}
function bNew(){
if(!ST.newM)return;
const cl=()=>{ST.newM=false;re();};
on('nmx',cl);on('nmcancel',cl);
on('nmbg',e=>{if(e.target.id==='nmbg')cl();});
on('nmok',async()=>{
const name=document.getElementById('nmn').value.trim().toLowerCase();
const platform=document.getElementById('nmp').value;
const err=document.getElementById('nmerr');
if(!name){err.style.display='block';return;}
// For kick: the main name field IS the kick name, twitch name field unused
// For twitch: name is twitch name, optional kick field is secondary
const kick_name = platform==='kick' ? name : (document.getElementById('nmk').value.trim().toLowerCase()||'');
const streamer_name = name;
try{
const res=await POST('/api/streamers?admin=1',{name:streamer_name,platform,kick_name});
ST.db.push({id:res.id,name:streamer_name,platform,kick:kick_name,status:'offline',game:'',title:'',added_by:'admin',evaluated:false,r:{s:0,g:0,a:0,v:0,n:0,ns:0,o:0},reward:0,notes:''});
ST.newM=false;toast('Streamer přidán');re();
}catch(e){err.textContent=e.message;err.style.display='block';}
});
}
// ── Admin: Users section (inline) ─────────────────────────────────
async function reloadUsers(){
const [mods,users]=await Promise.all([GET('/api/moderators'),GET('/api/moderators?users=1')]);
ST.currentMods=mods;ST.allUsers=users;
}
function bAdmUsers(){
if(!ST.admin||ST.tab!=='adm'||ST.admSection!=='users')return;
// Add mod by username
on('mod-add',async()=>{
const login=document.getElementById('mod-login')?.value.trim().toLowerCase();
const provider=document.getElementById('mod-provider')?.value;
const err=document.getElementById('mod-err');
if(!login){err.textContent='Zadej uživatelské jméno';err.style.display='block';return;}
try{
const res=await POST('/api/moderators',{login,provider});
toast(`${res.display_name} přidán jako moderátor`);
await reloadUsers();re();
}catch(e){err.textContent=e.message;err.style.display='block';}
});
// Remove mod
document.querySelectorAll('[data-delmod]').forEach(btn=>{
btn.addEventListener('click',async()=>{
if(!confirm('Odebrat moderátora?'))return;
try{
await DEL('/api/moderators?id='+btn.dataset.delmod);
toast('Moderátor odebrán');
await reloadUsers();re();
}catch(e){toast('Chyba: '+e.message,false);}
});
});
// Give mod from users list
document.querySelectorAll('[data-givemod]').forEach(btn=>{
btn.addEventListener('click',async()=>{
try{
const res=await POST('/api/moderators',{login:btn.dataset.login,provider:btn.dataset.provider});
toast(`${res.display_name} přidán jako moderátor`);
await reloadUsers();re();
}catch(e){toast('Chyba: '+e.message,false);}
});
});
// Revoke mod from users list (by user_id → find mod_id)
document.querySelectorAll('[data-revmod]').forEach(btn=>{
btn.addEventListener('click',async()=>{
if(!confirm('Odebrat mod oprávnění?'))return;
try{
const mod=ST.currentMods.find(m=>m.id==btn.dataset.revmod);
if(mod) await DEL('/api/moderators?id='+mod.mod_id);
toast('Mod odebrán');
await reloadUsers();re();
}catch(e){toast('Chyba: '+e.message,false);}
});
});
// Ban
document.querySelectorAll('[data-ban]').forEach(btn=>{
btn.addEventListener('click',async()=>{
if(!confirm('Zabanovat uživatele?'))return;
try{
await POST('/api/moderators?ban=1',{user_id:+btn.dataset.ban});
toast('Uživatel zabanován');
await reloadUsers();re();
}catch(e){toast('Chyba: '+e.message,false);}
});
});
// Unban
document.querySelectorAll('[data-unban]').forEach(btn=>{
btn.addEventListener('click',async()=>{
try{
await DEL('/api/moderators?unban='+btn.dataset.unban);
toast('Uživatel odbanován');
await reloadUsers();re();
}catch(e){toast('Chyba: '+e.message,false);}
});
});
}
// ── Admin: Teams section (inline list) ────────────────────────────
function bAdmTeams(){
if(!ST.admin||ST.tab!=='adm'||ST.admSection!=='teams')return;
// Create team — reads name + optional streamer_id
on('rg-create',async()=>{
const name=document.getElementById('rg-name')?.value.trim();
const sidVal=document.getElementById('rg-streamer')?.value;
const streamer_id=sidVal?+sidVal:null;
const err=document.getElementById('rg-err');
if(!name){err.textContent='Zadej název týmu';err.style.display='block';return;}
try{
const payload={name};
if(streamer_id)payload.streamer_id=streamer_id;
await POST('/api/rater_groups',payload);
ST.raterGroups=await GET('/api/rater_groups');
// Refresh streamers so team_id gets reflected
try{ST.db=await GET('/api/streamers?all=1');}catch(e){}
if(err){err.style.display='none';err.textContent='';}
toast('Tým vytvořen');re();
}catch(e){if(err){err.textContent=e.message;err.style.display='block';}}
});
// Edit team binding — rename and/or change linked streamer via prompt
document.querySelectorAll('[data-edteam]').forEach(btn=>{
btn.addEventListener('click',async()=>{
const gid=+btn.dataset.edteam;
const g=ST.raterGroups.find(x=>x.id===gid);
if(!g)return;
const newName=prompt('Nový název týmu (prázdné = ponechat):', g.name);
if(newName===null)return;
// Build a list of streamers to choose from (current linked + unlinked ones)
const linkedSids=new Set(ST.raterGroups.map(x=>x.streamer_id).filter(Boolean));
const choices=ST.db.filter(s=>!linkedSids.has(s.id)||s.id===g.streamer_id);
const numbered=choices.map((s,i)=>`${i+1}) ${s.name}`).join('\n');
const cur=g.streamer_id?choices.findIndex(s=>s.id===g.streamer_id)+1:0;
const ans=prompt(
`Vazba na streamera — zadej číslo (0 = bez vazby, prázdné = ponechat):\n\n0) — bez vazby —\n${numbered}`,
String(cur)
);
if(ans===null)return;
const payload={};
if(newName.trim()&&newName.trim()!==g.name) payload.name=newName.trim();
if(ans.trim()!==''){
const idx=parseInt(ans,10);
if(idx===0) payload.streamer_id=null;
else if(idx>=1&&idx<=choices.length) payload.streamer_id=choices[idx-1].id;
else { toast('Neplatné číslo',false); return; }
}
if(!Object.keys(payload).length){ toast('Nic nezměněno'); return; }
try{
await PUT('/api/rater_groups?id='+gid,payload);
ST.raterGroups=await GET('/api/rater_groups');
try{ST.db=await GET('/api/streamers?all=1');}catch(e){}
toast('Tým upraven');re();
}catch(e){toast('Chyba: '+e.message,false);}
});
});
// Delete team
document.querySelectorAll('[data-delrg]').forEach(btn=>{
btn.addEventListener('click',async()=>{
if(!confirm('Smazat tým a všechna jeho hodnocení?'))return;
try{
await DEL('/api/rater_groups?id='+btn.dataset.delrg);
ST.raterGroups=await GET('/api/rater_groups');
toast('Tým smazán');re();
}catch(e){toast('Chyba: '+e.message,false);}
});
});
}
// ── Admin: Settings section (inline) ──────────────────────────────
function bAdmSettings(){
if(!ST.admin||ST.tab!=='adm'||ST.admSection!=='settings')return;
on('sett-save',async()=>{
const msg=document.getElementById('sett-msg');
try{
await PUT('/api/settings',{
auth_enabled: document.getElementById('set-auth').checked,
auth_twitch_enabled: document.getElementById('set-twitch').checked,
auth_kick_enabled: document.getElementById('set-kick').checked,
});
ST.settings=await GET('/api/settings');
if(msg){msg.textContent='✓ Uloženo';msg.style.color='var(--green)';setTimeout(()=>{if(msg)msg.textContent='';},2500);}
toast('Nastavení uloženo');
}catch(e){toast('Chyba: '+e.message,false);}
});
}
// ── Team management modal (single team) ───────────────────────────
function bTeam(){
if(!ST.teamM)return;
const cl=()=>{ST.teamM=false;ST.teamId=null;re();};
on('team-close',cl);on('team-cancel',cl);
on('team-bg',e=>{if(e.target.id==='team-bg')cl();});
// Add member to this team
on('tm-add',async()=>{
const login=document.getElementById('tm-login')?.value.trim().toLowerCase();
const provider=document.getElementById('tm-provider')?.value;
const role=document.getElementById('tm-role')?.value||'rater';
const err=document.getElementById('tm-err');
if(!login){err.textContent='Zadej jméno';err.style.display='block';return;}
try{
const res=await POST('/api/rater_groups?members=1',{group_id:ST.teamId,login,provider,role});
ST.raterGroups=await GET('/api/rater_groups');
toast(`${res.display_name} přidán jako ${res.role==='owner'?'owner':'rater'}`);
if(err)err.style.display='none';re();
}catch(e){err.textContent=e.message;err.style.display='block';}
});
// Remove member
document.querySelectorAll('[data-delmember]').forEach(btn=>{
btn.addEventListener('click',async()=>{
try{
await DEL('/api/rater_groups?member='+btn.dataset.delmember);
ST.raterGroups=await GET('/api/rater_groups');
toast('Člen odebrán');re();
}catch(e){toast('Chyba: '+e.message,false);}
});
});
}
function bCommunityRate(){
if(!ST.rateM)return;
const cl=()=>{ST.rateM=false;ST.rateStreamerId=null;ST.myRating=null;re();};
on('crate-close',cl);on('crate-cancel',cl);
on('crate-bg',e=>{if(e.target.id==='crate-bg')cl();});
on('crate-save',async()=>{
const form=document.getElementById('crf');if(!form)return;
const fd=new FormData(form);
const r={};
CATS.forEach(c=>{r[c.k]=+(fd.get(c.k)||0);});
try{
const payload={r,notes:fd.get('notes')||''};
if(ST.activeTeamId)payload.group_id=ST.activeTeamId;
await PUT('/api/community_ratings?streamer_id='+ST.rateStreamerId,payload);
// Reload community ratings (used elsewhere) and the team's ratings map
try{ST.communityRatings[ST.rateStreamerId]=await GET('/api/community_ratings?streamer_id='+ST.rateStreamerId);}catch(e){}
if(ST.activeTeamId){
try{ST.teamRatings=await GET('/api/community_ratings?team_ratings=1&group_id='+ST.activeTeamId);}catch(e){}
}
// Refresh admin team list so ratings_count badge stays in sync
if(ST.admin){
try{ST.raterGroups=await GET('/api/rater_groups');}catch(e){}
}
toast('Hodnocení uloženo');
ST.rateM=false;ST.rateStreamerId=null;ST.myRating=null;re();
}catch(e){toast('Chyba: '+e.message,false);}
});
bcrSliders();
}
function bcrSliders(){
CATS.forEach(c=>{
const i=document.getElementById('r'+c.k),v=document.getElementById('rv'+c.k);
if(i&&v)i.addEventListener('input',()=>{v.textContent=i.value;v.style.color=gc(+i.value,10);});
});
}
boot();
</script>
</body>
</html>