diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py
index bf649b7..cb37dca 100644
--- a/web/src/padelnomics/admin/routes.py
+++ b/web/src/padelnomics/admin/routes.py
@@ -2736,13 +2736,13 @@ async def article_edit(article_id: int):
body = raw[m.end():].lstrip("\n") if m else raw
body_html = mistune.html(body) if body else ""
- css_url = url_for("static", filename="css/output.css")
preview_doc = (
- f"
"
- f""
- f""
- f"{body_html}
"
- ) if body_html else ""
+ await render_template(
+ "admin/partials/article_preview_doc.html", body_html=body_html
+ )
+ if body_html
+ else ""
+ )
data = {**dict(article), "body": body}
return await render_template(
@@ -2764,13 +2764,13 @@ async def article_preview():
m = _FRONTMATTER_RE.match(body)
body = body[m.end():].lstrip("\n") if m else body
body_html = mistune.html(body) if body else ""
- css_url = url_for("static", filename="css/output.css")
preview_doc = (
- f""
- f""
- f""
- f"{body_html}
"
- ) if body_html else ""
+ await render_template(
+ "admin/partials/article_preview_doc.html", body_html=body_html
+ )
+ if body_html
+ else ""
+ )
return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)
diff --git a/web/src/padelnomics/admin/templates/admin/article_form.html b/web/src/padelnomics/admin/templates/admin/article_form.html
index ae2b9f0..8db1daf 100644
--- a/web/src/padelnomics/admin/templates/admin/article_form.html
+++ b/web/src/padelnomics/admin/templates/admin/article_form.html
@@ -384,7 +384,7 @@
{% else %}
diff --git a/web/src/padelnomics/admin/templates/admin/partials/article_preview.html b/web/src/padelnomics/admin/templates/admin/partials/article_preview.html
index 7630039..88a27fa 100644
--- a/web/src/padelnomics/admin/templates/admin/partials/article_preview.html
+++ b/web/src/padelnomics/admin/templates/admin/partials/article_preview.html
@@ -4,7 +4,7 @@
{% else %}
diff --git a/web/src/padelnomics/admin/templates/admin/partials/article_preview_doc.html b/web/src/padelnomics/admin/templates/admin/partials/article_preview_doc.html
new file mode 100644
index 0000000..a160193
--- /dev/null
+++ b/web/src/padelnomics/admin/templates/admin/partials/article_preview_doc.html
@@ -0,0 +1,15 @@
+{# Standalone HTML document used as iframe srcdoc for the article editor preview.
+ Includes Leaflet so map shortcodes render correctly. #}
+
+
+
+
+
+
+
+
+ {{ body_html | safe }}
+
+
+
+
diff --git a/web/src/padelnomics/content/templates/article_detail.html b/web/src/padelnomics/content/templates/article_detail.html
index 3a6e1fd..2820e01 100644
--- a/web/src/padelnomics/content/templates/article_detail.html
+++ b/web/src/padelnomics/content/templates/article_detail.html
@@ -60,106 +60,6 @@
{% endblock %}
{% block scripts %}
-
+
+
{% endblock %}
diff --git a/web/src/padelnomics/static/js/article-maps.js b/web/src/padelnomics/static/js/article-maps.js
new file mode 100644
index 0000000..38de362
--- /dev/null
+++ b/web/src/padelnomics/static/js/article-maps.js
@@ -0,0 +1,108 @@
+/**
+ * Leaflet map initialisation for article pages (country + city maps).
+ *
+ * Looks for #country-map and #city-map elements. If neither exists, does nothing.
+ * Expects data-* attributes on the map elements and a global LEAFLET_JS_URL
+ * variable pointing to the Leaflet JS bundle.
+ */
+(function() {
+ var countryMapEl = document.getElementById('country-map');
+ var cityMapEl = document.getElementById('city-map');
+ if (!countryMapEl && !cityMapEl) return;
+
+ var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
+ var TILES_ATTR = '© OSM © CARTO';
+
+ function scoreColor(score) {
+ if (score >= 60) return '#16A34A';
+ if (score >= 30) return '#D97706';
+ return '#DC2626';
+ }
+
+ function makeIcon(size, color) {
+ var s = Math.round(size);
+ return L.divIcon({
+ className: '',
+ html: '',
+ iconSize: [s, s],
+ iconAnchor: [s / 2, s / 2],
+ });
+ }
+
+ function initCountryMap(el) {
+ var slug = el.dataset.countrySlug;
+ var map = L.map(el, {scrollWheelZoom: false});
+ L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
+ var lang = document.documentElement.lang || 'en';
+ fetch('/api/markets/' + slug + '/cities.json')
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (!data.length) return;
+ var maxV = Math.max.apply(null, data.map(function(d) { return d.padel_venue_count || 1; }));
+ var bounds = [];
+ data.forEach(function(c) {
+ if (!c.lat || !c.lon) return;
+ var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
+ var color = scoreColor(c.market_score);
+ var pop = c.population >= 1000000
+ ? (c.population / 1000000).toFixed(1) + 'M'
+ : (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || ''));
+ var tip = '' + c.city_name + '
'
+ + (c.padel_venue_count || 0) + ' venues'
+ + (pop ? ' · ' + pop : '') + '
'
+ + 'Score ' + Math.round(c.market_score) + '/100';
+ L.marker([c.lat, c.lon], { icon: makeIcon(size, color) })
+ .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
+ .on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; })
+ .addTo(map);
+ bounds.push([c.lat, c.lon]);
+ });
+ if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
+ });
+ }
+
+ var VENUE_ICON = L.divIcon({
+ className: '',
+ html: '',
+ iconSize: [10, 10],
+ iconAnchor: [5, 5],
+ });
+
+ function initCityMap(el) {
+ var countrySlug = el.dataset.countrySlug;
+ var citySlug = el.dataset.citySlug;
+ var lat = parseFloat(el.dataset.lat);
+ var lon = parseFloat(el.dataset.lon);
+ var map = L.map(el, {scrollWheelZoom: false}).setView([lat, lon], 13);
+ L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
+ fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json')
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ data.forEach(function(v) {
+ if (!v.lat || !v.lon) return;
+ var indoor = v.indoor_court_count || 0;
+ var outdoor = v.outdoor_court_count || 0;
+ var total = v.court_count || (indoor + outdoor);
+ var courtLine = total
+ ? total + ' court' + (total > 1 ? 's' : '')
+ + (indoor || outdoor
+ ? ' (' + [indoor ? indoor + ' indoor' : '', outdoor ? outdoor + ' outdoor' : ''].filter(Boolean).join(', ') + ')'
+ : '')
+ : '';
+ var tip = '' + v.name + '' + (courtLine ? '
' + courtLine : '');
+ L.marker([v.lat, v.lon], { icon: VENUE_ICON })
+ .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -7] })
+ .addTo(map);
+ });
+ });
+ }
+
+ /* Dynamically load Leaflet JS then init maps */
+ var script = document.createElement('script');
+ script.src = window.LEAFLET_JS_URL || '/static/vendor/leaflet/leaflet.min.js';
+ script.onload = function() {
+ if (countryMapEl) initCountryMap(countryMapEl);
+ if (cityMapEl) initCityMap(cityMapEl);
+ };
+ document.head.appendChild(script);
+})();