Sportski objekti: API + Leaflet map page + address enrichment
DB: pgz_sport.sportski_objekti (103 objekti, 103 s geo, 60 s adresom, 31 tip) API: - /api/v2/sportski-objekti (filter: tip, grad, sport, q) - /api/v2/sportski-objekti/meta (tipovi, gradovi, sportovi, ukupno) Frontend: - /static/objekti.html — Leaflet (OpenStreetMap) interactive map - 3 dropdown filter (tip, grad, sport) + search - Side panel s listom + map markers s ikonama (🏟️⚽🏊⛵🎿🎳⛸️🎯🥌🏃) - Popup: naziv, tip, kapacitet, adresa, upravitelj, izgradeno, sportovi, web link, Google Maps link - /objekti, /sport/objekti, /sport/api/v2/sportski-objekti routes Sidebar app.html: +Sportski objekti link Background: scripts/objekti_enrich_address.py (Nominatim reverse-geocode 60 objekata bez adrese)
This commit is contained in:
@@ -506,6 +506,7 @@ const NAV_BY_ROLE = {
|
||||
{id:'dashboard', ic:'\u{1F4CA}', label:'Dashboard'},
|
||||
{id:'korisnici', ic:'\u{1F465}', label:'Korisnici', href:'/admin/users'},
|
||||
{id:'savezi', ic:'\u{1F3C5}', label:'Savezi'},
|
||||
{id:'objekti', ic:'\u{1F3DF}', label:'Sportski objekti', href:'/objekti'},
|
||||
{id:'klubovi', ic:'⬢', label:'Klubovi'},
|
||||
{id:'sportasi', ic:'\u{1F464}', label:'Sportaši'},
|
||||
{id:'financije', ic:'€', label:'Financije'},
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
objekti.html — Sportski objekti PGŽ (Google Maps + filter)
|
||||
Author: Damir Radulić | v1.0 | 05.05.2026
|
||||
-->
|
||||
<html lang="hr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>🏟️ Sportski objekti PGŽ</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font:14px system-ui;background:#06080d;color:#e0e0e0}
|
||||
header{background:#0a0e15;padding:12px 20px;border-bottom:1px solid #2a2a2e;display:flex;justify-content:space-between;align-items:center}
|
||||
header h1{font-size:18px;color:#5fb6ff}
|
||||
header a{color:#888;text-decoration:none;margin-left:14px;font-size:13px}
|
||||
header a:hover{color:#fff}
|
||||
.container{display:grid;grid-template-columns:380px 1fr;height:calc(100vh - 50px)}
|
||||
.sidebar{background:#0c1016;border-right:1px solid #1a1a1e;overflow-y:auto;padding:14px}
|
||||
.filters{display:flex;flex-direction:column;gap:8px;margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid #1a1a1e}
|
||||
.filters label{font-size:11px;color:#888;text-transform:uppercase}
|
||||
.filters select, .filters input{
|
||||
background:#1a1a1e;border:1px solid #2a2a2e;color:#fff;padding:8px 10px;border-radius:5px;font-size:13px;width:100%
|
||||
}
|
||||
.stats{font-size:12px;color:#888;padding:8px 0;border-bottom:1px solid #1a1a1e;margin-bottom:8px}
|
||||
.stats b{color:#5fb6ff}
|
||||
.obj-list{display:flex;flex-direction:column;gap:6px}
|
||||
.obj-item{background:#0c1016;border:1px solid #1a1a1e;border-radius:5px;padding:10px;cursor:pointer;transition:all .15s}
|
||||
.obj-item:hover{border-color:#5fb6ff;background:#0f1620}
|
||||
.obj-item.active{border-color:#fbbf24;background:#1a1610}
|
||||
.obj-name{font-weight:600;color:#fff;margin-bottom:3px;font-size:13px}
|
||||
.obj-meta{font-size:10px;color:#888;display:flex;gap:6px;flex-wrap:wrap}
|
||||
.obj-meta span{background:#1a1a1e;padding:1px 6px;border-radius:3px}
|
||||
#map{flex:1;background:#000}
|
||||
.leaflet-container{background:#1a1a1e}
|
||||
.popup-title{font-weight:700;font-size:14px;margin-bottom:4px;color:#000}
|
||||
.popup-meta{font-size:11px;color:#666;margin-bottom:4px}
|
||||
.popup-link{display:inline-block;margin-top:6px;padding:4px 8px;background:#1a73e8;color:#fff;text-decoration:none;border-radius:3px;font-size:11px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/" style="color:#5fb6ff;text-decoration:none">🏟️ Sportski objekti PGŽ</a></h1>
|
||||
<div>
|
||||
<a href="/">🏠 Home</a>
|
||||
<a href="/static/sport2.html#dashboard">📊 Dashboard</a>
|
||||
<a href="/sport/dokumenti">📚 Dokumenti</a>
|
||||
<a href="/admin/users">👥 Admin</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="sidebar">
|
||||
<div class="filters">
|
||||
<div>
|
||||
<label>Tip objekta</label>
|
||||
<select id="f-tip" onchange="loadObjekti()">
|
||||
<option value="">Svi tipovi</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Grad</label>
|
||||
<select id="f-grad" onchange="loadObjekti()">
|
||||
<option value="">Svi gradovi</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Sport</label>
|
||||
<select id="f-sport" onchange="loadObjekti()">
|
||||
<option value="">Svi sportovi</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Pretraga</label>
|
||||
<input type="search" id="f-q" placeholder="Naziv, adresa…" onkeyup="if(event.key==='Enter') loadObjekti()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats" id="stats">Učitavanje…</div>
|
||||
<div class="obj-list" id="obj-list"></div>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const map = L.map('map').setView([45.3271, 14.4422], 10); // Rijeka centar
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19, attribution: '© OpenStreetMap'
|
||||
}).addTo(map);
|
||||
|
||||
let markers = L.layerGroup().addTo(map);
|
||||
|
||||
const tipIcons = {
|
||||
'dvorana': '🏟️', 'stadion': '⚽', 'bazen': '🏊', 'kompleks': '🏛️',
|
||||
'marina': '⛵', 'skijalište': '🎿', 'kuglana': '🎳', 'tenis kompleks': '🎾',
|
||||
'klizalište': '⛸️', 'strelište': '🎯', 'boćalište': '🥌', 'atletska staza': '🏃',
|
||||
'centar': '🏟️', 'sanjkalište': '🛷', 'hipodrom': '🐎'
|
||||
};
|
||||
|
||||
async function loadMeta(){
|
||||
const r = await fetch('/sport/api/v2/sportski-objekti/meta');
|
||||
const m = await r.json();
|
||||
|
||||
const tipSel = document.getElementById('f-tip');
|
||||
m.tipovi.forEach(t => tipSel.innerHTML += `<option value="${t.tip}">${tipIcons[t.tip]||'•'} ${t.tip} (${t.broj})</option>`);
|
||||
|
||||
const gradSel = document.getElementById('f-grad');
|
||||
m.gradovi.forEach(g => gradSel.innerHTML += `<option value="${g.grad}">${g.grad} (${g.broj})</option>`);
|
||||
|
||||
const sportSel = document.getElementById('f-sport');
|
||||
m.sportovi.forEach(s => sportSel.innerHTML += `<option value="${s.sport}">${s.sport} (${s.broj})</option>`);
|
||||
}
|
||||
|
||||
async function loadObjekti(){
|
||||
const params = new URLSearchParams();
|
||||
const tip = document.getElementById('f-tip').value;
|
||||
const grad = document.getElementById('f-grad').value;
|
||||
const sport = document.getElementById('f-sport').value;
|
||||
const q = document.getElementById('f-q').value;
|
||||
if(tip) params.set('tip', tip);
|
||||
if(grad) params.set('grad', grad);
|
||||
if(sport) params.set('sport', sport);
|
||||
if(q) params.set('q', q);
|
||||
params.set('limit', '500');
|
||||
|
||||
const r = await fetch('/sport/api/v2/sportski-objekti?'+params.toString());
|
||||
const d = await r.json();
|
||||
|
||||
document.getElementById('stats').innerHTML = `<b>${d.count}</b> objekata po filtru`;
|
||||
|
||||
// Markers
|
||||
markers.clearLayers();
|
||||
const list = document.getElementById('obj-list');
|
||||
list.innerHTML = '';
|
||||
|
||||
const bounds = [];
|
||||
|
||||
d.rows.forEach((o, i) => {
|
||||
if(o.lat && o.lng){
|
||||
const icon = tipIcons[o.tip] || '📍';
|
||||
const m = L.marker([o.lat, o.lng], {
|
||||
title: o.naziv,
|
||||
icon: L.divIcon({
|
||||
html: `<div style="background:#1a73e8;color:#fff;padding:2px 5px;border-radius:50%;border:2px solid #fff;box-shadow:0 2px 4px rgba(0,0,0,.5);font-size:14px;width:30px;height:30px;display:flex;align-items:center;justify-content:center">${icon}</div>`,
|
||||
className: '', iconSize: [30, 30], iconAnchor: [15, 15]
|
||||
})
|
||||
});
|
||||
m.bindPopup(`
|
||||
<div class="popup-title">${o.naziv}</div>
|
||||
<div class="popup-meta">
|
||||
${icon} ${o.tip} · ${o.grad || ''}
|
||||
${o.kapacitet ? ' · ' + o.kapacitet + ' mjesta' : ''}
|
||||
</div>
|
||||
${o.adresa ? '<div class="popup-meta">📍 ' + o.adresa + '</div>' : ''}
|
||||
${o.upravitelj ? '<div class="popup-meta">👤 ' + o.upravitelj + '</div>' : ''}
|
||||
${o.izgradeno ? '<div class="popup-meta">🏗 Izgrađeno: ' + o.izgradeno + '</div>' : ''}
|
||||
${o.sportovi && o.sportovi.length ? '<div class="popup-meta">⚽ ' + o.sportovi.join(', ') + '</div>' : ''}
|
||||
${o.web ? '<a href="' + o.web + '" target="_blank" class="popup-link">🌐 Web</a>' : ''}
|
||||
<a href="https://www.google.com/maps?q=${o.lat},${o.lng}" target="_blank" class="popup-link">🗺️ Google Maps</a>
|
||||
`, {maxWidth: 320});
|
||||
markers.addLayer(m);
|
||||
bounds.push([o.lat, o.lng]);
|
||||
}
|
||||
|
||||
list.innerHTML += `
|
||||
<div class="obj-item" onclick="zoomTo(${o.lat||0}, ${o.lng||0})">
|
||||
<div class="obj-name">${tipIcons[o.tip]||'•'} ${o.naziv}</div>
|
||||
<div class="obj-meta">
|
||||
<span>${o.tip}</span>
|
||||
${o.grad ? '<span>'+o.grad+'</span>' : ''}
|
||||
${o.kapacitet ? '<span>'+o.kapacitet+' mj</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
if(bounds.length > 0){
|
||||
map.fitBounds(bounds, {padding: [40, 40], maxZoom: 13});
|
||||
}
|
||||
}
|
||||
|
||||
function zoomTo(lat, lng){
|
||||
if(lat && lng){
|
||||
map.setView([lat, lng], 16);
|
||||
markers.eachLayer(m => {
|
||||
if(m.getLatLng().lat === lat) m.openPopup();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadMeta().then(() => loadObjekti());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user