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