Files
Owen f3a959334f Rename project from inventree to vibetree
- go.mod module path (and every internal/vibetree import) inventree -> vibetree
- cmd/inventree/ -> cmd/vibetree/, deploy/inventree.service -> vibetree.service
- Default site name (seed data + the siteName template func's fallback)
  is now "vibetree", shown in the navbar, <title>, and login page
- Session cookie renamed inventree_session -> vibetree_session
- Default DB path data/inventree.db -> data/vibetree.db, CLI/log text,
  Dockerfile, docker-compose.yml, README, .gitignore/.dockerignore all
  updated to match

Existing installs: this changes the session cookie name (users will be
signed out once) and the default DB filename (point -db at your existing
file, or rename it, to keep your data).
2026-08-17 17:14:52 -05:00

195 lines
5.6 KiB
Go

package web
import (
"context"
"fmt"
"html/template"
"io/fs"
"net/http"
"path"
"strings"
"time"
content "vibetree/web"
)
// templateFuncs are available to every template (pages and fragments).
var templateFuncs = template.FuncMap{
"dict": func(pairs ...any) (map[string]any, error) {
if len(pairs)%2 != 0 {
return nil, fmt.Errorf("dict: odd number of arguments")
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
return nil, fmt.Errorf("dict: key %v is not a string", pairs[i])
}
m[key] = pairs[i+1]
}
return m, nil
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"ceilDiv": func(a, b int) int {
if b <= 0 {
return 0
}
return (a + b - 1) / b
},
"seq": func(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i
}
return out
},
"seqFrom1": func(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i + 1
}
return out
},
"fmtDate": func(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04")
},
"fmtDateShort": func(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("Jan 2, 2006")
},
"iconOr": func(icon, fallback string) string {
if icon == "" {
return fallback
}
return icon
},
"hasPrefix": strings.HasPrefix,
"lower": strings.ToLower,
"list": func(items ...string) []string { return items },
"deref": func(p *int64) int64 {
if p == nil {
return 0
}
return *p
},
}
// templateSet bundles the shared fragment/partial template collection with
// one isolated template set per full page (see loadTemplates for why pages
// must be isolated from one another).
type templateSet struct {
shared *template.Template // layout.html + partials/*.html; used to render AJAX fragments directly
pages map[string]*template.Template // page name -> shared clone + that page's templates/pages/<name>.html
login *template.Template // standalone minimal layout for the login screen
}
// loadTemplates parses layout.html and every partial once into a shared
// template set, then clones that set once per page in templates/pages/ so
// each page can define a block named "content" without colliding with the
// others (html/template templates sharing one *template.Template must have
// unique names, but isolated clones may reuse the same name freely).
func loadTemplates(a *App) (*templateSet, error) {
// siteName is bound per-App (rather than living in the package-level
// templateFuncs) since it needs a live DB read through a.Settings; it's
// used by layout.html for the <title> and navbar brand text.
appFuncs := template.FuncMap{
"siteName": func() string {
name, err := a.Settings.Get(context.Background(), "site_name", "vibetree")
if err != nil || name == "" {
return "vibetree"
}
return name
},
}
shared := template.New("root").Funcs(templateFuncs).Funcs(appFuncs)
shared, err := shared.ParseFS(content.TemplatesFS, "templates/layout.html")
if err != nil {
return nil, fmt.Errorf("parse layout: %w", err)
}
shared, err = parseGlob(shared, "templates/partials")
if err != nil {
return nil, fmt.Errorf("parse partials: %w", err)
}
pageFiles, err := fs.Glob(content.TemplatesFS, "templates/pages/*.html")
if err != nil {
return nil, err
}
pages := map[string]*template.Template{}
for _, pf := range pageFiles {
name := strings.TrimSuffix(path.Base(pf), ".html")
clone, err := shared.Clone()
if err != nil {
return nil, fmt.Errorf("clone template set for %s: %w", name, err)
}
if _, err := clone.ParseFS(content.TemplatesFS, pf); err != nil {
return nil, fmt.Errorf("parse page %s: %w", pf, err)
}
pages[name] = clone
}
login := template.New("login-root").Funcs(templateFuncs).Funcs(appFuncs)
login, err = login.ParseFS(content.TemplatesFS, "templates/login.html")
if err != nil {
return nil, fmt.Errorf("parse login: %w", err)
}
return &templateSet{shared: shared, pages: pages, login: login}, nil
}
func parseGlob(t *template.Template, dir string) (*template.Template, error) {
files, err := fs.Glob(content.TemplatesFS, dir+"/*.html")
if err != nil {
return nil, err
}
if len(files) == 0 {
return t, nil
}
return t.ParseFS(content.TemplatesFS, files...)
}
// Render executes the named page's "layout" template (which pulls in that
// page's "content" block) into the response. Page "login" uses its own
// minimal "login" template instead.
func (a *App) Render(w http.ResponseWriter, status int, page string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if page == "login" {
w.WriteHeader(status)
if err := a.templates.login.ExecuteTemplate(w, "login", data); err != nil {
a.Log.Error("template render failed", "page", page, "error", err)
}
return
}
ts, ok := a.templates.pages[page]
if !ok {
writeErr(w, http.StatusInternalServerError, "template not found: "+page)
return
}
w.WriteHeader(status)
if err := ts.ExecuteTemplate(w, "layout", data); err != nil {
a.Log.Error("template render failed", "page", page, "error", err)
}
}
// RenderFragment executes a single named partial (defined under
// templates/partials/) with no layout wrapper, for AJAX responses.
func (a *App) RenderFragment(w http.ResponseWriter, status int, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := a.templates.shared.ExecuteTemplate(w, name, data); err != nil {
a.Log.Error("fragment render failed", "name", name, "error", err)
}
}