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) Opengraph(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}