at master 275 lines 8.4 kB view raw
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}