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.bg_image_help"}}
+ + + + + + {{if .Repository.SocialCardBgImage}} +{{ctx.Locale.Tr "repo.settings.social_card_theme_help"}}
-