diff --git a/classic-stroke/ClassicStroke-Texture.ttf b/classic-stroke/ClassicStroke-Texture.ttf new file mode 100644 index 0000000..b104848 Binary files /dev/null and b/classic-stroke/ClassicStroke-Texture.ttf differ diff --git a/classic-stroke/ClassicStroke.ttf b/classic-stroke/ClassicStroke.ttf new file mode 100644 index 0000000..cff7d7f Binary files /dev/null and b/classic-stroke/ClassicStroke.ttf differ diff --git a/go.mod b/go.mod index 74a2124..dc6c0d2 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,13 @@ module bacherik/killedby go 1.22.5 -require github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect +require github.com/fogleman/gg v1.3.0 + +require ( + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + golang.org/x/image v0.18.0 // indirect + golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 // indirect + golang.org/x/text v0.16.0 // indirect +) diff --git a/go.sum b/go.sum index 96adbed..1516a9b 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,16 @@ -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20210519020934-456a8d69b780 h1:oDMiXaTMyBEuZMU53atpxqYsSB3U1CHkeAu2zr6wTeY= +github.com/srwiley/rasterx v0.0.0-20210519020934-456a8d69b780/go.mod h1:mvWM0+15UqyrFKqdRjY6LuAVJR0HOVhJlEgZ5JWtSWU= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0= +golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= diff --git a/main.go b/main.go index a21af89..942b14a 100644 --- a/main.go +++ b/main.go @@ -5,22 +5,35 @@ import ( "errors" "fmt" "html/template" + "image" + "image/png" "io" "log" + "math" "net/http" "os" "path/filepath" "sort" "strings" "time" + + "github.com/fogleman/gg" + "github.com/srwiley/oksvg" + "github.com/srwiley/rasterx" + "golang.org/x/image/draw" + // Import a package that can handle SVG to PNG conversion. ) -var githubUsername = os.Getenv("GITHUB_USERNAME") -var githubRepository = os.Getenv("GITHUB_REPOSITORY") -var cacheDir = "cache" -var cacheDuration = 12 * time.Hour +var ( + githubUsername = os.Getenv("GITHUB_USERNAME") + githubRepository = os.Getenv("GITHUB_REPOSITORY") + cacheDir = "cache" + cacheDuration = 12 * time.Hour + companyConfig = make(map[string]Company) + projectTypes = make(map[string]string) + yearsProjects []YearProjects +) -// Define the structs for JSON data type Project struct { Name string `json:"name"` Description string `json:"description"` @@ -43,8 +56,12 @@ type ProjectType struct { } type BasePageData struct { - Title string - Companies map[string]string + Title string + Companies map[string]string + OGTitle string + OGUrl string + OGImage string + OGDescription string } type YearProjects struct { @@ -70,25 +87,19 @@ type ProjectPageData struct { } func main() { - // URLs for the JSON files and other initial setup companiesURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/main/config/companies.json", githubUsername, githubRepository) typesURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/main/config/types.json", githubUsername, githubRepository) - // Unmarshal company configs - companyConfig := make(map[string]Company) err := fetchJSON(companiesURL, "companies.json", &companyConfig) if err != nil { log.Fatal("Error fetching company config:", err) } - // Unmarshal project type configs - projectTypes := make(map[string]string) err = fetchJSON(typesURL, "types.json", &projectTypes) if err != nil { log.Fatal("Error fetching project type config:", err) } - // Fetch and unmarshal projects for each company var allProjects []Project for companyName := range companyConfig { projectsURL := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/main/companies/%s.json", githubUsername, githubRepository, companyName) @@ -99,34 +110,28 @@ func main() { log.Fatalf("Error fetching projects for %s: %v", companyName, err) } - // Add projects to the company in the map company := companyConfig[companyName] company.Projects = projects companyConfig[companyName] = company allProjects = append(allProjects, projects...) } - // Group projects by year projectYearMap := make(map[string][]Project) for _, project := range allProjects { - year := strings.Split(project.DateClose, "-")[0] // Assuming DateClose is in YYYY-MM-DD format + year := strings.Split(project.DateClose, "-")[0] projectYearMap[year] = append(projectYearMap[year], project) } - // Sort projects within each year for _, projects := range projectYearMap { sort.Slice(projects, func(i, j int) bool { return projects[i].DateClose > projects[j].DateClose }) } - // Convert the map to a slice for ordered processing in templates - var yearsProjects []YearProjects for year, projects := range projectYearMap { yearsProjects = append(yearsProjects, YearProjects{Year: year, Projects: projects}) } - // Sort years to display them in order sort.Slice(yearsProjects, func(i, j int) bool { return yearsProjects[i].Year > yearsProjects[j].Year }) @@ -154,12 +159,30 @@ func main() { log.Fatalf("Error parsing templates: %v", err) } - // Handlers - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.HandleFunc("/", indexHandler(tmpl)) + http.HandleFunc("/company/", companyHandler(tmpl)) + http.HandleFunc("/project/", projectHandler(tmpl)) + http.HandleFunc("/og/", ogImageHandler) + + fmt.Println("Starting server at :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +func indexHandler(tmpl *template.Template) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } pageData := IndexPageData{ BasePageData: BasePageData{ Title: "Projects by Year", Companies: make(map[string]string), + // Adding Open Graph properties + OGTitle: "Killed by - Home", + OGUrl: getFullURL(r), + OGImage: scheme + "://" + r.Host + "/og/home", + OGDescription: "Explore discontinued projects and their histories.", }, YearsProjects: yearsProjects, // Using the grouped projects by year Types: projectTypes, @@ -174,9 +197,15 @@ func main() { log.Printf("Error executing template: %v", err) http.Error(w, "Internal Server Error", 500) } - }) + } +} - http.HandleFunc("/company/", func(w http.ResponseWriter, r *http.Request) { +func companyHandler(tmpl *template.Template) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } companyName := strings.TrimPrefix(r.URL.Path, "/company/") company, ok := companyConfig[companyName] if !ok { @@ -206,6 +235,11 @@ func main() { BasePageData: BasePageData{ Title: companyName, Companies: make(map[string]string), + // Adding Open Graph properties + OGTitle: "Killed by - " + companyName, + OGUrl: getFullURL(r), + OGImage: scheme + "://" + r.Host + "/og/company/" + companyName, + OGDescription: company.Description, }, YearsProjects: yearsProjects, // Use grouped projects by year Types: projectTypes, @@ -220,9 +254,15 @@ func main() { log.Printf("Error executing template: %v", err) http.Error(w, "Internal Server Error", 500) } - }) + } +} - http.HandleFunc("/project/", func(w http.ResponseWriter, r *http.Request) { +func projectHandler(tmpl *template.Template) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } projectPath := strings.TrimPrefix(r.URL.Path, "/project/") parts := strings.SplitN(projectPath, "/", 2) if len(parts) < 2 { @@ -255,6 +295,11 @@ func main() { BasePageData: BasePageData{ Title: projectName, Companies: make(map[string]string), + // Adding Open Graph properties + OGTitle: projectName + " is a " + project.Type + " being killed by " + companyName + " in " + strings.Split(project.DateClose, "-")[0], + OGUrl: getFullURL(r), + OGImage: scheme + "://" + r.Host + "/og/project/" + companyName + "/" + projectName, + OGDescription: project.Description, }, Project: project, } @@ -268,14 +313,20 @@ func main() { log.Printf("Error executing template: %v", err) http.Error(w, "Internal Server Error", 500) } - }) + } +} - fmt.Println("Starting server at :8080") - log.Fatal(http.ListenAndServe(":8080", nil)) +// getFullURL returns the full URL from the given http.Request object. +func getFullURL(r *http.Request) string { + // Check if the request is made over HTTPS + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + return fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI) } func fetchJSON(url, cacheFileName string, v interface{}) error { - // Create cache directory if it doesn't exist if _, err := os.Stat(cacheDir); os.IsNotExist(err) { err := os.Mkdir(cacheDir, 0755) if err != nil { @@ -284,11 +335,8 @@ func fetchJSON(url, cacheFileName string, v interface{}) error { } cacheFilePath := filepath.Join(cacheDir, cacheFileName) - - // Check if the cached file exists and is still valid if info, err := os.Stat(cacheFilePath); err == nil { if time.Since(info.ModTime()) < cacheDuration { - // Read from the cached file bytes, err := os.ReadFile(cacheFilePath) if err != nil { return err @@ -297,9 +345,6 @@ func fetchJSON(url, cacheFileName string, v interface{}) error { } } - fmt.Print("Fetching ", url, "... ") - - // Fetch from the URL resp, err := http.Get(url) if err != nil { return err @@ -315,13 +360,11 @@ func fetchJSON(url, cacheFileName string, v interface{}) error { return err } - // Unmarshal the JSON data err = json.Unmarshal(bytes, v) if err != nil { return err } - // Write the data to the cache file err = os.WriteFile(cacheFilePath, bytes, 0644) if err != nil { return err @@ -330,9 +373,8 @@ func fetchJSON(url, cacheFileName string, v interface{}) error { return nil } -// isFutureDate checks if the given date string is in the future. func isFutureDate(dateStr string) bool { - layout := "2006-01-02" // Adjust this layout as necessary to match the date format + layout := "2006-01-02" date, err := time.Parse(layout, dateStr) if err != nil { log.Printf("Error parsing date: %v", err) @@ -340,3 +382,227 @@ func isFutureDate(dateStr string) bool { } return date.After(time.Now()) } + +func downloadImage(url string) (image.Image, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if strings.HasSuffix(url, ".svg") { + // Parse SVG data + icon, err := oksvg.ReadIconStream(resp.Body) + if err != nil { + return nil, err + } + + w, h := int(icon.ViewBox.W), int(icon.ViewBox.H) + img := image.NewRGBA(image.Rect(0, 0, w, h)) + scanner := rasterx.NewScannerGV(w, h, img, img.Bounds()) + raster := rasterx.NewDasher(w, h, scanner) + + icon.SetTarget(0, 0, float64(w), float64(h)) + icon.Draw(raster, 1.0) + + return img, nil + } else { + // Handle other image formats + img, _, err := image.Decode(resp.Body) + if err != nil { + return nil, err + } + return img, nil + } +} + +func generateSimpleImage(text string, logoURL string, description string) image.Image { + const W = 1200 + const H = 630 + const LogoMaxWidth = 300 + const LogoMaxHeight = 150 + + dc := gg.NewContext(W, H) + dc.SetRGB(0, 0, 0) // Set background color + dc.Clear() + dc.SetRGB(1, 1, 1) // Set text color + + fontPath := "./classic-stroke/ClassicStroke-Texture.ttf" + if err := dc.LoadFontFace(fontPath, 48); err != nil { + log.Fatalf("Error loading font: %v", err) + } + + dc.DrawStringAnchored(text, float64(W)/2, float64(H)/4, 0.5, 0.5) + + dc.LoadFontFace(fontPath, 24) + dc.DrawStringWrapped(description, float64(W)/2, float64(H)/2, 0.5, 0.5, float64(W)-40, 1.5, gg.AlignCenter) + + if logoURL != "" { + logo, err := downloadImage(logoURL) + if err == nil { + // Calculate scaling factor to fit the logo within the maximum dimensions if necessary + scaleWidth := float64(LogoMaxWidth) / float64(logo.Bounds().Dx()) + scaleHeight := float64(LogoMaxHeight) / float64(logo.Bounds().Dy()) + scale := math.Min(scaleWidth, scaleHeight) + + // If the logo is larger than the max dimensions, scale it down + if scale < 1 { + logoWidth := float64(logo.Bounds().Dx()) * scale + logoHeight := float64(logo.Bounds().Dy()) * scale + + dc.DrawImageAnchored(resizeImage(logo, int(logoWidth), int(logoHeight)), W/2, 3*H/4, 0.5, 0.5) + } else { + // If no scaling is needed, draw the logo at original size + dc.DrawImageAnchored(logo, W/2, 3*H/4, 0.5, 0.5) + } + } else { + log.Printf("Error downloading logo: %v", err) + } + } + + return dc.Image() +} + +// Helper function to resize an image while maintaining aspect ratio +func resizeImage(img image.Image, width int, height int) image.Image { + rect := image.Rect(0, 0, width, height) + dst := image.NewRGBA(rect) + draw.BiLinear.Scale(dst, rect, img, img.Bounds(), draw.Over, nil) + return dst +} + +// Handler for generating Open Graph images +func ogImageHandler(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/og/") + if path == "" { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + var img image.Image + + // Handle different types of OG paths + switch { + case path == "home": + // Generate a default image for the home page + img = generateHomePageImage() + case strings.HasPrefix(path, "company/"): + companyName := strings.TrimPrefix(path, "company/") + company, ok := companyConfig[companyName] + if !ok { + http.NotFound(w, r) + return + } + img = generateSimpleImage("Explore projects by "+companyName, company.Logo, company.Description) + case strings.HasPrefix(path, "project/"): + parts := strings.SplitN(path[len("project/"):], "/", 2) + if len(parts) < 2 { + http.NotFound(w, r) + return + } + company, ok := companyConfig[parts[0]] + if !ok { + http.NotFound(w, r) + return + } + project, ok := findProjectByName(company.Projects, parts[1]) + if !ok { + http.NotFound(w, r) + return + } + img = generateDetailedImage(project) + default: + http.NotFound(w, r) + return + } + + // Encode and write the image as PNG to the response + w.Header().Set("Content-Type", "image/png") + if err := png.Encode(w, img); err != nil { + http.Error(w, "Failed to encode image", http.StatusInternalServerError) + } +} + +// generateHomePageImage creates a generic image for the home page +func generateHomePageImage() image.Image { + const W = 1200 + const H = 630 + dc := gg.NewContext(W, H) + dc.SetRGB(0.9, 0.9, 0.9) // Light grey background + dc.Clear() + + dc.SetRGB(0, 0, 0) // Black text + dc.LoadFontFace("./classic-stroke/ClassicStroke-Texture.ttf", 48) // Adjust path and size as needed + dc.DrawStringAnchored("Welcome to Killed by", W/2, H/3, 0.5, 0.5) + dc.DrawStringAnchored("Explore the lifecycle of discontinued projects", W/2, H*2/3, 0.5, 0.5) + + return dc.Image() +} + +func generateDetailedImage(project Project) image.Image { + const W = 1200 + const H = 630 + dc := gg.NewContext(W, H) + + // Set background and text colors + dc.SetRGB(1, 1, 1) // White background for clarity + dc.Clear() + dc.SetRGB(0, 0, 0) // Black text for visibility + + // Load and set font + fontPath := "./classic-stroke/ClassicStroke-Texture.ttf" + if err := dc.LoadFontFace(fontPath, 24); err != nil { + log.Fatalf("Error loading font: %v", err) + } + + // Initialize y-coordinate for text placement + y := 50.0 + + // Function to draw text with wrapping + drawText := func(text string, wrapWidth float64) { + lines := dc.WordWrap(text, wrapWidth) + for _, line := range lines { + dc.DrawString(line, 50, y) + y += 30 // Increment y-coordinate for the next line + } + y += 20 // Add extra space after a block of text + } + + // Draw each piece of information + drawText(fmt.Sprintf("Project: %s", project.Name), W-100) + drawText(fmt.Sprintf("Description: %s", project.Description), W-100) + drawText(fmt.Sprintf("Developer/Company: %s", project.Company), W-100) + drawText(fmt.Sprintf("Released: %s - Discontinued: %s", project.DateOpen, project.DateClose), W-100) + + // Calculate and display lifespan + lifespan := calculateLifespan(project.DateOpen, project.DateClose) + drawText(fmt.Sprintf("Lifespan: %s years", lifespan), W-100) + + // Type with color + typeColor := projectTypes[project.Type] // Ensure this map is well-defined in your application + dc.SetHexColor(typeColor) + dc.DrawStringWrapped("Type: "+project.Type, 50, y, 0, 0, W-100, 1.5, gg.AlignLeft) + y += 40 + + // Reset color for any additional text + dc.SetRGB(0, 0, 0) + + return dc.Image() +} + +func calculateLifespan(start, end string) string { + startDate, _ := time.Parse("2006-01-02", start) + endDate, _ := time.Parse("2006-01-02", end) + lifespan := endDate.Sub(startDate).Hours() / 24 / 365 + return fmt.Sprintf("%.2f", lifespan) +} + +// Helper function to find a project by name within a slice of projects +func findProjectByName(projects []Project, name string) (Project, bool) { + for _, project := range projects { + if project.Name == name { + return project, true + } + } + return Project{}, false +} diff --git a/templates/header.html b/templates/header.html index 44f384f..c6291a3 100644 --- a/templates/header.html +++ b/templates/header.html @@ -5,6 +5,13 @@