1package issues
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "image"
8 "image/color"
9 "image/png"
10 "log"
11 "net/http"
12
13 "tangled.org/core/appview/models"
14 "tangled.org/core/appview/ogcard"
15)
16
17func (rp *Issues) drawIssueSummaryCard(issue *models.Issue, repo *models.Repo, commentCount int, ownerHandle string) (*ogcard.Card, error) {
18 width, height := ogcard.DefaultSize()
19 mainCard, err := ogcard.NewCard(width, height)
20 if err != nil {
21 return nil, err
22 }
23
24 // Split: content area (75%) and status/stats area (25%)
25 contentCard, statsArea := mainCard.Split(false, 75)
26
27 // Add padding to content
28 contentCard.SetMargin(50)
29
30 // Split content horizontally: main content (80%) and avatar area (20%)
31 mainContent, avatarArea := contentCard.Split(true, 80)
32
33 // Add margin to main content like repo card
34 mainContent.SetMargin(10)
35
36 // Use full main content area for repo name and title
37 bounds := mainContent.Img.Bounds()
38 startX := bounds.Min.X + mainContent.Margin
39 startY := bounds.Min.Y + mainContent.Margin
40
41 // Draw full repository name at top (owner/repo format)
42 var repoOwner string
43 owner, err := rp.idResolver.ResolveIdent(context.Background(), repo.Did)
44 if err != nil {
45 repoOwner = repo.Did
46 } else {
47 repoOwner = "@" + owner.Handle.String()
48 }
49
50 fullRepoName := repoOwner + " / " + repo.Name
51 if len(fullRepoName) > 60 {
52 fullRepoName = fullRepoName[:60] + "…"
53 }
54
55 grayColor := color.RGBA{88, 96, 105, 255}
56 err = mainContent.DrawTextAt(fullRepoName, startX, startY, grayColor, 36, ogcard.Top, ogcard.Left)
57 if err != nil {
58 return nil, err
59 }
60
61 // Draw issue title below repo name with wrapping
62 titleY := startY + 60
63 titleX := startX
64
65 // Truncate title if too long
66 issueTitle := issue.Title
67 maxTitleLength := 80
68 if len(issueTitle) > maxTitleLength {
69 issueTitle = issueTitle[:maxTitleLength] + "…"
70 }
71
72 // Create a temporary card for the title area to enable wrapping
73 titleBounds := mainContent.Img.Bounds()
74 titleWidth := titleBounds.Dx() - (startX - titleBounds.Min.X) - 20 // Leave some margin
75 titleHeight := titleBounds.Dy() - (titleY - titleBounds.Min.Y) - 100 // Leave space for issue ID
76
77 titleRect := image.Rect(titleX, titleY, titleX+titleWidth, titleY+titleHeight)
78 titleCard := &ogcard.Card{
79 Img: mainContent.Img.SubImage(titleRect).(*image.RGBA),
80 Font: mainContent.Font,
81 Margin: 0,
82 }
83
84 // Draw wrapped title
85 lines, err := titleCard.DrawText(issueTitle, color.Black, 54, ogcard.Top, ogcard.Left)
86 if err != nil {
87 return nil, err
88 }
89
90 // Calculate where title ends (number of lines * line height)
91 lineHeight := 60 // Approximate line height for 54pt font
92 titleEndY := titleY + (len(lines) * lineHeight) + 10
93
94 // Draw issue ID in gray below the title
95 issueIdText := fmt.Sprintf("#%d", issue.IssueId)
96 err = mainContent.DrawTextAt(issueIdText, startX, titleEndY, grayColor, 54, ogcard.Top, ogcard.Left)
97 if err != nil {
98 return nil, err
99 }
100
101 // Get issue author handle (needed for avatar and metadata)
102 var authorHandle string
103 author, err := rp.idResolver.ResolveIdent(context.Background(), issue.Did)
104 if err != nil {
105 authorHandle = issue.Did
106 } else {
107 authorHandle = "@" + author.Handle.String()
108 }
109
110 // Draw avatar circle on the right side
111 avatarBounds := avatarArea.Img.Bounds()
112 avatarSize := min(avatarBounds.Dx(), avatarBounds.Dy()) - 20 // Leave some margin
113 if avatarSize > 220 {
114 avatarSize = 220
115 }
116 avatarX := avatarBounds.Min.X + (avatarBounds.Dx() / 2) - (avatarSize / 2)
117 avatarY := avatarBounds.Min.Y + 20
118
119 // Get avatar URL for issue author
120 avatarURL := rp.pages.AvatarUrl(authorHandle, "256")
121 err = avatarArea.DrawCircularExternalImage(avatarURL, avatarX, avatarY, avatarSize)
122 if err != nil {
123 log.Printf("failed to draw avatar (non-fatal): %v", err)
124 }
125
126 // Split stats area: left side for status/comments (80%), right side for dolly (20%)
127 statusCommentsArea, dollyArea := statsArea.Split(true, 80)
128
129 // Draw status and comment count in status/comments area
130 statsBounds := statusCommentsArea.Img.Bounds()
131 statsX := statsBounds.Min.X + 60 // left padding
132 statsY := statsBounds.Min.Y
133
134 iconColor := color.RGBA{88, 96, 105, 255}
135 iconSize := 36
136 textSize := 36.0
137 labelSize := 28.0
138 iconBaselineOffset := int(textSize) / 2
139
140 // Draw status (open/closed) with colored icon and text
141 var statusIcon string
142 var statusText string
143 var statusBgColor color.RGBA
144
145 if issue.Open {
146 statusIcon = "circle-dot"
147 statusText = "open"
148 statusBgColor = color.RGBA{34, 139, 34, 255} // green
149 } else {
150 statusIcon = "ban"
151 statusText = "closed"
152 statusBgColor = color.RGBA{52, 58, 64, 255} // dark gray
153 }
154
155 badgeIconSize := 36
156
157 // Draw icon with status color (no background)
158 err = statusCommentsArea.DrawLucideIcon(statusIcon, statsX, statsY+iconBaselineOffset-badgeIconSize/2+5, badgeIconSize, statusBgColor)
159 if err != nil {
160 log.Printf("failed to draw status icon: %v", err)
161 }
162
163 // Draw text with status color (no background)
164 textX := statsX + badgeIconSize + 12
165 badgeTextSize := 32.0
166 err = statusCommentsArea.DrawTextAt(statusText, textX, statsY+iconBaselineOffset, statusBgColor, badgeTextSize, ogcard.Middle, ogcard.Left)
167 if err != nil {
168 log.Printf("failed to draw status text: %v", err)
169 }
170
171 statusTextWidth := len(statusText) * 20
172 currentX := statsX + badgeIconSize + 12 + statusTextWidth + 50
173
174 // Draw comment count
175 err = statusCommentsArea.DrawLucideIcon("message-square", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor)
176 if err != nil {
177 log.Printf("failed to draw comment icon: %v", err)
178 }
179
180 currentX += iconSize + 15
181 commentText := fmt.Sprintf("%d comments", commentCount)
182 if commentCount == 1 {
183 commentText = "1 comment"
184 }
185 err = statusCommentsArea.DrawTextAt(commentText, currentX, statsY+iconBaselineOffset, iconColor, textSize, ogcard.Middle, ogcard.Left)
186 if err != nil {
187 log.Printf("failed to draw comment text: %v", err)
188 }
189
190 // Draw dolly logo on the right side
191 dollyBounds := dollyArea.Img.Bounds()
192 dollySize := 90
193 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
194 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
195 dollyColor := color.RGBA{180, 180, 180, 255} // light gray
196 err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor)
197 if err != nil {
198 log.Printf("dolly silhouette not available (this is ok): %v", err)
199 }
200
201 // Draw "opened by @author" and date at the bottom with more spacing
202 labelY := statsY + iconSize + 30
203
204 // Format the opened date
205 openedDate := issue.Created.Format("Jan 2, 2006")
206 metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
207
208 err = statusCommentsArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
209 if err != nil {
210 log.Printf("failed to draw metadata: %v", err)
211 }
212
213 return mainCard, nil
214}
215
216func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
217 f, err := rp.repoResolver.Resolve(r)
218 if err != nil {
219 log.Println("failed to get repo and knot", err)
220 return
221 }
222
223 issue, ok := r.Context().Value("issue").(*models.Issue)
224 if !ok {
225 log.Println("issue not found in context")
226 http.Error(w, "issue not found", http.StatusNotFound)
227 return
228 }
229
230 // Get comment count
231 commentCount := len(issue.Comments)
232
233 // Get owner handle for avatar
234 var ownerHandle string
235 owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Repo.Did)
236 if err != nil {
237 ownerHandle = f.Repo.Did
238 } else {
239 ownerHandle = "@" + owner.Handle.String()
240 }
241
242 card, err := rp.drawIssueSummaryCard(issue, &f.Repo, commentCount, ownerHandle)
243 if err != nil {
244 log.Println("failed to draw issue summary card", err)
245 http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
246 return
247 }
248
249 var imageBuffer bytes.Buffer
250 err = png.Encode(&imageBuffer, card.Img)
251 if err != nil {
252 log.Println("failed to encode issue summary card", err)
253 http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError)
254 return
255 }
256
257 imageBytes := imageBuffer.Bytes()
258
259 w.Header().Set("Content-Type", "image/png")
260 w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
261 w.WriteHeader(http.StatusOK)
262 _, err = w.Write(imageBytes)
263 if err != nil {
264 log.Println("failed to write issue summary card", err)
265 return
266 }
267}