diff options
| author | grm <grm@eyesin.space> | 2026-09-17 16:42:15 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-17 16:42:15 +0300 |
| commit | d0601f06c080a8eb8d781fdd0e3338cd7ed42690 (patch) | |
| tree | 1931e4312ee2db15dfd0a99583c8ebadf0393e2b /internal | |
| parent | 4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88 (diff) | |
| download | blogspace-d0601f06c080a8eb8d781fdd0e3338cd7ed42690.tar.gz blogspace-d0601f06c080a8eb8d781fdd0e3338cd7ed42690.tar.bz2 blogspace-d0601f06c080a8eb8d781fdd0e3338cd7ed42690.zip | |
Add post summaries, featured images and a page outro
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 <!--more-->;
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 <name>_file so two editors fit in one form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
Diffstat (limited to 'internal')
24 files changed, 372 insertions, 116 deletions
diff --git a/internal/db/migrations/blog/00007_post_image.sql b/internal/db/migrations/blog/00007_post_image.sql new file mode 100644 index 0000000..75858a4 --- /dev/null +++ b/internal/db/migrations/blog/00007_post_image.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- A post's featured image: a library image shown as a thumbnail on listings +-- and on the post page. Deleting the file just unsets it. +ALTER TABLE posts ADD COLUMN image uuid REFERENCES files(id) ON DELETE SET NULL; + +-- +goose Down +ALTER TABLE posts DROP COLUMN image; diff --git a/internal/db/migrations/blog/00008_page_outro.sql b/internal/db/migrations/blog/00008_page_outro.sql new file mode 100644 index 0000000..e795df0 --- /dev/null +++ b/internal/db/migrations/blog/00008_page_outro.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- A page can carry text after its posts as well as before them, in the same +-- format as the intro (one format column per page). +ALTER TABLE pages + ADD COLUMN outro_md text NOT NULL DEFAULT '', + ADD COLUMN outro_html text NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE pages DROP COLUMN outro_md, DROP COLUMN outro_html; diff --git a/internal/i18n/el.go b/internal/i18n/el.go index 15270c1..3c70fb2 100644 --- a/internal/i18n/el.go +++ b/internal/i18n/el.go @@ -100,7 +100,12 @@ var el = map[string]string{ "not in menu": "εκτός μενού", "Make home": "Ορισμός ως αρχικής", "leave empty to make one from the title; e.g.": "αφήστε το κενό για να φτιαχτεί από τον τίτλο· π.χ.", - "Intro text": "Εισαγωγικό κείμενο", + "Intro text": "Εισαγωγικό κείμενο", + "Closing text": "Κείμενο κλεισίματος", + "shown after the posts, in the same format; optional": "εμφανίζεται μετά τις δημοσιεύσεις, στην ίδια μορφή· προαιρετικό", + "Featured image": "Κύρια εικόνα", + "Optional. A thumbnail on listings, and on the post page where the Design tab puts it.": "Προαιρετική. Μικρογραφία στις λίστες, και στη σελίδα της δημοσίευσης όπου την τοποθετεί η καρτέλα Σχεδίαση.", + "Image not added:": "Η εικόνα δεν προστέθηκε:", "shown above the posts; optional": "εμφανίζεται πάνω από τις δημοσιεύσεις· προαιρετικό", "Show in menu": "Εμφάνιση στο μενού", "Back to pages": "Πίσω στις σελίδες", @@ -236,6 +241,8 @@ var el = map[string]string{ "code": "κώδικας", "for a line": "για γραμμή", "Blank line = new paragraph. Press Enter once for a line break.": "Κενή γραμμή = νέα παράγραφος. Ένα Enter για αλλαγή γραμμής.", + "A line with": "Μια γραμμή με", + "marks where the summary ends on listings that show one.": "ορίζει πού τελειώνει η περίληψη στις λίστες που δείχνουν περίληψη.", // ---- settings ---- "Blog settings": "Ρυθμίσεις ιστολογίου", @@ -284,6 +291,7 @@ var el = map[string]string{ "My photos": "Οι φωτογραφίες μου", "no logo chosen": "δεν έχει επιλεγεί λογότυπο", "Nothing here yet.": "Τίποτα εδώ ακόμα.", + "Read more": "Διάβασε περισσότερα", "Nothing here yet — this area is not shown until you add a module.": "Τίποτα εδώ ακόμα — η περιοχή δεν εμφανίζεται μέχρι να προσθέσετε ένα στοιχείο.", "Add a module": "Προσθήκη στοιχείου", "Give the link a short text (max 60 characters).": "Δώστε στον σύνδεσμο ένα σύντομο κείμενο (έως 60 χαρακτήρες).", @@ -466,7 +474,15 @@ var el = map[string]string{ "A page or link filled in here is added when you save.": "Μια σελίδα ή ένας σύνδεσμος που συμπληρώνετε εδώ προστίθεται όταν αποθηκεύσετε.", "How the menu looks": "Πώς φαίνεται το μενού", "The box your posts and pages are shown in. It spans whatever the side columns leave.": "Το πλαίσιο στο οποίο εμφανίζονται οι δημοσιεύσεις και οι σελίδες σας. Απλώνεται σε ό,τι αφήνουν οι πλαϊνές στήλες.", - "Posts per page": "Δημοσιεύσεις ανά σελίδα", + "Posts per page": "Δημοσιεύσεις ανά σελίδα", + "On listings show": "Στις λίστες δείξε", + "The whole post": "Ολόκληρη τη δημοσίευση", + "A summary and a “Read more” link": "Περίληψη και σύνδεσμο «Διάβασε περισσότερα»", + "Featured image on the post page": "Κύρια εικόνα στη σελίδα της δημοσίευσης", + "Above the text": "Πάνω από το κείμενο", + "Below the text": "Κάτω από το κείμενο", + "Only on listings": "Μόνο στις λίστες", + "A summary is the post up to a line with <!--more-->, or its first paragraphs. A post's featured image is picked on the post form and shown as a thumbnail on listings.": "Περίληψη είναι η δημοσίευση μέχρι μια γραμμή με <!--more-->, αλλιώς οι πρώτες της παράγραφοι. Η κύρια εικόνα επιλέγεται στη φόρμα της δημοσίευσης και εμφανίζεται ως μικρογραφία στις λίστες.", "Around the posts": "Γύρω από τις δημοσιεύσεις", "Your own HTML before and after the posts — an intro box, a newsletter form, a badge…": "Δικό σας HTML πριν και μετά τις δημοσιεύσεις — ένα εισαγωγικό πλαίσιο, μια φόρμα newsletter, ένα σήμα…", "Show the left column": "Εμφάνιση της αριστερής στήλης", diff --git a/internal/store/pages.go b/internal/store/pages.go index 74da379..08af9fe 100644 --- a/internal/store/pages.go +++ b/internal/store/pages.go @@ -11,7 +11,9 @@ type Page struct { Title string IntroMD string IntroHTML string - Format string // FormatMarkdown | FormatHTML: how IntroMD is read + OutroMD string // shown after the posts + OutroHTML string + Format string // FormatMarkdown | FormatHTML: how IntroMD and OutroMD are read NavOrder int ShowInNav bool // derived: the page has a menu entry IsHome bool @@ -19,11 +21,11 @@ type Page struct { PostCount int // filled by ListPages only } -const pageCols = `id, slug, title, intro_md, intro_html, format, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), is_home, created_at` +const pageCols = `id, slug, title, intro_md, intro_html, outro_md, outro_html, format, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), is_home, created_at` func scanPage(row interface{ Scan(...any) error }) (*Page, error) { var p Page - err := row.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt) + err := row.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.OutroMD, &p.OutroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt) if err != nil { return nil, wrap(err) } @@ -40,7 +42,7 @@ func (bs *BlogStore) ListPages(ctx context.Context) ([]Page, error) { var out []Page for rows.Next() { var p Page - if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil { + if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.OutroMD, &p.OutroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil { return nil, err } out = append(out, p) @@ -68,9 +70,9 @@ func (bs *BlogStore) CreatePage(ctx context.Context, p *Page) (*Page, error) { return nil, err } defer tx.Rollback(ctx) - created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (slug, title, intro_md, intro_html, format, nav_order) - VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(nav_order),-1)+1 FROM pages)) RETURNING `+pageCols, - p.Slug, p.Title, p.IntroMD, p.IntroHTML, formatOrMD(p.Format))) + created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (slug, title, intro_md, intro_html, outro_md, outro_html, format, nav_order) + VALUES ($1,$2,$3,$4,$5,$6,$7,(SELECT coalesce(max(nav_order),-1)+1 FROM pages)) RETURNING `+pageCols, + p.Slug, p.Title, p.IntroMD, p.IntroHTML, p.OutroMD, p.OutroHTML, formatOrMD(p.Format))) if err != nil { return nil, err } @@ -90,8 +92,8 @@ func (bs *BlogStore) UpdatePage(ctx context.Context, p *Page) error { return err } defer tx.Rollback(ctx) - if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$2, title=$3, intro_md=$4, intro_html=$5, format=$6 WHERE id=$1`, - p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML, formatOrMD(p.Format)); err != nil { + if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$2, title=$3, intro_md=$4, intro_html=$5, outro_md=$6, outro_html=$7, format=$8 WHERE id=$1`, + p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML, p.OutroMD, p.OutroHTML, formatOrMD(p.Format)); err != nil { return wrap(err) } if p.ShowInNav { diff --git a/internal/store/posts.go b/internal/store/posts.go index 0e01e9c..368a4de 100644 --- a/internal/store/posts.go +++ b/internal/store/posts.go @@ -13,6 +13,7 @@ type Post struct { BodyMD string BodyHTML string Format string // FormatMarkdown | FormatHTML: how BodyMD is read + Image string // featured image: a file id, or "" Published bool CreatedAt time.Time UpdatedAt time.Time @@ -20,21 +21,29 @@ type Post struct { PageSlug string PageTitle string Tags []Tag // by name; IDs are not loaded + // filled by the web layer for listings: what the list shows of the body, + // and whether that is less than all of it + Excerpt string + HasMore bool } // The tags come along as two parallel arrays (names and slugs, both by name) // so every post query stays a single round trip; the alias p is the posts row. -const postCols = `p.id, p.page_id, p.slug, p.title, p.body_md, p.body_html, p.format, p.published, p.created_at, p.updated_at, g.slug, g.title, +const postCols = `p.id, p.page_id, p.slug, p.title, p.body_md, p.body_html, p.format, p.image, p.published, p.created_at, p.updated_at, g.slug, g.title, coalesce((SELECT array_agg(t.name ORDER BY t.name) FROM post_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.post_id=p.id), '{}'), coalesce((SELECT array_agg(t.slug ORDER BY t.name) FROM post_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.post_id=p.id), '{}')` func scanPost(row interface{ Scan(...any) error }) (*Post, error) { var p Post var names, slugs []string - err := row.Scan(&p.ID, &p.PageID, &p.Slug, &p.Title, &p.BodyMD, &p.BodyHTML, &p.Format, &p.Published, &p.CreatedAt, &p.UpdatedAt, &p.PageSlug, &p.PageTitle, &names, &slugs) + var image *string + err := row.Scan(&p.ID, &p.PageID, &p.Slug, &p.Title, &p.BodyMD, &p.BodyHTML, &p.Format, &image, &p.Published, &p.CreatedAt, &p.UpdatedAt, &p.PageSlug, &p.PageTitle, &names, &slugs) if err != nil { return nil, wrap(err) } + if image != nil { + p.Image = *image + } for i := range names { p.Tags = append(p.Tags, Tag{Name: names[i], Slug: slugs[i]}) } @@ -140,8 +149,8 @@ func (bs *BlogStore) CreatePost(ctx context.Context, p *Post) (*Post, error) { p.CreatedAt = time.Now() } var id int64 - err := bs.db.QueryRow(ctx, `INSERT INTO posts (page_id, slug, title, body_md, body_html, format, published, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`, - p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, formatOrMD(p.Format), p.Published, p.CreatedAt).Scan(&id) + err := bs.db.QueryRow(ctx, `INSERT INTO posts (page_id, slug, title, body_md, body_html, format, image, published, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, + p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, formatOrMD(p.Format), nullIfEmpty(p.Image), p.Published, p.CreatedAt).Scan(&id) if err != nil { return nil, wrap(err) } @@ -149,8 +158,8 @@ func (bs *BlogStore) CreatePost(ctx context.Context, p *Post) (*Post, error) { } func (bs *BlogStore) UpdatePost(ctx context.Context, p *Post) error { - _, err := bs.db.Exec(ctx, `UPDATE posts SET page_id=$2, slug=$3, title=$4, body_md=$5, body_html=$6, format=$7, published=$8, created_at=$9, updated_at=now() WHERE id=$1`, - p.ID, p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, formatOrMD(p.Format), p.Published, p.CreatedAt) + _, err := bs.db.Exec(ctx, `UPDATE posts SET page_id=$2, slug=$3, title=$4, body_md=$5, body_html=$6, format=$7, image=$8, published=$9, created_at=$10, updated_at=now() WHERE id=$1`, + p.ID, p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, formatOrMD(p.Format), nullIfEmpty(p.Image), p.Published, p.CreatedAt) return wrap(err) } @@ -160,3 +169,11 @@ func (bs *BlogStore) DeletePost(ctx context.Context, id int64) error { } return deleteOrphanTags(ctx, bs.db) } + +// nullIfEmpty is how an optional id goes into a nullable uuid column. +func nullIfEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} 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 +// <!--more--> 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 = "<!--more-->" + 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}}<h1 class="page-title">{{.Data.page.Title}}</h1>{{end}} {{if .Data.page.IntroHTML}}<div class="intro">{{html .Data.page.IntroHTML}}</div>{{end}} -{{if or .Data.posts .Data.page.IntroHTML}}{{template "postlist" .}}{{else}}<p class="muted">{{t "Nothing here yet."}}</p>{{end}} +{{if or .Data.posts .Data.page.IntroHTML .Data.page.OutroHTML}}{{template "postlist" .}}{{else}}<p class="muted">{{t "Nothing here yet."}}</p>{{end}} +{{if .Data.page.OutroHTML}}<div class="outro">{{html .Data.page.OutroHTML}}</div>{{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 @@ <h1 class="post-title">{{.Data.post.Title}}</h1> <p class="post-date">{{if .Data.theme.ShowDates}}{{postdate .Data.theme.DateFormat .Data.post.CreatedAt}} · {{end}}<a href="{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}">{{.Data.page.Title}}</a></p> {{template "posttags" .Data.post.Tags}} + {{$img := .Data.post.Image}}{{$place := .Data.theme.PostImage}} + {{if and $img (eq $place "top")}}<p class="post-image"><img src="/media/{{$img}}" alt=""></p>{{end}} <div class="post-body">{{html .Data.post.BodyHTML}}</div> + {{if and $img (eq $place "bottom")}}<p class="post-image"><img src="/media/{{$img}}" alt=""></p>{{end}} </article> {{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}}<option value="{{.}}"{{if eq . $t.PostsPerPage}} selected{{end}}>{{.}}</option>{{end}} </select></label> </div> + <div class="row"> + <label>{{t "On listings show"}}<br><select name="list_style"> + <option value="full"{{if eq $t.ListStyle "full"}} selected{{end}}>{{t "The whole post"}}</option> + <option value="summary"{{if eq $t.ListStyle "summary"}} selected{{end}}>{{t "A summary and a “Read more” link"}}</option> + </select></label> + <label>{{t "Featured image on the post page"}}<br><select name="post_image"> + <option value="top"{{if eq $t.PostImage "top"}} selected{{end}}>{{t "Above the text"}}</option> + <option value="bottom"{{if eq $t.PostImage "bottom"}} selected{{end}}>{{t "Below the text"}}</option> + <option value="list"{{if eq $t.PostImage "list"}} selected{{end}}>{{t "Only on listings"}}</option> + </select></label> + </div> + <p class="muted small">{{t "A summary is the post up to a line with <!--more-->, or its first paragraphs. A post's featured image is picked on the post form and shown as a thumbnail on listings."}}</p> <h3>{{t "Around the posts"}}</h3> <p class="muted small">{{t "Your own HTML before and after the posts — an intro box, a newsletter form, a badge…"}}</p> {{template "arealist" (dict "area" "above" "root" .)}} @@ -260,19 +272,8 @@ </details> </div> -{{/* The library panel: filled by the script from /files?kind=image as JSON, one page at a time. */}} -<div class="picker" id="picker" hidden> - <div class="picker-box"> - <div class="picker-head"> - <input type="search" placeholder="{{t "Filter by name…"}}" aria-label="{{t "Filter by name…"}}"> - <span class="muted small pcount">{{tf "%d images" .Data.imageCount}}</span> - <button type="button" class="secondary small" data-pick="close">{{t "Close"}}</button> - </div> - <div class="picks"></div> - <p class="muted small pnone" hidden>{{t "No images match."}}</p> - <div class="pager"><button type="button" class="mini" data-pick="prev">← {{t "Newer"}}</button> <span class="pnum"></span> <button type="button" class="mini" data-pick="next">{{t "Older"}} →</button></div> - </div> -</div> +{{/* The library panel behind the image pickers (partials/imagelib.html). */}} +{{template "imagelib" (dict "sub" $sub "count" .Data.imageCount)}} <script> (function () { @@ -303,7 +304,7 @@ return row; } form.addEventListener("click", function (e) { - var b = e.target.closest("[data-act],[data-menu],[data-pick],.preset"); + var b = e.target.closest("[data-act],[data-menu],.preset"); // the image pickers are imagelib's if (!b) return; var row = b.closest(".modrow"), list = row && row.parentNode; switch (b.getAttribute("data-act")) { @@ -336,7 +337,6 @@ q("[name=link_hover_custom]").checked = q("[name=nav_hover_custom]").checked = false; touch(); return; } - pickAction(b); }); form.addEventListener("change", function (e) { var t = e.target; @@ -379,52 +379,6 @@ q("[name=at]").value = section(); // so the page comes back to the section in view }); - // ---- the image library panel: one shared panel, fetched page by page - var panel = document.getElementById("picker"), pick = null, page = 1, filter = "", timer; - if (!window.fetch) { qa(".imagepick .jsonly").forEach(function (el) { el.hidden = true; }); qa(".imagepick select").forEach(function (el) { el.className = ""; }); return; } - function pickAction(b) { - var act = b.getAttribute("data-pick"), ip = b.closest(".imagepick"); - if (act === "open") { pick = ip; panel.hidden = false; q("input", panel).value = filter = ""; load(1); q("input", panel).focus(); } - else if (act === "clear") { choose(ip, "none", ""); } - } - panel.addEventListener("click", function (e) { - var b = e.target.closest("[data-pick],.pick"); - if (!b) return; - if (b.className.indexOf("pick") === 0) { choose(pick, b.getAttribute("data-id"), b.getAttribute("data-name")); panel.hidden = true; return; } - switch (b.getAttribute("data-pick")) { - case "close": panel.hidden = true; break; - case "prev": load(page - 1); break; - case "next": load(page + 1); break; - } - }); - panel.addEventListener("keydown", function (e) { if (e.key === "Escape") panel.hidden = true; }); - q("input", panel).addEventListener("input", function () { clearTimeout(timer); timer = setTimeout(function () { filter = q("input", panel).value.trim(); load(1); }, 250); }); - function load(n) { - var url = "/b/" + form.getAttribute("data-sub") + "/files?kind=image&p=" + n + (filter ? "&q=" + encodeURIComponent(filter) : ""); - fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" }).then(function (r) { return r.json(); }).then(function (d) { - page = d.page; - var grid = q(".picks", panel); grid.innerHTML = ""; - (d.files || []).forEach(function (f) { - var b = document.createElement("button"), img = document.createElement("img"), s = document.createElement("span"); - b.type = "button"; b.className = "pick"; b.setAttribute("data-id", f.id); b.setAttribute("data-name", f.filename); - img.loading = "lazy"; img.src = "/b/" + form.getAttribute("data-sub") + "/media/" + f.id; img.alt = ""; - s.className = "name"; s.textContent = f.filename; - b.appendChild(img); b.appendChild(s); grid.appendChild(b); - }); - q(".pnone", panel).hidden = !!(d.files && d.files.length); - q(".pnum", panel).textContent = d.last > 1 ? d.page + " / " + d.last : ""; - q("[data-pick=prev]", panel).disabled = d.page <= 1; q("[data-pick=next]", panel).disabled = d.page >= d.last; - }); - } - function choose(ip, id, name) { // the select stays the value the form sends - var sel = q("select", ip), pv = q(".preview", ip), found = false; - for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === id) found = true; - if (!found) { var o = document.createElement("option"); o.value = id; o.textContent = name; sel.appendChild(o); } - sel.value = id; - pv.innerHTML = id === "none" ? '<span class="swatch">×</span>' : ""; - if (id !== "none") { var img = document.createElement("img"); img.src = "/b/" + form.getAttribute("data-sub") + "/media/" + id; img.alt = ""; pv.appendChild(img); } - touch(); - } })(); </script> {{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 @@ <input name="slug" value="{{.Data.page.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label> <label for="ed-intro">{{t "Intro text"}} <span class="muted">({{t "shown above the posts; optional"}})</span></label> {{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)}} + <label for="ed-outro">{{t "Closing text"}} <span class="muted">({{t "shown after the posts, in the same format; optional"}})</span></label> + {{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)}} <label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> {{t "Show in menu"}}</label> <p class="actionrow"> <button type="submit">{{t "Save"}}</button> 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 @@ </script> <label for="ed-body">{{t "Content"}}</label> {{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."))}} <label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> {{t "Visible on the blog"}}</label> <p class="actionrow"> <button type="submit">{{t "Save"}}</button> @@ -47,4 +48,5 @@ </p> </form> </div> +{{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"}}<div class="editor{{if eq .format "html"}} html{{end}}" id="ed-{{.name}}-wrap"> -<p class="ed-format" id="ed-{{.name}}-format"> +{{if not .formatof}}<p class="ed-format" id="ed-{{.name}}-format"> <span class="muted">{{t "Format"}}:</span> <label><input type="radio" name="format" value="markdown"{{if ne .format "html"}} checked{{end}}> Markdown</label> <label><input type="radio" name="format" value="html"{{if eq .format "html"}} checked{{end}}> HTML</label> <span class="muted small ed-htmlnote">{{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."}}</span> -</p> +</p>{{end}} <div class="ed-toolbar" id="ed-{{.name}}-bar" hidden> <span class="ed-group ed-md"> <button type="button" data-cmd="bold" title="{{t "Bold"}} (Ctrl+B)"><svg viewBox="0 0 16 16"><path d="M4 2h5a3.5 3.5 0 0 1 2.4 6 3.75 3.75 0 0 1-2.2 6.8H4zM6 4v3.5h3a1.75 1.75 0 0 0 0-3.5zm0 5.5V13h3.2a1.75 1.75 0 0 0 0-3.5z"/></svg></button> @@ -41,7 +44,7 @@ <div class="ed-preview" id="ed-{{.name}}-preview" hidden></div> <iframe class="ed-preview-frame" id="ed-{{.name}}-frame" sandbox title="{{t "Preview"}}" hidden></iframe> <div class="editor-tools"> - <label class="upload">{{t "Insert file"}} <input type="file" name="inline_file" id="ed-{{.name}}-file"></label> + <label class="upload">{{t "Insert file"}} <input type="file" name="{{.name}}_file" id="ed-{{.name}}-file"></label> <span class="upload-status" id="ed-{{.name}}-status"></span> <span class="muted small">{{t "…or paste / drop a file into the text. Images are shown, other files linked."}}</span> <noscript><span class="muted small">{{t "The file is added at the end of the text when you save."}}</span></noscript> @@ -52,7 +55,7 @@ (function () { var id = "ed-{{.name}}", ta = document.getElementById(id), inp = document.getElementById(id + "-file"), st = document.getElementById(id + "-status"), wrap = document.getElementById(id + "-wrap"), bar = document.getElementById(id + "-bar"), lk = document.getElementById(id + "-link"), pv = document.getElementById(id + "-preview"), - frame = document.getElementById(id + "-frame"), fmt = document.getElementById(id + "-format"); + frame = document.getElementById(id + "-frame"), fmt = document.getElementById({{if .formatof}}"ed-{{.formatof}}-format"{{else}}id + "-format"{{end}}); if (!window.fetch || !window.FormData || !ta || !inp) return; // old browser: plain textarea, the file is uploaded on save bar.hidden = false; diff --git a/internal/web/templates/partials/imagelib.html b/internal/web/templates/partials/imagelib.html new file mode 100644 index 0000000..bc94f2b --- /dev/null +++ b/internal/web/templates/partials/imagelib.html @@ -0,0 +1,72 @@ +{{/* The image library panel behind every imagepick's "Choose from library…": + one per form, fetched page by page from the Files page's JSON. Its script + is self-contained: a choice goes into the imagepick's select and fires a + change event there, so the form sees it like any other edit. Without + fetch the buttons hide and the select stands in. .sub is the blog, + .count how many images the library has. */}} +{{define "imagelib"}}<div class="picker" id="picker" data-sub="{{.sub}}" hidden> + <div class="picker-box"> + <div class="picker-head"> + <input type="search" placeholder="{{t "Filter by name…"}}" aria-label="{{t "Filter by name…"}}"> + <span class="muted small pcount">{{tf "%d images" .count}}</span> + <button type="button" class="secondary small" data-pick="close">{{t "Close"}}</button> + </div> + <div class="picks"></div> + <p class="muted small pnone" hidden>{{t "No images match."}}</p> + <div class="pager"><button type="button" class="mini" data-pick="prev">← {{t "Newer"}}</button> <span class="pnum"></span> <button type="button" class="mini" data-pick="next">{{t "Older"}} →</button></div> + </div> +</div> +<script> +(function () { + var panel = document.getElementById("picker"), sub = panel.getAttribute("data-sub"), pick = null, page = 1, filter = "", timer; + function q(sel, el) { return (el || document).querySelector(sel); } + function qa(sel, el) { return Array.prototype.slice.call((el || document).querySelectorAll(sel)); } + if (!window.fetch || !panel.closest) return; // the select stands in + qa(".imagepick").forEach(function (ip) { ip.className += " js"; }); + document.addEventListener("click", function (e) { + var b = e.target.closest("[data-pick]"), ip = b && b.closest(".imagepick"); + if (!ip) return; + if (b.getAttribute("data-pick") === "open") { pick = ip; panel.hidden = false; q("input", panel).value = filter = ""; load(1); q("input", panel).focus(); } + else if (b.getAttribute("data-pick") === "clear") { choose(ip, "none", ""); } + }); + panel.addEventListener("click", function (e) { + var b = e.target.closest("[data-pick],.pick"); + if (!b) return; + if (b.className.indexOf("pick") === 0) { choose(pick, b.getAttribute("data-id"), b.getAttribute("data-name")); panel.hidden = true; return; } + switch (b.getAttribute("data-pick")) { + case "close": panel.hidden = true; break; + case "prev": load(page - 1); break; + case "next": load(page + 1); break; + } + }); + panel.addEventListener("keydown", function (e) { if (e.key === "Escape") panel.hidden = true; }); + q("input", panel).addEventListener("input", function () { clearTimeout(timer); timer = setTimeout(function () { filter = q("input", panel).value.trim(); load(1); }, 250); }); + function load(n) { + var url = "/b/" + sub + "/files?kind=image&p=" + n + (filter ? "&q=" + encodeURIComponent(filter) : ""); + fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" }).then(function (r) { return r.json(); }).then(function (d) { + page = d.page; + var grid = q(".picks", panel); grid.innerHTML = ""; + (d.files || []).forEach(function (f) { + var b = document.createElement("button"), img = document.createElement("img"), s = document.createElement("span"); + b.type = "button"; b.className = "pick"; b.setAttribute("data-id", f.id); b.setAttribute("data-name", f.filename); + img.loading = "lazy"; img.src = "/b/" + sub + "/media/" + f.id; img.alt = ""; + s.className = "name"; s.textContent = f.filename; + b.appendChild(img); b.appendChild(s); grid.appendChild(b); + }); + q(".pnone", panel).hidden = !!(d.files && d.files.length); + q(".pnum", panel).textContent = d.last > 1 ? d.page + " / " + d.last : ""; + q("[data-pick=prev]", panel).disabled = d.page <= 1; q("[data-pick=next]", panel).disabled = d.page >= d.last; + }); + } + function choose(ip, id, name) { // the select stays the value the form sends + var sel = q("select", ip), pv = q(".preview", ip), found = false; + for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === id) found = true; + if (!found) { var o = document.createElement("option"); o.value = id; o.textContent = name; sel.appendChild(o); } + sel.value = id; + pv.innerHTML = id === "none" ? '<span class="swatch">×</span>' : ""; + if (id !== "none") { var img = document.createElement("img"); img.src = "/b/" + sub + "/media/" + id; img.alt = ""; pv.appendChild(img); } + var ev; try { ev = new Event("change", { bubbles: true }); } catch (_) { ev = document.createEvent("Event"); ev.initEvent("change", true, false); } + sel.dispatchEvent(ev); + } +})(); +</script>{{end}} diff --git a/internal/web/templates/partials/imagepick.html b/internal/web/templates/partials/imagepick.html index 47c2146..ed9368c 100644 --- a/internal/web/templates/partials/imagepick.html +++ b/internal/web/templates/partials/imagepick.html @@ -1,19 +1,20 @@ -{{/* One image field of the design form. The select lists the recent images - (plus the chosen one) so it works without the script; with it the select is - hidden and "Choose from library…" opens the shared panel at the end of - design.html, which fetches the library page by page. */}} +{{/* One image field of a form (the design page, a post's featured image). + The select lists the recent images (plus the chosen one) so it works + without the script; with it the select is hidden and "Choose from + library…" opens the form's imagelib panel, which fetches the library + page by page and marks the pickers with .js. */}} {{define "imagepick"}}{{$cur := .current}}<div class="imagepick" data-name="{{.name}}"> <label>{{.label}}</label> {{if .hint}}<p class="muted small">{{.hint}}</p>{{end}} <div class="pickrow"> <span class="preview">{{if $cur}}<img src="/b/{{.sub}}/media/{{$cur}}" alt="">{{else}}<span class="swatch">×</span>{{end}}</span> <span class="pickctl"> - <select name="{{.name}}" class="nojs"> + <select name="{{.name}}" class="ip-nojs"> <option value="none"{{if not $cur}} selected{{end}}>{{t "None"}}</option> {{range .images}}<option value="{{.ID}}"{{if eq .ID.String $cur}} selected{{end}}>{{.Filename}}</option>{{end}} {{if and $cur (not (index .names $cur))}}<option value="{{$cur}}" selected>{{t "Current image"}}</option>{{end}} </select> - <span class="jsonly"><button type="button" class="secondary small" data-pick="open">{{t "Choose from library…"}}</button> <button type="button" class="secondary small" data-pick="clear">{{t "None"}}</button></span> + <span class="ip-jsonly"><button type="button" class="secondary small" data-pick="open">{{t "Choose from library…"}}</button> <button type="button" class="secondary small" data-pick="clear">{{t "None"}}</button></span> <label class="upload">{{t "…or upload a new one"}}<br><input type="file" name="{{.name}}_file" accept="{{if .accept}}{{.accept}}{{else}}image/*{{end}}"></label> </span> </div> diff --git a/internal/web/templates/partials/mdhelp.html b/internal/web/templates/partials/mdhelp.html index 5489510..cbf9b02 100644 --- a/internal/web/templates/partials/mdhelp.html +++ b/internal/web/templates/partials/mdhelp.html @@ -5,5 +5,5 @@ <tr><td><code>[{{t "link text"}}](https://example.org)</code></td><td><code></code>, <code>[{{t "file name"}}](/media/…)</code> — <em>{{t "Insert file"}}</em> {{t "writes this for you"}}</td></tr> <tr><td><code>- {{t "list item"}}</code> / <code>1. {{t "numbered"}}</code></td><td><code>> {{t "quote"}}</code>, <code>`{{t "code"}}`</code>, <code>---</code> {{t "for a line"}}</td></tr> </table> - <p class="muted small">{{t "Blank line = new paragraph. Press Enter once for a line break."}}</p> + <p class="muted small">{{t "Blank line = new paragraph. Press Enter once for a line break."}} {{t "A line with"}} <code><!--more--></code> {{t "marks where the summary ends on listings that show one."}}</p> </details>{{end}} diff --git a/internal/web/templates/partials/postlist.html b/internal/web/templates/partials/postlist.html index 01e1037..b1e43c6 100644 --- a/internal/web/templates/partials/postlist.html +++ b/internal/web/templates/partials/postlist.html @@ -3,11 +3,13 @@ own path, which ?p=N is appended to). */}} {{define "postlist"}} {{range .Data.posts}} -<article class="post"> +<article class="post{{if .Image}} has-thumb{{end}}"> + {{if .Image}}<a class="post-thumb" href="/{{.PageSlug}}/{{.Slug}}"><img src="/media/{{.Image}}" alt=""></a>{{end}} <h2 class="post-title"><a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a></h2> {{if $.Data.theme.ShowDates}}<p class="post-date">{{postdate $.Data.theme.DateFormat .CreatedAt}}</p>{{end}} {{template "posttags" .Tags}} - <div class="post-body">{{html .BodyHTML}}</div> + <div class="post-body">{{html .Excerpt}}</div> + {{if .HasMore}}<p class="read-more"><a href="/{{.PageSlug}}/{{.Slug}}">{{t "Read more"}} →</a></p>{{end}} </article> {{end}} {{if gt .Data.lastPage 1}} diff --git a/internal/web/theme.go b/internal/web/theme.go index acfe158..70b218e 100644 --- a/internal/web/theme.go +++ b/internal/web/theme.go @@ -45,6 +45,8 @@ type Theme struct { ShowDates bool `json:"show_dates"` DateFormat string `json:"date_format"` // long | short | iso (rendered by i18n.FormatDate) PostsPerPage int `json:"posts_per_page"` // on listings and tag pages; one of postsPerPageChoices + ListStyle string `json:"list_style"` // full | summary (see summary.go) + PostImage string `json:"post_image"` // where the post page shows the featured image: top | bottom | list (listings only) FooterBg string `json:"footer_bg"` FooterColor string `json:"footer_color"` @@ -87,7 +89,7 @@ func DefaultTheme() Theme { Font: "sans", HeadingFont: "same", FontSize: "normal", HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff", TitleSize: "normal", NavAlign: "left", NavStyle: "normal", NavBg: "#ffffff", NavText: "#222222", - ShowDates: true, DateFormat: "long", PostsPerPage: postsPerPageChoices[0], + ShowDates: true, DateFormat: "long", PostsPerPage: postsPerPageChoices[0], ListStyle: "full", PostImage: "top", FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", FooterAlign: "center", LogoSize: "medium", HeaderOn: true, LeftOn: true, RightOn: true, FooterOn: true, LeftWidth: 25, RightWidth: 25, @@ -171,6 +173,8 @@ func (t *Theme) normalize() { t.NavHover = optColor(t.NavHover) t.DateFormat = pick(t.DateFormat, "long", "short", "iso") t.PostsPerPage = pickInt(t.PostsPerPage, postsPerPageChoices...) + t.ListStyle = pick(t.ListStyle, "full", "summary") + t.PostImage = pick(t.PostImage, "top", "bottom", "list") t.FooterBg = color(t.FooterBg, d.FooterBg) t.FooterColor = color(t.FooterColor, d.FooterColor) t.FooterAlign = pick(t.FooterAlign, "center", "left") @@ -233,6 +237,8 @@ func ThemeFromForm(cur Theme, f url.Values) Theme { t.ShowDates = on("show_dates") t.DateFormat = get("date_format") t.PostsPerPage = num("posts_per_page") + t.ListStyle = get("list_style") + t.PostImage = get("post_image") t.FooterBg = get("footer_bg") t.FooterColor = get("footer_color") t.FooterAlign = get("footer_align") diff --git a/internal/web/web_test.go b/internal/web/web_test.go index aa9b78e..a13697c 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -635,3 +635,48 @@ func TestNormalizeSection(t *testing.T) { t.Errorf("half-valid placement: %+v", sec) } } + +func TestPostSummary(t *testing.T) { + md := func(s string) *store.Post { + return &store.Post{Format: store.FormatMarkdown, BodyMD: s, BodyHTML: renderBody(store.FormatMarkdown, s)} + } + // the marker wins, whatever the length + p := md("first\n\n<!--more-->\n\nsecond") + if html, more := postSummary(p); !more || !strings.Contains(html, "first") || strings.Contains(html, "second") { + t.Errorf("marker: got %q, %v", html, more) + } + // short posts are shown whole + if html, more := postSummary(md("just a line")); more || html != p.BodyHTML && !strings.Contains(html, "just a line") { + t.Errorf("short: got %q, %v", html, more) + } + // a long one is cut at a block boundary after summaryWords words + long := strings.Repeat("word ", 50) + "\n\n" + strings.Repeat("word ", 50) + "\n\nlast block" + if html, more := postSummary(md(long)); !more || strings.Contains(html, "last block") || strings.Count(html, "word") != 100 { + t.Errorf("auto cut: got %d words, more=%v", strings.Count(html, "word"), more) + } + // never inside a code fence + fenced := "intro\n\n```\n" + strings.Repeat("code ", 80) + "\n\nstill code\n```\n\nafter" + if html, more := postSummary(md(fenced)); !more || !strings.Contains(html, "still code") || strings.Contains(html, "after") { + t.Errorf("fence: got %q, %v", html, more) + } + // HTML posts are only cut at the marker + h := &store.Post{Format: store.FormatHTML, BodyMD: "<div>" + strings.Repeat("w ", 200) + "</div>"} + h.BodyHTML = h.BodyMD + if html, more := postSummary(h); more || html != h.BodyHTML { + t.Errorf("html: cut without a marker") + } + h.BodyMD = "<p>a</p><!--more--><p>b</p>" + if html, more := postSummary(h); !more || html != "<p>a</p>" { + t.Errorf("html marker: got %q, %v", html, more) + } + // fillExcerpts follows the theme + posts := []store.Post{*md(long)} + fillExcerpts(Theme{ListStyle: "full"}, posts) + if posts[0].HasMore || posts[0].Excerpt != posts[0].BodyHTML { + t.Error("full: excerpt should be the whole body") + } + fillExcerpts(Theme{ListStyle: "summary"}, posts) + if !posts[0].HasMore { + t.Error("summary: expected a cut") + } +} |
