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}