Initial working prototype

This commit is contained in:
Chandler Swift 2025-01-13 22:50:12 -06:00
parent 46a865b97c
commit b5a29f1f54
Signed by: chandlerswift
GPG key ID: A851D929D52FB93F
10 changed files with 437 additions and 1 deletions

2
go.mod
View file

@ -1,3 +1,5 @@
module git.chandlerswift.com/chandlerswift/bannergen module git.chandlerswift.com/chandlerswift/bannergen
go 1.23.4 go 1.23.4
require golang.org/x/image v0.23.0

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=

View file

@ -0,0 +1,81 @@
package espn
import (
"encoding/json"
"fmt"
"io"
"net/http"
"git.chandlerswift.com/chandlerswift/bannergen/internal/types"
)
type ESPNDataFetcher struct{}
func (ESPNDataFetcher) Fetch() (sports []types.Sport) {
sportNames := []string{"hockey/nhl"}
type ApiResponse struct {
Sports []struct {
Name string `json:"name"`
Leagues []struct {
Teams []struct {
Team struct {
Slug string `json:"slug"`
Abbreviation string `json:"abbreviation"`
DisplayName string `json:"displayName"`
Color string `json:"color"`
AlternateColor string `json:"alternateColor"`
Logos []struct {
Href string `json:"href"`
} `json:"logos"`
} `json:"team"`
} `json:"teams"`
} `json:"leagues"`
} `json:"sports"`
}
for _, sportName := range sportNames {
res, err := http.Get(fmt.Sprintf("https://site.api.espn.com/apis/site/v2/sports/%v/teams", sportName))
if err != nil {
panic(err)
}
if res.StatusCode != 200 {
panic(res.StatusCode)
}
data, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
var apiResponse ApiResponse
if err := json.Unmarshal([]byte(data), &apiResponse); err != nil {
panic(err)
}
if len(apiResponse.Sports) == 0 {
panic("empty apiResponse.Sports")
}
// Use the first sport
sport := types.Sport{
Name: apiResponse.Sports[0].Name,
}
// Iterate through leagues and teams
for _, league := range apiResponse.Sports[0].Leagues {
for _, t := range league.Teams {
team := types.Team{
Slug: t.Team.Slug,
Abbreviation: t.Team.Abbreviation,
DisplayName: t.Team.DisplayName,
Logo: t.Team.Logos[0].Href, // Use the first logo
Color: t.Team.Color,
AlternateColor: t.Team.AlternateColor,
}
sport.Teams = append(sport.Teams, team)
}
}
sports = append(sports, sport)
}
return
}

View file

@ -0,0 +1,7 @@
package datafetchers
import "git.chandlerswift.com/chandlerswift/bannergen/internal/types"
type DataFetcher interface {
Fetch() []types.Sport
}

15
internal/types/types.go Normal file
View file

@ -0,0 +1,15 @@
package types
type Team struct {
Slug string
Abbreviation string
DisplayName string
Color string
AlternateColor string
Logo string
}
type Sport struct {
Name string
Teams []Team
}

196
main.go
View file

