2
0

feat(pages): add hero image upload to pages settings

Add ability to upload hero images directly instead of only using URLs. Images are stored in repo-avatars storage with hash-based filenames (max 5 MB, JPG/PNG/WebP/GIF). Uploaded images take priority over URL field. Includes upload and delete endpoints, UI with preview, and ResolvedImageURL helper method. Improves UX by eliminating need for external image hosting.
This commit is contained in:
2026-03-15 22:39:52 -04:00
parent 6007a19bed
commit 79a0c2683e
6 changed files with 151 additions and 7 deletions

View File

@@ -83,13 +83,22 @@ type BrandConfig struct {
// HeroConfig represents hero section settings
type HeroConfig struct {
Headline string `yaml:"headline,omitempty"`
Subheadline string `yaml:"subheadline,omitempty"`
PrimaryCTA CTAButton `yaml:"primary_cta,omitempty"`
SecondaryCTA CTAButton `yaml:"secondary_cta,omitempty"`
ImageURL string `yaml:"image_url,omitempty"`
CodeExample string `yaml:"code_example,omitempty"`
VideoURL string `yaml:"video_url,omitempty"`
Headline string `yaml:"headline,omitempty"`
Subheadline string `yaml:"subheadline,omitempty"`
PrimaryCTA CTAButton `yaml:"primary_cta,omitempty"`
SecondaryCTA CTAButton `yaml:"secondary_cta,omitempty"`
ImageURL string `yaml:"image_url,omitempty"`
UploadedImage string `yaml:"uploaded_image,omitempty"` // filename in repo-avatars storage
CodeExample string `yaml:"code_example,omitempty"`
VideoURL string `yaml:"video_url,omitempty"`
}
// ResolvedImageURL returns the effective hero image URL, preferring uploaded image over URL.
func (h *HeroConfig) ResolvedImageURL() string {
if h.UploadedImage != "" {
return "/repo-avatars/" + h.UploadedImage
}
return h.ImageURL
}
// CTAButton represents a call-to-action button

View File

@@ -697,6 +697,17 @@
"repo.settings.pages.brand_tagline": "Tagline",
"repo.settings.pages.headline": "Headline",
"repo.settings.pages.subheadline": "Subheadline",
"repo.settings.pages.hero_image": "Hero Image",
"repo.settings.pages.hero_upload": "Upload Image",
"repo.settings.pages.hero_upload_btn": "Upload",
"repo.settings.pages.hero_upload_help": "Upload a JPG, PNG, WebP, or GIF image (max 5 MB). Uploaded images take priority over the URL field.",
"repo.settings.pages.hero_upload_error": "Failed to upload image.",
"repo.settings.pages.hero_image_too_large": "Image is too large. Maximum size is 5 MB.",
"repo.settings.pages.hero_not_an_image": "The uploaded file is not a valid image.",
"repo.settings.pages.hero_image_uploaded": "Hero image uploaded successfully.",
"repo.settings.pages.hero_image_deleted": "Hero image deleted.",
"repo.settings.pages.hero_delete_image": "Delete Image",
"repo.settings.pages.hero_or": "or use a URL",
"repo.settings.pages.image_url": "Hero Image URL",
"repo.settings.pages.video_url": "Demo Video URL",
"repo.settings.pages.code_example": "Code Example",

View File

@@ -4464,6 +4464,17 @@
"repo.settings.pages.brand_tagline": "Tagline",
"repo.settings.pages.headline": "Headline",
"repo.settings.pages.subheadline": "Subheadline",
"repo.settings.pages.hero_image": "Hero Image",
"repo.settings.pages.hero_upload": "Upload Image",
"repo.settings.pages.hero_upload_btn": "Upload",
"repo.settings.pages.hero_upload_help": "Upload a JPG, PNG, WebP, or GIF image (max 5 MB). Uploaded images take priority over the URL field.",
"repo.settings.pages.hero_upload_error": "Failed to upload image.",
"repo.settings.pages.hero_image_too_large": "Image is too large. Maximum size is 5 MB.",
"repo.settings.pages.hero_not_an_image": "The uploaded file is not a valid image.",
"repo.settings.pages.hero_image_uploaded": "Hero image uploaded successfully.",
"repo.settings.pages.hero_image_deleted": "Hero image deleted.",
"repo.settings.pages.hero_delete_image": "Delete Image",
"repo.settings.pages.hero_or": "or use a URL",
"repo.settings.pages.image_url": "Hero Image URL",
"repo.settings.pages.video_url": "Demo Video URL",
"repo.settings.pages.code_example": "Code Example",

View File

@@ -4,7 +4,9 @@
package setting
import (
"crypto/sha256"
"fmt"
"io"
"net/http"
"slices"
"strings"
@@ -16,7 +18,9 @@ import (
"code.gitcaddy.com/server/v3/modules/json"
"code.gitcaddy.com/server/v3/modules/log"
pages_module "code.gitcaddy.com/server/v3/modules/pages"
"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"
pages_service "code.gitcaddy.com/server/v3/services/pages"
)
@@ -282,6 +286,85 @@ func PagesHeroPost(ctx *context.Context) {
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/hero")
}
const maxHeroImageSize = 5 * 1024 * 1024 // 5MB
// PagesHeroUploadImage handles hero image file upload.
func PagesHeroUploadImage(ctx *context.Context) {
repo := ctx.Repo.Repository
file, header, err := ctx.Req.FormFile("hero_image")
if err != nil {
ctx.Flash.Error(ctx.Tr("repo.settings.pages.hero_upload_error"))
ctx.Redirect(repo.Link() + "/settings/pages/hero")
return
}
defer file.Close()
if header.Size > maxHeroImageSize {
ctx.Flash.Error(ctx.Tr("repo.settings.pages.hero_image_too_large"))
ctx.Redirect(repo.Link() + "/settings/pages/hero")
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.pages.hero_not_an_image"))
ctx.Redirect(repo.Link() + "/settings/pages/hero")
return
}
hash := sha256.Sum256(data)
filename := fmt.Sprintf("pages-hero-%d-%x", repo.ID, hash[:8])
config := getPagesLandingConfig(ctx)
// Delete old uploaded image if it exists
if config.Hero.UploadedImage != "" {
_ = storage.RepoAvatars.Delete(config.Hero.UploadedImage)
}
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
}
config.Hero.UploadedImage = filename
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.hero_image_uploaded"))
ctx.Redirect(repo.Link() + "/settings/pages/hero")
}
// PagesHeroDeleteImage removes the uploaded hero image.
func PagesHeroDeleteImage(ctx *context.Context) {
repo := ctx.Repo.Repository
config := getPagesLandingConfig(ctx)
if config.Hero.UploadedImage != "" {
_ = storage.RepoAvatars.Delete(config.Hero.UploadedImage)
config.Hero.UploadedImage = ""
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.hero_image_deleted"))
ctx.Redirect(repo.Link() + "/settings/pages/hero")
}
func PagesContent(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages.content")
ctx.Data["PageIsSettingsPages"] = true

View File

@@ -1334,6 +1334,8 @@ func registerWebRoutes(m *web.Router) {
m.Combo("").Get(repo_setting.Pages).Post(repo_setting.PagesPost)
m.Combo("/brand").Get(repo_setting.PagesBrand).Post(repo_setting.PagesBrandPost)
m.Combo("/hero").Get(repo_setting.PagesHero).Post(repo_setting.PagesHeroPost)
m.Post("/hero/upload_image", repo_setting.PagesHeroUploadImage)
m.Post("/hero/delete_image", repo_setting.PagesHeroDeleteImage)
m.Combo("/content").Get(repo_setting.PagesContent).Post(repo_setting.PagesContentPost)
m.Combo("/social").Get(repo_setting.PagesSocial).Post(repo_setting.PagesSocialPost)
m.Combo("/pricing").Get(repo_setting.PagesPricing).Post(repo_setting.PagesPricingPost)

View File

@@ -12,10 +12,38 @@
<label>{{ctx.Locale.Tr "repo.settings.pages.subheadline"}}</label>
<textarea name="subheadline" rows="2">{{.Config.Hero.Subheadline}}</textarea>
</div>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.hero_image"}}</h5>
{{if .Config.Hero.UploadedImage}}
<div class="field">
<img src="/repo-avatars/{{.Config.Hero.UploadedImage}}" style="max-width:400px;max-height:250px;border-radius:8px;border:1px solid #ddd;">
<div class="tw-mt-2">
<form method="post" action="{{.Link}}/delete_image" class="ignore-dirty" style="display:inline;">
{{$.CsrfTokenHtml}}
<button class="ui mini red button" type="submit">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "repo.settings.pages.hero_delete_image"}}
</button>
</form>
</div>
</div>
{{else}}
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.hero_upload"}}</label>
<form method="post" enctype="multipart/form-data" action="{{.Link}}/upload_image" class="ignore-dirty tw-flex tw-gap-2 tw-items-center">
{{$.CsrfTokenHtml}}
<input type="file" name="hero_image" accept="image/jpeg,image/png,image/webp,image/gif">
<button class="ui primary button" type="submit">
{{svg "octicon-upload" 16}} {{ctx.Locale.Tr "repo.settings.pages.hero_upload_btn"}}
</button>
</form>
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.hero_upload_help"}}</p>
</div>
<div class="ui horizontal divider">{{ctx.Locale.Tr "repo.settings.pages.hero_or"}}</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.image_url"}}</label>
<input name="image_url" value="{{.Config.Hero.ImageURL}}" placeholder="https://example.com/hero.png">
</div>
{{end}}
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.video_url"}}</label>
<input name="video_url" value="{{.Config.Hero.VideoURL}}" placeholder="https://youtube.com/watch?v=...">