aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-17 16:42:15 +0300
committergrm <grm@eyesin.space>2026-09-17 16:42:15 +0300
commitd0601f06c080a8eb8d781fdd0e3338cd7ed42690 (patch)
tree1931e4312ee2db15dfd0a99583c8ebadf0393e2b
parent4a6f987bf7f9d73e9dd33d225fd2d73e9bf69a88 (diff)
downloadblogspace-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
-rw-r--r--AGENTS.md53
-rw-r--r--README.md10
-rw-r--r--internal/db/migrations/blog/00007_post_image.sql7
-rw-r--r--internal/db/migrations/blog/00008_page_outro.sql9
-rw-r--r--internal/i18n/el.go20
-rw-r--r--internal/store/pages.go20
-rw-r--r--internal/store/posts.go29
-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
26 files changed, 422 insertions, 129 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 2560696..d518f8a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -66,7 +66,8 @@ internal/web/ server.go (host router, middleware, render helpers)
routes.go (all routes), handlers_*.go (sections = announcements, design = the one
look-and-layout form, files = the upload library), design_form.go (parsing the
design form's module and menu rows), filetype.go (what an upload is, how it may be served)
- theme.go (colours/fonts/layout switches), layout.go (module kinds, archive grouping)
+ theme.go (colours/fonts/layout switches), layout.go (module kinds, archive grouping),
+ summary.go (what a listing shows of a post)
templates/ (embedded; layouts/, partials/, auth/, dashboard/, admin/, blog/)
static/ (dashboard.css — lean look; custom properties, flexbox and grid
are fine there. blog.css — floats except the three-column `.cols`,
@@ -168,8 +169,10 @@ internal/web/ server.go (host router, middleware, render helpers)
and `dashboard/design.html`; defaults must reproduce the look blogs had
before the option existed. Non-CSS options (`ShowDates`, `DateFormat` via
`FormatDate`, `PostsPerPage`, `Favicon`, `Logo`) are read by the blog
- templates and handlers. `TitleSize` is emitted inside the wide-screen media
- query so blog.css's phone rule keeps winning.
+ templates and handlers, and so are `ListStyle` (`full` | `summary`, see
+ Summaries) and `PostImage` (where the post page shows the featured image:
+ `top` | `bottom` | `list` = listings only). `TitleSize` is emitted inside
+ the wide-screen media query so blog.css's phone rule keeps winning.
Optional colours (`LinkHover`, `NavHover`) are `""` = inherit and come
from a colour input paired with a `<name>_custom` checkbox, shown/hidden
with CSS only (`.hoverpick > input:not(:checked) ~ …`). Image fields use the
@@ -177,10 +180,14 @@ internal/web/ server.go (host router, middleware, render helpers)
images (`kind = image`) plus the chosen one (`imageNames` looks up an older
one's filename), so the page never grows with the library; `none` clears, a
uuid selects, no value keeps (`pickImage`); an upload in `<name>_file` wins.
- With the script the select is hidden and "Choose from library…" opens one
- shared panel that fetches `GET /b/{sub}/files?kind=image&q=&p=` with
+ The `imagelib` partial (one per form; the design page and the post form
+ both include it) is the panel behind "Choose from library…", with its own
+ script: it marks every `.imagepick` with `.js` (hiding the select, showing
+ the buttons — `.ip-nojs`/`.ip-jsonly`, independent of the form's own `js`
+ class), fetches `GET /b/{sub}/files?kind=image&q=&p=` with
`Accept: application/json` (`{files:[{id,filename}], page, last}`, 50 a
- page, thumbnails `loading=lazy`) and writes the choice into the select.
+ page, thumbnails `loading=lazy`), writes the choice into the select and
+ fires `change` on it, which is how the design form learns it is dirty.
`Presets()`/`WithPreset` are the colour schemes (colours only; `Preset.Colors`
is the JSON the scheme buttons carry). The layout switches
(`HeaderOn/LeftOn/RightOn/FooterOn`, `LeftWidth`, `RightWidth`,
@@ -255,8 +262,10 @@ internal/web/ server.go (host router, middleware, render helpers)
10), set at `POST /admin/blogs/{id}/upload-limit` from `admin/index.html`;
`Blog.UploadLimit(cfg)` resolves it.
- **Editor** (`partials/editor.html`, args via `dict`: name, value, format,
- rows, tall, upload, preview, csrf): a Format row (radios `format` =
- `markdown`|`html`, always rendered) and a textarea with a Markdown toolbar
+ rows, tall, upload, preview, csrf, and `formatof` for a second editor in
+ the same form — the page's outro passes `"intro"` — which then renders no
+ Format row and watches the named editor's radios): a Format row (radios
+ `format` = `markdown`|`html`) and a textarea with a Markdown toolbar
(bold, italic, strike, heading cycle, quote, code, lists, rule, link box,
Insert file), Ctrl+B/I/K, list continuation on Enter, and a Write/Preview
toggle that POSTs the text to `/b/{sub}/preview` (`handlePreview`:
@@ -266,7 +275,8 @@ internal/web/ server.go (host router, middleware, render helpers)
+ "Insert file" + cheat-sheet, and paste/drop take any file. Forms using it
must be `multipart/form-data` and their save handler must set `Format` with
`pick(r.FormValue("format"), store.FormatMarkdown, store.FormatHTML)`, call
- `s.readUpload(r, "inline_file", false)` + `appendFile(body, f, format)`
+ `s.readUpload(r, "<name>_file", false)` (`body_file`, `intro_file`,
+ `outro_file`) + `appendFile(body, f, format)`
(the no-JS path: `fileMarkdown` or `fileHTML`, i.e. `<img>`/`<a>`), and
store `renderBody(format, src)` (`markdown.Render`, or the source untouched
for HTML) in the `*_html` column — the public templates only ever print
@@ -279,6 +289,28 @@ internal/web/ server.go (host router, middleware, render helpers)
scripts — because the text may not be the viewer's own (a superadmin edits
other people's blogs) and must never run on the dashboard origin, where the
session lives. Markdown preview keeps using `.ed-preview` in the page.
+- **Page intro and outro** (`pages.intro_md/intro_html`, `outro_md/outro_html`,
+ one `format` for both): text before and after the page's posts, the
+ blog-wide `above`/`below` HTML modules' per-page counterpart (a home page
+ with a site map or a widget after its posts). `blog/page.html` prints the
+ outro after the `postlist` and treats a page with only an outro as not empty.
+- **Summaries** (`web/summary.go`, `theme.list_style`): with `summary` a
+ listing (page or tag) shows `Post.Excerpt` + a "Read more" link when
+ `Post.HasMore` — both filled by `fillExcerpts` in the listing handlers, never
+ stored. `postSummary` cuts the *source* at `<!--more-->` and renders that
+ part (bluemonday drops the comment from the full body; in HTML mode it
+ stays, harmless); without the marker a Markdown post is cut at the first
+ blank-line block boundary past `summaryWords` (70) words, never inside a
+ code fence, and an HTML post is shown whole (cutting hand-written markup
+ blind would leave tags open). `full` puts the whole body in `Excerpt`.
+- **Featured image** (`posts.image uuid REFERENCES files ON DELETE SET
+ NULL`, `Post.Image` as a string id or ""): picked on the post form with the
+ `imagepick` partial (`image`, upload `image_file` wins; `postFormData`
+ loads the recent images like the design page and clears an id whose file
+ is gone). Listings float it right as `.post-thumb` (`has-thumb` clears the
+ float); the post page shows it as `.post-image` above or below the body per
+ `theme.post_image`, or not at all (`list`). No resizing: the thumbnail is
+ the file scaled by CSS.
- **Announcements** (`sections` table, `store/sections.go`,
`handlers_sections.go`, `/b/{sub}/announcements…`): per-blog notices with
`placement` (`<column>-<position>`: left|main|right × top|bottom, split by
@@ -390,7 +422,8 @@ superadmin password to `admin`. Production refuses both.
`internal/db/migrations/control/` for users and the registry; goose
`-- +goose Up/Down` sections; they run automatically at startup. Never
edit an applied migration. Blog chain so far: `00001_init`, `00002_language`,
- `00003_files`, `00004_tags`, `00005_search`, `00006_format`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after
+ `00003_files`, `00004_tags`, `00005_search`, `00006_format`, `00007_post_image`,
+ `00008_page_outro`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after
the move to per-blog databases; deployments from before it have
`goose_db_version` rows 2–7 in the control DB that must be deleted once
(README "Upgrading from a single database") or the next control migration
diff --git a/README.md b/README.md
index 6b30fdc..90dca7a 100644
--- a/README.md
+++ b/README.md
@@ -16,9 +16,11 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones.
the superadmin can set a different limit per blog.
- **Announcements**: blog-wide notices (next meeting, this month's book, a closure)
shown at the top or bottom of the main content or of a side column; each can be hidden without deleting.
-- Every blog has **pages** (Home, About, News, …); each page holds posts. Posts can
+- Every blog has **pages** (Home, About, News, …); each page holds posts, with an
+ optional text before them (an intro) and after them (a site map, a widget). Posts can
carry **tags**, shown under the date; each tag has its own page listing every post
- with it.
+ with it. A post can have a **featured image**: a thumbnail beside it on listings,
+ and the picture above or below the text on its own page.
- **Design** tab: one form, one Save, organised by part of the blog — colours & fonts
(schemes, background colour/image, text and link colours, fonts), header, menu,
content, side columns, footer, logo & site icon. The page is five areas — header,
@@ -28,7 +30,9 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones.
case; several words match in order with anything in between), custom HTML, footer
text, RSS link, site map. Column widths are percentages; on phones the columns
stack under the posts. The **menu** mixes blog pages and outside links. Posts per
- page, the date format, the site title size and the footer alignment live here too.
+ page, the date format, the site title size and the footer alignment live here too,
+ as does whether listings show whole posts or **summaries** with a "Read more" link
+ (a post's summary ends at a `<!--more-->` line, or after its first paragraphs).
- Changes are live once saved: save, then refresh the blog tab. Nothing is saved
until you press Save, and Discard changes forgets the edits.
- **Languages**: English or Greek, chosen per blog on the Settings tab. It switches the
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}} &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")
+ }
+}