forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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}