go-blocks/templates.go

380 lines
8.2 KiB
Go

package goblocks
import (
"bytes"
"errors"
"fmt"
"html/template"
"log/slog"
"maps"
"net/http"
"path/filepath"
"reflect"
"strconv"
"strings"
"time"
)
type (
templateCache map[string]*template.Template
TemplateData struct {
Data map[string]any
Pages Pages
}
RenderOptions func(*Render)
Render struct {
EnableCache bool
TemplatesPath string
Functions template.FuncMap
TemplateData TemplateData
templateCache templateCache
}
)
func defaultHTMLRender() *Render {
return &Render{
EnableCache: false,
TemplatesPath: "templates",
TemplateData: TemplateData{},
Functions: template.FuncMap{
"default": defaultIfEmpty,
"dict": Dict,
"duration": Duration,
},
templateCache: templateCache{},
}
}
func NewHTMLRender(opts ...RenderOptions) *Render {
config := defaultHTMLRender()
return config.apply(opts...)
}
func (re *Render) apply(opts ...RenderOptions) *Render {
for _, opt := range opts {
if opt != nil {
opt(re)
}
}
return re
}
func defaultIfEmpty(fallback string, value any) string {
str, ok := value.(string)
if !ok || strings.TrimSpace(str) == "" {
return fallback
}
return str
}
func cloneFuncMap(src template.FuncMap) template.FuncMap {
c := make(template.FuncMap)
maps.Copy(c, src)
return c
}
func (re *Render) Render(w http.ResponseWriter, layoutName, pageName string, td *TemplateData, useLayout bool) error {
if td == nil {
td = &TemplateData{}
}
if !useLayout {
path := filepath.Join(re.TemplatesPath, "pages", pageName)
funcs := cloneFuncMap(re.Functions)
tmpl, err := template.New(strings.TrimSuffix(pageName, ".gohtml")).Funcs(funcs).ParseFiles(path)
if err != nil {
return err
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, strings.TrimSuffix(pageName, ".gohtml"), td); err != nil {
return err
}
_, err = buf.WriteTo(w)
return err
}
tmpl, err := re.loadTemplateWithLayout(layoutName, pageName)
if err != nil {
return err
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, strings.TrimSuffix(layoutName, ".gohtml"), td); err != nil {
return err
}
_, err = buf.WriteTo(w)
return err
}
func (re *Render) RenderComponent(name string, td *TemplateData) (string, error) {
if td == nil {
td = &TemplateData{}
}
path := filepath.Join(re.TemplatesPath, "components", name)
files := []string{path}
matches, err := filepath.Glob(filepath.Join(re.TemplatesPath, "components", "*.gohtml"))
if err != nil {
slog.Error("error loading component files", "error", err)
return "", err
}
files = append(files, matches...)
funcs := cloneFuncMap(re.Functions)
tmpl, err := template.New(name).Funcs(funcs).ParseFiles(files...)
if err != nil {
slog.Error("error loading component files", "error", err)
return "", err
}
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, strings.TrimSuffix(name, ".gohtml"), td)
if err != nil {
slog.Error("error executing component template", "error", err)
}
return buf.String(), err
}
func (re *Render) loadTemplateWithLayout(layoutName, pageName string) (*template.Template, error) {
cacheKey := layoutName + "::" + pageName
if re.EnableCache {
if tmpl, ok := re.templateCache[cacheKey]; ok {
return tmpl, nil
}
}
layoutPath := filepath.Join(re.TemplatesPath, "layouts", layoutName)
pagePath := filepath.Join(re.TemplatesPath, "pages", pageName)
files := []string{layoutPath, pagePath}
componentFiles, err := filepath.Glob(filepath.Join(re.TemplatesPath, "components", "*.gohtml"))
if err != nil {
return nil, err
}
files = append(files, componentFiles...)
funcs := cloneFuncMap(re.Functions)
tmpl, err := template.New(layoutName).Funcs(funcs).ParseFiles(files...)
if err != nil {
return nil, err
}
if re.EnableCache {
re.templateCache[cacheKey] = tmpl
slog.Debug("template cached", "key", cacheKey)
}
return tmpl, nil
}
// Pages contains pagination info.
type Pages struct {
// TotalElements indicates the total number of elements available for
// pagination.
TotalElements int
// ElementsPerPage defines the number of elements to display per page in
// pagination.
ElementsPerPage int
// ActualPage represents the current page number in pagination.
ActualPage int
}
func (p *Pages) PaginationParams(r *http.Request) {
limit := r.FormValue("limit")
page := r.FormValue("page")
if limit == "" {
if p.ElementsPerPage != 0 {
limit = strconv.Itoa(p.ElementsPerPage)
} else {
limit = "20"
}
}
if page == "" || page == "0" {
if p.ActualPage != 0 {
page = strconv.Itoa(p.ActualPage)
} else {
page = "1"
}
}
limitInt, _ := strconv.Atoi(limit)
pageInt, _ := strconv.Atoi(page)
offset := (pageInt - 1) * limitInt
currentPage := offset/limitInt + 1
p.ElementsPerPage = limitInt
p.ActualPage = currentPage
}
func (p Pages) PaginateArray(elements any) any {
itemsValue := reflect.ValueOf(elements)
if p.ActualPage < 1 {
p.ActualPage = 1
}
if p.ActualPage > p.TotalPages() {
p.ActualPage = p.TotalPages()
}
startIndex := (p.ActualPage - 1) * p.ElementsPerPage
endIndex := startIndex + p.ElementsPerPage
return itemsValue.Slice(startIndex, endIndex).Interface()
}
func (p Pages) CurrentPage() int {
return p.ActualPage
}
func (p Pages) TotalPages() int {
return (p.TotalElements + p.ElementsPerPage - 1) / p.ElementsPerPage
}
func (p Pages) IsFirst() bool {
return p.ActualPage == 1
}
func (p Pages) IsLast() bool {
return p.ActualPage == p.TotalPages()
}
func (p Pages) HasPrevious() bool {
return p.ActualPage > 1
}
func (p Pages) HasNext() bool {
return p.ActualPage < p.TotalPages()
}
func (p Pages) Previous() int {
if p.ActualPage > p.TotalPages() {
return p.TotalPages()
}
return p.ActualPage - 1
}
func (p Pages) Next() int {
if p.ActualPage < 1 {
return 1
}
return p.ActualPage + 1
}
func (p Pages) GoToPage(page int) int {
if page < 1 {
page = 1
} else if page > p.TotalPages() {
page = p.TotalPages()
}
return page
}
func (p Pages) First() int {
return p.GoToPage(1)
}
func (p Pages) Last() int {
return p.GoToPage(p.TotalPages())
}
// Page contiene la información de una página. Utilizado para la barra de
// paginación que suelen mostrarse en la parte inferior de una lista o tabla.
// Page represents a single page in pagination, including its number and active
// state. Useful for pagination bar.
type Page struct {
// Number is the numeric identifier of the page in pagination.
Number int
// Active indicates if the page is the currently selected page.
Active bool
}
func (p Page) NumberOfPage() int {
return p.Number
}
func (p Page) IsActive() bool {
return p.Active
}
// PageRange generates a slice of Page instances representing a range of pages
// to be displayed in a pagination bar.
func (p Pages) PageRange(maxPagesToShow int) []Page {
var pages []Page
totalPages := p.TotalPages()
startPage := p.ActualPage - (maxPagesToShow / 2)
endPage := p.ActualPage + (maxPagesToShow / 2)
if startPage < 1 {
startPage = 1
endPage = maxPagesToShow
}
if endPage > totalPages {
endPage = totalPages
startPage = totalPages - maxPagesToShow + 1
if startPage < 1 {
startPage = 1
}
}
for i := startPage; i <= endPage; i++ {
pages = append(pages, Page{
Number: i,
Active: i == p.ActualPage,
})
}
return pages
}
func Dict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
}
func FormatDateSpanish(date time.Time) string {
months := []string{"enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"}
days := []string{"domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado"}
dayName := days[date.Weekday()]
day := date.Day()
month := months[date.Month()-1]
year := date.Year()
return dayName + ", " + strconv.Itoa(day) + " de " + month + " de " + strconv.Itoa(year)
}
func Duration(start, end time.Time) string {
if end.IsZero() {
end = time.Now()
}
d := end.Sub(start)
h := int(d.Hours())
m := int(d.Minutes()) % 60
return fmt.Sprintf("%d:%02d", h, m)
}