177 lines
4.4 KiB
Go
177 lines
4.4 KiB
Go
/*
|
|
* 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
|
|
}
|