aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md27
-rw-r--r--README.md8
-rw-r--r--internal/db/migrations/00002_sections.sql19
-rw-r--r--internal/store/sections.go114
-rw-r--r--internal/web/handlers_blog.go25
-rw-r--r--internal/web/handlers_sections.go161
-rw-r--r--internal/web/routes.go9
-rw-r--r--internal/web/static/blog.css11
-rw-r--r--internal/web/templates/dashboard/overview.html2
-rw-r--r--internal/web/templates/dashboard/section_form.html32
-rw-r--r--internal/web/templates/dashboard/sections.html29
-rw-r--r--internal/web/templates/layouts/blog.html10
-rw-r--r--internal/web/templates/partials/dashnav.html1
-rw-r--r--internal/web/web_test.go21
14 files changed, 456 insertions, 13 deletions
diff --git a/AGENTS.md b/AGENTS.md
index cfe3d06..2352b85 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,7 +23,9 @@ table and deployment notes.
(e.g. the `<noscript>` button in `posts.html`).
- Server-rendered `html/template`; no SPA, no frontend toolchain.
- Postgres holds **everything**, including images (`images.data bytea`).
-- Posts are Markdown (goldmark → bluemonday). No WYSIWYG.
+- Posts are Markdown (goldmark → bluemonday). No WYSIWYG. The editor's
+ "Insert image" is the one scripted convenience (`partials/editor.html`);
+ it must keep its no-JS fallback (file appended on save).
- Changes are live immediately — there is no draft/preview system. The UX
is "save, then refresh your blog tab"; keep the "View blog ↗" links.
- Theme customisation is a structured form only; **no custom CSS input**.
@@ -36,14 +38,14 @@ table and deployment notes.
cmd/blogspace/ main.go (serve|seed|migrate, superadmin + root blog bootstrap), seed.go
internal/config/ env → Config; RootSubdomain = "www"
internal/db/ pgxpool + goose; migrations/*.sql embedded
-internal/store/ models + hand-written SQL (users, blogs, pages, posts, images)
+internal/store/ models + hand-written SQL (users, blogs, pages, posts, images, sections)
internal/auth/ bcrypt, JWT issue/parse, cookie, HMAC CSRF
internal/markdown/ Render(md) → sanitized HTML
internal/slug/ Make/Valid/WithSuffix (Greek transliteration included)
internal/web/ server.go (host router, middleware, render helpers)
- routes.go (all routes), handlers_*.go, theme.go
+ routes.go (all routes), handlers_*.go (sections = announcements), theme.go
templates/ (embedded; layouts/, partials/, auth/, dashboard/, admin/, blog/)
- static/ (dashboard.css, blog.css)
+ static/ (dashboard.css "paper & ink" look — plain CSS, no variables/flex; blog.css)
```
## Key mechanics
@@ -87,7 +89,22 @@ internal/web/ server.go (host router, middleware, render helpers)
`dashboard/design.html`.
- **Images**: `/media/{uuid}` served on every host with immutable cache
headers. Uploads are content-sniffed (png/jpeg/gif/webp). Deleting an
- image clears theme references to it.
+ image clears theme references to it. `POST /b/{sub}/images/upload`
+ answers JSON (`{id, filename, markdown}` / `{error}`) when the request
+ has `Accept: application/json`; that is what the editor script calls.
+- **Editor** (`partials/editor.html`, args via `dict`: name, value, rows,
+ tall, upload, csrf): textarea + "Insert image" + cheat-sheet. Forms using
+ it must be `multipart/form-data` and their save handler must call
+ `s.readUpload(r, "inline_image")` + `appendImageMD` (the no-JS path).
+- **Announcements** (`sections` table, `store/sections.go`,
+ `handlers_sections.go`, `/b/{sub}/announcements…`): per-blog notices with
+ `placement` (above|below|sidebar), `style` (plain|note|warning), `enabled`
+ and `sort_order`. `blogView` loads the enabled ones and `splitSections`
+ routes `sidebar` to the left-sidebar layout only (otherwise above).
+ Rendered by the `notices` template in `layouts/blog.html`; CSS classes
+ `notice notice-<style>` use translucent colours so they fit any theme.
+- **Dashboard nav**: `dashnav.html` marks the current tab with `hasPrefix
+ .Path`; a new management section needs a tab there.
- **Slugs**: auto-generated from the title; on collision generated slugs
get `-2`, `-3`…, user-typed slugs return a 409 with a message.
- **Errors**: `store.ErrNotFound` / `store.ErrConflict` are the sentinels
diff --git a/README.md b/README.md
index 3e0542e..6b22a4b 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,11 @@ A small multi-tenant blog host. One Go binary + Postgres. Bloggers log in at
`example.com` to manage their blog; each blog is served at `<name>.example.com`.
Server-rendered HTML, no JavaScript required, works on old browsers and phones.
-- Posts and page intros are written in **Markdown** (sanitized on save).
+- Posts and page intros are written in **Markdown** (sanitized on save). Images can be
+ inserted straight from the editor (file picker, paste or drag-and-drop; with JavaScript
+ off the file is appended on save).
+- **Announcements**: blog-wide notices (next meeting, this month's book, a closure)
+ shown above or below the posts, or under the menu; each can be hidden without deleting.
- Every blog has **pages** (Home, About, News, …); each page holds posts.
- **Design** form: background colour/image, fonts, widths, header, menu position, footer.
- Changes are live immediately: save, then refresh the blog tab.
@@ -95,7 +99,7 @@ Images live in the database, so that one dump is everything.
cmd/blogspace/ main (serve | seed | migrate)
internal/config/ environment → Config
internal/db/ pgx pool + goose migrations (embedded SQL)
-internal/store/ models and queries (users, blogs, pages, posts, images)
+internal/store/ models and queries (users, blogs, pages, posts, images, sections)
internal/auth/ bcrypt, JWT cookie sessions, CSRF tokens
internal/markdown/ goldmark + bluemonday
internal/slug/ title → slug
diff --git a/internal/db/migrations/00002_sections.sql b/internal/db/migrations/00002_sections.sql
new file mode 100644
index 0000000..388cb17
--- /dev/null
+++ b/internal/db/migrations/00002_sections.sql
@@ -0,0 +1,19 @@
+-- +goose Up
+-- Announcements: blog-wide notices shown on every page and post.
+CREATE TABLE sections (
+ id bigserial PRIMARY KEY,
+ blog_id bigint NOT NULL REFERENCES blogs(id) ON DELETE CASCADE,
+ title text NOT NULL DEFAULT '',
+ body_md text NOT NULL DEFAULT '',
+ body_html text NOT NULL DEFAULT '',
+ placement text NOT NULL DEFAULT 'above' CHECK (placement IN ('above', 'below', 'sidebar')),
+ style text NOT NULL DEFAULT 'note' CHECK (style IN ('plain', 'note', 'warning')),
+ enabled boolean NOT NULL DEFAULT true,
+ sort_order integer NOT NULL DEFAULT 0,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX sections_blog ON sections (blog_id, sort_order, id);
+
+-- +goose Down
+DROP TABLE sections;
diff --git a/internal/store/sections.go b/internal/store/sections.go
new file mode 100644
index 0000000..f112102
--- /dev/null
+++ b/internal/store/sections.go
@@ -0,0 +1,114 @@
+package store
+
+import (
+ "context"
+ "time"
+)
+
+// Section is an announcement shown on every page and post of a blog.
+type Section struct {
+ ID int64
+ BlogID int64
+ Title string
+ BodyMD string
+ BodyHTML string
+ Placement string // above | below | sidebar
+ Style string // plain | note | warning
+ Enabled bool
+ SortOrder int
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+const sectionCols = `id, blog_id, title, body_md, body_html, placement, style, enabled, sort_order, created_at, updated_at`
+
+func scanSection(row interface{ Scan(...any) error }) (*Section, error) {
+ var s Section
+ err := row.Scan(&s.ID, &s.BlogID, &s.Title, &s.BodyMD, &s.BodyHTML, &s.Placement, &s.Style, &s.Enabled, &s.SortOrder, &s.CreatedAt, &s.UpdatedAt)
+ if err != nil {
+ return nil, wrap(err)
+ }
+ return &s, nil
+}
+
+func (s *Store) collectSections(ctx context.Context, q string, args ...any) ([]Section, error) {
+ rows, err := s.db.Query(ctx, q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Section
+ for rows.Next() {
+ sec, err := scanSection(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *sec)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) ListSections(ctx context.Context, blogID int64) ([]Section, error) {
+ return s.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 ORDER BY sort_order, id`, blogID)
+}
+
+// EnabledSections is what the public blog renders.
+func (s *Store) EnabledSections(ctx context.Context, blogID int64) ([]Section, error) {
+ return s.collectSections(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 AND enabled ORDER BY sort_order, id`, blogID)
+}
+
+func (s *Store) SectionByID(ctx context.Context, blogID, id int64) (*Section, error) {
+ return scanSection(s.db.QueryRow(ctx, `SELECT `+sectionCols+` FROM sections WHERE blog_id=$1 AND id=$2`, blogID, id))
+}
+
+func (s *Store) CreateSection(ctx context.Context, sec *Section) (*Section, error) {
+ return scanSection(s.db.QueryRow(ctx, `INSERT INTO sections (blog_id, title, body_md, body_html, placement, style, enabled, sort_order)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,(SELECT coalesce(max(sort_order),-1)+1 FROM sections WHERE blog_id=$1)) RETURNING `+sectionCols,
+ sec.BlogID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled))
+}
+
+func (s *Store) UpdateSection(ctx context.Context, sec *Section) error {
+ _, err := s.db.Exec(ctx, `UPDATE sections SET title=$3, body_md=$4, body_html=$5, placement=$6, style=$7, enabled=$8, updated_at=now() WHERE blog_id=$1 AND id=$2`,
+ sec.BlogID, sec.ID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled)
+ return wrap(err)
+}
+
+func (s *Store) SetSectionEnabled(ctx context.Context, blogID, id int64, on bool) error {
+ _, err := s.db.Exec(ctx, `UPDATE sections SET enabled=$3, updated_at=now() WHERE blog_id=$1 AND id=$2`, blogID, id, on)
+ return err
+}
+
+func (s *Store) DeleteSection(ctx context.Context, blogID, id int64) error {
+ _, err := s.db.Exec(ctx, `DELETE FROM sections WHERE blog_id=$1 AND id=$2`, blogID, id)
+ return err
+}
+
+// MoveSection swaps sort_order with the neighbouring section (dir = -1 up, +1 down).
+func (s *Store) MoveSection(ctx context.Context, blogID, id int64, dir int) error {
+ secs, err := s.ListSections(ctx, blogID)
+ if err != nil {
+ return err
+ }
+ idx := -1
+ for i := range secs {
+ if secs[i].ID == id {
+ idx = i
+ }
+ }
+ j := idx + dir
+ if idx < 0 || j < 0 || j >= len(secs) {
+ return nil
+ }
+ secs[idx], secs[j] = secs[j], secs[idx]
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ for i, sec := range secs { // renumber everything; keeps orders dense
+ if _, err := tx.Exec(ctx, `UPDATE sections SET sort_order=$3 WHERE blog_id=$1 AND id=$2`, blogID, sec.ID, i); err != nil {
+ return err
+ }
+ }
+ return tx.Commit(ctx)
+}
diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go
index b768c4d..9b2fe5e 100644
--- a/internal/web/handlers_blog.go
+++ b/internal/web/handlers_blog.go
@@ -12,13 +12,17 @@ import (
const postsPerPage = 10
-// blogView gathers what every public page needs: theme css, nav, the blog.
+// blogView gathers what every public page needs: theme css, nav, announcements, the blog.
func (s *Server) blogView(r *http.Request) (map[string]any, error) {
blog := currentBlog(r)
pages, err := s.st.ListPages(r.Context(), blog.ID)
if err != nil {
return nil, err
}
+ sections, err := s.st.EnabledSections(r.Context(), blog.ID)
+ if err != nil {
+ return nil, err
+ }
theme := ParseTheme(blog.ThemeJSON)
var nav []store.Page
for _, p := range pages {
@@ -26,7 +30,24 @@ func (s *Server) blogView(r *http.Request) (map[string]any, error) {
nav = append(nav, p)
}
}
- return map[string]any{"theme": theme, "css": theme.CSS(), "nav": nav}, nil
+ above, below, sidebar := splitSections(sections, theme.NavPosition == "left-sidebar")
+ return map[string]any{"theme": theme, "css": theme.CSS(), "nav": nav, "above": above, "below": below, "sidebar": sidebar}, nil
+}
+
+// splitSections groups announcements by placement. The sidebar only exists in
+// the left-sidebar layout; elsewhere sidebar announcements go above the content.
+func splitSections(secs []store.Section, hasSidebar bool) (above, below, sidebar []store.Section) {
+ for _, sec := range secs {
+ switch {
+ case sec.Placement == "below":
+ below = append(below, sec)
+ case sec.Placement == "sidebar" && hasSidebar:
+ sidebar = append(sidebar, sec)
+ default:
+ above = append(above, sec)
+ }
+ }
+ return
}
func (s *Server) handleBlogHome(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go
new file mode 100644
index 0000000..21ea998
--- /dev/null
+++ b/internal/web/handlers_sections.go
@@ -0,0 +1,161 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gramanas/blogspace/internal/markdown"
+ "github.com/gramanas/blogspace/internal/store"
+)
+
+// Announcements ("sections" in the schema) are notices shown on every page and post.
+
+const maxSectionBytes = 20_000
+
+func (s *Server) sectionsURL(r *http.Request) string {
+ return "/b/" + currentBlog(r).Subdomain + "/announcements"
+}
+
+func (s *Server) handleSections(w http.ResponseWriter, r *http.Request) {
+ secs, err := s.st.ListSections(r.Context(), currentBlog(r).ID)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ s.render(w, r, "dashboard/sections.html", map[string]any{"sections": secs})
+}
+
+func (s *Server) loadSection(w http.ResponseWriter, r *http.Request) *store.Section {
+ id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ sec, err := s.st.SectionByID(r.Context(), currentBlog(r).ID, id)
+ if err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ http.NotFound(w, r)
+ } else {
+ s.serverError(w, err)
+ }
+ return nil
+ }
+ return sec
+}
+
+func (s *Server) handleSectionForm(w http.ResponseWriter, r *http.Request) {
+ sec := &store.Section{Placement: "above", Style: "note", Enabled: true}
+ if r.PathValue("id") != "" {
+ if sec = s.loadSection(w, r); sec == nil {
+ return
+ }
+ }
+ s.render(w, r, "dashboard/section_form.html", map[string]any{"section": sec})
+}
+
+// normalizeSection clamps the enum fields so templates and CSS class names can trust them.
+func normalizeSection(sec *store.Section) {
+ sec.Placement = pick(sec.Placement, "above", "below", "sidebar")
+ sec.Style = pick(sec.Style, "note", "plain", "warning")
+}
+
+func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ sec := &store.Section{BlogID: blog.ID}
+ if r.PathValue("id") != "" {
+ if sec = s.loadSection(w, r); sec == nil {
+ return
+ }
+ }
+ sec.Title = strings.TrimSpace(r.FormValue("title"))
+ sec.BodyMD = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n")
+ sec.Placement = r.FormValue("placement")
+ sec.Style = r.FormValue("style")
+ sec.Enabled = r.FormValue("enabled") == "on"
+ normalizeSection(sec)
+ 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_image")
+ switch {
+ case err != nil:
+ fail("Image not added: " + err.Error())
+ return
+ case len(sec.Title) > 120:
+ fail("Title is too long (max 120 characters).")
+ return
+ case strings.TrimSpace(sec.BodyMD) == "" && img == nil:
+ fail("Write some text for the announcement.")
+ return
+ case len(sec.BodyMD) > maxSectionBytes:
+ fail("Announcement is too long (20 KB max).")
+ return
+ }
+ sec.BodyMD = appendImageMD(sec.BodyMD, img)
+ sec.BodyHTML = markdown.Render(sec.BodyMD)
+ if sec.ID == 0 {
+ sec, err = s.st.CreateSection(r.Context(), sec)
+ } else {
+ err = s.st.UpdateSection(r.Context(), sec)
+ }
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, s.sectionsURL(r)+"/"+strconv.FormatInt(sec.ID, 10)+"/edit", "Saved. Refresh your blog to see it.")
+}
+
+func (s *Server) handleSectionDeleteConfirm(w http.ResponseWriter, r *http.Request) {
+ sec := s.loadSection(w, r)
+ if sec == nil {
+ return
+ }
+ what := "this announcement"
+ if sec.Title != "" {
+ what = "the announcement \"" + sec.Title + "\""
+ }
+ s.render(w, r, "dashboard/confirm.html", map[string]any{"what": what, "action": r.URL.Path, "back": s.sectionsURL(r)})
+}
+
+func (s *Server) handleSectionDelete(w http.ResponseWriter, r *http.Request) {
+ sec := s.loadSection(w, r)
+ if sec == nil {
+ return
+ }
+ if err := s.st.DeleteSection(r.Context(), sec.BlogID, sec.ID); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, s.sectionsURL(r), "Announcement deleted.")
+}
+
+func (s *Server) handleSectionMove(w http.ResponseWriter, r *http.Request) {
+ sec := s.loadSection(w, r)
+ if sec == nil {
+ return
+ }
+ dir := 1
+ if r.FormValue("dir") == "up" {
+ dir = -1
+ }
+ if err := s.st.MoveSection(r.Context(), sec.BlogID, sec.ID, dir); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ http.Redirect(w, r, s.sectionsURL(r), http.StatusSeeOther)
+}
+
+// handleSectionToggle flips visibility without a trip through the edit form.
+func (s *Server) handleSectionToggle(w http.ResponseWriter, r *http.Request) {
+ sec := s.loadSection(w, r)
+ if sec == nil {
+ return
+ }
+ if err := s.st.SetSectionEnabled(r.Context(), sec.BlogID, sec.ID, !sec.Enabled); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ msg := "Announcement hidden."
+ if !sec.Enabled {
+ msg = "Announcement shown. Refresh your blog to see it."
+ }
+ redirectOK(w, r, s.sectionsURL(r), msg)
+}
diff --git a/internal/web/routes.go b/internal/web/routes.go
index e135229..bd39a18 100644
--- a/internal/web/routes.go
+++ b/internal/web/routes.go
@@ -37,6 +37,15 @@ func (s *Server) rootRoutes() http.Handler {
m.HandleFunc("POST /b/{sub}/posts/{id}/edit", s.withBlog(s.handlePostSave))
m.HandleFunc("GET /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDeleteConfirm))
m.HandleFunc("POST /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDelete))
+ m.HandleFunc("GET /b/{sub}/announcements", s.withBlog(s.handleSections))
+ m.HandleFunc("GET /b/{sub}/announcements/new", s.withBlog(s.handleSectionForm))
+ m.HandleFunc("POST /b/{sub}/announcements/new", s.withBlog(s.handleSectionSave))
+ m.HandleFunc("GET /b/{sub}/announcements/{id}/edit", s.withBlog(s.handleSectionForm))
+ m.HandleFunc("POST /b/{sub}/announcements/{id}/edit", s.withBlog(s.handleSectionSave))
+ m.HandleFunc("GET /b/{sub}/announcements/{id}/delete", s.withBlog(s.handleSectionDeleteConfirm))
+ m.HandleFunc("POST /b/{sub}/announcements/{id}/delete", s.withBlog(s.handleSectionDelete))
+ m.HandleFunc("POST /b/{sub}/announcements/{id}/move", s.withBlog(s.handleSectionMove))
+ m.HandleFunc("POST /b/{sub}/announcements/{id}/toggle", s.withBlog(s.handleSectionToggle))
m.HandleFunc("GET /b/{sub}/design", s.withBlog(s.handleDesignForm))
m.HandleFunc("POST /b/{sub}/design", s.withBlog(s.handleDesign))
m.HandleFunc("GET /b/{sub}/images", s.withBlog(s.handleImages))
diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css
index b09689b..a3695ae 100644
--- a/internal/web/static/blog.css
+++ b/internal/web/static/blog.css
@@ -29,6 +29,14 @@ img { max-width: 100%; height: auto; }
.post-body table { border-collapse: collapse; }
.post-body th, .post-body td { border: 1px solid rgba(0,0,0,0.15); padding: 0.3em 0.6em; }
.post-body hr { border: 0; border-top: 1px solid rgba(0,0,0,0.15); }
+/* announcements: translucent tints so they sit on any theme colour */
+.notice { margin: 0 0 1.5em; padding: 0.8em 1em; border: 1px solid rgba(0,0,0,0.15); }
+.notice-note { background: rgba(255, 200, 60, 0.18); border-left: 5px solid rgba(210, 150, 0, 0.85); }
+.notice-warning { background: rgba(220, 60, 40, 0.10); border-left: 5px solid rgba(190, 40, 30, 0.85); }
+.notice-title { margin: 0 0 0.3em; font-size: 1em; text-transform: uppercase; letter-spacing: 0.05em; }
+.notice-body > :first-child { margin-top: 0; }
+.notice-body > :last-child { margin-bottom: 0; }
+.content > .notice:last-child { margin: 1.5em 0 0; }
.pager { margin-top: 2em; padding-top: 1em; border-top: 1px solid rgba(0,0,0,0.1); text-align: center; }
.pager a, .pager span { margin: 0 0.8em; }
.muted { opacity: 0.7; }
@@ -41,10 +49,13 @@ img { max-width: 100%; height: auto; }
.nav-left-sidebar .body-wrap .site-nav a { display: block; padding: 0.4em 1em; }
.nav-left-sidebar .body-wrap .site-nav .nav-inner { padding: 0; }
.nav-left-sidebar .body-wrap .content { margin-left: 200px; }
+.nav-left-sidebar .body-wrap .sidebar-notices { float: left; clear: left; width: 180px; margin-top: 1em; }
+.nav-left-sidebar .body-wrap .sidebar-notices .notice { font-size: 0.9em; padding: 0.6em 0.8em; }
@media (max-width: 700px) {
.content { padding: 1em; }
.site-title { font-size: 1.5em; }
.nav-left-sidebar .body-wrap .site-nav { float: none; width: auto; margin-bottom: 1em; }
.nav-left-sidebar .body-wrap .site-nav a { display: inline-block; }
.nav-left-sidebar .body-wrap .content { margin-left: 0; }
+ .nav-left-sidebar .body-wrap .sidebar-notices { float: none; width: auto; margin: 0 0 1em; }
}
diff --git a/internal/web/templates/dashboard/overview.html b/internal/web/templates/dashboard/overview.html
index 0ec72e6..fbe49f9 100644
--- a/internal/web/templates/dashboard/overview.html
+++ b/internal/web/templates/dashboard/overview.html
@@ -7,7 +7,7 @@
<div class="card">
<h2>Quick actions</h2>
<p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">Write a new post</a></p>
- <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> &middot; <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p>
+ <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> &middot; <a href="/b/{{.Blog.Subdomain}}/announcements/new">Post an announcement</a> &middot; <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> &middot; <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p>
</div>
<div class="card">
<h2>Pages</h2>
diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html
new file mode 100644
index 0000000..9d4529c
--- /dev/null
+++ b/internal/web/templates/dashboard/section_form.html
@@ -0,0 +1,32 @@
+{{define "title"}}{{if .Data.section.ID}}Edit announcement{{else}}New announcement{{end}} · {{.Blog.Title}}{{end}}
+{{define "content"}}
+{{$s := .Data.section}}
+<div class="card">
+ <h1>{{if $s.ID}}Edit announcement{{else}}New announcement{{end}}</h1>
+ <form method="post" enctype="multipart/form-data">
+ <input type="hidden" name="_csrf" value="{{.CSRF}}">
+ <label>Title <span class="muted">(optional)</span><br><input name="title" value="{{$s.Title}}" maxlength="120" autofocus placeholder="Next meeting"></label>
+ <div class="row">
+ <label>Where<br><select name="placement">
+ <option value="above"{{if eq $s.Placement "above"}} selected{{end}}>Above the posts</option>
+ <option value="below"{{if eq $s.Placement "below"}} selected{{end}}>Below the posts</option>
+ <option value="sidebar"{{if eq $s.Placement "sidebar"}} selected{{end}}>Sidebar, under the menu</option>
+ </select></label>
+ <label>Look<br><select name="style">
+ <option value="note"{{if eq $s.Style "note"}} selected{{end}}>Highlighted</option>
+ <option value="warning"{{if eq $s.Style "warning"}} selected{{end}}>Warning</option>
+ <option value="plain"{{if eq $s.Style "plain"}} selected{{end}}>Plain</option>
+ </select></label>
+ </div>
+ <p class="muted small">The sidebar spot only exists when the menu is a sidebar (see <a href="/b/{{.Blog.Subdomain}}/design">Design</a>); otherwise a sidebar announcement is shown above the posts.</p>
+ <label for="ed-body">Text <span class="muted">(Markdown)</span></label>
+ {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}}
+ <label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> Shown on the blog</label>
+ <p class="actionrow">
+ <button type="submit">Save</button>
+ <a class="btn secondary" href="{{.BlogURL}}" target="_blank">View blog &#8599;</a>
+ <a href="/b/{{.Blog.Subdomain}}/announcements">Back to announcements</a>
+ </p>
+ </form>
+</div>
+{{end}}
diff --git a/internal/web/templates/dashboard/sections.html b/internal/web/templates/dashboard/sections.html
new file mode 100644
index 0000000..d2fade7
--- /dev/null
+++ b/internal/web/templates/dashboard/sections.html
@@ -0,0 +1,29 @@
+{{define "title"}}Announcements · {{.Blog.Title}}{{end}}
+{{define "content"}}
+{{$u := print "/b/" .Blog.Subdomain "/announcements"}}
+<div class="pagehead">
+ <h1>Announcements</h1>
+ <div class="actions"><a class="btn" href="{{$u}}/new">+ New announcement</a></div>
+ <p class="lead muted">Notices shown on every page and post of your blog — the next meeting, this month's book, a holiday closure. Hide one instead of deleting it if you will need it again.</p>
+</div>
+<div class="card">
+{{if .Data.sections}}
+<table>
+ <tr><th>Order</th><th>Announcement</th><th>Where</th><th>Look</th><th></th></tr>
+ {{range .Data.sections}}<tr{{if not .Enabled}} class="disabled"{{end}}>
+ <td class="nowrap">
+ <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">&#9650;</button></form>
+ <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">&#9660;</button></form>
+ </td>
+ <td><a href="{{$u}}/{{.ID}}/edit">{{if .Title}}{{.Title}}{{else}}(untitled){{end}}</a>{{if not .Enabled}}<span class="tag off">hidden</span>{{end}}</td>
+ <td>{{if eq .Placement "above"}}Above the posts{{else if eq .Placement "below"}}Below the posts{{else}}Sidebar{{end}}</td>
+ <td>{{if eq .Style "note"}}Highlighted{{else if eq .Style "warning"}}Warning{{else}}Plain{{end}}</td>
+ <td class="nowrap">
+ <form method="post" action="{{$u}}/{{.ID}}/toggle" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{if .Enabled}}Hide{{else}}Show{{end}}</button></form>
+ <a class="danger" href="{{$u}}/{{.ID}}/delete">delete</a>
+ </td>
+ </tr>{{end}}
+</table>
+{{else}}<p class="muted">No announcements yet. <a href="{{$u}}/new">Write the first one.</a></p>{{end}}
+</div>
+{{end}}
diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html
index 7e3128e..ac6595b 100644
--- a/internal/web/templates/layouts/blog.html
+++ b/internal/web/templates/layouts/blog.html
@@ -19,9 +19,11 @@
</div>
{{if eq .Data.theme.NavPosition "below-header"}}{{template "blognav" .}}{{end}}
<div class="wrap body-wrap">
- {{if eq .Data.theme.NavPosition "left-sidebar"}}{{template "blognav" .}}{{end}}
+ {{if eq .Data.theme.NavPosition "left-sidebar"}}{{template "blognav" .}}{{if .Data.sidebar}}<div class="sidebar-notices">{{template "notices" .Data.sidebar}}</div>{{end}}{{end}}
<div class="content">
+ {{template "notices" .Data.above}}
{{template "content" .}}
+ {{template "notices" .Data.below}}
</div>
</div>
<div class="site-footer">
@@ -33,6 +35,12 @@
</body>
</html>{{end}}
+{{define "notices"}}{{range .}}<aside class="notice notice-{{.Style}}">
+ {{if .Title}}<h3 class="notice-title">{{.Title}}</h3>{{end}}
+ <div class="notice-body">{{html .BodyHTML}}</div>
+</aside>
+{{end}}{{end}}
+
{{define "blognav"}}<div class="site-nav"><div class="wrap nav-inner">
{{range .Data.nav}}<a href="{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}"{{if and $.Data.page (eq $.Data.page.ID .ID)}} class="active"{{end}}>{{.Title}}</a>
{{end}}</div></div>{{end}}
diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html
index 44e518e..f54cb2d 100644
--- a/internal/web/templates/partials/dashnav.html
+++ b/internal/web/templates/partials/dashnav.html
@@ -20,6 +20,7 @@
<a href="{{$b}}"{{if eq .Path $b}} class="active"{{end}}>Overview</a>
<a href="{{$b}}posts"{{if hasPrefix .Path (print $b "posts")}} class="active"{{end}}>Posts</a>
<a href="{{$b}}pages"{{if hasPrefix .Path (print $b "pages")}} class="active"{{end}}>Pages</a>
+ <a href="{{$b}}announcements"{{if hasPrefix .Path (print $b "announcements")}} class="active"{{end}}>Announcements</a>
<a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>Design</a>
<a href="{{$b}}images"{{if hasPrefix .Path (print $b "images")}} class="active"{{end}}>Images</a>
<a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>Settings</a>
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index 6c8072e..002f18a 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -50,7 +50,7 @@ func TestHostRoutingWithoutDB(t *testing.T) {
func TestRootRoutePrecedence(t *testing.T) {
cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20}
s := NewServer(cfg, nil)
- for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/account/password": 303} {
+ for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/account/password": 303} {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", path, nil)
req.Host = "example.com"
@@ -86,7 +86,7 @@ func TestAllTemplatesParse(t *testing.T) {
tpl := newTemplates(false, funcs)
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/images.html", "dashboard/settings.html",
- "dashboard/password.html", "dashboard/confirm.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.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/404.html"} {
if _, err := tpl.get(name); err != nil {
t.Errorf("%s: %v", name, err)
@@ -120,3 +120,20 @@ func TestAppendImageMD(t *testing.T) {
t.Errorf("nil image should not change the body: %q", got)
}
}
+
+func TestSplitSections(t *testing.T) {
+ secs := []store.Section{{ID: 1, Placement: "above"}, {ID: 2, Placement: "below"}, {ID: 3, Placement: "sidebar"}}
+ above, below, sidebar := splitSections(secs, true)
+ if len(above) != 1 || len(below) != 1 || len(sidebar) != 1 || sidebar[0].ID != 3 {
+ t.Errorf("with sidebar: %v %v %v", above, below, sidebar)
+ }
+ above, _, sidebar = splitSections(secs, false)
+ if len(above) != 2 || len(sidebar) != 0 || above[1].ID != 3 { // sidebar falls back to above, keeping order
+ t.Errorf("without sidebar: %v %v", above, sidebar)
+ }
+ sec := &store.Section{Placement: "footer", Style: "<script>"}
+ normalizeSection(sec)
+ if sec.Placement != "above" || sec.Style != "note" {
+ t.Errorf("normalize: %+v", sec)
+ }
+}