From d0601f06c080a8eb8d781fdd0e3338cd7ed42690 Mon Sep 17 00:00:00 2001 From: grm Date: Thu, 17 Sep 2026 16:42:15 +0300 Subject: Add post summaries, featured images and a page outro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recreating gizmolab.net as a blog showed three things the design system could not express, all of them ordinary blog features: - Listings can show a summary and a "Read more" link instead of the whole post (theme.list_style). The summary is the source up to ; without the marker a Markdown post is cut at a block boundary after 70 words (never inside a code fence) and an HTML post is shown whole, since cutting hand-written markup blind would leave tags open. Computed at render time (Post.Excerpt/HasMore), nothing new is stored. - A post can carry a featured image (posts.image, a library file that is unset when the file is deleted): a thumbnail floated beside the entry on listings, and on the post page above or below the text, or not at all, as theme.post_image says. The picker is the design page's imagepick; its library panel moves out of design.html into partials/imagelib.html so both forms share it — which also removes a stale pickAction call the design form's click handler would have hit. - A page can have a closing text after its posts (pages.outro_md/html), in the intro's format: the per-page counterpart of the blog-wide "after the posts" module, for a home page that ends in a site map or a widget. The editor partial takes formatof to share another editor's Format row, and its upload field is now _file so two editors fit in one form. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/handlers_blog.go | 2 + internal/web/handlers_pages.go | 9 ++- internal/web/handlers_posts.go | 73 +++++++++++++++++------- internal/web/handlers_sections.go | 2 +- internal/web/static/blog.css | 11 ++++ internal/web/static/dashboard.css | 1 + internal/web/summary.go | 62 ++++++++++++++++++++ internal/web/templates/blog/page.html | 3 +- internal/web/templates/blog/post.html | 3 + internal/web/templates/dashboard/design.html | 76 +++++-------------------- internal/web/templates/dashboard/page_form.html | 2 + internal/web/templates/dashboard/post_form.html | 2 + internal/web/templates/partials/editor.html | 11 ++-- internal/web/templates/partials/imagelib.html | 72 +++++++++++++++++++++++ internal/web/templates/partials/imagepick.html | 13 +++-- internal/web/templates/partials/mdhelp.html | 2 +- internal/web/templates/partials/postlist.html | 6 +- internal/web/theme.go | 8 ++- internal/web/web_test.go | 45 +++++++++++++++ 19 files changed, 304 insertions(+), 99 deletions(-) create mode 100644 internal/web/summary.go create mode 100644 internal/web/templates/partials/imagelib.html (limited to 'internal/web') diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go index 53ab891..c26adfa 100644 --- a/internal/web/handlers_blog.go +++ b/internal/web/handlers_blog.go @@ -107,6 +107,7 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store. return } last := (total + per - 1) / per + fillExcerpts(v["theme"].(Theme), posts) v["page"], v["posts"], v["pageNum"], v["lastPage"] = page, posts, n, last // The pager appends ?p=N to base: no trailing slash, which /{page} would not match. v["base"] = "/" + page.Slug @@ -140,6 +141,7 @@ func (s *Server) handleBlogTag(w http.ResponseWriter, r *http.Request) { return } last := (total + per - 1) / per + fillExcerpts(v["theme"].(Theme), posts) v["tag"], v["posts"], v["pageNum"], v["lastPage"], v["base"] = tag, posts, n, last, "/tag/"+tag.Slug s.render(w, r, "blog/tag.html", v) } diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go index cf72d39..40456f2 100644 --- a/internal/web/handlers_pages.go +++ b/internal/web/handlers_pages.go @@ -59,13 +59,18 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { p.Title = strings.TrimSpace(r.FormValue("title")) p.Slug = strings.TrimSpace(r.FormValue("slug")) p.IntroMD = strings.ReplaceAll(r.FormValue("intro"), "\r\n", "\n") + p.OutroMD = strings.ReplaceAll(r.FormValue("outro"), "\r\n", "\n") p.Format = pick(r.FormValue("format"), store.FormatMarkdown, store.FormatHTML) p.ShowInNav = r.FormValue("show_in_nav") == "on" autoSlug := p.Slug == "" if autoSlug { p.Slug = slug.Make(p.Title) } - img, err := s.readUpload(r, "inline_file", false) + img, err := s.readUpload(r, "intro_file", false) + outroImg, err2 := s.readUpload(r, "outro_file", false) + if err == nil { + err = err2 + } var msg string switch { case err != nil: @@ -81,6 +86,8 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { } p.IntroMD = appendFile(p.IntroMD, img, p.Format) p.IntroHTML = renderBody(p.Format, p.IntroMD) + p.OutroMD = appendFile(p.OutroMD, outroImg, p.Format) + p.OutroHTML = renderBody(p.Format, p.OutroMD) base := p.Slug for n := 1; ; n++ { // generated slugs get -2, -3… on collision; typed ones report the conflict p.Slug = slug.WithSuffix(base, n) diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go index e284e57..ec56138 100644 --- a/internal/web/handlers_posts.go +++ b/internal/web/handlers_posts.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/gramanas/blogspace/internal/markdown" "github.com/gramanas/blogspace/internal/slug" "github.com/gramanas/blogspace/internal/store" @@ -48,17 +49,39 @@ func (s *Server) loadPost(w http.ResponseWriter, r *http.Request) *store.Post { return p } -func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) { - pages, err := blogStore(r).ListPages(r.Context()) +// postFormData is what the post form needs besides the post: the pages to +// file it under, the tags to pick from, and the library images for the +// featured-image picker (the recent ones; the chosen one is added by name). +func (s *Server) postFormData(r *http.Request, p *store.Post) (map[string]any, error) { + bs := blogStore(r) + pages, err := bs.ListPages(r.Context()) if err != nil { - s.serverError(w, err) - return + return nil, err } - allTags, err := blogStore(r).ListTags(r.Context()) + allTags, err := bs.ListTags(r.Context()) if err != nil { - s.serverError(w, err) - return + return nil, err + } + images, imageCount, err := bs.ListFiles(r.Context(), "image", "", recentImages, 0) + if err != nil { + return nil, err + } + names := map[string]string{} + for _, f := range images { + names[f.ID.String()] = f.Filename } + if p.Image != "" && names[p.Image] == "" { + if f, err := bs.FileMeta(r.Context(), uuid.MustParse(p.Image)); err == nil { + names[p.Image] = f.Filename + } else { // gone from the library (or never there): the post has no image + p.Image = "" + } + } + return map[string]any{"post": p, "pages": pages, "allTags": allTags, "tagsText": tagsText(p.Tags), + "images": images, "imageNames": names, "imageCount": imageCount}, nil +} + +func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) { p := &store.Post{Published: true, CreatedAt: time.Now()} if r.PathValue("id") != "" { if p = s.loadPost(w, r); p == nil { @@ -69,21 +92,16 @@ func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) { } else if hp, err := blogStore(r).HomePage(r.Context()); err == nil { p.PageID = hp.ID } - s.render(w, r, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "allTags": allTags, "tagsText": tagsText(p.Tags)}) -} - -func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { - blog := currentBlog(r) - pages, err := blogStore(r).ListPages(r.Context()) - if err != nil { - s.serverError(w, err) - return - } - allTags, err := blogStore(r).ListTags(r.Context()) + d, err := s.postFormData(r, p) if err != nil { s.serverError(w, err) return } + s.render(w, r, "dashboard/post_form.html", d) +} + +func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { + blog := currentBlog(r) p := &store.Post{CreatedAt: time.Now()} if r.PathValue("id") != "" { if p = s.loadPost(w, r); p == nil { @@ -96,15 +114,22 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { p.Format = pick(r.FormValue("format"), store.FormatMarkdown, store.FormatHTML) p.Published = r.FormValue("published") == "on" p.PageID, _ = strconv.ParseInt(r.FormValue("page_id"), 10, 64) + p.Image = imageID(pickImage(p.Image, r.FormValue("image"))) tags, tagsOK := parseTags(strings.Split(r.FormValue("tags"), ",")) p.Tags = tags autoSlug := p.Slug == "" if autoSlug { p.Slug = slug.Make(p.Title) } + d, err := s.postFormData(r, p) + if err != nil { + s.serverError(w, err) + return + } + pages := d["pages"].([]store.Page) fail := func(status int, msg string) { - s.renderStatus(w, r, status, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "error": msg, - "allTags": allTags, "tagsText": r.FormValue("tags")}) + d["error"], d["tagsText"] = msg, r.FormValue("tags") + s.renderStatus(w, r, status, "dashboard/post_form.html", d) } if at := strings.TrimSpace(r.FormValue("posted_at")); at != "" { // blank keeps the current date var ok bool @@ -113,12 +138,18 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { return } } - img, err := s.readUpload(r, "inline_file", false) + img, err := s.readUpload(r, "body_file", false) if err != nil { fail(http.StatusBadRequest, s.tr(r, "File not added:")+" "+err.Error()) return } p.BodyMD = appendFile(p.BodyMD, img, p.Format) + if f, err := s.readUpload(r, "image_file", true); err != nil { // an upload beats the picker + fail(http.StatusBadRequest, s.tr(r, "Image not added:")+" "+err.Error()) + return + } else if f != nil { + p.Image = f.ID.String() + } pageOK := false for _, pg := range pages { if pg.ID == p.PageID { diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go index 77ac90d..8d6c9c9 100644 --- a/internal/web/handlers_sections.go +++ b/internal/web/handlers_sections.go @@ -73,7 +73,7 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) { fail := func(msg string) { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/section_form.html", map[string]any{"section": sec, "error": msg}) } - img, err := s.readUpload(r, "inline_file", false) + img, err := s.readUpload(r, "body_file", false) switch { case err != nil: fail(s.tr(r, "File not added:") + " " + err.Error()) diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css index 4fd7b76..ace5e5a 100644 --- a/internal/web/static/blog.css +++ b/internal/web/static/blog.css @@ -18,11 +18,21 @@ img { max-width: 100%; height: auto; } .content { padding: 1.5em 2em; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.15); } .page-title { margin-top: 0; } .intro { padding-bottom: 1em; margin-bottom: 1.5em; border-bottom: 1px solid rgba(0,0,0,0.1); } +.outro { padding-top: 1em; margin-top: 1.5em; border-top: 1px solid rgba(0,0,0,0.1); } .post { margin-bottom: 2.5em; } .post-title { margin: 0 0 0.1em; font-size: 1.5em; line-height: 1.25; } .post-title a { text-decoration: none; } .post-title a:hover { text-decoration: underline; } .post-date { margin: 0 0 0.8em; font-size: 0.85em; opacity: 0.7; } +/* featured image: a thumbnail floated beside the listing entry, full width on the post page */ +.post-thumb { float: right; width: 30%; max-width: 250px; margin: 0.3em 0 0.5em 1.5em; } +.post-thumb img { display: block; width: 100%; } +.post.has-thumb { overflow: hidden; } +.post-image { margin: 0 0 1em; } +.post-image img { display: block; max-width: 100%; margin: 0 auto; } +.read-more { margin: 0.3em 0 0; font-weight: bold; } +.read-more a { text-decoration: none; } +.read-more a:hover { text-decoration: underline; } .post.single .post-title { font-size: 2em; } .post-body blockquote { margin: 1em 0; padding: 0.2em 1em; border-left: 4px solid rgba(0,0,0,0.15); opacity: 0.9; } .post-body pre { overflow-x: auto; padding: 0.8em 1em; background: rgba(0,0,0,0.06); border-radius: 4px; } @@ -102,6 +112,7 @@ img { max-width: 100%; height: auto; } .col .notice { font-size: 0.9em; padding: 0.6em 0.8em; } @media (max-width: 700px) { .content { padding: 1em; } + .post-thumb { width: 40%; margin-left: 1em; } .site-title { font-size: 1.5em; } .cols { display: block; } .col-left, .col-right { padding: 0; margin-top: 1.5em; } diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css index 32561bf..f5e0fd4 100644 --- a/internal/web/static/dashboard.css +++ b/internal/web/static/dashboard.css @@ -208,6 +208,7 @@ table.files .copy { width: 100%; min-width: 11em; padding: 0.3em 0.4em; margin: /* what only makes sense with, or without, the script */ form.js .nojs { display: none !important; } form:not(.js) .jsonly { display: none !important; } +.imagepick.js .ip-nojs, .imagepick:not(.js) .ip-jsonly { display: none !important; } .presets { display: flex; flex-wrap: wrap; gap: 0.8em; } .presets .preset { width: 8.5em; margin: 0; padding: 0.55em 0.45em; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.8em; font-weight: 500; text-align: center; } .presets .preset:hover { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); } diff --git a/internal/web/summary.go b/internal/web/summary.go new file mode 100644 index 0000000..bc48e94 --- /dev/null +++ b/internal/web/summary.go @@ -0,0 +1,62 @@ +package web + +import ( + "strings" + + "github.com/gramanas/blogspace/internal/store" +) + +// Listings can show a summary of each post instead of the whole thing +// (theme.list_style). The blogger marks where the summary ends with +// in the source; without the marker a Markdown post is cut after +// its first summaryWords words, at a block boundary, and an HTML post is shown +// whole (cutting hand-written markup blind would leave tags open). + +const ( + moreMarker = "" + summaryWords = 70 +) + +// fillExcerpts sets what the list shows of each post: the summary when the +// theme asks for one, the whole body otherwise. +func fillExcerpts(theme Theme, posts []store.Post) { + for i := range posts { + p := &posts[i] + p.Excerpt, p.HasMore = p.BodyHTML, false + if theme.ListStyle == "summary" { + p.Excerpt, p.HasMore = postSummary(p) + } + } +} + +// postSummary renders the part of the post a listing shows, and says whether +// there is more to read. +func postSummary(p *store.Post) (string, bool) { + if before, _, ok := strings.Cut(p.BodyMD, moreMarker); ok { + return renderBody(p.Format, before), true + } + if p.Format == store.FormatHTML { + return p.BodyHTML, false + } + head, cut := leadingBlocks(p.BodyMD, summaryWords) + if !cut { + return p.BodyHTML, false + } + return renderBody(p.Format, head), true +} + +// leadingBlocks returns the blank-line separated blocks from the start of the +// Markdown up to the first that brings the word count to n, and whether +// anything was left out. A fenced code block is never cut in two. +func leadingBlocks(md string, n int) (string, bool) { + blocks := strings.Split(md, "\n\n") + words, fences := 0, 0 + for i, b := range blocks { + words += len(strings.Fields(b)) + fences += strings.Count(b, "```") + if words >= n && fences%2 == 0 { + return strings.Join(blocks[:i+1], "\n\n"), i+1 < len(blocks) + } + } + return md, false +} diff --git a/internal/web/templates/blog/page.html b/internal/web/templates/blog/page.html index 99403ef..6f833f8 100644 --- a/internal/web/templates/blog/page.html +++ b/internal/web/templates/blog/page.html @@ -2,5 +2,6 @@ {{define "content"}} {{if not .Data.page.IsHome}}

