From e8f910f140ee832ba5d84319a16e7b0e99a2cd6f Mon Sep 17 00:00:00 2001 From: logikonline Date: Fri, 30 Jan 2026 22:33:23 -0500 Subject: [PATCH] feat(socialcard): add custom color and background image support Adds new social card customization options including solid color backgrounds and custom background images with Unsplash integration. Introduces new database fields for storing color, background image URL, and Unsplash attribution. Adds media kit settings page for repository-level social card configuration. --- models/migrations/migrations.go | 1 + models/migrations/v1_26/v343.go | 20 + models/repo/repo.go | 3 + modules/setting/setting.go | 1 + modules/setting/unsplash.go | 23 ++ modules/socialcard/socialcard.go | 506 +++++++++++++++++++---- options/locale/locale_en-US.json | 28 +- routers/web/repo/setting/mediakit.go | 328 +++++++++++++++ routers/web/repo/setting/setting.go | 7 - routers/web/repo/socialcard.go | 48 ++- routers/web/web.go | 8 + services/forms/repo_form.go | 1 - templates/repo/settings/media_kit.tmpl | 217 ++++++++++ templates/repo/settings/navbar.tmpl | 3 + templates/repo/settings/options.tmpl | 24 -- web_src/js/components/RepoActionView.vue | 5 + 16 files changed, 1096 insertions(+), 127 deletions(-) create mode 100644 models/migrations/v1_26/v343.go create mode 100644 modules/setting/unsplash.go create mode 100644 routers/web/repo/setting/mediakit.go create mode 100644 templates/repo/settings/media_kit.tmpl diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 4e89d179e1..91a01263b0 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -417,6 +417,7 @@ func prepareMigrationTasks() []*migration { newMigration(340, "Create repo_hidden_folder table for hidden folders", v1_26.CreateRepoHiddenFolderTable), newMigration(341, "Add hide_dotfiles to repository", v1_26.AddHideDotfilesToRepository), newMigration(342, "Add social_card_theme to repository", v1_26.AddSocialCardThemeToRepository), + newMigration(343, "Add social card color, bg image, and unsplash author to repository", v1_26.AddSocialCardFieldsToRepository), } return preparedMigrations } diff --git a/models/migrations/v1_26/v343.go b/models/migrations/v1_26/v343.go new file mode 100644 index 0000000000..35485a12d0 --- /dev/null +++ b/models/migrations/v1_26/v343.go @@ -0,0 +1,20 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "xorm.io/xorm" +) + +// AddSocialCardFieldsToRepository adds social card color, background image, +// and unsplash author columns to the repository table. +func AddSocialCardFieldsToRepository(x *xorm.Engine) error { + type Repository struct { + SocialCardColor string `xorm:"VARCHAR(7) NOT NULL DEFAULT ''"` + SocialCardBgImage string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"` + SocialCardUnsplashAuthor string `xorm:"VARCHAR(100) NOT NULL DEFAULT ''"` + } + + return x.Sync(new(Repository)) +} diff --git a/models/repo/repo.go b/models/repo/repo.go index c9f1eec2b2..2aefdcbf40 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -214,6 +214,9 @@ type Repository struct { CloseIssuesViaCommitInAnyBranch bool `xorm:"NOT NULL DEFAULT false"` HideDotfiles bool `xorm:"NOT NULL DEFAULT false"` SocialCardTheme string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'dark'"` + SocialCardColor string `xorm:"VARCHAR(7) NOT NULL DEFAULT ''"` + SocialCardBgImage string `xorm:"VARCHAR(255) NOT NULL DEFAULT ''"` + SocialCardUnsplashAuthor string `xorm:"VARCHAR(100) NOT NULL DEFAULT ''"` Topics []string `xorm:"TEXT JSON"` ObjectFormatName string `xorm:"VARCHAR(6) NOT NULL DEFAULT 'sha1'"` diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 0e8b661830..95a794f533 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -220,6 +220,7 @@ func LoadSettings() { loadMimeTypeMapFrom(CfgProvider) loadFederationFrom(CfgProvider) loadAIFrom(CfgProvider) + loadUnsplashFrom(CfgProvider) } // LoadSettingsForInstall initializes the settings for install diff --git a/modules/setting/unsplash.go b/modules/setting/unsplash.go new file mode 100644 index 0000000000..f2471fdc2e --- /dev/null +++ b/modules/setting/unsplash.go @@ -0,0 +1,23 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +// Unsplash settings for background image search integration. +var Unsplash = struct { + Enabled bool + AccessKey string +}{ + Enabled: false, + AccessKey: "", +} + +func loadUnsplashFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("unsplash") + Unsplash.Enabled = sec.Key("ENABLED").MustBool(false) + Unsplash.AccessKey = sec.Key("ACCESS_KEY").MustString("") + + if Unsplash.Enabled && Unsplash.AccessKey == "" { + Unsplash.Enabled = false + } +} diff --git a/modules/socialcard/socialcard.go b/modules/socialcard/socialcard.go index 7a14e7e05d..fa3ad7e40d 100644 --- a/modules/socialcard/socialcard.go +++ b/modules/socialcard/socialcard.go @@ -12,7 +12,9 @@ import ( "image/draw" _ "image/jpeg" // register JPEG decoder for avatar images "image/png" + "math" "net/http" + "slices" "strings" "time" @@ -90,11 +92,21 @@ var ( } ) -// ThemeNames returns all available theme names. +// ThemeNames returns all available theme names (preset themes only). func ThemeNames() []string { return []string{"dark", "light", "colorful"} } +// StyleNames returns all available style names including custom styles. +func StyleNames() []string { + return []string{"dark", "light", "colorful", "solid", "image"} +} + +// IsValidStyle returns whether the given name is a valid style. +func IsValidStyle(name string) bool { + return slices.Contains(StyleNames(), name) +} + // GetTheme returns a theme by name, falling back to dark. func GetTheme(name string) Theme { if t, ok := ThemesByName[name]; ok { @@ -107,11 +119,13 @@ func GetTheme(name string) Theme { type CardData struct { Title string Description string - OwnerName string LanguageName string LanguageColor string // hex color like "#3572A5" - OwnerAvatarURL string + RepoAvatarURL string RepoFullName string + SolidColor string // hex color for "solid" style + BgImage image.Image // pre-loaded background image for "image" style + UnsplashAuthor string // attribution text for Unsplash images } // Renderer holds pre-parsed fonts and logo. @@ -140,6 +154,18 @@ func NewRenderer() (*Renderer, error) { // RenderCard generates a 1200x630 PNG social card. func (r *Renderer) RenderCard(data CardData, theme Theme) ([]byte, error) { + switch theme.Name { + case "image": + return r.renderImageCard(data) + case "solid": + return r.renderSolidCard(data) + default: + return r.renderPresetCard(data, theme) + } +} + +// renderPresetCard renders a card using a preset theme (dark/light/colorful). +func (r *Renderer) renderPresetCard(data CardData, theme Theme) ([]byte, error) { img := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight)) // Fill background @@ -148,82 +174,182 @@ func (r *Renderer) RenderCard(data CardData, theme Theme) ([]byte, error) { // Top accent bar (4px) fillRect(img, image.Rect(0, 0, CardWidth, 4), theme.CardBorderColor) - // Create font faces - titleFace, err := opentype.NewFace(r.boldFont, &opentype.FaceOptions{ - Size: 48, DPI: 72, Hinting: font.HintingFull, - }) + faces, err := r.createFaces() if err != nil { - return nil, fmt.Errorf("create title face: %w", err) + return nil, err } - defer titleFace.Close() - - descFace, err := opentype.NewFace(r.regularFont, &opentype.FaceOptions{ - Size: 24, DPI: 72, Hinting: font.HintingFull, - }) - if err != nil { - return nil, fmt.Errorf("create desc face: %w", err) - } - defer descFace.Close() - - metaFace, err := opentype.NewFace(r.regularFont, &opentype.FaceOptions{ - Size: 20, DPI: 72, Hinting: font.HintingFull, - }) - if err != nil { - return nil, fmt.Errorf("create meta face: %w", err) - } - defer metaFace.Close() + defer faces.close() padding := 60 + avatarSize := 96 + avatarGap := 24 + + // Repo avatar (top-right, 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) + hasAvatar = true + } + } + + // Text area width: shrink if avatar is present to avoid overlap contentWidth := CardWidth - 2*padding - - // Draw title (max 2 lines) - titleY := 80 + 48 - titleLines := wrapText(data.Title, titleFace, contentWidth) - if len(titleLines) > 2 { - titleLines = titleLines[:2] - titleLines[1] = truncateWithEllipsis(titleLines[1], titleFace, contentWidth) - } - for i, line := range titleLines { - drawText(img, padding, titleY+i*60, line, titleFace, theme.TitleColor) + if hasAvatar { + contentWidth = CardWidth - 2*padding - avatarSize - avatarGap } - // Draw description (max 3 lines) - descY := titleY + len(titleLines)*60 + 20 - if data.Description != "" { - descLines := wrapText(data.Description, descFace, contentWidth) - if len(descLines) > 3 { - descLines = descLines[:3] - descLines[2] = truncateWithEllipsis(descLines[2], descFace, contentWidth) - } - for i, line := range descLines { - drawText(img, padding, descY+i*34, line, descFace, theme.DescColor) + r.drawCardText(img, faces, data, theme, padding, contentWidth) + + // Encode to PNG + return encodePNG(img) +} + +// renderSolidCard renders a card with a user-chosen solid background color +// and auto-contrast text colors. +func (r *Renderer) renderSolidCard(data CardData) ([]byte, error) { + img := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight)) + + bgColor := parseHexColor(data.SolidColor) + theme := contrastTheme(bgColor) + + 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) + + faces, err := r.createFaces() + if err != nil { + return nil, err + } + defer faces.close() + + padding := 60 + avatarSize := 96 + avatarGap := 24 + + 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 } } - // Bottom section: language + owner info - bottomY := CardHeight - 80 - xCursor := padding + contentWidth := CardWidth - 2*padding + if hasAvatar { + contentWidth = CardWidth - 2*padding - avatarSize - avatarGap + } - // Language indicator (colored dot + name) + r.drawCardText(img, faces, data, theme, padding, contentWidth) + + return encodePNG(img) +} + +// renderImageCard renders a card with a full-bleed background image and +// a dark gradient scrim at the bottom for text readability. +func (r *Renderer) renderImageCard(data CardData) ([]byte, error) { + img := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight)) + + // Draw background image (cover-crop to fill) + if data.BgImage != nil { + cropped := coverCrop(data.BgImage, CardWidth, CardHeight) + 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) + + // 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}, + } + + faces, err := r.createFaces() + if err != nil { + return nil, err + } + 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 + + // Build text from bottom up + yBottom := CardHeight - bottomPadding + + // Repo full name (bottom-most) + drawText(img, padding, yBottom, data.RepoFullName, faces.meta, theme.SubtextColor) + yBottom -= 30 + + // Unsplash attribution + 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) + } + + // Language indicator if data.LanguageName != "" { langColor := parseHexColor(data.LanguageColor) - drawCircle(img, xCursor+8, bottomY-6, 8, langColor) - xCursor += 24 - drawText(img, xCursor, bottomY, data.LanguageName, metaFace, theme.SubtextColor) - xCursor += measureText(data.LanguageName, metaFace) + 30 + drawCircle(img, padding+8, yBottom-6, 8, langColor) + drawText(img, padding+24, yBottom, data.LanguageName, faces.meta, theme.SubtextColor) + yBottom -= 30 } - // Owner avatar (circular) - if data.OwnerAvatarURL != "" { - avatarImg := fetchAndDecodeAvatar(data.OwnerAvatarURL, 36) - if avatarImg != nil { - drawCircularAvatar(img, xCursor, bottomY-30, 36, avatarImg) - xCursor += 44 + yBottom -= 10 + + // Description (max 2 lines for image style) + 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) } + for i := len(descLines) - 1; i >= 0; i-- { + drawText(img, padding, yBottom, descLines[i], faces.desc, theme.DescColor) + yBottom -= 34 + } + yBottom -= 6 } - // Owner name - drawText(img, xCursor, bottomY, data.RepoFullName, metaFace, theme.SubtextColor) + // Title (max 2 lines) + titleLines := wrapText(data.Title, faces.title, contentWidth) + if len(titleLines) > 2 { + titleLines = titleLines[:2] + titleLines[1] = truncateWithEllipsis(titleLines[1], faces.title, contentWidth) + } + for i := len(titleLines) - 1; i >= 0; i-- { + drawText(img, padding, yBottom, titleLines[i], faces.title, theme.TitleColor) + yBottom -= 60 + } // GitCaddy logo (bottom right) logoBounds := r.logo.Bounds() @@ -232,12 +358,94 @@ func (r *Renderer) RenderCard(data CardData, theme Theme) ([]byte, error) { draw.Draw(img, image.Rect(logoX, logoY, logoX+logoBounds.Dx(), logoY+logoBounds.Dy()), r.logo, logoBounds.Min, draw.Over) - // Encode to PNG - var buf bytes.Buffer - if err := png.Encode(&buf, img); err != nil { - return nil, fmt.Errorf("encode png: %w", err) + return encodePNG(img) +} + +// fontFaces holds pre-created font faces for a single render. +type fontFaces struct { + title font.Face + desc font.Face + meta font.Face +} + +func (f *fontFaces) close() { + f.title.Close() + f.desc.Close() + f.meta.Close() +} + +func (r *Renderer) createFaces() (*fontFaces, error) { + titleFace, err := opentype.NewFace(r.boldFont, &opentype.FaceOptions{ + Size: 48, DPI: 72, Hinting: font.HintingFull, + }) + if err != nil { + return nil, fmt.Errorf("create title face: %w", err) } - return buf.Bytes(), nil + descFace, err := opentype.NewFace(r.regularFont, &opentype.FaceOptions{ + Size: 24, 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, + }) + if err != nil { + titleFace.Close() + descFace.Close() + return nil, fmt.Errorf("create meta face: %w", err) + } + 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. @@ -317,30 +525,55 @@ func drawCircle(img *image.RGBA, cx, cy, radius int, c color.RGBA) { } } -// drawCircularAvatar draws an avatar image clipped to a circle. -func drawCircularAvatar(dst *image.RGBA, x, y, size int, avatar image.Image) { - // Scale avatar to target size +// drawRoundedAvatar draws an avatar image clipped to a rounded rectangle. +func drawRoundedAvatar(dst *image.RGBA, x, y, size, radius int, avatar image.Image) { scaled := scaleImage(avatar, size, size) - radius := size / 2 - cx, cy := radius, radius for py := range size { for px := range size { - dx := float64(px - cx) - dy := float64(py - cy) - if dx*dx+dy*dy <= float64(radius*radius) { - 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), - }) + 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), + }) } } } +// insideRoundedRect checks if (px, py) is inside a rounded rectangle of the given dimensions. +func insideRoundedRect(px, py, w, h, radius int) bool { + // Check corners + corners := [][2]int{ + {radius, radius}, // top-left + {w - radius - 1, radius}, // top-right + {radius, h - radius - 1}, // bottom-left + {w - radius - 1, h - radius - 1}, // bottom-right + } + for _, c := range corners { + dx := px - c[0] + dy := py - c[1] + inCornerRegion := false + if c[0] == radius && px < radius && c[1] == radius && py < radius { + inCornerRegion = true + } else if c[0] == w-radius-1 && px > w-radius-1 && c[1] == radius && py < radius { + inCornerRegion = true + } else if c[0] == radius && px < radius && c[1] == h-radius-1 && py > h-radius-1 { + inCornerRegion = true + } else if c[0] == w-radius-1 && px > w-radius-1 && c[1] == h-radius-1 && py > h-radius-1 { + inCornerRegion = true + } + if inCornerRegion && dx*dx+dy*dy > radius*radius { + return false + } + } + return px >= 0 && px < w && py >= 0 && py < h +} + // fetchAndDecodeAvatar fetches an avatar image from a URL with a short timeout. func fetchAndDecodeAvatar(avatarURL string, size int) image.Image { client := &http.Client{Timeout: 3 * time.Second} @@ -369,6 +602,117 @@ func scaleImage(src image.Image, width, height int) image.Image { return dst } +// luminance returns the perceived brightness of a color (0-255). +func luminance(c color.RGBA) float64 { + return 0.299*float64(c.R) + 0.587*float64(c.G) + 0.114*float64(c.B) +} + +// contrastTheme generates a Theme with auto-contrast text colors for a given background. +func contrastTheme(bg color.RGBA) Theme { + lum := luminance(bg) + if lum > 140 { + // Light background → dark text + return Theme{ + Name: "solid", + Background: bg, + TitleColor: color.RGBA{R: 20, G: 20, B: 20, A: 255}, + DescColor: color.RGBA{R: 60, G: 60, B: 60, A: 255}, + AccentColor: color.RGBA{R: 9, G: 105, B: 218, A: 255}, + SubtextColor: color.RGBA{R: 80, G: 80, B: 80, A: 255}, + CardBorderColor: shiftColor(bg, 40), + } + } + // Dark background → light text + return Theme{ + Name: "solid", + Background: bg, + TitleColor: color.RGBA{R: 240, G: 246, B: 252, A: 255}, + DescColor: color.RGBA{R: 180, G: 190, B: 200, A: 255}, + AccentColor: color.RGBA{R: 56, G: 132, B: 244, A: 255}, + SubtextColor: color.RGBA{R: 140, G: 150, B: 160, A: 255}, + CardBorderColor: shiftColor(bg, 40), + } +} + +// shiftColor lightens or darkens a color by the given amount. +func shiftColor(c color.RGBA, amount int) color.RGBA { + lum := luminance(c) + if lum > 140 { + // Darken + return color.RGBA{ + R: uint8(math.Max(0, float64(c.R)-float64(amount))), + G: uint8(math.Max(0, float64(c.G)-float64(amount))), + B: uint8(math.Max(0, float64(c.B)-float64(amount))), + A: 255, + } + } + // Lighten + return color.RGBA{ + R: uint8(math.Min(255, float64(c.R)+float64(amount))), + G: uint8(math.Min(255, float64(c.G)+float64(amount))), + B: uint8(math.Min(255, float64(c.B)+float64(amount))), + A: 255, + } +} + +// drawGradientScrim draws a vertical gradient from transparent to dark +// over the given Y range. +func drawGradientScrim(img *image.RGBA, startY, endY int, maxAlpha uint8) { + height := endY - startY + if height <= 0 { + return + } + for y := startY; y < endY; y++ { + progress := float64(y-startY) / float64(height) + alpha := uint8(float64(maxAlpha) * progress) + for x := range CardWidth { + 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) + blendG := uint8((g>>8)*(255-uint32(alpha))/255 + 0) + blendB := uint8((b>>8)*(255-uint32(alpha))/255 + 0) + blendA := uint8(math.Min(255, float64(a>>8)+float64(alpha))) + img.SetRGBA(x, y, color.RGBA{R: blendR, G: blendG, B: blendB, A: blendA}) + } + } +} + +// coverCrop scales and center-crops an image to fill the target dimensions. +func coverCrop(src image.Image, targetW, targetH int) image.Image { + srcBounds := src.Bounds() + srcW := srcBounds.Dx() + srcH := srcBounds.Dy() + + // Calculate scale to cover + scaleX := float64(targetW) / float64(srcW) + scaleY := float64(targetH) / float64(srcH) + scale := math.Max(scaleX, scaleY) + + scaledW := int(math.Round(float64(srcW) * scale)) + scaledH := int(math.Round(float64(srcH) * scale)) + + // Scale up + scaled := scaleImage(src, scaledW, scaledH) + + // Center crop + offsetX := (scaledW - targetW) / 2 + offsetY := (scaledH - targetH) / 2 + + dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH)) + draw.Draw(dst, dst.Bounds(), scaled, + image.Pt(scaled.Bounds().Min.X+offsetX, scaled.Bounds().Min.Y+offsetY), draw.Src) + return dst +} + +// encodePNG encodes an RGBA image to PNG bytes. +func encodePNG(img *image.RGBA) ([]byte, error) { + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, fmt.Errorf("encode png: %w", err) + } + return buf.Bytes(), nil +} + // parseHexColor converts "#RRGGBB" to color.RGBA. func parseHexColor(hex string) color.RGBA { hex = strings.TrimPrefix(hex, "#") diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index df5e45aaca..684b273f39 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -4034,12 +4034,28 @@ "repo.settings.group_header": "Group Header", "repo.settings.group_header_placeholder": "e.g., Core Services, Libraries, Tools", "repo.settings.group_header_help": "Optional header for grouping this repository on the organization page", - "repo.settings.social_card_theme": "Social Card Theme", - "repo.settings.social_card_theme_help": "Choose the visual theme for your repository's share card image used in link previews.", - "repo.settings.social_card_theme.dark": "Dark", - "repo.settings.social_card_theme.light": "Light", - "repo.settings.social_card_theme.colorful": "Colorful", - "repo.settings.social_card_preview": "Preview", + "repo.settings.media_kit": "Media Kit", + "repo.settings.media_kit.style": "Social Card Style", + "repo.settings.media_kit.style_help": "Choose the visual style for your repository's share card image used in link previews.", + "repo.settings.media_kit.style.dark": "Dark", + "repo.settings.media_kit.style.light": "Light", + "repo.settings.media_kit.style.colorful": "Colorful", + "repo.settings.media_kit.style.solid": "Solid Color", + "repo.settings.media_kit.style.image": "Background Image", + "repo.settings.media_kit.solid_color": "Background Color", + "repo.settings.media_kit.solid_color_help": "Pick a hex color for the card background. Text color is chosen automatically for contrast.", + "repo.settings.media_kit.bg_image": "Background Image", + "repo.settings.media_kit.bg_image_help": "Upload a JPG or PNG image (max 2 MB) or search Unsplash.", + "repo.settings.media_kit.upload": "Upload Image", + "repo.settings.media_kit.upload_error": "Failed to upload image.", + "repo.settings.media_kit.image_too_large": "Image must be smaller than 2 MB.", + "repo.settings.media_kit.not_an_image": "File must be a JPG or PNG image.", + "repo.settings.media_kit.image_uploaded": "Background image uploaded.", + "repo.settings.media_kit.delete_image": "Remove Image", + "repo.settings.media_kit.unsplash_search": "Search Unsplash", + "repo.settings.media_kit.unsplash_placeholder": "Search for photos…", + "repo.settings.media_kit.unsplash_attribution": "Photo by %s on Unsplash", + "repo.settings.media_kit.preview": "Preview", "repo.settings.license": "License", "repo.settings.license_type": "License Type", "repo.settings.license_none": "No license selected", diff --git a/routers/web/repo/setting/mediakit.go b/routers/web/repo/setting/mediakit.go new file mode 100644 index 0000000000..f37e8b67aa --- /dev/null +++ b/routers/web/repo/setting/mediakit.go @@ -0,0 +1,328 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "crypto/sha256" + "fmt" + "io" + "net/http" + "strings" + "time" + + repo_model "code.gitcaddy.com/server/v3/models/repo" + "code.gitcaddy.com/server/v3/modules/json" + "code.gitcaddy.com/server/v3/modules/log" + "code.gitcaddy.com/server/v3/modules/setting" + "code.gitcaddy.com/server/v3/modules/socialcard" + "code.gitcaddy.com/server/v3/modules/storage" + "code.gitcaddy.com/server/v3/modules/templates" + "code.gitcaddy.com/server/v3/modules/typesniffer" + "code.gitcaddy.com/server/v3/services/context" +) + +const tplMediaKit templates.TplName = "repo/settings/media_kit" + +const maxBgImageSize = 2 * 1024 * 1024 // 2MB + +// MediaKit shows the media kit settings page. +func MediaKit(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.media_kit") + ctx.Data["PageIsSettingsMediaKit"] = true + ctx.Data["StyleNames"] = socialcard.StyleNames() + ctx.Data["UnsplashEnabled"] = setting.Unsplash.Enabled + + ctx.HTML(http.StatusOK, tplMediaKit) +} + +// MediaKitPost handles the media kit form submission. +func MediaKitPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.settings.media_kit") + ctx.Data["PageIsSettingsMediaKit"] = true + ctx.Data["StyleNames"] = socialcard.StyleNames() + ctx.Data["UnsplashEnabled"] = setting.Unsplash.Enabled + + repo := ctx.Repo.Repository + + style := ctx.FormString("social_card_theme") + if !socialcard.IsValidStyle(style) { + style = "dark" + } + repo.SocialCardTheme = style + + if style == "solid" { + color := strings.TrimSpace(ctx.FormString("social_card_color")) + if color != "" && !strings.HasPrefix(color, "#") { + color = "#" + color + } + if len(color) == 7 { + repo.SocialCardColor = color + } + } + + if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, + "social_card_theme", "social_card_color"); err != nil { + ctx.ServerError("UpdateRepositoryColsNoAutoTime", err) + return + } + + log.Trace("Media kit settings updated: %s", repo.FullName()) + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings/media_kit") +} + +// MediaKitUploadImage handles background image upload. +func MediaKitUploadImage(ctx *context.Context) { + repo := ctx.Repo.Repository + + file, header, err := ctx.Req.FormFile("bg_image") + if err != nil { + ctx.Flash.Error(ctx.Tr("repo.settings.media_kit.upload_error")) + ctx.Redirect(repo.Link() + "/settings/media_kit") + return + } + defer file.Close() + + if header.Size > maxBgImageSize { + ctx.Flash.Error(ctx.Tr("repo.settings.media_kit.image_too_large")) + ctx.Redirect(repo.Link() + "/settings/media_kit") + return + } + + data, err := io.ReadAll(file) + if err != nil { + ctx.ServerError("ReadAll", err) + return + } + + st := typesniffer.DetectContentType(data) + if !(st.IsImage() && !st.IsSvgImage()) { + ctx.Flash.Error(ctx.Tr("repo.settings.media_kit.not_an_image")) + ctx.Redirect(repo.Link() + "/settings/media_kit") + return + } + + // Generate storage path + hash := sha256.Sum256(data) + filename := fmt.Sprintf("social-bg-%d-%x", repo.ID, hash[:8]) + + // Delete old bg image if it exists + if repo.SocialCardBgImage != "" { + _ = storage.RepoAvatars.Delete(repo.SocialCardBgImage) + } + + // Save to storage + if err := storage.SaveFrom(storage.RepoAvatars, filename, func(w io.Writer) error { + _, err := w.Write(data) + return err + }); err != nil { + ctx.ServerError("SaveFrom", err) + return + } + + repo.SocialCardBgImage = filename + repo.SocialCardUnsplashAuthor = "" + repo.SocialCardTheme = "image" + if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, + "social_card_bg_image", "social_card_unsplash_author", "social_card_theme"); err != nil { + ctx.ServerError("UpdateRepositoryColsNoAutoTime", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.media_kit.image_uploaded")) + ctx.Redirect(repo.Link() + "/settings/media_kit") +} + +// MediaKitDeleteImage removes the background image. +func MediaKitDeleteImage(ctx *context.Context) { + repo := ctx.Repo.Repository + + if repo.SocialCardBgImage != "" { + _ = storage.RepoAvatars.Delete(repo.SocialCardBgImage) + } + + repo.SocialCardBgImage = "" + repo.SocialCardUnsplashAuthor = "" + if repo.SocialCardTheme == "image" { + repo.SocialCardTheme = "dark" + } + if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, + "social_card_bg_image", "social_card_unsplash_author", "social_card_theme"); err != nil { + ctx.ServerError("UpdateRepositoryColsNoAutoTime", err) + return + } + + ctx.JSONRedirect(repo.Link() + "/settings/media_kit") +} + +// unsplashSearchResult is a single search result from Unsplash. +type unsplashSearchResult struct { + ID string `json:"id"` + Thumb string `json:"thumb"` + Regular string `json:"regular"` + Author string `json:"author"` + AuthorURL string `json:"author_url"` +} + +// MediaKitUnsplashSearch proxies a search request to the Unsplash API. +func MediaKitUnsplashSearch(ctx *context.Context) { + if !setting.Unsplash.Enabled { + ctx.JSON(http.StatusNotFound, map[string]string{"error": "Unsplash not enabled"}) + return + } + + query := ctx.FormString("q") + if query == "" { + ctx.JSON(http.StatusOK, []unsplashSearchResult{}) + return + } + + page := ctx.FormString("page") + if page == "" { + page = "1" + } + + url := fmt.Sprintf("https://api.unsplash.com/search/photos?query=%s&orientation=landscape&per_page=12&page=%s", + query, page) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + ctx.ServerError("NewRequest", err) + return + } + req.Header.Set("Authorization", "Client-ID "+setting.Unsplash.AccessKey) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + ctx.ServerError("Unsplash request", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + ctx.JSON(resp.StatusCode, map[string]string{"error": "Unsplash API error"}) + return + } + + var apiResp struct { + Results []struct { + ID string `json:"id"` + URLs struct { + Thumb string `json:"thumb"` + Regular string `json:"regular"` + } `json:"urls"` + User struct { + Name string `json:"name"` + Links struct { + HTML string `json:"html"` + } `json:"links"` + } `json:"user"` + } `json:"results"` + } + + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + ctx.ServerError("Decode Unsplash response", err) + return + } + + results := make([]unsplashSearchResult, 0, len(apiResp.Results)) + for _, r := range apiResp.Results { + results = append(results, unsplashSearchResult{ + ID: r.ID, + Thumb: r.URLs.Thumb, + Regular: r.URLs.Regular, + Author: r.User.Name, + AuthorURL: r.User.Links.HTML, + }) + } + + ctx.JSON(http.StatusOK, results) +} + +// MediaKitUnsplashSelect downloads an Unsplash photo and saves it as the bg image. +func MediaKitUnsplashSelect(ctx *context.Context) { + if !setting.Unsplash.Enabled { + ctx.JSON(http.StatusNotFound, map[string]string{"error": "Unsplash not enabled"}) + return + } + + repo := ctx.Repo.Repository + + photoID := ctx.FormString("id") + author := ctx.FormString("author") + regularURL := ctx.FormString("url") + + if photoID == "" || regularURL == "" { + ctx.JSON(http.StatusBadRequest, map[string]string{"error": "missing id or url"}) + return + } + + // Trigger Unsplash download tracking (required by API guidelines) + go func() { + trackURL := fmt.Sprintf("https://api.unsplash.com/photos/%s/download", photoID) + req, err := http.NewRequest(http.MethodGet, trackURL, nil) + if err != nil { + return + } + req.Header.Set("Authorization", "Client-ID "+setting.Unsplash.AccessKey) + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return + } + resp.Body.Close() + }() + + // Download the regular-size image + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(regularURL) + if err != nil { + ctx.ServerError("Download Unsplash image", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + ctx.JSON(http.StatusBadGateway, map[string]string{"error": "Failed to download image"}) + return + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxBgImageSize+1)) + if err != nil { + ctx.ServerError("ReadAll", err) + return + } + if int64(len(data)) > maxBgImageSize { + ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Image too large"}) + return + } + + hash := sha256.Sum256(data) + filename := fmt.Sprintf("social-bg-%d-%x", repo.ID, hash[:8]) + + // Delete old bg image + if repo.SocialCardBgImage != "" { + _ = storage.RepoAvatars.Delete(repo.SocialCardBgImage) + } + + if err := storage.SaveFrom(storage.RepoAvatars, filename, func(w io.Writer) error { + _, err := w.Write(data) + return err + }); err != nil { + ctx.ServerError("SaveFrom", err) + return + } + + repo.SocialCardBgImage = filename + repo.SocialCardUnsplashAuthor = author + repo.SocialCardTheme = "image" + if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, + "social_card_bg_image", "social_card_unsplash_author", "social_card_theme"); err != nil { + ctx.ServerError("UpdateRepositoryColsNoAutoTime", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"ok": "true"}) +} diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 143280e304..0950c8b5a3 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -23,7 +23,6 @@ import ( "code.gitcaddy.com/server/v3/modules/lfs" "code.gitcaddy.com/server/v3/modules/log" "code.gitcaddy.com/server/v3/modules/setting" - "code.gitcaddy.com/server/v3/modules/socialcard" "code.gitcaddy.com/server/v3/modules/structs" "code.gitcaddy.com/server/v3/modules/templates" "code.gitcaddy.com/server/v3/modules/util" @@ -89,7 +88,6 @@ func SettingsCtxData(ctx *context.Context) { return } ctx.Data["PushMirrors"] = pushMirrors - ctx.Data["SocialCardThemes"] = socialcard.ThemeNames() } // Settings show a repository's settings page @@ -208,11 +206,6 @@ func handleSettingsPostUpdate(ctx *context.Context) { repo.Description = form.Description repo.DisplayTitle = form.DisplayTitle repo.GroupHeader = form.GroupHeader - if form.SocialCardTheme != "" { - if _, ok := socialcard.ThemesByName[form.SocialCardTheme]; ok { - repo.SocialCardTheme = form.SocialCardTheme - } - } repo.Website = form.Website repo.IsTemplate = form.Template diff --git a/routers/web/repo/socialcard.go b/routers/web/repo/socialcard.go index b76a9679af..c16ce160f5 100644 --- a/routers/web/repo/socialcard.go +++ b/routers/web/repo/socialcard.go @@ -6,9 +6,14 @@ package repo import ( "crypto/sha256" "fmt" + "image" + _ "image/jpeg" // register JPEG decoder for background images + _ "image/png" // register PNG decoder for background images + "io" "net/http" "code.gitcaddy.com/server/v3/modules/socialcard" + "code.gitcaddy.com/server/v3/modules/storage" "code.gitcaddy.com/server/v3/services/context" ) @@ -27,10 +32,12 @@ func SocialPreview(ctx *context.Context) { } data := socialcard.CardData{ - Title: title, - Description: repo.Description, - OwnerName: repo.OwnerName, - RepoFullName: repo.FullName(), + Title: title, + Description: repo.Description, + RepoAvatarURL: repo.AvatarLink(ctx), + RepoFullName: repo.FullName(), + SolidColor: repo.SocialCardColor, + UnsplashAuthor: repo.SocialCardUnsplashAuthor, } if repo.PrimaryLanguage != nil { @@ -38,15 +45,25 @@ func SocialPreview(ctx *context.Context) { data.LanguageColor = repo.PrimaryLanguage.Color } - if repo.Owner != nil { - data.OwnerAvatarURL = repo.Owner.AvatarLink(ctx) - } - // Allow ?theme= query param for preview (falls back to saved theme) themeName := ctx.FormString("theme") if themeName == "" { themeName = repo.SocialCardTheme } + + // Allow ?color= for solid color preview + if color := ctx.FormString("color"); color != "" { + data.SolidColor = "#" + color + } + + // Load background image from storage if needed + if themeName == "image" && repo.SocialCardBgImage != "" { + bgImg := loadBgImageFromStorage(repo.SocialCardBgImage) + if bgImg != nil { + data.BgImage = bgImg + } + } + theme := socialcard.GetTheme(themeName) renderer, err := socialcard.GlobalRenderer() @@ -76,3 +93,18 @@ func SocialPreview(ctx *context.Context) { ctx.Resp.WriteHeader(http.StatusOK) _, _ = ctx.Resp.Write(pngData) } + +// loadBgImageFromStorage loads a background image from repo avatar storage. +func loadBgImageFromStorage(path string) image.Image { + fr, err := storage.RepoAvatars.Open(path) + if err != nil { + return nil + } + defer fr.(io.ReadCloser).Close() + + img, _, err := image.Decode(fr.(io.Reader)) + if err != nil { + return nil + } + return img +} diff --git a/routers/web/web.go b/routers/web/web.go index 6371a2eb72..b43eaaf28b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1195,6 +1195,14 @@ func registerWebRoutes(m *web.Router) { m.Post("/delete", repo_setting.GalleryDelete) }, repo.MustBeNotEmpty) + 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.Get("/unsplash/search", repo_setting.MediaKitUnsplashSearch) + m.Post("/unsplash/select", repo_setting.MediaKitUnsplashSelect) + }) + m.Group("/hidden_folders", func() { m.Get("", repo_setting.HiddenFolders) m.Post("", repo_setting.HiddenFoldersPost) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 5de9f2e7ce..e082bbcf23 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -93,7 +93,6 @@ type RepoSettingForm struct { Description string `binding:"MaxSize(2048)"` DisplayTitle string `binding:"MaxSize(255)"` GroupHeader string `binding:"MaxSize(255)"` - SocialCardTheme string Website string `binding:"ValidUrl;MaxSize(1024)"` Interval string MirrorAddress string diff --git a/templates/repo/settings/media_kit.tmpl b/templates/repo/settings/media_kit.tmpl new file mode 100644 index 0000000000..8d3fc18c95 --- /dev/null +++ b/templates/repo/settings/media_kit.tmpl @@ -0,0 +1,217 @@ +{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings media-kit")}} +
+

