From e14a4250efb828262347f5a4bbc4067cd71d885f Mon Sep 17 00:00:00 2001 From: grm Date: Mon, 14 Sep 2026 01:32:25 +0300 Subject: Add languages: English and Greek, chosen per blog on the Settings tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/handlers_admin.go | 28 ++-- internal/web/handlers_auth.go | 12 +- internal/web/handlers_dashboard.go | 15 +- internal/web/handlers_design.go | 37 +++-- internal/web/handlers_layout.go | 54 ++++--- internal/web/handlers_pages.go | 18 +-- internal/web/handlers_posts.go | 18 +-- internal/web/handlers_sections.go | 20 +-- internal/web/layout.go | 20 +-- internal/web/server.go | 72 +++++++-- internal/web/templates.go | 50 ++++-- internal/web/templates/admin/delete_user.html | 10 +- internal/web/templates/admin/index.html | 28 ++-- internal/web/templates/admin/new_user.html | 14 +- internal/web/templates/auth/login.html | 10 +- internal/web/templates/blog/404.html | 6 +- internal/web/templates/blog/page.html | 10 +- internal/web/templates/blog/post.html | 2 +- internal/web/templates/dashboard/confirm.html | 14 +- internal/web/templates/dashboard/design.html | 178 ++++++++++----------- internal/web/templates/dashboard/images.html | 14 +- internal/web/templates/dashboard/layout.html | 102 ++++++------ internal/web/templates/dashboard/module_form.html | 28 ++-- internal/web/templates/dashboard/overview.html | 24 +-- internal/web/templates/dashboard/page_form.html | 18 +-- internal/web/templates/dashboard/pages.html | 20 +-- internal/web/templates/dashboard/password.html | 12 +- internal/web/templates/dashboard/post_form.html | 20 +-- internal/web/templates/dashboard/posts.html | 20 +-- internal/web/templates/dashboard/section_form.html | 40 ++--- internal/web/templates/dashboard/sections.html | 26 +-- internal/web/templates/dashboard/settings.html | 15 +- internal/web/templates/layouts/blog.html | 8 +- internal/web/templates/layouts/dashboard.html | 2 +- internal/web/templates/partials/dashnav.html | 26 +-- internal/web/templates/partials/editor.html | 14 +- internal/web/templates/partials/imagepick.html | 4 +- internal/web/templates/partials/mdhelp.html | 10 +- internal/web/theme.go | 14 +- internal/web/web_test.go | 111 ++++++++++--- 40 files changed, 647 insertions(+), 497 deletions(-) (limited to 'internal/web') diff --git a/internal/web/handlers_admin.go b/internal/web/handlers_admin.go index e0b3991..eb040c1 100644 --- a/internal/web/handlers_admin.go +++ b/internal/web/handlers_admin.go @@ -50,11 +50,11 @@ func (s *Server) handleAdminNewUser(w http.ResponseWriter, r *http.Request) { var msg string switch { case !usernameRe.MatchString(username): - msg = "Username: 2-40 letters, digits, dots, dashes or underscores." + msg = s.tr(r, "Username: 2-40 letters, digits, dots, dashes or underscores.") case len(pw) < 8: - msg = "Password must be at least 8 characters." + msg = s.tr(r, "Password must be at least 8 characters.") case !subdomainRe.MatchString(sub) || reservedSubdomains[sub]: - msg = "Subdomain: lowercase letters, digits and single dashes; not a reserved name." + msg = s.tr(r, "Subdomain: lowercase letters, digits and single dashes; not a reserved name.") } if msg != "" { f["error"] = msg @@ -69,19 +69,19 @@ func (s *Server) handleAdminNewUser(w http.ResponseWriter, r *http.Request) { _, blog, err := s.st.CreateBlogger(r.Context(), username, hash, sub, title) if err != nil { if errors.Is(err, store.ErrConflict) { - f["error"] = "Username or subdomain already taken." + f["error"] = s.tr(r, "Username or subdomain already taken.") s.renderStatus(w, r, http.StatusConflict, "admin/new_user.html", f) return } if errors.Is(err, db.ErrDatabaseExists) { - f["error"] = "A database " + db.DBName(sub) + " already exists in Postgres (a leftover of a deleted blog?). Drop it or pick another subdomain." + f["error"] = s.trf(r, "A database %s already exists in Postgres (a leftover of a deleted blog?). Drop it or pick another subdomain.", db.DBName(sub)) s.renderStatus(w, r, http.StatusConflict, "admin/new_user.html", f) return } s.serverError(w, err) return } - redirectOK(w, r, "/admin/", "Created "+username+" with blog "+blog.Subdomain+".") + redirectOK(w, r, "/admin/", s.trf(r, "Created %s with blog %s.", username, blog.Subdomain)) } func (s *Server) adminTargetUser(w http.ResponseWriter, r *http.Request) *store.User { @@ -96,7 +96,7 @@ func (s *Server) adminTargetUser(w http.ResponseWriter, r *http.Request) *store. return nil } if u.ID == currentUser(r).ID { - s.plainError(w, http.StatusBadRequest, "You cannot do that to your own account here; use Account > Password.") + s.plainError(w, http.StatusBadRequest, s.tr(r, "You cannot do that to your own account here; use Account > Password.")) return nil } return u @@ -109,7 +109,7 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request } pw := r.FormValue("password") if len(pw) < 8 { - s.plainError(w, http.StatusBadRequest, "Password must be at least 8 characters.") + s.plainError(w, http.StatusBadRequest, s.tr(r, "Password must be at least 8 characters.")) return } hash, err := auth.HashPassword(pw) @@ -121,7 +121,7 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request s.serverError(w, err) return } - redirectOK(w, r, "/admin/", "Password reset for "+u.Username+".") + redirectOK(w, r, "/admin/", s.trf(r, "Password reset for %s.", u.Username)) } func (s *Server) handleAdminSetDisabled(disabled bool) http.HandlerFunc { @@ -134,11 +134,11 @@ func (s *Server) handleAdminSetDisabled(disabled bool) http.HandlerFunc { s.serverError(w, err) return } - verb := "enabled" + msg := s.trf(r, "%s enabled.", u.Username) if disabled { - verb = "disabled" + msg = s.trf(r, "%s disabled.", u.Username) } - redirectOK(w, r, "/admin/", u.Username+" "+verb+".") + redirectOK(w, r, "/admin/", msg) } } @@ -156,12 +156,12 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) { return } if b, err := s.st.BlogByOwner(r.Context(), u.ID); err == nil && b.Subdomain == config.RootSubdomain { - s.plainError(w, http.StatusBadRequest, "This user owns the root blog and cannot be deleted.") + s.plainError(w, http.StatusBadRequest, s.tr(r, "This user owns the root blog and cannot be deleted.")) return } if err := s.st.DeleteUser(r.Context(), u.ID); err != nil { // drops their blog database too s.serverError(w, err) return } - redirectOK(w, r, "/admin/", "Deleted "+u.Username+" and their blog.") + redirectOK(w, r, "/admin/", s.trf(r, "Deleted %s and their blog.", u.Username)) } diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go index 7c1d1e8..3030f11 100644 --- a/internal/web/handlers_auth.go +++ b/internal/web/handlers_auth.go @@ -35,7 +35,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { next := safeNext(r.FormValue("next")) fail := func() { s.renderStatus(w, r, http.StatusUnauthorized, "auth/login.html", - map[string]any{"error": "Wrong username or password.", "username": username, "next": next}) + map[string]any{"error": s.tr(r, "Wrong username or password."), "username": username, "next": next}) } u, err := s.st.UserByUsername(r.Context(), username) if err != nil { @@ -77,7 +77,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/", http.StatusSeeOther) return } - s.plainError(w, http.StatusNotFound, "You have no blog yet. Ask the administrator to create one.") + s.plainError(w, http.StatusNotFound, s.tr(r, "You have no blog yet. Ask the administrator to create one.")) return } s.serverError(w, err) @@ -96,11 +96,11 @@ func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) { var msg string switch { case !auth.CheckPassword(u.PasswordHash, cur): - msg = "Current password is wrong." + msg = s.tr(r, "Current password is wrong.") case len(pw) < 8: - msg = "New password must be at least 8 characters." + msg = s.tr(r, "New password must be at least 8 characters.") case pw != pw2: - msg = "New passwords do not match." + msg = s.tr(r, "New passwords do not match.") } if msg != "" { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/password.html", map[string]any{"error": msg}) @@ -122,7 +122,7 @@ func (s *Server) handlePassword(w http.ResponseWriter, r *http.Request) { return } auth.SetSessionCookie(w, tok) - redirectOK(w, r, "/dashboard", "Password changed.") + redirectOK(w, r, "/dashboard", s.tr(r, "Password changed.")) } // safeNext only allows local paths as post-login redirect targets. diff --git a/internal/web/handlers_dashboard.go b/internal/web/handlers_dashboard.go index a1e0d89..506d28d 100644 --- a/internal/web/handlers_dashboard.go +++ b/internal/web/handlers_dashboard.go @@ -3,6 +3,8 @@ package web import ( "net/http" "strings" + + "github.com/gramanas/blogspace/internal/i18n" ) func (s *Server) handleBlogOverview(w http.ResponseWriter, r *http.Request) { @@ -23,20 +25,23 @@ func (s *Server) handleBlogOverview(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleSettingsForm(w http.ResponseWriter, r *http.Request) { - s.render(w, r, "dashboard/settings.html", nil) + s.render(w, r, "dashboard/settings.html", map[string]any{"languages": i18n.Languages()}) } func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) title := strings.TrimSpace(r.FormValue("title")) tagline := strings.TrimSpace(r.FormValue("tagline")) - if title == "" || len(title) > 120 || len(tagline) > 300 { - s.renderStatus(w, r, http.StatusBadRequest, "dashboard/settings.html", map[string]any{"error": "Title is required (max 120 chars); tagline max 300."}) + language := r.FormValue("language") + if title == "" || len(title) > 120 || len(tagline) > 300 || !i18n.Valid(language) { + s.renderStatus(w, r, http.StatusBadRequest, "dashboard/settings.html", + map[string]any{"languages": i18n.Languages(), "error": s.tr(r, "Title is required (max 120 chars); tagline max 300.")}) return } - if err := blogStore(r).UpdateSettings(r.Context(), title, tagline); err != nil { + if err := blogStore(r).UpdateSettings(r.Context(), title, tagline, language); err != nil { s.serverError(w, err) return } - redirectOK(w, r, "/b/"+blog.Subdomain+"/settings", "Saved. Refresh your blog to see it.") + // The flash speaks the language just chosen, not the one the form was in. + redirectOK(w, r, "/b/"+blog.Subdomain+"/settings", i18n.T(language, "Saved. Refresh your blog to see it.")) } 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.")) } 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.")) } diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go index 09257a3..fdd1bc8 100644 --- a/internal/web/handlers_pages.go +++ b/internal/web/handlers_pages.go @@ -69,11 +69,11 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { var msg string switch { case err != nil: - msg = "Image not added: " + err.Error() + msg = s.tr(r, "Image not added:") + " " + err.Error() case p.Title == "" || len(p.Title) > 120: - msg = "Title is required (max 120 characters)." + msg = s.tr(r, "Title is required (max 120 characters).") case !slug.Valid(p.Slug) || reservedPageSlugs[p.Slug]: - msg = "Slug may only contain lowercase letters, digits and dashes (and not be a reserved word)." + msg = s.tr(r, "Slug may only contain lowercase letters, digits and dashes (and not be a reserved word).") } if msg != "" { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/page_form.html", map[string]any{"page": p, "error": msg}) @@ -98,13 +98,13 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { } if err != nil { if errors.Is(err, store.ErrConflict) { - s.renderStatus(w, r, http.StatusConflict, "dashboard/page_form.html", map[string]any{"page": p, "error": "A page with that slug already exists."}) + s.renderStatus(w, r, http.StatusConflict, "dashboard/page_form.html", map[string]any{"page": p, "error": s.tr(r, "A page with that slug already exists.")}) return } s.serverError(w, err) return } - redirectOK(w, r, "/b/"+blog.Subdomain+"/pages/"+strconv.FormatInt(p.ID, 10)+"/edit", "Saved. Refresh your blog to see it.") + redirectOK(w, r, "/b/"+blog.Subdomain+"/pages/"+strconv.FormatInt(p.ID, 10)+"/edit", s.tr(r, "Saved. Refresh your blog to see it.")) } func (s *Server) handlePageDeleteConfirm(w http.ResponseWriter, r *http.Request) { @@ -113,7 +113,7 @@ func (s *Server) handlePageDeleteConfirm(w http.ResponseWriter, r *http.Request) return } s.render(w, r, "dashboard/confirm.html", map[string]any{ - "what": "the page \"" + p.Title + "\" and all " + strconv.Itoa(p.PostCount) + " of its posts", + "what": s.trf(r, "the page “%s” and all %d of its posts", p.Title, p.PostCount), "action": r.URL.Path, "back": "/b/" + currentBlog(r).Subdomain + "/pages", "isHome": p.IsHome, @@ -126,14 +126,14 @@ func (s *Server) handlePageDelete(w http.ResponseWriter, r *http.Request) { return } if p.IsHome { - s.plainError(w, http.StatusBadRequest, "The home page cannot be deleted. Make another page the home page first.") + s.plainError(w, http.StatusBadRequest, s.tr(r, "The home page cannot be deleted. Make another page the home page first.")) return } if err := blogStore(r).DeletePage(r.Context(), p.ID); err != nil { s.serverError(w, err) return } - redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", "Page deleted.") + redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", s.tr(r, "Page deleted.")) } func (s *Server) handlePageMove(w http.ResponseWriter, r *http.Request) { @@ -161,5 +161,5 @@ func (s *Server) handlePageHome(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", "\""+p.Title+"\" is now the home page.") + redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/pages", s.trf(r, "“%s” is now the home page.", p.Title)) } diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go index 02a3fd7..88d5c20 100644 --- a/internal/web/handlers_posts.go +++ b/internal/web/handlers_posts.go @@ -86,7 +86,7 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { } img, err := s.readUpload(r, "inline_image") if err != nil { - fail(http.StatusBadRequest, "Image not added: "+err.Error()) + fail(http.StatusBadRequest, s.tr(r, "Image not added:")+" "+err.Error()) return } p.BodyMD = appendImageMD(p.BodyMD, img) @@ -98,16 +98,16 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { } switch { case p.Title == "" || len(p.Title) > 200: - fail(http.StatusBadRequest, "Title is required (max 200 characters).") + fail(http.StatusBadRequest, s.tr(r, "Title is required (max 200 characters).")) return case !slug.Valid(p.Slug): - fail(http.StatusBadRequest, "Slug may only contain lowercase letters, digits and dashes.") + fail(http.StatusBadRequest, s.tr(r, "Slug may only contain lowercase letters, digits and dashes.")) return case !pageOK: - fail(http.StatusBadRequest, "Pick a page for this post.") + fail(http.StatusBadRequest, s.tr(r, "Pick a page for this post.")) return case len(p.BodyMD) > 200_000: - fail(http.StatusBadRequest, "Post is too long (200 KB max).") + fail(http.StatusBadRequest, s.tr(r, "Post is too long (200 KB max).")) return } p.BodyHTML = markdown.Render(p.BodyMD) @@ -128,13 +128,13 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { } if err != nil { if errors.Is(err, store.ErrConflict) { - fail(http.StatusConflict, "A post with that slug already exists on this page; choose another slug.") + fail(http.StatusConflict, s.tr(r, "A post with that slug already exists on this page; choose another slug.")) return } s.serverError(w, err) return } - redirectOK(w, r, "/b/"+blog.Subdomain+"/posts/"+strconv.FormatInt(p.ID, 10)+"/edit", "Saved. Refresh your blog to see it.") + redirectOK(w, r, "/b/"+blog.Subdomain+"/posts/"+strconv.FormatInt(p.ID, 10)+"/edit", s.tr(r, "Saved. Refresh your blog to see it.")) } func (s *Server) handlePostDeleteConfirm(w http.ResponseWriter, r *http.Request) { @@ -143,7 +143,7 @@ func (s *Server) handlePostDeleteConfirm(w http.ResponseWriter, r *http.Request) return } s.render(w, r, "dashboard/confirm.html", map[string]any{ - "what": "the post \"" + p.Title + "\"", + "what": s.trf(r, "the post “%s”", p.Title), "action": r.URL.Path, "back": "/b/" + currentBlog(r).Subdomain + "/posts", }) @@ -158,5 +158,5 @@ func (s *Server) handlePostDelete(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/posts", "Post deleted.") + redirectOK(w, r, "/b/"+currentBlog(r).Subdomain+"/posts", s.tr(r, "Post deleted.")) } diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go index 1239545..325192e 100644 --- a/internal/web/handlers_sections.go +++ b/internal/web/handlers_sections.go @@ -76,16 +76,16 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) { img, err := s.readUpload(r, "inline_image") switch { case err != nil: - fail("Image not added: " + err.Error()) + fail(s.tr(r, "Image not added:") + " " + err.Error()) return case len(sec.Title) > 120: - fail("Title is too long (max 120 characters).") + fail(s.tr(r, "Title is too long (max 120 characters).")) return case strings.TrimSpace(sec.BodyMD) == "" && img == nil: - fail("Write some text for the announcement.") + fail(s.tr(r, "Write some text for the announcement.")) return case len(sec.BodyMD) > maxSectionBytes: - fail("Announcement is too long (20 KB max).") + fail(s.tr(r, "Announcement is too long (20 KB max).")) return } sec.BodyMD = appendImageMD(sec.BodyMD, img) @@ -99,7 +99,7 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - redirectOK(w, r, s.sectionsURL(r)+"/"+strconv.FormatInt(sec.ID, 10)+"/edit", "Saved. Refresh your blog to see it.") + redirectOK(w, r, s.sectionsURL(r)+"/"+strconv.FormatInt(sec.ID, 10)+"/edit", s.tr(r, "Saved. Refresh your blog to see it.")) } func (s *Server) handleSectionDeleteConfirm(w http.ResponseWriter, r *http.Request) { @@ -107,9 +107,9 @@ func (s *Server) handleSectionDeleteConfirm(w http.ResponseWriter, r *http.Reque if sec == nil { return } - what := "this announcement" + what := s.tr(r, "this announcement") if sec.Title != "" { - what = "the announcement \"" + sec.Title + "\"" + what = s.trf(r, "the announcement “%s”", sec.Title) } s.render(w, r, "dashboard/confirm.html", map[string]any{"what": what, "action": r.URL.Path, "back": s.sectionsURL(r)}) } @@ -123,7 +123,7 @@ func (s *Server) handleSectionDelete(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - redirectOK(w, r, s.sectionsURL(r), "Announcement deleted.") + redirectOK(w, r, s.sectionsURL(r), s.tr(r, "Announcement deleted.")) } func (s *Server) handleSectionMove(w http.ResponseWriter, r *http.Request) { @@ -152,9 +152,9 @@ func (s *Server) handleSectionToggle(w http.ResponseWriter, r *http.Request) { s.serverError(w, err) return } - msg := "Announcement hidden." + msg := s.tr(r, "Announcement hidden.") if !sec.Enabled { - msg = "Announcement shown. Refresh your blog to see it." + msg = s.tr(r, "Announcement shown. Refresh your blog to see it.") } redirectOK(w, r, s.sectionsURL(r), msg) } diff --git a/internal/web/layout.go b/internal/web/layout.go index 521cb63..ad55e82 100644 --- a/internal/web/layout.go +++ b/internal/web/layout.go @@ -2,10 +2,10 @@ package web import ( "net/url" - "strconv" "strings" "time" + "github.com/gramanas/blogspace/internal/i18n" "github.com/gramanas/blogspace/internal/store" ) @@ -20,7 +20,7 @@ var moduleKinds = map[string][]string{ "footer": {"text", "rss", "sitemap", "html"}, } -// Friendly names for the dashboard. +// Friendly names for the dashboard (English; translate with t / s.tr at the point of use). var moduleNames = map[string]string{ "title": "Blog title & tagline", "logo": "Logo", @@ -60,28 +60,28 @@ func moduleHasSettings(kind string) bool { } // moduleSummary is the one-liner the Layout page shows next to a module's name. -func moduleSummary(m store.Module) string { +func moduleSummary(lang string, m store.Module) string { switch m.Kind { case "recent": - return strconv.Itoa(m.Count) + " posts" + headingNote(m) + return i18n.Tf(lang, "%d posts", m.Count) + headingNote(lang, m) case "html", "text": body := strings.Join(strings.Fields(m.Body), " ") if body == "" { - return "empty" + return i18n.T(lang, "empty") } if len(body) > 60 { body = body[:57] + "…" } return body } - return strings.TrimPrefix(headingNote(m), ", ") + return strings.TrimPrefix(headingNote(lang, m), ", ") } -func headingNote(m store.Module) string { +func headingNote(lang string, m store.Module) string { if m.Title == "" { return "" } - return ", heading “" + m.Title + "”" + return ", " + i18n.Tf(lang, "heading “%s”", m.Title) } // validLinkURL accepts what a menu link may point to: a web address, a mail @@ -174,7 +174,7 @@ func (l Layout) MaxRecent() int { // ---- archive --------------------------------------------------------------- type ArchiveMonth struct { - Name string // "September" + Month time.Month // named by the template in the blog's language Posts []store.PostRef } @@ -196,7 +196,7 @@ func groupArchive(posts []store.PostRef) []ArchiveYear { } yr := &years[len(years)-1] if len(yr.Months) == 0 || m != lastM { - yr.Months = append(yr.Months, ArchiveMonth{Name: time.Month(m).String()}) + yr.Months = append(yr.Months, ArchiveMonth{Month: time.Month(m)}) } mo := &yr.Months[len(yr.Months)-1] mo.Posts = append(mo.Posts, p) diff --git a/internal/web/server.go b/internal/web/server.go index a7af06b..4bb408e 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -12,6 +12,7 @@ import ( "github.com/gramanas/blogspace/internal/auth" "github.com/gramanas/blogspace/internal/config" + "github.com/gramanas/blogspace/internal/i18n" "github.com/gramanas/blogspace/internal/store" ) @@ -27,7 +28,7 @@ type Server struct { } func NewServer(cfg *config.Config, st *store.Store) *Server { - s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev, funcs)} + s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev)} s.root = s.rootRoutes() s.blog = s.subdomainRoutes() return s @@ -70,6 +71,7 @@ const ( ctxBlog // the blog a handler operates on (public page, or /b/{sub}/ management) ctxBlogStore // that blog's store, bound to its own database ctxHostSub // subdomain derived from the Host header + ctxLang // language of the page, once a middleware has settled it ) func currentUser(r *http.Request) *store.User { @@ -104,7 +106,8 @@ func (s *Server) resolveBlog(r *http.Request, sub string) (*http.Request, error) return r.WithContext(context.WithValue(ctx, ctxBlogStore, bs)), nil } -// hostBlog resolves the blog named by the Host header into the context for public pages. +// hostBlog resolves the blog named by the Host header into the context for +// public pages, which speak the blog's language. func (s *Server) hostBlog(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sub, _ := r.Context().Value(ctxHostSub).(string) @@ -117,8 +120,47 @@ func (s *Server) hostBlog(next http.HandlerFunc) http.HandlerFunc { s.serverError(w, err) return } - next(w, r) + next(w, withLang(r, currentBlog(r).Language)) + } +} + +func withLang(r *http.Request, lang string) *http.Request { + return r.WithContext(context.WithValue(r.Context(), ctxLang, lang)) +} + +// lang is the language of the page being served: settled by hostBlog/withBlog, +// otherwise the user's own blog's, otherwise whatever the browser asks for. +func (s *Server) lang(r *http.Request) string { + if l, ok := r.Context().Value(ctxLang).(string); ok { + return l + } + if u := currentUser(r); u != nil { + return s.userLang(r, u) + } + return i18n.Match(r.Header.Get("Accept-Language")) +} + +// userLang is the language of the user's own blog (a user has one blog), so +// the dashboard reads the same on every page, including another user's blog. +func (s *Server) userLang(r *http.Request, u *store.User) string { + blog, err := s.st.BlogByOwner(r.Context(), u.ID) + if err == nil { + _, err = s.st.Open(r.Context(), blog) } + if err != nil { + if !errors.Is(err, store.ErrNotFound) { + log.Printf("language of %s: %v", u.Username, err) + } + return i18n.Default + } + return blog.Language +} + +// tr translates a message for the request's language; trf also formats it. +func (s *Server) tr(r *http.Request, key string) string { return i18n.T(s.lang(r), key) } + +func (s *Server) trf(r *http.Request, key string, args ...any) string { + return i18n.Tf(s.lang(r), key, args...) } // session loads the user from the JWT cookie (if any) into the context. @@ -159,14 +201,14 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { if err := parseForm(r); err != nil { var tooBig *http.MaxBytesError if errors.As(err, &tooBig) { - s.plainError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Upload too large: the limit is %d MB.", s.cfg.MaxUploadBytes>>20)) + s.plainError(w, http.StatusRequestEntityTooLarge, s.trf(r, "Upload too large: the limit is %d MB.", s.cfg.MaxUploadBytes>>20)) return } - s.plainError(w, http.StatusBadRequest, "Could not read the form.") + s.plainError(w, http.StatusBadRequest, s.tr(r, "Could not read the form.")) return } if !auth.CheckCSRF(s.cfg.JWTSecret, u.ID, u.TokenVersion, r.FormValue("_csrf")) { - s.plainError(w, http.StatusForbidden, "Form expired or invalid. Go back, reload the page and try again.") + s.plainError(w, http.StatusForbidden, s.tr(r, "Form expired or invalid. Go back, reload the page and try again.")) return } } @@ -186,7 +228,7 @@ func parseForm(r *http.Request) error { func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { if !currentUser(r).IsSuperadmin() { - s.plainError(w, http.StatusForbidden, "Superadmin only.") + s.plainError(w, http.StatusForbidden, s.tr(r, "Superadmin only.")) return } next(w, r) @@ -206,11 +248,16 @@ func (s *Server) withBlog(next http.HandlerFunc) http.HandlerFunc { s.serverError(w, err) return } - if blog := currentBlog(r); blog.OwnerID != u.ID && !u.IsSuperadmin() { - s.plainError(w, http.StatusForbidden, "This is not your blog.") + blog := currentBlog(r) + if blog.OwnerID != u.ID && !u.IsSuperadmin() { + s.plainError(w, http.StatusForbidden, s.tr(r, "This is not your blog.")) return } - next(w, r) + lang := blog.Language + if blog.OwnerID != u.ID { + lang = s.userLang(r, u) + } + next(w, withLang(r, lang)) }) } @@ -226,6 +273,7 @@ type view struct { BlogURL string RootURL string Path string + Lang string // i18n code, also the Data map[string]any } @@ -237,7 +285,7 @@ func (s *Server) renderStatus(w http.ResponseWriter, r *http.Request, status int if data == nil { data = map[string]any{} } - v := view{User: currentUser(r), Blog: currentBlog(r), RootURL: s.cfg.RootURL(), Path: r.URL.Path, Data: data} + v := view{User: currentUser(r), Blog: currentBlog(r), RootURL: s.cfg.RootURL(), Path: r.URL.Path, Lang: s.lang(r), Data: data} if v.User != nil { v.CSRF = auth.CSRFToken(s.cfg.JWTSecret, v.User.ID, v.User.TokenVersion) } @@ -250,7 +298,7 @@ func (s *Server) renderStatus(w http.ResponseWriter, r *http.Request, status int } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) - if err := s.tpl.render(w, name, v); err != nil { + if err := s.tpl.render(w, v.Lang, name, v); err != nil { log.Printf("render %s: %v", name, err) fmt.Fprintf(w, "
template error: %v
", err) } diff --git a/internal/web/templates.go b/internal/web/templates.go index e7e35f5..7f71304 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -12,23 +12,27 @@ import ( "strings" "sync" "time" + + "github.com/gramanas/blogspace/internal/i18n" + "github.com/gramanas/blogspace/internal/store" ) //go:embed templates static var assets embed.FS -// templates compiles each page together with its layout. In dev mode it -// re-reads from disk on every render so edits show up without a restart. +// templates compiles each page together with its layout, once per language +// (the translating funcs are closed over the language, so templates just +// write {{t "…"}}). In dev mode it re-reads from disk on every render so +// edits show up without a restart. type templates struct { dev bool fs fs.FS - funcs template.FuncMap mu sync.Mutex - cache map[string]*template.Template + cache map[string]*template.Template // "lang/dir/file.html" } -func newTemplates(dev bool, funcs template.FuncMap) *templates { - t := &templates{dev: dev, funcs: funcs, cache: map[string]*template.Template{}} +func newTemplates(dev bool) *templates { + t := &templates{dev: dev, cache: map[string]*template.Template{}} t.fs = assets if dev { // locate the package directory so `go run` from anywhere still finds the files @@ -54,38 +58,55 @@ func layoutFor(name string) string { return "layouts/dashboard.html" } -func (t *templates) get(name string) (*template.Template, error) { +func (t *templates) get(lang, name string) (*template.Template, error) { + key := lang + "/" + name if !t.dev { t.mu.Lock() - if tpl, ok := t.cache[name]; ok { + if tpl, ok := t.cache[key]; ok { t.mu.Unlock() return tpl, nil } t.mu.Unlock() } - tpl, err := template.New("").Funcs(t.funcs).ParseFS(t.fs, + tpl, err := template.New("").Funcs(funcsFor(lang)).ParseFS(t.fs, "templates/"+layoutFor(name), "templates/partials/*.html", "templates/"+name) if err != nil { return nil, fmt.Errorf("parse %s: %w", name, err) } if !t.dev { t.mu.Lock() - t.cache[name] = tpl + t.cache[key] = tpl t.mu.Unlock() } return tpl, nil } -func (t *templates) render(w io.Writer, name string, data any) error { - tpl, err := t.get(name) +func (t *templates) render(w io.Writer, lang, name string, data any) error { + tpl, err := t.get(lang, name) if err != nil { return err } return tpl.ExecuteTemplate(w, "layout", data) } +// funcsFor is the shared funcs plus the ones that speak the page's language. +func funcsFor(lang string) template.FuncMap { + m := make(template.FuncMap, len(funcs)+6) + for k, v := range funcs { + m[k] = v + } + // t translates fixed text; tf also formats: {{tf "Page %d of %d" .N .Last}} + m["t"] = func(key string) string { return i18n.T(lang, key) } + m["tf"] = func(key string, args ...any) string { return i18n.Tf(lang, key, args...) } + // date is for the dashboard; postdate follows the theme's date style + m["date"] = func(t time.Time) string { return i18n.FormatDate(lang, "long", t) } + m["postdate"] = func(style string, t time.Time) string { return i18n.FormatDate(lang, style, t) } + m["month"] = func(mo time.Month) string { return i18n.Month(lang, mo) } + m["modsummary"] = func(mod store.Module) string { return moduleSummary(lang, mod) } + return m +} + var funcs = template.FuncMap{ - "date": func(t time.Time) string { return t.Format("2 January 2006") }, "rfc": func(t time.Time) string { return t.Format(time.RFC1123Z) }, "html": func(s string) template.HTML { return template.HTML(s) }, "css": func(s string) template.CSS { return template.CSS(s) }, @@ -107,8 +128,7 @@ var funcs = template.FuncMap{ // split turns a plain-text field into lines: {{range split .Body "\n"}}

{{.}}

{{end}} "split": strings.Split, "lower": strings.ToLower, - // layout modules: one-line description and whether the kind has an edit page - "modsummary": moduleSummary, + // layout modules: whether the kind has an edit page (modsummary is per language) "modsettings": moduleHasSettings, // hasPrefix marks the current dashboard tab: {{if hasPrefix .Path "/b/x/posts"}} "hasPrefix": strings.HasPrefix, diff --git a/internal/web/templates/admin/delete_user.html b/internal/web/templates/admin/delete_user.html index e673b64..886ff1a 100644 --- a/internal/web/templates/admin/delete_user.html +++ b/internal/web/templates/admin/delete_user.html @@ -1,12 +1,12 @@ -{{define "title"}}Delete user · Blogspace{{end}} +{{define "title"}}{{t "Delete user"}} · Blogspace{{end}} {{define "content"}}
-

Delete {{.Data.target.Username}}?

-

This permanently deletes the user {{.Data.target.Username}}, their blog, and every page, post and image in it.

+

{{tf "Delete %s?" .Data.target.Username}}

+

{{t "This permanently deletes the user"}} {{.Data.target.Username}}{{t ", their blog, and every page, post and image in it."}}

-

- Cancel

+

+ {{t "Cancel"}}

{{end}} diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html index 8293368..0e49e62 100644 --- a/internal/web/templates/admin/index.html +++ b/internal/web/templates/admin/index.html @@ -1,29 +1,29 @@ -{{define "title"}}Admin · Blogspace{{end}} +{{define "title"}}{{t "Admin"}} · Blogspace{{end}} {{define "content"}}
-

Users & blogs

- +

{{t "Users & blogs"}}

+
- + {{range .Data.users}} - - - + + + {{end}}
UserRoleBlogSinceActions
{{t "User"}}{{t "Role"}}{{t "Blog"}}{{t "Since"}}{{t "Actions"}}
{{.Username}}{{if .Disabled}} disabled{{end}}{{.Role}}{{if .Subdomain}}{{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}} view ↗{{else}}—{{end}}{{.Username}}{{if .Disabled}} {{t "disabled"}}{{end}}{{t .Role}}{{if .Subdomain}}{{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}} {{t "view"}} ↗{{else}}—{{end}} {{date .CreatedAt}} {{if ne .ID $.User.ID}} -
reset password +
{{t "reset password"}}
- - + +
- {{if .Disabled}}
- {{else}}
{{end}} - delete - {{else}}(you){{end}} + {{if .Disabled}}
+ {{else}}
{{end}} + {{t "delete"}} + {{else}}({{t "you"}}){{end}}
diff --git a/internal/web/templates/admin/new_user.html b/internal/web/templates/admin/new_user.html index 3d93dbd..f1c79be 100644 --- a/internal/web/templates/admin/new_user.html +++ b/internal/web/templates/admin/new_user.html @@ -1,15 +1,15 @@ -{{define "title"}}New blogger · Blogspace{{end}} +{{define "title"}}{{t "New blogger"}} · Blogspace{{end}} {{define "content"}}
-

New blogger

+

{{t "New blogger"}}

- - -
{{end}} diff --git a/internal/web/templates/auth/login.html b/internal/web/templates/auth/login.html index 3f66f3f..938b70e 100644 --- a/internal/web/templates/auth/login.html +++ b/internal/web/templates/auth/login.html @@ -1,13 +1,13 @@ -{{define "title"}}Log in · Blogspace{{end}} +{{define "title"}}{{t "Log in"}} · Blogspace{{end}} {{define "content"}}
-

Log in

+

{{t "Log in"}}

- - -

+ + +

{{end}} diff --git a/internal/web/templates/blog/404.html b/internal/web/templates/blog/404.html index b0aa6a9..00f5d14 100644 --- a/internal/web/templates/blog/404.html +++ b/internal/web/templates/blog/404.html @@ -1,5 +1,5 @@ -{{define "title"}}Not found · {{.Blog.Title}}{{end}} +{{define "title"}}{{t "Not found"}} · {{.Blog.Title}}{{end}} {{define "content"}} -

Not found

-

There is nothing at this address. Back to the front page.

+

{{t "Not found"}}

+

{{t "There is nothing at this address."}} {{t "Back to the front page."}}

{{end}} diff --git a/internal/web/templates/blog/page.html b/internal/web/templates/blog/page.html index ba78ff6..ac6a609 100644 --- a/internal/web/templates/blog/page.html +++ b/internal/web/templates/blog/page.html @@ -5,15 +5,15 @@ {{range .Data.posts}}

{{.Title}}

- {{if $.Data.theme.ShowDates}}{{end}} + {{if $.Data.theme.ShowDates}}{{end}}
{{html .BodyHTML}}
-{{else}}{{if not .Data.page.IntroHTML}}

Nothing here yet.

{{end}}{{end}} +{{else}}{{if not .Data.page.IntroHTML}}

{{t "Nothing here yet."}}

{{end}}{{end}} {{if gt .Data.lastPage 1}}
- {{if gt .Data.pageNum 1}}← Newer{{end}} - Page {{.Data.pageNum}} of {{.Data.lastPage}} - {{if lt .Data.pageNum .Data.lastPage}}Older →{{end}} + {{if gt .Data.pageNum 1}}← {{t "Newer"}}{{end}} + {{tf "Page %d of %d" .Data.pageNum .Data.lastPage}} + {{if lt .Data.pageNum .Data.lastPage}}{{t "Older"}} →{{end}}
{{end}} {{end}} diff --git a/internal/web/templates/blog/post.html b/internal/web/templates/blog/post.html index 5fa8ca4..d14f22a 100644 --- a/internal/web/templates/blog/post.html +++ b/internal/web/templates/blog/post.html @@ -2,7 +2,7 @@ {{define "content"}}

{{.Data.post.Title}}

- +
{{html .Data.post.BodyHTML}}
{{end}} diff --git a/internal/web/templates/dashboard/confirm.html b/internal/web/templates/dashboard/confirm.html index 8dd0b3a..62bf4a4 100644 --- a/internal/web/templates/dashboard/confirm.html +++ b/internal/web/templates/dashboard/confirm.html @@ -1,16 +1,16 @@ -{{define "title"}}Confirm · Blogspace{{end}} +{{define "title"}}{{t "Confirm"}} · Blogspace{{end}} {{define "content"}}
-

Are you sure?

+

{{t "Are you sure?"}}

{{if .Data.isHome}} -

This is the home page; it cannot be deleted. Make another page the home page first.

-

Back

+

{{t "This is the home page; it cannot be deleted. Make another page the home page first."}}

+

{{t "Back"}}

{{else}} -

You are about to permanently delete {{.Data.what}}. This cannot be undone.

+

{{tf "You are about to permanently delete %s. This cannot be undone." .Data.what}}

-

- Cancel

+

+ {{t "Cancel"}}

{{end}}
diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html index 9c2a12f..e961388 100644 --- a/internal/web/templates/dashboard/design.html +++ b/internal/web/templates/dashboard/design.html @@ -1,21 +1,21 @@ -{{define "title"}}Design · {{.Blog.Title}}{{end}} +{{define "title"}}{{t "Design"}} · {{.Blog.Title}}{{end}} {{define "content"}} {{$t := .Data.theme}}
-

Design

-

Changes are live as soon as you save; refresh your blog ↗ to see them.

+

{{t "Design"}}

+

{{t "Changes are live as soon as you save; refresh"}} {{t "your blog"}} ↗ {{t "to see them."}}

-

SchemesBackgroundTextHeaderMenuPostsFooterLogo & icon

-

What appears where — header, columns, footer and their modules — is arranged on the Layout tab.

+

{{t "Schemes"}}{{t "Background"}}{{t "Text"}}{{t "Header"}}{{t "Menu"}}{{t "Posts"}}{{t "Footer"}}{{t "Logo & icon"}}

+

{{t "What appears where — header, columns, footer and their modules — is arranged on the"}} {{t "Layout"}} {{t "tab."}}

-

Colour schemes

-

A quick start: applying a scheme sets every colour below and saves immediately. Images, fonts and layout are kept.

+

{{t "Colour schemes"}}

+

{{t "A quick start: applying a scheme sets every colour below and saves immediately. Images, fonts and layout are kept."}}

- {{range .Data.presets}} {{end}}
@@ -25,158 +25,158 @@
-

Background

+

{{t "Background"}}

- - +
- {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "sub" .Blog.Subdomain "images" .Data.images "label" "Background image")}} + {{template "imagepick" (dict "name" "bg_image" "current" $t.BgImage "sub" .Blog.Subdomain "images" .Data.images "label" (t "Background image"))}}
-

Text & content area

+

{{t "Text & content area"}}

- - - + + +
- - + +
- - - -
- -
-

The content spans the whole page; how much of it the posts get is set by the column widths on the Layout tab.

+

{{t "The content spans the whole page; how much of it the posts get is set by the column widths on the"}} {{t "Layout"}} {{t "tab."}}

-

Posts & dates

- +

{{t "Posts & dates"}}

+
-
- - View blog ↗ + + {{t "View blog"}} ↗
- Reset to defaults -

Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images (they stay in your library).

+ {{t "Reset to defaults"}} +

{{t "Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images (they stay in your library)."}}

- +
{{end}} diff --git a/internal/web/templates/dashboard/images.html b/internal/web/templates/dashboard/images.html index 2e39531..8b9faa3 100644 --- a/internal/web/templates/dashboard/images.html +++ b/internal/web/templates/dashboard/images.html @@ -1,14 +1,14 @@ -{{define "title"}}Images · {{.Blog.Title}}{{end}} +{{define "title"}}{{t "Images"}} · {{.Blog.Title}}{{end}} {{define "content"}}
-

Images

-

Images are stored with your blog. Put one in a post with Insert image in the editor, or copy its line from below.

+

{{t "Images"}}

+

{{t "Images are stored with your blog. Put one in a post with"}} {{t "Insert image"}} {{t "in the editor, or copy its line from below."}}

- -

+ +

-{{else}}

No images yet.

{{end}} +{{else}}

{{t "No images yet."}}

{{end}}
{{end}} diff --git a/internal/web/templates/dashboard/layout.html b/internal/web/templates/dashboard/layout.html index 229ca0e..48b6778 100644 --- a/internal/web/templates/dashboard/layout.html +++ b/internal/web/templates/dashboard/layout.html @@ -1,86 +1,86 @@ -{{define "title"}}Layout · {{.Blog.Title}}{{end}} +{{define "title"}}{{t "Layout"}} · {{.Blog.Title}}{{end}} {{define "content"}} {{$t := .Data.theme}} {{$u := print "/b/" .Blog.Subdomain "/layout"}}
-

Layout

-

Decide what goes where on your blog. Every change here is saved straight away; refresh your blog ↗ to see it. Colours and fonts are on the Design tab.

+

{{t "Layout"}}

+

{{t "Decide what goes where on your blog. Every change here is saved straight away; refresh"}} {{t "your blog"}} ↗ {{t "to see it."}} {{t "Colours and fonts are on the"}} {{t "Design"}} {{t "tab."}}

-

ColumnsHeaderLeft columnMain contentRight columnFooterMenu

+

{{t "Columns"}}{{t "Header"}}{{t "Left column"}}{{t "Main content"}}{{t "Right column"}}{{t "Footer"}}{{t "Menu"}}

-

Columns

+

{{t "Columns"}}

-
Header{{if not $t.HeaderOn}} (hidden){{end}}
+
{{t "Header"}}{{if not $t.HeaderOn}} ({{t "hidden"}}){{end}}
-
Left {{$t.LeftWidth}}%
-
Main content {{$t.MainWidth}}%
-
Right {{$t.RightWidth}}%
+
{{t "Left"}} {{$t.LeftWidth}}%
+
{{t "Main content"}} {{$t.MainWidth}}%
+
{{t "Right"}} {{$t.RightWidth}}%
-
Footer{{if not $t.FooterOn}} (hidden){{end}}
+
{{t "Footer"}}{{if not $t.FooterOn}} ({{t "hidden"}}){{end}}
- - + +
-

Between 15% and 40% each; the main content takes the rest. A column only appears when it has at least one module; without columns the posts take the whole width. On phones the columns stack: main content first, then the left and right columns, then the footer.

- -

+

{{t "Between 15% and 40% each; the main content takes the rest. A column only appears when it has at least one module; without columns the posts take the whole width. On phones the columns stack: main content first, then the left and right columns, then the footer."}}

+ +

-{{template "area" (dict "area" "header" "root" . "hint" "The top of every page. Stack the title, a logo and the menu in the order you like. A banner image can be chosen on the Design tab.")}} -{{template "area" (dict "area" "left" "root" . "hint" "A narrow column beside your posts. Good for the menu, an archive or recent posts.")}} +{{template "area" (dict "area" "header" "root" . "hint" (t "The top of every page. Stack the title, a logo and the menu in the order you like. A banner image can be chosen on the Design tab."))}} +{{template "area" (dict "area" "left" "root" . "hint" (t "A narrow column beside your posts. Good for the menu, an archive or recent posts."))}}
-

Main content

-

Your posts and pages always show here. You can add your own HTML before and after them — an intro box, a newsletter form, a badge…

+

{{t "Main content"}}

+

{{t "Your posts and pages always show here. You can add your own HTML before and after them — an intro box, a newsletter form, a badge…"}}

{{range $area := .Data.areaOrder}}{{$mods := index $.Data.areas $area}} - + {{if $mods}}{{$m := index $mods 0}} - - + + {{else}} - - + + {{end}} {{end}}
{{index $.Data.areaNames $area}}
{{t (index $.Data.areaNames $area)}}{{index $.Data.names $m.Kind}}{{with modsummary $m}} — {{.}}{{end}}edit remove{{t (index $.Data.names $m.Kind)}}{{with modsummary $m}} — {{.}}{{end}}{{t "edit"}} {{t "remove"}}Nothing extra.
{{t "Nothing extra."}}
-{{template "area" (dict "area" "right" "root" . "hint" "A narrow column on the other side of your posts. Same choices as the left one.")}} -{{template "area" (dict "area" "footer" "root" . "hint" "The bottom of every page: a copyright line, the RSS link, a list of your pages.")}} +{{template "area" (dict "area" "right" "root" . "hint" (t "A narrow column on the other side of your posts. Same choices as the left one."))}} +{{template "area" (dict "area" "footer" "root" . "hint" (t "The bottom of every page: a copyright line, the RSS link, a list of your pages."))}} @@ -90,24 +90,24 @@ {{define "area"}}{{$r := .root}}{{$area := .area}}{{$u := print "/b/" $r.Blog.Subdomain "/layout"}}{{$on := $r.Data.theme.AreaOn $area}}{{$mods := index $r.Data.areas $area}}
-

{{index $r.Data.areaNames $area}}{{if not $on}} hidden{{end}}

-
+

{{t (index $r.Data.areaNames $area)}}{{if not $on}} {{t "hidden"}}{{end}}

+

{{.hint}}

{{if $mods}} {{range $mods}} - - + + {{end}} -
-
-
+
+
{{index $r.Data.names .Kind}}{{with modsummary .}} — {{.}}{{end}} - {{if and (eq .Kind "logo") (not $r.Data.theme.Logo)}}no logo chosen pick one on the Design tab{{end}}{{if modsettings .Kind}}edit {{end}}remove{{t (index $r.Data.names .Kind)}}{{with modsummary .}} — {{.}}{{end}} + {{if and (eq .Kind "logo") (not $r.Data.theme.Logo)}}{{t "no logo chosen"}} {{t "pick one on the"}} {{t "Design"}} {{t "tab."}}{{end}}{{if modsettings .Kind}}{{t "edit"}} {{end}}{{t "remove"}}
{{else}}

Nothing here yet{{if ne $area "header"}} — this area is not shown until you add a module{{end}}.

{{end}} + {{else}}

{{if ne $area "header"}}{{t "Nothing here yet — this area is not shown until you add a module."}}{{else}}{{t "Nothing here yet."}}{{end}}

{{end}}
- - + +
{{end}} diff --git a/internal/web/templates/dashboard/module_form.html b/internal/web/templates/dashboard/module_form.html index b63b405..0cd27fa 100644 --- a/internal/web/templates/dashboard/module_form.html +++ b/internal/web/templates/dashboard/module_form.html @@ -2,32 +2,32 @@ {{define "content"}} {{$m := .Data.module}}
-

{{.Data.name}} in the {{lower .Data.area}}

+

{{.Data.name}} {{tf "in the %s" (lower .Data.area)}}

{{if or (eq $m.Kind "recent") (eq $m.Kind "archive") (eq $m.Kind "sitemap") (and (eq $m.Kind "html") (or (eq $m.Area "left") (eq $m.Area "right") (eq $m.Area "footer")))}} - + {{end}} {{if eq $m.Kind "recent"}} -
-

The newest published posts from every page of your blog, newest first.

+
+

{{t "The newest published posts from every page of your blog, newest first."}}

{{else if eq $m.Kind "archive"}} -

Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open.

+

{{t "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open."}}

{{else if eq $m.Kind "sitemap"}} -

Links to every page of your blog.

+

{{t "Links to every page of your blog."}}

{{else if eq $m.Kind "html"}} - -

Put into your page exactly as written — embeds, badges and scripts all work. Anything broken here (an unclosed tag, say) can break the page around it, so check your blog after saving. 20 KB at most.

+ +

{{t "Put into your page exactly as written — embeds, badges and scripts all work. Anything broken here (an unclosed tag, say) can break the page around it, so check your blog after saving. 20 KB at most."}}

{{else if eq $m.Kind "text"}} - - -

Plain text; each line becomes its own paragraph.

+ + +

{{t "Plain text; each line becomes its own paragraph."}}

{{end}}

- - View blog ↗ - Back to layout + + {{t "View blog"}} ↗ + {{t "Back to layout"}}

diff --git a/internal/web/templates/dashboard/overview.html b/internal/web/templates/dashboard/overview.html index acd539d..e021495 100644 --- a/internal/web/templates/dashboard/overview.html +++ b/internal/web/templates/dashboard/overview.html @@ -1,35 +1,35 @@ {{define "title"}}{{.Blog.Title}} · Blogspace{{end}} {{define "content"}}

{{.Blog.Title}}

-

Your blog is live at {{.BlogURL}}. Every change you save here shows up there as soon as you refresh.

+

{{t "Your blog is live at"}} {{.BlogURL}}. {{t "Every change you save here shows up there as soon as you refresh."}}

-

Pages

+

{{t "Pages"}}

    - {{range .Data.pages}}
  • {{.Title}} ({{.PostCount}} posts{{if .IsHome}}, home{{end}}{{if not .ShowInNav}}, hidden from menu{{end}})
  • {{end}} + {{range .Data.pages}}
  • {{.Title}} ({{tf "%d posts" .PostCount}}{{if .IsHome}}, {{t "home"}}{{end}}{{if not .ShowInNav}}, {{t "hidden from menu"}}{{end}})
  • {{end}}
-

Latest posts

+

{{t "Latest posts"}}

{{if .Data.posts}} - + {{range .Data.posts}} - + - + {{end}}
TitlePageDate
{{t "Title"}}{{t "Page"}}{{t "Date"}}
{{.Title}}{{if not .Published}} hidden{{end}}{{.Title}}{{if not .Published}} {{t "hidden"}}{{end}} {{.PageTitle}} {{date .CreatedAt}}view ↗{{t "view"}} ↗
-

All posts

- {{else}}

No posts yet. Write the first one.

{{end}} +

{{t "All posts"}}

+ {{else}}

{{t "No posts yet."}} {{t "Write the first one."}}

{{end}}
{{end}} diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html index 2692449..08c4a43 100644 --- a/internal/web/templates/dashboard/page_form.html +++ b/internal/web/templates/dashboard/page_form.html @@ -1,19 +1,19 @@ -{{define "title"}}{{if .Data.page.ID}}Edit page{{else}}New page{{end}} · {{.Blog.Title}}{{end}} +{{define "title"}}{{if .Data.page.ID}}{{t "Edit page"}}{{else}}{{t "New page"}}{{end}} · {{.Blog.Title}}{{end}} {{define "content"}}
-

{{if .Data.page.ID}}Edit page{{else}}New page{{end}}

+

{{if .Data.page.ID}}{{t "Edit page"}}{{else}}{{t "New page"}}{{end}}

- -
diff --git a/internal/web/templates/dashboard/pages.html b/internal/web/templates/dashboard/pages.html index eeb09c5..cb40eb4 100644 --- a/internal/web/templates/dashboard/pages.html +++ b/internal/web/templates/dashboard/pages.html @@ -1,25 +1,25 @@ -{{define "title"}}Pages · {{.Blog.Title}}{{end}} +{{define "title"}}{{t "Pages"}} · {{.Blog.Title}}{{end}} {{define "content"}}
-

Pages

- -

Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the Layout tab.

+

{{t "Pages"}}

+ +

{{t "Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the"}} {{t "Layout"}} {{t "tab."}}

- + {{range .Data.pages}} + {{if .IsHome}}{{t "home"}}{{end}}{{if not .ShowInNav}}{{t "not in menu"}}{{end}}{{end}}
OrderTitleAddressPosts
{{t "Order"}}{{t "Title"}}{{t "Address"}}{{t "Posts"}}
-
-
+
+
{{.Title}} - {{if .IsHome}}home{{end}}{{if not .ShowInNav}}not in menu{{end}} {{if .IsHome}}/{{else}}/{{.Slug}}{{end}} ↗ {{.PostCount}} - {{if not .IsHome}}
- delete{{end}} + {{if not .IsHome}}
+ {{t "delete"}}{{end}}
diff --git a/internal/web/templates/dashboard/password.html b/internal/web/templates/dashboard/password.html index 3e28744..2150a40 100644 --- a/internal/web/templates/dashboard/password.html +++ b/internal/web/templates/dashboard/password.html @@ -1,13 +1,13 @@ -{{define "title"}}Change password · Blogspace{{end}} +{{define "title"}}{{t "Change password"}} · Blogspace{{end}} {{define "content"}}
-

Change password

+

{{t "Change password"}}

- - - -

+ + + +

{{end}} diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html index 8b4a593..af48215 100644 --- a/internal/web/templates/dashboard/post_form.html +++ b/internal/web/templates/dashboard/post_form.html @@ -1,24 +1,24 @@ -{{define "title"}}{{if .Data.post.ID}}Edit post{{else}}New post{{end}} · {{.Blog.Title}}{{end}} +{{define "title"}}{{if .Data.post.ID}}{{t "Edit post"}}{{else}}{{t "New post"}}{{end}} · {{.Blog.Title}}{{end}} {{define "content"}}
-

{{if .Data.post.ID}}Edit post{{else}}New post{{end}}

+

{{if .Data.post.ID}}{{t "Edit post"}}{{else}}{{t "New post"}}{{end}}

- +
-
- + {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} - +

- - {{if .Data.post.ID}}View post ↗{{end}} - Back to posts + + {{if .Data.post.ID}}{{t "View post"}} ↗{{end}} + {{t "Back to posts"}}

diff --git a/internal/web/templates/dashboard/posts.html b/internal/web/templates/dashboard/posts.html index c0b7112..3a99a31 100644 --- a/internal/web/templates/dashboard/posts.html +++ b/internal/web/templates/dashboard/posts.html @@ -1,28 +1,28 @@ -{{define "title"}}Posts · {{.Blog.Title}}{{end}} +{{define "title"}}{{t "Posts"}} · {{.Blog.Title}}{{end}} {{define "content"}}
-

Posts

- +

{{t "Posts"}}

+
-
{{- else if eq $m.Kind "archive"}}
{{template "modtitle" $m}} {{range $i, $y := $r.Data.archive}}{{$y.Year}} ({{$y.Count}}) - {{range $y.Months}}
{{.Name}} ({{len .Posts}}) + {{range $y.Months}}
{{month .Month}} ({{len .Posts}})
{{end}} -
{{else}}

No posts yet.

{{end}} + {{else}}

{{t "No posts yet."}}

{{end}}
{{- else if eq $m.Kind "html"}}{{if not $m.Body}}{{else if or (eq $m.Area "above") (eq $m.Area "below")}}
{{html $m.Body}}
{{else}}
{{template "modtitle" $m}}{{html $m.Body}}
{{end}} -{{- else if eq $m.Kind "rss"}}

RSS feed

+{{- else if eq $m.Kind "rss"}}

{{t "RSS feed"}}

{{- else if eq $m.Kind "text"}}{{if $m.Body}}
{{range split $m.Body "\n"}}{{if .}}

{{.}}

{{end}}{{end}}
{{end}} {{- else if eq $m.Kind "sitemap"}}
{{template "modtitle" $m}} diff --git a/internal/web/templates/layouts/dashboard.html b/internal/web/templates/layouts/dashboard.html index e724cc6..6e75863 100644 --- a/internal/web/templates/layouts/dashboard.html +++ b/internal/web/templates/layouts/dashboard.html @@ -1,5 +1,5 @@ {{define "layout"}} - + diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html index 0966f7b..65b0ed5 100644 --- a/internal/web/templates/partials/dashnav.html +++ b/internal/web/templates/partials/dashnav.html @@ -2,11 +2,11 @@
Blogspace {{if .User}} - {{.User.Username}}{{if .User.IsSuperadmin}} (superadmin){{end}} + {{.User.Username}}{{if .User.IsSuperadmin}} ({{t "superadmin"}}){{end}} - {{if .User.IsSuperadmin}}Admin{{end}} - Password -
+ {{if .User.IsSuperadmin}}{{t "Admin"}}{{end}} + {{t "Password"}} +
{{end}}
@@ -17,16 +17,16 @@
{{end}}{{end}} diff --git a/internal/web/templates/partials/editor.html b/internal/web/templates/partials/editor.html index 8e0acaf..eb2e315 100644 --- a/internal/web/templates/partials/editor.html +++ b/internal/web/templates/partials/editor.html @@ -1,9 +1,9 @@ {{define "editor"}}
- + - …or paste / drop an image into the text. - + {{t "…or paste / drop an image into the text."}} +
{{template "mdhelp"}}