aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/handlers_design.go
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-14 01:32:25 +0300
committergrm <grm@eyesin.space>2026-09-14 01:32:25 +0300
commite14a4250efb828262347f5a4bbc4067cd71d885f (patch)
tree3fcf6512f3b030b06c818ff4a224677a026d3b1b /internal/web/handlers_design.go
parent282d2ab3e0fb3a74f32160a231225773001acd14 (diff)
downloadblogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.tar.gz
blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.tar.bz2
blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.zip
Add languages: English and Greek, chosen per blog on the Settings tab
Every blog and its dashboard were hard-wired to English. A blogger can now pick the language of their blog; it switches the whole dashboard and the blog's fixed text — post dates, archive months, the RSS link, the pager, the 404 page — while what the blogger wrote is left alone. The new internal/i18n package keys translations by the English string, so an untranslated key renders as English rather than blank, and TestGreekCatalogComplete scans the templates and handlers to fail when the Greek catalog misses a key or keeps a stale one. Templates are compiled once per language with t/tf/date/postdate/month closed over the language, so they need no data plumbing. The language lives in settings.language (blog migration 00002), not in the theme, so "Reset design" does not touch it. Public pages use the blog's language; management pages use the logged-in user's own blog's, so a superadmin editing someone else's blog keeps theirs; the login page follows Accept-Language. 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.go37
1 files changed, 23 insertions, 14 deletions
diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go
index 9f98e9e..7424eca 100644
--- a/internal/web/handlers_design.go
+++ b/internal/web/handlers_design.go
@@ -10,6 +10,7 @@ import (
"path/filepath"
"strconv"
"strings"
+ "time"
"github.com/google/uuid"
"github.com/gramanas/blogspace/internal/store"
@@ -25,13 +26,18 @@ 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, "presets": Presets()})
+ s.render(w, r, "dashboard/design.html", designData(ParseTheme(blog.ThemeJSON), images))
+}
+
+// designData is what design.html shows; "today" is the sample for the date formats.
+func designData(theme Theme, images []store.Image) map[string]any {
+ return map[string]any{"theme": theme, "images": images, "presets": Presets(), "today": time.Now()}
}
func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
blog := currentBlog(r)
if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
- s.plainError(w, http.StatusBadRequest, "Upload too large or malformed form.")
+ s.plainError(w, http.StatusBadRequest, s.tr(r, "Upload too large or malformed form."))
return
}
theme := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form)
@@ -40,7 +46,9 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
img, err := s.readUpload(r, field)
if err != nil {
images, _ := blogStore(r).ListImages(r.Context())
- s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", map[string]any{"theme": theme, "images": images, "presets": Presets(), "error": err.Error()})
+ d := designData(theme, images)
+ d["error"] = err.Error()
+ s.renderStatus(w, r, http.StatusBadRequest, "dashboard/design.html", d)
return
}
if img != nil {
@@ -51,7 +59,7 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design saved. Refresh your blog to see it.")
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.tr(r, "Design saved. Refresh your blog to see it."))
}
// handleDesignPreset applies a colour scheme straight away; it is a separate
@@ -60,7 +68,7 @@ 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.")
+ s.plainError(w, http.StatusBadRequest, s.tr(r, "Unknown colour scheme."))
return
}
theme := ParseTheme(blog.ThemeJSON).WithPreset(p)
@@ -68,7 +76,7 @@ func (s *Server) handleDesignPreset(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Applied the "+p.Name+" scheme. Refresh your blog to see it.")
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.trf(r, "Applied the %s scheme. Refresh your blog to see it.", s.tr(r, p.Name)))
}
// handleDesignReset restores the defaults, layout modules included; images
@@ -83,7 +91,7 @@ func (s *Server) handleDesignReset(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/design", "Design and layout reset to the defaults.")
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/design", s.tr(r, "Design and layout reset to the defaults."))
}
// readUpload stores the file from a multipart field, returning nil if the field is empty.
@@ -99,8 +107,9 @@ func (s *Server) readUpload(r *http.Request, field string) (*store.Image, error)
}
func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader) (*store.Image, error) {
+ tooBig := errors.New(s.trf(r, "Image is too large (max %s).", kbString(s.cfg.MaxUploadBytes)))
if fh.Size > s.cfg.MaxUploadBytes {
- return nil, errors.New("image is too large (max " + kbString(s.cfg.MaxUploadBytes) + ")")
+ return nil, tooBig
}
f, err := fh.Open()
if err != nil {
@@ -112,11 +121,11 @@ func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader) (*store.
return nil, err
}
if int64(buf.Len()) > s.cfg.MaxUploadBytes {
- return nil, errors.New("image is too large (max " + kbString(s.cfg.MaxUploadBytes) + ")")
+ return nil, tooBig
}
ct := http.DetectContentType(buf.Bytes())
if !allowedImageTypes[ct] {
- return nil, errors.New("only PNG, JPEG, GIF, WebP and ICO images are accepted")
+ return nil, errors.New(s.tr(r, "Only PNG, JPEG, GIF, WebP and ICO images are accepted."))
}
name := filepath.Base(fh.Filename)
if name == "" || name == "." || len(name) > 120 {
@@ -174,7 +183,7 @@ func (s *Server) handleImageUpload(w http.ResponseWriter, r *http.Request) {
s.plainError(w, http.StatusBadRequest, msg)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
- fail("Upload too large or malformed form.")
+ fail(s.tr(r, "Upload too large or malformed form."))
return
}
img, err := s.readUpload(r, "file")
@@ -183,14 +192,14 @@ func (s *Server) handleImageUpload(w http.ResponseWriter, r *http.Request) {
return
}
if img == nil {
- fail("Choose a file first.")
+ fail(s.tr(r, "Choose a file first."))
return
}
if wantJSON {
writeJSON(w, http.StatusOK, map[string]string{"id": img.ID.String(), "filename": img.Filename, "markdown": imageMarkdown(img)})
return
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/images", "Uploaded "+img.Filename+".")
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/images", s.trf(r, "Uploaded %s.", img.Filename))
}
func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -232,5 +241,5 @@ func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) {
return
}
}
- redirectOK(w, r, "/b/"+blog.Subdomain+"/images", "Image deleted.")
+ redirectOK(w, r, "/b/"+blog.Subdomain+"/images", s.tr(r, "Image deleted."))
}