8dce58c5f9
Shared module:
- /static/shared/sidebar.css ← unified CSS (#pgz-sb, .pgz-collapsed, mobile overlay, tooltip)
- /static/shared/sidebar.js ← auto-mounting JS shell + PGZSidebar API
* Auto-renders #pgz-sb na <body> start (data-inline=1 to opt out)
* NAV_EXTERNAL: Prijava, Aplikacija, Administracija, CRM, ERP, KPI, Audit, Public portal
* Toggle (≡) -> localStorage 'sidebarCollapsed' (perzistira preko SVIH stranica)
* Mobile <768px: ≡ burger + ✕ close, body backdrop
* Loads /api/auth/me u footer (avatar/username/uloga); ⎋ logout briše JWT i ide na /login
* data-active="<key>" highlight aktivnog portala
Page integration:
- sport2.html ← inline NAV_EXTERNAL u buildNav() + "Portali" separator (zadrži postojeći sidebar)
- app.html ← inline NAV_EXTERNAL u buildNav() (zadrži role-based interni nav, dopuni Portalima)
- admin.html ← Portali stavke u <aside class="sidebar"> (matching .nav-item style)
- erp.html ← Portali stavke u <aside class="sidebar"> (matching .nav-item style)
- crm.html ← include shared sidebar.css + sidebar.js data-active="crm"
- audit.html ← include shared sidebar.css + sidebar.js data-active="audit"
- kpi.html ← include shared sidebar.css + sidebar.js data-active="kpi"
- login.html ← include shared sidebar.css + sidebar.js data-active="login"
Backups: _backups/{*.cc3_pre_unified_sidebar.*}
Live verified: 8 pages serve HTTP 200; sidebar.css/js HTTP 200; portal markers per page OK.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
215 lines
8.7 KiB
JavaScript
215 lines
8.7 KiB
JavaScript
/* PGŽ SPORT — Unified Sidebar v1.0
|
|
* dradulic@outlook.com / damir@rinet.one — 2026-05-05
|
|
*
|
|
* Usage on each page:
|
|
* <link rel="stylesheet" href="/static/shared/sidebar.css">
|
|
* <script src="/static/shared/sidebar.js" defer
|
|
* data-active="app" // page key for highlight: app|admin|crm|erp|kpi|audit|login|sport2
|
|
* data-inline="0"></script> // 0 (default) = render on load. 1 = call PGZSidebar.mount() yourself
|
|
*
|
|
* The script renders #pgz-sb at start of <body>, adds class "pgz-has-sb" to body
|
|
* (so existing layouts can be migrated). Pages that already have their own sidebar
|
|
* should pass data-skip="1" — only NAV_EXTERNAL portal links will be appended to
|
|
* an element with id="pgz-portal-mount" if present.
|
|
*/
|
|
(function(){
|
|
'use strict';
|
|
|
|
// ────────── Configuration ──────────
|
|
// Per-portal "internal" sections (left as a hint; pages typically own their own internal nav)
|
|
// External portal links — same on every page
|
|
const NAV_EXTERNAL = [
|
|
{id:'login', href:'/sport/login', ic:'\u{1F511}', label:'Prijava'},
|
|
{id:'app', href:'/sport/app', ic:'\u{1F4F1}', label:'Aplikacija'},
|
|
{id:'admin', href:'/sport/admin', ic:'\u{1F6E1}', label:'Administracija'},
|
|
{id:'crm', href:'/sport/crm', ic:'\u{1F465}', label:'CRM'},
|
|
{id:'erp', href:'/sport/erp', ic:'\u{1F4B0}', label:'ERP'},
|
|
{id:'kpi', href:'/sport/kpi', ic:'\u{1F4C8}', label:'KPI'},
|
|
{id:'audit', href:'/sport/audit', ic:'\u{1F4CB}', label:'Audit'},
|
|
{id:'sport2', href:'/sport/static/sport2.html', ic:'\u{1F310}', label:'Public portal'}
|
|
];
|
|
|
|
const STATE_KEY = 'sidebarCollapsed'; // shared across all pages
|
|
const $ = (s, root) => (root||document).querySelector(s);
|
|
|
|
function readToken(){
|
|
try { return localStorage.getItem('jwt') || localStorage.getItem('access_token') || ''; }
|
|
catch(e){ return ''; }
|
|
}
|
|
function logout(){
|
|
if(!confirm('Odjava iz aplikacije?')) return;
|
|
try { localStorage.removeItem('jwt'); localStorage.removeItem('access_token'); localStorage.removeItem('app-role'); } catch(e){}
|
|
location.href = '/sport/login';
|
|
}
|
|
function initials(n){
|
|
if(!n) return '?';
|
|
const p = String(n).trim().split(/\s+/);
|
|
return ((p[0]||'')[0]||'').toUpperCase() + ((p[1]||'')[0]||'').toUpperCase();
|
|
}
|
|
function esc(s){
|
|
return String(s==null?'':s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
|
}
|
|
|
|
// Try to read /api/auth/me for footer display (best effort)
|
|
async function tryLoadMe(){
|
|
const tok = readToken(); if(!tok) return null;
|
|
try {
|
|
const r = await fetch('/sport/api/auth/me', {headers:{'Authorization':'Bearer '+tok}});
|
|
if(!r.ok) return null;
|
|
return await r.json();
|
|
} catch(e){ return null; }
|
|
}
|
|
|
|
function renderShell(activeKey, internalNavHTML){
|
|
const sb = document.createElement('aside');
|
|
sb.id = 'pgz-sb';
|
|
sb.innerHTML = `
|
|
<div class="pgz-sb-h">
|
|
<div class="pgz-logo">PGŽ <span class="g">SPORT</span></div>
|
|
<div class="pgz-sub">Operativna platforma</div>
|
|
<div class="pgz-sb-toggle" onclick="PGZSidebar.toggle()" title="Skupi/raširi (≡)">≡</div>
|
|
<div class="pgz-sb-mx" onclick="PGZSidebar.closeMobile()" title="Zatvori">✕</div>
|
|
</div>
|
|
${internalNavHTML ? `<div class="pgz-sb-sep">Sekcije</div>` : ''}
|
|
<nav class="pgz-sb-nav" id="pgz-sb-nav">
|
|
${internalNavHTML || ''}
|
|
<div class="pgz-sb-sep" id="pgz-portal-sep">Portali</div>
|
|
<div id="pgz-portal-mount">${renderExternal(activeKey)}</div>
|
|
</nav>
|
|
<div class="pgz-sb-foot" id="pgz-sb-foot">
|
|
<div class="av" id="pgz-sb-av">PG</div>
|
|
<div class="ui">
|
|
<div class="un" id="pgz-sb-un">Gost</div>
|
|
<div class="ur" id="pgz-sb-ur">Demo</div>
|
|
</div>
|
|
<div class="lo" onclick="PGZSidebar.logout()" title="Odjava">⎋</div>
|
|
</div>
|
|
`;
|
|
return sb;
|
|
}
|
|
function renderExternal(activeKey){
|
|
return NAV_EXTERNAL.map(n => `
|
|
<a class="pgz-nav-i pgz-nav-ext ${n.id===activeKey?'active':''}"
|
|
href="${n.href}" data-id="${n.id}" data-label="${esc(n.label)}">
|
|
<span class="ic">${n.ic}</span>
|
|
<span class="lbl">${esc(n.label)}</span>
|
|
</a>`).join('');
|
|
}
|
|
function renderBurger(){
|
|
if(document.getElementById('pgz-sb-burger')) return;
|
|
const b = document.createElement('div');
|
|
b.id = 'pgz-sb-burger';
|
|
b.className = 'pgz-sb-burger';
|
|
b.innerHTML = '≡';
|
|
b.onclick = () => PGZSidebar.openMobile();
|
|
document.body.appendChild(b);
|
|
}
|
|
|
|
function setUserDisplay(me){
|
|
if(!me){
|
|
$('#pgz-sb-un') && ($('#pgz-sb-un').textContent = 'Gost');
|
|
$('#pgz-sb-ur') && ($('#pgz-sb-ur').textContent = 'Demo · click Prijava');
|
|
$('#pgz-sb-av') && ($('#pgz-sb-av').textContent = '?');
|
|
return;
|
|
}
|
|
const name = me.full_name || ((me.ime||'')+' '+(me.prezime||'')).trim() || me.email || '—';
|
|
const role = me.user_type || '';
|
|
const av = me.avatar_url || me.google_picture;
|
|
if($('#pgz-sb-un')) $('#pgz-sb-un').textContent = name;
|
|
if($('#pgz-sb-ur')) $('#pgz-sb-ur').textContent = role;
|
|
const avEl = $('#pgz-sb-av');
|
|
if(avEl){
|
|
if(av) avEl.innerHTML = `<img src="${esc(av)}" alt="">`;
|
|
else avEl.textContent = initials(name);
|
|
}
|
|
}
|
|
|
|
function applyCollapsedFromStorage(){
|
|
let col = false;
|
|
try { col = localStorage.getItem(STATE_KEY) === '1'; } catch(e){}
|
|
const sb = document.getElementById('pgz-sb');
|
|
if(!sb) return;
|
|
sb.classList.toggle('pgz-collapsed', col);
|
|
document.body.classList.toggle('pgz-sb-col', col);
|
|
}
|
|
|
|
// ────────── Public API ──────────
|
|
const PGZSidebar = {
|
|
NAV_EXTERNAL,
|
|
|
|
// Render: insert sidebar shell at document start; if a page provides internalNavHTML, use it
|
|
mount(opts){
|
|
opts = opts || {};
|
|
const activeKey = opts.activeKey || (document.currentScript && document.currentScript.dataset.active) || '';
|
|
const internalNavHTML = opts.internalNavHTML || '';
|
|
// Skip mount if the page already has its own sidebar AND a portal mount point is provided
|
|
if(opts.skipShell){
|
|
const mount = document.getElementById('pgz-portal-mount');
|
|
if(mount){ mount.innerHTML = renderExternal(activeKey); }
|
|
return;
|
|
}
|
|
const existing = document.getElementById('pgz-sb');
|
|
if(existing) existing.remove();
|
|
const sb = renderShell(activeKey, internalNavHTML);
|
|
document.body.insertBefore(sb, document.body.firstChild);
|
|
document.body.classList.add('pgz-has-sb');
|
|
renderBurger();
|
|
applyCollapsedFromStorage();
|
|
tryLoadMe().then(setUserDisplay);
|
|
},
|
|
|
|
// Append portal links to an existing custom sidebar (call this from a page's own buildNav)
|
|
appendPortalLinksTo(navEl, activeKey){
|
|
if(!navEl) return;
|
|
activeKey = activeKey || '';
|
|
navEl.insertAdjacentHTML('beforeend',
|
|
'<div class="pgz-sb-sep" style="padding:14px 14px 4px 14px;font-size:9.5px;color:var(--t4,#4e5a7a);text-transform:uppercase;letter-spacing:1.2px;font-weight:700">Portali</div>'
|
|
);
|
|
navEl.insertAdjacentHTML('beforeend', renderExternal(activeKey));
|
|
},
|
|
|
|
toggle(){
|
|
const sb = document.getElementById('pgz-sb');
|
|
if(!sb) return;
|
|
const col = sb.classList.toggle('pgz-collapsed');
|
|
document.body.classList.toggle('pgz-sb-col', col);
|
|
try { localStorage.setItem(STATE_KEY, col ? '1' : '0'); } catch(e){}
|
|
},
|
|
openMobile(){
|
|
const sb = document.getElementById('pgz-sb');
|
|
if(!sb) return;
|
|
sb.classList.add('pgz-mobile-open');
|
|
document.body.classList.add('pgz-mobile-sb-open');
|
|
// close on backdrop click
|
|
const closer = (ev) => {
|
|
if(!sb.contains(ev.target) && ev.target.id !== 'pgz-sb-burger'){
|
|
PGZSidebar.closeMobile();
|
|
document.removeEventListener('click', closer, true);
|
|
}
|
|
};
|
|
setTimeout(() => document.addEventListener('click', closer, true), 50);
|
|
},
|
|
closeMobile(){
|
|
const sb = document.getElementById('pgz-sb');
|
|
if(!sb) return;
|
|
sb.classList.remove('pgz-mobile-open');
|
|
document.body.classList.remove('pgz-mobile-sb-open');
|
|
},
|
|
logout
|
|
};
|
|
window.PGZSidebar = PGZSidebar;
|
|
|
|
// Auto-mount unless data-inline=1
|
|
function autoMount(){
|
|
const cs = document.currentScript || Array.from(document.scripts).find(s => /sidebar\.js/.test(s.src||''));
|
|
const inline = cs && cs.dataset && cs.dataset.inline === '1';
|
|
if(inline) return; // page will call PGZSidebar.mount() itself
|
|
if(document.readyState === 'loading'){
|
|
document.addEventListener('DOMContentLoaded', () => PGZSidebar.mount({}));
|
|
} else {
|
|
PGZSidebar.mount({});
|
|
}
|
|
}
|
|
autoMount();
|
|
})();
|