diff options
Diffstat (limited to 'internal/web')
40 files changed, 647 insertions, 497 deletions
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 <html lang> 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, "<pre>template error: %v</pre>", 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"}}<p>{{.}}</p>{{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"}} <div class="card narrow"> - <h1>Delete {{.Data.target.Username}}?</h1> - <p>This permanently deletes the user <strong>{{.Data.target.Username}}</strong>, their blog, and every page, post and image in it.</p> + <h1>{{tf "Delete %s?" .Data.target.Username}}</h1> + <p>{{t "This permanently deletes the user"}} <strong>{{.Data.target.Username}}</strong>{{t ", their blog, and every page, post and image in it."}}</p> <form method="post" action="/admin/users/{{.Data.target.ID}}/delete"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <p class="actionrow"><button type="submit" class="danger">Yes, delete everything</button> - <a class="btn secondary" href="/admin/">Cancel</a></p> + <p class="actionrow"><button type="submit" class="danger">{{t "Yes, delete everything"}}</button> + <a class="btn secondary" href="/admin/">{{t "Cancel"}}</a></p> </form> </div> {{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"}} <div class="pagehead"> - <h1>Users & blogs</h1> - <div class="actions"><a class="btn" href="/admin/users/new">+ New blogger</a></div> + <h1>{{t "Users & blogs"}}</h1> + <div class="actions"><a class="btn" href="/admin/users/new">+ {{t "New blogger"}}</a></div> </div> <div class="card"> <table> - <tr><th>User</th><th>Role</th><th>Blog</th><th>Since</th><th>Actions</th></tr> + <tr><th>{{t "User"}}</th><th>{{t "Role"}}</th><th>{{t "Blog"}}</th><th>{{t "Since"}}</th><th>{{t "Actions"}}</th></tr> {{range .Data.users}}<tr{{if .Disabled}} class="disabled"{{end}}> - <td>{{.Username}}{{if .Disabled}} <span class="tag">disabled</span>{{end}}</td> - <td>{{.Role}}</td> - <td>{{if .Subdomain}}<a href="/b/{{deref .Subdomain}}/">{{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}}</a> <a href="{{$.Data.cfg.BlogURL (deref .Subdomain)}}" target="_blank" class="muted">view ↗</a>{{else}}<span class="muted">—</span>{{end}}</td> + <td>{{.Username}}{{if .Disabled}} <span class="tag">{{t "disabled"}}</span>{{end}}</td> + <td>{{t .Role}}</td> + <td>{{if .Subdomain}}<a href="/b/{{deref .Subdomain}}/">{{if eq (deref .Subdomain) "www"}}{{$.Data.cfg.BaseDomain}}{{else}}{{deref .Subdomain}}{{end}}</a> <a href="{{$.Data.cfg.BlogURL (deref .Subdomain)}}" target="_blank" class="muted">{{t "view"}} ↗</a>{{else}}<span class="muted">—</span>{{end}}</td> <td class="nowrap">{{date .CreatedAt}}</td> <td class="nowrap"> {{if ne .ID $.User.ID}} - <details class="inline"><summary class="mini">reset password</summary> + <details class="inline"><summary class="mini">{{t "reset password"}}</summary> <form method="post" action="/admin/users/{{.ID}}/reset-password" class="inline"> <input type="hidden" name="_csrf" value="{{$.CSRF}}"> - <input type="password" name="password" placeholder="new password" minlength="8" required size="14"> - <button class="mini">Set</button> + <input type="password" name="password" placeholder="{{t "new password"}}" minlength="8" required size="14"> + <button class="mini">{{t "Set"}}</button> </form></details> - {{if .Disabled}}<form method="post" action="/admin/users/{{.ID}}/enable" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">enable</button></form> - {{else}}<form method="post" action="/admin/users/{{.ID}}/disable" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">disable</button></form>{{end}} - <a class="danger" href="/admin/users/{{.ID}}/delete">delete</a> - {{else}}<span class="muted">(you)</span>{{end}} + {{if .Disabled}}<form method="post" action="/admin/users/{{.ID}}/enable" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{t "enable"}}</button></form> + {{else}}<form method="post" action="/admin/users/{{.ID}}/disable" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{t "disable"}}</button></form>{{end}} + <a class="danger" href="/admin/users/{{.ID}}/delete">{{t "delete"}}</a> + {{else}}<span class="muted">({{t "you"}})</span>{{end}} </td> </tr>{{end}} </table> 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"}} <div class="card narrow"> - <h1>New blogger</h1> + <h1>{{t "New blogger"}}</h1> <form method="post" action="/admin/users/new"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <label>Username<br><input name="username" value="{{.Data.username}}" required pattern="[a-zA-Z0-9_.-]{2,40}" autofocus></label> - <label>Password <span class="muted">(min 8 characters; they can change it later)</span><br><input type="password" name="password" required minlength="8"></label> - <label>Subdomain <span class="muted">(defaults to the username)</span><br> + <label>{{t "Username"}}<br><input name="username" value="{{.Data.username}}" required pattern="[a-zA-Z0-9_.-]{2,40}" autofocus></label> + <label>{{t "Password"}} <span class="muted">({{t "min 8 characters; they can change it later"}})</span><br><input type="password" name="password" required minlength="8"></label> + <label>{{t "Subdomain"}} <span class="muted">({{t "defaults to the username"}})</span><br> <span class="nowrap"><input name="subdomain" value="{{.Data.subdomain}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="63" size="14" style="width:auto">.{{.Data.cfg.BaseDomain}}</span></label> - <label>Blog title<br><input name="title" value="{{.Data.title}}" maxlength="120"></label> - <p class="actionrow"><button type="submit">Create</button> <a href="/admin/">Cancel</a></p> + <label>{{t "Blog title"}}<br><input name="title" value="{{.Data.title}}" maxlength="120"></label> + <p class="actionrow"><button type="submit">{{t "Create"}}</button> <a href="/admin/">{{t "Cancel"}}</a></p> </form> </div> {{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"}} <p class="login-brand">Blogspace</p> <div class="card narrow center"> - <h1>Log in</h1> + <h1>{{t "Log in"}}</h1> <form method="post" action="/webadmin"> <input type="hidden" name="next" value="{{.Data.next}}"> - <label>Username<br><input name="username" value="{{.Data.username}}" required autofocus autocomplete="username"></label> - <label>Password<br><input type="password" name="password" required autocomplete="current-password"></label> - <p class="actionrow"><button type="submit">Log in</button></p> + <label>{{t "Username"}}<br><input name="username" value="{{.Data.username}}" required autofocus autocomplete="username"></label> + <label>{{t "Password"}}<br><input type="password" name="password" required autocomplete="current-password"></label> + <p class="actionrow"><button type="submit">{{t "Log in"}}</button></p> </form> </div> {{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"}} -<h1>Not found</h1> -<p>There is nothing at this address. <a href="/">Back to the front page.</a></p> +<h1>{{t "Not found"}}</h1> +<p>{{t "There is nothing at this address."}} <a href="/">{{t "Back to the front page."}}</a></p> {{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}} <article class="post"> <h2 class="post-title"><a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a></h2> - {{if $.Data.theme.ShowDates}}<p class="post-date">{{$.Data.theme.FormatDate .CreatedAt}}</p>{{end}} + {{if $.Data.theme.ShowDates}}<p class="post-date">{{postdate $.Data.theme.DateFormat .CreatedAt}}</p>{{end}} <div class="post-body">{{html .BodyHTML}}</div> </article> -{{else}}{{if not .Data.page.IntroHTML}}<p class="muted">Nothing here yet.</p>{{end}}{{end}} +{{else}}{{if not .Data.page.IntroHTML}}<p class="muted">{{t "Nothing here yet."}}</p>{{end}}{{end}} {{if gt .Data.lastPage 1}} <div class="pager"> - {{if gt .Data.pageNum 1}}<a href="{{.Data.base}}/?p={{sub .Data.pageNum 1}}">← Newer</a>{{end}} - <span>Page {{.Data.pageNum}} of {{.Data.lastPage}}</span> - {{if lt .Data.pageNum .Data.lastPage}}<a href="{{.Data.base}}/?p={{add .Data.pageNum 1}}">Older →</a>{{end}} + {{if gt .Data.pageNum 1}}<a href="{{.Data.base}}/?p={{sub .Data.pageNum 1}}">← {{t "Newer"}}</a>{{end}} + <span>{{tf "Page %d of %d" .Data.pageNum .Data.lastPage}}</span> + {{if lt .Data.pageNum .Data.lastPage}}<a href="{{.Data.base}}/?p={{add .Data.pageNum 1}}">{{t "Older"}} →</a>{{end}} </div> {{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"}} <article class="post single"> <h1 class="post-title">{{.Data.post.Title}}</h1> - <p class="post-date">{{if .Data.theme.ShowDates}}{{.Data.theme.FormatDate .Data.post.CreatedAt}} · {{end}}<a href="{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}">{{.Data.page.Title}}</a></p> + <p class="post-date">{{if .Data.theme.ShowDates}}{{postdate .Data.theme.DateFormat .Data.post.CreatedAt}} · {{end}}<a href="{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}">{{.Data.page.Title}}</a></p> <div class="post-body">{{html .Data.post.BodyHTML}}</div> </article> {{end}} diff --git a/internal/web/templates/dashboard/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"}} <div class="card narrow"> - <h1>Are you sure?</h1> + <h1>{{t "Are you sure?"}}</h1> {{if .Data.isHome}} - <p>This is the home page; it cannot be deleted. Make another page the home page first.</p> - <p><a class="btn secondary" href="{{.Data.back}}">Back</a></p> + <p>{{t "This is the home page; it cannot be deleted. Make another page the home page first."}}</p> + <p><a class="btn secondary" href="{{.Data.back}}">{{t "Back"}}</a></p> {{else}} - <p>You are about to permanently delete {{.Data.what}}. This cannot be undone.</p> + <p>{{tf "You are about to permanently delete %s. This cannot be undone." .Data.what}}</p> <form method="post" action="{{.Data.action}}"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <p class="actionrow"><button type="submit" class="danger">Yes, delete</button> - <a class="btn secondary" href="{{.Data.back}}">Cancel</a></p> + <p class="actionrow"><button type="submit" class="danger">{{t "Yes, delete"}}</button> + <a class="btn secondary" href="{{.Data.back}}">{{t "Cancel"}}</a></p> </form> {{end}} </div> 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}} <div class="pagehead"> - <h1>Design</h1> - <p class="lead muted">Changes are live as soon as you save; refresh <a href="{{.BlogURL}}" target="_blank">your blog ↗</a> to see them.</p> + <h1>{{t "Design"}}</h1> + <p class="lead muted">{{t "Changes are live as soon as you save; refresh"}} <a href="{{.BlogURL}}" target="_blank">{{t "your blog"}} ↗</a> {{t "to see them."}}</p> </div> -<p class="jumps"><a href="#schemes">Schemes</a><a href="#background">Background</a><a href="#text">Text</a><a href="#header">Header</a><a href="#menu">Menu</a><a href="#posts">Posts</a><a href="#footer">Footer</a><a href="#logo">Logo & icon</a></p> -<p class="muted small">What appears where — header, columns, footer and their modules — is arranged on the <a href="/b/{{.Blog.Subdomain}}/layout">Layout</a> tab.</p> +<p class="jumps"><a href="#schemes">{{t "Schemes"}}</a><a href="#background">{{t "Background"}}</a><a href="#text">{{t "Text"}}</a><a href="#header">{{t "Header"}}</a><a href="#menu">{{t "Menu"}}</a><a href="#posts">{{t "Posts"}}</a><a href="#footer">{{t "Footer"}}</a><a href="#logo">{{t "Logo & icon"}}</a></p> +<p class="muted small">{{t "What appears where — header, columns, footer and their modules — is arranged on the"}} <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Layout"}}</a> {{t "tab."}}</p> <div class="card" id="schemes"> - <h2>Colour schemes</h2> - <p class="muted small">A quick start: applying a scheme sets every colour below and saves immediately. Images, fonts and layout are kept.</p> + <h2>{{t "Colour schemes"}}</h2> + <p class="muted small">{{t "A quick start: applying a scheme sets every colour below and saves immediately. Images, fonts and layout are kept."}}</p> <form method="post" action="/b/{{.Blog.Subdomain}}/design/preset" class="presets"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - {{range .Data.presets}}<button type="submit" name="preset" value="{{.Key}}" class="preset" title="Apply {{.Name}}"> + {{range .Data.presets}}<button type="submit" name="preset" value="{{.Key}}" class="preset" title="{{tf "Apply %s" (t .Name)}}"> <span class="sw" style="background:{{.HeaderBg}}"></span><span class="sw" style="background:{{.Bg}}"></span><span class="sw" style="background:{{.ContentBg}}"></span><span class="sw" style="background:{{.Link}}"></span> - <span class="pname">{{.Name}}</span> + <span class="pname">{{t .Name}}</span> </button> {{end}} </form> @@ -25,158 +25,158 @@ <input type="hidden" name="_csrf" value="{{.CSRF}}"> <div class="card" id="background"> - <h2>Background</h2> + <h2>{{t "Background"}}</h2> <div class="row"> - <label>Colour<br><input type="color" name="bg_color" value="{{$t.BgColor}}"></label> - <label>Image style<br><select name="bg_mode"> - <option value="cover"{{if eq $t.BgMode "cover"}} selected{{end}}>Stretch to fill</option> - <option value="fixed"{{if eq $t.BgMode "fixed"}} selected{{end}}>Fill, fixed while scrolling</option> - <option value="tile"{{if eq $t.BgMode "tile"}} selected{{end}}>Repeat (tile)</option> + <label>{{t "Colour"}}<br><input type="color" name="bg_color" value="{{$t.BgColor}}"></label> + <label>{{t "Image style"}}<br><select name="bg_mode"> + <option value="cover"{{if eq $t.BgMode "cover"}} selected{{end}}>{{t "Stretch to fill"}}</option> + <option value="fixed"{{if eq $t.BgMode "fixed"}} selected{{end}}>{{t "Fill, fixed while scrolling"}}</option> + <option value="tile"{{if eq $t.BgMode "tile"}} selected{{end}}>{{t "Repeat (tile)"}}</option> </select></label> </div> - {{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"))}} </div> <div class="card" id="text"> - <h2>Text & content area</h2> + <h2>{{t "Text & content area"}}</h2> <div class="row"> - <label>Content background<br><input type="color" name="content_bg" value="{{$t.ContentBg}}"></label> - <label>Text colour<br><input type="color" name="text_color" value="{{$t.TextColor}}"></label> - <label>Link colour<br><input type="color" name="link_color" value="{{$t.LinkColor}}"></label> + <label>{{t "Content background"}}<br><input type="color" name="content_bg" value="{{$t.ContentBg}}"></label> + <label>{{t "Text colour"}}<br><input type="color" name="text_color" value="{{$t.TextColor}}"></label> + <label>{{t "Link colour"}}<br><input type="color" name="link_color" value="{{$t.LinkColor}}"></label> </div> <div class="hoverpick"> <input type="checkbox" name="link_hover_custom" id="link-hover-custom"{{if $t.LinkHover}} checked{{end}}> - <label for="link-hover-custom" class="check">Change colour when the mouse is over a link</label> - <label class="hover-color">Hover colour<br><input type="color" name="link_hover" value="{{if $t.LinkHover}}{{$t.LinkHover}}{{else}}{{$t.LinkColor}}{{end}}"></label> + <label for="link-hover-custom" class="check">{{t "Change colour when the mouse is over a link"}}</label> + <label class="hover-color">{{t "Hover colour"}}<br><input type="color" name="link_hover" value="{{if $t.LinkHover}}{{$t.LinkHover}}{{else}}{{$t.LinkColor}}{{end}}"></label> </div> <div class="row"> - <label>Font<br><select name="font"> + <label>{{t "Font"}}<br><select name="font"> <option value="sans"{{if eq $t.Font "sans"}} selected{{end}}>Sans-serif (Helvetica/Arial)</option> <option value="serif"{{if eq $t.Font "serif"}} selected{{end}}>Serif (Georgia/Times)</option> <option value="mono"{{if eq $t.Font "mono"}} selected{{end}}>Monospace (Courier)</option> </select></label> - <label>Heading font<br><select name="heading_font"> - <option value="same"{{if eq $t.HeadingFont "same"}} selected{{end}}>Same as text</option> + <label>{{t "Heading font"}}<br><select name="heading_font"> + <option value="same"{{if eq $t.HeadingFont "same"}} selected{{end}}>{{t "Same as text"}}</option> <option value="sans"{{if eq $t.HeadingFont "sans"}} selected{{end}}>Sans-serif</option> <option value="serif"{{if eq $t.HeadingFont "serif"}} selected{{end}}>Serif</option> <option value="mono"{{if eq $t.HeadingFont "mono"}} selected{{end}}>Monospace</option> </select></label> - <label>Text size<br><select name="font_size"> - <option value="small"{{if eq $t.FontSize "small"}} selected{{end}}>Small</option> - <option value="normal"{{if eq $t.FontSize "normal"}} selected{{end}}>Normal</option> - <option value="large"{{if eq $t.FontSize "large"}} selected{{end}}>Large</option> + <label>{{t "Text size"}}<br><select name="font_size"> + <option value="small"{{if eq $t.FontSize "small"}} selected{{end}}>{{t "Small"}}</option> + <option value="normal"{{if eq $t.FontSize "normal"}} selected{{end}}>{{t "Normal"}}</option> + <option value="large"{{if eq $t.FontSize "large"}} selected{{end}}>{{t "Large"}}</option> </select></label> - <label>Underline links<br><select name="link_underline"> - <option value="always"{{if eq $t.LinkUnderline "always"}} selected{{end}}>Always</option> - <option value="hover"{{if eq $t.LinkUnderline "hover"}} selected{{end}}>Only on hover</option> - <option value="never"{{if eq $t.LinkUnderline "never"}} selected{{end}}>Never</option> + <label>{{t "Underline links"}}<br><select name="link_underline"> + <option value="always"{{if eq $t.LinkUnderline "always"}} selected{{end}}>{{t "Always"}}</option> + <option value="hover"{{if eq $t.LinkUnderline "hover"}} selected{{end}}>{{t "Only on hover"}}</option> + <option value="never"{{if eq $t.LinkUnderline "never"}} selected{{end}}>{{t "Never"}}</option> </select></label> </div> <div class="row"> - <label>Box style<br><select name="content_style"> - <option value="card"{{if eq $t.ContentStyle "card"}} selected{{end}}>Card (shadow, rounded corners)</option> - <option value="flat"{{if eq $t.ContentStyle "flat"}} selected{{end}}>Flat</option> + <label>{{t "Box style"}}<br><select name="content_style"> + <option value="card"{{if eq $t.ContentStyle "card"}} selected{{end}}>{{t "Card (shadow, rounded corners)"}}</option> + <option value="flat"{{if eq $t.ContentStyle "flat"}} selected{{end}}>{{t "Flat"}}</option> </select></label> - <label>Padding <span class="muted">(inside the box)</span><br><select name="content_padding"> - <option value="compact"{{if eq $t.ContentPadding "compact"}} selected{{end}}>Compact</option> - <option value="normal"{{if eq $t.ContentPadding "normal"}} selected{{end}}>Normal</option> - <option value="roomy"{{if eq $t.ContentPadding "roomy"}} selected{{end}}>Roomy</option> + <label>{{t "Padding"}} <span class="muted">({{t "inside the box"}})</span><br><select name="content_padding"> + <option value="compact"{{if eq $t.ContentPadding "compact"}} selected{{end}}>{{t "Compact"}}</option> + <option value="normal"{{if eq $t.ContentPadding "normal"}} selected{{end}}>{{t "Normal"}}</option> + <option value="roomy"{{if eq $t.ContentPadding "roomy"}} selected{{end}}>{{t "Roomy"}}</option> </select></label> </div> - <p class="muted small">The content spans the whole page; how much of it the posts get is set by the column widths on the <a href="/b/{{.Blog.Subdomain}}/layout#columns">Layout</a> tab.</p> + <p class="muted small">{{t "The content spans the whole page; how much of it the posts get is set by the column widths on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#columns">{{t "Layout"}}</a> {{t "tab."}}</p> </div> <div class="card" id="header"> - <h2>Header</h2> + <h2>{{t "Header"}}</h2> <div class="row"> - <label>Background<br><input type="color" name="header_bg" value="{{$t.HeaderBg}}"></label> - <label>Text colour<br><input type="color" name="header_text" value="{{$t.HeaderText}}"></label> - <label>Alignment<br><select name="header_align"> - <option value="left"{{if eq $t.HeaderAlign "left"}} selected{{end}}>Left</option> - <option value="center"{{if eq $t.HeaderAlign "center"}} selected{{end}}>Centred</option> + <label>{{t "Background"}}<br><input type="color" name="header_bg" value="{{$t.HeaderBg}}"></label> + <label>{{t "Text colour"}}<br><input type="color" name="header_text" value="{{$t.HeaderText}}"></label> + <label>{{t "Alignment"}}<br><select name="header_align"> + <option value="left"{{if eq $t.HeaderAlign "left"}} selected{{end}}>{{t "Left"}}</option> + <option value="center"{{if eq $t.HeaderAlign "center"}} selected{{end}}>{{t "Centred"}}</option> </select></label> - <label>Image height<br><select name="header_image_height"> - <option value="short"{{if eq $t.HeaderImageHeight "short"}} selected{{end}}>Short</option> - <option value="medium"{{if eq $t.HeaderImageHeight "medium"}} selected{{end}}>Medium</option> - <option value="tall"{{if eq $t.HeaderImageHeight "tall"}} selected{{end}}>Tall</option> + <label>{{t "Image height"}}<br><select name="header_image_height"> + <option value="short"{{if eq $t.HeaderImageHeight "short"}} selected{{end}}>{{t "Short"}}</option> + <option value="medium"{{if eq $t.HeaderImageHeight "medium"}} selected{{end}}>{{t "Medium"}}</option> + <option value="tall"{{if eq $t.HeaderImageHeight "tall"}} selected{{end}}>{{t "Tall"}}</option> </select></label> </div> - <p class="muted small">The title, logo and menu are header modules — add, remove and order them on the <a href="/b/{{.Blog.Subdomain}}/layout#header">Layout</a> tab. The banner below is shown across the top of the header whenever one is chosen.</p> - {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "sub" .Blog.Subdomain "images" .Data.images "label" "Header image (banner)")}} + <p class="muted small">{{t "The title, logo and menu are header modules — add, remove and order them on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#header">{{t "Layout"}}</a> {{t "tab."}} {{t "The banner below is shown across the top of the header whenever one is chosen."}}</p> + {{template "imagepick" (dict "name" "header_image" "current" $t.HeaderImage "sub" .Blog.Subdomain "images" .Data.images "label" (t "Header image (banner)"))}} </div> <div class="card" id="menu"> - <h2>Menu</h2> - <p class="muted small">Which links the menu shows, and where it sits, is set on the <a href="/b/{{.Blog.Subdomain}}/layout#menu">Layout</a> tab. Here you choose how it looks.</p> + <h2>{{t "Menu"}}</h2> + <p class="muted small">{{t "Which links the menu shows, and where it sits, is set on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#menu">{{t "Layout"}}</a> {{t "tab."}} {{t "Here you choose how it looks."}}</p> <div class="row"> - <label>Alignment <span class="muted">(menu in the header)</span><br><select name="nav_align"> - <option value="left"{{if eq $t.NavAlign "left"}} selected{{end}}>Left</option> - <option value="center"{{if eq $t.NavAlign "center"}} selected{{end}}>Centred</option> - <option value="right"{{if eq $t.NavAlign "right"}} selected{{end}}>Right</option> + <label>{{t "Alignment"}} <span class="muted">({{t "menu in the header"}})</span><br><select name="nav_align"> + <option value="left"{{if eq $t.NavAlign "left"}} selected{{end}}>{{t "Left"}}</option> + <option value="center"{{if eq $t.NavAlign "center"}} selected{{end}}>{{t "Centred"}}</option> + <option value="right"{{if eq $t.NavAlign "right"}} selected{{end}}>{{t "Right"}}</option> </select></label> - <label>Link style<br><select name="nav_style"> - <option value="normal"{{if eq $t.NavStyle "normal"}} selected{{end}}>Normal</option> - <option value="uppercase"{{if eq $t.NavStyle "uppercase"}} selected{{end}}>Uppercase, spaced</option> + <label>{{t "Link style"}}<br><select name="nav_style"> + <option value="normal"{{if eq $t.NavStyle "normal"}} selected{{end}}>{{t "Normal"}}</option> + <option value="uppercase"{{if eq $t.NavStyle "uppercase"}} selected{{end}}>{{t "Uppercase, spaced"}}</option> </select></label> </div> - <h3>Menu colours</h3> + <h3>{{t "Menu colours"}}</h3> <div class="row"> - <label>Background<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label> - <label>Text<br><input type="color" name="nav_text" value="{{$t.NavText}}"></label> + <label>{{t "Background"}}<br><input type="color" name="nav_bg" value="{{$t.NavBg}}"></label> + <label>{{t "Text"}}<br><input type="color" name="nav_text" value="{{$t.NavText}}"></label> </div> <div class="hoverpick"> <input type="checkbox" name="nav_hover_custom" id="nav-hover-custom"{{if $t.NavHover}} checked{{end}}> - <label for="nav-hover-custom" class="check">Change colour when the mouse is over a link</label> - <label class="hover-color">Hover colour<br><input type="color" name="nav_hover" value="{{if $t.NavHover}}{{$t.NavHover}}{{else}}{{$t.NavText}}{{end}}"></label> + <label for="nav-hover-custom" class="check">{{t "Change colour when the mouse is over a link"}}</label> + <label class="hover-color">{{t "Hover colour"}}<br><input type="color" name="nav_hover" value="{{if $t.NavHover}}{{$t.NavHover}}{{else}}{{$t.NavText}}{{end}}"></label> </div> </div> <div class="card" id="posts"> - <h2>Posts & dates</h2> - <label class="check"><input type="checkbox" name="show_dates"{{if $t.ShowDates}} checked{{end}}> Show the date on posts</label> + <h2>{{t "Posts & dates"}}</h2> + <label class="check"><input type="checkbox" name="show_dates"{{if $t.ShowDates}} checked{{end}}> {{t "Show the date on posts"}}</label> <div class="row"> - <label>Date format<br><select name="date_format"> - <option value="long"{{if eq $t.DateFormat "long"}} selected{{end}}>12 September 2026</option> - <option value="short"{{if eq $t.DateFormat "short"}} selected{{end}}>12 Sep 2026</option> - <option value="iso"{{if eq $t.DateFormat "iso"}} selected{{end}}>2026-09-12</option> + <label>{{t "Date format"}}<br><select name="date_format"> + <option value="long"{{if eq $t.DateFormat "long"}} selected{{end}}>{{postdate "long" .Data.today}}</option> + <option value="short"{{if eq $t.DateFormat "short"}} selected{{end}}>{{postdate "short" .Data.today}}</option> + <option value="iso"{{if eq $t.DateFormat "iso"}} selected{{end}}>{{postdate "iso" .Data.today}}</option> </select></label> </div> </div> <div class="card" id="footer"> - <h2>Footer</h2> - <p class="muted small">The footer text, RSS link and other footer modules live on the <a href="/b/{{.Blog.Subdomain}}/layout#footer">Layout</a> tab.</p> + <h2>{{t "Footer"}}</h2> + <p class="muted small">{{t "The footer text, RSS link and other footer modules live on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#footer">{{t "Layout"}}</a> {{t "tab."}}</p> <div class="row"> - <label>Background<br><input type="color" name="footer_bg" value="{{$t.FooterBg}}"></label> - <label>Text colour<br><input type="color" name="footer_color" value="{{$t.FooterColor}}"></label> + <label>{{t "Background"}}<br><input type="color" name="footer_bg" value="{{$t.FooterBg}}"></label> + <label>{{t "Text colour"}}<br><input type="color" name="footer_color" value="{{$t.FooterColor}}"></label> </div> </div> <div class="card" id="logo"> - <h2>Logo & site icon</h2> + <h2>{{t "Logo & site icon"}}</h2> <div class="row"> - <label>Logo size<br><select name="logo_size"> - <option value="small"{{if eq $t.LogoSize "small"}} selected{{end}}>Small (48 px tall)</option> - <option value="medium"{{if eq $t.LogoSize "medium"}} selected{{end}}>Medium (80 px)</option> - <option value="large"{{if eq $t.LogoSize "large"}} selected{{end}}>Large (140 px)</option> + <label>{{t "Logo size"}}<br><select name="logo_size"> + <option value="small"{{if eq $t.LogoSize "small"}} selected{{end}}>{{t "Small (48 px tall)"}}</option> + <option value="medium"{{if eq $t.LogoSize "medium"}} selected{{end}}>{{t "Medium (80 px)"}}</option> + <option value="large"{{if eq $t.LogoSize "large"}} selected{{end}}>{{t "Large (140 px)"}}</option> </select></label> </div> - {{template "imagepick" (dict "name" "logo" "current" $t.Logo "sub" .Blog.Subdomain "images" .Data.images "label" "Logo" "hint" "Shown wherever you place a Logo module (Layout tab); it links to your front page.")}} - {{template "imagepick" (dict "name" "favicon" "current" $t.Favicon "sub" .Blog.Subdomain "images" .Data.images "label" "Favicon" "hint" "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon." "accept" "image/png,image/x-icon,image/gif,image/webp,image/jpeg,.ico")}} + {{template "imagepick" (dict "name" "logo" "current" $t.Logo "sub" .Blog.Subdomain "images" .Data.images "label" (t "Logo") "hint" (t "Shown wherever you place a Logo module (Layout tab); it links to your front page."))}} + {{template "imagepick" (dict "name" "favicon" "current" $t.Favicon "sub" .Blog.Subdomain "images" .Data.images "label" (t "Favicon") "hint" (t "The small icon browsers show in the tab. A square PNG or ICO of 32–64 px works best; pick None for the Blogspace icon.") "accept" "image/png,image/x-icon,image/gif,image/webp,image/jpeg,.ico")}} </div> <div class="savebar"> - <button type="submit">Save design</button> - <a class="btn secondary" href="{{.BlogURL}}" target="_blank">View blog ↗</a> + <button type="submit">{{t "Save design"}}</button> + <a class="btn secondary" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} ↗</a> </div> </form> <details class="help reset"> - <summary>Reset to defaults</summary> - <p>Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images (they stay in your library).</p> + <summary>{{t "Reset to defaults"}}</summary> + <p>{{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)."}}</p> <form method="post" action="/b/{{.Blog.Subdomain}}/design/reset"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <button type="submit" class="danger">Reset everything</button> + <button type="submit" class="danger">{{t "Reset everything"}}</button> </form> </details> {{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"}} <div class="pagehead"> - <h1>Images</h1> - <p class="lead muted">Images are stored with your blog. Put one in a post with <em>Insert image</em> in the editor, or copy its line from below.</p> + <h1>{{t "Images"}}</h1> + <p class="lead muted">{{t "Images are stored with your blog. Put one in a post with"}} <em>{{t "Insert image"}}</em> {{t "in the editor, or copy its line from below."}}</p> </div> <div class="card"> <form method="post" action="/b/{{.Blog.Subdomain}}/images/upload" enctype="multipart/form-data"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <label>Upload an image <span class="muted">(PNG, JPEG, GIF, WebP or ICO)</span><br><input type="file" name="file" accept="image/*" required></label> - <p class="actionrow"><button type="submit">Upload</button></p> + <label>{{t "Upload an image"}} <span class="muted">({{t "PNG, JPEG, GIF, WebP or ICO"}})</span><br><input type="file" name="file" accept="image/*" required></label> + <p class="actionrow"><button type="submit">{{t "Upload"}}</button></p> </form> </div> <div class="gallery"> @@ -17,8 +17,8 @@ <a href="/b/{{$.Blog.Subdomain}}/media/{{.ID}}" target="_blank"><img src="/b/{{$.Blog.Subdomain}}/media/{{.ID}}" alt="{{.Filename}}"></a> <div class="meta">{{.Filename}} <span class="muted">({{kb .Size}})</span></div> <input class="copy" readonly value="" onclick="this.select()"> - <form method="post" action="/b/{{$.Blog.Subdomain}}/images/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">delete</button></form> + <form method="post" action="/b/{{$.Blog.Subdomain}}/images/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">{{t "delete"}}</button></form> </div> -{{else}}<p class="muted">No images yet.</p>{{end}} +{{else}}<p class="muted">{{t "No images yet."}}</p>{{end}} </div> {{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"}} <div class="pagehead"> - <h1>Layout</h1> - <p class="lead muted">Decide what goes where on your blog. Every change here is saved straight away; refresh <a href="{{.BlogURL}}" target="_blank">your blog ↗</a> to see it. Colours and fonts are on the <a href="/b/{{.Blog.Subdomain}}/design">Design</a> tab.</p> + <h1>{{t "Layout"}}</h1> + <p class="lead muted">{{t "Decide what goes where on your blog. Every change here is saved straight away; refresh"}} <a href="{{.BlogURL}}" target="_blank">{{t "your blog"}} ↗</a> {{t "to see it."}} {{t "Colours and fonts are on the"}} <a href="/b/{{.Blog.Subdomain}}/design">{{t "Design"}}</a> {{t "tab."}}</p> </div> -<p class="jumps"><a href="#columns">Columns</a><a href="#header">Header</a><a href="#left">Left column</a><a href="#main">Main content</a><a href="#right">Right column</a><a href="#footer">Footer</a><a href="#menu">Menu</a></p> +<p class="jumps"><a href="#columns">{{t "Columns"}}</a><a href="#header">{{t "Header"}}</a><a href="#left">{{t "Left column"}}</a><a href="#main">{{t "Main content"}}</a><a href="#right">{{t "Right column"}}</a><a href="#footer">{{t "Footer"}}</a><a href="#menu">{{t "Menu"}}</a></p> <div class="card" id="columns"> - <h2>Columns</h2> + <h2>{{t "Columns"}}</h2> <div class="lay"> - <div class="lay-row lay-header{{if not $t.HeaderOn}} off{{end}}">Header{{if not $t.HeaderOn}} (hidden){{end}}</div> + <div class="lay-row lay-header{{if not $t.HeaderOn}} off{{end}}">{{t "Header"}}{{if not $t.HeaderOn}} ({{t "hidden"}}){{end}}</div> <div class="lay-cols"> - <div class="lay-col lay-side{{if not $t.LeftOn}} off{{end}}{{if not (index .Data.areas "left")}} empty{{end}}" style="width:{{$t.LeftWidth}}%">Left {{$t.LeftWidth}}%</div> - <div class="lay-col lay-main" style="width:{{$t.MainWidth}}%">Main content {{$t.MainWidth}}%</div> - <div class="lay-col lay-side{{if not $t.RightOn}} off{{end}}{{if not (index .Data.areas "right")}} empty{{end}}" style="width:{{$t.RightWidth}}%">Right {{$t.RightWidth}}%</div> + <div class="lay-col lay-side{{if not $t.LeftOn}} off{{end}}{{if not (index .Data.areas "left")}} empty{{end}}" style="width:{{$t.LeftWidth}}%">{{t "Left"}} {{$t.LeftWidth}}%</div> + <div class="lay-col lay-main" style="width:{{$t.MainWidth}}%">{{t "Main content"}} {{$t.MainWidth}}%</div> + <div class="lay-col lay-side{{if not $t.RightOn}} off{{end}}{{if not (index .Data.areas "right")}} empty{{end}}" style="width:{{$t.RightWidth}}%">{{t "Right"}} {{$t.RightWidth}}%</div> </div> - <div class="lay-row lay-footer{{if not $t.FooterOn}} off{{end}}">Footer{{if not $t.FooterOn}} (hidden){{end}}</div> + <div class="lay-row lay-footer{{if not $t.FooterOn}} off{{end}}">{{t "Footer"}}{{if not $t.FooterOn}} ({{t "hidden"}}){{end}}</div> </div> <form method="post" action="{{$u}}/widths"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> <div class="row"> - <label>Left column width<br><input type="number" name="left_width" value="{{$t.LeftWidth}}" min="15" max="40" step="5"> %</label> - <label>Right column width<br><input type="number" name="right_width" value="{{$t.RightWidth}}" min="15" max="40" step="5"> %</label> + <label>{{t "Left column width"}}<br><input type="number" name="left_width" value="{{$t.LeftWidth}}" min="15" max="40" step="5"> %</label> + <label>{{t "Right column width"}}<br><input type="number" name="right_width" value="{{$t.RightWidth}}" min="15" max="40" step="5"> %</label> </div> - <p class="muted small">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.</p> - <label class="check"><input type="checkbox" name="keep_columns"{{if $t.KeepColumns}} checked{{end}}> Always keep the columns' space, even when a column is empty or hidden <span class="muted">(the main content then keeps its width and stays centred)</span></label> - <p class="actionrow"><button type="submit">Save columns</button></p> + <p class="muted small">{{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."}}</p> + <label class="check"><input type="checkbox" name="keep_columns"{{if $t.KeepColumns}} checked{{end}}> {{t "Always keep the columns' space, even when a column is empty or hidden"}} <span class="muted">({{t "the main content then keeps its width and stays centred"}})</span></label> + <p class="actionrow"><button type="submit">{{t "Save columns"}}</button></p> </form> </div> -{{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."))}} <div class="card" id="main"> - <div class="cardhead"><h2>Main content</h2></div> - <p class="muted small">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…</p> + <div class="cardhead"><h2>{{t "Main content"}}</h2></div> + <p class="muted small">{{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…"}}</p> <table class="modules"> {{range $area := .Data.areaOrder}}{{$mods := index $.Data.areas $area}} - <tr><th>{{index $.Data.areaNames $area}}</th> + <tr><th>{{t (index $.Data.areaNames $area)}}</th> {{if $mods}}{{$m := index $mods 0}} - <td>{{index $.Data.names $m.Kind}}{{with modsummary $m}} <span class="muted">— {{.}}</span>{{end}}</td> - <td class="nowrap actions"><a href="{{$u}}/modules/{{$m.ID}}/edit">edit</a> <a class="danger" href="{{$u}}/modules/{{$m.ID}}/delete">remove</a></td> + <td>{{t (index $.Data.names $m.Kind)}}{{with modsummary $m}} <span class="muted">— {{.}}</span>{{end}}</td> + <td class="nowrap actions"><a href="{{$u}}/modules/{{$m.ID}}/edit">{{t "edit"}}</a> <a class="danger" href="{{$u}}/modules/{{$m.ID}}/delete">{{t "remove"}}</a></td> {{else}} - <td class="muted">Nothing extra.</td> - <td class="nowrap actions"><form method="post" action="{{$u}}/areas/{{$area}}/add" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="kind" value="html"><button class="mini">+ Add custom HTML</button></form></td> + <td class="muted">{{t "Nothing extra."}}</td> + <td class="nowrap actions"><form method="post" action="{{$u}}/areas/{{$area}}/add" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="kind" value="html"><button class="mini">+ {{t "Add custom HTML"}}</button></form></td> {{end}} </tr>{{end}} </table> </div> -{{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."))}} <div class="card" id="menu"> - <h2>Menu</h2> - <p class="muted small">The links the Menu module shows, wherever you place it. Pages and outside links can be mixed in any order. Ticking "Show in menu" on a page does the same as adding it here.</p> + <h2>{{t "Menu"}}</h2> + <p class="muted small">{{t "The links the Menu module shows, wherever you place it. Pages and outside links can be mixed in any order. Ticking “Show in menu” on a page does the same as adding it here."}}</p> {{if .Data.menu}}<table class="modules"> {{range .Data.menu}}<tr> <td class="nowrap order"> - <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">▲</button></form> - <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> + <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="{{t "Move up"}}">▲</button></form> + <form method="post" action="{{$u}}/menu/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="{{t "Move down"}}">▼</button></form> </td> - <td>{{.Text}} {{if .PageID}}<span class="tag">page</span>{{else}}<span class="tag">link</span> <span class="muted small">{{.URL}}</span>{{end}}</td> - <td class="nowrap actions"><form method="post" action="{{$u}}/menu/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">Remove</button></form></td> + <td>{{.Text}} {{if .PageID}}<span class="tag">{{t "page"}}</span>{{else}}<span class="tag">{{t "link"}}</span> <span class="muted small">{{.URL}}</span>{{end}}</td> + <td class="nowrap actions"><form method="post" action="{{$u}}/menu/{{.ID}}/delete" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini danger">{{t "Remove"}}</button></form></td> </tr>{{end}} - </table>{{else}}<p class="muted">The menu is empty.</p>{{end}} + </table>{{else}}<p class="muted">{{t "The menu is empty."}}</p>{{end}} <div class="cols addrow"> <form method="post" action="{{$u}}/menu/add-page" class="add"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <h3>Add a page</h3> - {{if .Data.freePages}}<label>Page<br><select name="page">{{range .Data.freePages}}<option value="{{.ID}}">{{.Title}}</option>{{end}}</select></label> - <button type="submit" class="secondary">Add page</button> - {{else}}<p class="muted small">Every page is already in the menu.</p>{{end}} + <h3>{{t "Add a page"}}</h3> + {{if .Data.freePages}}<label>{{t "Page"}}<br><select name="page">{{range .Data.freePages}}<option value="{{.ID}}">{{.Title}}</option>{{end}}</select></label> + <button type="submit" class="secondary">{{t "Add page"}}</button> + {{else}}<p class="muted small">{{t "Every page is already in the menu."}}</p>{{end}} </form> <form method="post" action="{{$u}}/menu/add-link" class="add"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <h3>Add a link</h3> - <label>Text<br><input name="label" maxlength="60" placeholder="My photos"></label> - <label>Address<br><input name="url" maxlength="500" placeholder="https://example.org"></label> - <button type="submit" class="secondary">Add link</button> + <h3>{{t "Add a link"}}</h3> + <label>{{t "Text"}}<br><input name="label" maxlength="60" placeholder="{{t "My photos"}}"></label> + <label>{{t "Address"}}<br><input name="url" maxlength="500" placeholder="https://example.org"></label> + <button type="submit" class="secondary">{{t "Add link"}}</button> </form> </div> </div> @@ -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}} <div class="card{{if not $on}} area-off{{end}}" id="{{$area}}"> <div class="cardhead"> - <h2>{{index $r.Data.areaNames $area}}{{if not $on}} <span class="tag off">hidden</span>{{end}}</h2> - <form method="post" action="{{$u}}/areas/{{$area}}/toggle" class="inline switch"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><button class="mini">{{if $on}}Hide this area{{else}}Show this area{{end}}</button></form> + <h2>{{t (index $r.Data.areaNames $area)}}{{if not $on}} <span class="tag off">{{t "hidden"}}</span>{{end}}</h2> + <form method="post" action="{{$u}}/areas/{{$area}}/toggle" class="inline switch"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><button class="mini">{{if $on}}{{t "Hide this area"}}{{else}}{{t "Show this area"}}{{end}}</button></form> </div> <p class="muted small">{{.hint}}</p> {{if $mods}}<table class="modules"> {{range $mods}}<tr> <td class="nowrap order"> - <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">▲</button></form> - <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> + <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="{{t "Move up"}}">▲</button></form> + <form method="post" action="{{$u}}/modules/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$r.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="{{t "Move down"}}">▼</button></form> </td> - <td>{{index $r.Data.names .Kind}}{{with modsummary .}} <span class="muted">— {{.}}</span>{{end}} - {{if and (eq .Kind "logo") (not $r.Data.theme.Logo)}}<span class="tag off">no logo chosen</span> <span class="muted small">pick one on the <a href="/b/{{$r.Blog.Subdomain}}/design#logo">Design</a> tab</span>{{end}}</td> - <td class="nowrap actions">{{if modsettings .Kind}}<a href="{{$u}}/modules/{{.ID}}/edit">edit</a> {{end}}<a class="danger" href="{{$u}}/modules/{{.ID}}/delete">remove</a></td> + <td>{{t (index $r.Data.names .Kind)}}{{with modsummary .}} <span class="muted">— {{.}}</span>{{end}} + {{if and (eq .Kind "logo") (not $r.Data.theme.Logo)}}<span class="tag off">{{t "no logo chosen"}}</span> <span class="muted small">{{t "pick one on the"}} <a href="/b/{{$r.Blog.Subdomain}}/design#logo">{{t "Design"}}</a> {{t "tab."}}</span>{{end}}</td> + <td class="nowrap actions">{{if modsettings .Kind}}<a href="{{$u}}/modules/{{.ID}}/edit">{{t "edit"}}</a> {{end}}<a class="danger" href="{{$u}}/modules/{{.ID}}/delete">{{t "remove"}}</a></td> </tr>{{end}} - </table>{{else}}<p class="muted">Nothing here yet{{if ne $area "header"}} — this area is not shown until you add a module{{end}}.</p>{{end}} + </table>{{else}}<p class="muted">{{if ne $area "header"}}{{t "Nothing here yet — this area is not shown until you add a module."}}{{else}}{{t "Nothing here yet."}}{{end}}</p>{{end}} <form method="post" action="{{$u}}/areas/{{$area}}/add" class="addmod"> <input type="hidden" name="_csrf" value="{{$r.CSRF}}"> - <label>Add a module<br><select name="kind">{{range index $r.Data.kinds $area}}<option value="{{.}}">{{index $r.Data.names .}}</option>{{end}}</select></label> - <button type="submit" class="secondary">Add</button> + <label>{{t "Add a module"}}<br><select name="kind">{{range index $r.Data.kinds $area}}<option value="{{.}}">{{t (index $r.Data.names .)}}</option>{{end}}</select></label> + <button type="submit" class="secondary">{{t "Add"}}</button> </form> </div>{{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}} <div class="card"> - <h1>{{.Data.name}} <span class="muted small">in the {{lower .Data.area}}</span></h1> + <h1>{{.Data.name}} <span class="muted small">{{tf "in the %s" (lower .Data.area)}}</span></h1> <form method="post"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> {{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")))}} - <label>Heading <span class="muted">(optional)</span><br><input name="title" value="{{$m.Title}}" maxlength="120" placeholder="{{if eq $m.Kind "recent"}}Recent posts{{else if eq $m.Kind "archive"}}Archive{{else if eq $m.Kind "sitemap"}}Pages{{end}}"></label> + <label>{{t "Heading"}} <span class="muted">({{t "optional"}})</span><br><input name="title" value="{{$m.Title}}" maxlength="120" placeholder="{{if eq $m.Kind "recent"}}{{t "Recent posts"}}{{else if eq $m.Kind "archive"}}{{t "Archive"}}{{else if eq $m.Kind "sitemap"}}{{t "Pages"}}{{end}}"></label> {{end}} {{if eq $m.Kind "recent"}} - <div class="row"><label>How many posts<br><select name="count">{{range $n := .Data.counts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div> - <p class="muted small">The newest published posts from every page of your blog, newest first.</p> + <div class="row"><label>{{t "How many posts"}}<br><select name="count">{{range $n := .Data.counts}}<option value="{{$n}}"{{if eq $n $m.Count}} selected{{end}}>{{$n}}</option>{{end}}</select></label></div> + <p class="muted small">{{t "The newest published posts from every page of your blog, newest first."}}</p> {{else if eq $m.Kind "archive"}} - <p class="muted small">Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open.</p> + <p class="muted small">{{t "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open."}}</p> {{else if eq $m.Kind "sitemap"}} - <p class="muted small">Links to every page of your blog.</p> + <p class="muted small">{{t "Links to every page of your blog."}}</p> {{else if eq $m.Kind "html"}} <label for="mod-body">HTML</label> - <textarea id="mod-body" name="body" rows="14" class="mono" spellcheck="false" placeholder="<p>Hello!</p>">{{$m.Body}}</textarea> - <p class="muted small">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.</p> + <textarea id="mod-body" name="body" rows="14" class="mono" spellcheck="false" placeholder="<p>{{t "Hello!"}}</p>">{{$m.Body}}</textarea> + <p class="muted small">{{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."}}</p> {{else if eq $m.Kind "text"}} - <label for="mod-body">Text</label> - <textarea id="mod-body" name="body" rows="4" placeholder="© 2026 Me">{{$m.Body}}</textarea> - <p class="muted small">Plain text; each line becomes its own paragraph.</p> + <label for="mod-body">{{t "Text"}}</label> + <textarea id="mod-body" name="body" rows="4" placeholder="{{t "© 2026 Me"}}">{{$m.Body}}</textarea> + <p class="muted small">{{t "Plain text; each line becomes its own paragraph."}}</p> {{end}} <p class="actionrow"> - <button type="submit">Save</button> - <a class="btn secondary" href="{{.BlogURL}}" target="_blank">View blog ↗</a> - <a href="/b/{{.Blog.Subdomain}}/layout#{{if or (eq $m.Area "above") (eq $m.Area "below")}}main{{else}}{{$m.Area}}{{end}}">Back to layout</a> + <button type="submit">{{t "Save"}}</button> + <a class="btn secondary" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} ↗</a> + <a href="/b/{{.Blog.Subdomain}}/layout#{{if or (eq $m.Area "above") (eq $m.Area "below")}}main{{else}}{{$m.Area}}{{end}}">{{t "Back to layout"}}</a> </p> </form> </div> 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"}} <h1>{{.Blog.Title}}</h1> -<p class="muted">Your blog is live at <a href="{{.BlogURL}}" target="_blank">{{.BlogURL}}</a>. Every change you save here shows up there as soon as you refresh.</p> +<p class="muted">{{t "Your blog is live at"}} <a href="{{.BlogURL}}" target="_blank">{{.BlogURL}}</a>. {{t "Every change you save here shows up there as soon as you refresh."}}</p> <div class="cols"> <div class="card"> - <h2>Quick actions</h2> - <p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">Write a new post</a></p> - <p><a href="/b/{{.Blog.Subdomain}}/pages/new">Add a page</a> · <a href="/b/{{.Blog.Subdomain}}/announcements/new">Post an announcement</a> · <a href="/b/{{.Blog.Subdomain}}/layout">Arrange the layout</a> · <a href="/b/{{.Blog.Subdomain}}/design">Change the look</a> · <a href="/b/{{.Blog.Subdomain}}/images">Upload images</a></p> + <h2>{{t "Quick actions"}}</h2> + <p><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new">{{t "Write a new post"}}</a></p> + <p><a href="/b/{{.Blog.Subdomain}}/pages/new">{{t "Add a page"}}</a> · <a href="/b/{{.Blog.Subdomain}}/announcements/new">{{t "Post an announcement"}}</a> · <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Arrange the layout"}}</a> · <a href="/b/{{.Blog.Subdomain}}/design">{{t "Change the look"}}</a> · <a href="/b/{{.Blog.Subdomain}}/images">{{t "Upload images"}}</a></p> </div> <div class="card"> - <h2>Pages</h2> + <h2>{{t "Pages"}}</h2> <ul class="plain"> - {{range .Data.pages}}<li><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.Title}}</a> <span class="muted">({{.PostCount}} posts{{if .IsHome}}, home{{end}}{{if not .ShowInNav}}, hidden from menu{{end}})</span></li>{{end}} + {{range .Data.pages}}<li><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.Title}}</a> <span class="muted">({{tf "%d posts" .PostCount}}{{if .IsHome}}, {{t "home"}}{{end}}{{if not .ShowInNav}}, {{t "hidden from menu"}}{{end}})</span></li>{{end}} </ul> </div> </div> <div class="card"> - <h2>Latest posts</h2> + <h2>{{t "Latest posts"}}</h2> {{if .Data.posts}} <table> - <tr><th>Title</th><th>Page</th><th>Date</th><th></th></tr> + <tr><th>{{t "Title"}}</th><th>{{t "Page"}}</th><th>{{t "Date"}}</th><th></th></tr> {{range .Data.posts}}<tr> - <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">hidden</span>{{end}}</td> + <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">{{t "hidden"}}</span>{{end}}</td> <td>{{.PageTitle}}</td> <td>{{date .CreatedAt}}</td> - <td><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">view ↗</a></td> + <td><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">{{t "view"}} ↗</a></td> </tr>{{end}} </table> - <p><a href="/b/{{.Blog.Subdomain}}/posts">All posts</a></p> - {{else}}<p class="muted">No posts yet. <a href="/b/{{.Blog.Subdomain}}/posts/new">Write the first one.</a></p>{{end}} + <p><a href="/b/{{.Blog.Subdomain}}/posts">{{t "All posts"}}</a></p> + {{else}}<p class="muted">{{t "No posts yet."}} <a href="/b/{{.Blog.Subdomain}}/posts/new">{{t "Write the first one."}}</a></p>{{end}} </div> {{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"}} <div class="card"> - <h1>{{if .Data.page.ID}}Edit page{{else}}New page{{end}}</h1> + <h1>{{if .Data.page.ID}}{{t "Edit page"}}{{else}}{{t "New page"}}{{end}}</h1> <form method="post" enctype="multipart/form-data"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <label>Title<br><input name="title" value="{{.Data.page.Title}}" required maxlength="120"></label> - <label>Address <span class="muted">(leave empty to make one from the title; e.g. <code>about</code> → {{.BlogURL}}/about)</span><br> + <label>{{t "Title"}}<br><input name="title" value="{{.Data.page.Title}}" required maxlength="120"></label> + <label>{{t "Address"}} <span class="muted">({{t "leave empty to make one from the title; e.g."}} <code>about</code> → {{.BlogURL}}/about)</span><br> <input name="slug" value="{{.Data.page.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label> - <label for="ed-intro">Intro text <span class="muted">(Markdown, shown above the posts; optional)</span></label> + <label for="ed-intro">{{t "Intro text"}} <span class="muted">({{t "Markdown, shown above the posts; optional"}})</span></label> {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} - <label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> Show in menu</label> + <label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> {{t "Show in menu"}}</label> <p class="actionrow"> - <button type="submit">Save</button> - {{if .Data.page.ID}}<a class="btn secondary" href="{{.BlogURL}}{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}" target="_blank">View page ↗</a>{{end}} - <a href="/b/{{.Blog.Subdomain}}/pages">Back to pages</a> + <button type="submit">{{t "Save"}}</button> + {{if .Data.page.ID}}<a class="btn secondary" href="{{.BlogURL}}{{if .Data.page.IsHome}}/{{else}}/{{.Data.page.Slug}}{{end}}" target="_blank">{{t "View page"}} ↗</a>{{end}} + <a href="/b/{{.Blog.Subdomain}}/pages">{{t "Back to pages"}}</a> </p> </form> </div> 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"}} <div class="pagehead"> - <h1>Pages</h1> - <div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/pages/new">+ New page</a></div> - <p class="lead muted">Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the <a href="/b/{{.Blog.Subdomain}}/layout#menu">Layout</a> tab.</p> + <h1>{{t "Pages"}}</h1> + <div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/pages/new">+ {{t "New page"}}</a></div> + <p class="lead muted">{{t "Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the"}} <a href="/b/{{.Blog.Subdomain}}/layout#menu">{{t "Layout"}}</a> {{t "tab."}}</p> </div> <div class="card"> <table> - <tr><th>Order</th><th>Title</th><th>Address</th><th>Posts</th><th></th></tr> + <tr><th>{{t "Order"}}</th><th>{{t "Title"}}</th><th>{{t "Address"}}</th><th>{{t "Posts"}}</th><th></th></tr> {{range .Data.pages}}<tr> <td class="nowrap"> - <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">▲</button></form> - <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> + <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="{{t "Move up"}}">▲</button></form> + <form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="{{t "Move down"}}">▼</button></form> </td> <td><a href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/edit">{{.Title}}</a> - {{if .IsHome}}<span class="tag">home</span>{{end}}{{if not .ShowInNav}}<span class="tag off">not in menu</span>{{end}}</td> + {{if .IsHome}}<span class="tag">{{t "home"}}</span>{{end}}{{if not .ShowInNav}}<span class="tag off">{{t "not in menu"}}</span>{{end}}</td> <td><a href="{{$.BlogURL}}{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}" target="_blank">{{if .IsHome}}/{{else}}/{{.Slug}}{{end}} ↗</a></td> <td><a href="/b/{{$.Blog.Subdomain}}/posts?page={{.ID}}">{{.PostCount}}</a></td> <td class="nowrap"> - {{if not .IsHome}}<form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/home" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">Make home</button></form> - <a class="danger" href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/delete">delete</a>{{end}} + {{if not .IsHome}}<form method="post" action="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/home" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{t "Make home"}}</button></form> + <a class="danger" href="/b/{{$.Blog.Subdomain}}/pages/{{.ID}}/delete">{{t "delete"}}</a>{{end}} </td> </tr>{{end}} </table> 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"}} <div class="card narrow"> - <h1>Change password</h1> + <h1>{{t "Change password"}}</h1> <form method="post" action="/account/password"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <label>Current password<br><input type="password" name="current" required autocomplete="current-password"></label> - <label>New password<br><input type="password" name="password" required minlength="8" autocomplete="new-password"></label> - <label>Repeat new password<br><input type="password" name="password2" required minlength="8" autocomplete="new-password"></label> - <p class="actionrow"><button type="submit">Change password</button></p> + <label>{{t "Current password"}}<br><input type="password" name="current" required autocomplete="current-password"></label> + <label>{{t "New password"}}<br><input type="password" name="password" required minlength="8" autocomplete="new-password"></label> + <label>{{t "Repeat new password"}}<br><input type="password" name="password2" required minlength="8" autocomplete="new-password"></label> + <p class="actionrow"><button type="submit">{{t "Change password"}}</button></p> </form> </div> {{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"}} <div class="card"> - <h1>{{if .Data.post.ID}}Edit post{{else}}New post{{end}}</h1> + <h1>{{if .Data.post.ID}}{{t "Edit post"}}{{else}}{{t "New post"}}{{end}}</h1> <form method="post" enctype="multipart/form-data"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <label>Title<br><input name="title" value="{{.Data.post.Title}}" required maxlength="200" autofocus></label> + <label>{{t "Title"}}<br><input name="title" value="{{.Data.post.Title}}" required maxlength="200" autofocus></label> <div class="row"> - <label>Page<br> + <label>{{t "Page"}}<br> <select name="page_id">{{range .Data.pages}}<option value="{{.ID}}"{{if eq .ID $.Data.post.PageID}} selected{{end}}>{{.Title}}</option>{{end}}</select> </label> - <label>Address <span class="muted">(optional, made from the title)</span><br> + <label>{{t "Address"}} <span class="muted">({{t "optional, made from the title"}})</span><br> <input name="slug" value="{{.Data.post.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label> </div> - <label for="ed-body">Content <span class="muted">(Markdown)</span></label> + <label for="ed-body">{{t "Content"}} <span class="muted">(Markdown)</span></label> {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} - <label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> Visible on the blog</label> + <label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> {{t "Visible on the blog"}}</label> <p class="actionrow"> - <button type="submit">Save</button> - {{if .Data.post.ID}}<a class="btn secondary" href="{{.BlogURL}}/{{.Data.post.PageSlug}}/{{.Data.post.Slug}}" target="_blank">View post ↗</a>{{end}} - <a href="/b/{{.Blog.Subdomain}}/posts">Back to posts</a> + <button type="submit">{{t "Save"}}</button> + {{if .Data.post.ID}}<a class="btn secondary" href="{{.BlogURL}}/{{.Data.post.PageSlug}}/{{.Data.post.Slug}}" target="_blank">{{t "View post"}} ↗</a>{{end}} + <a href="/b/{{.Blog.Subdomain}}/posts">{{t "Back to posts"}}</a> </p> </form> </div> 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"}} <div class="pagehead"> - <h1>Posts</h1> - <div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new{{if .Data.pageID}}?page={{.Data.pageID}}{{end}}">+ New post</a></div> + <h1>{{t "Posts"}}</h1> + <div class="actions"><a class="btn" href="/b/{{.Blog.Subdomain}}/posts/new{{if .Data.pageID}}?page={{.Data.pageID}}{{end}}">+ {{t "New post"}}</a></div> </div> <form method="get" class="filter"> - <label>Page: + <label>{{t "Page:"}} <select name="page" onchange="this.form.submit()"> - <option value="0">All pages</option> + <option value="0">{{t "All pages"}}</option> {{range .Data.pages}}<option value="{{.ID}}"{{if eq .ID $.Data.pageID}} selected{{end}}>{{.Title}}</option>{{end}} </select></label> - <noscript><button type="submit">Filter</button></noscript> + <noscript><button type="submit">{{t "Filter"}}</button></noscript> </form> <div class="card"> {{if .Data.posts}} <table> - <tr><th>Title</th><th>Page</th><th>Date</th><th></th></tr> + <tr><th>{{t "Title"}}</th><th>{{t "Page"}}</th><th>{{t "Date"}}</th><th></th></tr> {{range .Data.posts}}<tr> - <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">hidden</span>{{end}}</td> + <td><a href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/edit">{{.Title}}</a>{{if not .Published}} <span class="tag">{{t "hidden"}}</span>{{end}}</td> <td>{{.PageTitle}}</td> <td class="nowrap">{{date .CreatedAt}}</td> - <td class="nowrap"><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">view ↗</a> · <a class="danger" href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/delete">delete</a></td> + <td class="nowrap"><a href="{{$.BlogURL}}/{{.PageSlug}}/{{.Slug}}" target="_blank">{{t "view"}} ↗</a> · <a class="danger" href="/b/{{$.Blog.Subdomain}}/posts/{{.ID}}/delete">{{t "delete"}}</a></td> </tr>{{end}} </table> -{{else}}<p class="muted">No posts here yet.</p>{{end}} +{{else}}<p class="muted">{{t "No posts here yet."}}</p>{{end}} </div> {{end}} diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html index 7eeaf7c..bc3ba34 100644 --- a/internal/web/templates/dashboard/section_form.html +++ b/internal/web/templates/dashboard/section_form.html @@ -1,35 +1,35 @@ -{{define "title"}}{{if .Data.section.ID}}Edit announcement{{else}}New announcement{{end}} · {{.Blog.Title}}{{end}} +{{define "title"}}{{if .Data.section.ID}}{{t "Edit announcement"}}{{else}}{{t "New announcement"}}{{end}} · {{.Blog.Title}}{{end}} {{define "content"}} {{$s := .Data.section}} <div class="card"> - <h1>{{if $s.ID}}Edit announcement{{else}}New announcement{{end}}</h1> + <h1>{{if $s.ID}}{{t "Edit announcement"}}{{else}}{{t "New announcement"}}{{end}}</h1> <form method="post" enctype="multipart/form-data"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <label>Title <span class="muted">(optional)</span><br><input name="title" value="{{$s.Title}}" maxlength="120" autofocus placeholder="Next meeting"></label> + <label>{{t "Title"}} <span class="muted">({{t "optional"}})</span><br><input name="title" value="{{$s.Title}}" maxlength="120" autofocus placeholder="{{t "Next meeting"}}"></label> <div class="row"> - <label>Where<br><select name="column"> - <option value="main"{{if eq $s.Column "main"}} selected{{end}}>Main content (with the posts)</option> - <option value="left"{{if eq $s.Column "left"}} selected{{end}}>Left column</option> - <option value="right"{{if eq $s.Column "right"}} selected{{end}}>Right column</option> + <label>{{t "Where"}}<br><select name="column"> + <option value="main"{{if eq $s.Column "main"}} selected{{end}}>{{t "Main content (with the posts)"}}</option> + <option value="left"{{if eq $s.Column "left"}} selected{{end}}>{{t "Left column"}}</option> + <option value="right"{{if eq $s.Column "right"}} selected{{end}}>{{t "Right column"}}</option> </select></label> - <label>Position<br><select name="position"> - <option value="top"{{if eq $s.Position "top"}} selected{{end}}>At the top</option> - <option value="bottom"{{if eq $s.Position "bottom"}} selected{{end}}>At the bottom</option> + <label>{{t "Position"}}<br><select name="position"> + <option value="top"{{if eq $s.Position "top"}} selected{{end}}>{{t "At the top"}}</option> + <option value="bottom"{{if eq $s.Position "bottom"}} selected{{end}}>{{t "At the bottom"}}</option> </select></label> - <label>Look<br><select name="style"> - <option value="note"{{if eq $s.Style "note"}} selected{{end}}>Highlighted</option> - <option value="warning"{{if eq $s.Style "warning"}} selected{{end}}>Warning</option> - <option value="plain"{{if eq $s.Style "plain"}} selected{{end}}>Plain</option> + <label>{{t "Look"}}<br><select name="style"> + <option value="note"{{if eq $s.Style "note"}} selected{{end}}>{{t "Highlighted"}}</option> + <option value="warning"{{if eq $s.Style "warning"}} selected{{end}}>{{t "Warning"}}</option> + <option value="plain"{{if eq $s.Style "plain"}} selected{{end}}>{{t "Plain"}}</option> </select></label> </div> - <p class="muted small">"Top" of a side column means above its modules, "bottom" below them. If that column is not shown on your blog (see <a href="/b/{{.Blog.Subdomain}}/layout">Layout</a>) the announcement moves to the main content instead.</p> - <label for="ed-body">Text <span class="muted">(Markdown)</span></label> + <p class="muted small">{{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Layout"}}</a>) {{t "the announcement moves to the main content instead."}}</p> + <label for="ed-body">{{t "Text"}} <span class="muted">(Markdown)</span></label> {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} - <label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> Shown on the blog</label> + <label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> {{t "Shown on the blog"}}</label> <p class="actionrow"> - <button type="submit">Save</button> - <a class="btn secondary" href="{{.BlogURL}}" target="_blank">View blog ↗</a> - <a href="/b/{{.Blog.Subdomain}}/announcements">Back to announcements</a> + <button type="submit">{{t "Save"}}</button> + <a class="btn secondary" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} ↗</a> + <a href="/b/{{.Blog.Subdomain}}/announcements">{{t "Back to announcements"}}</a> </p> </form> </div> diff --git a/internal/web/templates/dashboard/sections.html b/internal/web/templates/dashboard/sections.html index 1834229..4d62f29 100644 --- a/internal/web/templates/dashboard/sections.html +++ b/internal/web/templates/dashboard/sections.html @@ -1,29 +1,29 @@ -{{define "title"}}Announcements · {{.Blog.Title}}{{end}} +{{define "title"}}{{t "Announcements"}} · {{.Blog.Title}}{{end}} {{define "content"}} {{$u := print "/b/" .Blog.Subdomain "/announcements"}} <div class="pagehead"> - <h1>Announcements</h1> - <div class="actions"><a class="btn" href="{{$u}}/new">+ New announcement</a></div> - <p class="lead muted">Notices shown on every page and post of your blog — the next meeting, this month's book, a holiday closure. Hide one instead of deleting it if you will need it again.</p> + <h1>{{t "Announcements"}}</h1> + <div class="actions"><a class="btn" href="{{$u}}/new">+ {{t "New announcement"}}</a></div> + <p class="lead muted">{{t "Notices shown on every page and post of your blog — the next meeting, this month's book, a holiday closure. Hide one instead of deleting it if you will need it again."}}</p> </div> <div class="card"> {{if .Data.sections}} <table> - <tr><th>Order</th><th>Announcement</th><th>Where</th><th>Look</th><th></th></tr> + <tr><th>{{t "Order"}}</th><th>{{t "Announcement"}}</th><th>{{t "Where"}}</th><th>{{t "Look"}}</th><th></th></tr> {{range .Data.sections}}<tr{{if not .Enabled}} class="disabled"{{end}}> <td class="nowrap"> - <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="Move up">▲</button></form> - <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="Move down">▼</button></form> + <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="up"><button class="mini" title="{{t "Move up"}}">▲</button></form> + <form method="post" action="{{$u}}/{{.ID}}/move" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><input type="hidden" name="dir" value="down"><button class="mini" title="{{t "Move down"}}">▼</button></form> </td> - <td><a href="{{$u}}/{{.ID}}/edit">{{if .Title}}{{.Title}}{{else}}(untitled){{end}}</a>{{if not .Enabled}}<span class="tag off">hidden</span>{{end}}</td> - <td>{{if eq .Column "main"}}Main content{{else if eq .Column "left"}}Left column{{else}}Right column{{end}}, {{.Position}}</td> - <td>{{if eq .Style "note"}}Highlighted{{else if eq .Style "warning"}}Warning{{else}}Plain{{end}}</td> + <td><a href="{{$u}}/{{.ID}}/edit">{{if .Title}}{{.Title}}{{else}}({{t "untitled"}}){{end}}</a>{{if not .Enabled}}<span class="tag off">{{t "hidden"}}</span>{{end}}</td> + <td>{{if eq .Column "main"}}{{t "Main content"}}{{else if eq .Column "left"}}{{t "Left column"}}{{else}}{{t "Right column"}}{{end}}, {{t .Position}}</td> + <td>{{if eq .Style "note"}}{{t "Highlighted"}}{{else if eq .Style "warning"}}{{t "Warning"}}{{else}}{{t "Plain"}}{{end}}</td> <td class="nowrap"> - <form method="post" action="{{$u}}/{{.ID}}/toggle" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{if .Enabled}}Hide{{else}}Show{{end}}</button></form> - <a class="danger" href="{{$u}}/{{.ID}}/delete">delete</a> + <form method="post" action="{{$u}}/{{.ID}}/toggle" class="inline"><input type="hidden" name="_csrf" value="{{$.CSRF}}"><button class="mini">{{if .Enabled}}{{t "Hide"}}{{else}}{{t "Show"}}{{end}}</button></form> + <a class="danger" href="{{$u}}/{{.ID}}/delete">{{t "delete"}}</a> </td> </tr>{{end}} </table> -{{else}}<p class="muted">No announcements yet. <a href="{{$u}}/new">Write the first one.</a></p>{{end}} +{{else}}<p class="muted">{{t "No announcements yet."}} <a href="{{$u}}/new">{{t "Write the first one."}}</a></p>{{end}} </div> {{end}} diff --git a/internal/web/templates/dashboard/settings.html b/internal/web/templates/dashboard/settings.html index 53a24c4..9685360 100644 --- a/internal/web/templates/dashboard/settings.html +++ b/internal/web/templates/dashboard/settings.html @@ -1,13 +1,16 @@ -{{define "title"}}Settings · {{.Blog.Title}}{{end}} +{{define "title"}}{{t "Settings"}} · {{.Blog.Title}}{{end}} {{define "content"}} <div class="card narrow"> - <h1>Blog settings</h1> + <h1>{{t "Blog settings"}}</h1> <form method="post" action="/b/{{.Blog.Subdomain}}/settings"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> - <label>Blog title<br><input name="title" value="{{.Blog.Title}}" required maxlength="120"></label> - <label>Tagline <span class="muted">(shown under the title)</span><br><input name="tagline" value="{{.Blog.Tagline}}" maxlength="300"></label> - <p class="muted">Address: <code>{{.BlogURL}}</code>{{if ne .Blog.Subdomain "www"}} — only the administrator can change this{{end}}.</p> - <p class="actionrow"><button type="submit">Save</button></p> + <label>{{t "Blog title"}}<br><input name="title" value="{{.Blog.Title}}" required maxlength="120"></label> + <label>{{t "Tagline"}} <span class="muted">({{t "shown under the title"}})</span><br><input name="tagline" value="{{.Blog.Tagline}}" maxlength="300"></label> + <label>{{t "Language"}} <span class="muted">({{t "of the dashboard, dates and the blog's fixed text"}})</span><br><select name="language"> + {{range .Data.languages}}<option value="{{.Code}}"{{if eq .Code $.Blog.Language}} selected{{end}}>{{.Name}}</option>{{end}} + </select></label> + <p class="muted">{{t "Address:"}} <code>{{.BlogURL}}</code>{{if ne .Blog.Subdomain "www"}} — {{t "only the administrator can change this"}}{{end}}.</p> + <p class="actionrow"><button type="submit">{{t "Save"}}</button></p> </form> </div> {{end}} diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html index deaaa75..f73f729 100644 --- a/internal/web/templates/layouts/blog.html +++ b/internal/web/templates/layouts/blog.html @@ -1,5 +1,5 @@ {{define "layout"}}<!DOCTYPE html> -<html lang="en"> +<html lang="{{.Lang}}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> @@ -65,14 +65,14 @@ </div> {{- else if eq $m.Kind "archive"}}<div class="mod mod-archive">{{template "modtitle" $m}} {{range $i, $y := $r.Data.archive}}<details{{if eq $i 0}} open{{end}}><summary>{{$y.Year}} <span class="muted">({{$y.Count}})</span></summary> - {{range $y.Months}}<details><summary>{{.Name}} <span class="muted">({{len .Posts}})</span></summary> + {{range $y.Months}}<details><summary>{{month .Month}} <span class="muted">({{len .Posts}})</span></summary> <ul>{{range .Posts}}<li><a href="/{{.PageSlug}}/{{.Slug}}">{{.Title}}</a></li>{{end}}</ul> </details>{{end}} - </details>{{else}}<p class="muted">No posts yet.</p>{{end}} + </details>{{else}}<p class="muted">{{t "No posts yet."}}</p>{{end}} </div> {{- else if eq $m.Kind "html"}}{{if not $m.Body}}{{else if or (eq $m.Area "above") (eq $m.Area "below")}}<div class="custom-html custom-html-{{$m.Area}}">{{html $m.Body}}</div> {{else}}<div class="mod mod-html">{{template "modtitle" $m}}{{html $m.Body}}</div>{{end}} -{{- else if eq $m.Kind "rss"}}<p class="mod mod-rss small"><a href="/feed.xml">RSS feed</a></p> +{{- else if eq $m.Kind "rss"}}<p class="mod mod-rss small"><a href="/feed.xml">{{t "RSS feed"}}</a></p> {{- else if eq $m.Kind "text"}}{{if $m.Body}}<div class="mod mod-text">{{range split $m.Body "\n"}}{{if .}}<p>{{.}}</p>{{end}}{{end}}</div>{{end}} {{- else if eq $m.Kind "sitemap"}}<div class="mod mod-sitemap">{{template "modtitle" $m}} <ul>{{range $r.Data.pages}}<li><a href="{{if .IsHome}}/{{else}}/{{.Slug}}{{end}}">{{.Title}}</a></li>{{end}}</ul> 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"}}<!DOCTYPE html> -<html lang="en"> +<html lang="{{.Lang}}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> 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 @@ <div class="topbar-inner"> <a class="brand" href="{{if .User}}/dashboard{{else}}/{{end}}">Blogspace</a> {{if .User}} - <span class="who">{{.User.Username}}{{if .User.IsSuperadmin}} (superadmin){{end}}</span> + <span class="who">{{.User.Username}}{{if .User.IsSuperadmin}} ({{t "superadmin"}}){{end}}</span> <span class="links"> - {{if .User.IsSuperadmin}}<a href="/admin/"{{if hasPrefix .Path "/admin/"}} class="active"{{end}}>Admin</a>{{end}} - <a href="/account/password"{{if eq .Path "/account/password"}} class="active"{{end}}>Password</a> - <form method="post" action="/logout" class="inline"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit" class="linkbtn">Log out</button></form> + {{if .User.IsSuperadmin}}<a href="/admin/"{{if hasPrefix .Path "/admin/"}} class="active"{{end}}>{{t "Admin"}}</a>{{end}} + <a href="/account/password"{{if eq .Path "/account/password"}} class="active"{{end}}>{{t "Password"}}</a> + <form method="post" action="/logout" class="inline"><input type="hidden" name="_csrf" value="{{.CSRF}}"><button type="submit" class="linkbtn">{{t "Log out"}}</button></form> </span> {{end}} </div> @@ -17,16 +17,16 @@ <div class="topbar-inner"> <strong class="blogname">{{.Blog.Title}}</strong> <span class="tabs"> - <a href="{{$b}}"{{if eq .Path $b}} class="active"{{end}}>Overview</a> - <a href="{{$b}}posts"{{if hasPrefix .Path (print $b "posts")}} class="active"{{end}}>Posts</a> - <a href="{{$b}}pages"{{if hasPrefix .Path (print $b "pages")}} class="active"{{end}}>Pages</a> - <a href="{{$b}}announcements"{{if hasPrefix .Path (print $b "announcements")}} class="active"{{end}}>Announcements</a> - <a href="{{$b}}layout"{{if hasPrefix .Path (print $b "layout")}} class="active"{{end}}>Layout</a> - <a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>Design</a> - <a href="{{$b}}images"{{if hasPrefix .Path (print $b "images")}} class="active"{{end}}>Images</a> - <a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>Settings</a> + <a href="{{$b}}"{{if eq .Path $b}} class="active"{{end}}>{{t "Overview"}}</a> + <a href="{{$b}}posts"{{if hasPrefix .Path (print $b "posts")}} class="active"{{end}}>{{t "Posts"}}</a> + <a href="{{$b}}pages"{{if hasPrefix .Path (print $b "pages")}} class="active"{{end}}>{{t "Pages"}}</a> + <a href="{{$b}}announcements"{{if hasPrefix .Path (print $b "announcements")}} class="active"{{end}}>{{t "Announcements"}}</a> + <a href="{{$b}}layout"{{if hasPrefix .Path (print $b "layout")}} class="active"{{end}}>{{t "Layout"}}</a> + <a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>{{t "Design"}}</a> + <a href="{{$b}}images"{{if hasPrefix .Path (print $b "images")}} class="active"{{end}}>{{t "Images"}}</a> + <a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>{{t "Settings"}}</a> </span> - <a class="view" href="{{.BlogURL}}" target="_blank">View blog ↗</a> + <a class="view" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} ↗</a> </div> </div> {{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"}}<textarea name="{{.name}}" id="ed-{{.name}}" rows="{{.rows}}"{{if .tall}} class="editor"{{end}}>{{.value}}</textarea> <div class="editor-tools"> - <label class="upload">Insert image <input type="file" name="inline_image" id="ed-{{.name}}-file" accept="image/*"></label> + <label class="upload">{{t "Insert image"}} <input type="file" name="inline_image" id="ed-{{.name}}-file" accept="image/*"></label> <span class="upload-status" id="ed-{{.name}}-status"></span> - <span class="muted small">…or paste / drop an image into the text.</span> - <noscript><span class="muted small">The image is added at the end of the text when you save.</span></noscript> + <span class="muted small">{{t "…or paste / drop an image into the text."}}</span> + <noscript><span class="muted small">{{t "The image is added at the end of the text when you save."}}</span></noscript> </div> {{template "mdhelp"}} <script> @@ -18,14 +18,14 @@ ta.focus(); } function upload(file) { - st.textContent = "Uploading " + file.name + "…"; + st.textContent = {{t "Uploading"}} + " " + file.name + "…"; var fd = new FormData(); fd.append("_csrf", {{.csrf}}); fd.append("file", file); fetch({{.upload}}, { method: "POST", body: fd, credentials: "same-origin", headers: { Accept: "application/json" } }) - .then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || "upload failed"); return j; }); }) - .then(function (j) { insert(j.markdown); st.textContent = "Inserted " + j.filename + "."; inp.value = ""; }) - .catch(function (err) { st.textContent = "Could not upload: " + err.message; }); + .then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || {{t "upload failed"}}); return j; }); }) + .then(function (j) { insert(j.markdown); st.textContent = {{t "Inserted"}} + " " + j.filename + "."; inp.value = ""; }) + .catch(function (err) { st.textContent = {{t "Could not upload:"}} + " " + err.message; }); } inp.addEventListener("change", function () { if (inp.files && inp.files[0]) upload(inp.files[0]); }); ta.addEventListener("paste", function (ev) { diff --git a/internal/web/templates/partials/imagepick.html b/internal/web/templates/partials/imagepick.html index 4431d02..911b355 100644 --- a/internal/web/templates/partials/imagepick.html +++ b/internal/web/templates/partials/imagepick.html @@ -2,8 +2,8 @@ <label>{{.label}}</label> {{if .hint}}<p class="muted small">{{.hint}}</p>{{end}} <div class="picks"> - <label class="pick none"><input type="radio" name="{{.name}}" value="none"{{if not .current}} checked{{end}}><span class="swatch">×</span><span class="name">None</span></label> + <label class="pick none"><input type="radio" name="{{.name}}" value="none"{{if not .current}} checked{{end}}><span class="swatch">×</span><span class="name">{{t "None"}}</span></label> {{range .images}}<label class="pick"><input type="radio" name="{{$.name}}" value="{{.ID}}"{{if eq .ID.String $.current}} checked{{end}}><img src="/b/{{$.sub}}/media/{{.ID}}" alt=""><span class="name">{{.Filename}}</span></label>{{end}} </div> - <label>…or upload a new one<br><input type="file" name="{{.name}}_file" accept="{{if .accept}}{{.accept}}{{else}}image/*{{end}}"></label> + <label>{{t "…or upload a new one"}}<br><input type="file" name="{{.name}}_file" accept="{{if .accept}}{{.accept}}{{else}}image/*{{end}}"></label> </div>{{end}} diff --git a/internal/web/templates/partials/mdhelp.html b/internal/web/templates/partials/mdhelp.html index 3584025..748452e 100644 --- a/internal/web/templates/partials/mdhelp.html +++ b/internal/web/templates/partials/mdhelp.html @@ -1,9 +1,9 @@ {{define "mdhelp"}}<details class="help"> - <summary>Formatting cheat-sheet</summary> + <summary>{{t "Formatting cheat-sheet"}}</summary> <table class="cheat"> - <tr><td><code># Heading</code>, <code>## Smaller heading</code></td><td><code>**bold**</code>, <code>*italic*</code></td></tr> - <tr><td><code>[link text](https://example.org)</code></td><td><code></code> — <em>Insert image</em> writes this for you</td></tr> - <tr><td><code>- list item</code> / <code>1. numbered</code></td><td><code>> quote</code>, <code>`code`</code>, <code>---</code> for a line</td></tr> + <tr><td><code># {{t "Heading"}}</code>, <code>## {{t "Smaller heading"}}</code></td><td><code>**{{t "bold"}}**</code>, <code>*{{t "italic"}}*</code></td></tr> + <tr><td><code>[{{t "link text"}}](https://example.org)</code></td><td><code></code> — <em>{{t "Insert image"}}</em> {{t "writes this for you"}}</td></tr> + <tr><td><code>- {{t "list item"}}</code> / <code>1. {{t "numbered"}}</code></td><td><code>> {{t "quote"}}</code>, <code>`{{t "code"}}`</code>, <code>---</code> {{t "for a line"}}</td></tr> </table> - <p class="muted small">Blank line = new paragraph. Press Enter once for a line break.</p> + <p class="muted small">{{t "Blank line = new paragraph. Press Enter once for a line break."}}</p> </details>{{end}} diff --git a/internal/web/theme.go b/internal/web/theme.go index 1ecd832..2853667 100644 --- a/internal/web/theme.go +++ b/internal/web/theme.go @@ -8,7 +8,6 @@ import ( "strconv" "strings" "text/template" - "time" "github.com/google/uuid" ) @@ -43,7 +42,7 @@ type Theme struct { NavHover string `json:"nav_hover"` // "" = same as NavText ShowDates bool `json:"show_dates"` - DateFormat string `json:"date_format"` // long | short | iso + DateFormat string `json:"date_format"` // long | short | iso (rendered by i18n.FormatDate) FooterBg string `json:"footer_bg"` FooterColor string `json:"footer_color"` @@ -283,17 +282,6 @@ func (t Theme) JSON() json.RawMessage { return b } -// FormatDate renders post dates in the blogger's chosen style. -func (t Theme) FormatDate(at time.Time) string { - switch t.DateFormat { - case "short": - return at.Format("2 Jan 2006") - case "iso": - return at.Format("2006-01-02") - } - return at.Format("2 January 2006") -} - // ---- presets ---------------------------------------------------------------- // Preset is a named colour scheme; applying one touches only colours. diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 5078c44..ae26029 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -1,15 +1,20 @@ package web import ( + "io/fs" "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" + "regexp" "strings" "testing" "time" "github.com/google/uuid" "github.com/gramanas/blogspace/internal/config" + "github.com/gramanas/blogspace/internal/i18n" "github.com/gramanas/blogspace/internal/store" ) @@ -188,16 +193,16 @@ func TestModuleKinds(t *testing.T) { if !moduleHasSettings("html") || moduleHasSettings("rss") { t.Error("moduleHasSettings") } - if got := moduleSummary(store.Module{Kind: "recent", Count: 5, Title: "Fresh"}); got != "5 posts, heading “Fresh”" { + if got := moduleSummary("en", store.Module{Kind: "recent", Count: 5, Title: "Fresh"}); got != "5 posts, heading “Fresh”" { t.Errorf("recent summary: %q", got) } - if got := moduleSummary(store.Module{Kind: "html", Body: " <p>hi\n there</p> "}); got != "<p>hi there</p>" { + if got := moduleSummary("en", store.Module{Kind: "html", Body: " <p>hi\n there</p> "}); got != "<p>hi there</p>" { t.Errorf("html summary: %q", got) } - if got := moduleSummary(store.Module{Kind: "html", Body: strings.Repeat("x", 100)}); len([]rune(got)) != 58 || !strings.HasSuffix(got, "…") { + if got := moduleSummary("en", store.Module{Kind: "html", Body: strings.Repeat("x", 100)}); len([]rune(got)) != 58 || !strings.HasSuffix(got, "…") { t.Errorf("long html summary: %q", got) } - if got := moduleSummary(store.Module{Kind: "menu"}); got != "" { + if got := moduleSummary("en", store.Module{Kind: "menu"}); got != "" { t.Errorf("menu summary: %q", got) } } @@ -222,7 +227,7 @@ func TestGroupArchive(t *testing.T) { if len(years) != 2 || years[0].Year != 2026 || years[0].Count != 3 || years[1].Year != 2025 || years[1].Count != 1 { t.Fatalf("years: %+v", years) } - if len(years[0].Months) != 2 || years[0].Months[0].Name != "September" || len(years[0].Months[0].Posts) != 2 || years[0].Months[1].Name != "February" { + if len(years[0].Months) != 2 || years[0].Months[0].Month != time.September || len(years[0].Months[0].Posts) != 2 || years[0].Months[1].Month != time.February { t.Errorf("months: %+v", years[0].Months) } if years[0].Months[0].Posts[0].Title != "d" || years[1].Months[0].Posts[0].Title != "a" { @@ -294,26 +299,96 @@ func TestPresets(t *testing.T) { } } -func TestFormatDate(t *testing.T) { - at := time.Date(2026, 9, 3, 10, 0, 0, 0, time.UTC) - for format, want := range map[string]string{"long": "3 September 2026", "short": "3 Sep 2026", "iso": "2026-09-03"} { - th := Theme{DateFormat: format} - if got := th.FormatDate(at); got != want { - t.Errorf("%s: got %q, want %q", format, got, want) - } - } -} - func TestAllTemplatesParse(t *testing.T) { - tpl := newTemplates(false, funcs) + tpl := newTemplates(false) for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html", "dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/images.html", "dashboard/settings.html", "dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "dashboard/layout.html", "dashboard/module_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html", "blog/page.html", "blog/post.html", "blog/404.html"} { - if _, err := tpl.get(name); err != nil { - t.Errorf("%s: %v", name, err) + for _, l := range i18n.Languages() { + if _, err := tpl.get(l.Code, name); err != nil { + t.Errorf("%s (%s): %v", name, l.Code, err) + } + } + } +} + +// Every string the templates and handlers translate must be in the Greek +// catalog, and the catalog must not keep entries nothing uses any more (a +// reworded English key would otherwise silently fall back to English). +func TestGreekCatalogComplete(t *testing.T) { + used := map[string]bool{} + add := func(re *regexp.Regexp, src string) { + for _, m := range re.FindAllStringSubmatch(src, -1) { + used[m[1]] = true + } + } + tplKey := regexp.MustCompile(`\b(?:t|tf) "((?:[^"\\]|\\.)*)"`) + goKey := regexp.MustCompile(`\b(?:s\.trf?\(r|i18n\.Tf?\([A-Za-z]+), "((?:[^"\\]|\\.)*)"`) + if err := fs.WalkDir(assets, "templates", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + b, err := fs.ReadFile(assets, path) + add(tplKey, string(b)) + return err + }); err != nil { + t.Fatal(err) + } + goFiles, _ := filepath.Glob("*.go") + for _, f := range goFiles { + if strings.HasSuffix(f, "_test.go") { + continue + } + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + add(goKey, string(b)) + } + // Keys that reach t/tr through a variable rather than a literal. + for _, m := range []map[string]string{moduleNames, areaNames} { + for _, v := range m { + used[v] = true } } + for _, p := range Presets() { + used[p.Name] = true + } + for _, k := range []string{"top", "bottom", store.RoleSuperadmin, store.RoleBlogger} { + used[k] = true + } + have := map[string]bool{} + for _, k := range i18n.Keys("el") { + have[k] = true + if !used[k] { + t.Errorf("el catalog: %q is not used anywhere", k) + } + } + for k := range used { + if !have[k] { + t.Errorf("el catalog: missing %q", k) + } + } + if len(used) < 100 { + t.Errorf("only %d keys found; the scan is broken", len(used)) + } +} + +// Anonymous pages follow the browser's language; unknown ones fall back to English. +func TestLangFromAcceptLanguage(t *testing.T) { + s := NewServer(&config.Config{BaseDomain: "example.com", JWTSecret: []byte("x")}, nil) + req := httptest.NewRequest("GET", "/webadmin", nil) + if got := s.lang(req); got != "en" { + t.Errorf("no header: %q", got) + } + req.Header.Set("Accept-Language", "el-GR,el;q=0.9,en;q=0.8") + if got := s.lang(req); got != "el" { + t.Errorf("greek header: %q", got) + } + if got := s.lang(withLang(req, "en")); got != "en" { + t.Errorf("context wins: %q", got) + } } // /webadmin on a blog's own host bounces to the root login page and back to that blog's dashboard. |
