From ba2884e215293ca6132c13c84f12451a65407150 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Fri, 4 Sep 2026 20:39:22 -0400 Subject: [PATCH 01/13] chore(nixos): manually add gotext to dev shell because gotext is not available as nixpkg --- nixos/shells/dev.nix | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nixos/shells/dev.nix b/nixos/shells/dev.nix index 79e54ba..dd48c27 100644 --- a/nixos/shells/dev.nix +++ b/nixos/shells/dev.nix @@ -23,6 +23,10 @@ pkgs.mkShellNoCC { addlicense ]; shellHook = '' + # Manually install gotext because it is not available in nixpkgs yet. + export PATH="$PATH:$(go env GOPATH)/bin" + go install golang.org/x/text/cmd/gotext@latest + echo "Welcome to the dev shell! All required tools are available." ''; } From c78465974738571364a869c8478c97db1128cf59 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Fri, 4 Sep 2026 20:58:35 -0400 Subject: [PATCH 02/13] chore(localizer): added localizer that implements message printers to translate messages. --- internal/localizer/localizer.go | 81 +++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 internal/localizer/localizer.go diff --git a/internal/localizer/localizer.go b/internal/localizer/localizer.go new file mode 100644 index 0000000..b864577 --- /dev/null +++ b/internal/localizer/localizer.go @@ -0,0 +1,81 @@ +// Package localizer resolves a user's preferred language from a cookie or +// Accept-Language header and makes a pre-built message.Printer available via +// the Localizer map. Keeping the Printer in the Localizer (not the context) +// makes the dependency explicit in handler signatures. +package localizer + +import ( + "context" + "net/http" + + _ "github.com/bit8bytes/gearberg/internal/translations" + + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +type tagKey struct{} + +// TagFrom returns the resolved tag stored by the middleware. Falls back to +// English when the middleware has not run. +func TagFrom(ctx context.Context) language.Tag { + tag, ok := ctx.Value(tagKey{}).(language.Tag) + if !ok { + return language.English + } + return tag +} + +// Localizer resolves language tags and vends pre-built message.Printers. +// Construct with New; the zero value is not usable. +type Localizer struct { + cookieName string + tags []language.Tag + printers map[language.Tag]*message.Printer +} + +// New builds a Localizer for the given supported tags. Panics on empty input +// because a localizer with no languages is always misconfigured. +func New(tags ...language.Tag) *Localizer { + if len(tags) == 0 { + panic("at least one language tag must be provided") + } + printers := make(map[language.Tag]*message.Printer) + for _, tag := range tags { + printers[tag] = message.NewPrinter(tag) + } + return &Localizer{cookieName: "locale", tags: tags, printers: printers} +} + +// Printer returns the pre-built Printer for the given tag, falling back to +// English so callers never have to handle a nil printer. +func (l *Localizer) Printer(tag language.Tag) *message.Printer { + if p, ok := l.printers[tag]; ok { + return p + } + return l.printers[language.English] +} + +// Handler is an http.Handler middleware that reads the locale cookie and +// Accept-Language header, resolves the best-matching tag, and stores only the +// tag in the request context. Handlers fetch the Printer explicitly via Printer(tag). +func (l *Localizer) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var locale string + if c, err := r.Cookie(l.cookieName); err == nil { + locale = c.Value + } + tag := l.Resolve(locale, r.Header.Get("Accept-Language")) + ctx := context.WithValue(r.Context(), tagKey{}, tag) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// Resolve picks the best supported tag from an explicit locale string and an +// Accept-Language header value. Exposed so tests and non-HTTP code can resolve +// a tag without constructing a fake request. +func (l *Localizer) Resolve(locale, acceptLanguage string) language.Tag { + matcher := language.NewMatcher(l.tags) + tag, _ := language.MatchStrings(matcher, locale, acceptLanguage) + return tag +} From 77b6b7987d31ff7bff256c4efc6e587ce27bdfaa Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Fri, 4 Sep 2026 20:59:57 -0400 Subject: [PATCH 03/13] chore(main): initialized localizer to use printers in the handlers. --- cmd/www/main.go | 17 +++++++++++------ go.mod | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/cmd/www/main.go b/cmd/www/main.go index b976e23..fd59811 100644 --- a/cmd/www/main.go +++ b/cmd/www/main.go @@ -24,13 +24,17 @@ import ( "os/signal" "syscall" + "golang.org/x/text/language" + htmlpkg "github.com/bit8bytes/gearberg/internal/html" + "github.com/bit8bytes/gearberg/internal/localizer" ) type application struct { - logger *slog.Logger - options *options - html *htmlpkg.HTML + logger *slog.Logger + options *options + html *htmlpkg.HTML + localizer *localizer.Localizer } func main() { @@ -64,9 +68,10 @@ func run() error { } app := &application{ - logger: log, - options: options, - html: htmlpkg.New(log, base, cache, revision), + logger: log, + options: options, + html: htmlpkg.New(log, base, cache, revision), + localizer: localizer.New(language.English, language.German), } return app.serve(ctx) 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 From 2798adfcb4f818323c0125d246d555c704634ac1 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Fri, 4 Sep 2026 21:01:19 -0400 Subject: [PATCH 04/13] refactor(setup): use year as template funcs to remove the dependency from the TemplateData struct. --- cmd/www/setup.go | 7 ++++++- internal/templates/partials/landing-footer.tmpl | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cmd/www/setup.go b/cmd/www/setup.go index 1ffa07f..fa3aba0 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) { diff --git a/internal/templates/partials/landing-footer.tmpl b/internal/templates/partials/landing-footer.tmpl index 311bbdb..92022d2 100644 --- a/internal/templates/partials/landing-footer.tmpl +++ b/internal/templates/partials/landing-footer.tmpl @@ -7,7 +7,7 @@ GitHub {{ end }} From 26603a19dec203b2228cbbcb98e614e259141cb9 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Fri, 4 Sep 2026 21:02:33 -0400 Subject: [PATCH 05/13] chore(html): added localizer to TemplateDate to make it available in the template. --- internal/html/html.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/html/html.go b/internal/html/html.go index ea06a7b..74fd5fa 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/localizer" "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: localizer.TagFrom(r.Context()).String(), } } From 225bf79f7e55497f51d8794e67fe1b55aba13a8d Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sat, 5 Sep 2026 08:31:01 -0400 Subject: [PATCH 06/13] refactor(locale): rm localizer and add locale pkg with minimal funcs. Move language specifics into the withLocale middleware to keep abstraction low. --- internal/locale/locale.go | 30 ++++++++++++ internal/localizer/localizer.go | 81 --------------------------------- 2 files changed, 30 insertions(+), 81 deletions(-) create mode 100644 internal/locale/locale.go delete mode 100644 internal/localizer/localizer.go diff --git a/internal/locale/locale.go b/internal/locale/locale.go new file mode 100644 index 0000000..1c9ff62 --- /dev/null +++ b/internal/locale/locale.go @@ -0,0 +1,30 @@ +// 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" + + _ "github.com/bit8bytes/gearberg/internal/translations" + + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +// Context 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/localizer/localizer.go b/internal/localizer/localizer.go deleted file mode 100644 index b864577..0000000 --- a/internal/localizer/localizer.go +++ /dev/null @@ -1,81 +0,0 @@ -// Package localizer resolves a user's preferred language from a cookie or -// Accept-Language header and makes a pre-built message.Printer available via -// the Localizer map. Keeping the Printer in the Localizer (not the context) -// makes the dependency explicit in handler signatures. -package localizer - -import ( - "context" - "net/http" - - _ "github.com/bit8bytes/gearberg/internal/translations" - - "golang.org/x/text/language" - "golang.org/x/text/message" -) - -type tagKey struct{} - -// TagFrom returns the resolved tag stored by the middleware. Falls back to -// English when the middleware has not run. -func TagFrom(ctx context.Context) language.Tag { - tag, ok := ctx.Value(tagKey{}).(language.Tag) - if !ok { - return language.English - } - return tag -} - -// Localizer resolves language tags and vends pre-built message.Printers. -// Construct with New; the zero value is not usable. -type Localizer struct { - cookieName string - tags []language.Tag - printers map[language.Tag]*message.Printer -} - -// New builds a Localizer for the given supported tags. Panics on empty input -// because a localizer with no languages is always misconfigured. -func New(tags ...language.Tag) *Localizer { - if len(tags) == 0 { - panic("at least one language tag must be provided") - } - printers := make(map[language.Tag]*message.Printer) - for _, tag := range tags { - printers[tag] = message.NewPrinter(tag) - } - return &Localizer{cookieName: "locale", tags: tags, printers: printers} -} - -// Printer returns the pre-built Printer for the given tag, falling back to -// English so callers never have to handle a nil printer. -func (l *Localizer) Printer(tag language.Tag) *message.Printer { - if p, ok := l.printers[tag]; ok { - return p - } - return l.printers[language.English] -} - -// Handler is an http.Handler middleware that reads the locale cookie and -// Accept-Language header, resolves the best-matching tag, and stores only the -// tag in the request context. Handlers fetch the Printer explicitly via Printer(tag). -func (l *Localizer) Handler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var locale string - if c, err := r.Cookie(l.cookieName); err == nil { - locale = c.Value - } - tag := l.Resolve(locale, r.Header.Get("Accept-Language")) - ctx := context.WithValue(r.Context(), tagKey{}, tag) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -// Resolve picks the best supported tag from an explicit locale string and an -// Accept-Language header value. Exposed so tests and non-HTTP code can resolve -// a tag without constructing a fake request. -func (l *Localizer) Resolve(locale, acceptLanguage string) language.Tag { - matcher := language.NewMatcher(l.tags) - tag, _ := language.MatchStrings(matcher, locale, acceptLanguage) - return tag -} From 9c89054c37e696c476556219947621e500610293 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sat, 5 Sep 2026 08:32:04 -0400 Subject: [PATCH 07/13] refactor(www): remove localizer --- cmd/www/main.go | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/cmd/www/main.go b/cmd/www/main.go index fd59811..b976e23 100644 --- a/cmd/www/main.go +++ b/cmd/www/main.go @@ -24,17 +24,13 @@ import ( "os/signal" "syscall" - "golang.org/x/text/language" - htmlpkg "github.com/bit8bytes/gearberg/internal/html" - "github.com/bit8bytes/gearberg/internal/localizer" ) type application struct { - logger *slog.Logger - options *options - html *htmlpkg.HTML - localizer *localizer.Localizer + logger *slog.Logger + options *options + html *htmlpkg.HTML } func main() { @@ -68,10 +64,9 @@ func run() error { } app := &application{ - logger: log, - options: options, - html: htmlpkg.New(log, base, cache, revision), - localizer: localizer.New(language.English, language.German), + logger: log, + options: options, + html: htmlpkg.New(log, base, cache, revision), } return app.serve(ctx) From de93c8839869be0304020c36814453bb41b9d871 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sat, 5 Sep 2026 08:34:56 -0400 Subject: [PATCH 08/13] chore: added locale middleware and updated landing page to define its content in go code to be automatically picked up by gotext --- cmd/www/main.go | 2 ++ cmd/www/middleware.go | 17 +++++++++++ cmd/www/routes.go | 42 ++++++++++++++++++++++----- internal/html/html.go | 4 +-- internal/templates/pages/landing.tmpl | 10 +++---- 5 files changed, 59 insertions(+), 16 deletions(-) diff --git a/cmd/www/main.go b/cmd/www/main.go index b976e23..a4f6623 100644 --- a/cmd/www/main.go +++ b/cmd/www/main.go @@ -27,6 +27,8 @@ import ( htmlpkg "github.com/bit8bytes/gearberg/internal/html" ) +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..30b68df 100644 --- a/cmd/www/routes.go +++ b/cmd/www/routes.go @@ -17,10 +17,10 @@ package main import ( "fmt" "net/http" - "time" "github.com/bit8bytes/gearberg/internal/assets" "github.com/bit8bytes/gearberg/internal/httperr" + "github.com/bit8bytes/gearberg/internal/locale" "github.com/bit8bytes/gearberg/internal/templates/pages" ) @@ -43,6 +43,7 @@ func (app *application) routes() http.Handler { 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 +55,52 @@ func (app *application) routes() http.Handler { logRequest.handler( withSecurityHeaders( withMaxBodySize( - antiCSRF.Handler(mux))))))) + antiCSRF.Handler( + withLocale(mux)))))))) +} + +type landingPageData struct { + HeroTitle string + HeroSubtitle string + CTAQuickstart string + CTAPricing string } func (app *application) getLanding(w http.ResponseWriter, r *http.Request) *httperr.Error { + p := locale.PrinterFrom(r.Context()) data := app.html.TemplateData(r) - data.Data = struct { - Year int - }{ - Year: time.Now().Year(), + data.Data = landingPageData{ + HeroTitle: p.Sprintf("Open source equipment tracking software"), + HeroSubtitle: p.Sprintf("Simple equipment tracking. No lock-in. Self-host with a single Docker command."), + CTAQuickstart: p.Sprintf("Quickstart"), + CTAPricing: p.Sprintf("Pricing"), } 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, + SameSite: http.SameSiteLaxMode, + }) + ref := r.Referer() + if ref == "" { + ref = "/" + } + http.Redirect(w, r, ref, http.StatusSeeOther) +} + 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) } diff --git a/internal/html/html.go b/internal/html/html.go index 74fd5fa..0825293 100644 --- a/internal/html/html.go +++ b/internal/html/html.go @@ -28,7 +28,7 @@ import ( "github.com/bit8bytes/gearberg/internal/flash" "github.com/bit8bytes/gearberg/internal/httperr" - "github.com/bit8bytes/gearberg/internal/localizer" + "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" @@ -160,6 +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: localizer.TagFrom(r.Context()).String(), + Locale: locale.TagFrom(r.Context()).String(), } } diff --git a/internal/templates/pages/landing.tmpl b/internal/templates/pages/landing.tmpl index da357cc..f9d038a 100644 --- a/internal/templates/pages/landing.tmpl +++ b/internal/templates/pages/landing.tmpl @@ -31,17 +31,15 @@

