diff options
| author | grm <grm@eyesin.space> | 2026-09-14 01:32:25 +0300 |
|---|---|---|
| committer | grm <grm@eyesin.space> | 2026-09-14 01:32:25 +0300 |
| commit | e14a4250efb828262347f5a4bbc4067cd71d885f (patch) | |
| tree | 3fcf6512f3b030b06c818ff4a224677a026d3b1b | |
| parent | 282d2ab3e0fb3a74f32160a231225773001acd14 (diff) | |
| download | blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.tar.gz blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.tar.bz2 blogspace-e14a4250efb828262347f5a4bbc4067cd71d885f.zip | |
Add languages: English and Greek, chosen per blog on the Settings tab
Every blog and its dashboard were hard-wired to English. A blogger can
now pick the language of their blog; it switches the whole dashboard and
the blog's fixed text — post dates, archive months, the RSS link, the
pager, the 404 page — while what the blogger wrote is left alone.
The new internal/i18n package keys translations by the English string,
so an untranslated key renders as English rather than blank, and
TestGreekCatalogComplete scans the templates and handlers to fail when
the Greek catalog misses a key or keeps a stale one. Templates are
compiled once per language with t/tf/date/postdate/month closed over the
language, so they need no data plumbing.
The language lives in settings.language (blog migration 00002), not in
the theme, so "Reset design" does not touch it. Public pages use the
blog's language; management pages use the logged-in user's own blog's,
so a superadmin editing someone else's blog keeps theirs; the login page
follows Accept-Language.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
49 files changed, 1302 insertions, 503 deletions
@@ -52,6 +52,8 @@ internal/store/ Store = control DB (users, blog registry, create/delete bl BlogStore = one blog's DB (settings, pages, posts, images, sections, modules, menu) internal/auth/ bcrypt, JWT issue/parse, cookie, HMAC CSRF internal/markdown/ Render(md) → sanitized HTML +internal/i18n/ languages, T/Tf (English keys → catalog), FormatDate/Month, Accept-Language Match; + el.go is the Greek catalog, el_months.go the month tables internal/slug/ Make/Valid/WithSuffix (Greek transliteration included) internal/web/ server.go (host router, middleware, render helpers) routes.go (all routes), handlers_*.go (sections = announcements, layout = modules + menu) @@ -65,7 +67,7 @@ internal/web/ server.go (host router, middleware, render helpers) - **One database per blog**: the registry row (`blogs`: id, owner, subdomain, `db_name`) lives in the control DB; everything else — a one-row `settings` - table (title, tagline, theme) plus pages, posts, images, sections, modules, + table (title, tagline, language, theme) plus pages, posts, images, sections, modules, menu_items — lives in `blog_<sub>` (`db.DBName`: dashes → underscores, so subdomains are capped at 58 chars). No table in a blog DB carries a blog id; the database is the scope. `store.Store` (control) hands out a @@ -189,6 +191,33 @@ internal/web/ server.go (host router, middleware, render helpers) `notice notice-<style>` use translucent colours so they fit any theme. - **Dashboard nav**: `dashnav.html` marks the current tab with `hasPrefix .Path`; a new management section needs a tab there. +- **Languages** (`internal/i18n`, `settings.language`): every blog has a + language (`en` by default, `el` = Greek) chosen on the Settings tab. Public + pages use the blog's language; management pages use the **logged-in user's + own blog's** language (a user has one blog, so a superadmin editing someone + else's blog keeps theirs); anonymous pages (`/webadmin`) match + `Accept-Language`. `server.go`: `hostBlog`/`withBlog` put it in `ctxLang`, + `s.lang(r)` resolves it (lazily looking the user's blog up otherwise), the + `view` carries it as `.Lang` (also the `<html lang>`). + Keys are the English strings: templates write `{{t "Save"}}` / + `{{tf "Page %d of %d" .N .Last}}`, handlers `s.tr(r, "Post deleted.")` / + `s.trf(r, "Uploaded %s.", name)`; an unknown key renders as English. + Templates are compiled once **per language** (`templates.get(lang, name)`) + with `funcsFor(lang)`, whose `t`, `tf`, `date` (dashboard, long style), + `postdate` (`style, time` for the theme's `DateFormat`), `month` + (archive headings) and `modsummary` are closed over the language — so + templates need no data plumbing. A sentence with a link in the middle is + split into fragments around the `<a>`. `moduleNames`/`areaNames`/preset + names stay English maps and are translated where used (`{{t (index …)}}`, + `s.tr(r, moduleNames[k])`). Content the blogger typed is never translated; + default module headings are created in the blog's language. + `TestGreekCatalogComplete` scans the templates and handlers for keys and + fails when the Greek catalog misses one or keeps a stale one — reword an + English string and the catalog entry must follow. Keys reached through a + variable (`{{t .Role}}`) are listed in that test by hand. + Adding a language: an entry in `i18n.languages`, a catalog file, a month + table in `monthTables` (nominative, in-date and short forms), and the test + gets a copy per language if its rules differ. - **Slugs**: auto-generated from the title; on collision generated slugs get `-2`, `-3`…, user-typed slugs return a 409 with a message. - **Errors**: `store.ErrNotFound` / `store.ErrConflict` are the sentinels @@ -217,7 +246,7 @@ superadmin password to `admin`. Production refuses both. (runs in every blog database — it must not reference `users`/`blogs`) and `internal/db/migrations/control/` for users and the registry; goose `-- +goose Up/Down` sections; they run automatically at startup. Never - edit an applied migration. Both chains were re-baselined at 00001 after + edit an applied migration. Blog chain so far: `00001_init`, `00002_language`. Both chains were re-baselined at 00001 after the move to per-blog databases; deployments from before it have `goose_db_version` rows 2–7 in the control DB that must be deleted once (README "Upgrading from a single database") or the next control migration @@ -17,6 +17,9 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones. **menu** mixes blog pages and outside links. - **Design** form: colour schemes, background colour/image, fonts, link style, content box, header, menu, post dates, footer, logo and site icon (favicon). - Changes are live immediately: save, then refresh the blog tab. +- **Languages**: English or Greek, chosen per blog on the Settings tab. It switches the + whole dashboard and the blog's fixed text — dates, archive months, "RSS feed", the pager, + the 404 page. What the blogger writes is never translated. - A **superadmin** creates bloggers, resets passwords, disables or deletes accounts. - The **root domain is itself a blog**, owned by the superadmin and managed like any other. - **Each blog is its own Postgres database** (`blog_<name>`), images included, so one diff --git a/cmd/blogspace/seed.go b/cmd/blogspace/seed.go index a476486..f69a774 100644 --- a/cmd/blogspace/seed.go +++ b/cmd/blogspace/seed.go @@ -45,7 +45,7 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error { if err != nil { return err } - if err := bs.UpdateSettings(ctx, blog.Title, "Notes on gardening, code and everything in between"); err != nil { + if err := bs.UpdateSettings(ctx, blog.Title, "Notes on gardening, code and everything in between", blog.Language); err != nil { return err } home, err := bs.HomePage(ctx) diff --git a/internal/db/migrations/blog/00002_language.sql b/internal/db/migrations/blog/00002_language.sql new file mode 100644 index 0000000..1c83165 --- /dev/null +++ b/internal/db/migrations/blog/00002_language.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- The language of the dashboard and of the blog's fixed text (dates, archive months…). +-- Validated in Go against the supported list, so adding a language needs no migration. +ALTER TABLE settings ADD COLUMN language text NOT NULL DEFAULT 'en'; + +-- +goose Down +ALTER TABLE settings DROP COLUMN language; diff --git a/internal/i18n/el.go b/internal/i18n/el.go new file mode 100644 index 0000000..55b6cf6 --- /dev/null +++ b/internal/i18n/el.go @@ -0,0 +1,429 @@ +package i18n + +// el is the Greek catalog, keyed by the English text. TestGreekCatalogComplete +// (package web) fails when a key used by a template or handler is missing here, +// or when an entry is no longer used. +var el = map[string]string{ + // ---- dashboard chrome ---- + "superadmin": "υπερδιαχειριστής", + "blogger": "συντάκτης", + "Admin": "Διαχείριση", + "Password": "Κωδικός", + "Log out": "Αποσύνδεση", + "Log in": "Σύνδεση", + "Username": "Όνομα χρήστη", + "Overview": "Επισκόπηση", + "Posts": "Δημοσιεύσεις", + "Pages": "Σελίδες", + "Announcements": "Ανακοινώσεις", + "Layout": "Διάταξη", + "Design": "Σχεδίαση", + "Images": "Εικόνες", + "Settings": "Ρυθμίσεις", + "View blog": "Προβολή ιστολογίου", + "View page": "Προβολή σελίδας", + "View post": "Προβολή δημοσίευσης", + "view": "προβολή", + "edit": "επεξεργασία", + "delete": "διαγραφή", + "remove": "αφαίρεση", + "Remove": "Αφαίρεση", + "Save": "Αποθήκευση", + "Add": "Προσθήκη", + "Back": "Πίσω", + "Cancel": "Άκυρο", + "Create": "Δημιουργία", + "Set": "Ορισμός", + "Hide": "Απόκρυψη", + "Show": "Εμφάνιση", + "Filter": "Φιλτράρισμα", + "Upload": "Μεταφόρτωση", + "Confirm": "Επιβεβαίωση", + "Move up": "Μετακίνηση πάνω", + "Move down": "Μετακίνηση κάτω", + "Order": "Σειρά", + "Title": "Τίτλος", + "Page": "Σελίδα", + "Page:": "Σελίδα:", + "Date": "Ημερομηνία", + "Address": "Διεύθυνση", + "Address:": "Διεύθυνση:", + "Text": "Κείμενο", + "Content": "Περιεχόμενο", + "Heading": "Επικεφαλίδα", + "hidden": "κρυφό", + "home": "αρχική", + "optional": "προαιρετικό", + "untitled": "χωρίς τίτλο", + "you": "εσείς", + "tab.": "καρτέλα.", + "your blog": "το ιστολόγιό σας", + "to see it.": "για να τη δείτε.", + "to see them.": "για να τις δείτε.", + + // ---- overview ---- + "Your blog is live at": "Το ιστολόγιό σας είναι δημόσιο στη διεύθυνση", + "Every change you save here shows up there as soon as you refresh.": "Κάθε αλλαγή που αποθηκεύετε εδώ εμφανίζεται εκεί μόλις ανανεώσετε τη σελίδα.", + "Quick actions": "Γρήγορες ενέργειες", + "Write a new post": "Γράψτε νέα δημοσίευση", + "Add a page": "Προσθήκη σελίδας", + "Post an announcement": "Νέα ανακοίνωση", + "Arrange the layout": "Ρύθμιση διάταξης", + "Change the look": "Αλλαγή εμφάνισης", + "Upload images": "Μεταφόρτωση εικόνων", + "%d posts": "%d δημοσιεύσεις", + "hidden from menu": "εκτός μενού", + "Latest posts": "Τελευταίες δημοσιεύσεις", + "All posts": "Όλες οι δημοσιεύσεις", + "No posts yet.": "Δεν υπάρχουν δημοσιεύσεις ακόμα.", + "Write the first one.": "Γράψτε την πρώτη.", + + // ---- posts ---- + "New post": "Νέα δημοσίευση", + "Edit post": "Επεξεργασία δημοσίευσης", + "All pages": "Όλες οι σελίδες", + "No posts here yet.": "Δεν υπάρχουν δημοσιεύσεις εδώ ακόμα.", + "optional, made from the title": "προαιρετικό, φτιάχνεται από τον τίτλο", + "Visible on the blog": "Ορατή στο ιστολόγιο", + "Back to posts": "Πίσω στις δημοσιεύσεις", + "Post deleted.": "Η δημοσίευση διαγράφηκε.", + "the post “%s”": "τη δημοσίευση «%s»", + "Title is required (max 200 characters).": "Ο τίτλος είναι υποχρεωτικός (έως 200 χαρακτήρες).", + "Slug may only contain lowercase letters, digits and dashes.": "Η διεύθυνση επιτρέπει μόνο πεζά λατινικά γράμματα, ψηφία και παύλες.", + "Pick a page for this post.": "Επιλέξτε σελίδα για τη δημοσίευση.", + "Post is too long (200 KB max).": "Η δημοσίευση είναι πολύ μεγάλη (έως 200 KB).", + "A post with that slug already exists on this page; choose another slug.": "Υπάρχει ήδη δημοσίευση με αυτή τη διεύθυνση στη σελίδα· διαλέξτε άλλη.", + "Saved. Refresh your blog to see it.": "Αποθηκεύτηκε. Ανανεώστε το ιστολόγιό σας για να το δείτε.", + "Image not added:": "Η εικόνα δεν προστέθηκε:", + + // ---- pages ---- + "New page": "Νέα σελίδα", + "Edit page": "Επεξεργασία σελίδας", + "Each page holds its own list of posts. Which pages are in the menu, and in what order, is set on the": "Κάθε σελίδα έχει τη δική της λίστα δημοσιεύσεων. Ποιες σελίδες μπαίνουν στο μενού, και με ποια σειρά, ορίζεται στην", + "not in menu": "εκτός μενού", + "Make home": "Ορισμός ως αρχικής", + "leave empty to make one from the title; e.g.": "αφήστε το κενό για να φτιαχτεί από τον τίτλο· π.χ.", + "Intro text": "Εισαγωγικό κείμενο", + "Markdown, shown above the posts; optional": "Markdown, εμφανίζεται πάνω από τις δημοσιεύσεις· προαιρετικό", + "Show in menu": "Εμφάνιση στο μενού", + "Back to pages": "Πίσω στις σελίδες", + "Page deleted.": "Η σελίδα διαγράφηκε.", + "“%s” is now the home page.": "Η «%s» είναι τώρα η αρχική σελίδα.", + "the page “%s” and all %d of its posts": "τη σελίδα «%s» και όλες τις %d δημοσιεύσεις της", + "Title is required (max 120 characters).": "Ο τίτλος είναι υποχρεωτικός (έως 120 χαρακτήρες).", + "Slug may only contain lowercase letters, digits and dashes (and not be a reserved word).": "Η διεύθυνση επιτρέπει μόνο πεζά λατινικά γράμματα, ψηφία και παύλες (και δεν μπορεί να είναι δεσμευμένη λέξη).", + "A page with that slug already exists.": "Υπάρχει ήδη σελίδα με αυτή τη διεύθυνση.", + "The home page cannot be deleted. Make another page the home page first.": "Η αρχική σελίδα δεν διαγράφεται. Ορίστε πρώτα άλλη σελίδα ως αρχική.", + "This is the home page; it cannot be deleted. Make another page the home page first.": "Αυτή είναι η αρχική σελίδα και δεν διαγράφεται. Ορίστε πρώτα άλλη σελίδα ως αρχική.", + + // ---- confirm ---- + "Are you sure?": "Είστε σίγουροι;", + "You are about to permanently delete %s. This cannot be undone.": "Πρόκειται να διαγράψετε οριστικά %s. Δεν υπάρχει αναίρεση.", + "Yes, delete": "Ναι, διαγραφή", + + // ---- announcements ---- + "New announcement": "Νέα ανακοίνωση", + "Edit announcement": "Επεξεργασία ανακοίνωσης", + "Announcement": "Ανακοίνωση", + "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.": "Ειδοποιήσεις που εμφανίζονται σε κάθε σελίδα και δημοσίευση του ιστολογίου — η επόμενη συνάντηση, το βιβλίο του μήνα, οι διακοπές. Αν θα την ξαναχρειαστείτε, κρύψτε την αντί να τη διαγράψετε.", + "Where": "Πού", + "Look": "Εμφάνιση", + "Position": "Θέση", + "top": "πάνω", + "bottom": "κάτω", + "At the top": "Στην κορυφή", + "At the bottom": "Στο τέλος", + "Main content": "Κύριο περιεχόμενο", + "Main content (with the posts)": "Κύριο περιεχόμενο (μαζί με τις δημοσιεύσεις)", + "Left column": "Αριστερή στήλη", + "Right column": "Δεξιά στήλη", + "Highlighted": "Τονισμένη", + "Warning": "Προειδοποίηση", + "Plain": "Απλή", + "Next meeting": "Επόμενη συνάντηση", + "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see": "«Κορυφή» μιας πλαϊνής στήλης σημαίνει πάνω από τα στοιχεία της, «τέλος» κάτω από αυτά. Αν η στήλη δεν εμφανίζεται στο ιστολόγιό σας (βλ.", + "the announcement moves to the main content instead.": "η ανακοίνωση μεταφέρεται στο κύριο περιεχόμενο.", + "Shown on the blog": "Εμφανίζεται στο ιστολόγιο", + "Back to announcements": "Πίσω στις ανακοινώσεις", + "No announcements yet.": "Δεν υπάρχουν ανακοινώσεις ακόμα.", + "Announcement deleted.": "Η ανακοίνωση διαγράφηκε.", + "Announcement hidden.": "Η ανακοίνωση κρύφτηκε.", + "Announcement shown. Refresh your blog to see it.": "Η ανακοίνωση εμφανίζεται. Ανανεώστε το ιστολόγιό σας για να τη δείτε.", + "this announcement": "αυτή την ανακοίνωση", + "the announcement “%s”": "την ανακοίνωση «%s»", + "Title is too long (max 120 characters).": "Ο τίτλος είναι πολύ μεγάλος (έως 120 χαρακτήρες).", + "Write some text for the announcement.": "Γράψτε κάποιο κείμενο για την ανακοίνωση.", + "Announcement is too long (20 KB max).": "Η ανακοίνωση είναι πολύ μεγάλη (έως 20 KB).", + + // ---- images ---- + "Images are stored with your blog. Put one in a post with": "Οι εικόνες αποθηκεύονται μαζί με το ιστολόγιό σας. Βάλτε μία σε δημοσίευση με το", + "in the editor, or copy its line from below.": "στον επεξεργαστή κειμένου, ή αντιγράψτε τη γραμμή της από παρακάτω.", + "Upload an image": "Μεταφόρτωση εικόνας", + "PNG, JPEG, GIF, WebP or ICO": "PNG, JPEG, GIF, WebP ή ICO", + "No images yet.": "Δεν υπάρχουν εικόνες ακόμα.", + "Uploaded %s.": "Η εικόνα %s μεταφορτώθηκε.", + "Image deleted.": "Η εικόνα διαγράφηκε.", + "Choose a file first.": "Επιλέξτε πρώτα ένα αρχείο.", + "Upload too large or malformed form.": "Η μεταφόρτωση είναι πολύ μεγάλη ή η φόρμα ελαττωματική.", + "Image is too large (max %s).": "Η εικόνα είναι πολύ μεγάλη (έως %s).", + "Only PNG, JPEG, GIF, WebP and ICO images are accepted.": "Γίνονται δεκτές μόνο εικόνες PNG, JPEG, GIF, WebP και ICO.", + + // ---- editor ---- + "Insert image": "Εισαγωγή εικόνας", + "…or paste / drop an image into the text.": "…ή επικολλήστε / σύρετε μια εικόνα μέσα στο κείμενο.", + "The image is added at the end of the text when you save.": "Η εικόνα προστίθεται στο τέλος του κειμένου όταν αποθηκεύσετε.", + "Uploading": "Μεταφόρτωση", + "upload failed": "η μεταφόρτωση απέτυχε", + "Inserted": "Προστέθηκε η", + "Could not upload:": "Αποτυχία μεταφόρτωσης:", + "Formatting cheat-sheet": "Οδηγός μορφοποίησης", + "Smaller heading": "Μικρότερη επικεφαλίδα", + "bold": "έντονα", + "italic": "πλάγια", + "link text": "κείμενο συνδέσμου", + "description": "περιγραφή", + "writes this for you": "το γράφει για εσάς", + "list item": "στοιχείο λίστας", + "numbered": "αριθμημένο", + "quote": "παράθεση", + "code": "κώδικας", + "for a line": "για γραμμή", + "Blank line = new paragraph. Press Enter once for a line break.": "Κενή γραμμή = νέα παράγραφος. Ένα Enter για αλλαγή γραμμής.", + + // ---- settings ---- + "Blog settings": "Ρυθμίσεις ιστολογίου", + "Blog title": "Τίτλος ιστολογίου", + "Tagline": "Υπότιτλος", + "shown under the title": "εμφανίζεται κάτω από τον τίτλο", + "Language": "Γλώσσα", + "of the dashboard, dates and the blog's fixed text": "του πίνακα διαχείρισης, των ημερομηνιών και του σταθερού κειμένου του ιστολογίου", + "only the administrator can change this": "μόνο ο διαχειριστής μπορεί να την αλλάξει", + "Title is required (max 120 chars); tagline max 300.": "Ο τίτλος είναι υποχρεωτικός (έως 120 χαρακτήρες)· ο υπότιτλος έως 300.", + + // ---- password ---- + "Change password": "Αλλαγή κωδικού", + "Current password": "Τρέχων κωδικός", + "New password": "Νέος κωδικός", + "Repeat new password": "Επανάληψη νέου κωδικού", + "Current password is wrong.": "Ο τρέχων κωδικός είναι λάθος.", + "New password must be at least 8 characters.": "Ο νέος κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες.", + "New passwords do not match.": "Οι νέοι κωδικοί δεν ταιριάζουν.", + "Password changed.": "Ο κωδικός άλλαξε.", + "Wrong username or password.": "Λάθος όνομα χρήστη ή κωδικός.", + + // ---- layout ---- + "Decide what goes where on your blog. Every change here is saved straight away; refresh": "Αποφασίστε τι πάει πού στο ιστολόγιό σας. Κάθε αλλαγή εδώ αποθηκεύεται αμέσως· ανανεώστε", + "Colours and fonts are on the": "Τα χρώματα και οι γραμματοσειρές είναι στην", + "Columns": "Στήλες", + "Header": "Κεφαλίδα", + "Footer": "Υποσέλιδο", + "Menu": "Μενού", + "Left": "Αριστερά", + "Right": "Δεξιά", + "Left column width": "Πλάτος αριστερής στήλης", + "Right column width": "Πλάτος δεξιάς στήλης", + "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.": "Από 15% έως 40% η καθεμία· το κύριο περιεχόμενο παίρνει το υπόλοιπο. Μια στήλη εμφανίζεται μόνο όταν έχει τουλάχιστον ένα στοιχείο· χωρίς στήλες οι δημοσιεύσεις παίρνουν όλο το πλάτος. Στα κινητά οι στήλες στοιβάζονται: πρώτα το κύριο περιεχόμενο, μετά η αριστερή και η δεξιά στήλη, μετά το υποσέλιδο.", + "Always keep the columns' space, even when a column is empty or hidden": "Να κρατιέται πάντα ο χώρος των στηλών, ακόμα κι όταν μια στήλη είναι άδεια ή κρυφή", + "the main content then keeps its width and stays centred": "το κύριο περιεχόμενο κρατά έτσι το πλάτος του και μένει κεντραρισμένο", + "Save columns": "Αποθήκευση στηλών", + "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.": "Η κορυφή κάθε σελίδας. Βάλτε τον τίτλο, ένα λογότυπο και το μενού με όποια σειρά θέλετε. Μια εικόνα-πανό επιλέγεται στην καρτέλα Σχεδίαση.", + "A narrow column beside your posts. Good for the menu, an archive or recent posts.": "Μια στενή στήλη δίπλα στις δημοσιεύσεις σας. Καλή για το μενού, το αρχείο ή τις πρόσφατες δημοσιεύσεις.", + "A narrow column on the other side of your posts. Same choices as the left one.": "Μια στενή στήλη από την άλλη πλευρά των δημοσιεύσεων. Ίδιες επιλογές με την αριστερή.", + "The bottom of every page: a copyright line, the RSS link, a list of your pages.": "Το τέλος κάθε σελίδας: μια γραμμή copyright, ο σύνδεσμος RSS, μια λίστα με τις σελίδες σας.", + "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…": "Οι δημοσιεύσεις και οι σελίδες σας εμφανίζονται πάντα εδώ. Μπορείτε να προσθέσετε δικό σας HTML πριν και μετά — ένα εισαγωγικό πλαίσιο, μια φόρμα newsletter, ένα σήμα…", + "Nothing extra.": "Τίποτα επιπλέον.", + "Add custom HTML": "Προσθήκη δικού σας HTML", + "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.": "Οι σύνδεσμοι που δείχνει το στοιχείο Μενού, όπου κι αν το βάλετε. Σελίδες και εξωτερικοί σύνδεσμοι μπορούν να αναμειχθούν με όποια σειρά θέλετε. Το «Εμφάνιση στο μενού» σε μια σελίδα κάνει το ίδιο με την προσθήκη της εδώ.", + "page": "σελίδα", + "link": "σύνδεσμος", + "The menu is empty.": "Το μενού είναι άδειο.", + "Add page": "Προσθήκη σελίδας", + "Every page is already in the menu.": "Όλες οι σελίδες είναι ήδη στο μενού.", + "Add a link": "Προσθήκη συνδέσμου", + "Add link": "Προσθήκη συνδέσμου", + "My photos": "Οι φωτογραφίες μου", + "Hide this area": "Απόκρυψη περιοχής", + "Show this area": "Εμφάνιση περιοχής", + "no logo chosen": "δεν έχει επιλεγεί λογότυπο", + "pick one on the": "επιλέξτε ένα στην", + "Nothing here yet.": "Τίποτα εδώ ακόμα.", + "Nothing here yet — this area is not shown until you add a module.": "Τίποτα εδώ ακόμα — η περιοχή δεν εμφανίζεται μέχρι να προσθέσετε ένα στοιχείο.", + "Add a module": "Προσθήκη στοιχείου", + "Columns saved. Refresh your blog to see them.": "Οι στήλες αποθηκεύτηκαν. Ανανεώστε το ιστολόγιό σας για να τις δείτε.", + "%s hidden.": "%s: κρύφτηκε.", + "%s shown again. Refresh your blog to see it.": "%s: εμφανίζεται ξανά. Ανανεώστε το ιστολόγιό σας για να τη δείτε.", + "That module cannot go in that area.": "Αυτό το στοιχείο δεν μπορεί να μπει σε αυτή την περιοχή.", + "There is already a module there; edit or remove it first.": "Υπάρχει ήδη στοιχείο εκεί· επεξεργαστείτε το ή αφαιρέστε το πρώτα.", + "Added. Now write what it should show.": "Προστέθηκε. Γράψτε τώρα τι θα δείχνει.", + "%s added. Refresh your blog to see it.": "%s: προστέθηκε. Ανανεώστε το ιστολόγιό σας για να το δείτε.", + "%s removed.": "%s: αφαιρέθηκε.", + "the “%s” module and its text": "το στοιχείο «%s» και το κείμενό του", + "Pick a page first.": "Επιλέξτε πρώτα μια σελίδα.", + "Page added to the menu. Refresh your blog to see it.": "Η σελίδα προστέθηκε στο μενού. Ανανεώστε το ιστολόγιό σας για να τη δείτε.", + "Give the link a short text (max 60 characters).": "Δώστε στον σύνδεσμο ένα σύντομο κείμενο (έως 60 χαρακτήρες).", + "That address does not look right. Use a full web address like https://example.org or a path like /about.": "Η διεύθυνση δεν φαίνεται σωστή. Χρησιμοποιήστε πλήρη διεύθυνση όπως https://example.org ή διαδρομή όπως /about.", + "Link added to the menu. Refresh your blog to see it.": "Ο σύνδεσμος προστέθηκε στο μενού. Ανανεώστε το ιστολόγιό σας για να τον δείτε.", + "Removed from the menu.": "Αφαιρέθηκε από το μενού.", + + // module and area names (web.moduleNames / areaNames) + "Blog title & tagline": "Τίτλος & υπότιτλος ιστολογίου", + "Logo": "Λογότυπο", + "Archive (posts by month)": "Αρχείο (δημοσιεύσεις ανά μήνα)", + "Recent posts": "Πρόσφατες δημοσιεύσεις", + "Custom HTML": "Δικό σας HTML", + "RSS link": "Σύνδεσμος RSS", + "Footer text": "Κείμενο υποσέλιδου", + "Site map": "Χάρτης ιστολογίου", + "Before the posts": "Πριν τις δημοσιεύσεις", + "After the posts": "Μετά τις δημοσιεύσεις", + "Archive": "Αρχείο", + "empty": "κενό", + "heading “%s”": "επικεφαλίδα «%s»", + + // ---- module form ---- + "in the %s": "— %s", + "How many posts": "Πόσες δημοσιεύσεις", + "The newest published posts from every page of your blog, newest first.": "Οι νεότερες δημοσιεύσεις από όλες τις σελίδες του ιστολογίου, με τις πιο πρόσφατες πρώτα.", + "Every published post grouped by year and month, newest first. Years and months fold open and shut; the latest year starts open.": "Όλες οι δημοσιεύσεις ανά έτος και μήνα, με τις πιο πρόσφατες πρώτα. Έτη και μήνες ανοιγοκλείνουν· το τελευταίο έτος ξεκινά ανοιχτό.", + "Links to every page of your blog.": "Σύνδεσμοι προς όλες τις σελίδες του ιστολογίου.", + "Hello!": "Γεια σας!", + "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.": "Μπαίνει στη σελίδα σας ακριβώς όπως γράφεται — ενσωματώσεις, σήματα και scripts δουλεύουν όλα. Ό,τι είναι λάθος εδώ (π.χ. μια ετικέτα που δεν κλείνει) μπορεί να χαλάσει τη σελίδα γύρω του, γι' αυτό ελέγξτε το ιστολόγιό σας μετά την αποθήκευση. Έως 20 KB.", + "© 2026 Me": "© 2026 Εγώ", + "Plain text; each line becomes its own paragraph.": "Απλό κείμενο· κάθε γραμμή γίνεται ξεχωριστή παράγραφος.", + "Back to layout": "Πίσω στη διάταξη", + "Heading is too long (max 120 characters).": "Η επικεφαλίδα είναι πολύ μεγάλη (έως 120 χαρακτήρες).", + "Content is too long (20 KB max).": "Το περιεχόμενο είναι πολύ μεγάλο (έως 20 KB).", + + // ---- design ---- + "Changes are live as soon as you save; refresh": "Οι αλλαγές ισχύουν μόλις αποθηκεύσετε· ανανεώστε", + "Schemes": "Συνδυασμοί", + "Background": "Φόντο", + "Logo & icon": "Λογότυπο & εικονίδιο", + "What appears where — header, columns, footer and their modules — is arranged on the": "Τι εμφανίζεται πού — κεφαλίδα, στήλες, υποσέλιδο και τα στοιχεία τους — ρυθμίζεται στην", + "Colour schemes": "Χρωματικοί συνδυασμοί", + "A quick start: applying a scheme sets every colour below and saves immediately. Images, fonts and layout are kept.": "Για γρήγορη αρχή: ένας συνδυασμός ορίζει όλα τα παρακάτω χρώματα και αποθηκεύεται αμέσως. Εικόνες, γραμματοσειρές και διάταξη διατηρούνται.", + "Apply %s": "Εφαρμογή: %s", + "Paper": "Χαρτί", + "Night": "Νύχτα", + "Ocean": "Ωκεανός", + "Forest": "Δάσος", + "Slate": "Σχιστόλιθος", + "Rose": "Ρόδο", + "Colour": "Χρώμα", + "Image style": "Στυλ εικόνας", + "Stretch to fill": "Τέντωμα ώστε να γεμίζει", + "Fill, fixed while scrolling": "Γέμισμα, σταθερό στην κύλιση", + "Repeat (tile)": "Επανάληψη (πλακάκι)", + "Background image": "Εικόνα φόντου", + "Text & content area": "Κείμενο & περιοχή περιεχομένου", + "Content background": "Φόντο περιεχομένου", + "Text colour": "Χρώμα κειμένου", + "Link colour": "Χρώμα συνδέσμων", + "Change colour when the mouse is over a link": "Αλλαγή χρώματος όταν το ποντίκι είναι πάνω από σύνδεσμο", + "Hover colour": "Χρώμα στο πέρασμα", + "Font": "Γραμματοσειρά", + "Heading font": "Γραμματοσειρά επικεφαλίδων", + "Same as text": "Ίδια με το κείμενο", + "Text size": "Μέγεθος κειμένου", + "Small": "Μικρό", + "Normal": "Κανονικό", + "Large": "Μεγάλο", + "Underline links": "Υπογράμμιση συνδέσμων", + "Always": "Πάντα", + "Only on hover": "Μόνο στο πέρασμα", + "Never": "Ποτέ", + "Box style": "Στυλ πλαισίου", + "Card (shadow, rounded corners)": "Κάρτα (σκιά, στρογγυλεμένες γωνίες)", + "Flat": "Επίπεδο", + "Padding": "Περιθώριο", + "inside the box": "μέσα στο πλαίσιο", + "Compact": "Σφιχτό", + "Roomy": "Άνετο", + "The content spans the whole page; how much of it the posts get is set by the column widths on the": "Το περιεχόμενο απλώνεται σε όλη τη σελίδα· πόσο από αυτό παίρνουν οι δημοσιεύσεις ορίζεται από τα πλάτη των στηλών στην", + "Alignment": "Στοίχιση", + "Centred": "Στο κέντρο", + "Image height": "Ύψος εικόνας", + "Short": "Χαμηλό", + "Medium": "Μεσαίο", + "Tall": "Ψηλό", + "The title, logo and menu are header modules — add, remove and order them on the": "Ο τίτλος, το λογότυπο και το μενού είναι στοιχεία της κεφαλίδας — προσθέστε, αφαιρέστε και ταξινομήστε τα στην", + "The banner below is shown across the top of the header whenever one is chosen.": "Το πανό παρακάτω εμφανίζεται κατά μήκος της κορυφής της κεφαλίδας όποτε έχει επιλεγεί κάποιο.", + "Header image (banner)": "Εικόνα κεφαλίδας (πανό)", + "Which links the menu shows, and where it sits, is set on the": "Ποιους συνδέσμους δείχνει το μενού, και πού βρίσκεται, ορίζεται στην", + "Here you choose how it looks.": "Εδώ επιλέγετε πώς φαίνεται.", + "menu in the header": "μενού στην κεφαλίδα", + "Link style": "Στυλ συνδέσμων", + "Uppercase, spaced": "Κεφαλαία, αραιά", + "Menu colours": "Χρώματα μενού", + "Posts & dates": "Δημοσιεύσεις & ημερομηνίες", + "Show the date on posts": "Εμφάνιση ημερομηνίας στις δημοσιεύσεις", + "Date format": "Μορφή ημερομηνίας", + "The footer text, RSS link and other footer modules live on the": "Το κείμενο υποσέλιδου, ο σύνδεσμος RSS και τα άλλα στοιχεία του υποσέλιδου βρίσκονται στην", + "Logo & site icon": "Λογότυπο & εικονίδιο ιστολογίου", + "Logo size": "Μέγεθος λογότυπου", + "Small (48 px tall)": "Μικρό (48 px ύψος)", + "Medium (80 px)": "Μεσαίο (80 px)", + "Large (140 px)": "Μεγάλο (140 px)", + "Shown wherever you place a Logo module (Layout tab); it links to your front page.": "Εμφανίζεται όπου βάλετε στοιχείο Λογότυπο (καρτέλα Διάταξη)· οδηγεί στην αρχική σας σελίδα.", + "Favicon": "Εικονίδιο (favicon)", + "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.": "Το μικρό εικονίδιο που δείχνουν οι φυλλομετρητές στην καρτέλα. Ένα τετράγωνο PNG ή ICO 32–64 px είναι ιδανικό· επιλέξτε Κανένα για το εικονίδιο του Blogspace.", + "Save design": "Αποθήκευση σχεδίασης", + "Reset to defaults": "Επαναφορά προεπιλογών", + "Puts every colour, font and layout option back to how a new blog starts and forgets the chosen images (they stay in your library).": "Επαναφέρει κάθε χρώμα, γραμματοσειρά και επιλογή διάταξης όπως ξεκινά ένα νέο ιστολόγιο και ξεχνά τις επιλεγμένες εικόνες (μένουν στη συλλογή σας).", + "Reset everything": "Επαναφορά όλων", + "None": "Κανένα", + "…or upload a new one": "…ή μεταφορτώστε καινούργια", + "Design saved. Refresh your blog to see it.": "Η σχεδίαση αποθηκεύτηκε. Ανανεώστε το ιστολόγιό σας για να τη δείτε.", + "Unknown colour scheme.": "Άγνωστος χρωματικός συνδυασμός.", + "Applied the %s scheme. Refresh your blog to see it.": "Εφαρμόστηκε ο συνδυασμός %s. Ανανεώστε το ιστολόγιό σας για να τον δείτε.", + "Design and layout reset to the defaults.": "Σχεδίαση και διάταξη επανήλθαν στις προεπιλογές.", + + // ---- admin ---- + "Users & blogs": "Χρήστες & ιστολόγια", + "New blogger": "Νέος συντάκτης", + "User": "Χρήστης", + "Role": "Ρόλος", + "Blog": "Ιστολόγιο", + "Since": "Από", + "Actions": "Ενέργειες", + "disabled": "απενεργοποιημένος", + "reset password": "επαναφορά κωδικού", + "new password": "νέος κωδικός", + "enable": "ενεργοποίηση", + "disable": "απενεργοποίηση", + "min 8 characters; they can change it later": "τουλάχιστον 8 χαρακτήρες· μπορεί να τον αλλάξει αργότερα", + "Subdomain": "Υποτομέας", + "defaults to the username": "προεπιλογή το όνομα χρήστη", + "Delete user": "Διαγραφή χρήστη", + "Delete %s?": "Διαγραφή του χρήστη %s;", + "This permanently deletes the user": "Διαγράφεται οριστικά ο χρήστης", + ", their blog, and every page, post and image in it.": ", το ιστολόγιό του και κάθε σελίδα, δημοσίευση και εικόνα σε αυτό.", + "Yes, delete everything": "Ναι, διαγραφή όλων", + "Username: 2-40 letters, digits, dots, dashes or underscores.": "Όνομα χρήστη: 2-40 λατινικά γράμματα, ψηφία, τελείες, παύλες ή κάτω παύλες.", + "Password must be at least 8 characters.": "Ο κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες.", + "Subdomain: lowercase letters, digits and single dashes; not a reserved name.": "Υποτομέας: πεζά λατινικά γράμματα, ψηφία και μονές παύλες· όχι δεσμευμένο όνομα.", + "Username or subdomain already taken.": "Το όνομα χρήστη ή ο υποτομέας χρησιμοποιείται ήδη.", + "A database %s already exists in Postgres (a leftover of a deleted blog?). Drop it or pick another subdomain.": "Υπάρχει ήδη βάση %s στην Postgres (υπόλειμμα διαγραμμένου ιστολογίου;). Διαγράψτε την ή διαλέξτε άλλον υποτομέα.", + "Created %s with blog %s.": "Δημιουργήθηκε ο χρήστης %s με ιστολόγιο %s.", + "You cannot do that to your own account here; use Account > Password.": "Δεν μπορείτε να το κάνετε αυτό στον δικό σας λογαριασμό από εδώ· χρησιμοποιήστε το Κωδικός.", + "Password reset for %s.": "Ο κωδικός του χρήστη %s άλλαξε.", + "%s enabled.": "Ο χρήστης %s ενεργοποιήθηκε.", + "%s disabled.": "Ο χρήστης %s απενεργοποιήθηκε.", + "This user owns the root blog and cannot be deleted.": "Ο χρήστης έχει το κεντρικό ιστολόγιο και δεν διαγράφεται.", + "Deleted %s and their blog.": "Διαγράφηκε ο χρήστης %s και το ιστολόγιό του.", + "Superadmin only.": "Μόνο για υπερδιαχειριστή.", + "This is not your blog.": "Αυτό δεν είναι το ιστολόγιό σας.", + "You have no blog yet. Ask the administrator to create one.": "Δεν έχετε ιστολόγιο ακόμα. Ζητήστε από τον διαχειριστή να το δημιουργήσει.", + "Could not read the form.": "Δεν ήταν δυνατή η ανάγνωση της φόρμας.", + "Form expired or invalid. Go back, reload the page and try again.": "Η φόρμα έληξε ή δεν είναι έγκυρη. Πηγαίνετε πίσω, ανανεώστε τη σελίδα και δοκιμάστε ξανά.", + "Upload too large: the limit is %d MB.": "Η μεταφόρτωση είναι πολύ μεγάλη: το όριο είναι %d MB.", + + // ---- public blog ---- + "Newer": "Νεότερες", + "Older": "Παλαιότερες", + "Page %d of %d": "Σελίδα %d από %d", + "Not found": "Δεν βρέθηκε", + "There is nothing at this address.": "Δεν υπάρχει τίποτα σε αυτή τη διεύθυνση.", + "Back to the front page.": "Πίσω στην αρχική σελίδα.", + "RSS feed": "Ροή RSS", +} diff --git a/internal/i18n/el_months.go b/internal/i18n/el_months.go new file mode 100644 index 0000000..acfd228 --- /dev/null +++ b/internal/i18n/el_months.go @@ -0,0 +1,7 @@ +package i18n + +var elMonths = months{ + name: [12]string{"Ιανουάριος", "Φεβρουάριος", "Μάρτιος", "Απρίλιος", "Μάιος", "Ιούνιος", "Ιούλιος", "Αύγουστος", "Σεπτέμβριος", "Οκτώβριος", "Νοέμβριος", "Δεκέμβριος"}, + inDate: [12]string{"Ιανουαρίου", "Φεβρουαρίου", "Μαρτίου", "Απριλίου", "Μαΐου", "Ιουνίου", "Ιουλίου", "Αυγούστου", "Σεπτεμβρίου", "Οκτωβρίου", "Νοεμβρίου", "Δεκεμβρίου"}, + short: [12]string{"Ιαν", "Φεβ", "Μαρ", "Απρ", "Μαΐ", "Ιουν", "Ιουλ", "Αυγ", "Σεπ", "Οκτ", "Νοε", "Δεκ"}, +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..4f31667 --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,110 @@ +// Package i18n holds the languages a blog can be in and the translations of +// the fixed text (dashboard, messages, dates). Keys are the English strings +// themselves, so an untranslated key renders as English rather than blank. +package i18n + +import ( + "fmt" + "strings" + "time" +) + +// Default is what new blogs get and what anything unknown falls back to. +const Default = "en" + +type Language struct { + Code, Name string // Name is in the language itself, for the settings menu +} + +var languages = []Language{ + {Code: "en", Name: "English"}, + {Code: "el", Name: "Ελληνικά"}, +} + +// catalogs maps a language to its translations; English is the identity. +var catalogs = map[string]map[string]string{ + "el": el, +} + +func Languages() []Language { return languages } + +func Valid(code string) bool { + for _, l := range languages { + if l.Code == code { + return true + } + } + return false +} + +// T translates key into lang, returning the key itself when there is no translation. +func T(lang, key string) string { + if s, ok := catalogs[lang][key]; ok { + return s + } + return key +} + +// Tf is T followed by Sprintf; translations may reorder arguments with %[n]s. +func Tf(lang, key string, args ...any) string { + return fmt.Sprintf(T(lang, key), args...) +} + +// Keys lists what a catalog translates (for the completeness test). +func Keys(lang string) []string { + out := make([]string, 0, len(catalogs[lang])) + for k := range catalogs[lang] { + out = append(out, k) + } + return out +} + +// Match picks a supported language from an Accept-Language header: the first +// tag (in header order) whose primary subtag we have, else Default. +func Match(acceptLanguage string) string { + for _, part := range strings.Split(acceptLanguage, ",") { + tag, _, _ := strings.Cut(strings.TrimSpace(part), ";") + primary, _, _ := strings.Cut(strings.ToLower(tag), "-") + if Valid(primary) { + return primary + } + } + return Default +} + +// months are the names a language needs: standalone (archive headings), in a +// date ("14 September" — Greek uses the genitive there) and abbreviated. +type months struct { + name, inDate, short [12]string +} + +var monthTables = map[string]months{ + "el": elMonths, +} + +// Month is the standalone month name (archive headings). +func Month(lang string, m time.Month) string { + if t, ok := monthTables[lang]; ok { + return t.name[m-1] + } + return m.String() +} + +// FormatDate renders a post date in the blogger's chosen style (long | short | iso). +func FormatDate(lang, style string, at time.Time) string { + if style == "iso" { + return at.Format("2006-01-02") + } + t, ok := monthTables[lang] + if !ok { // English: the Go layouts + if style == "short" { + return at.Format("2 Jan 2006") + } + return at.Format("2 January 2006") + } + m := t.inDate[at.Month()-1] + if style == "short" { + m = t.short[at.Month()-1] + } + return fmt.Sprintf("%d %s %d", at.Day(), m, at.Year()) +} diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 0000000..e735b58 --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -0,0 +1,63 @@ +package i18n + +import ( + "testing" + "time" +) + +func TestFormatDate(t *testing.T) { + at := time.Date(2026, 9, 3, 10, 0, 0, 0, time.UTC) + for _, c := range []struct{ lang, style, want string }{ + {"en", "long", "3 September 2026"}, {"en", "short", "3 Sep 2026"}, {"en", "iso", "2026-09-03"}, + {"el", "long", "3 Σεπτεμβρίου 2026"}, {"el", "short", "3 Σεπ 2026"}, {"el", "iso", "2026-09-03"}, + {"xx", "long", "3 September 2026"}, // unknown language reads as English + } { + if got := FormatDate(c.lang, c.style, at); got != c.want { + t.Errorf("%s/%s: got %q, want %q", c.lang, c.style, got, c.want) + } + } +} + +func TestMonth(t *testing.T) { + if got := Month("en", time.May); got != "May" { + t.Errorf("en: %q", got) + } + if got := Month("el", time.May); got != "Μάιος" { + t.Errorf("el: %q", got) + } + for _, tbl := range monthTables { + for i := 0; i < 12; i++ { + if tbl.name[i] == "" || tbl.inDate[i] == "" || tbl.short[i] == "" { + t.Errorf("month %d has an empty name", i+1) + } + } + } +} + +func TestTranslate(t *testing.T) { + if T("el", "Save") != "Αποθήκευση" || T("en", "Save") != "Save" || T("el", "no such key") != "no such key" { + t.Error("T") + } + if got := Tf("el", "Page %d of %d", 2, 5); got != "Σελίδα 2 από 5" { + t.Errorf("Tf: %q", got) + } + for _, l := range Languages() { + if !Valid(l.Code) { + t.Errorf("%s not valid", l.Code) + } + } + if Valid("") || Valid("fr") { + t.Error("Valid accepts unknown codes") + } +} + +func TestMatch(t *testing.T) { + for header, want := range map[string]string{ + "": "en", "fr": "en", "el": "el", "el-GR,el;q=0.9,en;q=0.8": "el", "en-US,en;q=0.9,el;q=0.8": "en", + "fr-FR, EL;q=0.5": "el", "*": "en", + } { + if got := Match(header); got != want { + t.Errorf("%q: got %q, want %q", header, got, want) + } + } +} diff --git a/internal/store/blogs.go b/internal/store/blogs.go index a2e88a8..3117154 100644 --- a/internal/store/blogs.go +++ b/internal/store/blogs.go @@ -22,6 +22,7 @@ type Blog struct { // from the blog database Title string Tagline string + Language string // i18n code, "en" by default ThemeJSON json.RawMessage UpdatedAt time.Time } @@ -38,7 +39,7 @@ func scanBlog(row interface{ Scan(...any) error }) (*Blog, error) { } func (bs *BlogStore) loadSettings(ctx context.Context, b *Blog) error { - err := bs.db.QueryRow(ctx, `SELECT title, tagline, theme, updated_at FROM settings`).Scan(&b.Title, &b.Tagline, &b.ThemeJSON, &b.UpdatedAt) + err := bs.db.QueryRow(ctx, `SELECT title, tagline, language, theme, updated_at FROM settings`).Scan(&b.Title, &b.Tagline, &b.Language, &b.ThemeJSON, &b.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { // registered, but the database is empty: not a 404 return fmt.Errorf("database %s of blog %q has no settings row (empty or wrong dump?)", b.DBName, b.Subdomain) } @@ -173,8 +174,8 @@ func (s *Store) DeleteBlog(ctx context.Context, b *Blog) error { return s.cluster.DropBlogDB(ctx, b.DBName) } -func (bs *BlogStore) UpdateSettings(ctx context.Context, title, tagline string) error { - _, err := bs.db.Exec(ctx, `UPDATE settings SET title=$1, tagline=$2, updated_at=now()`, title, tagline) +func (bs *BlogStore) UpdateSettings(ctx context.Context, title, tagline, language string) error { + _, err := bs.db.Exec(ctx, `UPDATE settings SET title=$1, tagline=$2, language=$3, updated_at=now()`, title, tagline, language) return err } 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. |
