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
|
||||
|
||||
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
|
||||
|
||||
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