aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-12 19:47:28 +0300
committergrm <grm@eyesin.space>2026-09-12 19:47:28 +0300
commite974eb6f3feb06c8951f85c1d34161938905f807 (patch)
tree3bba878b6764e9d18519efd393d4e780eaabd81a
parentdd13e4b73a105ee416b6cfd8eb19b217622c0031 (diff)
downloadblogspace-e974eb6f3feb06c8951f85c1d34161938905f807.tar.gz
blogspace-e974eb6f3feb06c8951f85c1d34161938905f807.tar.bz2
blogspace-e974eb6f3feb06c8951f85c1d34161938905f807.zip
Add a favicon and rework the Design page
Blogs had no icon at all, and browsers probing /favicon.ico ended up rendering the 404 page. Ship a default paper & ink icon (svg + png) and let each blog replace it from the Design page; /favicon.ico now serves the default or redirects to the blog's own image. ICO uploads accepted. The Design page gains colour schemes, reset to defaults, thumbnail image pickers, jump links and a sticky save bar, plus new options: heading font, link underline/hover, content box style and padding, header image height, post dates and format, footer RSS toggle, menu alignment/style/hover and sidebar width. The Menu card picks the position with illustrated tiles and shows only the options that apply to it, using CSS :checked rules rather than JavaScript. All new defaults reproduce the previous look, so existing blogs are unchanged until edited. Theme is jsonb, so no migration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
-rw-r--r--AGENTS.md22
-rw-r--r--README.md2
-rw-r--r--internal/web/handlers_design.go42
-rw-r--r--internal/web/handlers_media.go21
-rw-r--r--internal/web/handlers_pages.go2
-rw-r--r--internal/web/routes.go4
-rw-r--r--internal/web/static/blog.css2
-rw-r--r--internal/web/static/dashboard.css46
-rw-r--r--internal/web/static/favicon.pngbin0 -> 199 bytes
-rw-r--r--internal/web/static/favicon.svg5
-rw-r--r--internal/web/templates/blog/page.html2
-rw-r--r--internal/web/templates/blog/post.html2
-rw-r--r--internal/web/templates/dashboard/design.html138
-rw-r--r--internal/web/templates/dashboard/images.html2
-rw-r--r--internal/web/templates/layouts/blog.html7
-rw-r--r--internal/web/templates/layouts/dashboard.html2
-rw-r--r--internal/web/templates/partials/imagepick.html16
-rw-r--r--internal/web/theme.go274
-rw-r--r--internal/web/web_test.go100
19 files changed, 603 insertions, 86 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 2352b85..b552615 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -86,9 +86,27 @@ internal/web/ server.go (host router, middleware, render helpers)
value to an allowlist (hex colours, enum strings, uuid image ids) — this
is what makes inlining the generated CSS safe. Add new options in the
struct, `normalize`, `ThemeFromForm`, the CSS template and
- `dashboard/design.html`.
+ `dashboard/design.html`; defaults must reproduce the look blogs had
+ before the option existed. Non-CSS options (`ShowDates`, `DateFormat` via
+ `FormatDate`, `FooterShowRSS`, `Favicon`) are read by the blog templates.
+ Optional colours (`LinkHover`, `NavHover`) are `""` = inherit and come
+ from a colour input paired with a `<name>_custom` checkbox. Image fields
+ use the `imagepick` partial's radios: `none` clears, a uuid selects, no
+ value keeps (`pickImage`); an upload in `<name>_file` wins.
+ Options that only apply to one menu layout (`NavAlign` for horizontal
+ menus, `NavSidebarWidth` for the sidebar) are shown/hidden in
+ `design.html` with CSS only (`#np-*:checked ~ .only-*` in dashboard.css,
+ same trick for the `_custom` hover checkboxes) — no JS, and browsers
+ without `:checked` just show everything.
+ `Presets()`/`WithPreset` are the colour schemes (`POST /b/{sub}/design/preset`,
+ colours only); `POST /b/{sub}/design/reset` stores `DefaultTheme()`.
+- **Favicon**: `static/favicon.svg` + `favicon.png` are the defaults, linked
+ from both layouts. A blog can set `theme.favicon` (an image id) which the
+ blog layout links instead. `GET /favicon.ico` on every host
+ (`handleFavicon`) serves the png or redirects to the blog's image, so
+ icon-probing browsers never hit the 404 page; `favicon.ico` is reserved.
- **Images**: `/media/{uuid}` served on every host with immutable cache
- headers. Uploads are content-sniffed (png/jpeg/gif/webp). Deleting an
+ headers. Uploads are content-sniffed (png/jpeg/gif/webp/ico). Deleting an
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.
diff --git a/README.md b/README.md
index 6b22a4b..8ccc78a 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones.
- **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.
+- **Design** form: colour schemes, background colour/image, fonts, link style, content box, header, menu, post dates, footer, site icon (favicon).
- Changes are live immediately: save, then refresh the blog tab.
- A **superadmin** creates bloggers, resets passwords, disables or deletes accounts.
- The **root domain is itself a blog**, owned by the superadmin and managed like any other.
diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go
index 0db66c2..3da7ee3 100644
--- a/internal/web/handlers_design.go
+++ b/internal/web/handlers_design.go
@@ -15,7 +15,8 @@ import (
"github.com/gramanas/blogspace/internal/store"
)
-var allowedImageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true}
+// ICO is here for site icons; it is harmless anywhere else an image can go.
+var allowedImageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true, "image/x-icon": true}
func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) {
blog := currentBlog(r)
@@ -24,7 +25,7 @@ func (s *Server) handleDesignForm(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- s.render(w, r, "dashboard/design.html", map[string]any{"theme": ParseTheme(blog.ThemeJSON), "images": images})
+ s.render(w, r, "dashboard/design.html", map[string]any{"theme": ParseTheme(blog.ThemeJSON), "images": images, "presets": Presets()})
}
func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
@@ -35,10 +36,11 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
}
theme := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form)
// Optional direct uploads from the design form.
- for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage} {
+ for field, dst := range map[string]*string{"bg_image_file": &theme.BgImage, "header_image_file": &theme.HeaderImage, "favicon_file": &theme.Favicon} {
img, err := s.readUpload(r, field)
if err != nil {
- s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", map[string]any{"theme": theme, "error": err.Error()})
+ images, _ := s.st.ListImages(r.Context(), blog.ID)
+ s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", map[string]any{"theme": theme, "images": images, "presets": Presets(), "error": err.Error()})
return
}
if img != nil {
@@ -52,6 +54,33 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design saved. Refresh your blog to see it.")
}
+// handleDesignPreset applies a colour scheme straight away; it is a separate
+// form so it cannot be confused with the unsaved edits of the main one.
+func (s *Server) handleDesignPreset(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ p, ok := PresetByKey(r.FormValue("preset"))
+ if !ok {
+ s.plainError(w, http.StatusBadRequest, "Unknown colour scheme.")
+ return
+ }
+ theme := ParseTheme(blog.ThemeJSON).WithPreset(p)
+ if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Applied the "+p.Name+" scheme. Refresh your blog to see it.")
+}
+
+// handleDesignReset restores the defaults; images stay in the library.
+func (s *Server) handleDesignReset(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, DefaultTheme().JSON()); err != nil {
+ s.serverError(w, err)
+ return
+ }
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design reset to the defaults.")
+}
+
// readUpload stores the file from a multipart field, returning nil if the field is empty.
func (s *Server) readUpload(r *http.Request, field string) (*store.Image, error) {
if r.MultipartForm == nil {
@@ -82,7 +111,7 @@ func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader) (*store.
}
ct := http.DetectContentType(buf.Bytes())
if !allowedImageTypes[ct] {
- return nil, errors.New("only PNG, JPEG, GIF and WebP images are accepted")
+ return nil, errors.New("only PNG, JPEG, GIF, WebP and ICO images are accepted")
}
name := filepath.Base(fh.Filename)
if name == "" || name == "." || len(name) > 120 {
@@ -186,6 +215,9 @@ func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) {
if theme.HeaderImage == id.String() {
theme.HeaderImage, changed = "", true
}
+ if theme.Favicon == id.String() {
+ theme.Favicon, changed = "", true
+ }
if changed {
if err := s.st.UpdateBlogTheme(r.Context(), blog.ID, theme.JSON()); err != nil {
s.serverError(w, err)
diff --git a/internal/web/handlers_media.go b/internal/web/handlers_media.go
index e651135..9d5a515 100644
--- a/internal/web/handlers_media.go
+++ b/internal/web/handlers_media.go
@@ -3,6 +3,7 @@ package web
import (
"bytes"
"errors"
+ "io/fs"
"net/http"
"github.com/google/uuid"
@@ -36,3 +37,23 @@ func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, img.Filename, img.CreatedAt, bytes.NewReader(img.Data))
}
+
+// handleFavicon answers the browsers that ask for /favicon.ico regardless of
+// the <link rel="icon"> tags: the blog's own icon if it set one, else the
+// Blogspace default. Without this the request would render the 404 page.
+func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
+ if icon := ParseTheme(currentBlog(r).ThemeJSON).Favicon; icon != "" {
+ http.Redirect(w, r, "/media/"+icon, http.StatusFound)
+ return
+ }
+ data, err := fs.ReadFile(s.tpl.AssetsFS(), "favicon.png")
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ w.Header().Set("Content-Type", "image/png")
+ if !s.cfg.Dev {
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ }
+ w.Write(data)
+}
diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go
index f2f82d1..a226953 100644
--- a/internal/web/handlers_pages.go
+++ b/internal/web/handlers_pages.go
@@ -12,7 +12,7 @@ import (
)
// Page slugs that would collide with blog routes, or with management routes on the root domain.
-var reservedPageSlugs = map[string]bool{"media": true, "static": true, "feed.xml": true,
+var reservedPageSlugs = map[string]bool{"media": true, "static": true, "feed.xml": true, "favicon.ico": true,
"webadmin": true, "logout": true, "dashboard": true, "account": true, "admin": true, "b": true, "healthz": true}
func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/web/routes.go b/internal/web/routes.go
index bd39a18..84b19a6 100644
--- a/internal/web/routes.go
+++ b/internal/web/routes.go
@@ -48,6 +48,8 @@ func (s *Server) rootRoutes() http.Handler {
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("POST /b/{sub}/design/preset", s.withBlog(s.handleDesignPreset))
+ m.HandleFunc("POST /b/{sub}/design/reset", s.withBlog(s.handleDesignReset))
m.HandleFunc("GET /b/{sub}/images", s.withBlog(s.handleImages))
m.HandleFunc("POST /b/{sub}/images/upload", s.withBlog(s.handleImageUpload))
m.HandleFunc("POST /b/{sub}/images/{id}/delete", s.withBlog(s.handleImageDelete))
@@ -64,6 +66,7 @@ func (s *Server) rootRoutes() http.Handler {
m.HandleFunc("GET /media/{id}", s.handleMedia)
m.Handle("GET /static/{file}", s.staticHandler())
+ m.HandleFunc("GET /favicon.ico", s.hostBlog(s.handleFavicon))
// The root domain also serves the superadmin's blog. Literal routes above win
// over these wildcards; see reservedPageSlugs for the names that are shadowed.
s.blogRoutes(m, s.hostBlog)
@@ -84,6 +87,7 @@ func (s *Server) subdomainRoutes() http.Handler {
m.HandleFunc("GET /webadmin", s.handleWebadminRedirect)
m.HandleFunc("GET /media/{id}", s.handleMedia)
m.Handle("GET /static/{file}", s.staticHandler())
+ m.HandleFunc("GET /favicon.ico", s.hostBlog(s.handleFavicon))
s.blogRoutes(m, s.hostBlog)
return m
}
diff --git a/internal/web/static/blog.css b/internal/web/static/blog.css
index a3695ae..778c169 100644
--- a/internal/web/static/blog.css
+++ b/internal/web/static/blog.css
@@ -47,7 +47,7 @@ img { max-width: 100%; height: auto; }
/* left sidebar layout: float the menu beside the content */
.nav-left-sidebar .body-wrap .site-nav { float: left; width: 180px; border: 0; border-radius: 6px; padding: 0.5em 0; }
.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 .site-nav .nav-inner { padding: 0; text-align: left; } /* alignment is a horizontal-menu option */
.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; }
diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css
index c3d1420..4ea0d43 100644
--- a/internal/web/static/dashboard.css
+++ b/internal/web/static/dashboard.css
@@ -125,14 +125,56 @@ details.help[open] summary { margin-bottom: 0.5em; }
.thumb .meta { font-size: 0.85em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 0.4em; }
.thumb .copy { font-size: 0.75em; margin: 0.4em 0; padding: 0.3em; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; }
.imagepick { margin-top: 0.5em; padding-top: 0.5em; border-top: 2px dashed #ddd6c7; }
-.imagepick img.preview { display: block; max-width: 240px; max-height: 120px; margin-top: 0.4em; border: 2px solid #1d1a17; }
-.imagepick select { width: auto; max-width: 100%; }
+.imagepick > label { margin-bottom: 0.3em; }
+.imagepick .picks { overflow: hidden; margin: 0.4em 0 0; }
+.imagepick .pick { float: left; width: 120px; margin: 0 0.7em 0.7em 0; padding: 0.35em; border: 2px solid #ddd6c7; background: #fffdf8; text-align: center; font-size: 0.75em; text-transform: none; letter-spacing: 0; cursor: pointer; }
+.imagepick .pick:hover { border-color: #1d1a17; }
+.imagepick .pick input { display: block; width: auto; margin: 0 auto 0.3em; }
+.imagepick .pick img, .imagepick .pick .swatch { display: block; width: 100%; height: 64px; margin: 0 auto 0.3em; border: 1px solid #ddd6c7; background: #f4efe4; }
+.imagepick .pick img { object-fit: contain; }
+.imagepick .pick .swatch { line-height: 64px; font-size: 1.6em; color: #b8b0a3; }
+.imagepick .pick .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.imagepick .pick input:checked ~ img, .imagepick .pick input:checked ~ .swatch { border: 3px solid #c8541a; }
+
+/* ---- design page ---------------------------------------------------------- */
+.jumps { margin: 0 0 1.2em; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; }
+.jumps a { display: inline-block; margin: 0 0.8em 0.3em 0; color: #1d1a17; text-decoration: none; border-bottom: 2px solid #ddd6c7; }
+.jumps a:hover { border-color: #c8541a; }
+.presets { overflow: hidden; }
+.presets .preset { float: left; width: 8.5em; margin: 0 0.9em 0.9em 0; padding: 0.45em; background: #fffdf8; color: #1d1a17; font-size: 0.8em; text-align: center; }
+.presets .preset:hover { background: #ebe5d6; color: #1d1a17; }
+.presets .sw { display: inline-block; width: 22%; height: 2.2em; margin: 0 1%; border: 1px solid rgba(0,0,0,0.25); vertical-align: middle; }
+.presets .pname { display: block; margin-top: 0.4em; }
+/* menu position tiles + options that only apply to one layout; the show/hide is
+ pure CSS (:checked ~), and browsers without it simply show everything */
+.navpos { overflow: hidden; }
+.navpos > input[type=radio] { position: absolute; opacity: 0; width: 1px; height: 1px; }
+.navpos .tile { float: left; width: 8.5em; margin: 0.3em 0.9em 0.9em 0; padding: 0.5em 0.4em 0.4em; border: 2px solid #ddd6c7; background: #fffdf8; text-align: center; cursor: pointer; font-size: 0.75em; }
+.navpos .tile svg { display: block; width: 80px; height: 56px; margin: 0 auto 0.4em; border: 1px solid #ddd6c7; }
+.navpos .tile span { display: block; }
+.navpos .tile:hover { border-color: #1d1a17; }
+.navpos > input:checked + .tile { border-color: #c8541a; box-shadow: 3px 3px 0 #c8541a; }
+.navpos > input:focus + .tile { outline: 2px solid #c8541a; outline-offset: 1px; }
+.navpos .only-horizontal, .navpos .only-sidebar { clear: both; }
+#np-side:checked ~ .only-horizontal { display: none; }
+#np-below:checked ~ .only-sidebar, #np-top:checked ~ .only-sidebar { display: none; }
+.hoverpick { margin: 0.8em 0; }
+.hoverpick > input { width: auto; margin: 0 0.4em 0 0; vertical-align: middle; }
+.hoverpick > label.check { display: inline; margin: 0; }
+.hoverpick .hover-color { margin: 0.4em 0 0 1.6em; }
+.hoverpick > input:not(:checked) ~ .hover-color { display: none; }
+.savebar { position: sticky; bottom: 0; margin: 0 4px 0 0; padding: 0.8em 0; background: #f4efe4; border-top: 2px solid #1d1a17; }
+.savebar .btn { margin-left: 0.8em; }
+details.reset { margin-top: 1.6em; }
/* ---- login --------------------------------------------------------------- */
.login-brand { text-align: center; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; text-transform: uppercase; letter-spacing: 0.2em; margin: 2em 0 1em; }
@media (max-width: 700px) {
.cols .card, .row label, .thumb { float: none; width: auto; margin-right: 4px; }
+ .imagepick .pick { width: 46%; margin-right: 4%; }
+ .presets .preset { width: 46%; margin-right: 4%; }
+ .navpos .tile { width: 30%; margin-right: 3%; }
.cols .card + .card { margin-right: 4px; }
.topbar .links { float: none; display: block; margin-top: 0.3em; }
.topbar .links a, .topbar .links form { margin-left: 0; margin-right: 1em; }
diff --git a/internal/web/static/favicon.png b/internal/web/static/favicon.png
new file mode 100644
index 0000000..26d45d4
--- /dev/null
+++ b/internal/web/static/favicon.png
Binary files differ
diff --git a/internal/web/static/favicon.svg b/internal/web/static/favicon.svg
new file mode 100644
index 0000000..5d44fd7
--- /dev/null
+++ b/internal/web/static/favicon.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+ <rect width="32" height="32" fill="#1d1a17"/>
+ <rect x="7" y="7" width="18" height="18" fill="#f4efe4"/>
+ <rect x="11" y="11" width="10" height="10" fill="#c8541a"/>
+</svg>
diff --git a/internal/web/templates/blog/page.html b/internal/web/templates/blog/page.html
index c1fe726..ba78ff6 100644
--- a/internal/web/templates/blog/page.html
+++ b/internal/web/templates/blog/page.html
@@ -5,7 +5,7 @@
{{range .Data.posts}}
<article class="post">
<h2 class="post-title"><a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a></h2>
- <p class="post-date">{{date .CreatedAt}}</p>
+ {{if $.Data.theme.ShowDates}}<p class="post-date">{{$.Data.theme.FormatDate .CreatedAt}}</p>{{end}}
<div class="post-body">{{html .BodyHTML}}</div>
</article>
{{else}}{{if not .Data.page.IntroHTML}}<p class="muted">Nothing here yet.</p>{{end}}{{end}}
diff --git a/internal/web/templates/blog/post.html b/internal/web/templates/blog/post.html
index 83ce782..5fa8ca4 100644
--- a/internal/web/templates/blog/post.html
+++ b/internal/web/templates/blog/post.html
@@ -2,7 +2,7 @@
{{define "content"}}
<article class="post single">
<h1 class="post-title">{{.Data.post.Title}}</h1>
- <p class="post-date">{{date .Data.post.CreatedAt}} &middot; <a href="{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}">{{.Data.page.Title}}</a></p>
+ <p class="post-date">{{if .Data.theme.ShowDates}}{{.Data.theme.FormatDate .Data.post.CreatedAt}} &middot; {{end}}<a href="{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}">{{.Data.page.Title}}</a></p>
<div class="post-body">{{html .Data.post.BodyHTML}}</div>
</article>
{{end}}
diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html
index 09ea1fe..af482fe 100644
--- a/internal/web/templates/dashboard/design.html
+++ b/internal/web/templates/dashboard/design.html
@@ -1,12 +1,29 @@
{{define "title"}}Design · {{.Blog.Title}}{{end}}
{{define "content"}}
{{$t := .Data.theme}}
-<h1>Design</h1>
-<p class="muted">Save, then refresh <a href="{{.BlogURL}}" target="_blank">your blog &#8599;</a> to see the result. Colours are hex values like <code>#336699</code>.</p>
+<div class="pagehead">
+ <h1>Design</h1>
+ <p class="lead muted">Changes are live as soon as you save; refresh <a href="{{.BlogURL}}" target="_blank">your blog &#8599;</a> to see them.</p>
+</div>
+<p class="jumps"><a href="#schemes">Schemes</a><a href="#background">Background</a><a href="#text">Text</a><a href="#header">Header</a><a href="#menu">Menu</a><a href="#posts">Posts</a><a href="#footer">Footer</a><a href="#icon">Icon</a></p>
+
+<div class="card" id="schemes">
+ <h2>Colour schemes</h2>
+ <p class="muted small">A quick start: applying a scheme sets every colour below and saves immediately. Images, fonts and layout are kept.</p>
+ <form method="post" action="/b/{{.Blog.Subdomain}}/design/preset" class="presets">
+ <input type="hidden" name="_csrf" value="{{.CSRF}}">
+ {{range .Data.presets}}<button type="submit" name="preset" value="{{.Key}}" class="preset" title="Apply {{.Name}}">
+ <span class="sw" style="background:{{.HeaderBg}}"></span><span class="sw" style="background:{{.Bg}}"></span><span class="sw" style="background:{{.ContentBg}}"></span><span class="sw" style="background:{{.Link}}"></span>
+ <span class="pname">{{.Name}}</span>
+ </button>
+ {{end}}
+ </form>
+</div>
+
<form method="post" action="/b/{{.Blog.Subdomain}}/design" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
-<div class="card">
+<div class="card" id="background">
<h2>Background</h2>
<div class="row">
<label>Colour<br><input type="color" name="bg_color" value="{{$t.BgColor}}"></label>
@@ -19,33 +36,60 @@
{{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "images" .Data.images "label" "Background image")}}
</div>
-<div class="card">
+<div class="card" id="text">
<h2>Text &amp; content area</h2>
<div class="row">
<label>Content background<br><input type="color" name="content_bg" value="{{$t.ContentBg}}"></label>
<label>Text colour<br><input type="color" name="text_color" value="{{$t.TextColor}}"></label>
<label>Link colour<br><input type="color" name="link_color" value="{{$t.LinkColor}}"></label>
</div>
+ <div class="hoverpick">
+ <input type="checkbox" name="link_hover_custom" id="link-hover-custom"{{if $t.LinkHover}} checked{{end}}>
+ <label for="link-hover-custom" class="check">Change colour when the mouse is over a link</label>
+ <label class="hover-color">Hover colour<br><input type="color" name="link_hover" value="{{if $t.LinkHover}}{{$t.LinkHover}}{{else}}{{$t.LinkColor}}{{end}}"></label>
+ </div>
<div class="row">
<label>Font<br><select name="font">
<option value="sans"{{if eq $t.Font "sans"}} selected{{end}}>Sans-serif (Helvetica/Arial)</option>
<option value="serif"{{if eq $t.Font "serif"}} selected{{end}}>Serif (Georgia/Times)</option>
<option value="mono"{{if eq $t.Font "mono"}} selected{{end}}>Monospace (Courier)</option>
</select></label>
+ <label>Heading font<br><select name="heading_font">
+ <option value="same"{{if eq $t.HeadingFont "same"}} selected{{end}}>Same as text</option>
+ <option value="sans"{{if eq $t.HeadingFont "sans"}} selected{{end}}>Sans-serif</option>
+ <option value="serif"{{if eq $t.HeadingFont "serif"}} selected{{end}}>Serif</option>
+ <option value="mono"{{if eq $t.HeadingFont "mono"}} selected{{end}}>Monospace</option>
+ </select></label>
<label>Text size<br><select name="font_size">
<option value="small"{{if eq $t.FontSize "small"}} selected{{end}}>Small</option>
<option value="normal"{{if eq $t.FontSize "normal"}} selected{{end}}>Normal</option>
<option value="large"{{if eq $t.FontSize "large"}} selected{{end}}>Large</option>
</select></label>
+ <label>Underline links<br><select name="link_underline">
+ <option value="always"{{if eq $t.LinkUnderline "always"}} selected{{end}}>Always</option>
+ <option value="hover"{{if eq $t.LinkUnderline "hover"}} selected{{end}}>Only on hover</option>
+ <option value="never"{{if eq $t.LinkUnderline "never"}} selected{{end}}>Never</option>
+ </select></label>
+ </div>
+ <div class="row">
<label>Width<br><select name="content_width">
<option value="narrow"{{if eq $t.ContentWidth "narrow"}} selected{{end}}>Narrow</option>
<option value="medium"{{if eq $t.ContentWidth "medium"}} selected{{end}}>Medium</option>
<option value="wide"{{if eq $t.ContentWidth "wide"}} selected{{end}}>Wide</option>
</select></label>
+ <label>Box style<br><select name="content_style">
+ <option value="card"{{if eq $t.ContentStyle "card"}} selected{{end}}>Card (shadow, rounded corners)</option>
+ <option value="flat"{{if eq $t.ContentStyle "flat"}} selected{{end}}>Flat</option>
+ </select></label>
+ <label>Padding<br><select name="content_padding">
+ <option value="compact"{{if eq $t.ContentPadding "compact"}} selected{{end}}>Compact</option>
+ <option value="normal"{{if eq $t.ContentPadding "normal"}} selected{{end}}>Normal</option>
+ <option value="roomy"{{if eq $t.ContentPadding "roomy"}} selected{{end}}>Roomy</option>
+ </select></label>
</div>
</div>
-<div class="card">
+<div class="card" id="header">
<h2>Header</h2>
<div class="row">
<label>Background<br><input type="color" name="header_bg" value="{{$t.HeaderBg}}"></label>
@@ -54,34 +98,100 @@
<option value="left"{{if eq $t.HeaderAlign "left"}} selected{{end}}>Left</option>
<option value="center"{{if eq $t.HeaderAlign "center"}} selected{{end}}>Centred</option>
</select></label>
+ <label>Image height<br><select name="header_image_height">
+ <option value="short"{{if eq $t.HeaderImageHeight "short"}} selected{{end}}>Short</option>
+ <option value="medium"{{if eq $t.HeaderImageHeight "medium"}} selected{{end}}>Medium</option>
+ <option value="tall"{{if eq $t.HeaderImageHeight "tall"}} selected{{end}}>Tall</option>
+ </select></label>
</div>
<label class="check"><input type="checkbox" name="header_show_title"{{if $t.HeaderShowTitle}} checked{{end}}> Show blog title and tagline in the header</label>
{{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "images" .Data.images "label" "Header image (banner)")}}
</div>
-<div class="card">
+<div class="card" id="menu">
<h2>Menu</h2>
+ <p class="muted small">The menu lists the pages you marked "show in menu". Pick where it goes; the options underneath follow your choice.</p>
+ <div class="navpos">
+ <input type="radio" name="nav_position" value="below-header" id="np-below"{{if eq $t.NavPosition "below-header"}} checked{{end}}>
+ <label for="np-below" class="tile"><svg viewBox="0 0 80 56" width="80" height="56" aria-hidden="true"><rect x="0" y="0" width="80" height="56" fill="#f4efe4"/><rect x="0" y="0" width="80" height="14" fill="#1d1a17"/><rect x="0" y="16" width="80" height="6" fill="#c8541a"/><rect x="10" y="28" width="60" height="24" fill="#fffdf8" stroke="#1d1a17"/></svg><span>Below the header</span></label>
+ <input type="radio" name="nav_position" value="top-bar" id="np-top"{{if eq $t.NavPosition "top-bar"}} checked{{end}}>
+ <label for="np-top" class="tile"><svg viewBox="0 0 80 56" width="80" height="56" aria-hidden="true"><rect x="0" y="0" width="80" height="56" fill="#f4efe4"/><rect x="0" y="0" width="80" height="6" fill="#c8541a"/><rect x="0" y="8" width="80" height="14" fill="#1d1a17"/><rect x="10" y="28" width="60" height="24" fill="#fffdf8" stroke="#1d1a17"/></svg><span>Bar at the very top</span></label>
+ <input type="radio" name="nav_position" value="left-sidebar" id="np-side"{{if eq $t.NavPosition "left-sidebar"}} checked{{end}}>
+ <label for="np-side" class="tile"><svg viewBox="0 0 80 56" width="80" height="56" aria-hidden="true"><rect x="0" y="0" width="80" height="56" fill="#f4efe4"/><rect x="0" y="0" width="80" height="14" fill="#1d1a17"/><rect x="8" y="20" width="18" height="32" fill="#c8541a"/><rect x="30" y="20" width="42" height="32" fill="#fffdf8" stroke="#1d1a17"/></svg><span>Sidebar on the left</span></label>
+ <div class="row only-horizontal">
+ <label>Alignment<br><select name="nav_align">
+ <option value="left"{{if eq $t.NavAlign "left"}} selected{{end}}>Left</option>
+ <option value="center"{{if eq $t.NavAlign "center"}} selected{{end}}>Centred</option>
+ <option value="right"{{if eq $t.NavAlign "right"}} selected{{end}}>Right</option>
+ </select></label>
+ </div>
+ <div class="row only-sidebar">
+ <label>Sidebar width<br><select name="nav_sidebar_width">
+ <option value="narrow"{{if eq $t.NavSidebarWidth "narrow"}} selected{{end}}>Narrow</option>
+ <option value="normal"{{if eq $t.NavSidebarWidth "normal"}} selected{{end}}>Normal</option>
+ <option value="wide"{{if eq $t.NavSidebarWidth "wide"}} selected{{end}}>Wide</option>
+ </select></label>
+ </div>
+ <p class="muted small only-sidebar">Links stack under each other; announcements placed "in the sidebar" appear beneath them. On phones the sidebar turns into a row of links above the content.</p>
+ </div>
<div class="row">
- <label>Position<br><select name="nav_position">
- <option value="below-header"{{if eq $t.NavPosition "below-header"}} selected{{end}}>Below the header</option>
- <option value="top-bar"{{if eq $t.NavPosition "top-bar"}} selected{{end}}>Bar at the very top</option>
- <option value="left-sidebar"{{if eq $t.NavPosition "left-sidebar"}} selected{{end}}>Sidebar on the left</option>
+ <label>Link style<br><select name="nav_style">
+ <option value="normal"{{if eq $t.NavStyle "normal"}} selected{{end}}>Normal</option>
+ <option value="uppercase"{{if eq $t.NavStyle "uppercase"}} selected{{end}}>Uppercase, spaced</option>
</select></label>
- <label>Background<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label>
- <label>Text colour<br><input type="color" name="nav_text" value="{{$t.NavText}}"></label>
</div>
<label class="check"><input type="checkbox" name="nav_show_home"{{if $t.NavShowHome}} checked{{end}}> Include the home page in the menu</label>
+ <h3>Menu colours</h3>
+ <div class="row">
+ <label>Background<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label>
+ <label>Text<br><input type="color" name="nav_text" value="{{$t.NavText}}"></label>
+ </div>
+ <div class="hoverpick">
+ <input type="checkbox" name="nav_hover_custom" id="nav-hover-custom"{{if $t.NavHover}} checked{{end}}>
+ <label for="nav-hover-custom" class="check">Change colour when the mouse is over a link</label>
+ <label class="hover-color">Hover colour<br><input type="color" name="nav_hover" value="{{if $t.NavHover}}{{$t.NavHover}}{{else}}{{$t.NavText}}{{end}}"></label>
+ </div>
</div>
-<div class="card">
+<div class="card" id="posts">
+ <h2>Posts &amp; dates</h2>
+ <label class="check"><input type="checkbox" name="show_dates"{{if $t.ShowDates}} checked{{end}}> Show the date on posts</label>
+ <div class="row">
+ <label>Date format<br><select name="date_format">
+ <option value="long"{{if eq $t.DateFormat "long"}} selected{{end}}>12 September 2026</option>
+ <option value="short"{{if eq $t.DateFormat "short"}} selected{{end}}>12 Sep 2026</option>
+ <option value="iso"{{if eq $t.DateFormat "iso"}} selected{{end}}>2026-09-12</option>
+ </select></label>
+ </div>
+</div>
+
+<div class="card" id="footer">
<h2>Footer</h2>
<label>Footer text<br><input name="footer_text" value="{{$t.FooterText}}" maxlength="2000" placeholder="© 2026 Me"></label>
<div class="row">
<label>Background<br><input type="color" name="footer_bg" value="{{$t.FooterBg}}"></label>
<label>Text colour<br><input type="color" name="footer_color" value="{{$t.FooterColor}}"></label>
</div>
+ <label class="check"><input type="checkbox" name="footer_show_rss"{{if $t.FooterShowRSS}} checked{{end}}> Show the RSS link in the footer</label>
+</div>
+
+<div class="card" id="icon">
+ <h2>Site icon</h2>
+ {{template "imagepick" (dict "name" "favicon" "current" $t.Favicon "images" .Data.images "label" "Favicon" "hint" "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon." "accept" "image/png,image/x-icon,image/gif,image/webp,image/jpeg,.ico")}}
</div>
-<p class="actionrow"><button type="submit">Save design</button></p>
+<div class="savebar">
+ <button type="submit">Save design</button>
+ <a class="btn secondary" href="{{.BlogURL}}" target="_blank">View blog &#8599;</a>
+</div>
</form>
+
+<details class="help reset">
+ <summary>Reset to defaults</summary>
+ <p>Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images (they stay in your library).</p>
+ <form method="post" action="/b/{{.Blog.Subdomain}}/design/reset">
+ <input type="hidden" name="_csrf" value="{{.CSRF}}">
+ <button type="submit" class="danger">Reset everything</button>
+ </form>
+</details>
{{end}}
diff --git a/internal/web/templates/dashboard/images.html b/internal/web/templates/dashboard/images.html
index b44289e..1c64391 100644
--- a/internal/web/templates/dashboard/images.html
+++ b/internal/web/templates/dashboard/images.html
@@ -7,7 +7,7 @@
<div class="card">
<form method="post" action="/b/{{.Blog.Subdomain}}/images/upload" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
- <label>Upload an image <span class="muted">(PNG, JPEG, GIF or WebP)</span><br><input type="file" name="file" accept="image/*" required></label>
+ <label>Upload an image <span class="muted">(PNG, JPEG, GIF, WebP or ICO)</span><br><input type="file" name="file" accept="image/*" required></label>
<p class="actionrow"><button type="submit">Upload</button></p>
</form>
</div>
diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html
index ac6595b..3447e1f 100644
--- a/internal/web/templates/layouts/blog.html
+++ b/internal/web/templates/layouts/blog.html
@@ -5,7 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}{{.Blog.Title}}{{end}}</title>
<link rel="alternate" type="application/rss+xml" title="{{.Blog.Title}}" href="/feed.xml">
-<link rel="stylesheet" href="/static/blog.css">
+{{if .Data.theme.Favicon}}<link rel="icon" href="/media/{{.Data.theme.Favicon}}">
+{{else}}<link rel="icon" href="/static/favicon.png" sizes="48x48">
+<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
+{{end}}<link rel="stylesheet" href="/static/blog.css">
<style>{{css .Data.css}}</style>
</head>
<body class="nav-{{.Data.theme.NavPosition}}">
@@ -29,7 +32,7 @@
<div class="site-footer">
<div class="wrap footer-inner">
{{if .Data.theme.FooterText}}<p>{{.Data.theme.FooterText}}</p>{{end}}
- <p class="small"><a href="/feed.xml">RSS</a> &middot; {{.Blog.Title}}</p>
+ <p class="small">{{if .Data.theme.FooterShowRSS}}<a href="/feed.xml">RSS</a> &middot; {{end}}{{.Blog.Title}}</p>
</div>
</div>
</body>
diff --git a/internal/web/templates/layouts/dashboard.html b/internal/web/templates/layouts/dashboard.html
index 6633c29..e724cc6 100644
--- a/internal/web/templates/layouts/dashboard.html
+++ b/internal/web/templates/layouts/dashboard.html
@@ -4,6 +4,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}Blogspace{{end}}</title>
+<link rel="icon" href="/static/favicon.png" sizes="48x48">
+<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/dashboard.css">
</head>
<body>
diff --git a/internal/web/templates/partials/imagepick.html b/internal/web/templates/partials/imagepick.html
index 687c6cd..0c6eced 100644
--- a/internal/web/templates/partials/imagepick.html
+++ b/internal/web/templates/partials/imagepick.html
@@ -1,11 +1,9 @@
{{define "imagepick"}}<div class="imagepick">
- <label>{{.label}}<br>
- <select name="{{.name}}">
- <option value="">{{if .current}}(keep current){{else}}(none){{end}}</option>
- {{range .images}}<option value="{{.ID}}"{{if eq .ID.String $.current}} selected{{end}}>{{.Filename}}</option>{{end}}
- </select>
- </label>
- <label>…or upload a new one<br><input type="file" name="{{.name}}_file" accept="image/*"></label>
- {{if .current}}<label class="check"><input type="checkbox" name="{{.name}}_remove"> Remove image</label>
- <img class="preview" src="/media/{{.current}}" alt="">{{end}}
+ <label>{{.label}}</label>
+ {{if .hint}}<p class="muted small">{{.hint}}</p>{{end}}
+ <div class="picks">
+ <label class="pick none"><input type="radio" name="{{.name}}" value="none"{{if not .current}} checked{{end}}><span class="swatch">&times;</span><span class="name">None</span></label>
+ {{range .images}}<label class="pick"><input type="radio" name="{{$.name}}" value="{{.ID}}"{{if eq .ID.String $.current}} checked{{end}}><img src="/media/{{.ID}}" alt=""><span class="name">{{.Filename}}</span></label>{{end}}
+ </div>
+ <label>…or upload a new one<br><input type="file" name="{{.name}}_file" accept="{{if .accept}}{{.accept}}{{else}}image/*{{end}}"></label>
</div>{{end}}
diff --git a/internal/web/theme.go b/internal/web/theme.go
index 40efc54..5ee2398 100644
--- a/internal/web/theme.go
+++ b/internal/web/theme.go
@@ -7,6 +7,7 @@ import (
"regexp"
"strings"
"text/template"
+ "time"
"github.com/google/uuid"
)
@@ -17,37 +18,55 @@ type Theme struct {
BgImage string `json:"bg_image"` // image uuid or ""
BgMode string `json:"bg_mode"` // cover | tile | fixed
- ContentBg string `json:"content_bg"`
- TextColor string `json:"text_color"`
- LinkColor string `json:"link_color"`
- ContentWidth string `json:"content_width"` // narrow | medium | wide
- Font string `json:"font"` // sans | serif | mono
- FontSize string `json:"font_size"` // small | normal | large
+ ContentBg string `json:"content_bg"`
+ TextColor string `json:"text_color"`
+ LinkColor string `json:"link_color"`
+ LinkHover string `json:"link_hover"` // "" = same as LinkColor
+ LinkUnderline string `json:"link_underline"` // always | hover | never
+ ContentWidth string `json:"content_width"` // narrow | medium | wide
+ ContentStyle string `json:"content_style"` // card | flat
+ ContentPadding string `json:"content_padding"` // normal | compact | roomy
+ Font string `json:"font"` // sans | serif | mono
+ HeadingFont string `json:"heading_font"` // same | sans | serif | mono
+ FontSize string `json:"font_size"` // small | normal | large
- HeaderShowTitle bool `json:"header_show_title"`
- HeaderImage string `json:"header_image"`
- HeaderAlign string `json:"header_align"` // left | center
- HeaderBg string `json:"header_bg"`
- HeaderText string `json:"header_text"`
+ HeaderShowTitle bool `json:"header_show_title"`
+ HeaderImage string `json:"header_image"`
+ HeaderImageHeight string `json:"header_image_height"` // short | medium | tall
+ HeaderAlign string `json:"header_align"` // left | center
+ HeaderBg string `json:"header_bg"`
+ HeaderText string `json:"header_text"`
- NavPosition string `json:"nav_position"` // below-header | top-bar | left-sidebar
- NavBg string `json:"nav_bg"`
- NavText string `json:"nav_text"`
- NavShowHome bool `json:"nav_show_home"`
+ NavPosition string `json:"nav_position"` // below-header | top-bar | left-sidebar
+ NavAlign string `json:"nav_align"` // left | center | right (horizontal menus)
+ NavSidebarWidth string `json:"nav_sidebar_width"` // narrow | normal | wide (sidebar only)
+ NavStyle string `json:"nav_style"` // normal | uppercase
+ NavBg string `json:"nav_bg"`
+ NavText string `json:"nav_text"`
+ NavHover string `json:"nav_hover"` // "" = same as NavText
+ NavShowHome bool `json:"nav_show_home"`
- FooterText string `json:"footer_text"`
- FooterBg string `json:"footer_bg"`
- FooterColor string `json:"footer_color"`
+ ShowDates bool `json:"show_dates"`
+ DateFormat string `json:"date_format"` // long | short | iso
+
+ FooterText string `json:"footer_text"`
+ FooterBg string `json:"footer_bg"`
+ FooterColor string `json:"footer_color"`
+ FooterShowRSS bool `json:"footer_show_rss"`
+
+ Favicon string `json:"favicon"` // image uuid or "" for the Blogspace default
}
func DefaultTheme() Theme {
return Theme{
BgColor: "#e9e6df", BgMode: "cover",
- ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4",
- ContentWidth: "medium", Font: "sans", FontSize: "normal",
- HeaderShowTitle: true, HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff",
- NavPosition: "below-header", NavBg: "#ffffff", NavText: "#222222", NavShowHome: true,
- FooterText: "", FooterBg: "#2f3a4a", FooterColor: "#d0d5dc",
+ ContentBg: "#ffffff", TextColor: "#222222", LinkColor: "#1a5fb4", LinkUnderline: "always",
+ ContentWidth: "medium", ContentStyle: "card", ContentPadding: "normal",
+ Font: "sans", HeadingFont: "same", FontSize: "normal",
+ HeaderShowTitle: true, HeaderImageHeight: "medium", HeaderAlign: "left", HeaderBg: "#2f3a4a", HeaderText: "#ffffff",
+ NavPosition: "below-header", NavAlign: "left", NavSidebarWidth: "normal", NavStyle: "normal", NavBg: "#ffffff", NavText: "#222222", NavShowHome: true,
+ ShowDates: true, DateFormat: "long",
+ FooterText: "", FooterBg: "#2f3a4a", FooterColor: "#d0d5dc", FooterShowRSS: true,
}
}
@@ -80,6 +99,9 @@ func color(v, def string) string {
return def
}
+// optColor is for colours that may be unset ("" = inherit).
+func optColor(v string) string { return color(v, "") }
+
func imageID(v string) string {
if _, err := uuid.Parse(v); err != nil {
return ""
@@ -96,68 +118,165 @@ func (t *Theme) normalize() {
t.ContentBg = color(t.ContentBg, d.ContentBg)
t.TextColor = color(t.TextColor, d.TextColor)
t.LinkColor = color(t.LinkColor, d.LinkColor)
+ t.LinkHover = optColor(t.LinkHover)
+ t.LinkUnderline = pick(t.LinkUnderline, "always", "hover", "never")
t.ContentWidth = pick(t.ContentWidth, "medium", "narrow", "wide")
+ t.ContentStyle = pick(t.ContentStyle, "card", "flat")
+ t.ContentPadding = pick(t.ContentPadding, "normal", "compact", "roomy")
t.Font = pick(t.Font, "sans", "serif", "mono")
+ t.HeadingFont = pick(t.HeadingFont, "same", "sans", "serif", "mono")
t.FontSize = pick(t.FontSize, "normal", "small", "large")
t.HeaderImage = imageID(t.HeaderImage)
+ t.HeaderImageHeight = pick(t.HeaderImageHeight, "medium", "short", "tall")
t.HeaderAlign = pick(t.HeaderAlign, "left", "center")
t.HeaderBg = color(t.HeaderBg, d.HeaderBg)
t.HeaderText = color(t.HeaderText, d.HeaderText)
t.NavPosition = pick(t.NavPosition, "below-header", "top-bar", "left-sidebar")
+ t.NavAlign = pick(t.NavAlign, "left", "center", "right")
+ t.NavSidebarWidth = pick(t.NavSidebarWidth, "normal", "narrow", "wide")
+ t.NavStyle = pick(t.NavStyle, "normal", "uppercase")
t.NavBg = color(t.NavBg, d.NavBg)
t.NavText = color(t.NavText, d.NavText)
+ t.NavHover = optColor(t.NavHover)
+ t.DateFormat = pick(t.DateFormat, "long", "short", "iso")
t.FooterBg = color(t.FooterBg, d.FooterBg)
t.FooterColor = color(t.FooterColor, d.FooterColor)
if len(t.FooterText) > 2000 {
t.FooterText = t.FooterText[:2000]
}
+ t.Favicon = imageID(t.Favicon)
}
// ThemeFromForm reads the design form on top of the current theme.
func ThemeFromForm(cur Theme, f url.Values) Theme {
t := cur
get := func(k string) string { return strings.TrimSpace(f.Get(k)) }
+ on := func(k string) bool { return f.Get(k) == "on" }
+ // Hover colours only apply when their checkbox is ticked; otherwise "inherit".
+ hover := func(k string) string {
+ if on(k + "_custom") {
+ return get(k)
+ }
+ return ""
+ }
t.BgColor = get("bg_color")
t.BgMode = get("bg_mode")
t.ContentBg = get("content_bg")
t.TextColor = get("text_color")
t.LinkColor = get("link_color")
+ t.LinkHover = hover("link_hover")
+ t.LinkUnderline = get("link_underline")
t.ContentWidth = get("content_width")
+ t.ContentStyle = get("content_style")
+ t.ContentPadding = get("content_padding")
t.Font = get("font")
+ t.HeadingFont = get("heading_font")
t.FontSize = get("font_size")
- t.HeaderShowTitle = f.Get("header_show_title") == "on"
+ t.HeaderShowTitle = on("header_show_title")
+ t.HeaderImageHeight = get("header_image_height")
t.HeaderAlign = get("header_align")
t.HeaderBg = get("header_bg")
t.HeaderText = get("header_text")
t.NavPosition = get("nav_position")
+ t.NavAlign = get("nav_align")
+ t.NavSidebarWidth = get("nav_sidebar_width")
+ t.NavStyle = get("nav_style")
t.NavBg = get("nav_bg")
t.NavText = get("nav_text")
- t.NavShowHome = f.Get("nav_show_home") == "on"
+ t.NavHover = hover("nav_hover")
+ t.NavShowHome = on("nav_show_home")
+ t.ShowDates = on("show_dates")
+ t.DateFormat = get("date_format")
t.FooterText = get("footer_text")
t.FooterBg = get("footer_bg")
t.FooterColor = get("footer_color")
- if f.Get("bg_image_remove") == "on" {
- t.BgImage = ""
- } else if v := get("bg_image"); v != "" {
- t.BgImage = v
- }
- if f.Get("header_image_remove") == "on" {
- t.HeaderImage = ""
- } else if v := get("header_image"); v != "" {
- t.HeaderImage = v
- }
+ t.FooterShowRSS = on("footer_show_rss")
+ t.BgImage = pickImage(t.BgImage, get("bg_image"))
+ t.HeaderImage = pickImage(t.HeaderImage, get("header_image"))
+ t.Favicon = pickImage(t.Favicon, get("favicon"))
t.normalize()
return t
}
+// pickImage applies an imagepick radio: "none" clears, an id selects, and an
+// empty value (no radio in the form) keeps what was there.
+func pickImage(cur, v string) string {
+ switch v {
+ case "":
+ return cur
+ case "none":
+ return ""
+ }
+ return v
+}
+
func (t Theme) JSON() json.RawMessage {
b, _ := json.Marshal(t)
return b
}
-// Helpers used by the CSS template.
-func (t Theme) FontFamily() string {
- switch t.Font {
+// FormatDate renders post dates in the blogger's chosen style.
+func (t Theme) FormatDate(at time.Time) string {
+ switch t.DateFormat {
+ case "short":
+ return at.Format("2 Jan 2006")
+ case "iso":
+ return at.Format("2006-01-02")
+ }
+ return at.Format("2 January 2006")
+}
+
+// ---- presets ----------------------------------------------------------------
+
+// Preset is a named colour scheme; applying one touches only colours.
+type Preset struct {
+ Key, Name string
+ Bg, ContentBg, Text, Link string
+ HeaderBg, HeaderText, NavBg, NavText string
+ FooterBg, FooterText string
+}
+
+var presets = []Preset{
+ {Key: "paper", Name: "Paper", Bg: "#e9e6df", ContentBg: "#ffffff", Text: "#222222", Link: "#1a5fb4",
+ HeaderBg: "#2f3a4a", HeaderText: "#ffffff", NavBg: "#ffffff", NavText: "#222222", FooterBg: "#2f3a4a", FooterText: "#d0d5dc"},
+ {Key: "night", Name: "Night", Bg: "#15171c", ContentBg: "#1f2229", Text: "#e6e6e6", Link: "#8ab4f8",
+ HeaderBg: "#0d0f13", HeaderText: "#f2f2f2", NavBg: "#1a1d23", NavText: "#dcdcdc", FooterBg: "#0d0f13", FooterText: "#9a9a9a"},
+ {Key: "ocean", Name: "Ocean", Bg: "#dfeef4", ContentBg: "#ffffff", Text: "#1b2b34", Link: "#0e7490",
+ HeaderBg: "#0f4c5c", HeaderText: "#e6f7fb", NavBg: "#e6f4f8", NavText: "#0f4c5c", FooterBg: "#0f4c5c", FooterText: "#bfe3ec"},
+ {Key: "forest", Name: "Forest", Bg: "#e4e9df", ContentBg: "#fbfcf8", Text: "#1f2a1f", Link: "#2f6b3a",
+ HeaderBg: "#243d2a", HeaderText: "#eef5ea", NavBg: "#eef3e8", NavText: "#243d2a", FooterBg: "#243d2a", FooterText: "#bccfb8"},
+ {Key: "slate", Name: "Slate", Bg: "#f0f0f0", ContentBg: "#ffffff", Text: "#2a2a2a", Link: "#4a4a4a",
+ HeaderBg: "#ffffff", HeaderText: "#111111", NavBg: "#f7f7f7", NavText: "#333333", FooterBg: "#e5e5e5", FooterText: "#555555"},
+ {Key: "rose", Name: "Rose", Bg: "#f7e9e6", ContentBg: "#fffaf8", Text: "#3a2426", Link: "#b03a48",
+ HeaderBg: "#7a2e3a", HeaderText: "#fdf1f2", NavBg: "#fbe4e4", NavText: "#7a2e3a", FooterBg: "#7a2e3a", FooterText: "#e9c3c8"},
+}
+
+func Presets() []Preset { return presets }
+
+func PresetByKey(key string) (Preset, bool) {
+ for _, p := range presets {
+ if p.Key == key {
+ return p, true
+ }
+ }
+ return Preset{}, false
+}
+
+// WithPreset swaps in a scheme's colours and drops custom hover colours; images
+// and layout choices are untouched.
+func (t Theme) WithPreset(p Preset) Theme {
+ t.BgColor, t.ContentBg, t.TextColor, t.LinkColor = p.Bg, p.ContentBg, p.Text, p.Link
+ t.HeaderBg, t.HeaderText, t.NavBg, t.NavText = p.HeaderBg, p.HeaderText, p.NavBg, p.NavText
+ t.FooterBg, t.FooterColor = p.FooterBg, p.FooterText
+ t.LinkHover, t.NavHover = "", ""
+ t.normalize()
+ return t
+}
+
+// ---- css --------------------------------------------------------------------
+
+func fontStack(name string) string {
+ switch name {
case "serif":
return `Georgia, "Times New Roman", Times, serif`
case "mono":
@@ -166,6 +285,11 @@ func (t Theme) FontFamily() string {
return `"Helvetica Neue", Helvetica, Arial, sans-serif`
}
+// Helpers used by the CSS template.
+func (t Theme) FontFamily() string { return fontStack(t.Font) }
+
+func (t Theme) HeadingFontFamily() string { return fontStack(t.HeadingFont) }
+
func (t Theme) FontSizePx() string {
switch t.FontSize {
case "small":
@@ -186,6 +310,55 @@ func (t Theme) MaxWidth() string {
return "840px"
}
+// ContentPaddingCSS returns desktop and phone paddings; "normal" is left to blog.css.
+func (t Theme) ContentPaddingCSS() (desktop, phone string) {
+ switch t.ContentPadding {
+ case "compact":
+ return "1em 1.2em", "0.7em"
+ case "roomy":
+ return "2.2em 3em", "1.4em"
+ }
+ return "", ""
+}
+
+func (t Theme) PaddingDesktop() string { d, _ := t.ContentPaddingCSS(); return d }
+func (t Theme) PaddingPhone() string { _, p := t.ContentPaddingCSS(); return p }
+
+// SidebarWidthPx is the sidebar width when it differs from blog.css's 180px, else "".
+func (t Theme) SidebarWidthPx() string {
+ if t.NavPosition != "left-sidebar" {
+ return ""
+ }
+ switch t.NavSidebarWidth {
+ case "narrow":
+ return "140px"
+ case "wide":
+ return "240px"
+ }
+ return ""
+}
+
+// SidebarContentMargin leaves the same 20px gutter blog.css uses.
+func (t Theme) SidebarContentMargin() string {
+ switch t.NavSidebarWidth {
+ case "narrow":
+ return "160px"
+ case "wide":
+ return "260px"
+ }
+ return ""
+}
+
+func (t Theme) HeaderImageMaxHeight() string {
+ switch t.HeaderImageHeight {
+ case "short":
+ return "180px"
+ case "tall":
+ return "520px"
+ }
+ return "320px"
+}
+
var themeCSS = template.Must(template.New("theme").Parse(`
body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-family:{{.FontFamily}}; font-size:{{.FontSizePx}}; line-height:1.55;
{{- if .BgImage}} background-image:url(/media/{{.BgImage}});
@@ -193,13 +366,32 @@ body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-famil
{{- else if eq .BgMode "fixed"}} background-size:cover; background-position:center; background-attachment:fixed; background-repeat:no-repeat;
{{- else}} background-repeat:repeat;{{end}}
{{- end}} }
-a { color:{{.LinkColor}}; }
+{{- if ne .HeadingFont "same"}}
+h1, h2, h3, h4, h5, h6 { font-family:{{.HeadingFontFamily}}; }
+{{- end}}
+a { color:{{.LinkColor}};{{if eq .LinkUnderline "always"}} text-decoration:underline;{{else}} text-decoration:none;{{end}} }
+{{- if or .LinkHover (eq .LinkUnderline "hover")}}
+a:hover {{"{"}}{{if .LinkHover}} color:{{.LinkHover}};{{end}}{{if eq .LinkUnderline "hover"}} text-decoration:underline;{{end}} }
+{{- end}}
.wrap { max-width:{{.MaxWidth}}; margin:0 auto; }
.site-header { background:{{.HeaderBg}}; color:{{.HeaderText}}; text-align:{{.HeaderAlign}}; }
.site-header a { color:{{.HeaderText}}; }
+.header-image img { max-height:{{.HeaderImageMaxHeight}}; }
.site-nav { background:{{.NavBg}}; }
-.site-nav a { color:{{.NavText}}; }
-.content { background:{{.ContentBg}}; }
+.nav-inner { text-align:{{.NavAlign}}; }
+.site-nav a { color:{{.NavText}};{{if eq .NavStyle "uppercase"}} text-transform:uppercase; letter-spacing:0.05em; font-size:0.9em;{{end}} }
+{{- if .NavHover}}
+.site-nav a:hover { color:{{.NavHover}}; }
+{{- end}}
+{{- with .SidebarWidthPx}}
+.nav-left-sidebar .body-wrap .site-nav, .nav-left-sidebar .body-wrap .sidebar-notices { width:{{.}}; }
+.nav-left-sidebar .body-wrap .content { margin-left:{{$.SidebarContentMargin}}; }
+@media (max-width: 700px) { .nav-left-sidebar .body-wrap .site-nav, .nav-left-sidebar .body-wrap .sidebar-notices { width:auto; } .nav-left-sidebar .body-wrap .content { margin-left:0; } }
+{{- end}}
+.content { background:{{.ContentBg}};{{with .PaddingDesktop}} padding:{{.}};{{end}}{{if eq .ContentStyle "flat"}} border-radius:0; box-shadow:none;{{end}} }
+{{- with .PaddingPhone}}
+@media (max-width: 700px) { .content { padding:{{.}}; } }
+{{- end}}
.site-footer { background:{{.FooterBg}}; color:{{.FooterColor}}; }
.site-footer a { color:{{.FooterColor}}; }
`))
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index 002f18a..9afa11a 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -6,6 +6,7 @@ import (
"net/url"
"strings"
"testing"
+ "time"
"github.com/google/uuid"
"github.com/gramanas/blogspace/internal/config"
@@ -59,7 +60,7 @@ func TestRootRoutePrecedence(t *testing.T) {
t.Errorf("%s: got %d, want %d", path, rec.Code, want)
}
}
- for _, slug := range []string{"webadmin", "admin", "b", "media", "feed.xml"} {
+ for _, slug := range []string{"webadmin", "admin", "b", "media", "feed.xml", "favicon.ico"} {
if !reservedPageSlugs[slug] {
t.Errorf("page slug %q should be reserved", slug)
}
@@ -67,19 +68,108 @@ func TestRootRoutePrecedence(t *testing.T) {
}
func TestThemeNormalizeAndCSS(t *testing.T) {
- th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","nav_position":"left-sidebar"}`))
- if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.NavPosition != "left-sidebar" {
+ th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","nav_position":"left-sidebar","link_hover":"blue","heading_font":"wingdings","favicon":"x"}`))
+ if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.NavPosition != "left-sidebar" || th.LinkHover != "" || th.HeadingFont != "same" || th.Favicon != "" {
t.Errorf("normalize: %+v", th)
}
- f := url.Values{"bg_color": {"#ABCDEF"}, "content_width": {"wide"}, "header_show_title": {"on"}, "bg_image": {"not-a-uuid"}}
+ // Themes saved before the new options existed keep today's look.
+ if !th.ShowDates || !th.FooterShowRSS || th.LinkUnderline != "always" || th.ContentStyle != "card" || th.DateFormat != "long" {
+ t.Errorf("old theme defaults: %+v", th)
+ }
+ f := url.Values{"bg_color": {"#ABCDEF"}, "content_width": {"wide"}, "header_show_title": {"on"}, "bg_image": {"not-a-uuid"},
+ "link_hover": {"#ff0000"}, "nav_hover": {"#00ff00"}, "nav_hover_custom": {"on"}, "nav_style": {"uppercase"}, "nav_align": {"center"},
+ "link_underline": {"hover"}, "heading_font": {"serif"}, "content_style": {"flat"}, "content_padding": {"roomy"}, "date_format": {"iso"}}
th = ThemeFromForm(DefaultTheme(), f)
if th.BgColor != "#abcdef" || th.ContentWidth != "wide" || !th.HeaderShowTitle || th.BgImage != "" {
t.Errorf("from form: %+v", th)
}
+ if th.LinkHover != "" || th.NavHover != "#00ff00" || th.ShowDates || th.DateFormat != "iso" {
+ t.Errorf("hover checkbox / unticked checkboxes: %+v", th)
+ }
css := th.CSS()
- if !strings.Contains(css, "background-color:#abcdef") || !strings.Contains(css, "max-width:1100px") || strings.Contains(css, "display:none") {
+ for _, want := range []string{"background-color:#abcdef", "max-width:1100px", "text-transform:uppercase", ".site-nav a:hover { color:#00ff00", "a:hover { text-decoration:underline",
+ "h1, h2, h3, h4, h5, h6 { font-family:Georgia", "text-align:center", "border-radius:0", "padding:2.2em 3em", "@media (max-width: 700px)"} {
+ if !strings.Contains(css, want) {
+ t.Errorf("css missing %q:\n%s", want, css)
+ }
+ }
+ if strings.Contains(css, "display:none") {
t.Errorf("css: %s", css)
}
+ // Default padding is left to blog.css so its phone override keeps working.
+ if css := DefaultTheme().CSS(); strings.Contains(css, "padding:") || strings.Contains(css, "a:hover") {
+ t.Errorf("default css should not set padding or hover: %s", css)
+ }
+}
+
+func TestSidebarWidthCSS(t *testing.T) {
+ th := ThemeFromForm(DefaultTheme(), url.Values{"nav_position": {"left-sidebar"}, "nav_sidebar_width": {"wide"}})
+ css := th.CSS()
+ if !strings.Contains(css, "width:240px") || !strings.Contains(css, "margin-left:260px") || !strings.Contains(css, "margin-left:0") {
+ t.Errorf("sidebar css: %s", css)
+ }
+ // The width only matters for the sidebar layout, and "normal" is blog.css's own value.
+ th.NavPosition = "top-bar"
+ if strings.Contains(th.CSS(), "width:240px") {
+ t.Error("sidebar width leaked into a horizontal menu")
+ }
+ th = ThemeFromForm(DefaultTheme(), url.Values{"nav_position": {"left-sidebar"}})
+ if strings.Contains(th.CSS(), ".nav-left-sidebar") {
+ t.Error("normal width should emit nothing")
+ }
+}
+
+func TestThemeImagePick(t *testing.T) {
+ id := "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
+ cur := DefaultTheme()
+ cur.BgImage, cur.Favicon = id, id
+ th := ThemeFromForm(cur, url.Values{})
+ if th.BgImage != id || th.Favicon != id {
+ t.Errorf("missing radio should keep the image: %+v", th)
+ }
+ th = ThemeFromForm(cur, url.Values{"bg_image": {"none"}, "favicon": {"junk"}})
+ if th.BgImage != "" || th.Favicon != "" {
+ t.Errorf("none clears, junk is dropped: %+v", th)
+ }
+ th = ThemeFromForm(DefaultTheme(), url.Values{"header_image": {id}})
+ if th.HeaderImage != id {
+ t.Errorf("radio selects: %+v", th)
+ }
+}
+
+func TestPresets(t *testing.T) {
+ if len(Presets()) < 4 {
+ t.Fatal("expected several presets")
+ }
+ cur := DefaultTheme()
+ cur.BgImage, cur.NavPosition, cur.Font, cur.LinkHover = "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "top-bar", "mono", "#123456"
+ for _, p := range Presets() {
+ for _, c := range []string{p.Bg, p.ContentBg, p.Text, p.Link, p.HeaderBg, p.HeaderText, p.NavBg, p.NavText, p.FooterBg, p.FooterText} {
+ if !hexColor.MatchString(c) {
+ t.Errorf("preset %s: bad colour %q", p.Key, c)
+ }
+ }
+ th := cur.WithPreset(p)
+ if th.BgColor != p.Bg || th.FooterColor != p.FooterText || th.LinkHover != "" {
+ t.Errorf("preset %s not applied: %+v", p.Key, th)
+ }
+ if th.BgImage != cur.BgImage || th.NavPosition != "top-bar" || th.Font != "mono" {
+ t.Errorf("preset %s touched non-colour fields: %+v", p.Key, th)
+ }
+ }
+ if _, ok := PresetByKey("nope"); ok {
+ t.Error("unknown preset should not resolve")
+ }
+}
+
+func TestFormatDate(t *testing.T) {
+ at := time.Date(2026, 9, 3, 10, 0, 0, 0, time.UTC)
+ for format, want := range map[string]string{"long": "3 September 2026", "short": "3 Sep 2026", "iso": "2026-09-03"} {
+ th := Theme{DateFormat: format}
+ if got := th.FormatDate(at); got != want {
+ t.Errorf("%s: got %q, want %q", format, got, want)
+ }
+ }
}
func TestAllTemplatesParse(t *testing.T) {