From 96336d8bacd2bdf81acf9a18e2fe57be9d4e9b4f Mon Sep 17 00:00:00 2001 From: grm Date: Fri, 18 Sep 2026 19:34:35 +0300 Subject: Show a live preview of the header on the Design tab's Menu card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/handlers_design.go | 35 ++++++++++++++++++++ internal/web/routes.go | 1 + internal/web/server.go | 3 +- internal/web/static/dashboard.css | 3 ++ internal/web/templates/blog/preview.html | 18 +++++++++++ internal/web/templates/dashboard/design.html | 48 +++++++++++++++++++++++++++- internal/web/templates/layouts/blog.html | 6 ++-- internal/web/theme.go | 17 +++++++--- internal/web/web_test.go | 37 +++++++++++++++++++-- 9 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 internal/web/templates/blog/preview.html (limited to 'internal/web') 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//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 + Media string // where the blog's files are served: /media, or /b//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"}} + + + + +{{t "Preview"}} + + + + +{{template "siteheader" .}} + +{{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 @@

{{t "A page or link filled in here is added when you save."}}

{{t "How the menu looks"}}

+ {{/* 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//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. */}} +
{{if $l.Fold}}