feat: global settings, multi-language (en/de), company branding on launcher + www
This commit is contained in:
parent
857ce833cf
commit
9790d9338a
6 changed files with 228 additions and 81 deletions
|
|
@ -3,81 +3,42 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NextWorkspace — Your Self-Hosted Workspace for Startups</title>
|
||||
<title>NextWorkspace</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f0f2f5;
|
||||
color: #1a1a2e;
|
||||
min-height: 100vh;
|
||||
}
|
||||
header {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #fff;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
header h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
||||
header p { color: #a0aec0; font-size: 1.2rem; }
|
||||
.domain { color: #63b3ed; font-size: 0.9rem; margin-top: 0.5rem; }
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
|
||||
h2 { font-size: 1.5rem; margin: 2rem 0 1rem; color: #2d3748; }
|
||||
ul { list-style: none; padding: 0; }
|
||||
li {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||
}
|
||||
li a { color: #1a1a2e; text-decoration: none; font-weight: 600; font-size: 1.05rem; }
|
||||
li a:hover { color: #63b3ed; }
|
||||
li span { color: #718096; font-size: 0.9rem; margin-left: 0.5rem; }
|
||||
.ai-credit {
|
||||
background: #edf2f7;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: #4a5568;
|
||||
}
|
||||
.ai-credit strong { color: #1a1a2e; }
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #a0aec0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #1a1a2e; color: #fff; display: flex; align-items: center;
|
||||
justify-content: center; min-height: 100vh; margin: 0; }
|
||||
.hero { text-align: center; max-width: 600px; padding: 2rem; }
|
||||
.hero h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
||||
.hero p { color: #a0aec0; font-size: 1.2rem; margin-bottom: 2rem; }
|
||||
.hero img { max-height: 80px; margin-bottom: 1rem; }
|
||||
.btn { display: inline-block; padding: 0.75rem 2rem; background: #63b3ed;
|
||||
color: #fff; text-decoration: none; border-radius: 8px; font-weight: 500; }
|
||||
.links { margin-top: 3rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
|
||||
.links a { color: #a0aec0; text-decoration: none; font-size: 0.9rem; }
|
||||
.links a:hover { color: #63b3ed; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>NextWorkspace</h1>
|
||||
<p>Your Self-Hosted Workspace for Startups</p>
|
||||
<div class="domain">nextwks.eu</div>
|
||||
</header>
|
||||
<div class="container">
|
||||
<h2>Components</h2>
|
||||
<ul>
|
||||
<li><a href="https://app.nextwks.eu/home">NextWks Core</a><span>— Launcher & Workspace Hub</span></li>
|
||||
<li><a href="https://app.nextwks.eu/drive">OpenCloud</a><span>— File Storage</span></li>
|
||||
<li><a href="https://app.nextwks.eu/connect">Alps Webmail</a><span>— Email Client</span></li>
|
||||
<li><a href="https://app.nextwks.eu/office">Euro Office</a><span>— Collaborative Suite</span></li>
|
||||
<li><a href="https://app.nextwks.eu/enterprise">ERPNext</a><span>— Enterprise ERP</span></li>
|
||||
<li><a href="https://app.nextwks.eu/chat">Element Web</a><span>— Matrix Chat</span></li>
|
||||
<li><a href="https://app.nextwks.eu/meet">Jitsi</a><span>— Video Conferencing</span></li>
|
||||
<li><a href="https://app.nextwks.eu/aida">Open WebUI</a><span>— AI Chat Frontend</span></li>
|
||||
</ul>
|
||||
|
||||
<div class="ai-credit">
|
||||
<h2>Built with AI</h2>
|
||||
<p>NextWorkspace was developed with assistance from AI coding tools, using <strong>DeepSeek</strong> as the provider and <strong>OpenCode</strong> as the development framework.</p>
|
||||
<div class="hero">
|
||||
<img id="company-logo" style="display:none" alt="Logo">
|
||||
<h1 id="company-name">NextWorkspace</h1>
|
||||
<p id="company-subtitle">Your Self-Hosted Workspace for Startups</p>
|
||||
<a class="btn" href="https://app.nextwks.eu/">Launch Workspace</a>
|
||||
<div class="links">
|
||||
<a href="https://auth.nextwks.eu/">Admin Login</a>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
© 2026 NextWorkspace — nextwks.eu
|
||||
</footer>
|
||||
<script>
|
||||
fetch('/api/settings/public')
|
||||
.then(r => r.json())
|
||||
.then(s => {
|
||||
if (s.logo) { const img = document.getElementById('company-logo');
|
||||
img.src = s.logo; img.style.display = 'block'; }
|
||||
document.getElementById('company-name').textContent = s.name;
|
||||
document.getElementById('company-subtitle').textContent = s.subtitle;
|
||||
document.title = s.name;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
32
lng/de/admin.yaml
Normal file
32
lng/de/admin.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
page_title: "Admin Bereich"
|
||||
nav_global: "Global"
|
||||
nav_access: "Zugriff"
|
||||
nav_security: "Sicherheit"
|
||||
nav_domain: "Domain"
|
||||
nav_mail: "E-Mail"
|
||||
nav_docs: "Dokumente"
|
||||
nav_calendar: "Kalender"
|
||||
save: "Einstellungen speichern"
|
||||
saved: "✓ Gespeichert"
|
||||
cancel: "Abbrechen"
|
||||
company_section: "Unternehmen"
|
||||
company_name: "Unternehmensname"
|
||||
company_subtitle: "Untertitel"
|
||||
company_logo: "Logo URL"
|
||||
language: "Standardsprache"
|
||||
timezone: "Zeitzone"
|
||||
smtp_section: "SMTP"
|
||||
smtp_host: "SMTP Host"
|
||||
smtp_port: "SMTP Port"
|
||||
smtp_user: "SMTP Benutzer"
|
||||
smtp_password: "SMTP Passwort"
|
||||
smtp_sender: "Absender E-Mail"
|
||||
system_section: "System (schreibgeschützt)"
|
||||
domain_label: "Domain"
|
||||
admin_email: "Admin E-Mail"
|
||||
authelia_status: "Authelia"
|
||||
version_label: "Version"
|
||||
global_title: "Globale Einstellungen"
|
||||
access_title: "Zugriffsverwaltung"
|
||||
users_label: "Benutzer"
|
||||
create_user: "Benutzer anlegen"
|
||||
8
lng/de/launcher.yaml
Normal file
8
lng/de/launcher.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
app_title: "NextWorkspace"
|
||||
welcome: "Willkommen, {user}"
|
||||
login: "Anmelden"
|
||||
logout: "Abmelden"
|
||||
launcher_title: "Ihr Arbeitsbereich"
|
||||
no_apps: "Keine Anwendungen verfügbar"
|
||||
admin_panel: "Admin Bereich"
|
||||
settings: "Einstellungen"
|
||||
32
lng/en/admin.yaml
Normal file
32
lng/en/admin.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
page_title: "Admin Panel"
|
||||
nav_global: "Global"
|
||||
nav_access: "Access"
|
||||
nav_security: "Security"
|
||||
nav_domain: "Domain"
|
||||
nav_mail: "Mail"
|
||||
nav_docs: "Docs"
|
||||
nav_calendar: "Calendar"
|
||||
save: "Save Settings"
|
||||
saved: "✓ Saved"
|
||||
cancel: "Cancel"
|
||||
company_section: "Company"
|
||||
company_name: "Company Name"
|
||||
company_subtitle: "Subtitle"
|
||||
company_logo: "Logo URL"
|
||||
language: "Default Language"
|
||||
timezone: "Timezone"
|
||||
smtp_section: "SMTP"
|
||||
smtp_host: "SMTP Host"
|
||||
smtp_port: "SMTP Port"
|
||||
smtp_user: "SMTP User"
|
||||
smtp_password: "SMTP Password"
|
||||
smtp_sender: "Sender Email"
|
||||
system_section: "System (read-only)"
|
||||
domain_label: "Domain"
|
||||
admin_email: "Admin Email"
|
||||
authelia_status: "Authelia"
|
||||
version_label: "Version"
|
||||
global_title: "Global Settings"
|
||||
access_title: "Access Management"
|
||||
users_label: "Users"
|
||||
create_user: "Create User"
|
||||
8
lng/en/launcher.yaml
Normal file
8
lng/en/launcher.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
app_title: "NextWorkspace"
|
||||
welcome: "Welcome, {user}"
|
||||
login: "Sign In"
|
||||
logout: "Logout"
|
||||
launcher_title: "Your Workspace"
|
||||
no_apps: "No applications available"
|
||||
admin_panel: "Admin Panel"
|
||||
settings: "Settings"
|
||||
126
main.go
126
main.go
|
|
@ -51,6 +51,8 @@ type AppsFile struct {
|
|||
type Settings struct {
|
||||
Company struct {
|
||||
Name string `yaml:"name"`
|
||||
Subtitle string `yaml:"subtitle"`
|
||||
Logo string `yaml:"logo"`
|
||||
Language string `yaml:"language"`
|
||||
Timezone string `yaml:"timezone"`
|
||||
} `yaml:"company"`
|
||||
|
|
@ -188,12 +190,22 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
|
||||
tmpl := template.Must(template.New("launcher").Parse(launcherHTML))
|
||||
funcMap := template.FuncMap{"t": func(key string, args ...string) string {
|
||||
lang := "en"
|
||||
settings, _ := loadSettings(os.Getenv("CONFIG_DIR"))
|
||||
if settings != nil && settings.Company.Language != "" {
|
||||
lang = settings.Company.Language
|
||||
}
|
||||
return T(lang, key, args...)
|
||||
}}
|
||||
tmpl := template.Must(template.New("launcher").Funcs(funcMap).Parse(launcherHTML))
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user := r.Header.Get("Remote-User")
|
||||
groupsHeader := r.Header.Get("Remote-Groups")
|
||||
userGroups := strings.Split(groupsHeader, ",")
|
||||
lang := getUserLang(r)
|
||||
settings, _ := loadSettings(os.Getenv("CONFIG_DIR"))
|
||||
|
||||
var allowedApps []AppEntry
|
||||
for _, app := range apps {
|
||||
|
|
@ -208,12 +220,18 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
|
|||
User string
|
||||
IsAdmin bool
|
||||
Apps []AppEntry
|
||||
Lang string
|
||||
CompanyLogo string
|
||||
Subtitle string
|
||||
}{
|
||||
AppName: cfg.App.Name,
|
||||
Description: cfg.App.Description,
|
||||
User: user,
|
||||
IsAdmin: strings.Contains(groupsHeader, "admins"),
|
||||
Apps: allowedApps,
|
||||
Lang: lang,
|
||||
CompanyLogo: settings.Company.Logo,
|
||||
Subtitle: settings.Company.Subtitle,
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl.Execute(w, data)
|
||||
|
|
@ -273,7 +291,10 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
tmpl := template.Must(template.New("admin").Parse(adminHTML))
|
||||
funcMap := template.FuncMap{"t": func(key string) string {
|
||||
return T(settings.Company.Language, key)
|
||||
}}
|
||||
tmpl := template.Must(template.New("admin").Funcs(funcMap).Parse(adminHTML))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
autheliaUp := false
|
||||
|
|
@ -287,6 +308,7 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
|
|||
"Domain": domain,
|
||||
"AdminEmail": adminEmail,
|
||||
"AutheliaUp": autheliaUp,
|
||||
"T": func(key string) string { return T(settings.Company.Language, key) },
|
||||
})
|
||||
|
||||
case http.MethodPost:
|
||||
|
|
@ -397,6 +419,8 @@ func globalSettingsSaveHandler(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
settings.Company.Name = r.FormValue("company_name")
|
||||
settings.Company.Subtitle = r.FormValue("company_subtitle")
|
||||
settings.Company.Logo = r.FormValue("company_logo")
|
||||
settings.Company.Language = r.FormValue("language")
|
||||
settings.Company.Timezone = r.FormValue("timezone")
|
||||
settings.SMTP.Host = r.FormValue("smtp_host")
|
||||
|
|
@ -436,6 +460,82 @@ func applySettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||
fmt.Fprintf(w, `{"success":true,"output":"%s"}`, strings.ReplaceAll(string(output), `"`, `\"`))
|
||||
}
|
||||
|
||||
// --- Public settings API ---
|
||||
|
||||
func publicSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
configDir := os.Getenv("CONFIG_DIR")
|
||||
if configDir == "" {
|
||||
configDir = "/opt/nextworkspace/config/nextworkspace"
|
||||
}
|
||||
settings, _ := loadSettings(configDir)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"name":"%s","subtitle":"%s","logo":"%s"}`,
|
||||
settings.Company.Name, settings.Company.Subtitle, settings.Company.Logo)
|
||||
}
|
||||
|
||||
// --- Translation system ---
|
||||
|
||||
type Translations map[string]string
|
||||
|
||||
var translationCache map[string]Translations
|
||||
|
||||
func loadTranslations(lang string) Translations {
|
||||
if translationCache != nil {
|
||||
if cached, ok := translationCache[lang]; ok {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
basePath := filepath.Join(os.Getenv("CONFIG_DIR"), "../../lng", lang)
|
||||
result := Translations{}
|
||||
files := []string{"launcher.yaml", "admin.yaml"}
|
||||
for _, f := range files {
|
||||
data, err := os.ReadFile(filepath.Join(basePath, f))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var m map[string]string
|
||||
yaml.Unmarshal(data, &m)
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
if translationCache == nil {
|
||||
translationCache = make(map[string]Translations)
|
||||
}
|
||||
translationCache[lang] = result
|
||||
return result
|
||||
}
|
||||
|
||||
func T(lang, key string, args ...string) string {
|
||||
t := loadTranslations(lang)
|
||||
text, ok := t[key]
|
||||
if !ok {
|
||||
en := loadTranslations("en")
|
||||
text = en[key]
|
||||
if text == "" {
|
||||
return key
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(args); i += 2 {
|
||||
if i+1 < len(args) {
|
||||
text = strings.ReplaceAll(text, "{"+args[i]+"}", args[i+1])
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func getUserLang(r *http.Request) string {
|
||||
headerLang := r.Header.Get("Remote-Lang")
|
||||
if headerLang == "de" || headerLang == "en" {
|
||||
return headerLang
|
||||
}
|
||||
settings, _ := loadSettings(os.Getenv("CONFIG_DIR"))
|
||||
if settings != nil && settings.Company.Language == "de" {
|
||||
return "de"
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// --- Templates ---
|
||||
|
||||
const landingPageHTML = `<!DOCTYPE html>
|
||||
|
|
@ -495,7 +595,7 @@ const landingPageHTML = `<!DOCTYPE html>
|
|||
</html>`
|
||||
|
||||
const launcherHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{.Lang}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
|
@ -509,6 +609,7 @@ const launcherHTML = `<!DOCTYPE html>
|
|||
min-height: 100vh;
|
||||
}
|
||||
header { background: linear-gradient(135deg, #1a1a2e, #16213e); color: #fff; padding: 2rem; text-align: center; }
|
||||
header .logo { max-height: 64px; margin-bottom: 0.5rem; }
|
||||
header h1 { font-size: 2rem; margin-bottom: 0.25rem; }
|
||||
header p { color: #a0aec0; font-size: 1rem; }
|
||||
.user-banner { background: #2d3748; color: #e2e8f0; padding: 0.75rem 2rem; text-align: center; font-size: 0.9rem; display: flex; justify-content: center; gap: 1rem; }
|
||||
|
|
@ -524,13 +625,15 @@ const launcherHTML = `<!DOCTYPE html>
|
|||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{if .CompanyLogo}}<img src="{{.CompanyLogo}}" class="logo" alt="Logo">{{end}}
|
||||
<h1>{{.AppName}}</h1>
|
||||
<p>{{.Description}}</p>
|
||||
{{if .Subtitle}}<p>{{.Subtitle}}</p>{{end}}
|
||||
</header>
|
||||
<div class="user-banner">
|
||||
<span>Welcome, {{.User}}</span>
|
||||
<a href="/settings">⚙ Settings</a>
|
||||
<a href="https://auth.nextwks.eu/logout">Logout</a>
|
||||
<span>{{t "welcome" "user" .User}}</span>
|
||||
{{if .IsAdmin}}<a href="/config">{{t "admin_panel"}}</a>{{end}}
|
||||
<a href="/settings">{{t "settings"}}</a>
|
||||
<a href="https://auth.nextwks.eu/logout">{{t "logout"}}</a>
|
||||
</div>
|
||||
<div class="grid">
|
||||
{{range .Apps}}
|
||||
|
|
@ -828,10 +931,12 @@ const adminHTML = `<!DOCTYPE html>
|
|||
<form id="global-form" onsubmit="return saveGlobal(event)">
|
||||
<div class="card">
|
||||
<h3>Company</h3>
|
||||
<div class="field"><label>Company Name</label><input type="text" name="company_name" value="{{.Settings.Company.Name}}"></div>
|
||||
<div class="field"><label>{{.T "company_name"}}</label><input type="text" name="company_name" value="{{.Settings.Company.Name}}"></div>
|
||||
<div class="field"><label>{{.T "company_subtitle"}}</label><input type="text" name="company_subtitle" value="{{.Settings.Company.Subtitle}}"></div>
|
||||
<div class="field"><label>{{.T "company_logo"}}</label><input type="url" name="company_logo" value="{{.Settings.Company.Logo}}" placeholder="https://example.com/logo.png"></div>
|
||||
<div class="field-row">
|
||||
<div class="field"><label>Language</label><select name="language"><option value="en" {{if eq .Settings.Company.Language "en"}}selected{{end}}>English</option><option value="de" {{if eq .Settings.Company.Language "de"}}selected{{end}}>Deutsch</option></select></div>
|
||||
<div class="field"><label>Timezone</label><select name="timezone">{{$tz := .Settings.Company.Timezone}}<option value="UTC" {{if eq $tz "UTC"}}selected{{end}}>UTC</option><option value="Europe/London" {{if eq $tz "Europe/London"}}selected{{end}}>Europe/London</option><option value="Europe/Berlin" {{if eq $tz "Europe/Berlin"}}selected{{end}}>Europe/Berlin</option><option value="America/New_York" {{if eq $tz "America/New_York"}}selected{{end}}>America/New_York</option><option value="Asia/Tokyo" {{if eq $tz "Asia/Tokyo"}}selected{{end}}>Asia/Tokyo</option></select></div>
|
||||
<div class="field"><label>{{.T "language"}}</label><select name="language"><option value="en" {{if eq .Settings.Company.Language "en"}}selected{{end}}>English</option><option value="de" {{if eq .Settings.Company.Language "de"}}selected{{end}}>Deutsch</option></select></div>
|
||||
<div class="field"><label>{{.T "timezone"}}</label><select name="timezone">{{$tz := .Settings.Company.Timezone}}<option value="UTC" {{if eq $tz "UTC"}}selected{{end}}>UTC</option><option value="Europe/London" {{if eq $tz "Europe/London"}}selected{{end}}>Europe/London</option><option value="Europe/Berlin" {{if eq $tz "Europe/Berlin"}}selected{{end}}>Europe/Berlin</option><option value="America/New_York" {{if eq $tz "America/New_York"}}selected{{end}}>America/New_York</option><option value="Asia/Tokyo" {{if eq $tz "Asia/Tokyo"}}selected{{end}}>Asia/Tokyo</option></select></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
|
|
@ -1040,6 +1145,7 @@ func main() {
|
|||
|
||||
// Public
|
||||
mux.HandleFunc("/health", healthHandler)
|
||||
mux.HandleFunc("/api/settings/public", publicSettingsHandler)
|
||||
|
||||
// Protectected: launcher
|
||||
if companyName != "" {
|
||||
|
|
|
|||
Loading…
Reference in a new issue