aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web')
-rw-r--r--internal/web/handlers_blog.go2
-rw-r--r--internal/web/handlers_pages.go9
-rw-r--r--internal/web/handlers_posts.go73
-rw-r--r--internal/web/handlers_sections.go2
-rw-r--r--internal/web/static/blog.css11
-rw-r--r--internal/web/static/dashboard.css1
-rw-r--r--internal/web/summary.go62
-rw-r--r--internal/web/templates/blog/page.html3
-rw-r--r--internal/web/templates/blog/post.html3
-rw-r--r--internal/web/templates/dashboard/design.html76
-rw-r--r--internal/web/templates/dashboard/page_form.html2
-rw-r--r--internal/web/templates/dashboard/post_form.html2
-rw-r--r--internal/web/templates/partials/editor.html11
-rw-r--r--internal/web/templates/partials/imagelib.html72
-rw-r--r--internal/web/templates/partials/imagepick.html13
-rw-r--r--internal/web/templates/partials/mdhelp.html2
-rw-r--r--internal/web/templates/partials/postlist.html6
-rw-r--r--internal/web/theme.go8
-rw-r--r--internal/web/web_test.go45
19 files changed, 304 insertions, 99 deletions
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}} &middot; {{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">&larr; {{t "Newer"}}</button> <span class="pnum"></span> <button type="button" class="mini" data-pick="next">{{t "Older"}} &rarr;</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">&times;</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">&larr; {{t "Newer"}}</button> <span class="pnum"></span> <button type="button" class="mini" data-pick="next">{{t "Older"}} &rarr;</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">&times;</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">&times;</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>![{{t "description"}}](/media/…)</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>&gt; {{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>&lt;!--more--&gt;</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"}} &rarr;</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")
+ }
+}