From 7c1595859eda67f48c0b85c36ecc9c981b511442 Mon Sep 17 00:00:00 2001 From: logikonline Date: Fri, 30 Jan 2026 23:23:38 -0500 Subject: [PATCH] refactor(socialcard): redesign card layouts and add portrait format Redesign preset card layout with left accent stripe, centered avatar, and vertically centered text blocks. Add portrait format (1080x1350) for solid and image styles with split layout. Improve text wrapping and truncation logic. Update solid card to support background image in bottom half with solid color top section. --- modules/socialcard/socialcard.go | 320 ++++++++++++++----------- routers/web/repo/socialcard.go | 4 +- templates/repo/settings/media_kit.tmpl | 9 +- 3 files changed, 191 insertions(+), 142 deletions(-) diff --git a/modules/socialcard/socialcard.go b/modules/socialcard/socialcard.go index fa3ad7e40d..79dafccc76 100644 --- a/modules/socialcard/socialcard.go +++ b/modules/socialcard/socialcard.go @@ -37,6 +37,10 @@ var logoData []byte const ( CardWidth = 1200 CardHeight = 630 + + // Portrait dimensions for solid and image styles. + PortraitWidth = 1080 + PortraitHeight = 1350 ) // Theme defines the color scheme for a social card. @@ -108,10 +112,15 @@ func IsValidStyle(name string) bool { } // GetTheme returns a theme by name, falling back to dark. +// For "solid" and "image" styles, returns a placeholder theme with the correct Name +// so the RenderCard dispatcher routes to the right renderer. func GetTheme(name string) Theme { if t, ok := ThemesByName[name]; ok { return t } + if name == "solid" || name == "image" { + return Theme{Name: name} + } return ThemeDark } @@ -171,8 +180,8 @@ func (r *Renderer) renderPresetCard(data CardData, theme Theme) ([]byte, error) // Fill background fillRect(img, img.Bounds(), theme.Background) - // Top accent bar (4px) - fillRect(img, image.Rect(0, 0, CardWidth, 4), theme.CardBorderColor) + // Left accent stripe (6px wide) + fillRect(img, image.Rect(0, 0, 6, CardHeight), theme.CardBorderColor) faces, err := r.createFaces() if err != nil { @@ -180,47 +189,110 @@ func (r *Renderer) renderPresetCard(data CardData, theme Theme) ([]byte, error) } defer faces.close() - padding := 60 - avatarSize := 96 - avatarGap := 24 + leftPad := 60 + rightPad := 60 + avatarSize := 140 + avatarGap := 40 - // Repo avatar (top-right, rounded) + // Repo avatar (right side, vertically centered, rounded) hasAvatar := false if data.RepoAvatarURL != "" { avatarImg := fetchAndDecodeAvatar(data.RepoAvatarURL, avatarSize) if avatarImg != nil { - avatarX := CardWidth - padding - avatarSize - avatarY := padding + 20 - drawRoundedAvatar(img, avatarX, avatarY, avatarSize, 12, avatarImg) + avatarX := CardWidth - rightPad - avatarSize + avatarY := (CardHeight - avatarSize) / 2 + drawRoundedAvatar(img, avatarX, avatarY, avatarSize, 16, avatarImg) hasAvatar = true } } - // Text area width: shrink if avatar is present to avoid overlap - contentWidth := CardWidth - 2*padding + // Text area width: shrink if avatar is present + contentWidth := CardWidth - leftPad - rightPad if hasAvatar { - contentWidth = CardWidth - 2*padding - avatarSize - avatarGap + contentWidth = CardWidth - leftPad - rightPad - avatarSize - avatarGap } - r.drawCardText(img, faces, data, theme, padding, contentWidth) + // Calculate total text block height for vertical centering + titleLines := wrapText(data.Title, faces.title, contentWidth) + if len(titleLines) > 2 { + titleLines = titleLines[:2] + titleLines[1] = truncateWithEllipsis(titleLines[1], faces.title, contentWidth) + } + titleBlockH := len(titleLines) * 60 + + var descLines []string + descBlockH := 0 + if data.Description != "" { + descLines = wrapText(data.Description, faces.desc, contentWidth) + if len(descLines) > 3 { + descLines = descLines[:3] + descLines[2] = truncateWithEllipsis(descLines[2], faces.desc, contentWidth) + } + descBlockH = len(descLines)*34 + 16 // 16 = gap between title and desc + } + + metaH := 28 // height of bottom metadata line + gapBeforeMeta := 24 + totalH := titleBlockH + descBlockH + gapBeforeMeta + metaH + + // Vertically center the text block + startY := (CardHeight-totalH)/2 + 48 // baseline of first title line + + // Draw title + for i, line := range titleLines { + drawText(img, leftPad, startY+i*60, line, faces.title, theme.TitleColor) + } + + // Draw description + descY := startY + titleBlockH + 16 + for i, line := range descLines { + drawText(img, leftPad, descY+i*34, line, faces.desc, theme.DescColor) + } + + // Bottom metadata: language + repo name + metaY := descY + descBlockH + gapBeforeMeta + xCursor := leftPad + if data.LanguageName != "" { + langColor := parseHexColor(data.LanguageColor) + drawCircle(img, xCursor+8, metaY-6, 8, langColor) + xCursor += 24 + drawText(img, xCursor, metaY, data.LanguageName, faces.meta, theme.SubtextColor) + xCursor += measureText(data.LanguageName, faces.meta) + 30 + } + drawText(img, xCursor, metaY, data.RepoFullName, faces.meta, theme.SubtextColor) + + // GitCaddy logo (bottom right) + logoBounds := r.logo.Bounds() + logoX := CardWidth - rightPad - logoBounds.Dx() + logoY := CardHeight - 40 - logoBounds.Dy() + draw.Draw(img, image.Rect(logoX, logoY, logoX+logoBounds.Dx(), logoY+logoBounds.Dy()), + r.logo, logoBounds.Min, draw.Over) - // Encode to PNG return encodePNG(img) } -// renderSolidCard renders a card with a user-chosen solid background color -// and auto-contrast text colors. +// renderSolidCard renders a portrait card with a solid-color top half +// containing center-aligned text, and the background image in the bottom half. +// If no background image is provided, the solid color fills the entire card. func (r *Renderer) renderSolidCard(data CardData) ([]byte, error) { - img := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight)) + w, h := PortraitWidth, PortraitHeight + img := image.NewRGBA(image.Rect(0, 0, w, h)) bgColor := parseHexColor(data.SolidColor) theme := contrastTheme(bgColor) + // Fill entire card with solid color first fillRect(img, img.Bounds(), bgColor) - // Top accent bar — slightly lighter/darker than background - accentColor := shiftColor(bgColor, 40) - fillRect(img, image.Rect(0, 0, CardWidth, 4), accentColor) + // If there's a background image, draw it in the bottom half + splitY := h / 2 + if data.BgImage != nil { + bottomH := h - splitY + cropped := coverCrop(data.BgImage, w, bottomH) + draw.Draw(img, + image.Rect(0, splitY, w, h), + cropped, cropped.Bounds().Min, draw.Src) + } faces, err := r.createFaces() if err != nil { @@ -228,56 +300,89 @@ func (r *Renderer) renderSolidCard(data CardData) ([]byte, error) { } defer faces.close() - padding := 60 - avatarSize := 96 - avatarGap := 24 + pad := 80 + contentWidth := w - 2*pad - hasAvatar := false - if data.RepoAvatarURL != "" { - avatarImg := fetchAndDecodeAvatar(data.RepoAvatarURL, avatarSize) - if avatarImg != nil { - avatarX := CardWidth - padding - avatarSize - avatarY := padding + 20 - drawRoundedAvatar(img, avatarX, avatarY, avatarSize, 12, avatarImg) - hasAvatar = true + // Build text block for the top half, center-aligned + titleLines := wrapText(data.Title, faces.title, contentWidth) + if len(titleLines) > 3 { + titleLines = titleLines[:3] + titleLines[2] = truncateWithEllipsis(titleLines[2], faces.title, contentWidth) + } + titleBlockH := len(titleLines) * 60 + + var descLines []string + descBlockH := 0 + if data.Description != "" { + descLines = wrapText(data.Description, faces.desc, contentWidth) + if len(descLines) > 3 { + descLines = descLines[:3] + descLines[2] = truncateWithEllipsis(descLines[2], faces.desc, contentWidth) } + descBlockH = len(descLines)*34 + 20 } - contentWidth := CardWidth - 2*padding - if hasAvatar { - contentWidth = CardWidth - 2*padding - avatarSize - avatarGap + metaH := 28 + gapBeforeMeta := 20 + totalH := titleBlockH + descBlockH + gapBeforeMeta + metaH + + // Vertically center in the top half + topCenter := splitY / 2 + startY := topCenter - totalH/2 + 48 + + // Draw title (centered) + for i, line := range titleLines { + lineW := measureText(line, faces.title) + x := (w - lineW) / 2 + drawText(img, x, startY+i*60, line, faces.title, theme.TitleColor) } - r.drawCardText(img, faces, data, theme, padding, contentWidth) + // Draw description (centered) + descY := startY + titleBlockH + 20 + for i, line := range descLines { + lineW := measureText(line, faces.desc) + x := (w - lineW) / 2 + drawText(img, x, descY+i*34, line, faces.desc, theme.DescColor) + } + + // Repo full name (centered, smaller) + metaY := descY + descBlockH + gapBeforeMeta + repoW := measureText(data.RepoFullName, faces.meta) + drawText(img, (w-repoW)/2, metaY, data.RepoFullName, faces.meta, theme.SubtextColor) + + // GitCaddy logo (bottom right) + logoBounds := r.logo.Bounds() + logoX := w - pad - logoBounds.Dx() + logoY := h - 40 - logoBounds.Dy() + draw.Draw(img, image.Rect(logoX, logoY, logoX+logoBounds.Dx(), logoY+logoBounds.Dy()), + r.logo, logoBounds.Min, draw.Over) return encodePNG(img) } -// renderImageCard renders a card with a full-bleed background image and -// a dark gradient scrim at the bottom for text readability. +// renderImageCard renders a portrait card with a full-bleed background image, +// a strong dark gradient scrim over the bottom ~45%, and white text at the bottom-left. func (r *Renderer) renderImageCard(data CardData) ([]byte, error) { - img := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight)) + w, h := PortraitWidth, PortraitHeight + img := image.NewRGBA(image.Rect(0, 0, w, h)) // Draw background image (cover-crop to fill) if data.BgImage != nil { - cropped := coverCrop(data.BgImage, CardWidth, CardHeight) + cropped := coverCrop(data.BgImage, w, h) draw.Draw(img, img.Bounds(), cropped, cropped.Bounds().Min, draw.Src) } else { // Fallback to dark if no image fillRect(img, img.Bounds(), ThemeDark.Background) } - // Draw gradient scrim over bottom 50% - scrimStartY := CardHeight * 50 / 100 - drawGradientScrim(img, scrimStartY, CardHeight, 200) + // Strong gradient scrim over bottom 45% + scrimStartY := h * 55 / 100 + drawGradientScrim(img, scrimStartY, h, 220) // White text on scrim - theme := Theme{ - TitleColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, - DescColor: color.RGBA{R: 220, G: 220, B: 220, A: 255}, - SubtextColor: color.RGBA{R: 180, G: 180, B: 180, A: 255}, - AccentColor: color.RGBA{R: 100, G: 160, B: 255, A: 255}, - } + titleColor := color.RGBA{R: 255, G: 255, B: 255, A: 255} + descColor := color.RGBA{R: 230, G: 230, B: 230, A: 255} + subtextColor := color.RGBA{R: 170, G: 170, B: 170, A: 255} faces, err := r.createFaces() if err != nil { @@ -285,76 +390,63 @@ func (r *Renderer) renderImageCard(data CardData) ([]byte, error) { } defer faces.close() - padding := 60 - - // Repo avatar top-right (with slight shadow effect for visibility) - avatarSize := 80 - if data.RepoAvatarURL != "" { - avatarImg := fetchAndDecodeAvatar(data.RepoAvatarURL, avatarSize) - if avatarImg != nil { - avatarX := CardWidth - padding - avatarSize - avatarY := padding - drawRoundedAvatar(img, avatarX, avatarY, avatarSize, 12, avatarImg) - } - } - - // Text at bottom on scrim — use larger title for impact - contentWidth := CardWidth - 2*padding - bottomPadding := 60 + pad := 80 + contentWidth := w - 2*pad + bottomPad := 80 // Build text from bottom up - yBottom := CardHeight - bottomPadding + yBottom := h - bottomPad - // Repo full name (bottom-most) - drawText(img, padding, yBottom, data.RepoFullName, faces.meta, theme.SubtextColor) - yBottom -= 30 + // Repo full name (bottom-most, small caps style) + drawText(img, pad, yBottom, data.RepoFullName, faces.meta, subtextColor) + yBottom -= 36 - // Unsplash attribution + // Unsplash attribution (right-aligned on same line as repo name) if data.UnsplashAuthor != "" { attrText := "Photo by " + data.UnsplashAuthor - attrX := CardWidth - padding - measureText(attrText, faces.meta) - drawText(img, attrX, CardHeight-bottomPadding, attrText, faces.meta, theme.SubtextColor) + attrX := w - pad - measureText(attrText, faces.meta) + drawText(img, attrX, yBottom+36, attrText, faces.meta, subtextColor) } // Language indicator if data.LanguageName != "" { langColor := parseHexColor(data.LanguageColor) - drawCircle(img, padding+8, yBottom-6, 8, langColor) - drawText(img, padding+24, yBottom, data.LanguageName, faces.meta, theme.SubtextColor) - yBottom -= 30 + drawCircle(img, pad+8, yBottom-6, 8, langColor) + drawText(img, pad+24, yBottom, data.LanguageName, faces.meta, subtextColor) + yBottom -= 36 } - yBottom -= 10 + yBottom -= 16 - // Description (max 2 lines for image style) + // Description (max 3 lines) if data.Description != "" { descLines := wrapText(data.Description, faces.desc, contentWidth) - if len(descLines) > 2 { - descLines = descLines[:2] - descLines[1] = truncateWithEllipsis(descLines[1], faces.desc, contentWidth) + if len(descLines) > 3 { + descLines = descLines[:3] + descLines[2] = truncateWithEllipsis(descLines[2], faces.desc, contentWidth) } for i := len(descLines) - 1; i >= 0; i-- { - drawText(img, padding, yBottom, descLines[i], faces.desc, theme.DescColor) + drawText(img, pad, yBottom, descLines[i], faces.desc, descColor) yBottom -= 34 } - yBottom -= 6 + yBottom -= 10 } - // Title (max 2 lines) + // Title (max 3 lines, large) titleLines := wrapText(data.Title, faces.title, contentWidth) - if len(titleLines) > 2 { - titleLines = titleLines[:2] - titleLines[1] = truncateWithEllipsis(titleLines[1], faces.title, contentWidth) + if len(titleLines) > 3 { + titleLines = titleLines[:3] + titleLines[2] = truncateWithEllipsis(titleLines[2], faces.title, contentWidth) } for i := len(titleLines) - 1; i >= 0; i-- { - drawText(img, padding, yBottom, titleLines[i], faces.title, theme.TitleColor) + drawText(img, pad, yBottom, titleLines[i], faces.title, titleColor) yBottom -= 60 } // GitCaddy logo (bottom right) logoBounds := r.logo.Bounds() - logoX := CardWidth - padding - logoBounds.Dx() - logoY := CardHeight - padding - logoBounds.Dy() + logoX := w - pad - logoBounds.Dx() + logoY := h - pad + 10 - logoBounds.Dy() draw.Draw(img, image.Rect(logoX, logoY, logoX+logoBounds.Dx(), logoY+logoBounds.Dy()), r.logo, logoBounds.Min, draw.Over) @@ -399,55 +491,6 @@ func (r *Renderer) createFaces() (*fontFaces, error) { return &fontFaces{title: titleFace, desc: descFace, meta: metaFace}, nil } -// drawCardText draws title, description, language, repo name, and logo -// using the standard layout (used by preset and solid styles). -func (r *Renderer) drawCardText(img *image.RGBA, faces *fontFaces, data CardData, theme Theme, padding, contentWidth int) { - // Draw title (max 2 lines) - titleY := 80 + 48 - titleLines := wrapText(data.Title, faces.title, contentWidth) - if len(titleLines) > 2 { - titleLines = titleLines[:2] - titleLines[1] = truncateWithEllipsis(titleLines[1], faces.title, contentWidth) - } - for i, line := range titleLines { - drawText(img, padding, titleY+i*60, line, faces.title, theme.TitleColor) - } - - // Draw description (max 3 lines) - descY := titleY + len(titleLines)*60 + 20 - if data.Description != "" { - descLines := wrapText(data.Description, faces.desc, contentWidth) - if len(descLines) > 3 { - descLines = descLines[:3] - descLines[2] = truncateWithEllipsis(descLines[2], faces.desc, contentWidth) - } - for i, line := range descLines { - drawText(img, padding, descY+i*34, line, faces.desc, theme.DescColor) - } - } - - // Bottom section: language + repo full name - bottomY := CardHeight - 80 - xCursor := padding - - if data.LanguageName != "" { - langColor := parseHexColor(data.LanguageColor) - drawCircle(img, xCursor+8, bottomY-6, 8, langColor) - xCursor += 24 - drawText(img, xCursor, bottomY, data.LanguageName, faces.meta, theme.SubtextColor) - xCursor += measureText(data.LanguageName, faces.meta) + 30 - } - - drawText(img, xCursor, bottomY, data.RepoFullName, faces.meta, theme.SubtextColor) - - // GitCaddy logo (bottom right) - logoBounds := r.logo.Bounds() - logoX := CardWidth - padding - logoBounds.Dx() - logoY := CardHeight - padding - logoBounds.Dy() - draw.Draw(img, image.Rect(logoX, logoY, logoX+logoBounds.Dx(), logoY+logoBounds.Dy()), - r.logo, logoBounds.Min, draw.Over) -} - // fillRect fills a rectangle with a solid color. func fillRect(img *image.RGBA, rect image.Rectangle, c color.RGBA) { for y := rect.Min.Y; y < rect.Max.Y; y++ { @@ -662,10 +705,11 @@ func drawGradientScrim(img *image.RGBA, startY, endY int, maxAlpha uint8) { if height <= 0 { return } + imgW := img.Bounds().Dx() for y := startY; y < endY; y++ { progress := float64(y-startY) / float64(height) alpha := uint8(float64(maxAlpha) * progress) - for x := range CardWidth { + for x := range imgW { r, g, b, a := img.At(x, y).RGBA() // Blend with black at the computed alpha blendR := uint8((r>>8)*(255-uint32(alpha))/255 + 0) diff --git a/routers/web/repo/socialcard.go b/routers/web/repo/socialcard.go index c16ce160f5..4b948a08ce 100644 --- a/routers/web/repo/socialcard.go +++ b/routers/web/repo/socialcard.go @@ -56,8 +56,8 @@ func SocialPreview(ctx *context.Context) { data.SolidColor = "#" + color } - // Load background image from storage if needed - if themeName == "image" && repo.SocialCardBgImage != "" { + // Load background image from storage if needed (used by both "image" and "solid" styles) + if (themeName == "image" || themeName == "solid") && repo.SocialCardBgImage != "" { bgImg := loadBgImageFromStorage(repo.SocialCardBgImage) if bgImg != nil { data.BgImage = bgImg diff --git a/templates/repo/settings/media_kit.tmpl b/templates/repo/settings/media_kit.tmpl index 8d3fc18c95..fbcd86f3a2 100644 --- a/templates/repo/settings/media_kit.tmpl +++ b/templates/repo/settings/media_kit.tmpl @@ -5,7 +5,7 @@
-
+ {{.CsrfTokenHtml}}
@@ -55,7 +55,7 @@ + class="ignore-dirty tw-flex tw-gap-2 tw-items-center tw-mt-2"> {{.CsrfTokenHtml}}