aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/handlers_design.go
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 /internal/web/handlers_design.go
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
Diffstat (limited to 'internal/web/handlers_design.go')
-rw-r--r--internal/web/handlers_design.go42
1 files changed, 37 insertions, 5 deletions
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)