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. +

+ +
+
+ Gearberg Equipment-Management-Oberfläche +
+
+
+ +{{/* 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.

+
+ +
+
+

Selbst hosten

+

Kostenlos

+

Auf eigenem Server mit Docker betreiben

+
    +
  • {{ template "success-icon" . }} Equipment-Verwaltung
  • +
  • {{ template "success-icon" . }} Kits & Kombinationen
  • +
  • {{ template "success-icon" . }} Import & Export
  • +
+ +
+
+ +
+
+

Cloud

+

Auf Anfrage

+

Die Software ist kostenlos. Du zahlst für den Komfort, sie nicht selbst betreiben zu müssen.

+
    +
  • {{ template "success-icon" . }} Equipment-Verwaltung
  • +
  • {{ template "success-icon" . }} Kits & Kombinationen
  • +
  • {{ template "success-icon" . }} Import & Export
  • +
+ +
+
+ +
+
+
+ +{{/* Quickstart */}} +
+
+

Quickstart

+

Mit einem einzigen Docker-Befehl ausprobieren:

+ +
+ +
docker run -p 8080:8080 ghcr.io/bit8bytes/gearberg serve
+
+ + +

Dann http://localhost:8080 im Browser öffnen.

+
+
+ +{{/* FAQ */}} +
+
+

Häufige Fragen

+
+ +
+ Für wen ist Gearberg gedacht? +
+

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.