forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
at master 12 kB view raw
1package repo 2 3import ( 4 "bytes" 5 "context" 6 "encoding/hex" 7 "fmt" 8 "image/color" 9 "image/png" 10 "log" 11 "net/http" 12 "sort" 13 "strings" 14 15 "github.com/go-enry/go-enry/v2" 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/ogcard" 19 "tangled.org/core/orm" 20 "tangled.org/core/types" 21) 22 23func (rp *Repo) drawRepoSummaryCard(repo *models.Repo, languageStats []types.RepoLanguageDetails) (*ogcard.Card, error) { 24 width, height := ogcard.DefaultSize() 25 mainCard, err := ogcard.NewCard(width, height) 26 if err != nil { 27 return nil, err 28 } 29 30 // Split: content area (75%) and language bar + icons (25%) 31 contentCard, bottomArea := mainCard.Split(false, 75) 32 33 // Add padding to content 34 contentCard.SetMargin(50) 35 36 // Split content horizontally: main content (80%) and avatar area (20%) 37 mainContent, avatarArea := contentCard.Split(true, 80) 38 39 // Use main content area for both repo name and description to allow dynamic wrapping. 40 mainContent.SetMargin(10) 41 42 var ownerHandle string 43 owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did) 44 if err != nil { 45 ownerHandle = repo.Did 46 } else { 47 ownerHandle = "@" + owner.Handle.String() 48 } 49 50 bounds := mainContent.Img.Bounds() 51 startX := bounds.Min.X + mainContent.Margin 52 startY := bounds.Min.Y + mainContent.Margin 53 currentX := startX 54 currentY := startY 55 lineHeight := 64 // Font size 54 + padding 56 textColor := color.RGBA{88, 96, 105, 255} 57 58 // Draw owner handle 59 ownerWidth, err := mainContent.DrawTextAtWithWidth(ownerHandle, currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) 60 if err != nil { 61 return nil, err 62 } 63 currentX += ownerWidth 64 65 // Draw separator 66 sepWidth, err := mainContent.DrawTextAtWithWidth(" / ", currentX, currentY, textColor, 54, ogcard.Top, ogcard.Left) 67 if err != nil { 68 return nil, err 69 } 70 currentX += sepWidth 71 72 words := strings.Fields(repo.Name) 73 spaceWidth, _ := mainContent.DrawTextAtWithWidth(" ", -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) 74 if spaceWidth == 0 { 75 spaceWidth = 15 76 } 77 78 for _, word := range words { 79 // estimate bold width by measuring regular width and adding a multiplier 80 regularWidth, _ := mainContent.DrawTextAtWithWidth(word, -1000, -1000, color.Black, 54, ogcard.Top, ogcard.Left) 81 estimatedBoldWidth := int(float64(regularWidth) * 1.15) // Heuristic for bold text 82 83 if currentX+estimatedBoldWidth > (bounds.Max.X - mainContent.Margin) { 84 currentX = startX 85 currentY += lineHeight 86 } 87 88 _, err := mainContent.DrawBoldText(word, currentX, currentY, color.Black, 54, ogcard.Top, ogcard.Left) 89 if err != nil { 90 return nil, err 91 } 92 currentX += estimatedBoldWidth + spaceWidth 93 } 94 95 // update Y position for the description 96 currentY += lineHeight 97 98 // draw description 99 if currentY < bounds.Max.Y-mainContent.Margin { 100 totalHeight := float64(bounds.Dy()) 101 repoNameHeight := float64(currentY - bounds.Min.Y) 102 103 if totalHeight > 0 && repoNameHeight < totalHeight { 104 repoNamePercent := (repoNameHeight / totalHeight) * 100 105 if repoNamePercent < 95 { // Ensure there's space left for description 106 _, descriptionCard := mainContent.Split(false, int(repoNamePercent)) 107 descriptionCard.SetMargin(8) 108 109 description := repo.Description 110 if len(description) > 70 { 111 description = description[:70] + "…" 112 } 113 114 _, err = descriptionCard.DrawText(description, color.RGBA{88, 96, 105, 255}, 36, ogcard.Top, ogcard.Left) 115 if err != nil { 116 log.Printf("failed to draw description: %v", err) 117 } 118 } 119 } 120 } 121 122 // Draw avatar circle on the right side 123 avatarBounds := avatarArea.Img.Bounds() 124 avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin 125 if avatarSize > 220 { 126 avatarSize = 220 127 } 128 avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2) 129 avatarY := avatarBounds.Min.Y + 20 130 131 // Get avatar URL and draw it 132 avatarURL := rp.pages.AvatarUrl(ownerHandle, "256") 133 err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize) 134 if err != nil { 135 log.Printf("failed to draw avatar (non-fatal): %v", err) 136 } 137 138 // Split bottom area: icons area (65%) and language bar (35%) 139 iconsArea, languageBarCard := bottomArea.Split(false, 75) 140 141 // Split icons area: left side for stats (80%), right side for dolly (20%) 142 statsArea, dollyArea := iconsArea.Split(true, 80) 143 144 // Draw stats with icons in the stats area 145 starsText := repo.RepoStats.StarCount 146 issuesText := repo.RepoStats.IssueCount.Open 147 pullRequestsText := repo.RepoStats.PullCount.Open 148 149 iconColor := color.RGBA{88, 96, 105, 255} 150 iconSize := 36 151 textSize := 36.0 152 153 // Position stats in the middle of the stats area 154 statsBounds := statsArea.Img.Bounds() 155 statsX := statsBounds.Min.X + 60 // left padding 156 statsY := statsBounds.Min.Y 157 currentX = statsX 158 labelSize := 22.0 159 // Draw star icon, count, and label 160 // Align icon baseline with text baseline 161 iconBaselineOffset := int(textSize) / 2 162 err = statsArea.DrawLucideIcon("star", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 163 if err != nil { 164 log.Printf("failed to draw star icon: %v", err) 165 } 166 starIconX := currentX 167 currentX += iconSize + 15 168 169 starText := fmt.Sprintf("%d", starsText) 170 err = statsArea.DrawTextAt(starText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 171 if err != nil { 172 log.Printf("failed to draw star text: %v", err) 173 } 174 starTextWidth := len(starText) * 20 175 starGroupWidth := iconSize + 15 + starTextWidth 176 177 // Draw "stars" label below and centered under the icon+text group 178 labelY := statsY + iconSize + 15 179 labelX := starIconX + starGroupWidth/2 180 err = iconsArea.DrawTextAt("stars", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 181 if err != nil { 182 log.Printf("failed to draw stars label: %v", err) 183 } 184 185 currentX += starTextWidth + 50 186 187 // Draw issues icon, count, and label 188 issueStartX := currentX 189 err = statsArea.DrawLucideIcon("circle-dot", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 190 if err != nil { 191 log.Printf("failed to draw circle-dot icon: %v", err) 192 } 193 currentX += iconSize + 15 194 195 issueText := fmt.Sprintf("%d", issuesText) 196 err = statsArea.DrawTextAt(issueText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 197 if err != nil { 198 log.Printf("failed to draw issue text: %v", err) 199 } 200 issueTextWidth := len(issueText) * 20 201 issueGroupWidth := iconSize + 15 + issueTextWidth 202 203 // Draw "issues" label below and centered under the icon+text group 204 labelX = issueStartX + issueGroupWidth/2 205 err = iconsArea.DrawTextAt("issues", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 206 if err != nil { 207 log.Printf("failed to draw issues label: %v", err) 208 } 209 210 currentX += issueTextWidth + 50 211 212 // Draw pull request icon, count, and label 213 prStartX := currentX 214 err = statsArea.DrawLucideIcon("git-pull-request", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 215 if err != nil { 216 log.Printf("failed to draw git-pull-request icon: %v", err) 217 } 218 currentX += iconSize + 15 219 220 prText := fmt.Sprintf("%d", pullRequestsText) 221 err = statsArea.DrawTextAt(prText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left) 222 if err != nil { 223 log.Printf("failed to draw PR text: %v", err) 224 } 225 prTextWidth := len(prText) * 20 226 prGroupWidth := iconSize + 15 + prTextWidth 227 228 // Draw "pulls" label below and centered under the icon+text group 229 labelX = prStartX + prGroupWidth/2 230 err = iconsArea.DrawTextAt("pulls", labelX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Center) 231 if err != nil { 232 log.Printf("failed to draw pulls label: %v", err) 233 } 234 235 dollyBounds := dollyArea.Img.Bounds() 236 dollySize := 90 237 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 238 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 239 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 240 err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 241 if err != nil { 242 log.Printf("dolly silhouette not available (this is ok): %v", err) 243 } 244 245 // Draw language bar at bottom 246 err = drawLanguagesCard(languageBarCard, languageStats) 247 if err != nil { 248 log.Printf("failed to draw language bar: %v", err) 249 return nil, err 250 } 251 252 return mainCard, nil 253} 254 255// hexToColor converts a hex color to a go color 256func hexToColor(colorStr string) (*color.RGBA, error) { 257 colorStr = strings.TrimLeft(colorStr, "#") 258 259 b, err := hex.DecodeString(colorStr) 260 if err != nil { 261 return nil, err 262 } 263 264 if len(b) < 3 { 265 return nil, fmt.Errorf("expected at least 3 bytes from DecodeString, got %d", len(b)) 266 } 267 268 clr := color.RGBA{b[0], b[1], b[2], 255} 269 270 return &clr, nil 271} 272 273func drawLanguagesCard(card *ogcard.Card, languageStats []types.RepoLanguageDetails) error { 274 bounds := card.Img.Bounds() 275 cardWidth := bounds.Dx() 276 277 if len(languageStats) == 0 { 278 // Draw a light gray bar if no languages detected 279 card.DrawRect(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, color.RGBA{225, 228, 232, 255}) 280 return nil 281 } 282 283 // Limit to top 5 languages for the visual bar 284 displayLanguages := languageStats 285 if len(displayLanguages) > 5 { 286 displayLanguages = displayLanguages[:5] 287 } 288 289 currentX := bounds.Min.X 290 291 for _, lang := range displayLanguages { 292 var langColor *color.RGBA 293 var err error 294 295 if lang.Color != "" { 296 langColor, err = hexToColor(lang.Color) 297 if err != nil { 298 // Fallback to a default color 299 langColor = &color.RGBA{149, 157, 165, 255} 300 } 301 } else { 302 // Default color if no color specified 303 langColor = &color.RGBA{149, 157, 165, 255} 304 } 305 306 langWidth := float32(cardWidth) * (lang.Percentage / 100) 307 card.DrawRect(currentX, bounds.Min.Y, currentX+int(langWidth), bounds.Max.Y, langColor) 308 currentX += int(langWidth) 309 } 310 311 // Fill remaining space with the last color (if any gap due to rounding) 312 if currentX < bounds.Max.X && len(displayLanguages) > 0 { 313 lastLang := displayLanguages[len(displayLanguages)-1] 314 var lastColor *color.RGBA 315 var err error 316 317 if lastLang.Color != "" { 318 lastColor, err = hexToColor(lastLang.Color) 319 if err != nil { 320 lastColor = &color.RGBA{149, 157, 165, 255} 321 } 322 } else { 323 lastColor = &color.RGBA{149, 157, 165, 255} 324 } 325 card.DrawRect(currentX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, lastColor) 326 } 327 328 return nil 329} 330 331func (rp *Repo) Opengraph(w http.ResponseWriter, r *http.Request) { 332 f, err := rp.repoResolver.Resolve(r) 333 if err != nil { 334 log.Println("failed to get repo and knot", err) 335 return 336 } 337 338 // Get language stats directly from database 339 var languageStats []types.RepoLanguageDetails 340 langs, err := db.GetRepoLanguages( 341 rp.db, 342 orm.FilterEq("repo_at", f.RepoAt()), 343 orm.FilterEq("is_default_ref", 1), 344 ) 345 if err != nil { 346 log.Printf("failed to get language stats from db: %v", err) 347 // non-fatal, continue without language stats 348 } else if len(langs) > 0 { 349 var total int64 350 for _, l := range langs { 351 total += l.Bytes 352 } 353 354 for _, l := range langs { 355 percentage := float32(l.Bytes) / float32(total) * 100 356 color := enry.GetColor(l.Language) 357 languageStats = append(languageStats, types.RepoLanguageDetails{ 358 Name: l.Language, 359 Percentage: percentage, 360 Color: color, 361 }) 362 } 363 364 sort.Slice(languageStats, func(i, j int) bool { 365 if languageStats[i].Name == enry.OtherLanguage { 366 return false 367 } 368 if languageStats[j].Name == enry.OtherLanguage { 369 return true 370 } 371 if languageStats[i].Percentage != languageStats[j].Percentage { 372 return languageStats[i].Percentage > languageStats[j].Percentage 373 } 374 return languageStats[i].Name < languageStats[j].Name 375 }) 376 } 377 378 card, err := rp.drawRepoSummaryCard(f, languageStats) 379 if err != nil { 380 log.Println("failed to draw repo summary card", err) 381 http.Error(w, "failed to draw repo summary card", http.StatusInternalServerError) 382 return 383 } 384 385 var imageBuffer bytes.Buffer 386 err = png.Encode(&imageBuffer, card.Img) 387 if err != nil { 388 log.Println("failed to encode repo summary card", err) 389 http.Error(w, "failed to encode repo summary card", http.StatusInternalServerError) 390 return 391 } 392 393 imageBytes := imageBuffer.Bytes() 394 395 w.Header().Set("Content-Type", "image/png") 396 w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour 397 w.WriteHeader(http.StatusOK) 398 _, err = w.Write(imageBytes) 399 if err != nil { 400 log.Println("failed to write repo summary card", err) 401 return 402 } 403}