feat(i18n): EN/DE translations, PageCtx, slide-out settings drawer
This commit is contained in:
parent
16b933bb07
commit
b7e5467369
4 changed files with 337 additions and 123 deletions
87
src/core/i18n/i18n.go
Normal file
87
src/core/i18n/i18n.go
Normal 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"}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()">×</button>
|
<button class="close-btn" onclick="closeSettings()">×</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
Loading…
Reference in a new issue