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