- Easy to use and
affordable Rentman alternative + {{ .Data.HeroTitle }}

- Simple equipment tracking and rentals. - No lock-in. - Self-host with a single Docker command. + {{ .Data.HeroSubtitle }}

From 9978291688c9913ff14944bdb606f446a4a13663 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sat, 5 Sep 2026 08:35:47 -0400 Subject: [PATCH 09/13] chore(translations): added translatios and ran generate/translations --- internal/assets/dist/index.css | 3 - .../templates/partials/landing-header.tmpl | 15 +++- internal/translations/catalog.go | 68 +++++++++++++++++++ .../locales/de/messages.gotext.json | 25 +++++++ .../translations/locales/de/out.gotext.json | 25 +++++++ .../locales/en-US/messages.gotext.json | 33 +++++++++ .../locales/en-US/out.gotext.json | 33 +++++++++ internal/translations/translations.go | 17 +++++ make/dev.mk | 5 ++ 9 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 internal/translations/catalog.go create mode 100644 internal/translations/locales/de/messages.gotext.json create mode 100644 internal/translations/locales/de/out.gotext.json create mode 100644 internal/translations/locales/en-US/messages.gotext.json create mode 100644 internal/translations/locales/en-US/out.gotext.json create mode 100644 internal/translations/translations.go 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/templates/partials/landing-header.tmpl b/internal/templates/partials/landing-header.tmpl index ce528bc..a81689c 100644 --- a/internal/templates/partials/landing-header.tmpl +++ b/internal/templates/partials/landing-header.tmpl @@ -3,7 +3,7 @@
{{ template "logo-with-text" . }}
- + {{ end }} diff --git a/internal/translations/catalog.go b/internal/translations/catalog.go new file mode 100644 index 0000000..90302f8 --- /dev/null +++ b/internal/translations/catalog.go @@ -0,0 +1,68 @@ +// Code generated by running "go generate" in golang.org/x/text. DO NOT EDIT. + +package translations + +import ( + "golang.org/x/text/language" + "golang.org/x/text/message" + "golang.org/x/text/message/catalog" +) + +type dictionary struct { + index []uint32 + data string +} + +func (d *dictionary) Lookup(key string) (data string, ok bool) { + p, ok := messageKeyToIndex[key] + if !ok { + return "", false + } + start, end := d.index[p], d.index[p+1] + if start == end { + return "", false + } + return d.data[start:end], true +} + +func init() { + dict := map[string]catalog.Dictionary{ + "de": &dictionary{index: deIndex, data: deData}, + "en_US": &dictionary{index: en_USIndex, data: en_USData}, + } + fallback := language.MustParse("en-US") + cat, err := catalog.NewFromMap(dict, catalog.Fallback(fallback)) + if err != nil { + panic(err) + } + message.DefaultCatalog = cat +} + +var messageKeyToIndex = map[string]int{ + "Open source equipment tracking software": 0, + "Pricing": 3, + "Quickstart": 2, + "Simple equipment tracking. No lock-in. Self-host with a single Docker command.": 1, +} + +var deIndex = []uint32{ // 5 elements + 0x00000000, 0x0000002b, 0x00000084, 0x00000091, + 0x00000098, +} // Size: 44 bytes + +const deData string = "" + // Size: 152 bytes + "\x02Open-Source-Software zur Geräteverwaltung\x02Einfache Geräteverwaltu" + + "ng. Kein Lock-in. Self-Hosting mit einem einzigen Docker-Befehl.\x02Schn" + + "ellstart\x02Preise" + +var en_USIndex = []uint32{ // 5 elements + 0x00000000, 0x00000028, 0x00000077, 0x00000082, + 0x0000008a, +} // Size: 44 bytes + +const en_USData string = "" + // Size: 138 bytes + "\x02Open source equipment tracking software\x02Simple equipment tracking" + + ". No lock-in. Self-host with a single Docker command.\x02Quickstart\x02P" + + "ricing" + + // Total table size 378 bytes (0KiB); checksum: CE4DDA2C diff --git a/internal/translations/locales/de/messages.gotext.json b/internal/translations/locales/de/messages.gotext.json new file mode 100644 index 0000000..24ac871 --- /dev/null +++ b/internal/translations/locales/de/messages.gotext.json @@ -0,0 +1,25 @@ +{ + "language": "de", + "messages": [ + { + "id": "Open source equipment tracking software", + "message": "Open source equipment tracking software", + "translation": "Open-Source-Software zur Geräteverwaltung" + }, + { + "id": "Simple equipment tracking. No lock-in. Self-host with a single Docker command.", + "message": "Simple equipment tracking. No lock-in. Self-host with a single Docker command.", + "translation": "Einfache Geräteverwaltung. Kein Lock-in. Self-Hosting mit einem einzigen Docker-Befehl." + }, + { + "id": "Quickstart", + "message": "Quickstart", + "translation": "Schnellstart" + }, + { + "id": "Pricing", + "message": "Pricing", + "translation": "Preise" + } + ] +} \ No newline at end of file diff --git a/internal/translations/locales/de/out.gotext.json b/internal/translations/locales/de/out.gotext.json new file mode 100644 index 0000000..24ac871 --- /dev/null +++ b/internal/translations/locales/de/out.gotext.json @@ -0,0 +1,25 @@ +{ + "language": "de", + "messages": [ + { + "id": "Open source equipment tracking software", + "message": "Open source equipment tracking software", + "translation": "Open-Source-Software zur Geräteverwaltung" + }, + { + "id": "Simple equipment tracking. No lock-in. Self-host with a single Docker command.", + "message": "Simple equipment tracking. No lock-in. Self-host with a single Docker command.", + "translation": "Einfache Geräteverwaltung. Kein Lock-in. Self-Hosting mit einem einzigen Docker-Befehl." + }, + { + "id": "Quickstart", + "message": "Quickstart", + "translation": "Schnellstart" + }, + { + "id": "Pricing", + "message": "Pricing", + "translation": "Preise" + } + ] +} \ No newline at end of file diff --git a/internal/translations/locales/en-US/messages.gotext.json b/internal/translations/locales/en-US/messages.gotext.json new file mode 100644 index 0000000..ea499d4 --- /dev/null +++ b/internal/translations/locales/en-US/messages.gotext.json @@ -0,0 +1,33 @@ +{ + "language": "en-US", + "messages": [ + { + "id": "Open source equipment tracking software", + "message": "Open source equipment tracking software", + "translation": "Open source equipment tracking software", + "translatorComment": "Copied from source.", + "fuzzy": true + }, + { + "id": "Simple equipment tracking. No lock-in. Self-host with a single Docker command.", + "message": "Simple equipment tracking. No lock-in. Self-host with a single Docker command.", + "translation": "Simple equipment tracking. No lock-in. Self-host with a single Docker command.", + "translatorComment": "Copied from source.", + "fuzzy": true + }, + { + "id": "Quickstart", + "message": "Quickstart", + "translation": "Quickstart", + "translatorComment": "Copied from source.", + "fuzzy": true + }, + { + "id": "Pricing", + "message": "Pricing", + "translation": "Pricing", + "translatorComment": "Copied from source.", + "fuzzy": true + } + ] +} \ No newline at end of file diff --git a/internal/translations/locales/en-US/out.gotext.json b/internal/translations/locales/en-US/out.gotext.json new file mode 100644 index 0000000..ea499d4 --- /dev/null +++ b/internal/translations/locales/en-US/out.gotext.json @@ -0,0 +1,33 @@ +{ + "language": "en-US", + "messages": [ + { + "id": "Open source equipment tracking software", + "message": "Open source equipment tracking software", + "translation": "Open source equipment tracking software", + "translatorComment": "Copied from source.", + "fuzzy": true + }, + { + "id": "Simple equipment tracking. No lock-in. Self-host with a single Docker command.", + "message": "Simple equipment tracking. No lock-in. Self-host with a single Docker command.", + "translation": "Simple equipment tracking. No lock-in. Self-host with a single Docker command.", + "translatorComment": "Copied from source.", + "fuzzy": true + }, + { + "id": "Quickstart", + "message": "Quickstart", + "translation": "Quickstart", + "translatorComment": "Copied from source.", + "fuzzy": true + }, + { + "id": "Pricing", + "message": "Pricing", + "translation": "Pricing", + "translatorComment": "Copied from source.", + "fuzzy": true + } + ] +} \ No newline at end of file diff --git a/internal/translations/translations.go b/internal/translations/translations.go new file mode 100644 index 0000000..12981bc --- /dev/null +++ b/internal/translations/translations.go @@ -0,0 +1,17 @@ +// Copyright (C) 2026 Tobias Gleiter +// +// 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 translations + +//go:generate gotext -srclang=en-US update -out=catalog.go -lang=en-US,de github.com/bit8bytes/gearberg/cmd/www diff --git a/make/dev.mk b/make/dev.mk index 02db42e..bedea8e 100644 --- a/make/dev.mk +++ b/make/dev.mk @@ -52,6 +52,11 @@ tailwind/build: generate/api: go generate ./internal/api/ +## generate/translations: generate translation files +.PHONY: generate/translations +generate/translations: + go generate ./internal/translations/ + ## sqlc: generate source code from SQL .PHONY: sqlc sqlc: From f4ad45353e77a5979d71dc02e295b43174e59fe0 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sat, 5 Sep 2026 08:39:25 -0400 Subject: [PATCH 10/13] refactor(www): move handlers from routes into handlers to improve readability --- cmd/www/handlers.go | 82 +++++++++++++++++++++++++++++++++++++++++++++ cmd/www/routes.go | 62 ++-------------------------------- 2 files changed, 84 insertions(+), 60 deletions(-) create mode 100644 cmd/www/handlers.go diff --git a/cmd/www/handlers.go b/cmd/www/handlers.go new file mode 100644 index 0000000..597704c --- /dev/null +++ b/cmd/www/handlers.go @@ -0,0 +1,82 @@ +// 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" + + "github.com/bit8bytes/gearberg/internal/httperr" + "github.com/bit8bytes/gearberg/internal/locale" + "github.com/bit8bytes/gearberg/internal/templates/pages" +) + +type landingPageData struct { + HeroTitle string + HeroSubtitle string + CTAQuickstart string + CTAPricing string +} + +func (app *application) getLanding(w http.ResponseWriter, r *http.Request) *httperr.Error { + p := locale.PrinterFrom(r.Context()) + data := app.html.TemplateData(r) + data.Data = landingPageData{ + HeroTitle: p.Sprintf("Open source equipment tracking software"), + HeroSubtitle: p.Sprintf("Simple equipment tracking. No lock-in. Self-host with a single Docker command."), + CTAQuickstart: p.Sprintf("Quickstart"), + CTAPricing: p.Sprintf("Pricing"), + } + 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, + SameSite: http.SameSiteLaxMode, + }) + ref := r.Referer() + if ref == "" { + ref = "/" + } + 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/routes.go b/cmd/www/routes.go index 30b68df..6ad4ca2 100644 --- a/cmd/www/routes.go +++ b/cmd/www/routes.go @@ -15,13 +15,9 @@ package main import ( - "fmt" "net/http" "github.com/bit8bytes/gearberg/internal/assets" - "github.com/bit8bytes/gearberg/internal/httperr" - "github.com/bit8bytes/gearberg/internal/locale" - "github.com/bit8bytes/gearberg/internal/templates/pages" ) func (app *application) routes() http.Handler { @@ -29,16 +25,8 @@ 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)) @@ -58,49 +46,3 @@ func (app *application) routes() http.Handler { antiCSRF.Handler( withLocale(mux)))))))) } - -type landingPageData struct { - HeroTitle string - HeroSubtitle string - CTAQuickstart string - CTAPricing string -} - -func (app *application) getLanding(w http.ResponseWriter, r *http.Request) *httperr.Error { - p := locale.PrinterFrom(r.Context()) - data := app.html.TemplateData(r) - data.Data = landingPageData{ - HeroTitle: p.Sprintf("Open source equipment tracking software"), - HeroSubtitle: p.Sprintf("Simple equipment tracking. No lock-in. Self-host with a single Docker command."), - CTAQuickstart: p.Sprintf("Quickstart"), - CTAPricing: p.Sprintf("Pricing"), - } - 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, - SameSite: http.SameSiteLaxMode, - }) - ref := r.Referer() - if ref == "" { - ref = "/" - } - 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) -} From 5b772f3847d03f3d743bca7754e44f1e883fe552 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sat, 5 Sep 2026 09:18:14 -0400 Subject: [PATCH 11/13] refactor: move landing translations in separate files. Keep locale and printer logic for cmd/web. --- cmd/www/handlers.go | 25 +- cmd/www/main.go | 1 + cmd/www/setup.go | 2 +- internal/assets/dist/index.css | 26 +++ internal/locale/locale.go | 4 +- internal/templates/pages/landing.de.tmpl | 282 +++++++++++++++++++++++ internal/templates/pages/landing.tmpl | 83 ++++--- internal/templates/pages/pages.go | 9 +- internal/translations/translations.go | 2 + 9 files changed, 376 insertions(+), 58 deletions(-) create mode 100644 internal/templates/pages/landing.de.tmpl diff --git a/cmd/www/handlers.go b/cmd/www/handlers.go index 597704c..29d4725 100644 --- a/cmd/www/handlers.go +++ b/cmd/www/handlers.go @@ -17,27 +17,18 @@ 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" ) -type landingPageData struct { - HeroTitle string - HeroSubtitle string - CTAQuickstart string - CTAPricing string -} - func (app *application) getLanding(w http.ResponseWriter, r *http.Request) *httperr.Error { - p := locale.PrinterFrom(r.Context()) data := app.html.TemplateData(r) - data.Data = landingPageData{ - HeroTitle: p.Sprintf("Open source equipment tracking software"), - HeroSubtitle: p.Sprintf("Simple equipment tracking. No lock-in. Self-host with a single Docker command."), - CTAQuickstart: p.Sprintf("Quickstart"), - CTAPricing: p.Sprintf("Pricing"), + 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) } @@ -50,11 +41,13 @@ func (app *application) postLocale(w http.ResponseWriter, r *http.Request) { Path: "/", MaxAge: 365 * 24 * 60 * 60, HttpOnly: true, + Secure: true, SameSite: http.SameSiteLaxMode, }) - ref := r.Referer() - if ref == "" { - ref = "/" + 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) } diff --git a/cmd/www/main.go b/cmd/www/main.go index a4f6623..0e41e19 100644 --- a/cmd/www/main.go +++ b/cmd/www/main.go @@ -25,6 +25,7 @@ import ( "syscall" htmlpkg "github.com/bit8bytes/gearberg/internal/html" + _ "github.com/bit8bytes/gearberg/internal/translations" ) const localeCookieName = "locale" diff --git a/cmd/www/setup.go b/cmd/www/setup.go index fa3aba0..04b33c6 100644 --- a/cmd/www/setup.go +++ b/cmd/www/setup.go @@ -62,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/internal/assets/dist/index.css b/internal/assets/dist/index.css index 3883c45..4aa292e 100644 --- a/internal/assets/dist/index.css +++ b/internal/assets/dist/index.css @@ -4274,6 +4274,9 @@ --toast-y: 0; } } + .top-1 { + top: calc(var(--spacing) * 1); + } .top-1\/2 { top: calc(1 / 2 * 100%); } @@ -6768,6 +6771,9 @@ .w-0 { width: calc(var(--spacing) * 0); } + .w-1 { + width: calc(var(--spacing) * 1); + } .w-1\/2 { width: calc(1 / 2 * 100%); } @@ -6900,6 +6906,10 @@ .border-collapse { border-collapse: collapse; } + .-translate-y-1 { + --tw-translate-y: calc(var(--spacing) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -7142,6 +7152,9 @@ .justify-items-center { justify-items: center; } + .gap-0 { + gap: calc(var(--spacing) * 0); + } .gap-0\.5 { gap: calc(var(--spacing) * 0.5); } @@ -7166,6 +7179,13 @@ .gap-16 { gap: calc(var(--spacing) * 16); } + .space-y-0 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-0\.5 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -7843,6 +7863,9 @@ .bg-red-400 { background-color: var(--color-red-400); } + .bg-red-500 { + background-color: var(--color-red-500); + } .bg-red-500\! { background-color: var(--color-red-500) !important; } @@ -8164,6 +8187,9 @@ .p-6 { padding: calc(var(--spacing) * 6); } + .p-7 { + padding: calc(var(--spacing) * 7); + } .p-8 { padding: calc(var(--spacing) * 8); } diff --git a/internal/locale/locale.go b/internal/locale/locale.go index 1c9ff62..b90c941 100644 --- a/internal/locale/locale.go +++ b/internal/locale/locale.go @@ -7,13 +7,11 @@ package locale import ( "context" - _ "github.com/bit8bytes/gearberg/internal/translations" - "golang.org/x/text/language" "golang.org/x/text/message" ) -// Context key for storing the resolved language tag. +// Key for storing the resolved language tag. type Key struct{} // TagFrom returns the resolved tag stored in ctx, falling back to English. diff --git a/internal/templates/pages/landing.de.tmpl b/internal/templates/pages/landing.de.tmpl new file mode 100644 index 0000000..27ad3f9 --- /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 */}} +
+ +
+ +{{/* 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 */}} +
+
+