{{.Data.page.Title}}

{{end}} {{if .Data.page.IntroHTML}}
{{html .Data.page.IntroHTML}}
{{end}} -{{if or .Data.posts .Data.page.IntroHTML}}{{template "postlist" .}}{{else}}

{{t "Nothing here yet."}}

{{end}} +{{if or .Data.posts .Data.page.IntroHTML .Data.page.OutroHTML}}{{template "postlist" .}}{{else}}

{{t "Nothing here yet."}}

{{end}} +{{if .Data.page.OutroHTML}}
{{html .Data.page.OutroHTML}}
{{end}} {{end}} diff --git a/internal/web/templates/blog/post.html b/internal/web/templates/blog/post.html index a3b0375..10d4c3a 100644 --- a/internal/web/templates/blog/post.html +++ b/internal/web/templates/blog/post.html @@ -4,6 +4,9 @@

{{.Data.post.Title}}

{{template "posttags" .Data.post.Tags}} + {{$img := .Data.post.Image}}{{$place := .Data.theme.PostImage}} + {{if and $img (eq $place "top")}}

{{end}}
{{html .Data.post.BodyHTML}}
+ {{if and $img (eq $place "bottom")}}

{{end}} {{end}} diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html index 261eb69..71597e8 100644 --- a/internal/web/templates/dashboard/design.html +++ b/internal/web/templates/dashboard/design.html @@ -181,6 +181,18 @@ {{range .Data.perPage}}{{end}} +
+ + +
+

