diff --git a/cmd/www/handlers.go b/cmd/www/handlers.go
new file mode 100644
index 0000000..29d4725
--- /dev/null
+++ b/cmd/www/handlers.go
@@ -0,0 +1,75 @@
+// Copyright (C) 2026 bit8bytes
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+package main
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/bit8bytes/gearberg/internal/httperr"
+ "github.com/bit8bytes/gearberg/internal/locale"
+ "github.com/bit8bytes/gearberg/internal/templates/pages"
+ "golang.org/x/text/language"
+)
+
+func (app *application) getLanding(w http.ResponseWriter, r *http.Request) *httperr.Error {
+ data := app.html.TemplateData(r)
+ if locale.TagFrom(r.Context()) == language.German {
+ return app.html.Render(w, r, http.StatusOK, pages.LandingDE, data)
+ }
+ return app.html.Render(w, r, http.StatusOK, pages.Landing, data)
+}
+
+func (app *application) postLocale(w http.ResponseWriter, r *http.Request) {
+ locale := r.FormValue("locale")
+ http.SetCookie(w, &http.Cookie{
+ Name: localeCookieName,
+ Value: locale,
+ Path: "/",
+ MaxAge: 365 * 24 * 60 * 60,
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+ ref := "/"
+ if u, err := url.Parse(r.Referer()); err == nil && u.Path != "" {
+ safe := url.URL{Path: u.Path, RawQuery: u.RawQuery}
+ ref = safe.String()
+ }
+ http.Redirect(w, r, ref, http.StatusSeeOther)
+}
+
+func (app *application) getImprint(w http.ResponseWriter, r *http.Request) *httperr.Error {
+ data := app.html.TemplateData(r)
+ return app.html.Render(w, r, http.StatusOK, pages.Imprint, data)
+}
+
+func (app *application) getPrivacy(w http.ResponseWriter, r *http.Request) *httperr.Error {
+ data := app.html.TemplateData(r)
+ return app.html.Render(w, r, http.StatusOK, pages.Privacy, data)
+}
+
+func getLLMsTxt(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ _, _ = fmt.Fprint(w, "# Gearberg\n\nOpen-source equipment tracking and rental management. Self-host with Docker. Licensed under AGPL-3.0.\n\n## Links\n\n- [GitHub](https://github.com/bit8bytes/gearberg)\n- [Specification](https://github.com/bit8bytes/gearberg/blob/main/wiki/SPECS.md)\n- [License](https://www.gnu.org/licenses/agpl-3.0.html)\n")
+}
+
+func getRobotsTxt(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ _, _ = fmt.Fprint(w, "User-agent: *\nAllow: /\n")
+}
diff --git a/cmd/www/main.go b/cmd/www/main.go
index b976e23..0e41e19 100644
--- a/cmd/www/main.go
+++ b/cmd/www/main.go
@@ -25,8 +25,11 @@ import (
"syscall"
htmlpkg "github.com/bit8bytes/gearberg/internal/html"
+ _ "github.com/bit8bytes/gearberg/internal/translations"
)
+const localeCookieName = "locale"
+
type application struct {
logger *slog.Logger
options *options
diff --git a/cmd/www/middleware.go b/cmd/www/middleware.go
index 1536a7e..399742a 100644
--- a/cmd/www/middleware.go
+++ b/cmd/www/middleware.go
@@ -15,6 +15,7 @@
package main
import (
+ "context"
"errors"
"fmt"
"log/slog"
@@ -22,11 +23,27 @@ import (
"runtime/debug"
"strings"
+ "github.com/bit8bytes/gearberg/internal/locale"
"github.com/bit8bytes/gearberg/internal/nonce"
"github.com/bit8bytes/gearberg/internal/trace"
"github.com/bit8bytes/gearberg/pkg/tokens"
+ "golang.org/x/text/language"
)
+func withLocale(next http.Handler) http.Handler {
+ tags := []language.Tag{language.English, language.German}
+ matcher := language.NewMatcher(tags)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ loc := "en-US"
+ if c, err := r.Cookie(localeCookieName); err == nil {
+ loc = c.Value
+ }
+ tag, _ := language.MatchStrings(matcher, loc, r.Header.Get("Accept-Language"))
+ ctx := context.WithValue(r.Context(), locale.Key{}, tag)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
func withTrace(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := trace.NewContext(r.Context(), tokens.Generate().Hex())
diff --git a/cmd/www/routes.go b/cmd/www/routes.go
index 6664fdd..6ad4ca2 100644
--- a/cmd/www/routes.go
+++ b/cmd/www/routes.go
@@ -15,13 +15,9 @@
package main
import (
- "fmt"
"net/http"
- "time"
"github.com/bit8bytes/gearberg/internal/assets"
- "github.com/bit8bytes/gearberg/internal/httperr"
- "github.com/bit8bytes/gearberg/internal/templates/pages"
)
func (app *application) routes() http.Handler {
@@ -29,20 +25,13 @@ func (app *application) routes() http.Handler {
mux.Handle("GET /dist/", assets.ServeStaticFiles())
mux.Handle("GET /favicon.ico", http.RedirectHandler("/dist/images/favicon.ico", http.StatusMovedPermanently))
- mux.HandleFunc("GET /robots.txt", func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- w.Header().Set("Cache-Control", "public, max-age=86400")
- _, _ = fmt.Fprint(w, "User-agent: *\nAllow: /\n")
- })
- mux.HandleFunc("GET /llms.txt", func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- w.Header().Set("Cache-Control", "public, max-age=86400")
- _, _ = fmt.Fprint(w, "# Gearberg\n\nOpen-source equipment tracking and rental management. Self-host with Docker. Licensed under AGPL-3.0.\n\n## Links\n\n- [GitHub](https://github.com/bit8bytes/gearberg)\n- [Specification](https://github.com/bit8bytes/gearberg/blob/main/wiki/SPECS.md)\n- [License](https://www.gnu.org/licenses/agpl-3.0.html)\n")
- })
+ mux.HandleFunc("GET /robots.txt", getRobotsTxt)
+ mux.HandleFunc("GET /llms.txt", getLLMsTxt)
mux.HandleFunc("/", app.html.Handle(app.getLanding))
mux.HandleFunc("GET /imprint", app.html.Handle(app.getImprint))
mux.HandleFunc("GET /privacy", app.html.Handle(app.getPrivacy))
+ mux.HandleFunc("POST /locale", app.postLocale)
antiCSRF := http.NewCrossOriginProtection()
logRequest := newRequestLogger(app.logger)
@@ -54,27 +43,6 @@ func (app *application) routes() http.Handler {
logRequest.handler(
withSecurityHeaders(
withMaxBodySize(
- antiCSRF.Handler(mux)))))))
-}
-
-func (app *application) getLanding(w http.ResponseWriter, r *http.Request) *httperr.Error {
- data := app.html.TemplateData(r)
- data.Data = struct {
- Year int
- }{
- Year: time.Now().Year(),
- }
- return app.html.Render(w, r, http.StatusOK, pages.Landing, data)
-}
-
-func (app *application) getImprint(w http.ResponseWriter, r *http.Request) *httperr.Error {
- data := app.html.TemplateData(r)
- data.Data = struct{ Year int }{Year: time.Now().Year()}
- return app.html.Render(w, r, http.StatusOK, pages.Imprint, data)
-}
-
-func (app *application) getPrivacy(w http.ResponseWriter, r *http.Request) *httperr.Error {
- data := app.html.TemplateData(r)
- data.Data = struct{ Year int }{Year: time.Now().Year()}
- return app.html.Render(w, r, http.StatusOK, pages.Privacy, data)
+ antiCSRF.Handler(
+ withLocale(mux))))))))
}
diff --git a/cmd/www/setup.go b/cmd/www/setup.go
index 1ffa07f..04b33c6 100644
--- a/cmd/www/setup.go
+++ b/cmd/www/setup.go
@@ -21,6 +21,7 @@ import (
"log"
"log/slog"
"path/filepath"
+ "time"
"github.com/bit8bytes/gearberg/internal/templates"
"github.com/bit8bytes/gearberg/internal/templates/pages"
@@ -48,7 +49,11 @@ func includeSourceFile(_ []string, a slog.Attr) slog.Attr {
}
func templateFuncs() template.FuncMap {
- return template.FuncMap{}
+ return template.FuncMap{
+ "year": func() int {
+ return time.Now().Year()
+ },
+ }
}
func parseTemplates() (*template.Template, map[string]*template.Template, error) {
@@ -57,7 +62,7 @@ func parseTemplates() (*template.Template, map[string]*template.Template, error)
return nil, nil, fmt.Errorf("base template: %w", err)
}
- allPages := []pages.Page{pages.Landing, pages.Imprint, pages.Privacy, pages.Error, pages.NotFound}
+ allPages := []pages.Page{pages.Landing, pages.LandingDE, pages.Imprint, pages.Privacy, pages.Error, pages.NotFound}
tmpls := make(map[string]*template.Template, len(allPages))
for _, page := range allPages {
t, err := pageTemplate(templates.EmbedFS, base, page)
diff --git a/go.mod b/go.mod
index 6101f43..9a985c7 100644
--- a/go.mod
+++ b/go.mod
@@ -22,6 +22,7 @@ require (
golang.org/x/image v0.43.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.21.0
+ golang.org/x/text v0.38.0
modernc.org/sqlite v1.50.1
)
@@ -51,7 +52,6 @@ require (
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
- golang.org/x/text v0.38.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
diff --git a/internal/assets/dist/index.css b/internal/assets/dist/index.css
index 41f9cfe..3883c45 100644
--- a/internal/assets/dist/index.css
+++ b/internal/assets/dist/index.css
@@ -8070,9 +8070,6 @@
.mask-repeat {
mask-repeat: repeat;
}
- .stroke-current {
- stroke: currentcolor;
- }
.object-contain {
object-fit: contain;
}
diff --git a/internal/html/html.go b/internal/html/html.go
index ea06a7b..0825293 100644
--- a/internal/html/html.go
+++ b/internal/html/html.go
@@ -28,6 +28,7 @@ import (
"github.com/bit8bytes/gearberg/internal/flash"
"github.com/bit8bytes/gearberg/internal/httperr"
+ "github.com/bit8bytes/gearberg/internal/locale"
"github.com/bit8bytes/gearberg/internal/nonce"
"github.com/bit8bytes/gearberg/internal/templates/pages"
"github.com/bit8bytes/gearberg/internal/trace"
@@ -53,6 +54,8 @@ type TemplateData struct {
OrgID string
// Flash is a one-shot notification popped from the session and rendered as a toast.
Flash *flash.Flash
+ // Locale is the active locale tag string (e.g. "en-US", "de"), read from the locale cookie.
+ Locale string
}
// Option configures an HTML renderer.
@@ -157,5 +160,6 @@ func (rnd *HTML) TemplateData(r *http.Request) *TemplateData {
Revision: rnd.revision,
OrgID: r.PathValue("org_id"),
Flash: rnd.flash.Pop(r.Context()),
+ Locale: locale.TagFrom(r.Context()).String(),
}
}
diff --git a/internal/locale/locale.go b/internal/locale/locale.go
new file mode 100644
index 0000000..b90c941
--- /dev/null
+++ b/internal/locale/locale.go
@@ -0,0 +1,28 @@
+// Package locale resolves a user's preferred language and vends pre-built
+// message.Printers. It is transport-agnostic: cookie middleware, session
+// middleware, and tests all call WithTag to store the resolved tag; handlers
+// read it back with TagFrom or PrinterFrom.
+package locale
+
+import (
+ "context"
+
+ "golang.org/x/text/language"
+ "golang.org/x/text/message"
+)
+
+// Key for storing the resolved language tag.
+type Key struct{}
+
+// TagFrom returns the resolved tag stored in ctx, falling back to English.
+func TagFrom(ctx context.Context) language.Tag {
+ if tag, ok := ctx.Value(Key{}).(language.Tag); ok {
+ return tag
+ }
+ return language.English
+}
+
+// PrinterFrom returns a Printer for the tag stored in ctx, falling back to English.
+func PrinterFrom(ctx context.Context) *message.Printer {
+ return message.NewPrinter(TagFrom(ctx))
+}
diff --git a/internal/templates/pages/landing.de.tmpl b/internal/templates/pages/landing.de.tmpl
new file mode 100644
index 0000000..275dc3c
--- /dev/null
+++ b/internal/templates/pages/landing.de.tmpl
@@ -0,0 +1,282 @@
+{{ define "title" }}Equipment-Management-Software{{ end }}
+{{ define "description" }}Behalte den Überblick über dein AV-Equipment. Open-Source, kein Vendor-Lock-in.{{ end }}
+
+{{ define "seo" }}
+
+
+
+
+
+
+
+{{ end }}
+
+{{ define "page" }}
+
+{{/* Hero Section */}}
+
+
+
+
+ Open-Source AV-Equipment-Software
+
+
+ Einfache Equipment-Verwaltung für Einzelpersonen und kleine Teams. Keine Tabellen, kein Vendor-Risiko. Selbst hosten mit einem einzigen Docker-Befehl.
+
+
+
+
+
+
+
+
+
+{{/* Why use Gearberg? */}}
+
+
+
+
Warum Gearberg?
+
Die meisten Equipment-Programme sind für Grossunternehmen gemacht. Gearberg gibt Einzelpersonen und kleinen Teams genau das, was sie brauchen: klare, nützliche Übersicht ohne Komplexität, Schulungsaufwand oder hohe Kosten.
+
+
+
+
+ {{ template "check-icon" . }}
+
+
Immer wissen, was du hast
+
Alle Geräte an einem Ort. Modell, Seriennummer, Standort – sofort abrufbar, ohne in Tabellen zu suchen.
+
+
+
+
+ {{ template "check-icon" . }}
+
+
Weiss, was dein Inventar wert ist
+
Den Gesamtwert deines Equipments auf einen Blick. Hilfreich für Versicherungen, Kaufentscheidungen und Gespräche mit dem Steuerberater.
+
+
+
+
+ {{ template "check-icon" . }}
+
+
Deine Daten, deine Bedingungen
+
Bestehende Equipment-Listen per CSV importieren und jederzeit exportieren. Nichts bleibt in Gearberg eingeschlossen.
+
+
+
+
+ {{ template "check-icon" . }}
+
+
Kits, die zusammen unterwegs sind
+
Kabel, Stative und Scheinwerfer zu einem Kit zusammenfassen. Das ganze Set als Einheit verwalten und sofort sehen, ob etwas fehlt.
+
+
+
+
+ {{ template "check-icon" . }}
+
+
Open Source, selbst hostbar
+
AGPL v3. Auf eigenem Server betreiben, den Code lesen, beitragen. Kein Vendor-Risiko. Auf GitHub ansehen.
+
+
+
+
+
+
+
+{{/* Pricing */}}
+
+
+
Preise
+
Open Source ist kostenlos. Wenn du keinen Server verwalten möchtest, frag einfach bei uns an.
Einzelpersonen und kleine Teams, die einen klaren Überblick über ihr Equipment brauchen – ohne den Aufwand einer Enterprise-Asset-Management-Software.
+
+
+
+
+ Welche Probleme löst Gearberg?
+
+
Zu wissen, was man besitzt, wo es gerade ist, was es wert ist und ob es verfügbar ist. Ohne Tabellen und ohne teure Software.
+
+
+
+
+ Kann ich meine Daten exportieren?
+
+
Ja. Die Equipment-Liste lässt sich jederzeit als CSV exportieren. Die Daten gehören dir und werden nie in Gearberg eingeschlossen.
+
+
+
+
+ Unter welcher Lizenz steht Gearberg?
+
+
Gearberg steht unter der AGPL-3.0. Nutzung, Studium, Änderung und Weitergabe sind erlaubt. Änderungen, die über ein Netzwerk bereitgestellt werden, müssen unter derselben Lizenz veröffentlicht werden.
+
+
+
+
+ Was, wenn ich doch lieber wieder Tabellen nutzen möchte?
+
+
Kein Problem. Die vollständige Equipment-Liste lässt sich jederzeit als CSV exportieren und mitnehmen, auch zurück in eine Tabelle. Wir nennen das die Zurück-zur-Tabelle-Garantie, und wir meinen sie ernst.
+
+
+
+
+
+
+
+
+{{/* Closing CTA */}}
+
+
+
Immer wissen, wo dein Equipment ist.
+
Gearberg in unter einer Minute selbst starten – oder melde dich, wenn wir es für dich betreiben sollen.
+
+
+
+ {{ template "chevron-up" . }}
+
+
+
+{{ end }}
diff --git a/internal/templates/pages/landing.tmpl b/internal/templates/pages/landing.tmpl
index da357cc..e979aef 100644
--- a/internal/templates/pages/landing.tmpl
+++ b/internal/templates/pages/landing.tmpl
@@ -1,13 +1,13 @@
-{{ define "title" }}Equipment & Rental Management{{ end }}
-{{ define "description" }}Track your gear. Run your rentals. Open-source with no lock-in.{{ end }}
+{{ define "title" }}Equipment Management Software{{ end }}
+{{ define "description" }}Know what you own, where it is, and what it's worth. Open-source equipment tracking with no lock-in.{{ end }}
{{ define "seo" }}
-
-
+
+
-
-
+
+
{{ end }}
@@ -31,12 +31,10 @@
- Easy to use and affordable Rentman alternative
+ Open-Source AV Equipment Software
- Simple equipment tracking and rentals.
- No lock-in.
- Self-host with a single Docker command.
+ Simple equipment tracking for individuals and small teams. No spreadsheets, no vendor risk. Self-host with a single Docker command.