- 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).
195 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|