aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-18 14:32:22 +0300
committergrm <grm@eyesin.space>2026-09-18 14:32:22 +0300
commit0fc470a8d588cac1a51cc4b3e1a2237dc56041c9 (patch)
treef4cde97479977d46046bc18953a1d0ecc3686165
parenta2b2574538c30278f4edd8e6b2704a176a7750a2 (diff)
downloadblogspace-0fc470a8d588cac1a51cc4b3e1a2237dc56041c9.tar.gz
blogspace-0fc470a8d588cac1a51cc4b3e1a2237dc56041c9.tar.bz2
blogspace-0fc470a8d588cac1a51cc4b3e1a2237dc56041c9.zip
Special pages: a gallery page of the blog's images
A page's kind says what it shows between its intro and outro: posts, as before, or something special. The first special kind is a gallery — every image in the files library as a masonry wall (CSS columns, no script), newest first, each linked to its full-size file. The page form lists the library's images with a Hide tick per picture; exclusions are stored (page_hidden_files) rather than inclusions so a new upload shows up without editing the page, and the design's own pictures — logo, favicon, header and background image — are never shown. Special pages keep the page chrome (menu, announcements, home, ordering) but hold no posts: the post forms skip them and a forced page id is refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
-rw-r--r--AGENTS.md26
-rw-r--r--README.md6
-rw-r--r--internal/db/migrations/blog/00013_page_kind.sql16
-rw-r--r--internal/i18n/el.go28
-rw-r--r--internal/store/files.go22
-rw-r--r--internal/store/pages.go70
-rw-r--r--internal/web/handlers_blog.go19
-rw-r--r--internal/web/handlers_pages.go107
-rw-r--r--internal/web/handlers_posts.go6
-rw-r--r--internal/web/static/blog.css5
-rw-r--r--internal/web/static/dashboard.css17
-rw-r--r--internal/web/templates/blog/gallery.html9
-rw-r--r--internal/web/templates/dashboard/overview.html2
-rw-r--r--internal/web/templates/dashboard/page_form.html15
-rw-r--r--internal/web/templates/dashboard/pages.html6
-rw-r--r--internal/web/templates/dashboard/post_form.html2
-rw-r--r--internal/web/templates/dashboard/posts.html2
-rw-r--r--internal/web/web_test.go4
18 files changed, 315 insertions, 47 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 5aec663..e8ab0bd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -58,7 +58,7 @@ internal/config/ env → Config; RootSubdomain = "www"
internal/db/ Cluster (control pool + lazy per-blog pools, CREATE/DROP DATABASE),
goose providers; migrations/control/*.sql, migrations/blog/*.sql
internal/store/ Store = control DB (users, blog registry, create/delete blog);
- BlogStore = one blog's DB (settings, pages, posts, files, sections, modules, menu)
+ BlogStore = one blog's DB (settings, pages + hidden gallery files, posts, files, sections, modules, menu)
internal/auth/ bcrypt, JWT issue/parse, cookie, HMAC CSRF
internal/markdown/ Render(md) → sanitized HTML
internal/i18n/ languages, T/Tf (English keys → catalog), FormatDate/Month, Accept-Language Match;
@@ -372,6 +372,28 @@ internal/web/ server.go (host router, middleware, render helpers)
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.
+- **Special pages** (`pages.kind`, `store/pages.go`): a page's `kind` says
+ what it shows between its intro and outro — `posts` (the default) or a
+ special kind; the list is a CHECK constraint, so a new kind needs a
+ migration. `Page.Special()` is "not posts": special pages keep the whole
+ page chrome (title, address, intro/outro, menu, announcements, home, order)
+ but never hold posts — the post form and posts filter skip them and
+ `handlePostSave` refuses their id. `Kind` is set at creation only (`?kind=`
+ on the Pages tab's "+ New special page" link, a hidden field on the POST,
+ read by `pageKind`); `UpdatePage` never touches it. With one special kind
+ the button links straight to `?kind=gallery`; a second kind should turn it
+ into a chooser. `renderPage` branches on the kind after `blogView`.
+ **Gallery** (`gallery`, `blog/gallery.html`): every `kind = image` file in
+ the library, newest first, each linked to its `/media/<id>` (the no-JS
+ lightbox), minus the page's `page_hidden_files` rows (the form's "Hide"
+ checkboxes, an `.imagegrid` of thumbnails; exclusions are stored, so a new
+ upload shows up without editing the page; the file's FK cascades) and
+ minus the theme's own images (`themeFiles`: logo, favicon, header and
+ background image) — `BlogStore.GalleryImages(pageID, except)`. No
+ pagination: images are `loading=lazy` and the library is bounded by the
+ upload limit. The wall is CSS multi-column (`.gallery { column-width }` in
+ blog.css, narrower under 700px), no script; browsers without columns
+ stack the pictures.
- **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
@@ -526,7 +548,7 @@ superadmin password to `admin`. Production refuses both.
`-- +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`, `00007_post_image`,
- `00008_page_outro`, `00009_gizmo_style`, `00010_font_kind`, `00011_post_hide_date`, `00012_page_show_notices`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after
+ `00008_page_outro`, `00009_gizmo_style`, `00010_font_kind`, `00011_post_hide_date`, `00012_page_show_notices`, `00013_page_kind`; 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 84980bd..1125eab 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,11 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones.
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. 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.
+ and the picture above or below the text on its own page. A **special page** shows
+ something else instead of posts: a **gallery** page is a masonry wall of every image
+ in the blog's files (newest first, minus the ones ticked "Hide" on the page and the
+ design's own logo, favicon, header and background pictures), with the same intro,
+ closing text and menu switch as any page.
- **Design** tab: one form, one Save, organised by part of the blog — colours & fonts
(schemes, background colour/image, text and link colours, fonts — built-in
stacks, Fira Code, or your own uploaded WOFF2/WOFF/TTF/OTF files), header, menu,
diff --git a/internal/db/migrations/blog/00013_page_kind.sql b/internal/db/migrations/blog/00013_page_kind.sql
new file mode 100644
index 0000000..072d53d
--- /dev/null
+++ b/internal/db/migrations/blog/00013_page_kind.sql
@@ -0,0 +1,16 @@
+-- +goose Up
+-- Special pages: a page's kind says what it shows instead of posts. The
+-- first special kind is a gallery of the library's images; the images a
+-- gallery leaves out are rows here, so a deleted file drops out by itself.
+ALTER TABLE pages ADD COLUMN kind text NOT NULL DEFAULT 'posts'
+ CHECK (kind IN ('posts', 'gallery'));
+
+CREATE TABLE page_hidden_files (
+ page_id bigint NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
+ file_id uuid NOT NULL REFERENCES files(id) ON DELETE CASCADE,
+ PRIMARY KEY (page_id, file_id)
+);
+
+-- +goose Down
+DROP TABLE page_hidden_files;
+ALTER TABLE pages DROP COLUMN kind;
diff --git a/internal/i18n/el.go b/internal/i18n/el.go
index e2523cb..b46aa38 100644
--- a/internal/i18n/el.go
+++ b/internal/i18n/el.go
@@ -106,15 +106,25 @@ var el = map[string]string{
"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": "Εμφάνιση στο μενού",
- "Show announcements on this page": "Εμφάνιση ανακοινώσεων σε αυτή τη σελίδα",
- "Back to pages": "Πίσω στις σελίδες",
- "Page deleted.": "Η σελίδα διαγράφηκε.",
- "“%s” is now the home page.": "Η «%s» είναι τώρα η αρχική σελίδα.",
- "the page “%s” and all %d of its posts": "τη σελίδα «%s» και όλες τις %d δημοσιεύσεις της",
- "Title is required (max 120 characters).": "Ο τίτλος είναι υποχρεωτικός (έως 120 χαρακτήρες).",
+ "Image not added:": "Η εικόνα δεν προστέθηκε:",
+ "shown above the posts; optional": "εμφανίζεται πάνω από τις δημοσιεύσεις· προαιρετικό",
+ "Show in menu": "Εμφάνιση στο μενού",
+ "Show announcements on this page": "Εμφάνιση ανακοινώσεων σε αυτή τη σελίδα",
+ "Back to pages": "Πίσω στις σελίδες",
+ "Page deleted.": "Η σελίδα διαγράφηκε.",
+ "“%s” is now the home page.": "Η «%s» είναι τώρα η αρχική σελίδα.",
+ "the page “%s” and all %d of its posts": "τη σελίδα «%s» και όλες τις %d δημοσιεύσεις της",
+ // special pages
+ "New special page": "Νέα ειδική σελίδα",
+ "A gallery of every image in your files": "Μια συλλογή με όλες τις εικόνες των αρχείων σας",
+ "gallery": "συλλογή",
+ "New gallery page": "Νέα σελίδα συλλογής",
+ "Edit gallery page": "Επεξεργασία σελίδας συλλογής",
+ "the gallery page “%s”": "τη σελίδα συλλογής «%s»",
+ "No images in your files yet.": "Δεν υπάρχουν ακόμη εικόνες στα αρχεία σας.",
+ "A gallery page shows every image in your files as a wall of pictures, newest first, between its intro and closing text.": "Μια σελίδα συλλογής δείχνει όλες τις εικόνες των αρχείων σας σαν τοίχο από φωτογραφίες, με τις νεότερες πρώτες, ανάμεσα στο εισαγωγικό κείμενο και το κείμενο κλεισίματος.",
+ "Every image in your files is shown except the ones ticked here. The logo, favicon, header and background images are never shown.": "Εμφανίζονται όλες οι εικόνες των αρχείων σας εκτός από όσες τσεκάρετε εδώ. Το λογότυπο, το favicon, η εικόνα κεφαλίδας και η εικόνα φόντου δεν εμφανίζονται ποτέ.",
+ "Title is required (max 120 characters).": "Ο τίτλος είναι υποχρεωτικός (έως 120 χαρακτήρες).",
"Slug may only contain lowercase letters, digits and dashes (and not be a reserved word).": "Η διεύθυνση επιτρέπει μόνο πεζά λατινικά γράμματα, ψηφία και παύλες (και δεν μπορεί να είναι δεσμευμένη λέξη).",
"A page with that slug already exists.": "Υπάρχει ήδη σελίδα με αυτή τη διεύθυνση.",
"The home page cannot be deleted. Make another page the home page first.": "Η αρχική σελίδα δεν διαγράφεται. Ορίστε πρώτα άλλη σελίδα ως αρχική.",
diff --git a/internal/store/files.go b/internal/store/files.go
index 287b4d7..a4a4fb3 100644
--- a/internal/store/files.go
+++ b/internal/store/files.go
@@ -79,6 +79,28 @@ func (bs *BlogStore) ListFiles(ctx context.Context, kind, q string, limit, offse
return out, total, rows.Err()
}
+// GalleryImages lists the images a gallery page shows, newest first: every
+// library image but the page's hidden ones and except (the theme's logo and
+// favicon, which the caller knows).
+func (bs *BlogStore) GalleryImages(ctx context.Context, pageID int64, except []uuid.UUID) ([]File, error) {
+ rows, err := bs.db.Query(ctx, `SELECT `+fileCols+` FROM files WHERE kind = 'image' AND id <> ALL($2)
+ AND NOT EXISTS (SELECT 1 FROM page_hidden_files h WHERE h.page_id=$1 AND h.file_id=files.id)
+ ORDER BY created_at DESC, id DESC`, pageID, except)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []File
+ for rows.Next() {
+ f, err := scanFile(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *f)
+ }
+ return out, rows.Err()
+}
+
// FileMeta loads a file without its bytes.
func (bs *BlogStore) FileMeta(ctx context.Context, id uuid.UUID) (*File, error) {
return scanFile(bs.db.QueryRow(ctx, `SELECT `+fileCols+` FROM files WHERE id=$1`, id))
diff --git a/internal/store/pages.go b/internal/store/pages.go
index 3fdc492..2a69d99 100644
--- a/internal/store/pages.go
+++ b/internal/store/pages.go
@@ -3,10 +3,20 @@ package store
import (
"context"
"time"
+
+ "github.com/google/uuid"
+)
+
+// Page kinds: what a page shows under its intro. Everything but posts is a
+// "special page"; the list is also a CHECK constraint on pages.kind.
+const (
+ PageKindPosts = "posts"
+ PageKindGallery = "gallery" // every library image, except the hidden ones
)
type Page struct {
ID int64
+ Kind string // PageKindPosts | PageKindGallery; set at creation, never changed
Slug string
Title string
IntroMD string
@@ -22,11 +32,21 @@ type Page struct {
PostCount int // filled by ListPages only
}
-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), show_notices, is_home, created_at`
+// Special is a page that shows something other than posts.
+func (p *Page) Special() bool { return p.Kind != PageKindPosts }
+
+func kindOrPosts(k string) string {
+ if k == PageKindGallery {
+ return k
+ }
+ return PageKindPosts
+}
+
+const pageCols = `id, kind, 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), show_notices, 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.OutroMD, &p.OutroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.ShowNotices, &p.IsHome, &p.CreatedAt)
+ err := row.Scan(&p.ID, &p.Kind, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.OutroMD, &p.OutroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.ShowNotices, &p.IsHome, &p.CreatedAt)
if err != nil {
return nil, wrap(err)
}
@@ -43,7 +63,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.OutroMD, &p.OutroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.ShowNotices, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil {
+ if err := rows.Scan(&p.ID, &p.Kind, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.OutroMD, &p.OutroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.ShowNotices, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil {
return nil, err
}
out = append(out, p)
@@ -71,9 +91,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, outro_md, outro_html, format, show_notices, nav_order)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,(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), p.ShowNotices))
+ created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (kind, slug, title, intro_md, intro_html, outro_md, outro_html, format, show_notices, nav_order)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,(SELECT coalesce(max(nav_order),-1)+1 FROM pages)) RETURNING `+pageCols,
+ kindOrPosts(p.Kind), p.Slug, p.Title, p.IntroMD, p.IntroHTML, p.OutroMD, p.OutroHTML, formatOrMD(p.Format), p.ShowNotices))
if err != nil {
return nil, err
}
@@ -87,6 +107,7 @@ func (bs *BlogStore) CreatePage(ctx context.Context, p *Page) (*Page, error) {
}
// UpdatePage saves the page and keeps its menu entry in step with ShowInNav.
+// The kind is not touched: a page is what it was created as.
func (bs *BlogStore) UpdatePage(ctx context.Context, p *Page) error {
tx, err := bs.db.Begin(ctx)
if err != nil {
@@ -160,3 +181,40 @@ func (bs *BlogStore) MovePage(ctx context.Context, id int64, dir int) error {
}
return tx.Commit(ctx)
}
+
+// HiddenFiles lists the library files a gallery page leaves out.
+func (bs *BlogStore) HiddenFiles(ctx context.Context, pageID int64) (map[string]bool, error) {
+ rows, err := bs.db.Query(ctx, `SELECT file_id FROM page_hidden_files WHERE page_id=$1`, pageID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := map[string]bool{}
+ for rows.Next() {
+ var id uuid.UUID
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ out[id.String()] = true
+ }
+ return out, rows.Err()
+}
+
+// SetHiddenFiles makes ids the files a gallery page leaves out. Ids of files
+// that are gone are skipped rather than refused: the form was rendered before
+// the deletion.
+func (bs *BlogStore) SetHiddenFiles(ctx context.Context, pageID int64, ids []uuid.UUID) error {
+ tx, err := bs.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ if _, err := tx.Exec(ctx, `DELETE FROM page_hidden_files WHERE page_id=$1`, pageID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO page_hidden_files (page_id, file_id)
+ SELECT $1, id FROM files WHERE id = ANY($2)`, pageID, ids); err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+}
diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go
index 55cec2e..006129c 100644
--- a/internal/web/handlers_blog.go
+++ b/internal/web/handlers_blog.go
@@ -107,6 +107,20 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store.
s.serverError(w, err)
return
}
+ v["page"] = page
+ if !page.ShowNotices {
+ v["notices"] = noNotices
+ }
+ if page.Kind == store.PageKindGallery { // a special page: its images instead of posts
+ images, err := blogStore(r).GalleryImages(r.Context(), page.ID, themeFiles(v["theme"].(Theme)))
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ v["images"] = images
+ s.render(w, r, "blog/gallery.html", v)
+ return
+ }
n, per := pageNum(r), v["theme"].(Theme).PostsPerPage
posts, total, err := blogStore(r).PublishedPosts(r.Context(), page.ID, per, (n-1)*per)
if err != nil {
@@ -115,10 +129,7 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page *store.
}
last := (total + per - 1) / per
fillExcerpts(v["theme"].(Theme), posts)
- v["page"], v["posts"], v["pageNum"], v["lastPage"] = page, posts, n, last
- if !page.ShowNotices {
- v["notices"] = noNotices
- }
+ v["posts"], v["pageNum"], v["lastPage"] = posts, n, last
// The pager appends ?p=N to base: no trailing slash, which /{page} would not match.
v["base"] = "/" + page.Slug
if page.IsHome {
diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go
index b085b82..0a2c9c4 100644
--- a/internal/web/handlers_pages.go
+++ b/internal/web/handlers_pages.go
@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
+ "github.com/google/uuid"
"github.com/gramanas/blogspace/internal/slug"
"github.com/gramanas/blogspace/internal/store"
)
@@ -38,19 +39,86 @@ func (s *Server) loadPage(w http.ResponseWriter, r *http.Request) *store.Page {
return p
}
+// pageKind reads a new page's kind from the form or query (?kind= on the
+// "New special page" link, a hidden field on the POST). An existing page
+// keeps its own.
+func pageKind(r *http.Request) string {
+ return pick(r.FormValue("kind"), store.PageKindPosts, store.PageKindGallery)
+}
+
+// themeFiles are the images the design uses (logo, favicon, header and
+// background image): decoration, not content, so a gallery never shows them.
+func themeFiles(t Theme) []uuid.UUID {
+ var ids []uuid.UUID
+ for _, v := range []string{t.Logo, t.Favicon, t.HeaderImage, t.BgImage} {
+ if id, err := uuid.Parse(v); err == nil {
+ ids = append(ids, id)
+ }
+ }
+ return ids
+}
+
+// pageFormData is what the page form needs besides the page. For a gallery
+// that is every library image but the theme's, and which ones the page
+// hides — an unsaved page hides none.
+func (s *Server) pageFormData(r *http.Request, p *store.Page) (map[string]any, error) {
+ d := map[string]any{"page": p}
+ if p.Kind != store.PageKindGallery {
+ return d, nil
+ }
+ images, _, err := blogStore(r).ListFiles(r.Context(), "image", "", 0, 0)
+ if err != nil {
+ return nil, err
+ }
+ skip := map[uuid.UUID]bool{}
+ for _, id := range themeFiles(ParseTheme(currentBlog(r).ThemeJSON)) {
+ skip[id] = true
+ }
+ shown := images[:0]
+ for _, f := range images {
+ if !skip[f.ID] {
+ shown = append(shown, f)
+ }
+ }
+ hidden := map[string]bool{}
+ if p.ID != 0 {
+ if hidden, err = blogStore(r).HiddenFiles(r.Context(), p.ID); err != nil {
+ return nil, err
+ }
+ }
+ d["images"], d["hidden"] = shown, hidden
+ return d, nil
+}
+
+// hiddenFromForm is the "hide" checkboxes of a gallery form as ids; junk is dropped.
+func hiddenFromForm(r *http.Request) []uuid.UUID {
+ ids := []uuid.UUID{}
+ for _, v := range r.Form["hide"] {
+ if id, err := uuid.Parse(v); err == nil {
+ ids = append(ids, id)
+ }
+ }
+ return ids
+}
+
func (s *Server) handlePageForm(w http.ResponseWriter, r *http.Request) {
- p := &store.Page{ShowInNav: true, ShowNotices: true}
+ p := &store.Page{Kind: pageKind(r), ShowInNav: true, ShowNotices: true}
if r.PathValue("id") != "" {
if p = s.loadPage(w, r); p == nil {
return
}
}
- s.render(w, r, "dashboard/page_form.html", map[string]any{"page": p})
+ d, err := s.pageFormData(r, p)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ s.render(w, r, "dashboard/page_form.html", d)
}
func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) {
blog := currentBlog(r)
- p := &store.Page{}
+ p := &store.Page{Kind: pageKind(r)}
if r.PathValue("id") != "" {
if p = s.loadPage(w, r); p == nil {
return
@@ -81,8 +149,25 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) {
case !slug.Valid(p.Slug) || reservedPageSlugs[p.Slug]:
msg = s.tr(r, "Slug may only contain lowercase letters, digits and dashes (and not be a reserved word).")
}
+ // The rejected form is rendered with what was sent, ticks included.
+ fail := func(status int, msg string) {
+ d, err := s.pageFormData(r, p)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ if p.Kind == store.PageKindGallery {
+ hidden := map[string]bool{}
+ for _, id := range hiddenFromForm(r) {
+ hidden[id.String()] = true
+ }
+ d["hidden"] = hidden
+ }
+ d["error"] = msg
+ s.renderStatus(w, r, status, "dashboard/page_form.html", d)
+ }
if msg != "" {
- s.renderStatus(w, r, http.StatusBadRequest, "dashboard/page_form.html", map[string]any{"page": p, "error": msg})
+ fail(http.StatusBadRequest, msg)
return
}
p.IntroMD = appendFile(p.IntroMD, img, p.Format)
@@ -106,12 +191,18 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) {
}
if err != nil {
if errors.Is(err, store.ErrConflict) {
- s.renderStatus(w, r, http.StatusConflict, "dashboard/page_form.html", map[string]any{"page": p, "error": s.tr(r, "A page with that slug already exists.")})
+ fail(http.StatusConflict, s.tr(r, "A page with that slug already exists."))
return
}
s.serverError(w, err)
return
}
+ if p.Kind == store.PageKindGallery {
+ if err := blogStore(r).SetHiddenFiles(r.Context(), p.ID, hiddenFromForm(r)); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ }
redirectOK(w, r, "/b/"+blog.Subdomain+"/pages/"+strconv.FormatInt(p.ID, 10)+"/edit", s.tr(r, "Saved. Refresh your blog to see it."))
}
@@ -120,8 +211,12 @@ func (s *Server) handlePageDeleteConfirm(w http.ResponseWriter, r *http.Request)
if p == nil {
return
}
+ what := s.trf(r, "the page “%s” and all %d of its posts", p.Title, p.PostCount)
+ if p.Special() {
+ what = s.trf(r, "the gallery page “%s”", p.Title)
+ }
s.render(w, r, "dashboard/confirm.html", map[string]any{
- "what": s.trf(r, "the page “%s” and all %d of its posts", p.Title, p.PostCount),
+ "what": what,
"action": r.URL.Path,
"back": "/b/" + currentBlog(r).Subdomain + "/pages",
"isHome": p.IsHome,
diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go
index 8a54c31..0c34198 100644
--- a/internal/web/handlers_posts.go
+++ b/internal/web/handlers_posts.go
@@ -89,8 +89,8 @@ func (s *Server) handlePostForm(w http.ResponseWriter, r *http.Request) {
}
} else if pid, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64); pid != 0 {
p.PageID = pid
- } else if hp, err := blogStore(r).HomePage(r.Context()); err == nil {
- p.PageID = hp.ID
+ } else if hp, err := blogStore(r).HomePage(r.Context()); err == nil && !hp.Special() {
+ p.PageID = hp.ID // a gallery home page takes no posts: the select's first page stands in
}
d, err := s.postFormData(r, p)
if err != nil {
@@ -153,7 +153,7 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) {
}
pageOK := false
for _, pg := range pages {
- if pg.ID == p.PageID {
+ if pg.ID == p.PageID && !pg.Special() {
pageOK = true
}
}
diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css
index 8cd969e..aa8357e 100644
--- a/internal/web/static/blog.css
+++ b/internal/web/static/blog.css
@@ -56,6 +56,10 @@ img { max-width: 100%; height: auto; }
.post.has-thumb { overflow: hidden; }
.post-image { margin: 0 0 1em; }
.post-image img { display: block; max-width: 100%; margin: 0 auto; }
+/* gallery page: a masonry wall from CSS columns alone; without them the pictures simply stack */
+.gallery { -webkit-column-width: 220px; -moz-column-width: 220px; column-width: 220px; -webkit-column-gap: 1em; -moz-column-gap: 1em; column-gap: 1em; }
+.gallery a { display: block; margin: 0 0 1em; -webkit-column-break-inside: avoid; page-break-inside: avoid; break-inside: avoid; }
+.gallery img { display: block; width: 100%; }
.read-more { margin: 0.3em 0 0; font-weight: bold; }
.read-more a { text-decoration: none; }
.read-more a:hover { text-decoration: underline; }
@@ -158,6 +162,7 @@ img { max-width: 100%; height: auto; }
@media (max-width: 700px) {
.content { padding: 1em; }
.post-thumb { width: 40%; margin-left: 1em; }
+ .gallery { -webkit-column-width: 140px; -moz-column-width: 140px; column-width: 140px; }
.site-title { font-size: 1.5em; }
.ruled > .header-rule { display: none; } .ruled > .brand { white-space: normal; }
.cols { display: block; }
diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css
index 4aa0213..70df6fa 100644
--- a/internal/web/static/dashboard.css
+++ b/internal/web/static/dashboard.css
@@ -278,11 +278,16 @@ textarea.mono { font-size: 0.9em; }
.picker-box { max-width: 860px; margin: 0 auto; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 8px 30px rgba(16, 24, 40, 0.25); padding: 1em 1.2em; }
.picker-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6em; margin-bottom: 0.8em; }
.picker-head input { flex: 1 1 12em; width: auto; margin: 0; padding: 0.4em 0.6em; }
-.picker .picks { display: flex; flex-wrap: wrap; gap: 0.7em; }
-.picker .pick { width: 120px; margin: 0; padding: 0.4em; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); text-align: center; font-size: 0.75em; font-weight: normal; cursor: pointer; }
-.picker .pick:hover { border-color: var(--accent); background: var(--surface-2); color: var(--text); }
-.picker .pick img { display: block; width: 100%; height: 64px; margin: 0 auto 0.3em; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); object-fit: contain; }
-.picker .pick .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+/* .imagegrid is the wall of thumbnails: the picker's choices and the gallery page's Hide grid */
+.imagegrid, .picker .picks { display: flex; flex-wrap: wrap; gap: 0.7em; }
+.imagegrid .pick, .picker .pick { display: block; width: 120px; margin: 0; padding: 0.4em; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); text-align: center; font-size: 0.75em; font-weight: normal; cursor: pointer; }
+.imagegrid .pick:hover, .picker .pick:hover { border-color: var(--accent); background: var(--surface-2); color: var(--text); }
+.imagegrid .pick img, .picker .pick img { display: block; width: 100%; height: 64px; margin: 0 auto 0.3em; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); object-fit: contain; }
+.imagegrid .pick .name, .picker .pick .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.hidegrid { margin: 0 0 1em; }
+.hidegrid .hide { display: block; margin-top: 0.3em; }
+.hidegrid .hide input { width: auto; margin: 0 0.2em 0 0; vertical-align: middle; }
+.hidegrid .pick:has(:checked) img { opacity: 0.35; } /* a ticked card dims; the tick itself is the signal elsewhere */
.picker .pager { margin: 0.8em 0 0; }
.picker .pager button[disabled] { opacity: 0.4; cursor: default; }
.savebar { position: sticky; bottom: 0; z-index: 5; margin: 0; padding: 0.8em 0; background: var(--bg); border-top: 1px solid var(--border-strong); display: flex; flex-wrap: wrap; gap: 0.8em; align-items: center; }
@@ -303,7 +308,7 @@ details.reset { margin-top: 1.6em; }
.cols { grid-template-columns: 1fr; gap: 1.2em; }
.row { gap: 0; }
.row label { flex-basis: 100%; }
- .picker .pick { width: 46%; }
+ .imagegrid .pick, .picker .pick { width: 46%; }
.presets .preset { width: 46%; }
.sidenav { flex-wrap: nowrap; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; margin: 0 -1em 1em; padding: 0 1em; }
.pickrow { flex-wrap: wrap; }
diff --git a/internal/web/templates/blog/gallery.html b/internal/web/templates/blog/gallery.html
new file mode 100644
index 0000000..4dbde13
--- /dev/null
+++ b/internal/web/templates/blog/gallery.html
@@ -0,0 +1,9 @@
+{{define "title"}}{{if not .Data.page.IsHome}}{{.Data.page.Title}} · {{end}}{{.Blog.Title}}{{end}}
+{{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 .Data.images}}<div class="gallery">{{range .Data.images}}
+ <a href="/media/{{.ID}}"><img src="/media/{{.ID}}" alt="{{.Filename}}" loading="lazy"></a>{{end}}
+</div>{{else if not (or .Data.page.IntroHTML .Data.page.OutroHTML)}}<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/dashboard/overview.html b/internal/web/templates/dashboard/overview.html
index e239445..2a797d8 100644
--- a/internal/web/templates/dashboard/overview.html
+++ b/internal/web/templates/dashboard/overview.html
@@ -12,7 +12,7 @@
<div class="card">
<h2>{{t "Pages"}}</h2>
<ul class="plain">
- {{range .Data.pages}}<li><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.Title}}</a> <span class="muted">({{tf "%d posts" .PostCount}}{{if .IsHome}}, {{t "home"}}{{end}}{{if not .ShowInNav}}, {{t "hidden from menu"}}{{end}})</span></li>{{end}}
+ {{range .Data.pages}}<li><a href="{{if .Special}}/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/edit{{else}}/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}{{end}}">{{.Title}}</a> <span class="muted">({{if .Special}}{{t .Kind}}{{else}}{{tf "%d posts" .PostCount}}{{end}}{{if .IsHome}}, {{t "home"}}{{end}}{{if not .ShowInNav}}, {{t "hidden from menu"}}{{end}})</span></li>{{end}}
</ul>
</div>
</div>
diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html
index 4ee720b..5b47d05 100644
--- a/internal/web/templates/dashboard/page_form.html
+++ b/internal/web/templates/dashboard/page_form.html
@@ -1,9 +1,12 @@
-{{define "title"}}{{if .Data.page.ID}}{{t "Edit page"}}{{else}}{{t "New page"}}{{end}} · {{.Blog.Title}}{{end}}
+{{define "title"}}{{template "pageformtitle" .}} · {{.Blog.Title}}{{end}}
+{{define "pageformtitle"}}{{if eq .Data.page.Kind "gallery"}}{{if .Data.page.ID}}{{t "Edit gallery page"}}{{else}}{{t "New gallery page"}}{{end}}{{else}}{{if .Data.page.ID}}{{t "Edit page"}}{{else}}{{t "New page"}}{{end}}{{end}}{{end}}
{{define "content"}}
<div class="card">
- <h1>{{if .Data.page.ID}}{{t "Edit page"}}{{else}}{{t "New page"}}{{end}}</h1>
+ <h1>{{template "pageformtitle" .}}</h1>
+ {{if eq .Data.page.Kind "gallery"}}<p class="lead muted">{{t "A gallery page shows every image in your files as a wall of pictures, newest first, between its intro and closing text."}}</p>{{end}}
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
+ {{if not .Data.page.ID}}<input type="hidden" name="kind" value="{{.Data.page.Kind}}">{{end}}
<label>{{t "Title"}}<br><input name="title" value="{{.Data.page.Title}}" required maxlength="120"></label>
<label>{{t "Address"}} <span class="muted">({{t "leave empty to make one from the title; e.g."}} <code>about</code> → {{.BlogURL}}/about)</span><br>
<input name="slug" value="{{.Data.page.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label>
@@ -11,6 +14,14 @@
{{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)}}
+ {{if eq .Data.page.Kind "gallery"}}
+ <h2>{{t "Images"}}</h2>
+ <p class="muted small">{{t "Every image in your files is shown except the ones ticked here. The logo, favicon, header and background images are never shown."}}</p>
+ {{if .Data.images}}<div class="imagegrid hidegrid">
+ {{range .Data.images}}<label class="pick"><img src="/b/{{$.Blog.Subdomain}}/media/{{.ID}}" alt="" loading="lazy"><span class="name" title="{{.Filename}}">{{.Filename}}</span><span class="hide"><input type="checkbox" name="hide" value="{{.ID}}"{{if index $.Data.hidden .ID.String}} checked{{end}}> {{t "Hide"}}</span></label>{{end}}
+ </div>
+ {{else}}<p class="muted">{{t "No images in your files yet."}} <a href="/b/{{.Blog.Subdomain}}/files">{{t "Files"}}</a></p>{{end}}
+ {{end}}
<label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> {{t "Show in menu"}}</label>
<label class="check"><input type="checkbox" name="show_notices"{{if .Data.page.ShowNotices}} checked{{end}}> {{t "Show announcements on this page"}}</label>
<p class="actionrow">
diff --git a/internal/web/templates/dashboard/pages.html b/internal/web/templates/dashboard/pages.html
index 6c3b56d..af3bb5e 100644
--- a/internal/web/templates/dashboard/pages.html
+++ b/internal/web/templates/dashboard/pages.html
@@ -2,7 +2,7 @@
{{define "content"}}
<div class="pagehead">
<h1>{{t "Pages"}}</h1>
- <div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/pages/new">+ {{t "New page"}}</a></div>
+ <div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/pages/new">+ {{t "New page"}}</a> <a class="btn secondary" href="/b/{{.Blog.Subdomain}}/pages/new?kind=gallery" title="{{t "A gallery of every image in your files"}}">+ {{t "New special page"}}</a></div>
<p class="lead muted">{{t "Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the"}} <a href="/b/{{.Blog.Subdomain}}/design#menu">{{t "Design"}}</a> {{t "tab."}}</p>
</div>
<div class="card">
@@ -14,9 +14,9 @@
<form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="{{t "Move down"}}">&#9660;</button></form>
</td>
<td><a href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/edit">{{.Title}}</a>
- {{if .IsHome}}<span class="tag">{{t "home"}}</span>{{end}}{{if not .ShowInNav}}<span class="tag off">{{t "not in menu"}}</span>{{end}}</td>
+ {{if .Special}}<span class="tag">{{t .Kind}}</span>{{end}}{{if .IsHome}}<span class="tag">{{t "home"}}</span>{{end}}{{if not .ShowInNav}}<span class="tag off">{{t "not in menu"}}</span>{{end}}</td>
<td><a href="{{$.BlogURL}}{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}" target="_blank">{{if .IsHome}}/{{else}}/{{.Slug}}{{end}} &#8599;</a></td>
- <td><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.PostCount}}</a></td>
+ <td>{{if .Special}}<span class="muted">&mdash;</span>{{else}}<a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.PostCount}}</a>{{end}}</td>
<td class="nowrap">
{{if not .IsHome}}<form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/home" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{t "Make home"}}</button></form>
<a class="danger" href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/delete">{{t "delete"}}</a>{{end}}
diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html
index e64a331..edb60fd 100644
--- a/internal/web/templates/dashboard/post_form.html
+++ b/internal/web/templates/dashboard/post_form.html
@@ -7,7 +7,7 @@
<label>{{t "Title"}}<br><input name="title" value="{{.Data.post.Title}}" required maxlength="200" autofocus></label>
<div class="row">
<label>{{t "Page"}}<br>
- <select name="page_id">{{range .Data.pages}}<option value="{{.ID}}"{{if eq .ID $.Data.post.PageID}} selected{{end}}>{{.Title}}</option>{{end}}</select>
+ <select name="page_id">{{range .Data.pages}}{{if not .Special}}<option value="{{.ID}}"{{if eq .ID $.Data.post.PageID}} selected{{end}}>{{.Title}}</option>{{end}}{{end}}</select>
</label>
<label>{{t "Address"}} <span class="muted">({{t "optional, made from the title"}})</span><br>
<input name="slug" value="{{.Data.post.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label>
diff --git a/internal/web/templates/dashboard/posts.html b/internal/web/templates/dashboard/posts.html
index 983fbae..217c966 100644
--- a/internal/web/templates/dashboard/posts.html
+++ b/internal/web/templates/dashboard/posts.html
@@ -8,7 +8,7 @@
<label>{{t "Page:"}}
<select name="page" onchange="this.form.submit()">
<option value="0">{{t "All pages"}}</option>
- {{range .Data.pages}}<option value="{{.ID}}"{{if eq .ID $.Data.pageID}} selected{{end}}>{{.Title}}</option>{{end}}
+ {{range .Data.pages}}{{if not .Special}}<option value="{{.ID}}"{{if eq .ID $.Data.pageID}} selected{{end}}>{{.Title}}</option>{{end}}{{end}}
</select></label>
<noscript><button type="submit">{{t "Filter"}}</button></noscript>
</form>
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index 2f3bc4f..2d4df5c 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -643,7 +643,7 @@ func TestAllTemplatesParse(t *testing.T) {
for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html",
"dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/files.html", "dashboard/settings.html",
"dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html",
- "blog/page.html", "blog/post.html", "blog/tag.html", "blog/search.html", "blog/404.html"} {
+ "blog/page.html", "blog/gallery.html", "blog/post.html", "blog/tag.html", "blog/search.html", "blog/404.html"} {
for _, l := range i18n.Languages() {
if _, err := tpl.get(l.Code, name); err != nil {
t.Errorf("%s (%s): %v", name, l.Code, err)
@@ -694,7 +694,7 @@ func TestGreekCatalogComplete(t *testing.T) {
for _, p := range Presets() {
used[p.Name] = true
}
- for _, k := range []string{"top", "bottom", store.RoleSuperadmin, store.RoleBlogger} {
+ for _, k := range []string{"top", "bottom", store.RoleSuperadmin, store.RoleBlogger, store.PageKindGallery} {
used[k] = true
}
have := map[string]bool{}