Files
vibetree/internal/web/render.go
T
Owen 42e7b2a21c Statuses import, an icon picker, folder-scoped CSV import, and site name
- Statuses can now be bulk-imported/updated via CSV (Name, Color columns;
  matches and updates existing statuses by name). New
  internal/statuses/csv.go, tested.
- Icon fields (folder create/rename, item type form) are now a searchable
  dropdown of the 50 most common Bootstrap Icons instead of a free-text
  box, via a small reusable .icon-picker widget in app.js/app.css.
- CSV asset import is now folder-scoped when triggered from the tree:
  right-click a folder (or its background) > Import CSV... imports every
  row directly into that folder, so the CSV needs no Folder column at
  all, and the option no longer appears at the top level. The original
  global, Folder-path-aware import (with auto-create-missing-folders)
  moved to a new admin-only Settings > Import / Export page, alongside
  the new Statuses import and format examples for both. assets.Import
  gained ImportOptions.TargetFolderID to drive the folder-scoped mode.
- Site Name (Settings > General) now actually renders in the navbar
  brand and <title>, via a per-App "siteName" template func bound in
  loadTemplates. The brand is plain text now, not a link to /inventory.
2026-08-17 15:57:59 -05:00

195 lines
5.6 KiB
Go

package web
import (
"context"
"fmt"
"html/template"
"io/fs"
"net/http"
"path"
"strings"
"time"
content "inventree/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", "inventree")
if err != nil || name == "" {
return "inventree"
}
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)
}
}