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 statusArea, dollyArea := statsArea.Split(true, 80)
128
129 // Draw status and comment count in status/comments area
130 statsBounds := statusArea.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 statusColor color.RGBA
144
145 if issue.Open {
146 statusIcon = "circle-dot"
147 statusText = "open"
148 statusColor = color.RGBA{34, 139, 34, 255} // green
149 } else {
150 statusIcon = "ban"
151 statusText = "closed"
152 statusColor = color.RGBA{52, 58, 64, 255} // dark gray
153 }
154
155 statusTextWidth := statusArea.TextWidth(statusText, textSize)
156 badgePadding := 12
157 badgeHeight := int(textSize) + (badgePadding * 2)
158 badgeWidth := iconSize + badgePadding + statusTextWidth + (badgePadding * 2)
159 cornerRadius := 8
160 badgeX := 60
161 badgeY := 0
162
163 statusArea.DrawRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, cornerRadius, statusColor)
164
165 whiteColor := color.RGBA{255, 255, 255, 255}
166 iconX := statsX + badgePadding
167 iconY := statsY + (badgeHeight-iconSize)/2
168 err = statusArea.DrawLucideIcon(statusIcon, iconX, iconY, iconSize, whiteColor)
169 if err != nil {
170 log.Printf("failed to draw status icon: %v", err)
171 }
172
173 textX := statsX + badgePadding + iconSize + badgePadding
174 textY := statsY + (badgeHeight-int(textSize))/2 - 5
175 err = statusArea.DrawTextAt(statusText, textX, textY, whiteColor, textSize, ogcard.Top, ogcard.Left)
176 if err != nil {
177 log.Printf("failed to draw status text: %v", err)
178 }
179
180 currentX := statsX + badgeWidth + 50
181
182 // Draw comment count
183 err = statusArea.DrawLucideIcon("message-square", currentX, iconY, iconSize, iconColor)
184 if err != nil {
185 log.Printf("failed to draw comment icon: %v", err)
186 }
187
188 currentX += iconSize + 15
189 commentText := fmt.Sprintf("%d comments", commentCount)
190 if commentCount == 1 {
191 commentText = "1 comment"
192 }
193 err = statusArea.DrawTextAt(commentText, currentX, textY, iconColor, textSize, ogcard.Top, ogcard.Left)
194 if err != nil {
195 log.Printf("failed to draw comment text: %v", err)
196 }
197
198 // Draw dolly logo on the right side
199 dollyBounds := dollyArea.Img.Bounds()
200 dollySize := 90
201 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2)
202 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25
203 dollyColor := color.RGBA{180, 180, 180, 255} // light gray
204 err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor)
205 if err != nil {
206 log.Printf("dolly not available (this is ok): %v", err)
207 }
208
209 // Draw "opened by @author" and date at the bottom with more spacing
210 labelY := statsY + iconSize + 30
211
212 // Format the opened date
213 openedDate := issue.Created.Format("Jan 2, 2006")
214 metaText := fmt.Sprintf("opened by %s · %s", authorHandle, openedDate)
215
216 err = statusArea.DrawTextAt(metaText, statsX, labelY, iconColor, labelSize, ogcard.Top, ogcard.Left)
217 if err != nil {
218 log.Printf("failed to draw metadata: %v", err)
219 }
220
221 return mainCard, nil
222}
223
224func (rp *Issues) IssueOpenGraphSummary(w http.ResponseWriter, r *http.Request) {
225 f, err := rp.repoResolver.Resolve(r)
226 if err != nil {
227 log.Println("failed to get repo and knot", err)
228 return
229 }
230
231 issue, ok := r.Context().Value("issue").(*models.Issue)
232 if !ok {
233 log.Println("issue not found in context")
234 http.Error(w, "issue not found", http.StatusNotFound)
235 return
236 }
237
238 // Get comment count
239 commentCount := len(issue.Comments)
240
241 // Get owner handle for avatar
242 var ownerHandle string
243 owner, err := rp.idResolver.ResolveIdent(r.Context(), f.Did)
244 if err != nil {
245 ownerHandle = f.Did
246 } else {
247 ownerHandle = "@" + owner.Handle.String()
248 }
249
250 card, err := rp.drawIssueSummaryCard(issue, f, commentCount, ownerHandle)
251 if err != nil {
252 log.Println("failed to draw issue summary card", err)
253 http.Error(w, "failed to draw issue summary card", http.StatusInternalServerError)
254 return
255 }
256
257 var imageBuffer bytes.Buffer
258 err = png.Encode(&imageBuffer, card.Img)
259 if err != nil {
260 log.Println("failed to encode issue summary card", err)
261 http.Error(w, "failed to encode issue summary card", http.StatusInternalServerError)
262 return
263 }
264
265 imageBytes := imageBuffer.Bytes()
266
267 w.Header().Set("Content-Type", "image/png")
268 w.Header().Set("Cache-Control", "public, max-age=3600") // 1 hour
269 w.WriteHeader(http.StatusOK)
270 _, err = w.Write(imageBytes)
271 if err != nil {
272 log.Println("failed to write issue summary card", err)
273 return
274 }
275}