aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web')
-rw-r--r--internal/web/handlers_design.go35
-rw-r--r--internal/web/routes.go1
-rw-r--r--internal/web/server.go3
-rw-r--r--internal/web/static/dashboard.css3
-rw-r--r--internal/web/templates/blog/preview.html18
-rw-r--r--internal/web/templates/dashboard/design.html48
-rw-r--r--internal/web/templates/layouts/blog.html6
-rw-r--r--internal/web/theme.go17
-rw-r--r--internal/web/web_test.go37
9 files changed, 155 insertions, 13 deletions
diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go
index 792dcdc..a10f285 100644
--- a/internal/web/handlers_design.go
+++ b/internal/web/handlers_design.go
@@ -1,6 +1,7 @@
package web
import (
+ "log"
"net/http"
"time"
@@ -203,6 +204,40 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
redirectOK(w, r, back, s.tr(r, "Design saved. Refresh your blog to see it."))
}
+// handleDesignPreview renders the header as the design form would save it,
+// for the Menu card's preview frame: the form is read like a save (theme,
+// modules, menu), nothing is stored, and a bad row is shown rather than
+// reported. Login, ownership, CSRF and the body cap are withBlog's. The page
+// is on the root host, so the blog's files are linked through /b/<sub>/media.
+func (s *Server) handleDesignPreview(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ th := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form)
+ th.HeaderImage = "" // the banner would dwarf the menu, which is what the frame is for
+ mods, _ := parseModules(r.Form, s.lang(r), blog.Language)
+ pages, err := blogStore(r).ListPages(r.Context())
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ menu, _ := parseMenu(r.Form, s.lang(r), pages)
+ media := "/b/" + blog.Subdomain + "/media"
+ data := map[string]any{"theme": th, "css": th.CSSAt(media), "layout": buildLayout(th, mods), "menu": menu, "pages": pages}
+ for i := range pages { // the home page is "current", so its link shows the mark
+ if pages[i].IsHome {
+ data["page"] = &pages[i]
+ }
+ }
+ tpl, err := s.tpl.get(blog.Language, "blog/preview.html")
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := tpl.ExecuteTemplate(w, "preview", view{Blog: blog, Lang: blog.Language, Media: media, Data: data}); err != nil {
+ log.Printf("render preview: %v", err)
+ }
+}
+
// handleDesignReset restores the defaults, layout modules included; images
// stay in the library and the menu is left alone.
func (s *Server) handleDesignReset(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/web/routes.go b/internal/web/routes.go
index d846160..52cdee1 100644
--- a/internal/web/routes.go
+++ b/internal/web/routes.go
@@ -51,6 +51,7 @@ func (s *Server) rootRoutes() http.Handler {
m.HandleFunc("GET /b/{sub}/design", s.withBlog(s.handleDesignForm))
m.HandleFunc("POST /b/{sub}/design", s.withBlog(s.handleDesign))
m.HandleFunc("POST /b/{sub}/design/reset", s.withBlog(s.handleDesignReset))
+ m.HandleFunc("POST /b/{sub}/design/preview", s.withBlog(s.handleDesignPreview))
m.HandleFunc("GET /b/{sub}/files", s.withBlog(s.handleFiles))
m.HandleFunc("POST /b/{sub}/files/upload", s.withBlogFiles(maxUploadFiles, s.handleFileUpload))
m.HandleFunc("POST /b/{sub}/files/{id}/rename", s.withBlog(s.handleFileRename))
diff --git a/internal/web/server.go b/internal/web/server.go
index f5d32d1..e3fef6e 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -373,6 +373,7 @@ type view struct {
RootURL string
Path string
Lang string // i18n code, also the <html lang>
+ Media string // where the blog's files are served: /media, or /b/<sub>/media for the design preview on the root host
Data map[string]any
}
@@ -384,7 +385,7 @@ func (s *Server) renderStatus(w http.ResponseWriter, r *http.Request, status int
if data == nil {
data = map[string]any{}
}
- v := view{User: currentUser(r), Blog: currentBlog(r), RootURL: s.cfg.RootURL(), Path: r.URL.Path, Lang: s.lang(r), Data: data}
+ v := view{User: currentUser(r), Blog: currentBlog(r), RootURL: s.cfg.RootURL(), Path: r.URL.Path, Lang: s.lang(r), Media: "/media", Data: data}
if v.User != nil {
v.CSRF = auth.CSRFToken(s.cfg.JWTSecret, v.User.ID, v.User.TokenVersion)
}
diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css
index 4aa0213..1c5142a 100644
--- a/internal/web/static/dashboard.css
+++ b/internal/web/static/dashboard.css
@@ -253,6 +253,9 @@ form:not(.js) .jsonly { display: none !important; }
.addrow select, .addrow input { width: auto; min-width: 14em; }
.addrow button { vertical-align: bottom; margin-bottom: 0.4em; }
textarea.mono { font-size: 0.9em; }
+.navpreview { margin: 0 0 1em; }
+.navpreview-frame { display: block; width: 100%; height: 12em; border: 1px solid var(--border-strong); background: #fff; }
+.navpreview-frame.phone { width: 380px; max-width: 100%; } /* a phone's viewport, so blog.css's phone rules apply inside */
.hoverpick { margin: 0.8em 0; }
.hoverpick > input { width: auto; margin: 0 0.4em 0 0; vertical-align: middle; }
.hoverpick > label.check { display: inline; margin: 0; }
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}}
diff --git a/internal/web/theme.go b/internal/web/theme.go
index a43603b..ee3ef11 100644
--- a/internal/web/theme.go
+++ b/internal/web/theme.go
@@ -576,13 +576,13 @@ var themeCSS = template.Must(template.New("theme").Funcs(template.FuncMap{"sub":
@font-face { font-family:"Fira Code"; src:url(/static/FiraCode-VF.woff2) format("woff2"); font-weight:300 700; font-display:swap; }
{{- end}}
{{- if .CustomFont}}
-@font-face { font-family:"BlogFont"; src:url(/media/{{.CustomFont}}); font-display:swap; }
+@font-face { font-family:"BlogFont"; src:url({{.Media}}/{{.CustomFont}}); font-display:swap; }
{{- end}}
{{- if .CustomHeadingFont}}
-@font-face { font-family:"BlogHeadingFont"; src:url(/media/{{.CustomHeadingFont}}); font-display:swap; }
+@font-face { font-family:"BlogHeadingFont"; src:url({{.Media}}/{{.CustomHeadingFont}}); font-display:swap; }
{{- end}}
body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-family:{{.FontFamily}}; font-size:{{.FontSizePx}}; line-height:1.5;
-{{- if .BgImage}} background-image:url(/media/{{.BgImage}});
+{{- if .BgImage}} background-image:url({{.Media}}/{{.BgImage}});
{{- if eq .BgMode "cover"}} background-size:cover; background-position:center top; background-repeat:no-repeat;
{{- else if eq .BgMode "fixed"}} background-size:cover; background-position:center; background-attachment:fixed; background-repeat:no-repeat;
{{- else}} background-repeat:repeat;{{end}}
@@ -636,8 +636,15 @@ a:hover {{"{"}}{{if .LinkHover}} color:{{.LinkHover}};{{end}}{{if eq .LinkUnderl
`))
// CSS renders the per-blog stylesheet; all values were normalized so it is safe to inline.
-func (t Theme) CSS() string {
+func (t Theme) CSS() string { return t.CSSAt("/media") }
+
+// CSSAt is CSS with the blog's files under another path: the design page's
+// preview is on the root host, where /media is the root blog's.
+func (t Theme) CSSAt(media string) string {
var b bytes.Buffer
- _ = themeCSS.Execute(&b, t)
+ _ = themeCSS.Execute(&b, struct {
+ Theme
+ Media string
+ }{t, media})
return b.String()
}
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index 12e30f2..e9e38fb 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -216,7 +216,7 @@ func TestHeaderFold(t *testing.T) {
render := func(th Theme, mods []store.Module) string {
var b strings.Builder
l := buildLayout(th, mods)
- v := view{Blog: &store.Blog{Title: "Gizmo"}, Data: map[string]any{"theme": th, "layout": l, "menu": []store.MenuItem{{Label: "Home", URL: "/"}, {Label: "About", URL: "/about"}}}}
+ v := view{Blog: &store.Blog{Title: "Gizmo"}, Media: "/media", Data: map[string]any{"theme": th, "layout": l, "menu": []store.MenuItem{{Label: "Home", URL: "/"}, {Label: "About", URL: "/about"}}}}
if err := tpl.ExecuteTemplate(&b, "siteheader", v); err != nil {
t.Fatal(err)
}
@@ -244,6 +244,37 @@ func TestHeaderFold(t *testing.T) {
}
}
+// The design page's preview is the header alone as a page of its own, with
+// the blog's files linked through the dashboard's path for them.
+func TestDesignPreview(t *testing.T) {
+ tpl, err := newTemplates(false).get("en", "blog/preview.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ th := DefaultTheme()
+ th.Logo, th.BgImage = uuid.New().String(), uuid.New().String()
+ media := "/b/gizmo/media"
+ l := buildLayout(th, []store.Module{{Area: "header", Kind: "title"}, {Area: "header", Kind: "logo"}, {Area: "header", Kind: "menu"}, {Area: "left", Kind: "recent"}})
+ v := view{Blog: &store.Blog{Title: "Gizmo"}, Lang: "en", Media: media, Data: map[string]any{"theme": th, "css": th.CSSAt(media), "layout": l, "menu": []store.MenuItem{{Label: "About", URL: "/about"}}}}
+ var b strings.Builder
+ if err := tpl.ExecuteTemplate(&b, "preview", v); err != nil {
+ t.Fatal(err)
+ }
+ out := b.String()
+ for _, want := range []string{`<link rel="stylesheet" href="/static/blog.css">`, `<body class="nav-left nav-ul-hover nav-size-normal nav-gap-normal nav-active-underline nav-bold">`,
+ `<img class="logo" src="/b/gizmo/media/` + th.Logo, `background-image:url(/b/gizmo/media/` + th.BgImage, `<a href="/about">About</a>`} {
+ if !strings.Contains(out, want) {
+ t.Errorf("preview missing %q:\n%s", want, out)
+ }
+ }
+ if strings.Contains(out, "mod-recent") || strings.Contains(out, "url(/media/") {
+ t.Errorf("preview: %s", out)
+ }
+ if css := th.CSS(); !strings.Contains(css, "url(/media/"+th.BgImage) {
+ t.Errorf("blog css: %s", css)
+ }
+}
+
// Announcements on post pages are on by default (the look blogs had before
// the switch) and, like every checkbox, off when the box is unticked; stored
// themes from before the option get the default too.
@@ -318,7 +349,7 @@ func TestHeaderRule(t *testing.T) {
logo := uuid.New().String()
render := func(kind string, th Theme) string {
var b strings.Builder
- root := view{Blog: &store.Blog{Title: "Gizmo", Tagline: "a hacklab"}, Data: map[string]any{"theme": th, "layout": Layout{}}}
+ root := view{Blog: &store.Blog{Title: "Gizmo", Tagline: "a hacklab"}, Media: "/media", Data: map[string]any{"theme": th, "layout": Layout{}}}
if err := tpl.ExecuteTemplate(&b, "module", map[string]any{"m": store.Module{Area: "header", Kind: kind}, "root": root}); err != nil {
t.Fatal(err)
}
@@ -748,7 +779,7 @@ func TestAllTemplatesParse(t *testing.T) {
for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html",
"dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/files.html", "dashboard/settings.html",
"dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html",
- "blog/page.html", "blog/post.html", "blog/tag.html", "blog/search.html", "blog/404.html"} {
+ "blog/page.html", "blog/post.html", "blog/tag.html", "blog/search.html", "blog/404.html", "blog/preview.html"} {
for _, l := range i18n.Languages() {
if _, err := tpl.get(l.Code, name); err != nil {
t.Errorf("%s (%s): %v", name, l.Code, err)