diff options
| -rw-r--r-- | AGENTS.md | 27 | ||||
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | internal/db/migrations/00002_sections.sql | 19 | ||||
| -rw-r--r-- | internal/store/sections.go | 114 | ||||
| -rw-r--r-- | internal/web/handlers_blog.go | 25 | ||||
| -rw-r--r-- | internal/web/handlers_sections.go | 161 | ||||
| -rw-r--r-- | internal/web/routes.go | 9 | ||||
| -rw-r--r-- | internal/web/static/blog.css | 11 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/overview.html | 2 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/section_form.html | 32 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/sections.html | 29 | ||||
| -rw-r--r-- | internal/web/templates/layouts/blog.html | 10 | ||||
| -rw-r--r-- | internal/web/templates/partials/dashnav.html | 1 | ||||
| -rw-r--r-- | internal/web/web_test.go | 21 |
14 files changed, 456 insertions, 13 deletions
@@ -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 @@ -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> · <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> · <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p> + <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> · <a href="/b/{{.Blog.Subdomain}}/announcements/new">Post an announcement</a> · <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> · <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 ↗</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">▲</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">▼</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) + } +} |