+ {{ctx.Locale.Tr "repo.settings.media_kit"}} +

+
+ +
+ {{.CsrfTokenHtml}} +
+ +

{{ctx.Locale.Tr "repo.settings.media_kit.style_help"}}

+
+ {{range .StyleNames}} + + {{end}} +
+
+ + +
+ +

{{ctx.Locale.Tr "repo.settings.media_kit.solid_color_help"}}

+
+ + +
+
+ +
+ +
+
+ + +
+
+
+ +

{{ctx.Locale.Tr "repo.settings.media_kit.bg_image_help"}}

+ + +
+ {{.CsrfTokenHtml}} + + +
+ + + {{if .Repository.SocialCardBgImage}} +
+ + {{if .Repository.SocialCardUnsplashAuthor}} + {{svg "octicon-image" 14}} + Photo by {{.Repository.SocialCardUnsplashAuthor}} on Unsplash + {{else}} + {{svg "octicon-image" 14}} Custom uploaded image + {{end}} + + +
+ {{end}} + + + {{if .UnsplashEnabled}} +
+ +
+ + +
+
+
+
+
+
+ {{end}} +
+
+ + +
+
+ + Social card preview +
+
+
+ + + +{{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index fd4031fb28..3e8d40cea2 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -10,6 +10,9 @@ {{ctx.Locale.Tr "repo.settings.gallery"}} + + {{ctx.Locale.Tr "repo.settings.media_kit"}} + {{if or .Repository.IsPrivate .Permission.HasAnyUnitPublicAccess}} {{ctx.Locale.Tr "repo.settings.public_access"}} diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index b179c684c7..f28fecae1d 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -40,30 +40,6 @@ -
- - -

{{ctx.Locale.Tr "repo.settings.social_card_theme_help"}}

-
- - Social card preview -
- -
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 2058bceda1..19c7bd1991 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -580,6 +580,11 @@ export default defineComponent({