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)
This commit is contained in:
2026-05-05 13:51:07 +02:00
parent c6a5ec62aa
commit f7b5114f58
289 changed files with 37204 additions and 363 deletions
+194 -14
View File
@@ -1,9 +1,10 @@
<!DOCTYPE html>
<!--
PGŽ Sport — Dokumenti UI v1.0
PGŽ Sport — Dokumenti UI v1.1
dradulic@outlook.com / damir@rinet.one — 2026-05-05
Library svih dokumenata: godišnjaci, publikacije, pravilnici, javni pozivi.
Tabovi + filteri + drill-down modal s RAG citatima + XLSX export + globalna pretraga.
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>
@@ -150,9 +151,16 @@
.modal-bg.open { display:flex; }
.modal {
background:var(--bg1); border:1px solid #2a3144; border-radius:12px;
width:100%; max-width:880px; padding:0; overflow:hidden;
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;
@@ -239,6 +247,7 @@
</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>
@@ -254,7 +263,9 @@
// Tab definitions (vrste mapping)
// ─────────────────────────────────────────
const TABS = [
{ id:'godisnjaci', label:'📅 Godišnjaci ZSPGZ',
{ 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' },
@@ -269,6 +280,28 @@ const TABS = [
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
@@ -277,13 +310,16 @@ const API_DIRECT = '/api/v2';
// ─────────────────────────────────────────
// State
// ─────────────────────────────────────────
let activeTab = 'godisnjaci';
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 {tab: {godinaMin, godinaMax, vrste:Set, q}}
const filterState = {}; // per-tab filter state
const unifiedState = { tip:'', izdavatelj:'', q:'', godinaMin:null, godinaMax:null };
// ─────────────────────────────────────────
// Utilities
@@ -332,12 +368,108 @@ function setTab(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
// ─────────────────────────────────────────
@@ -507,24 +639,42 @@ function cardHTML(d, yearStyle){
// 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 r = await api('/dokumenti?limit=1000');
const j = await r.json();
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();
renderFilters();
applyFilters();
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){
const old = activeTab; activeTab = t.id;
const c = tabDocs().length;
activeTab = old;
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;
}
@@ -658,6 +808,31 @@ function setModalTab(t){
${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">
@@ -721,10 +896,15 @@ function exportXLSX(){
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,