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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=...">
|
||||
|
||||
Reference in New Issue
Block a user