Bereit, endlich zu wissen, wo dein Equipment ist?

+

Gearberg in unter einer Minute starten, oder melden, 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 f9d038a..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,15 +31,15 @@

- {{ .Data.HeroTitle }} + Open-Source
AV Equipment Software

- {{ .Data.HeroSubtitle }} + Simple equipment tracking for individuals and small teams. No spreadsheets, no vendor risk. Self-host with a single Docker command.

@@ -63,7 +63,7 @@

Why use Gearberg?

-

Rentman is overkill for most shops. Gearberg gives you clear, useful tracking without complexity, training or prior experience.

+

Most equipment software is built for enterprises. Gearberg gives AV freelancers and small production teams exactly what they need: clear, useful tracking without the complexity, training, or price tag.

@@ -71,15 +71,15 @@ {{ template "check-icon" . }}

Know where your gear is

-

You lent that lens out three weeks ago. Gearberg tells you who has it, when it's back, and what's still on the shelf.

+

That lens you lent out three weeks ago? Gearberg tells you exactly who has it, when it's due back, and what's still on the shelf.

{{ template "check-icon" . }}
-

Answer availability questions in seconds

-

Pick a date range, see what's free. No spreadsheet, no back-and-forth, no double-booking a client.

+

Always know what you have

+

Every piece of gear in one place. Model, serial number, location — instantly searchable, no spreadsheet hunting.

