feat(quick-261016-0fm): per-admin theme and locale preferences

- GET and PUT /account/preferences (own row only, interactive sessions,
  CSRF header on cookie writes) store admin_theme and admin_locale in
  backend_users.metadata, keeping every other key
- GET /auth/me and the My account answers carry preferences
- phrasebook.Translator.Locales lists the locales defining a prefix
- route tables, conformance cases, OpenAPI document, TS types and docs
This commit is contained in:
Jakub Zych
2026-10-16 09:26:09 +02:00
parent 1a03399357
commit 047858ae84
21 changed files with 729 additions and 12 deletions

View File

@@ -28,7 +28,7 @@ Use cases:
- Lookups by request locale (`phrasebook.Translator.Get`, read from the context through `towel.Locale`) or by explicit locale (`phrasebook.Translator.GetIn`), walking the locale, its parent tags (`pt-BR` to `pt`) and then the fallback locale. A missing key returns the key itself.
- Laravel-style placeholders: `:name`, `:Name` (first letter upper-cased) and `:NAME` (upper-cased).
- Pluralization with `phrasebook.Translator.Choice` and `phrasebook.Translator.ChoiceIn`: CLDR plural maps (`one`, `few`, `many`, `other`, ...) validated against the locale's categories, or Laravel pipes with exact (`{0}`) and range (`[2,*]`) conditions. `:count` is filled in automatically.
- Export for the admin SPA: `phrasebook.Translator.Forms` returns a key as CLDR plural forms, `phrasebook.Translator.Bundle` returns every key under a prefix, and `phrasebook.Translator.Resolved` reports which locale such a bundle mostly comes from. Activation fails if a `backend::` string cannot be expressed as CLDR forms.
- Export for the admin SPA: `phrasebook.Translator.Forms` returns a key as CLDR plural forms, `phrasebook.Translator.Bundle` returns every key under a prefix, `phrasebook.Translator.Resolved` reports which locale such a bundle mostly comes from, and `phrasebook.Translator.Locales` lists the locales that define keys under a prefix (the admin's supported languages are `Locales("backend::lang.")`). Activation fails if a `backend::` string cannot be expressed as CLDR forms.
- Missing keys are logged once per key through `log/slog`, except in the production environment.
## Usage
@@ -104,6 +104,7 @@ func Title(ctx context.Context, app *backpack.App) string {
| `phrasebook.Translator.Forms` | Returns a key as CLDR plural forms for the admin SPA. |
| `phrasebook.Translator.Bundle` | Returns all keys under a prefix as CLDR forms, merged over the fallback chain. |
| `phrasebook.Translator.Resolved` | Returns the first locale in the fallback chain that has keys under a prefix. |
| `phrasebook.Translator.Locales` | Returns the sorted locale codes that define at least one key under a prefix. |
## Configuration

View File

@@ -19,6 +19,11 @@ nav:
expand: Expand menu
user_menu: User menu
view_site: View site
theme: "Theme: :mode"
theme_system: System
theme_light: Light
theme_dark: Dark
language: Language
page:
not_found: Page not found.
list:
@@ -341,6 +346,8 @@ myaccount:
current_password_invalid: The current password is incorrect.
field_not_editable: This field cannot be changed on My account.
subtitle_unavailable: The subtitle cannot be saved because the stored profile metadata is not a JSON object.
preferences_unavailable: The preference cannot be saved because the stored profile metadata is not a JSON object.
preferences_failed: Your preference could not be saved.
saved: Account saved
avatar: Avatar
avatar_upload: Upload picture

View File

@@ -21,6 +21,11 @@ nav:
expand: Rozwiń menu
user_menu: Menu użytkownika
view_site: Zobacz stronę
theme: "Motyw: :mode"
theme_system: Systemowy
theme_light: Jasny
theme_dark: Ciemny
language: Język
page:
not_found: Nie znaleziono strony.
list:
@@ -363,6 +368,8 @@ myaccount:
current_password_invalid: Obecne hasło jest nieprawidłowe.
field_not_editable: Tego pola nie można zmienić w ustawieniach konta.
subtitle_unavailable: Nie można zapisać podtytułu, ponieważ zapisane metadane profilu nie są obiektem JSON.
preferences_unavailable: Nie można zapisać ustawienia, ponieważ zapisane metadane profilu nie są obiektem JSON.
preferences_failed: Nie udało się zapisać ustawienia.
saved: Zapisano konto
avatar: Awatar
avatar_upload: Wgraj zdjęcie

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
"sync"
@@ -533,6 +534,26 @@ func (t *Translator) Resolved(locale, prefix string) string {
return t.fallbackLocale()
}
// Locales returns the sorted locale codes whose catalog defines at least one
// key starting with prefix: the locales a Bundle under prefix exists in. A
// nil translator or catalog returns nil.
func (t *Translator) Locales(prefix string) []string {
if t == nil || t.cat == nil {
return nil
}
var out []string
for locale, byKey := range t.cat.messages {
for key := range byKey {
if strings.HasPrefix(key, prefix) {
out = append(out, locale)
break
}
}
}
slices.Sort(out)
return out
}
// Has reports whether any locale of the catalog defines key.
func (t *Translator) Has(key string) bool {
if t == nil || t.cat == nil {