feat(i18n): EN/DE translations, PageCtx, slide-out settings drawer

This commit is contained in:
Claus Lohmar 2026-06-14 18:21:54 +00:00
parent 16b933bb07
commit b7e5467369
4 changed files with 337 additions and 123 deletions

87
src/core/i18n/i18n.go Normal file
View file

@ -0,0 +1,87 @@
package i18n
// T holds all translation keys.
type T map[string]string
var translations = map[string]T{
"en": {
"greeting_morning": "Good morning",
"greeting_afternoon": "Good afternoon",
"greeting_evening": "Good evening",
"workspace_ready": "Your workspace is ready",
"install_app": "Install app",
"settings": "Settings",
"logout": "Logout",
"language": "Language",
"timezone": "Timezone",
"save": "Save",
"available": "Available",
"coming_soon": "Coming Soon",
"users_groups": "Users, groups & settings",
"storage_sharing": "Storage & sharing",
"email_client": "Email client",
"schedule_events": "Schedule & events",
"documents_sheets": "Documents & sheets",
"people_directory": "People & directory",
"project_management": "Project management",
"team_messaging": "Team messaging",
"pwa_desktop_title": "Desktop Chrome/Edge",
"pwa_desktop_step1": "Click the ⊕ icon in the address bar",
"pwa_desktop_step2": "Click Install",
"pwa_ios_title": "iOS Safari",
"pwa_ios_step1": "Tap Share ⎋",
"pwa_ios_step2": "Tap Add to Home Screen",
"pwa_android_title": "Android Chrome",
"pwa_android_step1": "Tap ⋮ menu",
"pwa_android_step2": "Tap Install app",
"pwa_got_it": "Got it",
"install_workspace": "Install Next Workspace",
},
"de": {
"greeting_morning": "Guten Morgen",
"greeting_afternoon": "Guten Tag",
"greeting_evening": "Guten Abend",
"workspace_ready": "Ihr Arbeitsbereich ist bereit",
"install_app": "App installieren",
"settings": "Einstellungen",
"logout": "Abmelden",
"language": "Sprache",
"timezone": "Zeitzone",
"save": "Speichern",
"available": "Verfügbar",
"coming_soon": "Demnächst",
"users_groups": "Benutzer, Gruppen & Einstellungen",
"storage_sharing": "Speicher & Freigabe",
"email_client": "E-Mail-Client",
"schedule_events": "Termine & Ereignisse",
"documents_sheets": "Dokumente & Tabellen",
"people_directory": "Personen & Verzeichnis",
"project_management": "Projektmanagement",
"team_messaging": "Team-Chat",
"pwa_desktop_title": "Desktop Chrome/Edge",
"pwa_desktop_step1": "Klicken Sie auf das ⊕-Symbol in der Adressleiste",
"pwa_desktop_step2": "Klicken Sie auf Installieren",
"pwa_ios_title": "iOS Safari",
"pwa_ios_step1": "Tippen Sie auf Teilen ⎋",
"pwa_ios_step2": "Tippen Sie auf Zum Home-Bildschirm",
"pwa_android_title": "Android Chrome",
"pwa_android_step1": "Tippen Sie auf ⋮ Menü",
"pwa_android_step2": "Tippen Sie auf App installieren",
"pwa_got_it": "Verstanden",
"install_workspace": "Next Workspace installieren",
},
}
// Get returns the translation map for a language code.
func Get(lang string) T {
t, ok := translations[lang]
if !ok {
t = translations["en"]
}
return t
}
// Supported returns the list of supported language codes.
func Supported() []string {
return []string{"en", "de"}
}

View file

@ -3,21 +3,27 @@ package ui
import ( import (
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings"
"git.lohmar.co.uk/lexton-it/NextWks/core/auth" "git.lohmar.co.uk/lexton-it/NextWks/core/auth"
"git.lohmar.co.uk/lexton-it/NextWks/core/i18n"
) )
// Handler serves the workspace launcher UI. // PageCtx holds page-level data passed to templates.
type PageCtx struct {
UserID string
Role string
Locale i18n.T
}
type Handler struct { type Handler struct {
appDir string appDir string
} }
// NewHandler creates a UI handler that serves the launcher and static assets.
func NewHandler(appDir string) *Handler { func NewHandler(appDir string) *Handler {
return &Handler{appDir: appDir} return &Handler{appDir: appDir}
} }
// RegisterRoutes mounts the public UI routes on the given mux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux, authGate func(http.Handler) http.Handler) { func (h *Handler) RegisterRoutes(mux *http.ServeMux, authGate func(http.Handler) http.Handler) {
staticDir := filepath.Join(h.appDir, "static") staticDir := filepath.Join(h.appDir, "static")
mux.Handle("GET /static/", http.StripPrefix("/static", http.FileServer(http.Dir(staticDir)))) mux.Handle("GET /static/", http.StripPrefix("/static", http.FileServer(http.Dir(staticDir))))
@ -30,18 +36,35 @@ func (h *Handler) launcherPage(w http.ResponseWriter, r *http.Request) {
return return
} }
userID, _ := auth.GetUserID(r) ctx := getContext(r)
role := getUserRole(r) apps := DefaultApps(ctx.Role)
apps := DefaultApps(role) component := LauncherPage(ctx, apps)
component := LauncherPage(userID, role, apps)
component.Render(r.Context(), w) component.Render(r.Context(), w)
} }
// getUserRole extracts the user's role from the request context or session. func getContext(r *http.Request) PageCtx {
func getUserRole(r *http.Request) string { userID, _ := auth.GetUserID(r)
if role, ok := r.Context().Value(auth.ContextRole).(string); ok { role, _ := r.Context().Value(auth.ContextRole).(string)
return role if role == "" {
role = "user"
}
// Detect language: cookie > header > default
lang := "en"
if cookie, err := r.Cookie("lang"); err == nil {
lang = cookie.Value
} else if al := r.Header.Get("Accept-Language"); al != "" {
for _, l := range i18n.Supported() {
if strings.HasPrefix(al, l) {
lang = l
break
}
}
}
return PageCtx{
UserID: userID,
Role: role,
Locale: i18n.Get(lang),
} }
return "user"
} }

