diff options
| author | grm <grm@eyesin.space> | 2026-09-14 01:32:25 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-14 01:32:25 +0300 |
| commit | e14a4250efb828262347f5a4bbc4067cd71d885f (patch) | |
| tree | 3fcf6512f3b030b06c818ff4a224677a026d3b1b /internal/web/handlers_layout.go | |
| parent | 282d2ab3e0fb3a74f32160a231225773001acd14 (diff) | |
| download | blogspace-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_layout.go')
| -rw-r--r-- | internal/web/handlers_layout.go | 54 |
1 files changed, 28 insertions, 26 deletions
diff --git a/internal/web/handlers_layout.go b/internal/web/handlers_layout.go index 1046a19..94bf32f 100644 --- a/internal/web/handlers_layout.go +++ b/internal/web/handlers_layout.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/gramanas/blogspace/internal/i18n" "github.com/gramanas/blogspace/internal/store" ) @@ -67,7 +68,7 @@ func (s *Server) handleLayoutWidths(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - redirectOK(w, r, s.layoutURL(r)+"#columns", "Columns saved. Refresh your blog to see them.") + redirectOK(w, r, s.layoutURL(r)+"#columns", s.tr(r, "Columns saved. Refresh your blog to see them.")) } // handleAreaToggle shows or hides a whole area, keeping its modules. @@ -85,9 +86,9 @@ func (s *Server) handleAreaToggle(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - msg := areaNames[area] + " hidden." + msg := s.trf(r, "%s hidden.", s.tr(r, areaNames[area])) if on { - msg = areaNames[area] + " shown again. Refresh your blog to see it." + msg = s.trf(r, "%s shown again. Refresh your blog to see it.", s.tr(r, areaNames[area])) } redirectOK(w, r, s.layoutURL(r)+"#"+area, msg) } @@ -95,7 +96,7 @@ func (s *Server) handleAreaToggle(w http.ResponseWriter, r *http.Request) { func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) { area, kind := r.PathValue("area"), r.FormValue("kind") if !allowedKind(area, kind) { - s.plainError(w, http.StatusBadRequest, "That module cannot go in that area.") + s.plainError(w, http.StatusBadRequest, s.tr(r, "That module cannot go in that area.")) return } if singleModuleAreas[area] { @@ -106,19 +107,20 @@ func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) { } for _, m := range mods { if m.Area == area { - redirectOK(w, r, s.layoutURL(r)+"#main", "There is already a module there; edit or remove it first.") + redirectOK(w, r, s.layoutURL(r)+"#main", s.tr(r, "There is already a module there; edit or remove it first.")) return } } } m := &store.Module{Area: area, Kind: kind, Count: 5} - switch kind { // sensible headings so the module reads well before it is edited + blogLang := currentBlog(r).Language // headings are content: the blog's language, not the viewer's + switch kind { // sensible headings so the module reads well before it is edited case "archive": - m.Title = "Archive" + m.Title = i18n.T(blogLang, "Archive") case "recent": - m.Title = "Recent posts" + m.Title = i18n.T(blogLang, "Recent posts") case "sitemap": - m.Title = "Pages" + m.Title = i18n.T(blogLang, "Pages") } m, err := blogStore(r).CreateModule(r.Context(), m) if err != nil { @@ -126,10 +128,10 @@ func (s *Server) handleModuleAdd(w http.ResponseWriter, r *http.Request) { return } if kind == "html" || kind == "text" { - redirectOK(w, r, s.layoutURL(r)+"/modules/"+strconv.FormatInt(m.ID, 10)+"/edit", "Added. Now write what it should show.") + redirectOK(w, r, s.layoutURL(r)+"/modules/"+strconv.FormatInt(m.ID, 10)+"/edit", s.tr(r, "Added. Now write what it should show.")) return } - redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(area), moduleNames[kind]+" added. Refresh your blog to see it.") + redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(area), s.trf(r, "%s added. Refresh your blog to see it.", s.tr(r, moduleNames[kind]))) } // anchorFor is the layout.html card an area is edited in. @@ -163,11 +165,11 @@ func (s *Server) handleModuleForm(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, s.layoutURL(r), http.StatusSeeOther) return } - s.render(w, r, "dashboard/module_form.html", moduleFormData(m, "")) + s.render(w, r, "dashboard/module_form.html", s.moduleFormData(r, m, "")) } -func moduleFormData(m *store.Module, errMsg string) map[string]any { - d := map[string]any{"module": m, "name": moduleNames[m.Kind], "area": areaNames[m.Area], "counts": []int{3, 5, 8, 10, 15, 20}} +func (s *Server) moduleFormData(r *http.Request, m *store.Module, errMsg string) map[string]any { + d := map[string]any{"module": m, "name": s.tr(r, moduleNames[m.Kind]), "area": s.tr(r, areaNames[m.Area]), "counts": []int{3, 5, 8, 10, 15, 20}} if errMsg != "" { d["error"] = errMsg } @@ -185,21 +187,21 @@ func (s *Server) handleModuleSave(w http.ResponseWriter, r *http.Request) { m.Count = clampInt(n, 1, maxRecentCount) } fail := func(msg string) { - s.renderStatus(w, r, http.StatusBadRequest, "dashboard/module_form.html", moduleFormData(m, msg)) + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/module_form.html", s.moduleFormData(r, m, msg)) } switch { case len(m.Title) > 120: - fail("Heading is too long (max 120 characters).") + fail(s.tr(r, "Heading is too long (max 120 characters).")) return case len(m.Body) > maxModuleBytes: - fail("Content is too long (20 KB max).") + fail(s.tr(r, "Content is too long (20 KB max).")) return } if err := blogStore(r).UpdateModule(r.Context(), m); err != nil { s.serverError(w, err) return } - redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), "Saved. Refresh your blog to see it.") + redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), s.tr(r, "Saved. Refresh your blog to see it.")) } func (s *Server) handleModuleMove(w http.ResponseWriter, r *http.Request) { @@ -229,7 +231,7 @@ func (s *Server) handleModuleDeleteConfirm(w http.ResponseWriter, r *http.Reques s.deleteModule(w, r, m) return } - s.render(w, r, "dashboard/confirm.html", map[string]any{"what": "the \"" + moduleNames[m.Kind] + "\" module and its text", "action": r.URL.Path, "back": s.layoutURL(r)}) + s.render(w, r, "dashboard/confirm.html", map[string]any{"what": s.trf(r, "the ā%sā module and its text", s.tr(r, moduleNames[m.Kind])), "action": r.URL.Path, "back": s.layoutURL(r)}) } func (s *Server) handleModuleDelete(w http.ResponseWriter, r *http.Request) { @@ -243,7 +245,7 @@ func (s *Server) deleteModule(w http.ResponseWriter, r *http.Request, m *store.M s.serverError(w, err) return } - redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), moduleNames[m.Kind]+" removed.") + redirectOK(w, r, s.layoutURL(r)+"#"+anchorFor(m.Area), s.trf(r, "%s removed.", s.tr(r, moduleNames[m.Kind]))) } // ---- menu -------------------------------------------------------------------- @@ -251,14 +253,14 @@ func (s *Server) deleteModule(w http.ResponseWriter, r *http.Request, m *store.M func (s *Server) handleMenuAddPage(w http.ResponseWriter, r *http.Request) { pageID, _ := strconv.ParseInt(r.FormValue("page"), 10, 64) if pageID == 0 { - redirectOK(w, r, s.layoutURL(r)+"#menu", "Pick a page first.") + redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Pick a page first.")) return } if err := blogStore(r).AddMenuPage(r.Context(), pageID); err != nil { s.serverError(w, err) return } - redirectOK(w, r, s.layoutURL(r)+"#menu", "Page added to the menu. Refresh your blog to see it.") + redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Page added to the menu. Refresh your blog to see it.")) } func (s *Server) handleMenuAddLink(w http.ResponseWriter, r *http.Request) { @@ -268,17 +270,17 @@ func (s *Server) handleMenuAddLink(w http.ResponseWriter, r *http.Request) { } switch { case label == "" || len(label) > maxMenuLabel: - redirectOK(w, r, s.layoutURL(r)+"#menu", "Give the link a short text (max 60 characters).") + redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Give the link a short text (max 60 characters).")) return case !validLinkURL(link): - redirectOK(w, r, s.layoutURL(r)+"#menu", "That address does not look right. Use a full web address like https://example.org or a path like /about.") + redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "That address does not look right. Use a full web address like https://example.org or a path like /about.")) return } if err := blogStore(r).AddMenuLink(r.Context(), label, link); err != nil { s.serverError(w, err) return } - redirectOK(w, r, s.layoutURL(r)+"#menu", "Link added to the menu. Refresh your blog to see it.") + redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Link added to the menu. Refresh your blog to see it.")) } func (s *Server) handleMenuMove(w http.ResponseWriter, r *http.Request) { @@ -300,5 +302,5 @@ func (s *Server) handleMenuDelete(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - redirectOK(w, r, s.layoutURL(r)+"#menu", "Removed from the menu.") + redirectOK(w, r, s.layoutURL(r)+"#menu", s.tr(r, "Removed from the menu.")) } |
