A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
1package appview
2
3import (
4 "embed"
5 "fmt"
6 "html/template"
7 "io/fs"
8 "net/http"
9 "strings"
10 "time"
11
12 "atcr.io/pkg/appview/licenses"
13)
14
15//go:generate curl -fsSL -o static/js/htmx.min.js https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js
16//go:generate curl -fsSL -o static/js/lucide.min.js https://unpkg.com/lucide@latest/dist/umd/lucide.min.js
17
18//go:embed templates/**/*.html
19var templatesFS embed.FS
20
21//go:embed static
22var staticFS embed.FS
23
24// Templates returns parsed templates with helper functions
25func Templates() (*template.Template, error) {
26 funcMap := template.FuncMap{
27 "timeAgo": func(t time.Time) string {
28 duration := time.Since(t)
29
30 if duration < time.Minute {
31 return "just now"
32 } else if duration < time.Hour {
33 mins := int(duration.Minutes())
34 if mins == 1 {
35 return "1 minute ago"
36 }
37 return fmt.Sprintf("%d minutes ago", mins)
38 } else if duration < 24*time.Hour {
39 hours := int(duration.Hours())
40 if hours == 1 {
41 return "1 hour ago"
42 }
43 return fmt.Sprintf("%d hours ago", hours)
44 } else {
45 days := int(duration.Hours() / 24)
46 if days == 1 {
47 return "1 day ago"
48 }
49 return fmt.Sprintf("%d days ago", days)
50 }
51 },
52
53 "humanizeBytes": func(bytes int64) string {
54 const unit = 1024
55 if bytes < unit {
56 return fmt.Sprintf("%d B", bytes)
57 }
58 div, exp := int64(unit), 0
59 for n := bytes / unit; n >= unit; n /= unit {
60 div *= unit
61 exp++
62 }
63 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
64 },
65
66 "truncateDigest": func(digest string, length int) string {
67 if len(digest) <= length {
68 return digest
69 }
70 return digest[:length] + "..."
71 },
72
73 "firstChar": func(s string) string {
74 if len(s) == 0 {
75 return "?"
76 }
77 return string([]rune(s)[0])
78 },
79
80 "trimPrefix": func(prefix, s string) string {
81 if len(s) >= len(prefix) && s[:len(prefix)] == prefix {
82 return s[len(prefix):]
83 }
84 return s
85 },
86
87 "sanitizeID": func(s string) string {
88 // Replace special CSS selector characters with dashes
89 // e.g., "sha256:abc123" becomes "sha256-abc123"
90 // e.g., "v0.0.2" becomes "v0-0-2"
91 s = strings.ReplaceAll(s, ":", "-")
92 s = strings.ReplaceAll(s, ".", "-")
93 return s
94 },
95
96 "parseLicenses": func(licensesStr string) []licenses.LicenseInfo {
97 return licenses.ParseLicenses(licensesStr)
98 },
99 }
100
101 tmpl := template.New("").Funcs(funcMap)
102 tmpl, err := tmpl.ParseFS(templatesFS, "templates/**/*.html")
103 if err != nil {
104 return nil, err
105 }
106
107 return tmpl, nil
108}
109
110// StaticHandler returns HTTP handler for static files
111func StaticHandler() http.Handler {
112 sub, err := fs.Sub(staticFS, "static")
113 if err != nil {
114 panic(err)
115 }
116 return http.FileServer(http.FS(sub))
117}
118
119// StaticRootFiles returns list of root-level files in static directory (not subdirectories)
120func StaticRootFiles() ([]string, error) {
121 entries, err := staticFS.ReadDir("static")
122 if err != nil {
123 return nil, err
124 }
125
126 var files []string
127 for _, entry := range entries {
128 // Only include files, not directories
129 if !entry.IsDir() {
130 files = append(files, entry.Name())
131 }
132 }
133 return files, nil
134}
135
136// StaticSubdir returns an fs.FS for a subdirectory within static/
137func StaticSubdir(name string) http.Handler {
138 sub, err := fs.Sub(staticFS, "static/"+name)
139 if err != nil {
140 panic(err)
141 }
142 return http.FileServer(http.FS(sub))
143}