Initial working prototype
This commit is contained in:
parent
46a865b97c
commit
b5a29f1f54
2
go.mod
2
go.mod
|
@ -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
2
go.sum
Normal 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=
|
81
internal/datafetchers/espn/main.go
Normal file
81
internal/datafetchers/espn/main.go
Normal 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
|
||||||
|
}
|
7
internal/datafetchers/interface.go
Normal file
7
internal/datafetchers/interface.go
Normal 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
15
internal/types/types.go
Normal 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
196
main.go
|
@ -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
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
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
52
templates/about.html
Normal 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
70
templates/index.html
Normal 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>
|
Loading…
Reference in a new issue