Files
hockey_new/khl_site/web/index.html
2026-08-19 15:08:39 +03:00

304 lines
47 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="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>KHL Data Center</title>
<style>
:root{
color-scheme:dark;
--bg:#080b11;
--panel:#101620;
--panel-2:#151d2a;
--panel-3:#1a2433;
--line:#293548;
--line-soft:#202a39;
--text:#edf3ff;
--muted:#8e9bb0;
--accent:#6aa8ff;
--accent-strong:#3e8ef7;
--good:#51d692;
--warn:#f2bd68;
--danger:#ff6f7e;
--sticky:#111925;
--sticky-alt:#151e2c;
}
*{box-sizing:border-box}
html,body{min-height:100%}
body{margin:0;font-family:Inter,Segoe UI,Arial,sans-serif;background:var(--bg);color:var(--text)}
button,input,select{font:inherit}
button,input,select{border:1px solid var(--line);border-radius:9px;background:var(--panel-2);color:var(--text)}
button{padding:9px 13px;cursor:pointer}
button:hover{border-color:var(--accent)}
button:disabled{opacity:.45;cursor:default}
input,select{padding:10px;width:100%}
a{color:var(--accent)}
header{height:66px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:30}
.brand{font-size:19px;font-weight:850}.small{font-size:12px;color:var(--muted)}.actions{display:flex;gap:7px;flex-wrap:wrap;justify-content:flex-end}
.layout{display:grid;grid-template-columns:390px minmax(0,1fr);min-height:calc(100vh - 66px)}
aside{background:var(--panel);border-right:1px solid var(--line);min-width:0;position:sticky;top:66px;height:calc(100vh - 66px)}
.tabs{display:flex;gap:6px;padding:12px}.tabs button{flex:1}.tabs button.active{background:var(--accent);color:#07111e;border-color:var(--accent);font-weight:850}
.filters{padding:0 12px 12px;display:grid;gap:8px;border-bottom:1px solid var(--line)}.filter-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}.list-head{padding:10px 13px;display:flex;justify-content:space-between}.list{height:calc(100vh - 284px);overflow:auto}.row{display:grid;grid-template-columns:48px minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px 12px;border-top:1px solid rgba(43,53,72,.62);cursor:pointer}.row:hover{background:var(--panel-2)}.row.active{background:linear-gradient(90deg,rgba(106,168,255,.17),var(--panel-2));box-shadow:inset 3px 0 0 var(--accent)}.row.active .name{color:#fff}.avatar{width:46px;height:46px;border-radius:50%;object-fit:cover;background:#222c3c}.name{font-weight:780;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.name-en{font-size:11px;color:var(--muted);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.badges{display:flex;gap:4px;flex-wrap:wrap;margin-top:4px}.badge{font-size:10px;padding:2px 6px;border-radius:999px;background:#28344a;color:#d2dcf0}.badge.good{color:var(--good);background:rgba(81,214,146,.12)}.pager{display:flex;gap:8px;align-items:center;justify-content:center;padding:10px;border-top:1px solid var(--line)}
main{padding:18px;min-width:0;background:linear-gradient(180deg,#0b1018 0%,#090d14 100%)}.empty{min-height:75vh;display:grid;place-items:center;color:var(--muted);font-size:18px}
.profile-card{background:linear-gradient(135deg,#121a27 0%,#0e141e 100%);border:1px solid var(--line);border-radius:15px;padding:24px;box-shadow:0 16px 40px rgba(0,0,0,.24);margin-bottom:18px}.profile-head{display:grid;grid-template-columns:158px minmax(0,1fr);gap:24px;align-items:center}.profile-photo-wrap{display:flex;flex-direction:column;align-items:center;gap:11px}.profile-photo{width:158px;height:158px;border-radius:15px;object-fit:cover;background:#202a39;border:1px solid #344056}.profile-type{padding:7px 11px;border-radius:999px;background:#c81f32;color:#fff;font-size:11px;font-weight:850;letter-spacing:.25px}.profile-title-row{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}.profile-title{margin:0;font-size:31px;line-height:1.15;color:#fff}.profile-title-en{font-size:17px;color:#b4c2d7;font-weight:650}.profile-subtitle{margin-top:9px;font-size:17px;color:#b4c2d7}.profile-roleline{display:flex;flex-wrap:wrap;gap:14px;align-items:center;margin-top:17px;font-size:18px;font-weight:750}.profile-roleline .sep{width:1px;height:26px;background:#38465c}.profile-linkline{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}.profile-link{display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border:1px solid #34445c;border-radius:999px;background:#162131;text-decoration:none;font-size:12px;font-weight:750}.profile-link:hover{background:#1b2a40;border-color:var(--accent)}
.fact-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(135px,1fr));margin-top:21px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.fact{padding:13px 16px;border-right:1px solid var(--line);min-width:0}.fact:last-child{border-right:0}.fact label{display:block;color:var(--muted);font-size:11px;margin-bottom:5px}.fact div{font-weight:800;color:#fff;overflow-wrap:anywhere}.fact img{width:18px;height:18px;border-radius:50%;vertical-align:-4px;margin-right:6px}
/* Компактная карточка игрока: максимум данных без пустых вертикальных зон. */
.profile-card.compact-player{padding:13px 14px 12px;margin-bottom:9px;border-radius:13px;box-shadow:0 10px 26px rgba(0,0,0,.20)}
.profile-card.compact-player .profile-head{grid-template-columns:112px minmax(0,1fr);gap:14px;align-items:stretch}
.profile-card.compact-player .profile-photo-wrap{gap:6px;justify-content:flex-start}
.profile-card.compact-player .profile-photo{width:112px;height:126px;border-radius:11px}
.profile-card.compact-player .profile-type{width:100%;padding:5px 7px;border-radius:8px;font-size:10px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.profile-card.compact-player .profile-content{display:flex;flex-direction:column;min-width:0}
.profile-card.compact-player .profile-title-row{gap:9px;align-items:center;min-height:30px}
.profile-card.compact-player .profile-title{font-size:24px;line-height:1.05;letter-spacing:-.25px}
.profile-card.compact-player .profile-title-en{font-size:13px;line-height:1.1;color:#9eacc1}
.profile-card.compact-player .profile-roleline{gap:9px;margin-top:4px;font-size:13px;font-weight:750;color:#d8e2f1}
.profile-card.compact-player .profile-roleline .sep{height:15px}
.profile-card.compact-player .fact-grid{grid-template-columns:repeat(6,minmax(88px,1fr));gap:5px;margin-top:8px;border:0}
.profile-card.compact-player .fact{padding:6px 8px;border:1px solid var(--line-soft);border-radius:7px;background:rgba(20,29,42,.76);min-height:43px}
.profile-card.compact-player .fact label{font-size:9px;margin-bottom:3px;line-height:1.05;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.profile-card.compact-player .fact div{font-size:12px;line-height:1.15;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.profile-card.compact-player .fact img{width:15px;height:15px;vertical-align:-3px;margin-right:4px}
.profile-card.compact-player .profile-linkline{gap:5px;margin-top:6px}
.profile-card.compact-player .profile-link{padding:4px 8px;border-radius:7px;font-size:10px}
.section{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:19px 0 9px}.section h2{font-size:18px;margin:0}.notice{padding:10px 12px;border:1px solid rgba(106,168,255,.3);background:rgba(106,168,255,.08);border-radius:10px;color:#cfdef3;font-size:12px}.player-tabs{display:flex;gap:7px;margin:0 0 14px;padding:6px;background:#0f1621;border:1px solid var(--line);border-radius:12px;position:sticky;top:76px;z-index:12}.player-tab{padding:9px 16px;background:transparent;border-color:transparent;color:var(--muted);font-weight:800}.player-tab:hover{color:#fff;border-color:#34445c}.player-tab.active{background:var(--accent);border-color:var(--accent);color:#07111e}.matches-state{padding:24px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--muted);text-align:center}.matches-pager{display:flex;align-items:center;justify-content:center;gap:10px;padding:11px 12px;border-top:1px solid var(--line);background:#111925}.matches-pager .small{min-width:190px;text-align:center}.match-link{color:#9bc5ff;text-decoration:none;font-weight:750}.match-link:hover{text-decoration:underline}
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--panel);margin-bottom:13px;max-width:100%;scrollbar-color:#47566f #111824}.table-title{padding:11px 13px;background:var(--panel-2);border-bottom:1px solid var(--line);font-size:13px;font-weight:850}.table-note{padding:9px 12px;color:var(--muted);font-size:11px;border-top:1px solid var(--line)}
.khl-table{width:max-content;min-width:100%;border-collapse:separate;border-spacing:0;background:var(--panel)}.khl-table th,.khl-table td{padding:9px 11px;border-bottom:1px solid var(--line-soft);font-size:12px;vertical-align:middle;white-space:nowrap;color:var(--text)}.khl-table th{position:sticky;top:0;z-index:4;background:#182130;color:#99a7bb;text-align:center;font-weight:800}.khl-table td{text-align:center;font-variant-numeric:tabular-nums}.khl-table tbody tr:nth-child(even){background:#121a26}.khl-table tbody tr:hover{background:#192436}.khl-table tbody tr:last-child td{border-bottom:0}.khl-table th:first-child,.khl-table td:first-child{position:sticky;left:0;z-index:5;text-align:left;min-width:205px;max-width:320px;white-space:normal;box-shadow:8px 0 12px -12px #000}.khl-table th:first-child{z-index:8;background:#1b2636}.khl-table td:first-child{background:var(--sticky);font-weight:750}.khl-table tbody tr:nth-child(even) td:first-child{background:var(--sticky-alt)}.khl-table tbody tr:hover td:first-child{background:#1b2738}.khl-table .number{text-align:right}.khl-table .muted-cell{color:var(--muted)}.khl-table .team{display:block;color:#69adff;margin-top:5px;font-weight:600}.khl-table .season{display:block;color:#fff;font-weight:850}.khl-table abbr{text-decoration:none;border-bottom:1px dotted #6f7e93;cursor:help}.khl-table .total-row td{font-weight:850;background:#172333}.khl-table .total-row td:first-child{background:#1c2a3d}.rows-table{width:100%;border-collapse:collapse}.rows-table td{padding:9px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}.rows-table td:first-child{color:var(--muted);width:31%}.rows-table td:last-child{font-weight:650;overflow-wrap:anywhere}.rows-table tr:last-child td{border-bottom:0}.team-history-cell{display:flex;align-items:center;gap:10px;min-width:190px}.team-history-logo{width:36px;height:36px;border-radius:8px;object-fit:contain;background:#202b3b;border:1px solid #344157;padding:3px;flex:0 0 auto}.team-history-name{font-weight:850;color:#fff}.season-tags{display:flex;flex-wrap:wrap;gap:5px;max-width:440px}.season-tag{display:inline-flex;padding:3px 7px;border:1px solid #33435a;border-radius:999px;background:#172233;color:#c9d7ea;font-size:10px;font-weight:700}
.chipbar{display:flex;flex-wrap:wrap;gap:6px}.chip{padding:4px 8px;border:1px solid #30405a;border-radius:999px;background:#162131;font-size:11px;color:#c7d3e5}.flag{width:21px;height:21px;border-radius:50%;vertical-align:-5px;margin-right:6px}.flag-emoji{display:inline-flex;align-items:center;justify-content:center;width:24px;height:20px;margin-right:6px;font-size:18px;line-height:1;vertical-align:-2px}.job{position:fixed;right:16px;bottom:16px;width:min(520px,calc(100vw - 32px));background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px;box-shadow:0 12px 40px rgba(0,0,0,.45);display:none;z-index:50}.job.show{display:block}.progress{height:6px;background:#273147;border-radius:999px;overflow:hidden;margin-top:8px}.progress span{display:block;height:100%;background:var(--accent);width:35%;animation:move 1.1s linear infinite}@keyframes move{from{transform:translateX(-110%)}to{transform:translateX(300%)}}
@media(max-width:1200px){.profile-card.compact-player .fact-grid{grid-template-columns:repeat(4,minmax(92px,1fr))}}
@media(max-width:900px){header{height:auto;padding:10px;gap:8px;align-items:flex-start;flex-direction:column}.actions{justify-content:flex-start}.layout{grid-template-columns:1fr}aside{position:static;height:auto}.list{height:380px}.profile-head{grid-template-columns:105px 1fr}.profile-photo{width:105px;height:105px}.profile-title{font-size:24px}.fact-grid{grid-template-columns:repeat(2,minmax(120px,1fr))}.profile-card.compact-player .profile-head{grid-template-columns:92px minmax(0,1fr);gap:10px}.profile-card.compact-player .profile-photo{width:92px;height:104px}.profile-card.compact-player .profile-title{font-size:20px}.profile-card.compact-player .profile-title-en{font-size:12px}.profile-card.compact-player .fact-grid{grid-template-columns:repeat(3,minmax(92px,1fr))}}
@media(max-width:600px){.profile-card.compact-player .profile-head{grid-template-columns:1fr}.profile-card.compact-player .profile-photo-wrap{display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center}.profile-card.compact-player .profile-photo{width:72px;height:82px}.profile-card.compact-player .profile-type{width:auto}.profile-card.compact-player .fact-grid{grid-template-columns:repeat(2,minmax(100px,1fr))}}
.primary-action{background:var(--accent)!important;border-color:var(--accent)!important;color:#06111f!important;font-weight:900}</style>
</head>
<body>
<header>
<div><div class="brand">KHL Data Center</div><div class="small" id="statusLine">Загрузка данных…</div></div>
<div class="actions">
<button class="primary-action" onclick="collectData('all')">KHL.ru: обновить всё</button>
<button onclick="collectData('players')">Игроки</button>
<button onclick="collectData('coaches')">Тренеры</button>
<button onclick="collectData('officials-current')">Судьи: текущие</button>
<button onclick="collectData('officials-history')">Судьи: все</button>
<button onclick="reloadData()">Перечитать JSON</button>
</div>
</header>
<div class="layout">
<aside>
<div class="tabs">
<button id="tab-players" class="active" onclick="setKind('players')">Игроки</button>
<button id="tab-coaches" onclick="setKind('coaches')">Тренеры</button>
<button id="tab-officials" onclick="setKind('officials')">Судьи</button>
</div>
<div class="filters">
<input id="search" placeholder="Имя, фамилия, English name или ID" oninput="debouncedLoad()">
<div class="filter-row">
<select id="season" onchange="loadList(true)"><option value="">Все сезоны</option></select>
<select id="extra" onchange="loadList(true)"><option value="">Все</option></select>
</div>
</div>
<div class="list-head"><span id="total">0 записей</span><span class="small">по 100</span></div>
<div class="list" id="list"></div>
<div class="pager"><button id="prev" onclick="changePage(-1)"></button><span class="small" id="pageInfo">1 / 1</span><button id="next" onclick="changePage(1)"></button></div>
</aside>
<main><div id="detail" class="empty">Выбери запись слева</div></main>
</div>
<div class="job" id="job"><b id="jobTitle">Обновление</b><div class="small" id="jobMessage"></div><div class="progress" id="jobProgress"><span></span></div></div>
<script>
const UI_STORAGE_KEY='khlDataCenterUiStateV1';
function loadUiState(){try{return JSON.parse(localStorage.getItem(UI_STORAGE_KEY)||'{}')||{}}catch(e){return {}}}
const UI=loadUiState();
const KINDS=['players','coaches','officials'];
const defaultMap=()=>({players:null,coaches:null,officials:null});
const defaultPages=()=>({players:1,coaches:1,officials:1});
const defaultScroll=()=>({players:0,coaches:0,officials:0});
const S={
kind:KINDS.includes(UI.kind)?UI.kind:'players',
page:1,
pages:1,
selectedByKind:{...defaultMap(),...(UI.selectedByKind||{})},
pageByKind:{...defaultPages(),...(UI.pageByKind||{})},
scrollByKind:{...defaultScroll(),...(UI.scrollByKind||{})},
status:null,
timer:null,
openSeq:0,
currentPlayer:null,
currentPlayerId:null,
playerTab:'overview',
matchesPage:1,
itemCache:new Map(),
extraCache:new Map(),
matchesCache:new Map(),
extraTimer:null
};
S.page=Math.max(1,Number(S.pageByKind[S.kind])||1);
function selectedId(kind=S.kind){const v=S.selectedByKind[kind];return v===null||v===undefined||v===''?null:String(v)}
function saveUiState(){try{localStorage.setItem(UI_STORAGE_KEY,JSON.stringify({kind:S.kind,selectedByKind:S.selectedByKind,pageByKind:S.pageByKind,scrollByKind:S.scrollByKind}))}catch(e){}}
function captureListState(){const list=$('list');if(!list)return;S.pageByKind[S.kind]=S.page;S.scrollByKind[S.kind]=list.scrollTop||0;saveUiState()}
function markActiveRow(id=selectedId()){document.querySelectorAll('#list .row').forEach(row=>row.classList.toggle('active',id!==null&&row.dataset.id===String(id)))}
function restoreListScroll(kind=S.kind){requestAnimationFrame(()=>{if(kind!==S.kind)return;const list=$('list');if(!list)return;list.scrollTop=Math.max(0,Number(S.scrollByKind[kind])||0);markActiveRow(selectedId(kind))})}
const $=id=>document.getElementById(id);
const esc=v=>String(v??'').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;').replaceAll("'",'&#039;');
const SKATER_ORDER=['gp','g','a','pts','pm','plus','minus','pim','g_even','g_pp','g_sh','g_ot','gwg','sds','shots','shot_pct','shots_avg','faceoffs','faceoff_wins','faceoff_pct','toi_avg','sft_avg','toi_even_avg','sft_even_avg','toi_pp_avg','sft_pp_avg','toi_sh_avg','sft_sh_avg','hits','blocks','fouls_against','takeaways','interceptions'];
const GOALIE_ORDER=['gp','w','l','shootout_games','shots_against','ga','sv','sv_pct','gaa','g','a','so','pim','toi'];
const SHORT={gp:'И',g:'Ш',a:'А',pts:'О',pm:'+/-',plus:'+',minus:'-',pim:'Штр',g_even:'ШР',g_pp:'ШБ',g_sh:'ШМ',g_ot:'ШО',gwg:'ШП',sds:'РБ',shots:'БВ',shot_pct:'%БВ',shots_avg:'БВ/И',faceoffs:'Вбр',faceoff_wins:'ВВбр',faceoff_pct:'%Вбр',toi_avg:'ВП/И',sft_avg:'См/И',toi_even_avg:'ВПР/И',sft_even_avg:'СмР/И',toi_pp_avg:'ВПБ/И',sft_pp_avg:'СмБ/И',toi_sh_avg:'ВПМ/И',sft_sh_avg:'СмМ/И',hits:'СПр',blocks:'БлБ',fouls_against:'ФоП',takeaways:'ОТБ',interceptions:'ПХТ',w:'В',l:'П',shootout_games:'ИБ',shots_against:'Бр',ga:'ПШ',sv:'ОБ',sv_pct:'%ОБ',gaa:'КН',so:'И"0"',toi:'ВП'};
const STAT_HINTS={
'И':'Количество проведённых игр','Ш':'Заброшенные шайбы','А':'Передачи','О':'Очки','+/-':'Плюс/Минус','+':'Плюс','-':'Минус','Штр':'Штрафное время',
'ШР':'Шайбы в равенстве','ШБ':'Шайбы в большинстве','ШМ':'Шайбы в меньшинстве','ШО':'Шайбы в овертайме','ШП':'Победные шайбы','РБ':'Решающие буллиты',
'БВ':'Броски по воротам','%БВ':'Процент реализованных бросков','БВ/И':'Среднее количество бросков по воротам за игру','Вбр':'Вбрасывания','ВВбр':'Выигранные вбрасывания','%Вбр':'Процент выигранных вбрасываний',
'ВП/И':'Среднее время на площадке за игру','См/И':'Среднее количество смен за игру','ВПР/И':'Среднее время на площадке при игре в равных составах за игру','СмР/И':'Среднее количество смен при игре в равных составах за игру',
'ВПБ/И':'Среднее время на площадке при игре в большинстве за игру','СмБ/И':'Среднее количество смен при игре в большинстве за игру','ВПМ/И':'Среднее время на площадке при игре в меньшинстве за игру','СмМ/И':'Среднее количество смен при игре в меньшинстве за игру',
'СПр':'Силовые приёмы','БлБ':'Блокированные броски','ФоП':'Фолы против','ОТБ':'Отборы шайбы','ПХТ':'Перехваты передач',
'В':'Выигрыши','П':'Проигрыши','ИБ':'Игры с буллитными сериями','Бр':'Броски','ПШ':'Пропущено шайб','ОБ':'Отражённые броски','%ОБ':'Процент отражённых бросков','КН':'Коэффициент надёжности = 60 мин × ПШ / ВП','И"0"':'Сухие игры','ВП':'Время на площадке',
'№':'Номер игрока','Дата':'Дата матча','Команды':'Участники матча','Счёт':'Итоговый счёт','Турнир':'Турнир / этап сезона'
};
function statHint(label,fallbackTitle=''){const l=String(label||'').trim();return STAT_HINTS[l]||fallbackTitle||l}
function fallback(name){return 'data:image/svg+xml;charset=UTF-8,'+encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140"><rect width="100%" height="100%" fill="#202a39"/><text x="50%" y="59%" text-anchor="middle" font-family="Arial" font-size="58" fill="#8795aa">${esc((name||'?').slice(0,1))}</text></svg>`)}
const KHL_API_BASE=location.pathname.startsWith('/khl-site')?'/khl-site':'';async function getJSON(url,opt){const target=(typeof url==='string'&&url.startsWith('/'))?KHL_API_BASE+url:url;const r=await fetch(target,opt);const p=await r.json();if(!r.ok)throw new Error(p.detail||p.error||r.statusText);return p}
function putCache(map,key,value,max=60){if(map.has(key))map.delete(key);map.set(key,value);while(map.size>max)map.delete(map.keys().next().value)}
function mergePlayerExtra(player,fields){if(!player||!fields)return player;const p=player._profile_full||(player._profile_full={...(player.profile||{})});for(const [k,v] of Object.entries(fields)){if(notEmpty(v)&&!notEmpty(p[k]))p[k]=v}return player}
function format(v){if(v===null||v===undefined||v==='')return '—';if(typeof v==='number'&&!Number.isInteger(v))return Math.round(v*100)/100;return v}
function formatValue(v){if(v===null||v===undefined||v==='')return '—';if(Array.isArray(v))return v.map(formatValue).join(', ');if(typeof v==='object')return Object.entries(v).map(([k,x])=>`${k}: ${formatValue(x)}`).join('; ');return format(v)}
function notEmpty(v){return v!==null&&v!==undefined&&v!==''&&!(Array.isArray(v)&&!v.length)&&!(typeof v==='object'&&v&&!Object.keys(v).length)}
function dateValue(value){if(!notEmpty(value))return '—';if(typeof value==='number'||/^\d{9,13}$/.test(String(value))){let n=Number(value);if(n<1e12)n*=1000;const d=new Date(n);if(!Number.isNaN(d.valueOf()))return d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'})}return String(value)}
function physicalValue(value,unit){if(!notEmpty(value))return '';const text=String(value).trim();return new RegExp(`\\b${unit}\\b`,'i').test(text)?text:`${text} ${unit}`}
function gripValue(value){if(!notEmpty(value))return '';const text=String(value).trim(),v=text.toLocaleLowerCase('ru');if(['l','left','лев','левый'].includes(v))return 'левый';if(['r','right','прав','правый'].includes(v))return 'правый';return text}
function countryInfo(value){let raw=String(value||'').trim();if(!raw)return {name:'',code:'',emoji:''};const map={'россия':'RU','рф':'RU','беларусь':'BY','белоруссия':'BY','казахстан':'KZ','финляндия':'FI','швеция':'SE','чехия':'CZ','словакия':'SK','латвия':'LV','литва':'LT','эстония':'EE','сша':'US','соединенные штаты':'US','соединённые штаты':'US','канада':'CA','германия':'DE','швейцария':'CH','австрия':'AT','дания':'DK','норвегия':'NO','франция':'FR','словения':'SI','хорватия':'HR','польша':'PL','венгрия':'HU','китай':'CN','япония':'JP','южная корея':'KR','великобритания':'GB','италия':'IT','украина':'UA','грузия':'GE','армения':'AM','азербайджан':'AZ','узбекистан':'UZ'};let code='',name=raw;const m=raw.match(/^([A-Za-z]{2})\s+(.+)$/);if(m){code=m[1].toUpperCase();name=m[2].trim()}else code=map[raw.toLocaleLowerCase('ru')]||(/^[A-Za-z]{2}$/.test(raw)?raw.toUpperCase():'');const emoji=code?[...code].map(ch=>String.fromCodePoint(127397+ch.charCodeAt(0))).join(''):'';return {name,code,emoji}}
function countryFlagEmoji(value){return countryInfo(value).emoji}
function nameParts(value){const p=String(value||'').trim().split(/\s+/).filter(Boolean);return {last:p[0]||'',first:p[1]||'',middle:p.slice(2).join(' ')}}
function headerHtml(value){if(typeof value==='object'&&value)return `<abbr title="${esc(value.title||value.label||'')}">${esc(value.label||value.title||'')}</abbr>`;return esc(value)}
function table(headers,rows,opts={}){const body=(rows||[]).length?rows.map((row,ri)=>`<tr class="${opts.totalRows?.includes(ri)?'total-row':''}">${row.map((cell,ci)=>`<td class="${opts.numberCols?.includes(ci)?'number':''}">${cell}</td>`).join('')}</tr>`).join(''):`<tr><td colspan="${Math.max(1,headers.length)}">${esc(opts.empty||'Данных нет')}</td></tr>`;return `<div class="table-wrap">${opts.title?`<div class="table-title">${esc(opts.title)}</div>`:''}<table class="khl-table"><thead><tr>${headers.map(x=>`<th>${headerHtml(x)}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table>${opts.note?`<div class="table-note">${esc(opts.note)}</div>`:''}</div>`}
function rowsTable(rows,title){const clean=(rows||[]).filter(x=>x&&notEmpty(x[1]));const body=clean.length?clean.map(([k,v])=>`<tr><td>${esc(k)}</td><td>${esc(formatValue(v))}</td></tr>`).join(''):`<tr><td colspan="2">Данных нет</td></tr>`;return `<div class="table-wrap">${title?`<div class="table-title">${esc(title)}</div>`:''}<table class="rows-table"><tbody>${body}</tbody></table></div>`}
function factsHtml(facts){return `<div class="fact-grid">${facts.filter(x=>notEmpty(x.value)).map(x=>`<div class="fact"><label>${esc(x.label)}</label><div>${x.html??esc(formatValue(x.value))}</div></div>`).join('')}</div>`}
function profileCard({type,name,nameEn,photo,role,team,facts,links,compact=false}){const roleBits=[team,role].filter(Boolean).map((x,i)=>i?`<span class="sep"></span><span>${esc(x)}</span>`:`<span>${esc(x)}</span>`).join('');return `<div class="profile-card${compact?' compact-player':''}"><div class="profile-head"><div class="profile-photo-wrap"><img class="profile-photo" src="${esc(photo||fallback(name))}" onerror="this.src='${fallback(name)}'"><div class="profile-type">${esc(type)}</div></div><div class="profile-content"><div class="profile-title-row"><h1 class="profile-title">${esc(name)}</h1>${nameEn?`<div class="profile-title-en">${esc(nameEn)}</div>`:''}</div>${roleBits?`<div class="profile-roleline">${roleBits}</div>`:''}${factsHtml(facts)}<div class="profile-linkline">${links.map(([t,u])=>`<a class="profile-link" target="_blank" href="${esc(u)}">${esc(t)} ↗</a>`).join('')}</div></div></div></div>`}
function scalarRows(obj,prefix='',depth=0){const rows=[];for(const [k,v] of Object.entries(obj||{})){const key=prefix?`${prefix}.${k}`:k;if(v===null||v===undefined||v==='')continue;if(Array.isArray(v)){if(v.length&&v.every(x=>typeof x!=='object'))rows.push([key,v.join(', ')]);continue}if(typeof v==='object'){if(depth<1)rows.push(...scalarRows(v,key,depth+1));continue}rows.push([key,k.includes('birthday')||k.includes('birth_date')?dateValue(v):v])}return rows}
function statMap(stats){const m={};for(const s of (stats||[]))m[String(s.id)]=s;return m}
function statColumns(player){
const ids=new Set(),titles={},descriptions={};
for(const record of Object.values(player.stages||{}))for(const st of ((record.data||{}).stats||[])){const id=String(st.id);ids.add(id);titles[id]=st.title||titles[id]||'';descriptions[id]=st.description||descriptions[id]||''}
for(const agg of Object.values(player.aggregates||{}))for(const st of (agg.stats||[])){const id=String(st.id);ids.add(id);titles[id]=st.title||titles[id]||'';descriptions[id]=st.description||descriptions[id]||''}
const order=player._is_goalie?GOALIE_ORDER:SKATER_ORDER,known=order.filter(x=>ids.has(x));
const rest=[...ids].filter(x=>!known.includes(x)).sort((a,b)=>(titles[a]||a).localeCompare(titles[b]||b,'ru'));
return [...known,...rest].map(id=>{const title=titles[id]||SHORT[id]||id,label=SHORT[id]||title;return {id,label,title,description:descriptions[id]||statHint(label,title)}})
}
function stageEntries(player){return Object.entries(player.stages||{}).sort((a,b)=>{const ai=Number(a[0]),bi=Number(b[0]);return (Number.isFinite(bi)?bi:-1)-(Number.isFinite(ai)?ai:-1)})}
function stageLabelParts(record,id){const full=record.stage_label||`Этап ${id}`;const parts=String(full).split(' · ').filter(Boolean);const first=parts[0]||'';const hasSeason=/^(?:19|20)\d{2}\/(?:19|20)\d{2}$/.test(first);return {season:hasSeason?first:'Год не указан',stage:(hasSeason?parts.slice(1):parts).filter(x=>!/^ID /.test(x)).join(' · ')||'Этап'}}
function stageStatsTable(player){const cols=statColumns(player),entries=stageEntries(player);const headers=[{label:'Турнир / Команда',title:'Сезон, этап и команда'},'№',...cols.map(x=>({label:x.label,title:x.description||x.title}))];const rows=entries.map(([id,r])=>{const d=r.data||{},p=stageLabelParts(r,id),team=d.team?.name||((d.teams||[])[0]?.name)||'—',m=statMap(d.stats||[]);const first=`<span class="season">${esc(p.season)}${p.stage&&p.stage!==p.season?` | ${esc(p.stage)}`:''}</span><span class="team">${esc(team)}</span>`;return [first,esc(d.shirt_number??player._profile_full?.shirt_number??'—'),...cols.map(c=>esc(format(m[c.id]?.val)))]});return table(headers,rows,{numberCols:[1,...cols.map((_,i)=>i+2)],note:'Показаны все статистические поля, найденные у выбранного игрока. Первая колонка закреплена.'})}
function aggregateTable(player){const cols=statColumns(player);const modes=[['regular','Регулярный чемпионат'],['playoff','Плей-офф'],['hope','Кубок Надежды'],['all','Всего в КХЛ']].filter(([k])=>(player.aggregates?.[k]?.stats||[]).length);const rows=modes.map(([key,title])=>{const m=statMap(player.aggregates[key].stats||[]);return [`<span class="season">${esc(title)}</span><span class="team">Этапов учтено: ${esc(player.aggregates[key].stage_count||0)}</span>`,...cols.map(c=>esc(format(m[c.id]?.val)))]});return table([{label:'Категория',title:'Категория суммарной статистики'},...cols.map(x=>({label:x.label,title:x.description||x.title}))],rows,{numberCols:cols.map((_,i)=>i+1),totalRows:rows.length?[rows.length-1]:[],note:'Используются суммарные строки KHL.ru; если сайт не отдал итоговую строку, применяется локальный резервный расчёт.'})}
function teamRows(teams){
const merged=new Map();
for(const t of (teams||[])){
const name=String(t.name||'').trim();if(!name||/^Див\./i.test(name)||/конференц/i.test(name))continue;
const key=name.toLocaleLowerCase('ru'),seasons=String(t.seasons||'').split(',').map(x=>x.trim()).filter(Boolean);
if(!merged.has(key))merged.set(key,{name,image:t.image||t.logo||'',url:t.url||'',seasons:new Set()});
const item=merged.get(key);if(!item.image&&(t.image||t.logo))item.image=t.image||t.logo;if(!item.url&&t.url)item.url=t.url;for(const season of seasons)item.seasons.add(season)
}
return [...merged.values()].sort((a,b)=>a.name.localeCompare(b.name,'ru')).map(t=>{
const seasons=[...t.seasons].sort((a,b)=>b.localeCompare(a));
const name=t.url?`<a href="${esc(t.url)}" target="_blank" rel="noopener noreferrer">${esc(t.name)}</a>`:esc(t.name);
const team=`<div class="team-history-cell"><img class="team-history-logo" src="${esc(t.image||fallback(t.name))}" onerror="this.src='${fallback(t.name)}'"><div class="team-history-name">${name}</div></div>`;
const seasonHtml=seasons.length?`<div class="season-tags">${seasons.map(x=>`<span class="season-tag">${esc(x)}</span>`).join('')}</div>`:'—';
return [team,seasonHtml]
})
}
function playerHeaderHtml(player){
const i=player.identity||{},ie=player.identity_en||{},p=player._profile_full||player.profile||{},latest=(player.stages||{})[player._latest_stage_id]?.data||{},team=p.team||latest.team||{};
const generic=/(?:конференц|дивизион|^игроки?$|^players?$|^игрок$|^player$)/i;
const ruCandidates=[p.name,i.full_name].filter(x=>x&&!generic.test(String(x).trim()));
const enCandidates=[p.name_en,player._name_en,ie.full_name].filter(x=>x&&!generic.test(String(x).trim()));
const name=ruCandidates[0]||`Игрок #${esc(i.khl_id||p.khl_id||'')}`,nameEn=enCandidates[0]||'';let role=String(p.role||latest.role||'').trim();if(!['вратарь','защитник','нападающий'].includes(role.toLocaleLowerCase('ru')))role='';const countryRaw=p.country||p.country_name||p.nationality||p.citizenship||latest.country||latest.nationality||latest.citizenship||'',country=countryInfo(countryRaw),birthday=p.birthday||p.birth_date||p.date_of_birth||p.birthdate||p.dob,height=p.height||p.height_cm||p.player_height,weight=p.weight||p.weight_kg||p.player_weight,stick=p.stick||p.shoots||p.grip||p.handedness||p.shooting_side;
const facts=[{label:'Дата рождения',value:dateValue(birthday)},{label:'Возраст',value:p.age||latest.age},{label:'Страна / гражданство',value:country.name,html:country.name?`<span class="flag-emoji">${country.emoji}</span>${esc(country.name)}`:undefined},{label:'Рост',value:physicalValue(height,'см')},{label:'Вес',value:physicalValue(weight,'кг')},{label:'Хват',value:gripValue(stick)},{label:'Сборная',value:p.national_team||latest.national_team},{label:'Контракт до',value:p.contract_until||latest.contract_until},{label:'Место рождения',value:p.birth_place||latest.birth_place},{label:'Номер',value:p.shirt_number||latest.shirt_number},{label:'KHL ID',value:i.khl_id||p.khl_id},{label:'Сезонов КХЛ',value:p.seasons_count?.khl||latest.seasons_count?.khl}],links=[];
if(i.khl_id||p.khl_id)links.push(['Профиль на KHL.ru',`https://www.khl.ru/players/${i.khl_id||p.khl_id}/`]);
return profileCard({type:role||'Амплуа не указано',name,nameEn,photo:p.image||p.photo,role:'',team:team.name||'',facts,links,compact:true})
}
function playerOverviewHtml(player){
let html=`<div class="section"><h2>${player._is_goalie?'Суммарная вратарская статистика':'Суммарная статистика КХЛ'}</h2></div>${aggregateTable(player)}`;
html+=`<div class="section"><h2>${player._is_goalie?'Вратарская статистика по сезонам':'Статистика по сезонам и этапам'}</h2></div>${stageStatsTable(player)}`;
const p=player._profile_full||player.profile||{},latest=(player.stages||{})[player._latest_stage_id]?.data||{},allTeams=p.teams||latest.teams||[],allTeamRows=teamRows(allTeams);if(allTeamRows.length)html+=table(['Команда','Сезоны'],allTeamRows,{title:'Все команды'});
return html
}
function playerTabsHtml(){return `<div class="player-tabs"><button id="playerTabOverview" class="player-tab ${S.playerTab==='overview'?'active':''}" onclick="showPlayerTab('overview')">Статистика</button><button id="playerTabMatches" class="player-tab ${S.playerTab==='matches'?'active':''}" onclick="showPlayerTab('matches')">Матчи</button></div><div id="playerTabContent"></div>`}
function renderPlayer(player,{keepTab=false}={}){S.currentPlayer=player;S.currentPlayerId=String((player.identity||{}).id||(player.profile||{}).id||selectedId('players')||'');if(!keepTab){S.playerTab='overview';S.matchesPage=1}$('detail').className='';$('detail').innerHTML=playerHeaderHtml(player)+playerTabsHtml();if(S.playerTab==='matches')loadPlayerMatches(S.matchesPage||1);else $('playerTabContent').innerHTML=playerOverviewHtml(player)}
function showPlayerTab(tab){if(!S.currentPlayer||!['overview','matches'].includes(tab))return;S.playerTab=tab;const a=$('playerTabOverview'),b=$('playerTabMatches');if(a)a.classList.toggle('active',tab==='overview');if(b)b.classList.toggle('active',tab==='matches');if(tab==='overview'){$('playerTabContent').innerHTML=playerOverviewHtml(S.currentPlayer);return}S.matchesPage=1;loadPlayerMatches(1)}
function renderPlayerMatches(data){
const host=$('playerTabContent');if(!host)return;
const rawHeaders=data.headers||[],headers=rawHeaders.map(h=>({label:String(h||''),title:statHint(String(h||''),String(h||''))}));
const teamIndex=rawHeaders.findIndex(x=>String(x).toLocaleLowerCase('ru').includes('команд'));
const rows=(data.rows||[]).map(row=>(row.values||[]).map((value,ci)=>{const cell=esc(formatValue(value));return row.url&&ci===teamIndex?`<a class="match-link" href="${esc(row.url)}" target="_blank" rel="noopener noreferrer">${cell} ↗</a>`:cell}));
const from=data.range_start||((data.page-1)*(data.page_size||30)+1),to=data.range_end||(rows.length?from+rows.length-1:0);
let html=table(headers,rows,{title:`Матчи КХЛ · ${data.total||rows.length}`,empty:data.total?`KHL сообщает ${data.total} матчей, но строки этой страницы не удалось разобрать`:'Матчей нет',note:data.total&&rows.length?`Показаны игры ${from}${to} из ${data.total}`:''});
html+=`<div class="matches-pager"><button ${data.page<=1?'disabled':''} onclick="loadPlayerMatches(${Math.max(1,data.page-1)})">← Назад</button><span class="small">Страница ${data.page} из ${data.pages||1}</span><button ${data.page>=(data.pages||1)?'disabled':''} onclick="loadPlayerMatches(${data.page+1})">Вперёд →</button></div>`;host.innerHTML=html
}
async function loadPlayerMatches(page=1){S.matchesPage=Math.max(1,Number(page)||1);page=S.matchesPage;const id=S.currentPlayerId;if(!id||S.playerTab!=='matches')return;const host=$('playerTabContent');if(!host)return;const key=`${id}:${page}`;if(S.matchesCache.has(key)){renderPlayerMatches(S.matchesCache.get(key));return}host.innerHTML='<div class="matches-state">Загрузка матчей KHL…</div>';try{const data=await getJSON(`/api/player-matches/${encodeURIComponent(id)}?page=${page}`);if(S.playerTab!=='matches'||S.currentPlayerId!==String(id))return;putCache(S.matchesCache,key,data,80);renderPlayerMatches(data)}catch(e){console.error(e);if(S.playerTab==='matches'&&S.currentPlayerId===String(id))host.innerHTML=`<div class="matches-state">Не удалось загрузить матчи: ${esc(e.message||e)}</div>`}}
async function loadPlayerExtra(id,player,seq){const key=String(id);if(S.extraCache.has(key)){mergePlayerExtra(player,S.extraCache.get(key));if(seq===S.openSeq&&S.kind==='players'&&selectedId('players')===key)renderPlayer(player,{keepTab:true});return}try{const data=await getJSON(`/api/player-extra/${encodeURIComponent(id)}`);putCache(S.extraCache,key,data.fields||{},100);mergePlayerExtra(player,data.fields||{});if(seq===S.openSeq&&S.kind==='players'&&selectedId('players')===key)renderPlayer(player,{keepTab:true})}catch(e){console.debug('Дополнительный профиль KHL недоступен',e)}}
function parsedTable(t,prefix=''){const n=Math.max(t.headers?.length||0,...(t.rows||[]).map(r=>r.values?.length||0));const heads=(t.headers?.length?t.headers:Array.from({length:n},(_,i)=>`Колонка ${i+1}`));const rows=(t.rows||[]).map(r=>Array.from({length:n},(_,i)=>esc(formatValue((r.values||[])[i]))));return table(heads,rows,{title:`${prefix}${t.title||''}`.trim()})}
function linksTable(items,title){const rows=(items||[]).filter(x=>x&&(x.title||x.url)).map(x=>[esc(x.title||x.url||'—'),x.url?`<a href="${esc(x.url)}" target="_blank" rel="noopener noreferrer">Открыть ↗</a>`:'—']);return rows.length?table(['Название','Ссылка'],rows,{title}):''}
function sectionsTable(sections,title){const rows=(sections||[]).map(s=>[esc(s.title||'—'),esc(s.text||s.content||'—'),esc((s.links||[]).map(x=>x.title||x.url).join(', ')||'—')]);return rows.length?table(['Раздел','Текст','Ссылки'],rows,{title}):''}
function renderStaff(person){const profile=person.profile||{},ru=person.languages?.ru||{},en=person.languages?.en||{},name=person._display_name||person.name_ru||profile.name||person.name||'Без имени',nameEn=person._display_name_en||person.name_en||profile.name_en||en.name||'',fields=profile.fields||ru.fields||{},apps=person.appearances||[],photos=profile.photos||person.photos||[],type=S.kind==='coaches'?'Тренер':'Судья',role=fields['Амплуа']||fields['Роль']||fields['Role']||apps.find(x=>x.context?.role)?.context?.role||'',teams=[...new Set(apps.flatMap(x=>(x.team_links||[]).map(t=>t.title)).filter(Boolean))],facts=Object.entries(fields).slice(0,10).map(([label,value])=>({label,value:label.toLowerCase().includes('рожд')?dateValue(value):value})),links=[];if(person.url_ru||person.url)links.push(['Профиль на KHL.ru',person.url_ru||person.url]);if(person.url_en)links.push(['English profile',person.url_en]);let html=profileCard({type,name,nameEn,photo:(photos.find(x=>x&&!/logo|sprite|icon|banner|advert|sponsor/i.test(String(x)))||''),role,team:teams[0]||'',facts,links});html+=table(['Сезон','Этап','Роль / амплуа','Команды','Язык','Описание'],apps.map(a=>[esc(a.context?.season||'—'),esc(a.context?.stage||a.context?.stage_type||'—'),esc(a.context?.role||'—'),esc((a.team_links||[]).map(x=>x.title).join(', ')||'—'),esc(a.language||a.context?.language||'—'),esc(a.text||'—')]),{title:'Все появления в списках'});for(const t of (profile.tables||ru.tables||[]))html+=parsedTable(t,'RU · ');for(const t of (profile.tables_en||en.tables||[]))html+=parsedTable(t,'EN · ');html+=linksTable(profile.teams||ru.teams||[],'Команды');html+=linksTable(profile.events||ru.events||[],'Матчи и события');html+=linksTable(profile.news||ru.news||[],'Новости');$('detail').className='';$('detail').innerHTML=html}
async function loadStatus(){S.status=await getJSON('/api/status');const c=S.status.counts,b=Number(S.status.broken_player_names||0);$('statusLine').textContent=`Игроки: ${c.players} · тренеры: ${c.coaches} · судьи: ${c.officials}`+(b?` · ⚠ обновите игроков: имена требуют пересборки (${b})`:'');fillFilters()}
async function setKind(kind){
if(!KINDS.includes(kind)||kind===S.kind)return;
captureListState();
S.kind=kind;
S.page=Math.max(1,Number(S.pageByKind[kind])||1);
saveUiState();
document.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x.id===`tab-${kind}`));
$('detail').className='empty';
$('detail').textContent='Загрузка карточки…';
fillFilters();
await loadList(false,{restoreScroll:true});
if(kind!==S.kind)return;
const id=selectedId(kind);
if(id)await openItem(id,{updateSelection:false});
else{$('detail').className='empty';$('detail').textContent='Выбери запись слева'}
}
function fillFilters(){if(!S.status)return;const f=S.status.filters[S.kind]||{};$('season').innerHTML='<option value="">Все сезоны</option>'+(f.seasons||[]).map(x=>`<option>${esc(x)}</option>`).join('');let values=[],label='Все';if(S.kind==='players'){values=f.roles||[];label='Все амплуа'}else if(S.kind==='coaches'){values=f.teams||[];label='Все команды'}else{values=f.roles||[];label='Все амплуа'}$('extra').innerHTML=`<option value="">${label}</option>`+values.map(x=>`<option>${esc(x)}</option>`).join('')}
let debounce=null;function debouncedLoad(){clearTimeout(debounce);debounce=setTimeout(()=>loadList(true),180)}
async function loadList(reset=false,{restoreScroll=true}={}){
const kind=S.kind;
if(reset){S.page=1;S.pageByKind[kind]=1;S.scrollByKind[kind]=0}else S.page=Math.max(1,Number(S.pageByKind[kind])||S.page||1);
$('list').innerHTML='<div class="small" style="padding:15px">Загрузка…</div>';
const q=new URLSearchParams({page:S.page,limit:100,q:$('search').value,season:$('season').value,extra:$('extra').value});
const p=await getJSON(`/api/list/${kind}?${q}`);
if(kind!==S.kind)return;
S.page=p.page;S.pages=p.pages;S.pageByKind[kind]=p.page;
$('total').textContent=`${p.total} записей`;$('pageInfo').textContent=`${p.page} / ${p.pages}`;$('prev').disabled=p.page<=1;$('next').disabled=p.page>=p.pages;
const active=selectedId(kind);
$('list').innerHTML=p.items.map(item=>`<div class="row ${active===String(item.id)?'active':''}" data-id="${esc(item.id)}" onclick="openItem('${esc(item.id)}')"><img class="avatar" src="${esc(item.image||fallback(item.name))}" onerror="this.src='${fallback(item.name)}'"><div><div class="name">${esc(item.name)}</div>${item.name_en?`<div class="name-en">${esc(item.name_en)}</div>`:''}<div class="badges"><span class="badge">ID ${esc(item.id)}</span>${item.subtitle?`<span class="badge">${esc(item.subtitle)}</span>`:''}${item.count?`<span class="badge good">${esc(item.count)}</span>`:''}</div></div><span class="small">${esc(item.role||'')}</span></div>`).join('')||'<div class="small" style="padding:15px">Ничего не найдено</div>';
saveUiState();
if(restoreScroll&&!reset)restoreListScroll(kind);else{S.scrollByKind[kind]=0;$('list').scrollTop=0;markActiveRow(active)}
}
function changePage(d){const n=S.page+d;if(n>=1&&n<=S.pages){captureListState();S.page=n;S.pageByKind[S.kind]=n;S.scrollByKind[S.kind]=0;saveUiState();loadList(false,{restoreScroll:false})}}
async function openItem(id,{updateSelection=true}={}){
const kind=S.kind,key=`${kind}:${id}`,seq=++S.openSeq;
if(updateSelection){S.selectedByKind[kind]=String(id);S.pageByKind[kind]=S.page;S.scrollByKind[kind]=$('list').scrollTop||0;saveUiState()}
markActiveRow(String(id));
try{
let item=S.itemCache.get(key);
if(!item){item=await getJSON(`/api/item/${kind}/${encodeURIComponent(id)}`);putCache(S.itemCache,key,item,80)}
if(seq!==S.openSeq||kind!==S.kind)return;
if(kind==='players'){
renderPlayer(item);
}else renderStaff(item);
markActiveRow(selectedId(kind));
}catch(e){console.error(e);if(seq!==S.openSeq)return;$('detail').className='empty';$('detail').textContent=`Не удалось открыть карточку: ${e.message||e}`}
}
async function collectData(kind){try{await getJSON(`/api/collect/${kind}`,{method:'POST'});pollJob()}catch(e){alert(e.message)}}
async function reloadData(){captureListState();await getJSON('/api/reload',{method:'POST'});await loadStatus();await loadList(false,{restoreScroll:true});const id=selectedId();if(id)await openItem(id,{updateSelection:false})}
async function pollJob(){clearInterval(S.timer);$('job').classList.add('show');S.timer=setInterval(async()=>{const j=await getJSON('/api/job');$('jobTitle').textContent=j.title||'Обновление';$('jobMessage').textContent=j.message||'';$('jobProgress').style.display=j.running?'block':'none';if(!j.running){clearInterval(S.timer);setTimeout(()=>$('job').classList.remove('show'),4000);captureListState();await loadStatus();await loadList(false,{restoreScroll:true});const id=selectedId();if(id)await openItem(id,{updateSelection:false})}},800)}
(async()=>{
document.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x.id===`tab-${S.kind}`));
await loadStatus();
S.page=Math.max(1,Number(S.pageByKind[S.kind])||1);
await loadList(false,{restoreScroll:true});
const id=selectedId();
if(id)await openItem(id,{updateSelection:false});
const j=await getJSON('/api/job');if(j.running)pollJob()
})()
</script>
</body>
</html>