View file

@ -2,7 +2,7 @@ package ui
import appver "git.lohmar.co.uk/lexton-it/NextWks/core/version" import appver "git.lohmar.co.uk/lexton-it/NextWks/core/version"
templ LauncherPage(userName string, role string, apps []AppTile) { templ LauncherPage(c PageCtx, apps []AppTile) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -18,9 +18,9 @@ templ LauncherPage(userName string, role string, apps []AppTile) {
</head> </head>
<body> <body>
<div class="shell"> <div class="shell">
@topBar(userName, role) @topBar(c)
<main class="content"> <main class="content">
@greetingSection(userName) @greetingSection(c)
@AppGrid(apps) @AppGrid(apps)
</main> </main>
<footer class="footer"> <footer class="footer">
@ -45,19 +45,19 @@ templ LauncherPage(userName string, role string, apps []AppTile) {
<div class="drawer-overlay" id="settings-overlay" onclick="closeSettings()"></div> <div class="drawer-overlay" id="settings-overlay" onclick="closeSettings()"></div>
<aside class="drawer" id="settings-drawer"> <aside class="drawer" id="settings-drawer">
<div class="drawer-head"> <div class="drawer-head">
<h2>Settings</h2> <h2>{ c.Locale["settings"] }</h2>
<button class="close-btn" onclick="closeSettings()">&times;</button> <button class="close-btn" onclick="closeSettings()">&times;</button>
</div> </div>
<div class="drawer-body"> <div class="drawer-body">
<div class="setting-group"> <div class="setting-group">
<label>Language</label> <label>{ c.Locale["language"] }</label>
<select id="setting-lang" class="form-select"> <select id="setting-lang" class="form-select">
<option value="en">English</option> <option value="en">English</option>
<option value="de">Deutsch</option> <option value="de">Deutsch</option>
</select> </select>
</div> </div>
<div class="setting-group"> <div class="setting-group">
<label>Timezone</label> <label>{ c.Locale["timezone"] }</label>
<select id="setting-tz" class="form-select"> <select id="setting-tz" class="form-select">
<option value="UTC">UTC</option> <option value="UTC">UTC</option>
<option value="Europe/London">London</option> <option value="Europe/London">London</option>
@ -66,7 +66,7 @@ templ LauncherPage(userName string, role string, apps []AppTile) {
<option value="America/New_York">New York</option> <option value="America/New_York">New York</option>
</select> </select>
</div> </div>
<button class="btn" onclick="saveSettings()">Save</button> <button class="btn" onclick="saveSettings()">{ c.Locale["save"] }</button>
</div> </div>
</aside> </aside>
@ -75,37 +75,37 @@ templ LauncherPage(userName string, role string, apps []AppTile) {
</html> </html>
} }
templ greetingSection(userName string) { templ greetingSection(page PageCtx) {
<div class="greeting"> <div class="greeting">
<div class="avatar">{ initials(userName) }</div> <div class="avatar">{ initials(page.UserID) }</div>
<div class="greeting-text"> <div class="greeting-text">
<h1>Good { timeOfDay() }, { userName }</h1> <h1>{ page.Locale["greeting_morning"] }, { page.UserID }</h1>
<p>Your workspace is ready</p> <p>{ page.Locale["workspace_ready"] }</p>
</div> </div>
</div> </div>
} }
templ topBar(userName string, role string) { templ topBar(page PageCtx) {
<header class="topbar"> <header class="topbar">
<div class="topbar-left"> <div class="topbar-left">
<div class="logo-icon">N</div> <div class="logo-icon">N</div>
<span class="logo-text">NextWks</span> <span class="logo-text">NextWks</span>
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
<button class="icon-btn" onclick="showPWA()" title="Install app"> <button class="icon-btn" onclick="showPWA()" title={ page.Locale["install_app"] }>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button> </button>
<div class="user-menu" onclick="toggleUserMenu(event)"> <div class="user-menu" onclick="toggleUserMenu(event)">
<div class="avatar sm">{ initials(userName) }</div> <div class="avatar sm">{ initials(page.UserID) }</div>
<div class="dropdown" id="userDropdown"> <div class="dropdown" id="userDropdown">
<div class="dropdown-header">{ userName }</div> <div class="dropdown-header">{ page.UserID }</div>
<div class="dropdown-item" onclick="window.showSettings()"> <div class="dropdown-item" onclick="window.showSettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l-.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l-.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
Settings { page.Locale["settings"] }
</div> </div>
<div class="dropdown-item" onclick="window.location.href='/auth/logout'"> <div class="dropdown-item" onclick="window.location.href='/auth/logout'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Logout { page.Locale["logout"] }
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because one or more lines are too long