package appview import ( "embed" "fmt" "html/template" "io/fs" "net/http" "strings" "time" "atcr.io/pkg/appview/licenses" ) //go:generate curl -fsSL -o static/js/htmx.min.js https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js //go:generate curl -fsSL -o static/js/lucide.min.js https://unpkg.com/lucide@latest/dist/umd/lucide.min.js //go:embed templates/**/*.html var templatesFS embed.FS //go:embed static var staticFS embed.FS // Templates returns parsed templates with helper functions func Templates() (*template.Template, error) { funcMap := template.FuncMap{ "timeAgo": func(t time.Time) string { duration := time.Since(t) if duration < time.Minute { return "just now" } else if duration < time.Hour { mins := int(duration.Minutes()) if mins == 1 { return "1 minute ago" } return fmt.Sprintf("%d minutes ago", mins) } else if duration < 24*time.Hour { hours := int(duration.Hours()) if hours == 1 { return "1 hour ago" } return fmt.Sprintf("%d hours ago", hours) } else { days := int(duration.Hours() / 24) if days == 1 { return "1 day ago" } return fmt.Sprintf("%d days ago", days) } }, "humanizeBytes": func(bytes int64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) } div, exp := int64(unit), 0 for n := bytes / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) }, "truncateDigest": func(digest string, length int) string { if len(digest) <= length { return digest } return digest[:length] + "..." }, "firstChar": func(s string) string { if len(s) == 0 { return "?" } return string([]rune(s)[0]) }, "trimPrefix": func(prefix, s string) string { if len(s) >= len(prefix) && s[:len(prefix)] == prefix { return s[len(prefix):] } return s }, "sanitizeID": func(s string) string { // Replace special CSS selector characters with dashes // e.g., "sha256:abc123" becomes "sha256-abc123" // e.g., "v0.0.2" becomes "v0-0-2" s = strings.ReplaceAll(s, ":", "-") s = strings.ReplaceAll(s, ".", "-") return s }, "parseLicenses": func(licensesStr string) []licenses.LicenseInfo { return licenses.ParseLicenses(licensesStr) }, } tmpl := template.New("").Funcs(funcMap) tmpl, err := tmpl.ParseFS(templatesFS, "templates/**/*.html") if err != nil { return nil, err } return tmpl, nil } // StaticHandler returns HTTP handler for static files func StaticHandler() http.Handler { sub, err := fs.Sub(staticFS, "static") if err != nil { panic(err) } return http.FileServer(http.FS(sub)) } // StaticRootFiles returns list of root-level files in static directory (not subdirectories) func StaticRootFiles() ([]string, error) { entries, err := staticFS.ReadDir("static") if err != nil { return nil, err } var files []string for _, entry := range entries { // Only include files, not directories if !entry.IsDir() { files = append(files, entry.Name()) } } return files, nil } // StaticSubdir returns an fs.FS for a subdirectory within static/ func StaticSubdir(name string) http.Handler { sub, err := fs.Sub(staticFS, "static/"+name) if err != nil { panic(err) } return http.FileServer(http.FS(sub)) }