{{t "A summary is the post up to a line with , or its first paragraphs. A post's featured image is picked on the post form and shown as a thumbnail on listings."}}

{{t "Around the posts"}}

{{t "Your own HTML before and after the posts — an intro box, a newsletter form, a badge…"}}

{{template "arealist" (dict "area" "above" "root" .)}} @@ -260,19 +272,8 @@ -{{/* The library panel: filled by the script from /files?kind=image as JSON, one page at a time. */}} - +{{/* The library panel behind the image pickers (partials/imagelib.html). */}} +{{template "imagelib" (dict "sub" $sub "count" .Data.imageCount)}} {{end}} diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html index 4754c91..18b0a86 100644 --- a/internal/web/templates/dashboard/page_form.html +++ b/internal/web/templates/dashboard/page_form.html @@ -9,6 +9,8 @@ {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "format" .Data.page.Format "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}} + + {{template "editor" (dict "name" "outro" "value" .Data.page.OutroMD "format" .Data.page.Format "formatof" "intro" "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}}

diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html index 70cddcc..9bd7624 100644 --- a/internal/web/templates/dashboard/post_form.html +++ b/internal/web/templates/dashboard/post_form.html @@ -39,6 +39,7 @@ {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "format" .Data.post.Format "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}} + {{template "imagepick" (dict "name" "image" "current" .Data.post.Image "sub" .Blog.Subdomain "images" .Data.images "names" .Data.imageNames "label" (t "Featured image") "hint" (t "Optional. A thumbnail on listings, and on the post page where the Design tab puts it."))}}

@@ -47,4 +48,5 @@

+{{template "imagelib" (dict "sub" .Blog.Subdomain "count" .Data.imageCount)}} {{end}} diff --git a/internal/web/templates/partials/editor.html b/internal/web/templates/partials/editor.html index a0f8c7f..cfc5642 100644 --- a/internal/web/templates/partials/editor.html +++ b/internal/web/templates/partials/editor.html @@ -1,10 +1,13 @@ +{{/* A second editor in the same form (a page's outro) shares the first one's + Format row: pass "formatof" with that editor's name and no radios are + rendered, the script watches the other row instead. */}} {{define "editor"}}
-

+{{if not .formatof}}

{{t "Format"}}: {{t "Put on your blog exactly as written — embeds and scripts all work. An unclosed tag can break the page around it, so check your blog after saving."}} -

+

{{end}}