Configurable API base + CORS for the frontend/backend split (Stage 5) (#18)
This commit is contained in:
parent
11872d7e64
commit
c51c60bd94
10 changed files with 103 additions and 54 deletions
25
content.py
25
content.py
|
|
@ -27,6 +27,14 @@ import paths
|
|||
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
||||
BASE = f"/{_BASE}" if _BASE else ""
|
||||
|
||||
# Backend's browser-facing origin+base (e.g. "http://192.168.1.5:8137/thermograph"),
|
||||
# for templates referencing backend-owned things -- the interactive SPA pages
|
||||
# (_page()-served, never by this service), /digest, and every static JS/CSS/
|
||||
# favicon/manifest file (this service mounts no static files of its own; see
|
||||
# app.py). Empty by default: today's exact same-origin behavior, where those
|
||||
# references stay relative (ASSET_BASE == BASE). Repo-split Stage 5.
|
||||
ASSET_BASE = os.environ.get("THERMOGRAPH_API_BASE_PUBLIC", "").rstrip("/") or BASE
|
||||
|
||||
_env = Environment(
|
||||
loader=FileSystemLoader(paths.TEMPLATES_DIR),
|
||||
autoescape=select_autoescape(["html", "xml", "j2"]),
|
||||
|
|
@ -42,15 +50,28 @@ _env.filters["ordinal"] = fmt.pct_ordinal
|
|||
|
||||
# --- helpers -------------------------------------------------------------
|
||||
def _origin(request: Request) -> str:
|
||||
# x-forwarded-host takes precedence over host: when reached via backend's
|
||||
# internal proxy fallback (no Caddy in front -- see _proxy_to_frontend in
|
||||
# backend/web/app.py), Host is the internal hop's own address, not the
|
||||
# browser-facing one.
|
||||
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||
host = request.headers.get("host") or request.url.netloc
|
||||
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
|
||||
return f"{proto}://{host}"
|
||||
|
||||
|
||||
def _respond_html(request: Request, template: str, **ctx) -> Response:
|
||||
o = _origin(request)
|
||||
# og:image (an Open Graph tag, which the spec requires be absolute) is the
|
||||
# one place a *static asset* reference needs an absolute URL always, not
|
||||
# just when THERMOGRAPH_API_BASE_PUBLIC happens to be set -- ASSET_BASE is
|
||||
# already absolute in that case, otherwise it's the same relative BASE
|
||||
# base_url itself is built from, so prefixing with this request's own
|
||||
# origin recovers exactly today's behavior in the default topology.
|
||||
asset_base_url = ASSET_BASE if ASSET_BASE.startswith(("http://", "https://")) else f"{o}{ASSET_BASE}"
|
||||
ctx.setdefault("unit_default", fmt.current_unit())
|
||||
html = _env.get_template(template).render(base=BASE, origin=o, base_url=f"{o}{BASE}", **ctx)
|
||||
html = _env.get_template(template).render(
|
||||
base=BASE, asset_base=ASSET_BASE, asset_base_url=asset_base_url,
|
||||
origin=o, base_url=f"{o}{BASE}", **ctx)
|
||||
etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"'
|
||||
inm = request.headers.get("if-none-match")
|
||||
if inm and etag in {t.strip() for t in inm.split(",")}:
|
||||
|
|
|
|||
|
|
@ -1,28 +1,37 @@
|
|||
// Accounts: the header "Sign in / account" entry, the auth modal, and the shared
|
||||
// helpers other modules (subscriptions.js) use to talk to authed endpoints.
|
||||
// helpers other modules (cache.js, mappicker.js, subscriptions.js, compare.js,
|
||||
// digest.js) use to talk to backend — API calls and page/asset links to
|
||||
// backend-owned paths (the interactive tool pages, /alerts, /digest, …) alike.
|
||||
//
|
||||
// Auth is a same-origin HttpOnly cookie, so the frontend stores no token — it just
|
||||
// sends `credentials: same-origin` and asks `GET users/me` who it is. Loaded on
|
||||
// every page the way units.js is (imported by each page's entry module).
|
||||
// Auth is an HttpOnly cookie; credentials: "include" (not "same-origin") so it
|
||||
// still rides along when this script itself is loaded cross-origin (repo-split
|
||||
// Stage 5 — a frontend SSR service on a different origin than backend, proven
|
||||
// via a genuinely cross-origin LAN-dev run). Loaded on every page the way
|
||||
// units.js is (imported by each page's entry module).
|
||||
|
||||
let currentUser = null; // {id, email, display_name} or null
|
||||
let discordEnabled = false; // is Discord linking configured on the server?
|
||||
let discordChecked = false; // have we asked yet? (once per page load)
|
||||
const authCbs = []; // notified on login/logout so pages can re-gate
|
||||
|
||||
// The app's base path (e.g. "/thermograph"), derived from this module's own URL
|
||||
// — it's always served from `${base}/account.js`, so this resolves correctly on
|
||||
// both the depth-1 interactive pages and the deep SEO URLs (/climate/{slug}/…),
|
||||
// where a directory-relative "api/v2/…" would otherwise resolve wrong and 404.
|
||||
const APP_BASE = new URL(".", import.meta.url).pathname.replace(/\/$/, "");
|
||||
const u = (path) => `${APP_BASE}/${String(path).replace(/^\//, "")}`;
|
||||
// backend's own origin+base (e.g. "https://thermograph.org/thermograph", or
|
||||
// "http://192.168.1.5:8137/thermograph" when frontend and backend are on
|
||||
// genuinely different origins) — derived from this module's own URL, since
|
||||
// account.js (like every other frontend/*.js file) is always served BY
|
||||
// backend, never by the frontend SSR service. Keeping the full origin (not
|
||||
// just the path, as this used to) is what makes fetch()/href targets built
|
||||
// from it resolve to backend even when this script was loaded from a page on
|
||||
// a different origin (a frontend_ssr-rendered page in the cross-origin case).
|
||||
export const APP_BASE = new URL(".", import.meta.url).href.replace(/\/$/, "");
|
||||
export const u = (path) => `${APP_BASE}/${String(path).replace(/^\//, "")}`;
|
||||
|
||||
// --- shared fetch helper -----------------------------------------------------
|
||||
// Absolute app-base URL (so it works from any page depth), cookie sent, and a
|
||||
// constant X-TG-Auth header the server can require as cheap CSRF defense (trivial
|
||||
// same-origin, impossible cross-site without a preflight we never grant).
|
||||
// Absolute app-base URL (so it works from any page depth, and from any origin
|
||||
// once split), cookie sent, and a constant X-TG-Auth header the server can
|
||||
// require as cheap CSRF defense (still meaningful cross-origin: forging it
|
||||
// needs a preflight-clearing request we never grant, same as same-origin).
|
||||
export async function apiFetch(path, { method = "GET", json, form } = {}) {
|
||||
const opts = { method, credentials: "same-origin", headers: { "X-TG-Auth": "1" } };
|
||||
const opts = { method, credentials: "include", headers: { "X-TG-Auth": "1" } };
|
||||
if (json !== undefined) {
|
||||
opts.headers["Content-Type"] = "application/json";
|
||||
opts.body = JSON.stringify(json);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@
|
|||
// multi-year calendar payloads persist across tabs and restarts — with a small
|
||||
// in-memory map in front for same-page rereads.
|
||||
//
|
||||
// URLs are resolved via account.js's `u()` (backend's own origin+base) rather
|
||||
// than a bare relative fetch() — account.js is always served by backend, so
|
||||
// this keeps every request correctly targeted at backend even when this
|
||||
// script is loaded from a frontend_ssr-rendered page on a different origin
|
||||
// (repo-split Stage 5). credentials: "include" (not the fetch default) is
|
||||
// what makes the auth cookie ride along in that same case.
|
||||
//
|
||||
// Freshness is layered:
|
||||
// * fresh (within TTL, stored today) → served as-is, no request.
|
||||
// * stale + caller passed onUpdate → served instantly, then revalidated in
|
||||
|
|
@ -13,6 +20,8 @@
|
|||
// etag is stored, so an unchanged payload costs an empty 304).
|
||||
// * network failure with any entry cached → the stale copy, rather than an error.
|
||||
|
||||
import { u } from "./account.js";
|
||||
|
||||
export const TTL = { grade: 600000, forecast: 1800000, calendar: 21600000, day: 600000, score: 21600000 }; // calendar/score: 6h
|
||||
|
||||
const IDB_NAME = "thermograph-cache", IDB_STORE = "resp";
|
||||
|
|
@ -92,7 +101,7 @@ const isFresh = (rec, ttlMs) => !!(rec && rec.day === localDay() && Date.now() -
|
|||
// entry (fresh again, no payload moved); a 200 stores payload + etag.
|
||||
async function fetchStore(url, rec) {
|
||||
const headers = rec && rec.etag ? { "If-None-Match": rec.etag } : {};
|
||||
const res = await fetch(url, { headers });
|
||||
const res = await fetch(u(url), { headers, credentials: "include" });
|
||||
if (res.status === 304 && rec) {
|
||||
cachePut({ ...rec, t: Date.now(), day: localDay() });
|
||||
return rec.d;
|
||||
|
|
@ -206,8 +215,8 @@ export function prefetchViews(lat, lon, ownViews) {
|
|||
const ek = "tg-cell-etag:" + q;
|
||||
let prev = null;
|
||||
try { prev = sessionStorage.getItem(ek); } catch (e) {}
|
||||
const res = await fetch(`api/v2/cell?${q}&neighbors=1`,
|
||||
prev ? { headers: { "If-None-Match": prev } } : {});
|
||||
const res = await fetch(u(`api/v2/cell?${q}&neighbors=1`),
|
||||
{ credentials: "include", ...(prev ? { headers: { "If-None-Match": prev } } : {}) });
|
||||
if (res.ok && res.status !== 304) {
|
||||
const bundle = await res.json();
|
||||
try { sessionStorage.setItem(ek, res.headers.get("ETag") || ""); } catch (e) {}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import { MONTHS, pad, monthStart, monthEnd, buildChunks,
|
|||
allMonths, monthsToMask, maskToMonths,
|
||||
namePrimary, nameParts } from "./shared.js";
|
||||
import { fmtTemp, fmtDelta, onUnitChange } from "./units.js";
|
||||
import "./account.js"; // header sign-in entry + notification bell
|
||||
import { u } from "./account.js"; // header sign-in entry + notification bell, and the shared URL resolver
|
||||
import { initFilterSheet } from "./filtersheet.js";
|
||||
|
||||
const CMP = {
|
||||
|
|
@ -262,7 +262,7 @@ function addLocation(lat, lon) {
|
|||
// the name too, so a failure here is harmless.
|
||||
async function resolveName(loc) {
|
||||
try {
|
||||
const res = await fetch(`api/v2/place?lat=${loc.lat}&lon=${loc.lon}`);
|
||||
const res = await fetch(u(`api/v2/place?lat=${loc.lat}&lon=${loc.lon}`), { credentials: "include" });
|
||||
if (!res.ok) return;
|
||||
const d = await res.json();
|
||||
// Skip if the location was removed meanwhile, or already got named by a load.
|
||||
|
|
|
|||
|
|
@ -4,16 +4,23 @@
|
|||
// this file blocked, and the beacons are fire-and-forget with no UI depending
|
||||
// on them.
|
||||
|
||||
const BASE = new URL("./", import.meta.url).pathname.replace(/\/$/, "");
|
||||
// Backend's own origin+base, shared with account.js rather than a second
|
||||
// independent import.meta.url-derived copy (repo-split Stage 5 — this file,
|
||||
// like account.js, is always served by backend, possibly from a page on a
|
||||
// different origin once frontend/backend genuinely split).
|
||||
import { APP_BASE } from "./account.js";
|
||||
|
||||
/** Report one product event. Aggregate-only: the server takes the referrer from
|
||||
* the request itself, so nothing identifying is sent from here. */
|
||||
export function track(event) {
|
||||
try {
|
||||
const url = `${BASE}/api/v2/event`;
|
||||
const url = `${APP_BASE}/api/v2/event`;
|
||||
const body = JSON.stringify({ event });
|
||||
// sendBeacon survives the page unloading, which a fetch() started on a link
|
||||
// click usually does not — that's the whole point for nav/records clicks.
|
||||
// Spec limitation: sendBeacon has no credentials/header control at all, so
|
||||
// it can never carry the auth cookie cross-origin — fine here, event
|
||||
// tracking is anonymous and needs no auth either way.
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
// Leaflet stays a classic script (global L), loaded before the page modules. The
|
||||
// map itself is created lazily the first time the picker opens (and reused after),
|
||||
// so pages that never open it pay nothing.
|
||||
import { u } from "./account.js";
|
||||
|
||||
let overlay, mapEl, map, marker, pin = null, onPickCb = null, pinIcon = null;
|
||||
let searchForm, searchInput, sugEl, confirmBtn, hintEl;
|
||||
let sugSeq = 0, sugTimer = 0, sugAbort = null;
|
||||
|
|
@ -56,7 +58,8 @@ function build() {
|
|||
if (sugAbort) sugAbort.abort();
|
||||
sugAbort = new AbortController();
|
||||
try {
|
||||
const res = await fetch(`api/v2/suggest?q=${encodeURIComponent(q)}`, { signal: sugAbort.signal });
|
||||
const res = await fetch(u(`api/v2/suggest?q=${encodeURIComponent(q)}`),
|
||||
{ signal: sugAbort.signal, credentials: "include" });
|
||||
const d = await res.json();
|
||||
if (seq === sugSeq) renderSug(d.results || []);
|
||||
} catch (err) { /* aborted, or offline — the map stays the fallback way to pick */ }
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ function startAdd() {
|
|||
openPicker(null, async (lat, lon) => {
|
||||
let label = null;
|
||||
try {
|
||||
const r = await fetch(`api/v2/place?lat=${lat}&lon=${lon}`);
|
||||
const r = await apiFetch(`api/v2/place?lat=${lat}&lon=${lon}`);
|
||||
const d = await r.json();
|
||||
label = d.place || null;
|
||||
} catch (e) { /* label optional; server falls back to cached/coords */ }
|
||||
|
|
|
|||
|
|
@ -19,19 +19,19 @@
|
|||
<meta property="og:title" content="{{ self.title() }}" />
|
||||
<meta property="og:description" content="{{ self.description() }}" />
|
||||
<meta property="og:url" content="{{ base_url }}{{ self.canonical_path() }}" />
|
||||
<meta property="og:image" content="{{ base_url }}/logo.png?v=3" />
|
||||
<meta property="og:image" content="{{ asset_base_url }}/logo.png?v=3" />
|
||||
<meta property="og:image:width" content="512" />
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta property="og:image:alt" content="Thermograph logo" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<link rel="icon" href="{{ base }}/favicon.svg?v=3" type="image/svg+xml" />
|
||||
<link rel="icon" href="{{ base }}/favicon-48.png?v=3" type="image/png" sizes="48x48" />
|
||||
<link rel="icon" href="{{ base }}/favicon-32.png?v=3" type="image/png" sizes="32x32" />
|
||||
<link rel="icon" href="{{ base }}/favicon-16.png?v=3" type="image/png" sizes="16x16" />
|
||||
<link rel="apple-touch-icon" href="{{ base }}/apple-touch-icon.png?v=3" />
|
||||
<link rel="manifest" href="{{ base }}/manifest.webmanifest" />
|
||||
<link rel="icon" href="{{ asset_base }}/favicon.svg?v=3" type="image/svg+xml" />
|
||||
<link rel="icon" href="{{ asset_base }}/favicon-48.png?v=3" type="image/png" sizes="48x48" />
|
||||
<link rel="icon" href="{{ asset_base }}/favicon-32.png?v=3" type="image/png" sizes="32x32" />
|
||||
<link rel="icon" href="{{ asset_base }}/favicon-16.png?v=3" type="image/png" sizes="16x16" />
|
||||
<link rel="apple-touch-icon" href="{{ asset_base }}/apple-touch-icon.png?v=3" />
|
||||
<link rel="manifest" href="{{ asset_base }}/manifest.webmanifest" />
|
||||
{% block head_extra %}{% endblock %}
|
||||
<link rel="stylesheet" href="{{ base }}/style.css" />
|
||||
<link rel="stylesheet" href="{{ asset_base }}/style.css" />
|
||||
{% block jsonld %}{% endblock %}
|
||||
</head>
|
||||
<body{% if unit_default %} data-unit-default="{{ unit_default }}"{% endif %}>
|
||||
|
|
@ -54,12 +54,12 @@
|
|||
Inert on the SEO pages, which never load nav.js. #}
|
||||
<nav class="view-nav" aria-label="Views">
|
||||
<a href="{{ base }}/" data-view="map"{% if section == 'home' %} class="active"{% endif %}>Weekly</a>
|
||||
<a href="{{ base }}/calendar" data-view="calendar">Calendar</a>
|
||||
<a href="{{ base }}/day" data-view="day">Day Detail</a>
|
||||
<a href="{{ base }}/compare" data-view="compare">Compare</a>
|
||||
<a href="{{ base }}/score" data-view="score"{% if section == 'score' %} class="active"{% endif %}>Score</a>
|
||||
<a href="{{ asset_base }}/calendar" data-view="calendar">Calendar</a>
|
||||
<a href="{{ asset_base }}/day" data-view="day">Day Detail</a>
|
||||
<a href="{{ asset_base }}/compare" data-view="compare">Compare</a>
|
||||
<a href="{{ asset_base }}/score" data-view="score"{% if section == 'score' %} class="active"{% endif %}>Score</a>
|
||||
<a href="{{ base }}/climate"{% if section == 'climate' %} class="active"{% endif %}>Climate</a>
|
||||
<a href="{{ base }}/alerts">Alerts</a>
|
||||
<a href="{{ asset_base }}/alerts">Alerts</a>
|
||||
</nav>
|
||||
</div>
|
||||
</details>
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
homepage-only surface: every page is a possible entry point.
|
||||
Hidden for now; restore by uncommenting the form block below. #}
|
||||
{#
|
||||
<form class="digest-form digest-compact" method="post" action="{{ base }}/digest"
|
||||
<form class="digest-form digest-compact" method="post" action="{{ asset_base }}/digest"
|
||||
data-digest aria-labelledby="footer-digest-label">
|
||||
<label id="footer-digest-label" for="footer-digest-email">Your city's weather, graded — monthly</label>
|
||||
<div class="digest-row">
|
||||
|
|
@ -103,11 +103,11 @@
|
|||
{% block body_scripts %}
|
||||
<!-- Header parity with the interactive pages: the °F/°C toggle, account + bell,
|
||||
and the client-side temperature converter. All self-contained modules. -->
|
||||
<script type="module" src="{{ base }}/units.js"></script>
|
||||
<script type="module" src="{{ base }}/account.js"></script>
|
||||
<script type="module" src="{{ base }}/climate.js"></script>
|
||||
<script type="module" src="{{ asset_base }}/units.js"></script>
|
||||
<script type="module" src="{{ asset_base }}/account.js"></script>
|
||||
<script type="module" src="{{ asset_base }}/climate.js"></script>
|
||||
{% endblock %}
|
||||
<script type="module" src="{{ base }}/digest.js"></script>
|
||||
<script type="module" src="{{ base }}/ios-install.js"></script>
|
||||
<script type="module" src="{{ asset_base }}/digest.js"></script>
|
||||
<script type="module" src="{{ asset_base }}/ios-install.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -106,6 +106,6 @@
|
|||
|
||||
<p class="climate-foot muted">Percentiles compare each day to {{ display }}'s own history, so grades are
|
||||
relative to this location: a “Near Record” day here isn't the same temperature as elsewhere.
|
||||
<a href="{{ base }}/legend">How the grades work →</a></p>
|
||||
<a href="{{ asset_base }}/legend">How the grades work →</a></p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@
|
|||
<input id="date-input" type="date" />
|
||||
</label>
|
||||
<button type="button" id="today-btn" class="today-chip" title="Reset the target date to today">Today</button>
|
||||
<span class="hint"><a href="{{ base }}/legend">What do the grades mean? →</a></span>
|
||||
<span class="hint"><a href="{{ asset_base }}/legend">What do the grades mean? →</a></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -112,7 +112,7 @@
|
|||
<ul class="unusual-row">
|
||||
{% for r in ranked %}
|
||||
<li class="unusual-card" style="--cat: var(--{{ r.cls }})">
|
||||
<a href="{{ base }}/day#lat={{ r.lat }}&lon={{ r.lon }}"
|
||||
<a href="{{ asset_base }}/day#lat={{ r.lat }}&lon={{ r.lon }}"
|
||||
data-event="home.records_click">
|
||||
<span class="unusual-city">{{ r.display }}</span>
|
||||
{# The reading itself, then what it is and what it should be —
|
||||
|
|
@ -141,7 +141,7 @@
|
|||
<li>The result is a percentile and a plain-language grade, from Near Record cold to Near Record hot.</li>
|
||||
</ol>
|
||||
<p class="muted">
|
||||
<a href="{{ base }}/legend">Full methodology →</a>
|
||||
<a href="{{ asset_base }}/legend">Full methodology →</a>
|
||||
· Data: ERA5 via Open-Meteo · NASA POWER · MET Norway
|
||||
</p>
|
||||
</section>
|
||||
|
|
@ -153,7 +153,7 @@
|
|||
palette, so the section reads as part of the product's own scale.
|
||||
Calendar carries the ramp itself — it IS the heatmap. #}
|
||||
<div class="explore-cards">
|
||||
<a class="explore-card" href="{{ base }}/calendar" data-event="home.nav_calendar"
|
||||
<a class="explore-card" href="{{ asset_base }}/calendar" data-event="home.nav_calendar"
|
||||
style="--cat: var(--warm)">
|
||||
<span class="explore-name">Calendar</span>
|
||||
<span class="explore-ramp" aria-hidden="true">
|
||||
|
|
@ -163,17 +163,17 @@
|
|||
</span>
|
||||
<span class="explore-desc">Two years of your days, each colored by how unusual it was.</span>
|
||||
</a>
|
||||
<a class="explore-card" href="{{ base }}/compare" data-event="home.nav_compare"
|
||||
<a class="explore-card" href="{{ asset_base }}/compare" data-event="home.nav_compare"
|
||||
style="--cat: var(--normal)">
|
||||
<span class="explore-name">Compare</span>
|
||||
<span class="explore-desc">Rank any places by comfort for a trip or a move.</span>
|
||||
</a>
|
||||
<a class="explore-card" href="{{ base }}/day" data-event="home.nav_day"
|
||||
<a class="explore-card" href="{{ asset_base }}/day" data-event="home.nav_day"
|
||||
style="--cat: var(--cool)">
|
||||
<span class="explore-name">Day</span>
|
||||
<span class="explore-desc">Any date since 1980, anywhere: what was normal, what happened.</span>
|
||||
</a>
|
||||
<a class="explore-card" href="{{ base }}/alerts" data-event="home.nav_alerts"
|
||||
<a class="explore-card" href="{{ asset_base }}/alerts" data-event="home.nav_alerts"
|
||||
style="--cat: var(--rec-hot)">
|
||||
<span class="explore-name">Alerts</span>
|
||||
<span class="explore-desc">Free percentile alerts: get pinged when your weather goes statistically weird.</span>
|
||||
|
|
@ -197,9 +197,9 @@
|
|||
|
||||
{% block body_scripts %}
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script type="module" src="{{ base }}/app.js"></script>
|
||||
<script type="module" src="{{ asset_base }}/app.js"></script>
|
||||
{# The records strip renders real temperatures server-side as .temp spans, so
|
||||
it needs the same converter the SEO pages use or they'd stay in °F while
|
||||
the toggle says °C. Both import units.js, which the module loader dedupes. #}
|
||||
<script type="module" src="{{ base }}/climate.js"></script>
|
||||
<script type="module" src="{{ asset_base }}/climate.js"></script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue