/* * Copyright (c) 2025-2026, Антон Аксенов * This file is part of iptvc project * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE */ package web import ( "axenov/iptv-checker/app/config" "bytes" "embed" "encoding/json" "fmt" "html/template" "io" "log" "math" "net/http" "strconv" "time" ) //go:embed views/*.html var viewsFS embed.FS // TemplateManager управляет загрузкой и рендерингом шаблонов type TemplateManager struct { templates map[string]*template.Template funcMap template.FuncMap } // NewTemplateManager создаёт новый менеджер шаблонов func NewTemplateManager(cfg *config.Config) (*TemplateManager, error) { funcMap := template.FuncMap{ "baseUrl": func(path string) string { base := cfg.Site.BaseUrl if path == "" { return base } return base + "/" + path }, "formatDate": formatDate, "formatTimeAgo": formatTimeAgo, "statusClass": func(v PlaylistView) string { return statusClass(&v) }, "statusBadge": func(v PlaylistView) string { return statusBadge(&v) }, "jsonChannels": func(channels map[string]ChannelView) template.JS { arr := make([]ChannelView, 0, len(channels)) for _, ch := range channels { arr = append(arr, ch) } b, _ := json.Marshal(arr) return template.JS(b) }, "seq": func(start, end int) []int { result := make([]int, 0, end-start+1) for i := start; i <= end; i++ { result = append(result, i) } return result }, "sub": func(a, b int) int { return a - b }, "add": func(a, b int) int { return a + b }, "channelCount": func(v PlaylistView) int { return len(v.Channels) }, "groupCount": func(v PlaylistView) int { return len(v.Groups) }, "hasTag": containsTag, } tm := &TemplateManager{ templates: make(map[string]*template.Template), funcMap: funcMap, } // parse base template baseContent, err := viewsFS.ReadFile("views/base.html") if err != nil { return nil, fmt.Errorf("cannot read base.html: %w", err) } pages := []string{"list", "details", "notfound"} for _, page := range pages { pageContent, err := viewsFS.ReadFile(fmt.Sprintf("views/%s.html", page)) if err != nil { return nil, fmt.Errorf("cannot read %s.html: %w", page, err) } tmpl := template.New("base").Funcs(funcMap) tmpl, err = tmpl.Parse(string(baseContent)) if err != nil { return nil, fmt.Errorf("cannot parse base template: %w", err) } tmpl, err = tmpl.Parse(string(pageContent)) if err != nil { return nil, fmt.Errorf("cannot parse %s template: %w", page, err) } tm.templates[page] = tmpl } return tm, nil } // Render рендерит шаблон страницы func (tm *TemplateManager) Render(w io.Writer, page string, data *PageData) { tmpl, ok := tm.templates[page] if !ok { http.Error(w.(http.ResponseWriter), "Template not found", http.StatusInternalServerError) return } var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil { log.Printf("Template error (%s): %s", page, err) http.Error(w.(http.ResponseWriter), "Template error", http.StatusInternalServerError) return } w.Write(buf.Bytes()) } // formatTimeAgo возвращает относительное время на русском func formatTimeAgo(ts int64) string { if ts == 0 { return "неизвестно" } diff := time.Now().Unix() - ts if diff < 0 { diff = 0 } if diff < 60 { return "только что" } if diff < 3600 { minutes := diff / 60 return strconv.FormatInt(minutes, 10) + " " + getNounForm(int(minutes), "минуту", "минуты", "минут") + " назад" } if diff < 86400 { hours := diff / 3600 return strconv.FormatInt(hours, 10) + " " + getNounForm(int(hours), "час", "часа", "часов") + " назад" } if diff < 604800 { days := diff / 86400 return strconv.FormatInt(days, 10) + " " + getNounForm(int(days), "день", "дня", "дней") + " назад" } return formatDate(ts) } // getNounForm возвращает правильную форму существительного для числительного func getNounForm(number int, form1, form2, form5 string) string { abs := int(math.Abs(float64(number))) lastDigit := abs % 10 lastTwoDigits := abs % 100 if lastTwoDigits >= 11 && lastTwoDigits <= 14 { return form5 } if lastDigit == 1 { return form1 } if lastDigit >= 2 && lastDigit <= 4 { return form2 } return form5 }