@@ -87,15 +87,15 @@ {{ template "check-icon" . }}

Know what your inventory is worth

-

See the total value of everything you own. Useful for insurance, purchasing decisions, and your accountant.

+

See the total value of everything you own at a glance. Useful for insurance coverage, purchasing decisions, and conversations with your accountant.

{{ template "check-icon" . }}
-

Your data, no lock-in

-

Import your existing gear list from CSV. Export it anytime. Nothing is trapped inside Gearberg.

+

Your data, your terms

+

Bring in your existing gear list from CSV and export it anytime. Nothing is ever trapped inside Gearberg.

@@ -103,7 +103,7 @@ {{ template "check-icon" . }}

Kits that travel together

-

Group your cables, stands, and lights into a kit. Check the whole thing out at once and know instantly if anything is missing.

+

Group your cables, stands, and lights into a kit. Track the whole set as one unit and know instantly if anything is missing.

@@ -123,7 +123,7 @@

Pricing

-

Self-host for free with all features included. Managed, worry-free cloud on request.

+

Open Source is free. Prefer not to manage a server? Just reach out.

@@ -132,8 +132,8 @@

Free

Run on your own server with Docker

    -
  • {{ template "success-icon" . }} Equipment
  • -
  • {{ template "success-icon" . }} Rentals
  • +
  • {{ template "success-icon" . }} Equipment tracking
  • +
  • {{ template "success-icon" . }} Kits & combinations
  • {{ template "success-icon" . }} Import & Export
