Files
pgz-sport/static/dokumenti.html
T
damir f7b5114f58 PDF link target=_blank + nginx timeouts + priority filteri (samo s podacima)
nginx (sport.rinet.one):
- proxy_read_timeout 60s → 300s
- proxy_send_timeout 300s
- proxy_buffering off (PDF stream)
- client_max_body_size 50M → 100M

Endpoints:
- /api/v2/klubovi/financirani: +with_data filter (samo s potporama/godišnjakom/HNS)
- /api/v2/sportasi/filtered: +samo_priority +samo_s_hns

Frontend:
- PDF link target=_blank rel=noopener
- window._klub_only_priority = true (default)
- window._sportas_only_priority = true (default)

DB View:
- pgz_sport.v_nogomet_priority (prima_potpore, u_godisnjaku, ima_hns_roster)
2026-05-05 13:51:07 +02:00

932 lines
41 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>
<!--
PGŽ Sport — Dokumenti UI v1.1
dradulic@outlook.com / damir@rinet.one — 2026-05-05
Library svih dokumenata: godišnjaci, publikacije, pravilnici, javni pozivi,
+ unified "Svi dokumenti" view (tip × izdavatelj) preko /api/v2/dokumenti/unified.
Tabovi + filteri + drill-down modal s RAG citatima + inline PDF iframe + XLSX export.
-->
<html lang="hr">
<head>
<meta charset="UTF-8">
<title>Dokumenti — PGŽ Sport</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="data:,">
<style>
:root { --bg0:#08090e; --bg1:#11141d; --bg2:#1a1f2c; --bg3:#232a3d;
--txt:#e6e9ef; --muted:#7a8294; --line:#1a1f2c;
--pgz-blue:#003087; --pgz-blue-l:#0040b8; --pgz-gold:#F4C430;
--green:#1a8754; --red:#dc3545; --orange:#fd7e14; --cyan:#5fa8d3; }
* { box-sizing:border-box; margin:0; padding:0; }
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg0); color:var(--txt);
padding:20px; line-height:1.5; min-height:100vh; }
a { color:var(--cyan); }
h1 { color:var(--pgz-gold); margin-bottom:4px; font-size:1.6rem; }
h2 { color:var(--pgz-gold); margin:0 0 10px 0; font-size:1.1rem; }
h3 { color:var(--txt); margin:0 0 6px 0; font-size:0.98rem; }
.sub { color:var(--muted); margin-bottom:18px; font-size:0.92rem; }
/* Top toolbar — global search + export */
.top-toolbar { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.top-toolbar .global-search { flex:1; min-width:280px; position:relative; }
.top-toolbar .global-search input {
width:100%; padding:11px 14px 11px 38px; border-radius:8px; border:1px solid #2a3144;
background:var(--bg1); color:var(--txt); font-size:0.95rem;
}
.top-toolbar .global-search::before {
content:"🔎"; position:absolute; left:12px; top:9px; opacity:0.7;
}
.btn { background:var(--pgz-blue); color:white; border:none; padding:10px 16px;
border-radius:6px; cursor:pointer; font-weight:500; font-size:0.92rem;
transition:background 0.15s; }
.btn:hover { background:var(--pgz-blue-l); }
.btn.secondary { background:var(--bg2); color:var(--txt); border:1px solid #2a3144; }
.btn.secondary:hover { background:var(--bg3); }
.btn.gold { background:var(--pgz-gold); color:#000; }
.btn.gold:hover { background:#ffe066; }
.btn.sm { padding:6px 11px; font-size:0.84rem; }
.btn:disabled { opacity:0.4; cursor:not-allowed; }
/* Tabbar */
.tabbar { display:flex; gap:2px; border-bottom:2px solid #2a3144; margin-bottom:14px;
overflow-x:auto; -webkit-overflow-scrolling:touch; }
.tab { padding:11px 18px; cursor:pointer; color:var(--muted); border:none; background:none;
font-size:0.95rem; font-weight:500; border-bottom:3px solid transparent;
margin-bottom:-2px; white-space:nowrap; transition:all 0.15s; }
.tab:hover { color:var(--txt); }
.tab.active { color:var(--pgz-gold); border-bottom-color:var(--pgz-gold); }
.tab .count { color:var(--muted); font-size:0.78rem; margin-left:6px;
background:var(--bg2); padding:1px 7px; border-radius:9px; }
.tab.active .count { color:var(--pgz-gold); }
/* Filters bar */
.filters { display:flex; gap:12px; flex-wrap:wrap; align-items:center;
background:var(--bg1); padding:12px 14px; border-radius:8px; margin-bottom:14px; }
.filter-grp { display:flex; gap:8px; align-items:center; font-size:0.88rem; }
.filter-grp label { color:var(--muted); }
.filter-grp input[type=number], .filter-grp input[type=text], .filter-grp select {
background:var(--bg2); color:var(--txt); border:1px solid #2a3144; padding:7px 9px;
border-radius:5px; font-size:0.88rem; min-width:90px;
}
.filter-grp input[type=text] { min-width:200px; }
.multiselect { position:relative; }
.multiselect-btn {
background:var(--bg2); border:1px solid #2a3144; padding:7px 10px; border-radius:5px;
color:var(--txt); cursor:pointer; min-width:160px; text-align:left; font-size:0.88rem;
}
.multiselect-pop {
position:absolute; top:100%; left:0; margin-top:4px; background:var(--bg2);
border:1px solid #2a3144; border-radius:6px; padding:6px; z-index:50;
max-height:240px; overflow-y:auto; min-width:200px; display:none;
box-shadow:0 4px 24px rgba(0,0,0,0.5);
}
.multiselect-pop.open { display:block; }
.multiselect-pop label {
display:flex; align-items:center; gap:8px; padding:5px 7px; border-radius:4px;
cursor:pointer; font-size:0.88rem;
}
.multiselect-pop label:hover { background:var(--bg3); }
/* Cards grid */
.grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(260px, 1fr));
gap:14px; }
.card {
background:var(--bg1); border:1px solid #2a3144; border-radius:10px; padding:14px;
cursor:pointer; transition:transform 0.15s, border-color 0.15s, box-shadow 0.15s;
display:flex; flex-direction:column; gap:8px; min-height:160px;
}
.card:hover {
transform:translateY(-2px); border-color:var(--pgz-gold);
box-shadow:0 6px 24px rgba(244,196,48,0.12);
}
.card .cardyear {
font-size:1.7rem; font-weight:700; color:var(--pgz-gold); line-height:1; letter-spacing:-1px;
}
.card .cardvrsta {
display:inline-block; font-size:0.72rem; padding:2px 8px; border-radius:9px;
background:rgba(0,48,135,0.5); color:#aac8ff; align-self:flex-start; text-transform:uppercase;
letter-spacing:0.5px; font-weight:600;
}
.card .cardtitle {
font-size:0.95rem; font-weight:600; color:var(--txt); flex:1; line-height:1.3;
}
.card .cardmeta { font-size:0.78rem; color:var(--muted); display:flex; gap:10px; }
.card .cardmeta span { display:flex; align-items:center; gap:3px; }
/* Loading & empty */
.empty { padding:60px 20px; text-align:center; color:var(--muted); font-size:0.95rem; }
.loading { padding:30px; text-align:center; color:var(--muted); }
.loading::before {
content:""; display:inline-block; width:18px; height:18px;
border:2px solid var(--bg3); border-top-color:var(--pgz-gold);
border-radius:50%; animation:spin 0.8s linear infinite; margin-right:8px;
vertical-align:middle;
}
@keyframes spin { to { transform:rotate(360deg); } }
/* Search results table */
.search-results { background:var(--bg1); border-radius:8px; overflow:hidden; }
.search-results .res-row {
padding:14px 18px; border-bottom:1px solid var(--line); cursor:pointer;
transition:background 0.15s;
}
.search-results .res-row:hover { background:var(--bg2); }
.search-results .res-row:last-child { border-bottom:none; }
.search-results .res-title { font-weight:600; color:var(--pgz-gold); }
.search-results .res-meta { font-size:0.8rem; color:var(--muted); margin:3px 0 6px 0; }
.search-results .res-excerpt {
font-size:0.88rem; color:#cdd2dc; background:var(--bg2); padding:8px 11px;
border-radius:5px; border-left:3px solid var(--cyan);
}
.search-results mark {
background:var(--pgz-gold); color:#000; padding:1px 3px; border-radius:3px;
}
/* Modal */
.modal-bg {
position:fixed; inset:0; background:rgba(0,0,0,0.7); backdrop-filter:blur(4px);
z-index:100; display:none; align-items:flex-start; justify-content:center;
padding:40px 20px; overflow-y:auto;
}
.modal-bg.open { display:flex; }
.modal {
background:var(--bg1); border:1px solid #2a3144; border-radius:12px;
width:100%; max-width:1100px; padding:0; overflow:hidden;
box-shadow:0 12px 60px rgba(0,0,0,0.6);
}
.pdf-frame {
width:100%; height:72vh; border:0; background:#0a0c12; border-radius:6px;
}
.pdf-fallback {
padding:30px; text-align:center; color:var(--muted);
background:var(--bg2); border-radius:6px;
}
.modal-h {
padding:18px 22px; border-bottom:1px solid var(--line); display:flex;
align-items:flex-start; justify-content:space-between; gap:12px;
}
.modal-h h2 { color:var(--pgz-gold); font-size:1.15rem; flex:1; }
.modal-h .close {
background:none; border:none; color:var(--muted); font-size:1.5rem; cursor:pointer;
padding:0 4px; line-height:1;
}
.modal-h .close:hover { color:var(--txt); }
.modal-tabs { display:flex; gap:0; padding:0 22px; border-bottom:1px solid var(--line); }
.modal-tab {
padding:9px 14px; cursor:pointer; color:var(--muted); border:none; background:none;
font-size:0.9rem; border-bottom:2px solid transparent; margin-bottom:-1px;
}
.modal-tab.active { color:var(--pgz-gold); border-bottom-color:var(--pgz-gold); }
.modal-body { padding:18px 22px; max-height:60vh; overflow-y:auto; }
.modal-actions {
padding:14px 22px; border-top:1px solid var(--line); display:flex; gap:10px; flex-wrap:wrap;
}
.info-row {
display:flex; gap:14px; padding:6px 0; border-bottom:1px dashed #1f2535; font-size:0.92rem;
}
.info-row:last-child { border-bottom:none; }
.info-row .lab { color:var(--muted); min-width:130px; }
.info-row .val { flex:1; word-break:break-word; }
.chip {
display:inline-block; background:var(--bg2); padding:2px 8px; border-radius:9px;
font-size:0.78rem; margin:0 4px 4px 0;
}
.chunk {
background:var(--bg2); padding:10px 14px; border-radius:6px; margin-bottom:10px;
border-left:3px solid var(--cyan); font-size:0.88rem; line-height:1.55;
}
.chunk .chunk-meta { font-size:0.75rem; color:var(--muted); margin-bottom:5px; }
/* Inline scoped search */
.inline-search {
background:var(--bg2); padding:12px 14px; border-radius:6px; margin-bottom:14px;
display:flex; gap:8px; align-items:center;
}
.inline-search input {
flex:1; background:var(--bg1); color:var(--txt); border:1px solid #2a3144;
padding:7px 11px; border-radius:5px; font-size:0.9rem;
}
/* Sidebar offset */
body.pgz-has-sb { padding-left:280px; }
body.pgz-has-sb.pgz-sb-col { padding-left:80px; }
@media (max-width:900px) {
body.pgz-has-sb { padding-left:20px; }
body.pgz-has-sb.pgz-sb-col { padding-left:20px; }
}
</style>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="dokumentilib"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js" defer></script>
</head>
<body>
<h1>📚 Dokumenti</h1>
<div class="sub">Knjižnica svih sportskih publikacija PGŽ — godišnjaci ZSPGZ, pravilnici, programi, izvještaji. Klik na dokument otvara PDF + RAG citate.</div>
<div class="top-toolbar">
<div class="global-search">
<input id="globalQ" type="search" placeholder="Pretraži kroz cjelokupni sadržaj svih dokumenata (npr. „Andrijaševic", mladi", proračun")">
</div>
<button class="btn" onclick="globalSearch()">Pretraži</button>
<button class="btn secondary" id="btnExport" onclick="exportXLSX()">📥 XLSX</button>
</div>
<div class="tabbar" id="tabbar"></div>
<div id="filters-wrap"></div>
<div id="content"></div>
<!-- Drill-down modal -->
<div class="modal-bg" id="modal" onclick="if(event.target===this) closeModal()">
<div class="modal" id="modalInner">
<div class="modal-h">
<h2 id="mTitle"></h2>
<button class="close" onclick="closeModal()">×</button>
</div>
<div class="modal-tabs">
<button class="modal-tab active" data-mtab="info" onclick="setModalTab('info')">️ Info</button>
<button class="modal-tab" data-mtab="pdf" onclick="setModalTab('pdf')">📄 PDF</button>
<button class="modal-tab" data-mtab="search" onclick="setModalTab('search')">🔎 Pretraži sadržaj</button>
<button class="modal-tab" data-mtab="citati" onclick="setModalTab('citati')">📎 Citati</button>
</div>
<div class="modal-body" id="mBody"></div>
<div class="modal-actions" id="mActions"></div>
</div>
</div>
<script>
'use strict';
// ─────────────────────────────────────────
// Tab definitions (vrste mapping)
// ─────────────────────────────────────────
const TABS = [
{ id:'svi', label:'🗂️ Svi dokumenti',
unified:true, view:'cards' },
{ id:'godisnjaci', label:'📅 Godišnjaci ZSPGZ',
vrste:['godisnjak'], view:'cards-year' },
{ id:'publikacije', label:'📰 Publikacije',
vrste:['program','plan','strategija','izvjestaj','raspodjela','erasmus'], view:'cards' },
{ id:'pravilnici', label:'📜 Pravilnici',
vrste:['pravilnik','statut','odluka','zakon'], view:'cards' },
{ id:'javni', label:'📢 Javni pozivi',
vrste:['manifestacija'], view:'cards', titleLike:['javni','natje','poziv','potpor'] },
{ id:'ostalo', label:'📂 Ostalo',
vrsteExclude:['godisnjak','program','plan','strategija','izvjestaj','raspodjela','erasmus',
'pravilnik','statut','odluka','zakon','manifestacija',
'corpus','corpus_v2','corpus_v3','novost_savez','audit','godisnjak_facts'],
view:'cards' },
];
// Unified library — derived classifications from the API
const TIP_OPTIONS = [
{ v:'', l:'Svi tipovi' },
{ v:'godisnjak', l:'Godišnjak' },
{ v:'publikacija', l:'Publikacija' },
{ v:'pravilnik', l:'Pravilnik / Statut / Zakon' },
{ v:'javni-poziv', l:'Javni poziv' },
{ v:'natjecaj', l:'Natjecaj' },
{ v:'ostalo', l:'Ostalo' },
];
const IZDAVATELJ_OPTIONS = [
{ v:'', l:'Svi izdavatelji' },
{ v:'ZSPGZ', l:'Zajednica sportova PGŽ' },
{ v:'PGŽ', l:'PGŽ (Županija)' },
{ v:'RSS', l:'Riječki sportski savez' },
{ v:'HOO', l:'Hrvatski olimpijski odbor' },
{ v:'JLS', l:'Grad / Općina (JLS)' },
{ v:'savez', l:'Nacionalni sportski savez' },
{ v:'klub', l:'Klub' },
{ v:'ostalo',l:'Ostalo' },
];
const VRSTE_HIDDEN = ['corpus','corpus_v2','corpus_v3','novost_savez','audit','godisnjak_facts'];
const API = '/sport/api/v2'; // hosted under /sport/ via reverse proxy
// Fallback: when running directly on :8095, /sport prefix is stripped — try both
const API_DIRECT = '/api/v2';
// ─────────────────────────────────────────
// State
// ─────────────────────────────────────────
let activeTab = 'svi';
let allDocs = []; // all loaded docs (cached after first fetch)
let unifiedDocs = []; // separately cached unified-view docs (with tip/izdavatelj)
let unifiedFacets = null; // counts {by_tip, by_izdavatelj}
let displayDocs = []; // currently filtered/visible
let lastSearchHits = null; // for export of search results
let currentDocId = null;
let currentDocFull = null;
const filterState = {}; // per-tab filter state
const unifiedState = { tip:'', izdavatelj:'', q:'', godinaMin:null, godinaMax:null };
// ─────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────
const $ = (s, root) => (root||document).querySelector(s);
const $$ = (s, root) => Array.from((root||document).querySelectorAll(s));
const esc = s => String(s==null?'':s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
function fmtDate(d){
if(!d) return '—';
try { return new Date(d).toLocaleDateString('hr-HR'); } catch(e){ return d; }
}
async function api(path, opts){
// Try /sport/api/v2 first, fall back to /api/v2 (same-origin direct)
try {
const r = await fetch(API + path, opts);
if(r.ok || r.status === 404) return r;
} catch(e) {}
return await fetch(API_DIRECT + path, opts);
}
function highlightTerm(text, q){
if(!text || !q) return esc(text||'');
const safe = esc(text);
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return safe.replace(re, '<mark>$1</mark>');
}
// ─────────────────────────────────────────
// Initial render
// ─────────────────────────────────────────
function renderTabs(){
const html = TABS.map(t => `
<button class="tab ${t.id===activeTab?'active':''}" data-tab="${t.id}" onclick="setTab('${t.id}')">
${t.label}<span class="count" id="cnt-${t.id}">…</span>
</button>`).join('') +
`<button class="tab" data-tab="search" id="tab-search" style="display:none" onclick="setTab('search')">
🔎 Rezultati pretrage<span class="count" id="cnt-search">0</span>
</button>`;
$('#tabbar').innerHTML = html;
}
function setTab(id){
activeTab = id;
$$('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab===id));
if(id==='search'){
renderSearchView();
} else if(id==='svi'){
renderUnifiedFilters();
loadUnified();
} else {
renderFilters();
applyFilters();
}
}
// ─────────────────────────────────────────
// Unified mode (Svi dokumenti)
// ─────────────────────────────────────────
function renderUnifiedFilters(){
const tipSel = TIP_OPTIONS.map(o =>
`<option value="${esc(o.v)}" ${unifiedState.tip===o.v?'selected':''}>${esc(o.l)}</option>`).join('');
const izdSel = IZDAVATELJ_OPTIONS.map(o =>
`<option value="${esc(o.v)}" ${unifiedState.izdavatelj===o.v?'selected':''}>${esc(o.l)}</option>`).join('');
$('#filters-wrap').innerHTML = `
<div class="filters">
<div class="filter-grp">
<label>Tip:</label>
<select id="uTip" onchange="onUnifiedFilterChange()">${tipSel}</select>
</div>
<div class="filter-grp">
<label>Izdavatelj:</label>
<select id="uIzd" onchange="onUnifiedFilterChange()">${izdSel}</select>
</div>
<div class="filter-grp">
<label>Godina:</label>
<input type="number" id="uYmin" value="${unifiedState.godinaMin||''}" placeholder="od" min="1990" max="2030" onchange="onUnifiedFilterChange()" style="width:80px">
<span></span>
<input type="number" id="uYmax" value="${unifiedState.godinaMax||''}" placeholder="do" min="1990" max="2030" onchange="onUnifiedFilterChange()" style="width:80px">
</div>
<div class="filter-grp">
<label>Pretraga:</label>
<input type="text" id="uQ" value="${esc(unifiedState.q)}" placeholder="naziv / opis / organizacija..." oninput="onUnifiedQDebounce()">
</div>
<div style="margin-left:auto;color:var(--muted);font-size:0.85rem;" id="filterCount">…</div>
</div>`;
}
let _uQTimer = null;
function onUnifiedQDebounce(){
clearTimeout(_uQTimer);
_uQTimer = setTimeout(() => {
unifiedState.q = $('#uQ').value || '';
loadUnified();
}, 350);
}
function onUnifiedFilterChange(){
unifiedState.tip = $('#uTip').value || '';
unifiedState.izdavatelj = $('#uIzd').value || '';
unifiedState.godinaMin = parseInt($('#uYmin').value) || null;
unifiedState.godinaMax = parseInt($('#uYmax').value) || null;
unifiedState.q = $('#uQ').value || '';
loadUnified();
}
async function loadUnified(){
$('#content').innerHTML = '<div class="loading">Učitavam dokumente…</div>';
const params = new URLSearchParams();
if(unifiedState.tip) params.set('tip', unifiedState.tip);
if(unifiedState.izdavatelj) params.set('izdavatelj', unifiedState.izdavatelj);
if(unifiedState.q) params.set('q', unifiedState.q);
if(unifiedState.godinaMin) params.set('godina_min', unifiedState.godinaMin);
if(unifiedState.godinaMax) params.set('godina_max', unifiedState.godinaMax);
params.set('limit', 500);
try {
const r = await api('/dokumenti/unified?' + params.toString());
const j = await r.json();
unifiedDocs = j.dokumenti || [];
displayDocs = unifiedDocs;
$('#filterCount') && ($('#filterCount').textContent = `${unifiedDocs.length} dokumenata`);
renderUnifiedContent(unifiedDocs);
} catch(e){
$('#content').innerHTML = `<div class="empty">⚠️ Greška: ${esc(e.message)}</div>`;
}
}
function renderUnifiedContent(docs){
if(docs.length === 0){
$('#content').innerHTML = `<div class="empty">Nema dokumenata za odabrane filtere.</div>`;
return;
}
const html = docs.map(d => unifiedCardHTML(d)).join('');
$('#content').innerHTML = `<div class="grid">${html}</div>`;
}
function unifiedCardHTML(d){
const meta = [];
if(d.izdavatelj) meta.push(`<span class="chip" style="background:rgba(0,48,135,0.5);color:#aac8ff">${esc(d.izdavatelj)}</span>`);
if(d.organizacija) meta.push(`<span style="color:var(--muted)">🏛️ ${esc(d.organizacija.slice(0,28))}${d.organizacija.length>28?'…':''}</span>`);
if(d.izdano_datum) meta.push(`<span style="color:var(--muted)">📅 ${fmtDate(d.izdano_datum)}</span>`);
else if(d.godina) meta.push(`<span style="color:var(--muted)">📅 ${d.godina}</span>`);
return `
<div class="card" onclick="openModal(${d.id})">
<span class="cardvrsta">${esc(d.tip||d.vrsta||'—')}</span>
<div class="cardtitle">${esc(d.title || '(bez naslova)')}</div>
<div class="cardmeta" style="flex-wrap:wrap;gap:6px;">${meta.join('')}</div>
</div>`;
}
// ─────────────────────────────────────────
// Filters UI
// ─────────────────────────────────────────
function getTabConfig(){ return TABS.find(t => t.id===activeTab); }
function tabDocs(){
const cfg = getTabConfig();
if(!cfg) return [];
return allDocs.filter(d => {
if(VRSTE_HIDDEN.includes(d.vrsta)) return false;
if(cfg.vrste && !cfg.vrste.includes(d.vrsta)) return false;
if(cfg.vrsteExclude && cfg.vrsteExclude.includes(d.vrsta)) return false;
if(cfg.titleLike){
const t = (d.title||'').toLowerCase();
const desc = (d.kratak_opis||'').toLowerCase();
if(!cfg.titleLike.some(k => t.includes(k) || desc.includes(k))) return false;
}
return true;
});
}
function renderFilters(){
const docs = tabDocs();
const years = docs.map(d => d.godina).filter(Boolean).sort((a,b)=>a-b);
const minY = years[0] || 2000;
const maxY = years[years.length-1] || 2026;
const vrste = [...new Set(docs.map(d => d.vrsta).filter(Boolean))].sort();
const fs = filterState[activeTab] = filterState[activeTab] || {
godinaMin:minY, godinaMax:maxY, vrste:new Set(vrste), q:''
};
// re-clamp on first render
if(fs.godinaMin < minY) fs.godinaMin = minY;
if(fs.godinaMax > maxY) fs.godinaMax = maxY;
$('#filters-wrap').innerHTML = `
<div class="filters">
<div class="filter-grp">
<label>Godina:</label>
<input type="number" id="fyMin" value="${fs.godinaMin}" min="${minY}" max="${maxY}" onchange="onFilterChange()">
<span></span>
<input type="number" id="fyMax" value="${fs.godinaMax}" min="${minY}" max="${maxY}" onchange="onFilterChange()">
</div>
<div class="filter-grp">
<label>Vrsta:</label>
<div class="multiselect">
<button class="multiselect-btn" id="msBtn" onclick="toggleMS(event)">${msLabel(fs.vrste, vrste)}</button>
<div class="multiselect-pop" id="msPop">
${vrste.map(v => `
<label><input type="checkbox" data-vrsta="${esc(v)}" ${fs.vrste.has(v)?'checked':''} onchange="onMSChange()"> ${esc(v)}</label>
`).join('')}
</div>
</div>
</div>
<div class="filter-grp">
<label>U tabu:</label>
<input type="text" id="fq" value="${esc(fs.q)}" placeholder="filter po nazivu/opisu..." oninput="onFilterChange()">
</div>
<div style="margin-left:auto;color:var(--muted);font-size:0.85rem;" id="filterCount">…</div>
</div>`;
}
function msLabel(setVal, all){
if(setVal.size === 0) return 'Ništa odabrano';
if(setVal.size === all.length) return `Sve vrste (${all.length})`;
return `${setVal.size} odabrano`;
}
function toggleMS(ev){
ev.stopPropagation();
const pop = $('#msPop');
const isOpen = pop.classList.toggle('open');
if(isOpen){
setTimeout(() => {
const closer = (e) => {
if(!pop.contains(e.target) && e.target.id !== 'msBtn'){
pop.classList.remove('open');
document.removeEventListener('click', closer);
}
};
document.addEventListener('click', closer);
}, 50);
}
}
function onMSChange(){
const fs = filterState[activeTab];
fs.vrste = new Set($$('#msPop input:checked').map(i => i.dataset.vrsta));
const docs = tabDocs();
const allVrste = [...new Set(docs.map(d => d.vrsta))];
$('#msBtn').textContent = msLabel(fs.vrste, allVrste);
applyFilters();
}
function onFilterChange(){
const fs = filterState[activeTab];
fs.godinaMin = parseInt($('#fyMin').value) || fs.godinaMin;
fs.godinaMax = parseInt($('#fyMax').value) || fs.godinaMax;
fs.q = $('#fq').value || '';
applyFilters();
}
// ─────────────────────────────────────────
// Apply filters → render content
// ─────────────────────────────────────────
function applyFilters(){
const cfg = getTabConfig();
const fs = filterState[activeTab];
let docs = tabDocs();
if(fs){
docs = docs.filter(d => {
if(d.godina != null){
if(d.godina < fs.godinaMin || d.godina > fs.godinaMax) return false;
} else {
// no year — only show if range covers full span (i.e., user hasn't narrowed)
}
if(fs.vrste && fs.vrste.size > 0 && !fs.vrste.has(d.vrsta)) return false;
if(fs.q){
const ql = fs.q.toLowerCase();
const hit = (d.title||'').toLowerCase().includes(ql) ||
(d.kratak_opis||'').toLowerCase().includes(ql) ||
(d.organizacija||'').toLowerCase().includes(ql);
if(!hit) return false;
}
return true;
});
}
// Sort: godisnjaci by year DESC; ostalo by izdano_datum DESC, then godina DESC
if(cfg && cfg.view === 'cards-year'){
docs.sort((a,b)=> (b.godina||0) - (a.godina||0));
} else {
docs.sort((a,b) => {
const ay = a.godina || 0, by = b.godina || 0;
if(ay !== by) return by - ay;
return (a.title||'').localeCompare(b.title||'', 'hr');
});
}
displayDocs = docs;
$('#filterCount') && ($('#filterCount').textContent = `${docs.length} dokumenata`);
renderContent(docs);
}
function renderContent(docs){
if(docs.length === 0){
$('#content').innerHTML = `<div class="empty">Nema dokumenata za odabrane filtere.</div>`;
return;
}
const cfg = getTabConfig();
const html = docs.map(d => cardHTML(d, cfg && cfg.view === 'cards-year')).join('');
$('#content').innerHTML = `<div class="grid">${html}</div>`;
}
function cardHTML(d, yearStyle){
const year = d.godina ? `<div class="cardyear">${d.godina}</div>` : '';
const meta = [];
if(d.organizacija) meta.push(`<span>🏛️ ${esc(d.organizacija.slice(0,30))}${d.organizacija.length>30?'…':''}</span>`);
if(d.izdano_datum) meta.push(`<span>📅 ${fmtDate(d.izdano_datum)}</span>`);
if(d.chars) meta.push(`<span>📝 ${(d.chars/1000).toFixed(0)}K znakova</span>`);
return `
<div class="card" onclick="openModal(${d.id})">
${yearStyle ? year : ''}
<span class="cardvrsta">${esc(d.vrsta||'—')}</span>
<div class="cardtitle">${esc(d.title || '(bez naslova)')}</div>
<div class="cardmeta">${meta.join('')}</div>
</div>`;
}
// ─────────────────────────────────────────
// Data load
// ─────────────────────────────────────────
async function loadAll(){
// Load both legacy list (for the 4 vrsta-tabs) and unified facets (for "Svi" badge).
$('#content').innerHTML = '<div class="loading">Učitavam dokumente…</div>';
try {
const [r1, r2] = await Promise.all([
api('/dokumenti?limit=1000'),
api('/dokumenti/facets'),
]);
const j = await r1.json();
allDocs = (j.dokumenti || []).filter(d => !VRSTE_HIDDEN.includes(d.vrsta));
try { unifiedFacets = await r2.json(); } catch(_){ unifiedFacets = null; }
updateTabCounts();
if(activeTab === 'svi'){
renderUnifiedFilters();
loadUnified();
} else {
renderFilters();
applyFilters();
}
} catch(e){
$('#content').innerHTML = `<div class="empty">⚠️ Greška pri učitavanju: ${esc(e.message)}</div>`;
}
}
function updateTabCounts(){
// "svi" tab uses unified facets total; the rest count over allDocs.
for(const t of TABS){
let c = 0;
if(t.id === 'svi'){
if(unifiedFacets && unifiedFacets.by_tip){
c = unifiedFacets.by_tip.reduce((s,x) => s + (x.broj||0), 0);
} else { c = '—'; }
} else {
const old = activeTab; activeTab = t.id;
c = tabDocs().length;
activeTab = old;
}
const el = document.getElementById('cnt-'+t.id);
if(el) el.textContent = c;
}
}
// ─────────────────────────────────────────
// Global search
// ─────────────────────────────────────────
async function globalSearch(){
const q = $('#globalQ').value.trim();
if(!q || q.length < 2){
alert('Upiši minimalno 2 znaka za pretragu.');
return;
}
$('#tab-search').style.display = '';
setTab('search');
$('#content').innerHTML = '<div class="loading">Pretražujem cjelokupni sadržaj…</div>';
$('#filters-wrap').innerHTML = `
<div class="filters">
<div class="filter-grp">
<strong>🔎 Pretraga: </strong><span style="color:var(--pgz-gold)">"${esc(q)}"</span>
</div>
<div style="margin-left:auto;color:var(--muted);font-size:0.85rem;" id="searchInfo">…</div>
</div>`;
try {
const r = await api('/dokumenti/search/q?q=' + encodeURIComponent(q) + '&limit=80');
const j = await r.json();
lastSearchHits = j.rezultati || [];
$('#cnt-search').textContent = lastSearchHits.length;
$('#searchInfo').textContent = `${lastSearchHits.length} pogodaka`;
renderSearchResults(lastSearchHits, q);
} catch(e){
$('#content').innerHTML = `<div class="empty">⚠️ Pretraga neuspjela: ${esc(e.message)}</div>`;
}
}
function renderSearchView(){
if(!lastSearchHits){
$('#content').innerHTML = '<div class="empty">Upiši upit u tražilicu iznad i klikni „Pretraži".</div>';
$('#filters-wrap').innerHTML = '';
return;
}
const q = $('#globalQ').value.trim();
renderSearchResults(lastSearchHits, q);
}
function renderSearchResults(hits, q){
if(hits.length === 0){
$('#content').innerHTML = `<div class="empty">Nema pogodaka za "${esc(q)}".</div>`;
return;
}
const html = hits.map(h => `
<div class="res-row" onclick="openModal(${h.id})">
<div class="res-title">${esc(h.title || '(bez naslova)')}</div>
<div class="res-meta">
<span class="chip">${esc(h.vrsta||'—')}</span>
${h.godina ? '<span class="chip">'+h.godina+'</span>' : ''}
${h.izdano_datum ? '<span class="chip">'+fmtDate(h.izdano_datum)+'</span>' : ''}
</div>
<div class="res-excerpt">${highlightTerm(h.excerpt||'', q)}</div>
</div>`).join('');
$('#content').innerHTML = `<div class="search-results">${html}</div>`;
}
// ─────────────────────────────────────────
// Modal — drill-down
// ─────────────────────────────────────────
async function openModal(id){
currentDocId = id;
currentDocFull = null;
$('#modal').classList.add('open');
$('#mTitle').textContent = 'Učitavam…';
$('#mBody').innerHTML = '<div class="loading">Učitavam detalje…</div>';
$('#mActions').innerHTML = '';
setModalTab('info');
try {
const r = await api(`/dokumenti/${id}/full`);
if(!r.ok){
// Fallback to basic detail
const r2 = await api(`/dokumenti/${id}`);
const j2 = await r2.json();
currentDocFull = { dokument: { ...j2, naziv: j2.title }, chunks: [], chunks_count: 0 };
} else {
currentDocFull = await r.json();
}
const d = currentDocFull.dokument;
$('#mTitle').textContent = d.naziv || d.title || '(bez naslova)';
renderModalActions(d);
setModalTab('info');
} catch(e){
$('#mBody').innerHTML = `<div class="empty">⚠️ Greška: ${esc(e.message)}</div>`;
}
}
function closeModal(){
$('#modal').classList.remove('open');
currentDocId = null; currentDocFull = null;
}
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeModal(); });
function renderModalActions(d){
const pdfUrl = d.pdf_url || `${API}/dokumenti/${d.id}/pdf`;
const fallbackPdf = `${API_DIRECT}/dokumenti/${d.id}/pdf`;
const txtUrl = `${API}/dokumenti/${d.id}/text`;
$('#mActions').innerHTML = `
<a class="btn gold" target="_blank" rel="noopener" href="${esc(pdfUrl)}"
onerror="this.href='${esc(fallbackPdf)}'">📄 Otvori PDF</a>
<a class="btn secondary" target="_blank" rel="noopener" href="${esc(txtUrl)}">📝 Plain text</a>
${d.izvor_url ? `<a class="btn secondary" target="_blank" rel="noopener" href="${esc(d.izvor_url)}">🔗 Izvor</a>` : ''}
<button class="btn secondary" style="margin-left:auto" onclick="closeModal()">Zatvori</button>`;
}
function setModalTab(t){
$$('.modal-tab').forEach(b => b.classList.toggle('active', b.dataset.mtab === t));
if(!currentDocFull){ return; }
const d = currentDocFull.dokument;
if(t === 'info'){
const kr = Array.isArray(d.kljucne_rijeci) ? d.kljucne_rijeci :
(typeof d.kljucne_rijeci === 'string' ? d.kljucne_rijeci.split(/[,;]/).map(s=>s.trim()).filter(Boolean) : []);
$('#mBody').innerHTML = `
<div class="info-row"><div class="lab">Naslov</div><div class="val">${esc(d.naziv||d.title||'—')}</div></div>
${d.kratak_opis ? `<div class="info-row"><div class="lab">Opis</div><div class="val">${esc(d.kratak_opis)}</div></div>` : ''}
<div class="info-row"><div class="lab">Vrsta</div><div class="val"><span class="chip">${esc(d.vrsta||'—')}</span></div></div>
${d.razina ? `<div class="info-row"><div class="lab">Razina</div><div class="val">${esc(d.razina)}</div></div>` : ''}
${d.organizacija ? `<div class="info-row"><div class="lab">Organizacija</div><div class="val">${esc(d.organizacija)}</div></div>` : ''}
${d.sport ? `<div class="info-row"><div class="lab">Sport</div><div class="val">${esc(d.sport)}</div></div>` : ''}
${d.godina ? `<div class="info-row"><div class="lab">Godina</div><div class="val">${esc(d.godina)}</div></div>` : ''}
${d.izdano_datum ? `<div class="info-row"><div class="lab">Izdano</div><div class="val">${fmtDate(d.izdano_datum)}</div></div>` : ''}
${d.sluzbeni_glasnik ? `<div class="info-row"><div class="lab">Službeni glasnik</div><div class="val">${esc(d.sluzbeni_glasnik)}</div></div>` : ''}
${kr.length ? `<div class="info-row"><div class="lab">Ključne riječi</div><div class="val">${kr.map(k=>`<span class="chip">${esc(k)}</span>`).join('')}</div></div>` : ''}
${d.izvor_url ? `<div class="info-row"><div class="lab">Izvor URL</div><div class="val"><a href="${esc(d.izvor_url)}" target="_blank">${esc(d.izvor_url)}</a></div></div>` : ''}
<div class="info-row"><div class="lab">RAG chunks</div><div class="val">${currentDocFull.chunks_count || 0}</div></div>
`;
} else if(t === 'pdf'){
// Inline PDF viewer — try server stream, fall back to external pdf_url, then "open in new tab".
const localPdf = `${API}/dokumenti/${d.id}/pdf`;
const fallbackPdf = `${API_DIRECT}/dokumenti/${d.id}/pdf`;
const externalPdf = d.pdf_url || d.izvor_url || null;
$('#mBody').innerHTML = `
<div style="margin-bottom:8px;display:flex;gap:8px;flex-wrap:wrap;">
<a class="btn gold sm" target="_blank" rel="noopener" href="${esc(localPdf)}">⤴ Otvori u novom tabu</a>
${externalPdf ? `<a class="btn secondary sm" target="_blank" rel="noopener" href="${esc(externalPdf)}">🔗 Originalni izvor</a>` : ''}
</div>
<iframe class="pdf-frame" id="pdfFrame" src="${esc(localPdf)}"
onerror="this.src='${esc(fallbackPdf)}'"></iframe>
<div id="pdfFallback" style="display:none;" class="pdf-fallback">
PDF nije dostupan u inline pregledniku.
${externalPdf ? `Pokušaj <a href="${esc(externalPdf)}" target="_blank">originalni izvor</a>.` : ''}
</div>
`;
// Detect 404 on iframe load — best-effort timeout
setTimeout(()=>{
try {
const fr = $('#pdfFrame');
if(!fr || !fr.contentWindow) return;
// Same-origin? May read; cross-origin we can't introspect — leave as-is.
} catch(_){}
}, 1500);
} else if(t === 'search'){
$('#mBody').innerHTML = `
<div class="inline-search">
<input id="docQ" type="search" placeholder="Pretraži samo ovaj dokument...">
<button class="btn sm" onclick="searchInDoc()">Traži</button>
</div>
<div id="docSearchOut" style="color:var(--muted);font-size:0.9rem;">Upiši pojam i klikni Traži.</div>
`;
setTimeout(()=>$('#docQ').focus(), 50);
} else if(t === 'citati'){
const chunks = currentDocFull.chunks || [];
if(chunks.length === 0){
$('#mBody').innerHTML = '<div class="empty">Ovaj dokument nema RAG chunks.</div>';
return;
}
const limit = 30;
const slice = chunks.slice(0, limit);
$('#mBody').innerHTML = slice.map(c => `
<div class="chunk">
<div class="chunk-meta">Chunk #${c.chunk_index} · ${c.chunk_tokens||'?'} tokens</div>
<div>${esc((c.chunk_text||'').slice(0, 1000))}${(c.chunk_text||'').length>1000?'…':''}</div>
</div>
`).join('') + (chunks.length>limit ?
`<div class="empty">+ još ${chunks.length-limit} chunkova (skip)…</div>` : '');
}
}
async function searchInDoc(){
const q = $('#docQ').value.trim();
if(!q || q.length < 2){ return; }
$('#docSearchOut').innerHTML = '<div class="loading">Pretražujem…</div>';
try {
// Reuse global search and filter klijentski na dokument_id
const r = await api('/dokumenti/search/q?q='+encodeURIComponent(q)+'&limit=80');
const j = await r.json();
const chunks = currentDocFull.chunks || [];
// Local in-doc match: highlight chunks koji sadrže pojam
const matchingChunks = chunks.filter(c => (c.chunk_text||'').toLowerCase().includes(q.toLowerCase()));
if(matchingChunks.length === 0){
$('#docSearchOut').innerHTML = `<div class="empty">Nema pogodaka unutar ovog dokumenta za "${esc(q)}".</div>`;
return;
}
$('#docSearchOut').innerHTML = matchingChunks.slice(0, 20).map(c => `
<div class="chunk">
<div class="chunk-meta">Chunk #${c.chunk_index}</div>
<div>${highlightTerm((c.chunk_text||'').slice(0,1200), q)}${(c.chunk_text||'').length>1200?'…':''}</div>
</div>
`).join('') + (matchingChunks.length > 20 ?
`<div class="empty">+ još ${matchingChunks.length-20} pogodaka…</div>` : '');
} catch(e){
$('#docSearchOut').innerHTML = `<div class="empty">⚠️ ${esc(e.message)}</div>`;
}
}
// ─────────────────────────────────────────
// XLSX export
// ─────────────────────────────────────────
function exportXLSX(){
if(typeof XLSX === 'undefined'){
alert('XLSX biblioteka još učitava — pričekaj sekundu i probaj opet.');
return;
}
const isSearch = activeTab === 'search';
const isUnified = activeTab === 'svi';
const rows = isSearch ? (lastSearchHits || []).map(h => ({
id:h.id, title:h.title, vrsta:h.vrsta, godina:h.godina,
izdano_datum:h.izdano_datum, izvor_url:h.izvor_url,
excerpt:(h.excerpt||'').replace(/\s+/g,' ').slice(0,300)
})) : isUnified ? displayDocs.map(d => ({
id:d.id, title:d.title, tip:d.tip, izdavatelj:d.izdavatelj,
vrsta:d.vrsta, godina:d.godina, organizacija:d.organizacija,
izdano_datum:d.izdano_datum, izvor_url:d.izvor_url, chars:d.chars
})) : displayDocs.map(d => ({
id:d.id, title:d.title, vrsta:d.vrsta, godina:d.godina,
organizacija:d.organizacija, izdano_datum:d.izdano_datum,
izvor_url:d.izvor_url, chars:d.chars
}));
if(rows.length === 0){ alert('Nema redaka za export.'); return; }
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Dokumenti');
const fname = `pgz-dokumenti-${activeTab}-${new Date().toISOString().slice(0,10)}.xlsx`;
XLSX.writeFile(wb, fname);
}
// ─────────────────────────────────────────
// Init
// ─────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
renderTabs();
loadAll();
$('#globalQ').addEventListener('keydown', e => { if(e.key === 'Enter') globalSearch(); });
});
</script>
</body>
</html>