8e136351f9
ROOT CAUSE ISOLATED:
Backend POST /api/auth/login, GET/PUT /api/auth/me, POST avatar, POST /logout
all return 200 OK (verified curl). Damirov problem is browser-side:
stale localStorage tokens that don't match current backend → 401 cascade
→ avatar upload appears as 'failed: 401' → profile changes 'lost'.
FIXES:
1. apiAuth() in app.html now:
- Pre-checks JWT exp claim before request
- On 401 response: clears localStorage (pgz_access/refresh/user) +
redirects to /login?reason=unauthorized
- On JWT expired: redirects to /login?reason=expired
2. login.html displays toast for ?reason=expired/unauthorized
3. Mobile responsive CSS (max-width: 768px):
- app.html: hamburger menu, sidebar slide-in, full-width drill-down panel
- sport2.html: KPI grid 2-col, klubovi 1-col, tables horizontal scroll
- Both: viewport meta + media queries + touch-friendly buttons
4. Mobile menu toggle button + backdrop overlay added
VERIFIED E2E (curl):
- POST /auth/login → 200 + JWT
- GET /auth/me → 200 + telefon persisted
- PUT /auth/me → 200, DB row updated
- POST /auth/me/avatar → 200, file saved + avatar_url returned
- POST /auth/logout → 200, token revoked (next /me returns 401)
374 lines
17 KiB
HTML
374 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="hr"><head><meta charset="utf-8"><title>3D Sport Network · PGŽ</title>
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{background:#06080d;color:#e8eaf0;font-family:system-ui,sans-serif;overflow:hidden}
|
|
#g{width:100vw;height:100vh;position:fixed;inset:0}
|
|
.panel{position:fixed;background:rgba(10,14,24,.92);border:1px solid #1e293b;border-radius:8px;
|
|
backdrop-filter:blur(8px);z-index:100}
|
|
#hud{top:12px;left:12px;padding:12px 16px;font-size:11px;line-height:1.7;min-width:240px}
|
|
#hud .h{font-size:13px;font-weight:600;color:#00f0ff;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center}
|
|
#hud .row{display:flex;justify-content:space-between;gap:16px}
|
|
#hud b{color:#fbbf24}
|
|
#ctrl{top:12px;right:12px;padding:10px 12px;width:240px}
|
|
#ctrl .h{font-size:11px;color:#94a3b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
|
|
input,select{width:100%;background:#0d1117;color:#e8eaf0;border:1px solid #1e293b;
|
|
padding:6px 8px;border-radius:4px;font-size:11px;margin:3px 0;font-family:inherit}
|
|
input:focus,select:focus{outline:none;border-color:#00f0ff}
|
|
button{cursor:pointer;font-weight:600}
|
|
.btn-pri{background:#00f0ff;color:#000;border:none;padding:6px 12px;border-radius:4px;font-size:11px;width:100%;margin-top:6px}
|
|
.btn-pri:hover{background:#22d3ee}
|
|
.btn-lim{background:#0d1117;color:#94a3b8;border:1px solid #1e293b;padding:4px 8px;border-radius:4px;font-size:10px;margin:0 2px}
|
|
.btn-lim.act{background:#00f0ff;color:#000;border-color:#00f0ff}
|
|
#leg{bottom:12px;left:12px;padding:8px 12px;font-size:10px;display:flex;gap:14px;align-items:center}
|
|
.dot{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:4px;vertical-align:middle}
|
|
#tt{position:fixed;display:none;z-index:200;background:rgba(10,14,24,.96);border:1px solid #fbbf24;
|
|
border-radius:8px;padding:10px 14px;font-size:11px;max-width:300px;pointer-events:none;line-height:1.6}
|
|
#tt b{color:#00f0ff}
|
|
#tt .meta{color:#94a3b8;font-size:10px;margin-top:4px}
|
|
#detail{display:none;position:fixed;right:12px;top:12px;bottom:12px;width:420px;
|
|
background:rgba(10,14,24,.97);border:1px solid #00f0ff;border-radius:10px;
|
|
padding:18px;font-size:12px;overflow-y:auto;z-index:300;backdrop-filter:blur(10px)}
|
|
#detail.open{display:block}
|
|
#detail .x{position:absolute;top:10px;right:14px;cursor:pointer;font-size:18px;color:#94a3b8;background:none;border:none;width:auto;padding:4px}
|
|
#detail h2{color:#00f0ff;font-size:16px;margin-bottom:8px;padding-right:30px}
|
|
#detail h3{color:#fbbf24;font-size:12px;text-transform:uppercase;letter-spacing:.5px;margin:14px 0 6px}
|
|
#detail .kv{display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid #1e293b;font-size:11px}
|
|
#detail .kv b{color:#e8eaf0}
|
|
#detail ul{list-style:none;padding-left:0}
|
|
#detail li{padding:6px 0;border-bottom:1px dotted #1e293b;font-size:11px;cursor:pointer}
|
|
#detail li:hover{color:#00f0ff}
|
|
#yearSel{margin:8px 0;padding:6px 8px}
|
|
#load{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);color:#00f0ff;font-size:14px;z-index:50}
|
|
.loader{display:inline-block;width:14px;height:14px;border:2px solid #00f0ff;border-top-color:transparent;border-radius:50%;animation:sp 0.8s linear infinite;vertical-align:middle;margin-right:6px}
|
|
@keyframes sp{to{transform:rotate(360deg)}}
|
|
</style>
|
|
<script src="/static/oib_format.js" defer></script>
|
|
</head><body>
|
|
|
|
<div id="g"></div>
|
|
<div id="load"><span class="loader"></span> Učitavam mrežu...</div>
|
|
|
|
<div id="hud" class="panel">
|
|
<div class="h"><span>⚡ 3D SPORT NETWORK</span><span style="font-size:10px;color:#475569">v2.0</span></div>
|
|
<div class="row"><span>Osobe:</span><b id="s-p">—</b></div>
|
|
<div class="row"><span>Multi-chair:</span><b id="s-mc" style="color:#fbbf24">—</b></div>
|
|
<div class="row"><span>Savezi:</span><b id="s-s">—</b></div>
|
|
<div class="row"><span>Klubovi:</span><b id="s-k">—</b></div>
|
|
<div class="row"><span>Veze:</span><b id="s-l">—</b></div>
|
|
<div style="font-size:10px;color:#475569;margin-top:8px;border-top:1px solid #1e293b;padding-top:6px">Klik = detalj · Hover = info</div>
|
|
</div>
|
|
|
|
<div id="ctrl" class="panel">
|
|
<div class="h">FILTRI</div>
|
|
<input type="text" id="f-search" placeholder="🔍 Označi po imenu (highlight zlato)..." oninput="applyHighlight()"/>
|
|
<select id="f-sport"><option value="">Svi sportovi</option></select>
|
|
<input type="number" id="f-min" value="2" min="1" max="10" placeholder="Min organizacija"/>
|
|
<select id="f-year"><option value="">Sve sezone</option></select>
|
|
<div style="display:flex;gap:4px;margin:6px 0;justify-content:space-between">
|
|
<span style="font-size:10px;color:#94a3b8;align-self:center">Limit:</span>
|
|
<button class="btn-lim" data-l="50" onclick="setLimit(50)">50</button>
|
|
<button class="btn-lim act" data-l="100" onclick="setLimit(100)">100</button>
|
|
<button class="btn-lim" data-l="200" onclick="setLimit(200)">200</button>
|
|
<button class="btn-lim" data-l="500" onclick="setLimit(500)">500</button>
|
|
</div>
|
|
<button class="btn-pri" onclick="loadGraph()">↻ Učitaj</button>
|
|
</div>
|
|
|
|
<div id="leg" class="panel">
|
|
<span><span class="dot" style="background:#fbbf24"></span>Multi-chair</span>
|
|
<span><span class="dot" style="background:#22c55e"></span>Osoba</span>
|
|
<span><span class="dot" style="background:#06b6d4"></span>Savez</span>
|
|
<span><span class="dot" style="background:#64748b"></span>Klub</span>
|
|
<span style="color:#475569;font-size:9px">drag/scroll/click</span>
|
|
</div>
|
|
|
|
<div id="tt"></div>
|
|
|
|
<div id="detail">
|
|
<button class="x" onclick="closeDetail()">✕</button>
|
|
<div id="detail-content">Loading...</div>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
|
|
<script src="https://unpkg.com/3d-force-graph@1.73.4/dist/3d-force-graph.min.js"></script>
|
|
<script>
|
|
const COLOR = {person:'#22c55e', multichair:'#fbbf24', savez:'#06b6d4', klub:'#64748b', highlight:'#FFD700'};
|
|
let Graph = null, currentLimit = 100, currentData = null, highlightQuery = '';
|
|
|
|
async function loadSports() {
|
|
try {
|
|
const r = await fetch('/sport/api/v2/dashboard/sport-stats');
|
|
const d = await r.json();
|
|
const sel = document.getElementById('f-sport');
|
|
const sports = (d.klub_breakdown || []).map(k => k.sport).filter(Boolean);
|
|
[...new Set(sports)].sort().forEach(s => {
|
|
const o = document.createElement('option'); o.value=s; o.textContent=s; sel.appendChild(o);
|
|
});
|
|
} catch(e){}
|
|
// Years
|
|
const yearSel = document.getElementById('f-year');
|
|
for (let y = 2026; y >= 2015; y--) {
|
|
const o = document.createElement('option'); o.value=y; o.textContent=y; yearSel.appendChild(o);
|
|
}
|
|
}
|
|
|
|
async function loadGraph() {
|
|
document.getElementById('load').style.display = 'block';
|
|
const min = document.getElementById('f-min').value || 2;
|
|
const sport = document.getElementById('f-sport').value || '';
|
|
const year = document.getElementById('f-year').value || '';
|
|
|
|
const url = `/sport/api/v2/graph/3d-network?min_orgs=${min}&top_n=${currentLimit}&sport=${encodeURIComponent(sport)}` +
|
|
(year ? `&year=${year}` : '');
|
|
|
|
try {
|
|
const r = await fetch(url);
|
|
const data = await r.json();
|
|
currentData = data;
|
|
|
|
// Update HUD
|
|
const stats = data.stats || {};
|
|
document.getElementById('s-p').textContent = (stats.persons || 0) - (stats.multichair || 0);
|
|
document.getElementById('s-mc').textContent = stats.multichair || 0;
|
|
document.getElementById('s-l').textContent = stats.links || 0;
|
|
|
|
// Count savezi vs klubovi
|
|
let nSav = 0, nKl = 0;
|
|
(data.nodes || []).forEach(n => {
|
|
if (n.type === 'savez') nSav++;
|
|
else if (n.type === 'klub') nKl++;
|
|
});
|
|
document.getElementById('s-s').textContent = nSav;
|
|
document.getElementById('s-k').textContent = nKl;
|
|
|
|
if (!Graph) initGraph(data);
|
|
else Graph.graphData(data);
|
|
} catch(e) {
|
|
console.error('Load fail', e);
|
|
}
|
|
document.getElementById('load').style.display = 'none';
|
|
}
|
|
|
|
function initGraph(data) {
|
|
const el = document.getElementById('g');
|
|
Graph = ForceGraph3D()(el)
|
|
.backgroundColor('#06080d')
|
|
.nodeColor(getNodeColor)
|
|
.nodeVal(getNodeVal)
|
|
.nodeOpacity(0.92)
|
|
.linkColor(() => 'rgba(148,163,184,0.18)')
|
|
.linkWidth(0.6)
|
|
.linkDirectionalParticles(0)
|
|
.nodeLabel(n => '') // we use custom tooltip
|
|
.onNodeHover(n => {
|
|
const tt = document.getElementById('tt');
|
|
if (n) {
|
|
let html = `<b>${escape(n.name)}</b>`;
|
|
if (n.type === 'multichair' || n.type === 'person') {
|
|
html += `<div class="meta">${n.n_orgs || 1} organizacija${(n.n_orgs||1)>=2?' · MULTI-CHAIR':''}</div>`;
|
|
} else if (n.type === 'savez') {
|
|
html += `<div class="meta">Savez</div>`;
|
|
} else if (n.type === 'klub') {
|
|
html += `<div class="meta">Klub${n.sport?' · '+n.sport:''}</div>`;
|
|
}
|
|
html += `<div class="meta">Klik = detalj</div>`;
|
|
tt.innerHTML = html;
|
|
tt.style.display = 'block';
|
|
} else tt.style.display = 'none';
|
|
})
|
|
.onNodeClick(n => {
|
|
openDetail(n);
|
|
})
|
|
.graphData(data);
|
|
}
|
|
|
|
function getNodeColor(n) {
|
|
if (highlightQuery && n.name && n.name.toLowerCase().includes(highlightQuery.toLowerCase())) {
|
|
return COLOR.highlight;
|
|
}
|
|
return COLOR[n.type] || '#94a3b8';
|
|
}
|
|
|
|
function getNodeVal(n) {
|
|
let base = n.val || 5;
|
|
if (highlightQuery && n.name && n.name.toLowerCase().includes(highlightQuery.toLowerCase())) {
|
|
return base * 2.5;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
function applyHighlight() {
|
|
highlightQuery = document.getElementById('f-search').value.trim();
|
|
if (Graph) Graph.refresh();
|
|
}
|
|
|
|
function setLimit(l) {
|
|
currentLimit = l;
|
|
document.querySelectorAll('.btn-lim').forEach(b => b.classList.toggle('act', +b.dataset.l === l));
|
|
loadGraph();
|
|
}
|
|
|
|
async function openDetail(node) {
|
|
const d = document.getElementById('detail');
|
|
const c = document.getElementById('detail-content');
|
|
d.classList.add('open');
|
|
c.innerHTML = '<div style="text-align:center;padding:30px"><span class="loader"></span> Učitavam detalje...</div>';
|
|
|
|
try {
|
|
if (node.type === 'klub') {
|
|
const klubId = node.id.replace('klub:', '');
|
|
const r = await fetch(`/sport/api/klubovi/${klubId}`);
|
|
const k = await r.json();
|
|
let html = `<h2>🏆 ${escape(k.naziv || node.name)}</h2>`;
|
|
html += `<div class="kv"><span>Sport</span><b>${escape(k.sport || '—')}</b></div>`;
|
|
html += `<div class="kv"><span>Grad</span><b>${escape(k.grad || '—')}</b></div>`;
|
|
html += `<div class="kv"><span>OIB</span><b>${escape(k.oib?formatOib(k.oib,{klub_id:k.id,savez_id:k.savez_id}):'—')}</b></div>`;
|
|
html += `<div class="kv"><span>Predsjednik</span><b>${escape(k.predsjednik || '—')}</b></div>`;
|
|
html += `<div class="kv"><span>Tajnik</span><b>${escape(k.tajnik || '—')}</b></div>`;
|
|
html += `<div class="kv"><span>Email</span><b>${escape(k.email || '—')}</b></div>`;
|
|
html += `<div class="kv"><span>Web</span><b>${k.web_stranica?'<a href="'+escape(k.web_stranica)+'" target="_blank" style="color:#00f0ff">otvori</a>':'—'}</b></div>`;
|
|
|
|
// RGFI year selector
|
|
html += `<h3>FINANCIJE PO GODINI</h3>`;
|
|
html += `<select id="yearSel" onchange="loadKlubFinance('${klubId}')">`;
|
|
html += `<option value="">Odaberi godinu...</option>`;
|
|
for (let y = 2024; y >= 2015; y--) html += `<option value="${y}">${y}</option>`;
|
|
html += `</select>`;
|
|
html += `<div id="finance-data"></div>`;
|
|
|
|
// Linked savezi & osobe
|
|
if (currentData && currentData.links) {
|
|
const linkedNodes = currentData.links
|
|
.filter(l => l.source.id === node.id || l.target.id === node.id)
|
|
.map(l => l.source.id === node.id ? l.target : l.source);
|
|
if (linkedNodes.length) {
|
|
html += `<h3>POVEZANE OSOBE/SAVEZI (${linkedNodes.length})</h3><ul>`;
|
|
linkedNodes.slice(0, 30).forEach(n => {
|
|
html += `<li onclick="focusNode('${n.id}')">${getEmoji(n.type)} ${escape(n.name)}</li>`;
|
|
});
|
|
html += `</ul>`;
|
|
}
|
|
}
|
|
c.innerHTML = html;
|
|
} else if (node.type === 'savez') {
|
|
const savezId = node.id.replace('savez:', '');
|
|
const r = await fetch(`/sport/api/savezi/${savezId}`);
|
|
const s = await r.json();
|
|
let html = `<h2>🏛 ${escape(s.naziv || node.name)}</h2>`;
|
|
html += `<div class="kv"><span>Sport</span><b>${escape(s.sport || '—')}</b></div>`;
|
|
html += `<div class="kv"><span>OIB</span><b>${escape(s.oib?formatOib(s.oib,{savez_id:s.id}):'—')}</b></div>`;
|
|
html += `<div class="kv"><span>Predsjednik</span><b>${escape(s.predsjednik || '—')}</b></div>`;
|
|
html += `<div class="kv"><span>Tajnik</span><b>${escape(s.tajnik || '—')}</b></div>`;
|
|
html += `<div class="kv"><span>Godina osnutka</span><b>${escape(s.godina_osnutka || '—')}</b></div>`;
|
|
|
|
if (currentData && currentData.links) {
|
|
const linkedKlubovi = currentData.links
|
|
.filter(l => l.source.id === node.id || l.target.id === node.id)
|
|
.map(l => l.source.id === node.id ? l.target : l.source)
|
|
.filter(n => n.type === 'klub');
|
|
if (linkedKlubovi.length) {
|
|
html += `<h3>KLUBOVI U SAVEZU (${linkedKlubovi.length})</h3><ul>`;
|
|
linkedKlubovi.forEach(n => {
|
|
html += `<li onclick="focusNode('${n.id}')">🏆 ${escape(n.name)}</li>`;
|
|
});
|
|
html += `</ul>`;
|
|
}
|
|
}
|
|
c.innerHTML = html;
|
|
} else {
|
|
// Person / multi-chair
|
|
let html = `<h2>${node.type==='multichair'?'⚠️':'👤'} ${escape(node.name)}</h2>`;
|
|
html += `<div class="kv"><span>Tip</span><b>${node.type === 'multichair' ? 'MULTI-CHAIR' : 'Osoba'}</b></div>`;
|
|
html += `<div class="kv"><span>Broj organizacija</span><b>${node.n_orgs || 1}</b></div>`;
|
|
|
|
if (currentData && currentData.links) {
|
|
const orgs = currentData.links
|
|
.filter(l => l.source.id === node.id || l.target.id === node.id)
|
|
.map(l => ({
|
|
node: l.source.id === node.id ? l.target : l.source,
|
|
role: l.role
|
|
}));
|
|
if (orgs.length) {
|
|
html += `<h3>FUNKCIJE U ORGANIZACIJAMA</h3><ul>`;
|
|
orgs.forEach(o => {
|
|
html += `<li onclick="focusNode('${o.node.id}')">${getEmoji(o.node.type)} <b>${escape(o.node.name)}</b><div style="color:#94a3b8;font-size:10px;margin-top:2px">${escape(o.role || '')}</div></li>`;
|
|
});
|
|
html += `</ul>`;
|
|
}
|
|
}
|
|
c.innerHTML = html;
|
|
}
|
|
} catch(e) {
|
|
c.innerHTML = '<div style="color:#f87171">Greška učitavanja: '+escape(e.message)+'</div>';
|
|
}
|
|
}
|
|
|
|
async function loadKlubFinance(klubId) {
|
|
const yr = document.getElementById('yearSel').value;
|
|
const fd = document.getElementById('finance-data');
|
|
if (!yr) { fd.innerHTML = ''; return; }
|
|
fd.innerHTML = '<div style="padding:10px;color:#94a3b8"><span class="loader"></span> Tražim RGFI '+yr+'...</div>';
|
|
try {
|
|
// Try multiple endpoints
|
|
let data = null;
|
|
for (const ep of [`/sport/api/klubovi/${klubId}/finance?year=${yr}`, `/sport/api/v2/klub/${klubId}/rgfi?year=${yr}`]) {
|
|
try { const r = await fetch(ep); if (r.ok) { data = await r.json(); break; } } catch{}
|
|
}
|
|
if (!data || (Array.isArray(data) && !data.length) || data.error) {
|
|
fd.innerHTML = '<div style="padding:10px;color:#475569;font-size:10px">⚠ Nema RGFI podataka za '+yr+'. (Klubovi su udruge, ne d.o.o. — RGFI nije obavezan.)</div>';
|
|
return;
|
|
}
|
|
// Render financial data with clickable accounts
|
|
let html = '<div style="padding:6px;background:#0d1117;border:1px solid #1e293b;border-radius:6px;margin-top:8px">';
|
|
html += '<div style="font-size:10px;color:#fbbf24;margin-bottom:6px">RGFI '+yr+'</div>';
|
|
Object.entries(data).forEach(([k,v]) => {
|
|
if (typeof v === 'number' && v) {
|
|
html += '<div class="kv" onclick="alert(\'Konto: '+k+' = '+v.toLocaleString('hr-HR')+' EUR\')" style="cursor:pointer"><span>'+k+'</span><b>'+(v||0).toLocaleString('hr-HR')+' €</b></div>';
|
|
}
|
|
});
|
|
html += '</div>';
|
|
fd.innerHTML = html;
|
|
} catch(e) {
|
|
fd.innerHTML = '<div style="color:#f87171;padding:10px;font-size:10px">Greška: '+e.message+'</div>';
|
|
}
|
|
}
|
|
|
|
function focusNode(nodeId) {
|
|
if (!currentData || !Graph) return;
|
|
const node = currentData.nodes.find(n => n.id === nodeId);
|
|
if (!node) return;
|
|
const dist = 80;
|
|
const distRatio = 1 + dist/Math.hypot(node.x||1, node.y||1, node.z||1);
|
|
Graph.cameraPosition({x:(node.x||0)*distRatio, y:(node.y||0)*distRatio, z:(node.z||0)*distRatio}, node, 1500);
|
|
setTimeout(() => openDetail(node), 800);
|
|
}
|
|
|
|
function getEmoji(type) {
|
|
return {multichair:'⚠️', person:'👤', savez:'🏛', klub:'🏆'}[type] || '•';
|
|
}
|
|
|
|
function escape(s) {
|
|
return String(s||'').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
|
}
|
|
|
|
function closeDetail() {
|
|
document.getElementById('detail').classList.remove('open');
|
|
}
|
|
|
|
document.addEventListener('mousemove', e => {
|
|
const tt = document.getElementById('tt');
|
|
tt.style.left = (e.clientX+14)+'px';
|
|
tt.style.top = (e.clientY+14)+'px';
|
|
});
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') closeDetail();
|
|
});
|
|
|
|
// Init
|
|
loadSports().then(loadGraph);
|
|
</script>
|
|
</body></html>
|