@@ -146,12 +146,11 @@

Cloud

By request

-

The software is free. You pay for the convenience of not running it yourself

+

The software is free. You pay for the convenience of not running it yourself.

    -
  • {{ template "success-icon" . }} Equipment
  • -
  • {{ template "success-icon" . }} Rentals
  • +
  • {{ template "success-icon" . }} Equipment tracking
  • +
  • {{ template "success-icon" . }} Kits & combinations
  • {{ template "success-icon" . }} Import & Export
  • -
Get in touch @@ -197,21 +196,21 @@
Who is Gearberg for?
-

Individuals, freelancers, and small businesses who need straightforward equipment tracking and rentals.

+

Individuals and small teams who need a clear overview of their equipment — without the complexity of enterprise asset management software.

What problems does Gearberg solve?
-

Knowing what gear you own, where it is, and whether it's available. Without spreadsheets or expensive rental software.

+

Knowing what gear you own, where it is, what it's worth, and whether it's available. Without spreadsheets or expensive software.

Can I export my data?
-

Yes. Export your equipment list as CSV at any time.

+

Yes. Export your equipment list as CSV at any time. Your data is yours and never gets locked inside Gearberg.

@@ -222,6 +221,13 @@
+
+ What if I want to go back to spreadsheets? +
+

We respect that. Export your full equipment list as CSV at any time and take it wherever you like, including back to a spreadsheet. We call it the Back-to-Spreadsheets Guarantee, and we mean it.

+
+
+