Files
pgz-sport/static/dokumenti.html
T
damir 1d02c0897d Sidebar: +ERP +CRM +Dokumenti, godišnjaci import (18 PDFs), filter helpers
- pgz nav now includes /erp/full, /crm/v2, /admin/users, /dokumenti
- 4 dokumenti endpoints: list, godišnjaci/list, godišnjak/{godina} PDF, detail
- 18 godišnjaka u pgz_sport.dokumenti (2006-2024) with savez_id=333
- PGŽ filter helpers (window._pgz_filter_priority, togglePGZFilter)
- navItemClick handler for nav items with href
2026-05-05 13:08:11 +02:00

752 lines
33 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.0
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.
-->
<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:880px; padding:0; overflow:hidden;
box-shadow:0 12px 60px rgba(0,0,0,0.6);
}
.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="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:'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' },
];
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 = 'godisnjaci';
let allDocs = []; // all loaded docs (cached after first fetch)
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}}
// ─────────────────────────────────────────
// 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 {
renderFilters();
applyFilters();
}
}
// ─────────────────────────────────────────
// 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(){
$('#content').innerHTML = '<div class="loading">Učitavam dokumente…</div>';
try {
const r = await api('/dokumenti?limit=1000');
const j = await r.json();
allDocs = (j.dokumenti || []).filter(d => !VRSTE_HIDDEN.includes(d.vrsta));
updateTabCounts();
renderFilters();
applyFilters();
} catch(e){
$('#content').innerHTML = `<div class="empty">⚠️ Greška pri učitavanju: ${esc(e.message)}</div>`;
}
}
function updateTabCounts(){
for(const t of TABS){
const old = activeTab; activeTab = t.id;
const 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 === '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 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)
})) : 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>