From 3c989482189b53d64603cb9b4b6036a8a0fc4dbd Mon Sep 17 00:00:00 2001 From: logikonline Date: Fri, 30 Jan 2026 23:52:32 -0500 Subject: [PATCH] fix(socialcard): improve typography and avatar rendering Adjust font sizes and line heights for better text layout in preset cards (64px title, 28px desc). Create separate portrait font faces with 56px title for portrait formats. Fix avatar rendering to use proper alpha compositing for transparent images. Change delete image endpoint from DELETE to POST method and add author attribution overlay to Unsplash photo thumbnails. --- modules/socialcard/socialcard.go | 68 +++++++++++++++++++------- routers/web/web.go | 2 +- templates/repo/settings/media_kit.tmpl | 42 +++++++++++----- 3 files changed, 81 insertions(+), 31 deletions(-) diff --git a/modules/socialcard/socialcard.go b/modules/socialcard/socialcard.go index 79dafccc76..e7b0e3ae98 100644 --- a/modules/socialcard/socialcard.go +++ b/modules/socialcard/socialcard.go @@ -213,12 +213,15 @@ func (r *Renderer) renderPresetCard(data CardData, theme Theme) ([]byte, error) } // Calculate total text block height for vertical centering + titleLineH := 76 // line height for 64px title + descLineH := 38 // line height for 28px desc + 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 + titleBlockH := len(titleLines) * titleLineH var descLines []string descBlockH := 0 @@ -228,25 +231,25 @@ func (r *Renderer) renderPresetCard(data CardData, theme Theme) ([]byte, error) descLines = descLines[:3] descLines[2] = truncateWithEllipsis(descLines[2], faces.desc, contentWidth) } - descBlockH = len(descLines)*34 + 16 // 16 = gap between title and desc + descBlockH = len(descLines)*descLineH + 16 } - metaH := 28 // height of bottom metadata line + metaH := 30 gapBeforeMeta := 24 totalH := titleBlockH + descBlockH + gapBeforeMeta + metaH // Vertically center the text block - startY := (CardHeight-totalH)/2 + 48 // baseline of first title line + startY := (CardHeight-totalH)/2 + 64 // baseline of first title line // Draw title for i, line := range titleLines { - drawText(img, leftPad, startY+i*60, line, faces.title, theme.TitleColor) + drawText(img, leftPad, startY+i*titleLineH, 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) + drawText(img, leftPad, descY+i*descLineH, line, faces.desc, theme.DescColor) } // Bottom metadata: language + repo name @@ -294,7 +297,7 @@ func (r *Renderer) renderSolidCard(data CardData) ([]byte, error) { cropped, cropped.Bounds().Min, draw.Src) } - faces, err := r.createFaces() + faces, err := r.createPortraitFaces() if err != nil { return nil, err } @@ -384,7 +387,7 @@ func (r *Renderer) renderImageCard(data CardData) ([]byte, error) { 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() + faces, err := r.createPortraitFaces() if err != nil { return nil, err } @@ -467,21 +470,29 @@ func (f *fontFaces) close() { } func (r *Renderer) createFaces() (*fontFaces, error) { + return r.createFacesWithSizes(64, 28, 22) +} + +func (r *Renderer) createPortraitFaces() (*fontFaces, error) { + return r.createFacesWithSizes(56, 28, 22) +} + +func (r *Renderer) createFacesWithSizes(titleSize, descSize, metaSize float64) (*fontFaces, error) { titleFace, err := opentype.NewFace(r.boldFont, &opentype.FaceOptions{ - Size: 48, DPI: 72, Hinting: font.HintingFull, + Size: titleSize, DPI: 72, Hinting: font.HintingFull, }) if err != nil { return nil, fmt.Errorf("create title face: %w", err) } descFace, err := opentype.NewFace(r.regularFont, &opentype.FaceOptions{ - Size: 24, DPI: 72, Hinting: font.HintingFull, + Size: descSize, DPI: 72, Hinting: font.HintingFull, }) if err != nil { titleFace.Close() return nil, fmt.Errorf("create desc face: %w", err) } metaFace, err := opentype.NewFace(r.regularFont, &opentype.FaceOptions{ - Size: 20, DPI: 72, Hinting: font.HintingFull, + Size: metaSize, DPI: 72, Hinting: font.HintingFull, }) if err != nil { titleFace.Close() @@ -568,7 +579,8 @@ func drawCircle(img *image.RGBA, cx, cy, radius int, c color.RGBA) { } } -// drawRoundedAvatar draws an avatar image clipped to a rounded rectangle. +// drawRoundedAvatar draws an avatar image clipped to a rounded rectangle +// using proper alpha compositing so transparent areas show the card background. func drawRoundedAvatar(dst *image.RGBA, x, y, size, radius int, avatar image.Image) { scaled := scaleImage(avatar, size, size) @@ -577,13 +589,31 @@ func drawRoundedAvatar(dst *image.RGBA, x, y, size, radius int, avatar image.Ima if !insideRoundedRect(px, py, size, size, radius) { continue } - r, g, b, a := scaled.At(px, py).RGBA() - dst.SetRGBA(x+px, y+py, color.RGBA{ - R: uint8(r >> 8), - G: uint8(g >> 8), - B: uint8(b >> 8), - A: uint8(a >> 8), - }) + sr, sg, sb, sa := scaled.At(px, py).RGBA() + if sa == 0 { + continue // fully transparent — skip + } + dx, dy := x+px, y+py + if sa == 0xFFFF { + // Fully opaque — direct write + dst.SetRGBA(dx, dy, color.RGBA{ + R: uint8(sr >> 8), G: uint8(sg >> 8), + B: uint8(sb >> 8), A: 255, + }) + } else { + // Alpha composite: dst = src over dst + dr, dg, db, da := dst.At(dx, dy).RGBA() + a := sa + invA := 0xFFFF - a + outR := (sr*a + dr*invA) / 0xFFFF + outG := (sg*a + dg*invA) / 0xFFFF + outB := (sb*a + db*invA) / 0xFFFF + outA := a + (da*invA)/0xFFFF + dst.SetRGBA(dx, dy, color.RGBA{ + R: uint8(outR >> 8), G: uint8(outG >> 8), + B: uint8(outB >> 8), A: uint8(outA >> 8), + }) + } } } } diff --git a/routers/web/web.go b/routers/web/web.go index b43eaaf28b..27a959ce2d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1198,7 +1198,7 @@ func registerWebRoutes(m *web.Router) { m.Group("/media_kit", func() { m.Combo("").Get(repo_setting.MediaKit).Post(repo_setting.MediaKitPost) m.Post("/upload", repo_setting.MediaKitUploadImage) - m.Delete("/image", repo_setting.MediaKitDeleteImage) + m.Post("/delete_image", repo_setting.MediaKitDeleteImage) m.Get("/unsplash/search", repo_setting.MediaKitUnsplashSearch) m.Post("/unsplash/select", repo_setting.MediaKitUnsplashSelect) }) diff --git a/templates/repo/settings/media_kit.tmpl b/templates/repo/settings/media_kit.tmpl index fbcd86f3a2..a6838ff263 100644 --- a/templates/repo/settings/media_kit.tmpl +++ b/templates/repo/settings/media_kit.tmpl @@ -74,7 +74,7 @@ {{svg "octicon-image" 14}} Custom uploaded image {{end}} - @@ -191,18 +191,38 @@ loadingDiv.classList.add('tw-hidden'); photos.forEach(function(photo) { const el = document.createElement('div'); - el.style.cssText = 'cursor:pointer;border-radius:6px;overflow:hidden;border:2px solid transparent;'; - el.innerHTML = ''; + el.style.cssText = 'cursor:pointer;border-radius:6px;overflow:hidden;border:2px solid transparent;position:relative;'; + el.innerHTML = '' + + '
' + photo.author + '
'; el.addEventListener('click', async function() { + // Deselect all others + resultsDiv.querySelectorAll('div').forEach(function(d) { d.style.borderColor = 'transparent'; }); el.style.borderColor = 'var(--color-primary)'; - const csrf = document.querySelector('input[name="_csrf"]').value; - const fd = new FormData(); - fd.append('_csrf', csrf); - fd.append('id', photo.id); - fd.append('author', photo.author); - fd.append('url', photo.regular); - await fetch('{{.Link}}/unsplash/select', {method: 'POST', body: fd}); - updatePreview(); + el.style.opacity = '0.7'; + el.style.pointerEvents = 'none'; + try { + const csrf = document.querySelector('input[name="_csrf"]').value; + const fd = new FormData(); + fd.append('_csrf', csrf); + fd.append('id', photo.id); + fd.append('author', photo.author); + fd.append('url', photo.regular); + const resp = await fetch('{{.Link}}/unsplash/select', {method: 'POST', body: fd}); + if (resp.ok) { + // Switch radio to "image" and update UI + const imageRadio = document.querySelector('.style-radio[value="image"]'); + if (imageRadio) { + imageRadio.checked = true; + updateButtonStyles(); + solidOpts.style.display = 'none'; + imageOpts.style.display = ''; + } + updatePreview(); + } + } finally { + el.style.opacity = '1'; + el.style.pointerEvents = ''; + } }); resultsDiv.appendChild(el); });