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/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}