Rewrite PSN + CityHome scrapers, add price/m² map coloring, ratings system, and status dashboard
- Rewrite PSN scraper to use /api/units-list endpoint (single API call, no HTML parsing) - Fix CityHome scraper: GPS from multiple URL patterns, address from table cells, no 404 retries - Color map markers by price/m² instead of disposition (blue→green→orange→red scale) - Add persistent rating system (favorite/reject) with Flask ratings server and localStorage fallback - Rejected markers show original color at reduced opacity with 🚫 SVG overlay - Favorite markers shown as ⭐ star icons with gold pulse animation - Add "new today" marker logic (scraped_at == today) with larger pulsing green outline - Add filter panel with floor, price, hide-rejected controls and ☰/✕ toggle buttons - Add generate_status.py for scraper run statistics and status.html dashboard - Add scraped_at field to all scrapers for freshness tracking - Update run_all.sh with log capture and status generation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -347,6 +347,7 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
||||
"ownership": ownership,
|
||||
"url": sreality_url(hash_id, seo),
|
||||
"image": (estate.get("_links", {}).get("images", [{}])[0].get("href", "") if estate.get("_links", {}).get("images") else ""),
|
||||
"scraped_at": datetime.now().strftime("%Y-%m-%d"),
|
||||
}
|
||||
results.append(result)
|
||||
details_fetched += 1
|
||||
@@ -373,20 +374,58 @@ def scrape(max_pages: int | None = None, max_properties: int | None = None):
|
||||
def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
||||
"""Generate an interactive Leaflet.js HTML map."""
|
||||
|
||||
# Color by disposition
|
||||
color_map = {
|
||||
"3+kk": "#2196F3", # blue
|
||||
"3+1": "#4CAF50", # green
|
||||
"4+kk": "#FF9800", # orange
|
||||
"4+1": "#F44336", # red
|
||||
"5+kk": "#9C27B0", # purple
|
||||
"5+1": "#795548", # brown
|
||||
"6+": "#607D8B", # grey-blue
|
||||
}
|
||||
# Color by price per m² — cool blue→warm red scale, no yellow
|
||||
# Thresholds based on Prague market distribution (p25=120k, p50=144k, p75=162k)
|
||||
price_color_scale = [
|
||||
(110_000, "#1565C0"), # < 110k/m² → deep blue (levné)
|
||||
(130_000, "#42A5F5"), # 110–130k → light blue
|
||||
(150_000, "#66BB6A"), # 130–150k → green (střed)
|
||||
(165_000, "#EF6C00"), # 150–165k → dark orange
|
||||
(float("inf"), "#C62828"), # > 165k → dark red (drahé)
|
||||
]
|
||||
|
||||
def price_color(estate: dict) -> str:
|
||||
price = estate.get("price") or 0
|
||||
area = estate.get("area") or 0
|
||||
if not area:
|
||||
return "#9E9E9E"
|
||||
ppm2 = price / area
|
||||
for threshold, color in price_color_scale:
|
||||
if ppm2 < threshold:
|
||||
return color
|
||||
return "#E53935"
|
||||
|
||||
# Legend bands for info panel (built once)
|
||||
price_legend_items = (
|
||||
'<div style="margin-bottom:4px;font-size:12px;color:#555;font-weight:600;">Cena / m²:</div>'
|
||||
)
|
||||
bands = [
|
||||
("#1565C0", "< 110 000 Kč/m²"),
|
||||
("#42A5F5", "110 – 130 000 Kč/m²"),
|
||||
("#66BB6A", "130 – 150 000 Kč/m²"),
|
||||
("#EF6C00", "150 – 165 000 Kč/m²"),
|
||||
("#C62828", "> 165 000 Kč/m²"),
|
||||
("#9E9E9E", "cena/plocha neuvedena"),
|
||||
]
|
||||
for bcolor, blabel in bands:
|
||||
price_legend_items += (
|
||||
f'<div style="display:flex;align-items:center;gap:6px;margin:2px 0;">'
|
||||
f'<span style="width:14px;height:14px;border-radius:50%;background:{bcolor};'
|
||||
f'display:inline-block;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);flex-shrink:0;"></span>'
|
||||
f'<span>{blabel}</span></div>'
|
||||
)
|
||||
# New marker indicator — bigger dot, no extra border
|
||||
price_legend_items += (
|
||||
'<div style="display:flex;align-items:center;gap:6px;margin:6px 0 0 0;'
|
||||
'padding-top:6px;border-top:1px solid #eee;">'
|
||||
'<span style="width:18px;height:18px;border-radius:50%;background:#66BB6A;'
|
||||
'display:inline-block;box-shadow:0 1px 4px rgba(0,0,0,0.35);flex-shrink:0;"></span>'
|
||||
'<span>Nové (z dnešního scrapu) — větší</span></div>'
|
||||
)
|
||||
|
||||
markers_js = ""
|
||||
for e in estates:
|
||||
color = color_map.get(e["disposition"], "#999999")
|
||||
color = price_color(e)
|
||||
floor_text = f'{e["floor"]}. NP' if e["floor"] else "neuvedeno"
|
||||
area_text = f'{e["area"]} m²' if e["area"] else "neuvedeno"
|
||||
building_text = e["building_type"] or "neuvedeno"
|
||||
@@ -405,11 +444,19 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
||||
|
||||
hash_id = e.get("hash_id", "")
|
||||
|
||||
scraped_at = e.get("scraped_at", "")
|
||||
is_new = scraped_at == datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
new_badge = (
|
||||
'<span style="margin-left:6px;font-size:11px;background:#FFD600;color:#333;'
|
||||
'padding:1px 6px;border-radius:3px;font-weight:bold;">NOVÉ</span>'
|
||||
if is_new else ""
|
||||
)
|
||||
popup = (
|
||||
f'<div style="min-width:280px;font-family:system-ui,sans-serif;" data-hashid="{hash_id}">'
|
||||
f'<b style="font-size:14px;">{format_price(e["price"])}</b>'
|
||||
f'<span style="margin-left:8px;font-size:11px;background:{source_color};color:white;'
|
||||
f'padding:1px 6px;border-radius:3px;">{source_label}</span><br>'
|
||||
f'padding:1px 6px;border-radius:3px;">{source_label}</span>{new_badge}<br>'
|
||||
f'<span style="color:#666;">{e["disposition"]} | {area_text} | {floor_text}</span>'
|
||||
f'{floor_note}<br><br>'
|
||||
f'<b>{e["locality"]}</b><br>'
|
||||
@@ -438,27 +485,33 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
||||
popup = popup.replace("'", "\\'").replace("\n", "")
|
||||
|
||||
is_fav = source in ("psn", "cityhome")
|
||||
marker_fn = "addHeartMarker" if is_fav else "addMarker"
|
||||
|
||||
if is_fav:
|
||||
marker_fn = "addHeartMarker"
|
||||
elif is_new:
|
||||
marker_fn = "addNewMarker"
|
||||
else:
|
||||
marker_fn = "addMarker"
|
||||
markers_js += (
|
||||
f" {marker_fn}({e['lat']}, {e['lon']}, '{color}', '{popup}', '{hash_id}');\n"
|
||||
)
|
||||
|
||||
# Build legend
|
||||
legend_items = ""
|
||||
# Build legend — price per m² bands + disposition counts
|
||||
legend_items = price_legend_items
|
||||
|
||||
# Disposition counts below the color legend
|
||||
disp_counts = {}
|
||||
for e in estates:
|
||||
d = e["disposition"]
|
||||
disp_counts[d] = disp_counts.get(d, 0) + 1
|
||||
for disp, color in color_map.items():
|
||||
count = disp_counts.get(disp, 0)
|
||||
if count > 0:
|
||||
legend_items += (
|
||||
f'<div style="display:flex;align-items:center;gap:6px;margin:3px 0;">'
|
||||
f'<span style="width:14px;height:14px;border-radius:50%;'
|
||||
f'background:{color};display:inline-block;border:2px solid white;'
|
||||
f'box-shadow:0 1px 3px rgba(0,0,0,0.3);"></span>'
|
||||
f'<span>{disp} ({count})</span></div>'
|
||||
)
|
||||
disp_order = ["3+kk", "3+1", "4+kk", "4+1", "5+kk", "5+1", "6+"]
|
||||
disp_summary = ", ".join(
|
||||
f"{d} ({disp_counts[d]})" for d in disp_order if d in disp_counts
|
||||
)
|
||||
legend_items += (
|
||||
f'<div style="margin-top:8px;padding-top:6px;border-top:1px solid #eee;'
|
||||
f'font-size:12px;color:#666;">{disp_summary}</div>'
|
||||
)
|
||||
|
||||
# Heart marker legend for PSN/CityHome
|
||||
fav_count = sum(1 for e in estates if e.get("source") in ("psn", "cityhome"))
|
||||
@@ -493,6 +546,7 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
||||
body {{ font-family: system-ui, -apple-system, sans-serif; }}
|
||||
#map {{ width: 100%; height: 100vh; }}
|
||||
.heart-icon {{ background: none !important; border: none !important; }}
|
||||
.star-icon {{ background: none !important; border: none !important; }}
|
||||
.rate-btn:hover {{ background: #f0f0f0 !important; }}
|
||||
.rate-btn.active-fav {{ background: #FFF9C4 !important; border-color: #FFC107 !important; }}
|
||||
.rate-btn.active-rej {{ background: #FFEBEE !important; border-color: #F44336 !important; }}
|
||||
@@ -503,13 +557,42 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
||||
}}
|
||||
.marker-favorite {{ animation: pulse-glow 2s ease-in-out infinite; border-radius: 50%; }}
|
||||
.heart-icon-fav svg path {{ stroke: gold !important; stroke-width: 2.5 !important; filter: drop-shadow(0 0 4px rgba(255,193,7,0.7)); }}
|
||||
.heart-icon-rej {{ opacity: 0.2 !important; }}
|
||||
.heart-icon-rej {{ opacity: 0.4 !important; filter: grayscale(1); }}
|
||||
.reject-overlay {{ background: none !important; border: none !important; pointer-events: none !important; }}
|
||||
@keyframes pulse-new {{
|
||||
0% {{ stroke-opacity: 1; stroke-width: 3px; r: 11; }}
|
||||
50% {{ stroke-opacity: 0.4; stroke-width: 6px; r: 12; }}
|
||||
100% {{ stroke-opacity: 1; stroke-width: 3px; r: 11; }}
|
||||
}}
|
||||
.marker-new {{ animation: pulse-new 2s ease-in-out infinite; }}
|
||||
.info-panel {{
|
||||
position: absolute; top: 10px; right: 10px; z-index: 1000;
|
||||
background: white; padding: 16px; border-radius: 10px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.15); max-width: 260px;
|
||||
font-size: 13px; line-height: 1.5;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}}
|
||||
.info-panel.collapsed {{
|
||||
transform: translateX(calc(100% + 20px));
|
||||
opacity: 0; pointer-events: none;
|
||||
}}
|
||||
.panel-open-btn {{
|
||||
position: absolute; top: 10px; right: 10px; z-index: 1001;
|
||||
width: 40px; height: 40px; border-radius: 8px;
|
||||
background: white; border: none; cursor: pointer;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||
font-size: 20px; display: flex; align-items: center; justify-content: center;
|
||||
transition: opacity 0.3s ease;
|
||||
}}
|
||||
.panel-open-btn.hidden {{ opacity: 0; pointer-events: none; }}
|
||||
.panel-close-btn {{
|
||||
position: absolute; top: 8px; right: 8px;
|
||||
width: 28px; height: 28px; border-radius: 6px;
|
||||
background: none; border: 1px solid #ddd; cursor: pointer;
|
||||
font-size: 16px; display: flex; align-items: center; justify-content: center;
|
||||
color: #888;
|
||||
}}
|
||||
.panel-close-btn:hover {{ background: #f0f0f0; color: #333; }}
|
||||
.info-panel h2 {{ font-size: 16px; margin-bottom: 8px; }}
|
||||
.info-panel .stats {{ color: #666; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #eee; }}
|
||||
.filter-section {{ margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee; }}
|
||||
@@ -517,18 +600,26 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
||||
.filter-section input[type="checkbox"] {{ accent-color: #1976D2; }}
|
||||
#floor-filter {{ margin-top: 8px; }}
|
||||
#floor-filter select {{ width: 100%; padding: 4px; border-radius: 4px; border: 1px solid #ccc; }}
|
||||
.status-link {{ display: block; margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee; text-align: center; }}
|
||||
.status-link a {{ color: #1976D2; text-decoration: none; font-size: 12px; }}
|
||||
@media (max-width: 600px) {{
|
||||
.info-panel {{ max-width: calc(100vw - 60px); right: 10px; }}
|
||||
.info-panel.collapsed {{ transform: translateX(calc(100% + 20px)); }}
|
||||
.panel-close-btn {{ top: 6px; right: 6px; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<div class="info-panel">
|
||||
<button class="panel-open-btn hidden" id="panel-open-btn" onclick="togglePanel()">☰</button>
|
||||
<div class="info-panel" id="info-panel">
|
||||
<button class="panel-close-btn" id="panel-close-btn" onclick="togglePanel()">✕</button>
|
||||
<h2>Byty v Praze</h2>
|
||||
<div class="stats">
|
||||
<div>Celkem: <b id="visible-count">{len(estates)}</b> bytů</div>
|
||||
<div>Cena: {min_price} — {max_price}</div>
|
||||
<div>Průměr: {avg_price}</div>
|
||||
</div>
|
||||
<div><b>Dispozice:</b></div>
|
||||
{legend_items}
|
||||
<div class="filter-section">
|
||||
<b>Filtry:</b>
|
||||
@@ -562,6 +653,7 @@ def generate_map(estates: list[dict], output_path: str = "mapa_bytu.html"):
|
||||
Skrýt zamítnuté
|
||||
</label>
|
||||
</div>
|
||||
<div class="status-link"><a href="status.html">Scraper status</a></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -597,6 +689,23 @@ function addMarker(lat, lon, color, popup, hashId) {{
|
||||
marker.addTo(map);
|
||||
}}
|
||||
|
||||
function addNewMarker(lat, lon, color, popup, hashId) {{
|
||||
var marker = L.circleMarker([lat, lon], {{
|
||||
radius: 12,
|
||||
fillColor: color,
|
||||
color: color,
|
||||
weight: 4,
|
||||
opacity: 0.35,
|
||||
fillOpacity: 0.95,
|
||||
}}).bindPopup(popup);
|
||||
marker._data = {{ lat: lat, lon: lon, color: color, hashId: hashId, isNew: true }};
|
||||
allMarkers.push(marker);
|
||||
marker.addTo(map);
|
||||
marker.on('add', function() {{
|
||||
if (marker._path) marker._path.classList.add('marker-new');
|
||||
}});
|
||||
}}
|
||||
|
||||
function heartIcon(color) {{
|
||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">'
|
||||
+ '<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 '
|
||||
@@ -612,6 +721,21 @@ function heartIcon(color) {{
|
||||
}});
|
||||
}}
|
||||
|
||||
function starIcon() {{
|
||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24">'
|
||||
+ '<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 '
|
||||
+ '5.82 22 7 14.14 2 9.27l6.91-1.01L12 2z" '
|
||||
+ 'fill="#FFC107" stroke="#F57F17" stroke-width="1" '
|
||||
+ 'filter="drop-shadow(0 1px 3px rgba(0,0,0,0.3))"/></svg>';
|
||||
return L.divIcon({{
|
||||
html: svg,
|
||||
className: 'star-icon',
|
||||
iconSize: [28, 28],
|
||||
iconAnchor: [14, 14],
|
||||
popupAnchor: [0, -14],
|
||||
}});
|
||||
}}
|
||||
|
||||
function addHeartMarker(lat, lon, color, popup, hashId) {{
|
||||
var marker = L.marker([lat, lon], {{
|
||||
icon: heartIcon(color),
|
||||
@@ -637,6 +761,36 @@ function saveRatings(ratings) {{
|
||||
localStorage.setItem(RATINGS_KEY, JSON.stringify(ratings));
|
||||
}}
|
||||
|
||||
function addRejectStrike(marker) {{
|
||||
removeRejectStrike(marker);
|
||||
var color = marker._data.color || '#999';
|
||||
// SVG "no entry" icon — circle with diagonal line, colored to match marker
|
||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20">'
|
||||
+ '<circle cx="12" cy="12" r="10" fill="none" stroke="' + color + '" stroke-width="2.5" opacity="0.85"/>'
|
||||
+ '<line x1="5.5" y1="5.5" x2="18.5" y2="18.5" stroke="' + color + '" stroke-width="2.5" stroke-linecap="round" opacity="0.85"/>'
|
||||
+ '</svg>';
|
||||
var icon = L.divIcon({{
|
||||
className: 'reject-overlay',
|
||||
html: svg,
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
}});
|
||||
var m = L.marker([marker._data.lat, marker._data.lon], {{
|
||||
icon: icon,
|
||||
interactive: false,
|
||||
pane: 'markerPane',
|
||||
}});
|
||||
m.addTo(map);
|
||||
marker._rejectStrike = m;
|
||||
}}
|
||||
|
||||
function removeRejectStrike(marker) {{
|
||||
if (marker._rejectStrike) {{
|
||||
map.removeLayer(marker._rejectStrike);
|
||||
marker._rejectStrike = null;
|
||||
}}
|
||||
}}
|
||||
|
||||
function applyMarkerStyle(marker, status) {{
|
||||
if (marker._data.isHeart) {{
|
||||
var el = marker._icon;
|
||||
@@ -651,26 +805,59 @@ function applyMarkerStyle(marker, status) {{
|
||||
}}
|
||||
}} else {{
|
||||
if (status === 'fav') {{
|
||||
marker.setStyle({{
|
||||
radius: 12, fillOpacity: 1, weight: 3,
|
||||
fillColor: marker._data.color, color: '#fff',
|
||||
}});
|
||||
if (marker._path) marker._path.classList.add('marker-favorite');
|
||||
removeRejectStrike(marker);
|
||||
if (!marker._data._origCircle) marker._data._origCircle = true;
|
||||
var popup = marker.getPopup();
|
||||
var popupContent = popup ? popup.getContent() : '';
|
||||
var wasOnMap = map.hasLayer(marker);
|
||||
if (wasOnMap) map.removeLayer(marker);
|
||||
var starMarker = L.marker([marker._data.lat, marker._data.lon], {{
|
||||
icon: starIcon(),
|
||||
}}).bindPopup(popupContent);
|
||||
starMarker._data = marker._data;
|
||||
var idx = allMarkers.indexOf(marker);
|
||||
if (idx !== -1) allMarkers[idx] = starMarker;
|
||||
if (wasOnMap) starMarker.addTo(map);
|
||||
}} else if (status === 'reject') {{
|
||||
marker.setStyle({{
|
||||
radius: 6, fillOpacity: 0.15, fillColor: '#999', color: '#bbb', weight: 1,
|
||||
}});
|
||||
if (marker._path) marker._path.classList.remove('marker-favorite');
|
||||
if (marker._data._origCircle && !(marker instanceof L.CircleMarker)) {{
|
||||
revertToCircle(marker, {{ radius: 6, fillOpacity: 0.35, fillColor: marker._data.color, color: '#fff', weight: 1 }});
|
||||
}} else {{
|
||||
marker.setStyle({{
|
||||
radius: 6, fillOpacity: 0.35, fillColor: marker._data.color, color: '#fff', weight: 1,
|
||||
}});
|
||||
if (marker._path) marker._path.classList.remove('marker-favorite');
|
||||
}}
|
||||
// Add strikethrough line over the marker
|
||||
addRejectStrike(marker);
|
||||
}} else {{
|
||||
marker.setStyle({{
|
||||
radius: 8, fillColor: marker._data.color, color: '#fff',
|
||||
weight: 2, fillOpacity: 0.85,
|
||||
}});
|
||||
if (marker._path) marker._path.classList.remove('marker-favorite');
|
||||
if (marker._data._origCircle && !(marker instanceof L.CircleMarker)) {{
|
||||
revertToCircle(marker, {{ radius: 8, fillColor: marker._data.color, color: '#fff', weight: 2, fillOpacity: 0.85 }});
|
||||
}} else {{
|
||||
marker.setStyle({{
|
||||
radius: 8, fillColor: marker._data.color, color: '#fff',
|
||||
weight: 2, fillOpacity: 0.85,
|
||||
}});
|
||||
if (marker._path) marker._path.classList.remove('marker-favorite');
|
||||
}}
|
||||
if (marker._path) marker._path.classList.remove('marker-rejected');
|
||||
removeRejectStrike(marker);
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
function revertToCircle(marker, style) {{
|
||||
var popup = marker.getPopup();
|
||||
var popupContent = popup ? popup.getContent() : '';
|
||||
var wasOnMap = map.hasLayer(marker);
|
||||
if (wasOnMap) map.removeLayer(marker);
|
||||
var cm = L.circleMarker([marker._data.lat, marker._data.lon], style).bindPopup(popupContent);
|
||||
cm._data = marker._data;
|
||||
delete cm._data._starRef;
|
||||
var idx = allMarkers.indexOf(marker);
|
||||
if (idx !== -1) allMarkers[idx] = cm;
|
||||
if (wasOnMap) cm.addTo(map);
|
||||
}}
|
||||
|
||||
function rateMarker(marker, action) {{
|
||||
var hashId = marker._data.hashId;
|
||||
var ratings = loadRatings();
|
||||
@@ -832,8 +1019,12 @@ function applyFilters() {{
|
||||
if (show) {{
|
||||
if (!map.hasLayer(m)) m.addTo(map);
|
||||
visible++;
|
||||
// Show strike line if rejected and visible
|
||||
if (m._rejectStrike && !map.hasLayer(m._rejectStrike)) m._rejectStrike.addTo(map);
|
||||
}} else {{
|
||||
if (map.hasLayer(m)) map.removeLayer(m);
|
||||
// Hide strike line when marker hidden
|
||||
if (m._rejectStrike && map.hasLayer(m._rejectStrike)) map.removeLayer(m._rejectStrike);
|
||||
}}
|
||||
}});
|
||||
|
||||
@@ -851,6 +1042,26 @@ function applyFilters() {{
|
||||
// Initialize ratings on load
|
||||
restoreRatings();
|
||||
|
||||
// ── Panel toggle ──────────────────────────────────────────────
|
||||
function togglePanel() {{
|
||||
var panel = document.getElementById('info-panel');
|
||||
var openBtn = document.getElementById('panel-open-btn');
|
||||
var isOpen = !panel.classList.contains('collapsed');
|
||||
if (isOpen) {{
|
||||
panel.classList.add('collapsed');
|
||||
openBtn.classList.remove('hidden');
|
||||
}} else {{
|
||||
panel.classList.remove('collapsed');
|
||||
openBtn.classList.add('hidden');
|
||||
}}
|
||||
}}
|
||||
|
||||
// On mobile, start with panel collapsed
|
||||
if (window.innerWidth <= 600) {{
|
||||
document.getElementById('info-panel').classList.add('collapsed');
|
||||
document.getElementById('panel-open-btn').classList.remove('hidden');
|
||||
}}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
Reference in New Issue
Block a user