diff options
| author | grm <grm@eyesin.space> | 2026-09-18 19:34:35 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-18 19:34:35 +0300 |
| commit | 96336d8bacd2bdf81acf9a18e2fe57be9d4e9b4f (patch) | |
| tree | e7851ddadf2ed776ab24a1bad4d446b7c48567db /internal/web/templates | |
| parent | 63f9fdff6e5bdf6333a4b7813ebeb5b797838b6d (diff) | |
| download | blogspace-96336d8bacd2bdf81acf9a18e2fe57be9d4e9b4f.tar.gz blogspace-96336d8bacd2bdf81acf9a18e2fe57be9d4e9b4f.tar.bz2 blogspace-96336d8bacd2bdf81acf9a18e2fe57be9d4e9b4f.zip | |
Show a live preview of the header on the Design tab's Menu card
The menu options had grown to a dozen switches with nothing to look at
but the blog after a save. A new POST /b/{sub}/design/preview reads the
form as a save would and renders the header alone — the real blog.css
and theme CSS, the modules in their order, the menu as edited — into a
sandboxed frame that refreshes shortly after every change, with a Wide
screen / Phone toggle. Nothing is stored.
The frame keeps the dashboard's origin (allow-same-origin, no scripts)
so the blog's logo and fonts, linked through /b/<sub>/media since the
root host's /media is the root blog's, get the session cookie; that is
safe because no header module is owner HTML. The frame runs no scripts,
so the page's own script folds the menu in it the way the blog does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal/web/templates')
| -rw-r--r-- | internal/web/templates/blog/preview.html | 18 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/design.html | 48 | ||||
| -rw-r--r-- | internal/web/templates/layouts/blog.html | 6 |
3 files changed, 68 insertions, 4 deletions
diff --git a/internal/web/templates/blog/preview.html b/internal/web/templates/blog/preview.html new file mode 100644 index 0000000..a87e410 --- /dev/null +++ b/internal/web/templates/blog/preview.html @@ -0,0 +1,18 @@ +{{/* The design page's preview of the header: a page of its own for the + sandboxed frame — the real blog.css and theme CSS, and the header as the + form would save it. Only "preview" is ever executed; the empty content + block keeps the layout's reference satisfied. */}} +{{define "content"}}{{end}} +{{define "preview"}}<!DOCTYPE html> +<html lang="{{.Lang}}"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{t "Preview"}}</title> +<link rel="stylesheet" href="/static/blog.css"> +<style>{{css .Data.css}}</style> +</head> +<body class="{{.Data.theme.NavClass}}{{with .Data.theme.TitleRuleClass}} {{.}}{{end}}"> +{{template "siteheader" .}} +</body> +</html>{{end}} diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html index 0b27e17..039eabe 100644 --- a/internal/web/templates/dashboard/design.html +++ b/internal/web/templates/dashboard/design.html @@ -149,6 +149,17 @@ </div> <p class="muted small nojs">{{t "A page or link filled in here is added when you save."}}</p> <h3>{{t "How the menu looks"}}</h3> + {{/* The header as the form would save it, rendered by the server into a + sandboxed frame. allow-same-origin (never allow-scripts) so the frame + can load the blog's logo and fonts through /b/<sub>/media, which need the + session cookie: an opaque origin would not send it. Safe because the + header is never owner HTML — every header module kind is escaped by the + templates — and nothing runs without scripts. */}} + <div class="navpreview jsonly"> + <p class="muted small">{{t "Preview"}}: <button type="button" class="mini" data-pv="wide" aria-pressed="true">{{t "Wide screen"}}</button> <button type="button" class="mini" data-pv="phone" aria-pressed="false">{{t "Phone"}}</button> <span class="pvstatus"></span></p> + <iframe class="navpreview-frame" sandbox="allow-same-origin" scrolling="no" title="{{t "Preview"}}"></iframe> + <p class="muted small">{{t "Your header with the menu as set here; the phone view shows the folded menu. Nothing is saved until you press Save."}}</p> + </div> <div class="row"> <label>{{t "Alignment"}} <span class="muted">({{t "menu in the header"}})</span><br><select name="nav_align"> <option value="left"{{if eq $t.NavAlign "left"}} selected{{end}}>{{t "Left"}}</option> @@ -348,12 +359,47 @@ var next = +form.getAttribute("data-next") || 0, dirty = false, sending = false; function q(sel, el) { return (el || document).querySelector(sel); } function qa(sel, el) { return Array.prototype.slice.call((el || document).querySelectorAll(sel)); } - function touch() { dirty = true; q(".unsaved").hidden = false; } + function touch() { dirty = true; q(".unsaved").hidden = false; schedule(); } form.addEventListener("input", touch); form.addEventListener("change", touch); window.addEventListener("beforeunload", function (e) { if (dirty && !sending) { e.preventDefault(); e.returnValue = ""; } }); q("#discard").addEventListener("click", function (e) { if (dirty && !confirm({{t "Throw away your unsaved changes?"}})) e.preventDefault(); else dirty = false; }); + // ---- the header preview: the server renders the form as it would save it + var frame = q(".navpreview-frame"), pvTimer, pvBusy = false, pvAgain = false; + function preview() { + if (!frame || !window.fetch || !window.FormData) return; + if (pvBusy) { pvAgain = true; return; } // one at a time; the last change wins + pvBusy = true; + var fd = new FormData(form); + qa("input[type=file]", form).forEach(function (i) { fd.delete(i.name); }); // never upload while typing + fetch("/b/" + form.getAttribute("data-sub") + "/design/preview", { method: "POST", body: fd, credentials: "same-origin" }) + .then(function (r) { if (!r.ok) throw new Error(r.statusText); return r.text(); }) + .then(function (html) { frame.srcdoc = html; q(".pvstatus").textContent = ""; }) + .catch(function () { q(".pvstatus").textContent = {{t "Preview unavailable."}}; }) + .then(function () { pvBusy = false; if (pvAgain) { pvAgain = false; preview(); } }); + } + function schedule() { clearTimeout(pvTimer); pvTimer = setTimeout(preview, 300); } + function fitFrame() { // the frame runs no scripts, so fold the menu the way the blog's own script does, and size the frame to the header + try { + var d = frame.contentDocument, h = d.querySelector(".site-header.nav-fold"), links = h ? h.querySelectorAll(".site-nav .nav-inner a") : []; + if (links.length) { + h.classList.remove("nav-auto"); h.classList.remove("nav-folded"); + for (var i = 1; i < links.length; i++) if (links[i].offsetTop !== links[0].offsetTop) { h.classList.add("nav-folded"); break; } + } + frame.style.height = d.body.scrollHeight + "px"; + } catch (e) {} + } + if (frame) { + frame.addEventListener("load", function () { fitFrame(); try { frame.contentDocument.addEventListener("change", fitFrame); } catch (e) {} }); // change: the fold button opened or closed the panel + qa("[data-pv]").forEach(function (b) { b.addEventListener("click", function () { + frame.classList.toggle("phone", b.getAttribute("data-pv") === "phone"); + qa("[data-pv]").forEach(function (o) { o.setAttribute("aria-pressed", String(o === b)); }); + fitFrame(); + }); }); + preview(); + } + // ---- module and menu rows: order, remove, add function rows(list) { return qa(".modrow", list); } function refresh(list) { // the empty note and the "add" control of a one-module area diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html index d55512a..71a62b0 100644 --- a/internal/web/templates/layouts/blog.html +++ b/internal/web/templates/layouts/blog.html @@ -48,7 +48,7 @@ which drops nav-auto, the no-script fallback blog.css folds on phones. */}} {{define "siteheader"}}{{$l := .Data.layout}}{{$t := .Data.theme}}<div class="site-header{{if $l.Fold}} nav-fold nav-auto{{end}}{{if eq $t.HeaderAlign "center"}} header-center{{end}}"> {{if $l.Fold}}<input type="checkbox" id="nav-toggle" class="nav-toggle" aria-label="{{t "Menu"}}">{{end}} - {{if $t.HeaderImage}}<div class="header-image"><a href="/"><img src="/media/{{$t.HeaderImage}}" alt=""></a></div>{{end}} + {{if $t.HeaderImage}}<div class="header-image"><a href="/"><img src="{{$.Media}}/{{$t.HeaderImage}}" alt=""></a></div>{{end}} {{range $i, $m := $l.Header}}{{template "module" (dict "m" $m "root" $ "burger" (and $l.Fold (eq $i $l.BurgerAt)))}}{{end}} </div>{{if $l.Fold}}<script>(function () { var h = document.querySelector && document.querySelector(".site-header.nav-fold"), cb = document.getElementById("nav-toggle"); @@ -86,8 +86,8 @@ <h1 class="site-title"><a href="/">{{$r.Blog.Title}}</a></h1> {{if $r.Blog.Tagline}}<p class="tagline">{{$r.Blog.Tagline}}</p>{{end}} </div>{{template "rule" (dict "before" false "rc" $rc "theme" $t)}}{{if $b}}{{template "burger" $t}}{{end}}</div> -{{- else if eq $m.Kind "logo"}}{{if $t.Logo}}{{if eq $m.Area "header"}}<div class="wrap header-logo{{with $rc}} {{.}}{{end}}{{if $b}} has-burger{{end}}">{{template "rule" (dict "before" true "rc" $rc "theme" $t)}}<div class="brand"><a href="/"><img class="logo" src="/media/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div>{{template "rule" (dict "before" false "rc" $rc "theme" $t)}}{{if $b}}{{template "burger" $t}}{{end}}</div> -{{else}}<div class="mod mod-logo"><a href="/"><img class="logo" src="/media/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div>{{end}}{{end}} +{{- else if eq $m.Kind "logo"}}{{if $t.Logo}}{{if eq $m.Area "header"}}<div class="wrap header-logo{{with $rc}} {{.}}{{end}}{{if $b}} has-burger{{end}}">{{template "rule" (dict "before" true "rc" $rc "theme" $t)}}<div class="brand"><a href="/"><img class="logo" src="{{$r.Media}}/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div>{{template "rule" (dict "before" false "rc" $rc "theme" $t)}}{{if $b}}{{template "burger" $t}}{{end}}</div> +{{else}}<div class="mod mod-logo"><a href="/"><img class="logo" src="{{$r.Media}}/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div>{{end}}{{end}} {{- else if eq $m.Kind "menu"}}{{if eq $m.Area "header"}}<div class="site-nav{{if $b}} has-burger{{end}}"><div class="wrap nav-inner">{{if $b}}{{template "burger" $t}}{{end}}{{if $r.Data.layout.HeaderSearch}}{{template "searchform" $r}}{{end}}{{template "menulinks" $r}}</div></div> {{else}}<div class="mod site-nav mod-menu">{{template "modtitle" $m}}<div class="nav-inner">{{template "menulinks" $r}}</div></div>{{end}} {{- else if eq $m.Kind "recent"}}<div class="mod mod-recent">{{template "modtitle" $m}} |
