aboutsummaryrefslogtreecommitdiffstats
path: root/internal/store
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 /internal/store
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
Diffstat (limited to 'internal/store')
-rw-r--r--internal/store/files.go22
-rw-r--r--internal/store/pages.go70
2 files changed, 86 insertions, 6 deletions
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)
+}