@ -1,5 +1,199 @@
package main package main
func main() { import (
"context"
"embed"
"flag"
"fmt"
"image"
"image/color"
"image/png"
"io"
"math"
"net/http"
"runtime/debug"
"text/template"
"time"
"golang.org/x/image/draw"
"git.chandlerswift.com/chandlerswift/bannergen/internal/datafetchers"
"git.chandlerswift.com/chandlerswift/bannergen/internal/datafetchers/espn"
"git.chandlerswift.com/chandlerswift/bannergen/internal/types"
)
//go:embed static templates
var frontend embed.FS
func main() {
port := flag.Int("port", 8000, "Port to listen on")
flag.Parse()
// preload teams data
fetchers := []datafetchers.DataFetcher{
espn.ESPNDataFetcher{},
}
var sports []types.Sport
for _, fetcher := range fetchers {
sports = append(sports, fetcher.Fetch()...)
}
rawBuildInfo, _ := debug.ReadBuildInfo()
var revision, time string
var dirty bool
for _, buildSetting := range rawBuildInfo.Settings {
if buildSetting.Key == "vcs.revision" {
revision = buildSetting.Value
} else if buildSetting.Key == "vcs.time" {
time = buildSetting.Value
} else if buildSetting.Key == "vcs.modified" {
dirty = buildSetting.Value == "true"
}
}
buildInfo := map[string]interface{}{
"GitRev": revision,
"GitDirty": dirty,
"GitTime": time,
"GoVersion": rawBuildInfo.GoVersion,
"AllBuildInfo": rawBuildInfo.String(),
}
http.Handle("GET /static/", http.FileServer(http.FS(frontend)))
tmpls := template.Must(template.ParseFS(frontend, "templates/*"))
http.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
err := tmpls.ExecuteTemplate(w, "index.html", map[string]interface{}{})
if err != nil {
panic(err)
}
})
http.HandleFunc("GET /about.html", func(w http.ResponseWriter, r *http.Request) {
err := tmpls.ExecuteTemplate(w, "about.html", map[string]interface{}{
"buildInfo": buildInfo,
})
if err != nil {
panic(err)
}
})
http.HandleFunc("/generate/{image_name}", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
colorLeftHex := r.Form.Get("left_color")
if colorLeftHex == "" {
http.Error(w, "no left_color provided", 400)
}
colorRightHex := r.Form.Get("right_color")
if colorRightHex == "" {
http.Error(w, "no right_color provided", 400)
}
logoLeftPath := r.Form.Get("left_logo")
if logoLeftPath == "" {
http.Error(w, "no left_logo provided", 400)
}
logoRightPath := r.Form.Get("right_logo")
if logoRightPath == "" {
http.Error(w, "no right_logo provided", 400)
}
size := 400 // TODO: param
// Set image dimensions; TODO: param
const width, height = 1280, 720
// Create a blank RGBA image
img := image.NewRGBA(image.Rect(0, 0, width, height))
// Convert hex colors to RGBA
colorLeft := hexToRGBA(colorLeftHex)
colorRight := hexToRGBA(colorRightHex)
// Fill the image with colors split by angle
centerX, centerY := float64(width)/2, float64(height)/2
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
dx, dy := float64(x)-centerX, float64(y)-centerY
rotatedAngle := math.Atan2(dy, dx) - 16.0*math.Pi/180
if math.Cos(rotatedAngle) >= 0 {
img.Set(x, y, colorRight)
} else {
img.Set(x, y, colorLeft)
}
}
}
// Load logos
logoLeft, err := loadPNG(logoLeftPath)
if err != nil {
panic(err)
}
logoRight, err := loadPNG(logoRightPath)
if err != nil {
panic(err)
}
// Resize logos using golang.org/x/image/draw
logoLeftResized := resizeImage(logoLeft, size, size)
logoRightResized := resizeImage(logoRight, size, size)
// Place logos
logoLeftX, logoLeftY := (width/4)-(size/2), (height/2)-(size/2)
logoRightX, logoRightY := (3*width/4)-(size/2), (height/2)-(size/2)
draw.Draw(img, image.Rect(logoLeftX, logoLeftY, logoLeftX+size, logoLeftY+size), logoLeftResized, image.Point{}, draw.Over)
draw.Draw(img, image.Rect(logoRightX, logoRightY, logoRightX+size, logoRightY+size), logoRightResized, image.Point{}, draw.Over)
png.Encode(w, img)
})
panic(http.ListenAndServe(fmt.Sprintf(":%v", *port), nil))
}
func resizeImage(src *image.RGBA, newWidth, newHeight int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
return dst
}
func hexToRGBA(hex string) color.RGBA {
var r, g, b int
var err error
if hex[0] == '#' {
_, err = fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
} else {
_, err = fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b)
}
if err != nil {
panic(err)
}
return color.RGBA{uint8(r), uint8(g), uint8(b), 255}
}
func loadPNG(path string) (*image.RGBA, error) {
var reader io.ReadCloser
var err error
// TODO: smarter limits here
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("failed to fetch image: %s", resp.Status)
}
reader = resp.Body
defer reader.Close()
img, err := png.Decode(reader)
if err != nil {
return nil, err
}
// Convert to RGBA
rgbaImg := image.NewRGBA(img.Bounds())
draw.Draw(rgbaImg, rgbaImg.Bounds(), img, image.Point{}, draw.Src)
return rgbaImg, nil
} }

7
static/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

6
static/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

52
templates/about.html Normal file
View file

@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>About | BannerGen</title>
<link href="static/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="./">BannerGen</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" aria-current="page" href="./">Home</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="about.html">About</a>
</li>
<!-- <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Dropdown
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</li> -->
</ul>
</div>
</div>
</nav>
<div class="container my-5">
<h1>BannerGen</h1>
<div class="col-lg-8 px-0">
Designed by <a href="https://ericvillnow.com/">Eric Villnow</a> and <a href="https://chandlerswift.com/">Chandler Swift</a>.
<hr class="col-1 my-4">
<pre>{{.buildInfo}}</pre>
<a href="https://git.chandlerswift.com/chandlerswift/bannergen" class="btn btn-primary">Source available under the AGPL</a>
</div>
</div>
</body>
</html>

70
templates/index.html Normal file
View file

@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BannerGen</title>
<link href="static/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="./">BannerGen</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="./">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="about.html">About</a>
</li>
<!-- <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Dropdown
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</li> -->
</ul>
</div>
</div>
</nav>
<div class="container my-5">
<h1>BannerGen</h1>
<form action="generate/image.png">
<div class="mb-3">
<label for="left_logo" class="form-label">Left image</label>
<input type="url" class="form-control" id="left_logo" name="left_logo" value="https://a.espncdn.com/i/teamlogos/nhl/500/min.png">
</div>
<div class="mb-3">
<label for="left_color" class="form-label">Left background color</label>
<input type="color" class="form-control" id="left_color" name="left_color" value="#124734">
</div>
<div class="mb-3">
<label for="right_logo" class="form-label">Right image</label>
<input type="url" class="form-control" id="right_logo" name="right_logo" value="https://a.espncdn.com/i/teamlogos/nhl/500/vgk.png">
</div>
<div class="mb-3">
<label for="right_color" class="form-label">Right background color</label>
<input type="color" class="form-control" id="right_color" name="right_color" value="#344043">
</div>
<button id="submit" type="submit" class="btn btn-primary">Generate image</button>
</form>
<div id="result"></div>
</div>
</body>
<script>
document.querySelector('form').addEventListener('submit', function(event){
event.preventDefault();
document.getElementById('result').innerHTML = `<img src="generate/image.png?${new URLSearchParams(new FormData(event.target)).toString()}">`;
});
</script>
</html>