CC3 R3: Sectioned sidebar redesign (DABI-style) — PORTAL/OPERATIVA/CRM/ERP/ANALITIKA/ADMIN
Reference: app.rinet.one/klasik/dabi — uppercase section headers + grouped items.
Shared module rewrite:
- /static/shared/sidebar.css v2.0
* 6 named sections, 240px expanded / 58px collapsed
* Active item: gold left-border + transparent gradient fill
* Hover: blue left-border accent
* Section header hidden in collapsed mode (replaced with dashed separator)
* Tooltip on hover (data-label) when collapsed
* Mobile <768px overlay with backdrop
- /static/shared/sidebar.js v2.0
* SIDEBAR_SECTIONS = [PORTAL, OPERATIVA, CRM, ERP, ANALITIKA, ADMIN]
* ADMIN section hidden unless user_type ∈ {pgz_admin, super_admin} (gated by /api/auth/me)
* Cross-portal links (↗ marker) for items that target a different page
* Same-page items trigger hashchange instead of full reload
* Footer = avatar + name + role + ▾ user menu (Profil / Postavke / Public portal / Prijava ↔ Odjava)
* localStorage 'sidebarCollapsed' persists across all 8 pages
Page integration:
- sport2.html ← native .sb hidden; data-active=dashboard; hashchange→navTo
- app.html ← native .sb hidden; data-active=profil; hashchange→navTo
- admin.html ← native .sidebar hidden; data-active=korisnici
- erp.html ← native .sidebar hidden; data-active=racuni
- crm.html ← data-active=clanarine
- audit.html ← data-active=audit (existing)
- kpi.html ← data-active=kpi (existing)
- login.html ← data-active=login (no item match → no highlight; user menu shows Prijava)
Backups: _backups/*.cc3_pre_redesign.{TS}
Live verified: all 8 pages HTTP 200; shared sidebar.css 200 (8664 B); sidebar.js 200 (12678 B); 6 sections present.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+175
-91
@@ -1,36 +1,68 @@
|
||||
/* PGŽ SPORT — Unified Sidebar v1.0
|
||||
/* PGŽ SPORT — Unified Sectioned Sidebar v2.0
|
||||
* dradulic@outlook.com / damir@rinet.one — 2026-05-05
|
||||
* Reference: app.rinet.one/klasik/dabi
|
||||
*
|
||||
* 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
|
||||
* Usage:
|
||||
* <link rel="stylesheet" href="/sport/static/shared/sidebar.css">
|
||||
* <script src="/sport/static/shared/sidebar.js" defer
|
||||
* data-active="dashboard" // active item id
|
||||
* data-portal="portal"></script> // active portal hint (optional)
|
||||
*
|
||||
* 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.
|
||||
* Auto-mounts <aside id="pgz-sb"> at start of <body> and adds class "pgz-has-sb" to <body>
|
||||
* for layout. ADMIN section is gated by user role from /api/auth/me.
|
||||
*/
|
||||
(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'}
|
||||
// Sectioned menu (DABI-style).
|
||||
// href can be:
|
||||
// "/sport/<page>" → cross-portal navigation (full page load)
|
||||
// "/sport/<page>#<hash>" → cross-portal + intent on that page
|
||||
// "#<id>" → in-page anchor (handled by host page on hashchange)
|
||||
const SIDEBAR_SECTIONS = [
|
||||
{title:'PORTAL', items: [
|
||||
{id:'dashboard', ic:'\u{1F4CA}', label:'Dashboard', href:'/sport/static/sport2.html#dashboard'},
|
||||
{id:'savezi', ic:'\u{1F3C5}', label:'Savezi', href:'/sport/static/sport2.html#savezi'},
|
||||
{id:'klubovi', ic:'⬢', label:'Klubovi', href:'/sport/static/sport2.html#klubovi'},
|
||||
{id:'sportasi', ic:'\u{1F464}', label:'Sportaši', href:'/sport/static/sport2.html#sportasi'},
|
||||
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije', href:'/sport/static/sport2.html#manifestacije'}
|
||||
]},
|
||||
{title:'OPERATIVA', items: [
|
||||
{id:'profil', ic:'\u{1F464}', label:'Moj profil', href:'/sport/app#profil'},
|
||||
{id:'app', ic:'\u{1F4F1}', label:'Aplikacija', href:'/sport/app'},
|
||||
{id:'kalendar', ic:'\u{1F4C5}', label:'Kalendar', href:'/sport/app#kalendar'},
|
||||
{id:'notif', ic:'\u{1F514}', label:'Notifikacije', href:'/sport/app#notif'}
|
||||
]},
|
||||
{title:'CRM', items: [
|
||||
{id:'clanarine', ic:'\u{1F4B3}', label:'Članarine', href:'/sport/crm#clanarine'},
|
||||
{id:'lijecnicki',ic:'⚕', label:'Liječnički', href:'/sport/crm#lijecnicki'},
|
||||
{id:'obrasci', ic:'\u{1F4CB}', label:'Obrasci', href:'/sport/crm#obrasci'},
|
||||
{id:'dokumenti', ic:'\u{1F4C4}', label:'Dokumenti', href:'/sport/crm#dokumenti'}
|
||||
]},
|
||||
{title:'ERP', items: [
|
||||
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)', href:'/sport/erp#racuni'},
|
||||
{id:'putni', ic:'✈', label:'Putni nalozi', href:'/sport/erp#putni'},
|
||||
{id:'placanja', ic:'\u{1F4B0}', label:'Plaćanja', href:'/sport/erp#placanja'},
|
||||
{id:'xlsx', ic:'\u{1F4C8}', label:'XLSX export', href:'/sport/erp#xlsx'}
|
||||
]},
|
||||
{title:'ANALITIKA', items: [
|
||||
{id:'kpi', ic:'\u{1F4C8}', label:'KPI Dashboard', href:'/sport/kpi'},
|
||||
{id:'financije', ic:'€', label:'Financije', href:'/sport/static/sport2.html#financije'},
|
||||
{id:'mreza', ic:'\u{1F578}', label:'Mreža (graf)', href:'/sport/static/sport2.html#mreza'},
|
||||
{id:'forenzika', ic:'⚠', label:'Forenzika', href:'/sport/static/sport2.html#forenzika'},
|
||||
{id:'audit', ic:'\u{1F512}', label:'Audit log', href:'/sport/audit'}
|
||||
]},
|
||||
{title:'ADMIN', requireRole:['pgz_admin','super_admin'], items: [
|
||||
{id:'korisnici', ic:'\u{1F465}', label:'Korisnici', href:'/sport/admin#korisnici'},
|
||||
{id:'tenanti', ic:'\u{1F3E2}', label:'Tenanti', href:'/sport/admin#tenanti'},
|
||||
{id:'sigurnost', ic:'\u{1F6E1}', label:'Sigurnost', href:'/sport/admin#sigurnost'},
|
||||
{id:'sustav', ic:'⚙', label:'Sustav', href:'/sport/admin#sustav'}
|
||||
]}
|
||||
];
|
||||
|
||||
const STATE_KEY = 'sidebarCollapsed'; // shared across all pages
|
||||
const STATE_KEY = 'sidebarCollapsed';
|
||||
const $ = (s, root) => (root||document).querySelector(s);
|
||||
const esc = s => String(s==null?'':s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
|
||||
function readToken(){
|
||||
try { return localStorage.getItem('jwt') || localStorage.getItem('access_token') || ''; }
|
||||
@@ -38,7 +70,11 @@
|
||||
}
|
||||
function logout(){
|
||||
if(!confirm('Odjava iz aplikacije?')) return;
|
||||
try { localStorage.removeItem('jwt'); localStorage.removeItem('access_token'); localStorage.removeItem('app-role'); } catch(e){}
|
||||
try {
|
||||
localStorage.removeItem('jwt');
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('app-role');
|
||||
} catch(e){}
|
||||
location.href = '/sport/login';
|
||||
}
|
||||
function initials(n){
|
||||
@@ -46,55 +82,79 @@
|
||||
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]));
|
||||
|
||||
// Determine current page intent
|
||||
function getActiveKey(){
|
||||
const cs = document.currentScript || Array.from(document.scripts).find(s => /sidebar\.js/.test(s.src||''));
|
||||
return (cs && cs.dataset && cs.dataset.active) || '';
|
||||
}
|
||||
|
||||
// Try to read /api/auth/me for footer display (best effort)
|
||||
async function tryLoadMe(){
|
||||
const tok = readToken(); if(!tok) return null;
|
||||
function userCanSeeSection(sec, user){
|
||||
if(!sec.requireRole || !sec.requireRole.length) return true;
|
||||
if(!user) return false;
|
||||
return sec.requireRole.indexOf(user.user_type) >= 0;
|
||||
}
|
||||
|
||||
function renderSections(activeKey, user){
|
||||
const out = [];
|
||||
for(const sec of SIDEBAR_SECTIONS){
|
||||
if(!userCanSeeSection(sec, user)) continue;
|
||||
out.push(`<div class="pgz-sb-section ${sec.title==='ADMIN'?'pgz-admin':''}">${esc(sec.title)}</div>`);
|
||||
for(const it of sec.items){
|
||||
const active = it.id === activeKey;
|
||||
const cross = it.href && it.href.startsWith('/sport/') && !sameOrigin(it.href);
|
||||
out.push(`<a class="pgz-nav-i ${active?'active':''} ${cross?'cross-portal':''}"
|
||||
href="${esc(it.href)}" data-id="${esc(it.id)}" data-label="${esc(it.label)}">
|
||||
<span class="ic">${it.ic||'•'}</span>
|
||||
<span class="lbl">${esc(it.label)}</span>
|
||||
${it.badge?`<span class="badge">${esc(it.badge)}</span>`:''}
|
||||
</a>`);
|
||||
}
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
// is this href same as current page (just hash navigation)?
|
||||
function sameOrigin(href){
|
||||
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; }
|
||||
const u = new URL(href, location.href);
|
||||
return u.origin === location.origin && u.pathname === location.pathname;
|
||||
} catch(e){ return false; }
|
||||
}
|
||||
|
||||
function renderShell(activeKey, internalNavHTML){
|
||||
function renderShell(activeKey, user){
|
||||
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-mark">PGŽ</div>
|
||||
<div class="pgz-htxt">
|
||||
<div class="pgz-logo">PGŽ <span class="g">SPORT</span></div>
|
||||
<div class="pgz-sub">Odjel za sport</div>
|
||||
</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">
|
||||
<nav class="pgz-sb-nav" id="pgz-sb-nav">${renderSections(activeKey, user)}</nav>
|
||||
<div class="pgz-sb-foot" id="pgz-sb-foot" onclick="PGZSidebar.toggleUserMenu(event)">
|
||||
<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 class="caret">▾</div>
|
||||
<div class="pgz-user-menu" id="pgz-user-menu" onclick="event.stopPropagation()">
|
||||
<a href="/sport/app#profil"><span>👤</span><span>Moj profil</span></a>
|
||||
<a href="/sport/app#postavke"><span>⚙</span><span>Postavke</span></a>
|
||||
<a href="/sport/static/sport2.html"><span>🌐</span><span>Public portal</span></a>
|
||||
<div class="sep"></div>
|
||||
<a href="/sport/login" id="pgz-menu-login"><span>🔑</span><span>Prijava</span></a>
|
||||
<a class="danger" id="pgz-menu-logout" onclick="PGZSidebar.logout()" style="display:none"><span>⎋</span><span>Odjava</span></a>
|
||||
</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');
|
||||
@@ -106,22 +166,28 @@
|
||||
}
|
||||
|
||||
function setUserDisplay(me){
|
||||
const un = $('#pgz-sb-un'), ur = $('#pgz-sb-ur'), av = $('#pgz-sb-av');
|
||||
const loginLink = document.getElementById('pgz-menu-login');
|
||||
const logoutLink = document.getElementById('pgz-menu-logout');
|
||||
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 = '?');
|
||||
if(un) un.textContent = 'Gost';
|
||||
if(ur) ur.textContent = 'Klikni za prijavu';
|
||||
if(av){ av.textContent = '?'; av.innerHTML = av.innerHTML; }
|
||||
if(loginLink) loginLink.style.display = 'flex';
|
||||
if(logoutLink) logoutLink.style.display = 'none';
|
||||
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);
|
||||
const avSrc = me.avatar_url || me.google_picture;
|
||||
if(un) un.textContent = name;
|
||||
if(ur) ur.textContent = role;
|
||||
if(av){
|
||||
if(avSrc) av.innerHTML = `<img src="${esc(avSrc)}" alt="">`;
|
||||
else av.textContent = initials(name);
|
||||
}
|
||||
if(loginLink) loginLink.style.display = 'none';
|
||||
if(logoutLink) logoutLink.style.display = 'flex';
|
||||
}
|
||||
|
||||
function applyCollapsedFromStorage(){
|
||||
@@ -133,39 +199,42 @@
|
||||
document.body.classList.toggle('pgz-sb-col', col);
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
// ────────── Public API ──────────
|
||||
const PGZSidebar = {
|
||||
NAV_EXTERNAL,
|
||||
SIDEBAR_SECTIONS,
|
||||
|
||||
// Render: insert sidebar shell at document start; if a page provides internalNavHTML, use it
|
||||
mount(opts){
|
||||
async 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 activeKey = opts.activeKey || getActiveKey();
|
||||
const existing = document.getElementById('pgz-sb');
|
||||
if(existing) existing.remove();
|
||||
const sb = renderShell(activeKey, internalNavHTML);
|
||||
|
||||
// Try /api/auth/me first so ADMIN section is gated correctly
|
||||
const me = await tryLoadMe();
|
||||
const sb = renderShell(activeKey, me);
|
||||
document.body.insertBefore(sb, document.body.firstChild);
|
||||
document.body.classList.add('pgz-has-sb');
|
||||
renderBurger();
|
||||
applyCollapsedFromStorage();
|
||||
tryLoadMe().then(setUserDisplay);
|
||||
},
|
||||
setUserDisplay(me);
|
||||
|
||||
// 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));
|
||||
// listen for hashchange to update active item without reload
|
||||
window.addEventListener('hashchange', () => {
|
||||
const h = location.hash.replace(/^#/,'');
|
||||
if(!h) return;
|
||||
document.querySelectorAll('#pgz-sb .pgz-nav-i').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.id===h);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
toggle(){
|
||||
@@ -180,7 +249,6 @@
|
||||
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();
|
||||
@@ -195,20 +263,36 @@
|
||||
sb.classList.remove('pgz-mobile-open');
|
||||
document.body.classList.remove('pgz-mobile-sb-open');
|
||||
},
|
||||
toggleUserMenu(ev){
|
||||
ev && ev.stopPropagation();
|
||||
const f = document.getElementById('pgz-sb-foot');
|
||||
const m = document.getElementById('pgz-user-menu');
|
||||
if(!f || !m) return;
|
||||
const open = f.classList.toggle('menu-open');
|
||||
m.classList.toggle('open', open);
|
||||
if(open){
|
||||
const closer = (e) => {
|
||||
if(!f.contains(e.target)){
|
||||
f.classList.remove('menu-open');
|
||||
m.classList.remove('open');
|
||||
document.removeEventListener('click', closer, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closer, true), 50);
|
||||
}
|
||||
},
|
||||
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
|
||||
const cs = document.currentScript;
|
||||
const inline = cs && cs.dataset && cs.dataset.inline === '1';
|
||||
if(!inline){
|
||||
if(document.readyState === 'loading'){
|
||||
document.addEventListener('DOMContentLoaded', () => PGZSidebar.mount({}));
|
||||
} else {
|
||||
PGZSidebar.mount({});
|
||||
}
|
||||
}
|
||||
autoMount();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user