2
0
Files
gitcaddy-server/routers/web/repo/setting/pages.go
logikonline 5e165b97be feat(pages): add option to hide mobile releases from landing page
Add HideMobileReleases config option to hide Android and iOS releases from the downloads section on landing pages. Useful when mobile apps are distributed via app stores rather than direct downloads. Adds checkbox in Content settings and conditional rendering in all four page templates.
2026-03-16 01:22:52 -04:00

943 lines
31 KiB
Go

// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"crypto/sha256"
"fmt"
"io"
"net/http"
"slices"
"strings"
pages_model "code.gitcaddy.com/server/v3/models/pages"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/modules/ai"
"code.gitcaddy.com/server/v3/modules/git"
"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"
)
const (
tplRepoSettingsPages templates.TplName = "repo/settings/pages"
tplRepoSettingsPagesBrand templates.TplName = "repo/settings/pages_brand"
tplRepoSettingsPagesHero templates.TplName = "repo/settings/pages_hero"
tplRepoSettingsPagesContent templates.TplName = "repo/settings/pages_content"
tplRepoSettingsPagesSocial templates.TplName = "repo/settings/pages_social"
tplRepoSettingsPagesPricing templates.TplName = "repo/settings/pages_pricing"
tplRepoSettingsPagesFooter templates.TplName = "repo/settings/pages_footer"
tplRepoSettingsPagesTheme templates.TplName = "repo/settings/pages_theme"
tplRepoSettingsPagesLanguages templates.TplName = "repo/settings/pages_languages"
)
// getPagesLandingConfig loads the landing page configuration
func getPagesLandingConfig(ctx *context.Context) *pages_module.LandingConfig {
config, err := pages_service.GetPagesConfig(ctx, ctx.Repo.Repository)
if err != nil {
// Return default config
return &pages_module.LandingConfig{
Enabled: false,
Template: "open-source-hero",
Brand: pages_module.BrandConfig{Name: ctx.Repo.Repository.Name},
Hero: pages_module.HeroConfig{
Headline: ctx.Repo.Repository.Name,
Subheadline: ctx.Repo.Repository.Description,
},
Theme: pages_module.ThemeConfig{Mode: "auto"},
}
}
return config
}
// savePagesLandingConfig saves the landing page configuration
func savePagesLandingConfig(ctx *context.Context, config *pages_module.LandingConfig) error {
configJSON, err := json.Marshal(config)
if err != nil {
return err
}
dbConfig, err := repo_model.GetPagesConfigByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil {
if repo_model.IsErrPagesConfigNotExist(err) {
// Create new config
dbConfig = &repo_model.PagesConfig{
RepoID: ctx.Repo.Repository.ID,
Enabled: config.Enabled,
Template: repo_model.PagesTemplate(config.Template),
ConfigJSON: string(configJSON),
}
return repo_model.CreatePagesConfig(ctx, dbConfig)
}
return err
}
// Update existing config
dbConfig.Enabled = config.Enabled
dbConfig.Template = repo_model.PagesTemplate(config.Template)
dbConfig.ConfigJSON = string(configJSON)
return repo_model.UpdatePagesConfig(ctx, dbConfig)
}
// setCommonPagesData sets common data for all pages settings pages
func setCommonPagesData(ctx *context.Context) {
config := getPagesLandingConfig(ctx)
ctx.Data["Config"] = config
ctx.Data["PagesEnabled"] = config.Enabled
ctx.Data["PagesSubdomain"] = pages_service.GetPagesSubdomain(ctx.Repo.Repository)
ctx.Data["PagesURL"] = pages_service.GetPagesURL(ctx.Repo.Repository)
ctx.Data["PagesTemplates"] = pages_module.ValidTemplates()
ctx.Data["PagesTemplateNames"] = pages_module.TemplateDisplayNames()
ctx.Data["AIEnabled"] = ai.IsEnabled()
}
// Pages shows the repository pages settings (General page)
func Pages(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages")
ctx.Data["PageIsSettingsPages"] = true
ctx.Data["PageIsSettingsPagesGeneral"] = true
setCommonPagesData(ctx)
// Get pages config
config, err := repo_model.GetPagesConfigByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil && !repo_model.IsErrPagesConfigNotExist(err) {
ctx.ServerError("GetPagesConfig", err)
return
}
if config != nil {
ctx.Data["PagesEnabled"] = config.Enabled
ctx.Data["PagesTemplate"] = config.Template
}
// Get pages domains
domains, err := repo_model.GetPagesDomains(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetPagesDomains", err)
return
}
ctx.Data["PagesDomains"] = domains
ctx.HTML(http.StatusOK, tplRepoSettingsPages)
}
func PagesPost(ctx *context.Context) {
action := ctx.FormString("action")
switch action {
case "enable":
template := ctx.FormString("template")
if template == "" || !pages_module.IsValidTemplate(template) {
template = "open-source-hero"
}
config := getPagesLandingConfig(ctx)
config.Enabled = true
config.Template = template
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("EnablePages", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.enabled_success"))
case "disable":
config := getPagesLandingConfig(ctx)
config.Enabled = false
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("DisablePages", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.disabled_success"))
case "update_template":
template := ctx.FormString("template")
if template == "" || !pages_module.IsValidTemplate(template) {
template = "open-source-hero"
}
config := getPagesLandingConfig(ctx)
config.Template = template
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("UpdateTemplate", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
case "add_domain":
domain := ctx.FormString("domain")
if domain == "" {
ctx.Flash.Error(ctx.Tr("repo.settings.pages.domain_required"))
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages")
return
}
sslExternal := ctx.FormBool("ssl_external")
_, err := pages_service.AddPagesDomain(ctx, ctx.Repo.Repository.ID, domain, sslExternal)
if err != nil {
if repo_model.IsErrPagesDomainAlreadyExist(err) {
ctx.Flash.Error(ctx.Tr("repo.settings.pages.domain_exists"))
} else {
ctx.ServerError("AddPagesDomain", err)
return
}
} else {
ctx.Flash.Success(ctx.Tr("repo.settings.pages.domain_added"))
}
case "delete_domain":
domainID := ctx.FormInt64("domain_id")
if err := repo_model.DeletePagesDomain(ctx, domainID); err != nil {
ctx.ServerError("DeletePagesDomain", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.domain_deleted"))
case "activate_ssl":
domainID := ctx.FormInt64("domain_id")
if err := repo_model.ActivatePagesDomainSSL(ctx, domainID); err != nil {
ctx.ServerError("ActivatePagesDomainSSL", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.ssl_activated"))
case "verify_domain":
domainID := ctx.FormInt64("domain_id")
if err := pages_service.VerifyDomain(ctx, domainID); err != nil {
ctx.Flash.Error(ctx.Tr("repo.settings.pages.domain_verification_failed"))
} else {
ctx.Flash.Success(ctx.Tr("repo.settings.pages.domain_verified"))
}
case "ai_generate":
readme := loadRawReadme(ctx, ctx.Repo.Repository)
generated, err := pages_service.GenerateLandingPageContent(ctx, ctx.Repo.Repository, readme)
if err != nil {
log.Error("AI landing page generation failed: %v", err)
ctx.Flash.Error(ctx.Tr("repo.settings.pages.ai_generate_failed"))
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages")
return
}
// Merge AI-generated content into existing config, preserving settings
config := getPagesLandingConfig(ctx)
config.Brand.Name = generated.Brand.Name
config.Brand.Tagline = generated.Brand.Tagline
config.Hero = generated.Hero
config.Stats = generated.Stats
config.ValueProps = generated.ValueProps
config.Features = generated.Features
config.CTASection = generated.CTASection
config.SEO = generated.SEO
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.ai_generate_success"))
default:
ctx.NotFound(nil)
return
}
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages")
}
func PagesBrand(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages.brand")
ctx.Data["PageIsSettingsPages"] = true
ctx.Data["PageIsSettingsPagesBrand"] = true
setCommonPagesData(ctx)
ctx.HTML(http.StatusOK, tplRepoSettingsPagesBrand)
}
func PagesBrandPost(ctx *context.Context) {
config := getPagesLandingConfig(ctx)
config.Brand.Name = ctx.FormString("brand_name")
config.Brand.Tagline = ctx.FormString("brand_tagline")
if config.Brand.UploadedLogo == "" {
config.Brand.LogoURL = ctx.FormString("brand_logo_url")
}
if config.Brand.UploadedFavicon == "" {
config.Brand.FaviconURL = ctx.FormString("brand_favicon_url")
}
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved"))
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/brand")
}
const maxPagesImageSize = 5 * 1024 * 1024 // 5 MB
// pagesUploadImage is a shared helper for uploading images to pages config fields.
// formField is the multipart form field name, filePrefix is used in the stored filename,
// redirectPath is the settings page to redirect to, getOld/setNew read/write the config field,
// and successKey is the locale key for the success flash message.
func pagesUploadImage(
ctx *context.Context,
formField, filePrefix, redirectPath string,
getOld func(*pages_module.LandingConfig) string,
setNew func(*pages_module.LandingConfig, string),
errorKey, tooLargeKey, notImageKey, successKey string,
) {
repo := ctx.Repo.Repository
redirect := repo.Link() + redirectPath
file, header, err := ctx.Req.FormFile(formField)
if err != nil {
ctx.Flash.Error(ctx.Tr(errorKey))
ctx.Redirect(redirect)
return
}
defer file.Close()
if header.Size > maxPagesImageSize {
ctx.Flash.Error(ctx.Tr(tooLargeKey))
ctx.Redirect(redirect)
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(notImageKey))
ctx.Redirect(redirect)
return
}
hash := sha256.Sum256(data)
filename := fmt.Sprintf("%s-%d-%x", filePrefix, repo.ID, hash[:8])
config := getPagesLandingConfig(ctx)
if old := getOld(config); old != "" {
_ = storage.RepoAvatars.Delete(old)
}
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
}
setNew(config, filename)
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
ctx.Flash.Success(ctx.Tr(successKey))
ctx.Redirect(redirect)
}
// pagesDeleteImage is a shared helper for deleting uploaded images from pages config fields.
func pagesDeleteImage(
ctx *context.Context,
redirectPath string,
getOld func(*pages_module.LandingConfig) string,
clearField func(*pages_module.LandingConfig),
successKey string,
) {
repo := ctx.Repo.Repository
config := getPagesLandingConfig(ctx)
if old := getOld(config); old != "" {
_ = storage.RepoAvatars.Delete(old)
clearField(config)
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
}
ctx.Flash.Success(ctx.Tr(successKey))
ctx.Redirect(repo.Link() + redirectPath)
}
// PagesBrandUploadLogo handles logo image file upload.
func PagesBrandUploadLogo(ctx *context.Context) {
pagesUploadImage(ctx, "brand_logo", "pages-logo", "/settings/pages/brand",
func(c *pages_module.LandingConfig) string { return c.Brand.UploadedLogo },
func(c *pages_module.LandingConfig, f string) { c.Brand.UploadedLogo = f },
"repo.settings.pages.brand_upload_error", "repo.settings.pages.brand_image_too_large",
"repo.settings.pages.brand_not_an_image", "repo.settings.pages.brand_logo_uploaded")
}
// PagesBrandDeleteLogo removes the uploaded logo image.
func PagesBrandDeleteLogo(ctx *context.Context) {
pagesDeleteImage(ctx, "/settings/pages/brand",
func(c *pages_module.LandingConfig) string { return c.Brand.UploadedLogo },
func(c *pages_module.LandingConfig) { c.Brand.UploadedLogo = "" },
"repo.settings.pages.brand_logo_deleted")
}
// PagesBrandUploadFavicon handles favicon file upload.
func PagesBrandUploadFavicon(ctx *context.Context) {
pagesUploadImage(ctx, "brand_favicon", "pages-favicon", "/settings/pages/brand",
func(c *pages_module.LandingConfig) string { return c.Brand.UploadedFavicon },
func(c *pages_module.LandingConfig, f string) { c.Brand.UploadedFavicon = f },
"repo.settings.pages.brand_upload_error", "repo.settings.pages.brand_image_too_large",
"repo.settings.pages.brand_not_an_image", "repo.settings.pages.brand_favicon_uploaded")
}
// PagesBrandDeleteFavicon removes the uploaded favicon.
func PagesBrandDeleteFavicon(ctx *context.Context) {
pagesDeleteImage(ctx, "/settings/pages/brand",
func(c *pages_module.LandingConfig) string { return c.Brand.UploadedFavicon },
func(c *pages_module.LandingConfig) { c.Brand.UploadedFavicon = "" },
"repo.settings.pages.brand_favicon_deleted")
}
func PagesHero(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages.hero")
ctx.Data["PageIsSettingsPages"] = true
ctx.Data["PageIsSettingsPagesHero"] = true
setCommonPagesData(ctx)
ctx.HTML(http.StatusOK, tplRepoSettingsPagesHero)
}
func PagesHeroPost(ctx *context.Context) {
config := getPagesLandingConfig(ctx)
config.Hero.Headline = ctx.FormString("headline")
config.Hero.Subheadline = ctx.FormString("subheadline")
config.Hero.ImageURL = ctx.FormString("image_url")
config.Hero.VideoURL = ctx.FormString("video_url")
config.Hero.CodeExample = ctx.FormString("code_example")
config.Hero.PrimaryCTA.Label = ctx.FormString("primary_cta_label")
config.Hero.PrimaryCTA.URL = ctx.FormString("primary_cta_url")
config.Hero.PrimaryCTA.Variant = ctx.FormString("primary_cta_variant")
config.Hero.SecondaryCTA.Label = ctx.FormString("secondary_cta_label")
config.Hero.SecondaryCTA.URL = ctx.FormString("secondary_cta_url")
config.Hero.SecondaryCTA.Variant = ctx.FormString("secondary_cta_variant")
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved"))
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/hero")
}
// PagesHeroUploadImage handles hero image file upload.
func PagesHeroUploadImage(ctx *context.Context) {
pagesUploadImage(ctx, "hero_image", "pages-hero", "/settings/pages/hero",
func(c *pages_module.LandingConfig) string { return c.Hero.UploadedImage },
func(c *pages_module.LandingConfig, f string) { c.Hero.UploadedImage = f },
"repo.settings.pages.hero_upload_error", "repo.settings.pages.hero_image_too_large",
"repo.settings.pages.hero_not_an_image", "repo.settings.pages.hero_image_uploaded")
}
// PagesHeroDeleteImage removes the uploaded hero image.
func PagesHeroDeleteImage(ctx *context.Context) {
pagesDeleteImage(ctx, "/settings/pages/hero",
func(c *pages_module.LandingConfig) string { return c.Hero.UploadedImage },
func(c *pages_module.LandingConfig) { c.Hero.UploadedImage = "" },
"repo.settings.pages.hero_image_deleted")
}
func PagesContent(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages.content")
ctx.Data["PageIsSettingsPages"] = true
ctx.Data["PageIsSettingsPagesContent"] = true
setCommonPagesData(ctx)
ctx.HTML(http.StatusOK, tplRepoSettingsPagesContent)
}
func PagesContentPost(ctx *context.Context) {
config := getPagesLandingConfig(ctx)
config.Advanced.PublicReleases = ctx.FormBool("public_releases")
config.Advanced.HideMobileReleases = ctx.FormBool("hide_mobile_releases")
config.Navigation.ShowDocs = ctx.FormBool("nav_show_docs")
config.Navigation.ShowAPI = ctx.FormBool("nav_show_api")
config.Navigation.ShowRepository = ctx.FormBool("nav_show_repository")
config.Navigation.ShowReleases = ctx.FormBool("nav_show_releases")
config.Navigation.ShowIssues = ctx.FormBool("nav_show_issues")
config.Blog.Enabled = ctx.FormBool("blog_enabled")
config.Blog.Headline = ctx.FormString("blog_headline")
config.Blog.Subheadline = ctx.FormString("blog_subheadline")
if maxPosts := ctx.FormInt("blog_max_posts"); maxPosts > 0 {
config.Blog.MaxPosts = maxPosts
}
config.Gallery.Enabled = ctx.FormBool("gallery_enabled")
config.Gallery.Headline = ctx.FormString("gallery_headline")
config.Gallery.Subheadline = ctx.FormString("gallery_subheadline")
if maxImages := ctx.FormInt("gallery_max_images"); maxImages > 0 {
config.Gallery.MaxImages = maxImages
}
if cols := ctx.FormInt("gallery_columns"); cols >= 2 && cols <= 4 {
config.Gallery.Columns = cols
}
config.Stats = nil
for i := range 10 {
value := ctx.FormString(fmt.Sprintf("stat_value_%d", i))
label := ctx.FormString(fmt.Sprintf("stat_label_%d", i))
if value == "" && label == "" {
continue
}
config.Stats = append(config.Stats, pages_module.StatConfig{Value: value, Label: label})
}
config.ValueProps = nil
for i := range 10 {
title := ctx.FormString(fmt.Sprintf("valueprop_title_%d", i))
desc := ctx.FormString(fmt.Sprintf("valueprop_desc_%d", i))
icon := ctx.FormString(fmt.Sprintf("valueprop_icon_%d", i))
if title == "" && desc == "" {
continue
}
config.ValueProps = append(config.ValueProps, pages_module.ValuePropConfig{Title: title, Description: desc, Icon: icon})
}
config.Features = nil
for i := range 20 {
title := ctx.FormString(fmt.Sprintf("feature_title_%d", i))
desc := ctx.FormString(fmt.Sprintf("feature_desc_%d", i))
icon := ctx.FormString(fmt.Sprintf("feature_icon_%d", i))
imageURL := ctx.FormString(fmt.Sprintf("feature_image_%d", i))
if title == "" && desc == "" {
continue
}
config.Features = append(config.Features, pages_module.FeatureConfig{Title: title, Description: desc, Icon: icon, ImageURL: imageURL})
}
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved"))
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/content")
}
func PagesSocial(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages.social")
ctx.Data["PageIsSettingsPages"] = true
ctx.Data["PageIsSettingsPagesSocial"] = true
setCommonPagesData(ctx)
ctx.HTML(http.StatusOK, tplRepoSettingsPagesSocial)
}
func PagesSocialPost(ctx *context.Context) {
config := getPagesLandingConfig(ctx)
config.SocialProof.Logos = nil
for i := range 20 {
logo := ctx.FormString(fmt.Sprintf("logo_%d", i))
if logo == "" {
continue
}
config.SocialProof.Logos = append(config.SocialProof.Logos, logo)
}
config.SocialProof.Testimonials = nil
for i := range 10 {
quote := ctx.FormString(fmt.Sprintf("testimonial_quote_%d", i))
author := ctx.FormString(fmt.Sprintf("testimonial_author_%d", i))
role := ctx.FormString(fmt.Sprintf("testimonial_role_%d", i))
avatar := ctx.FormString(fmt.Sprintf("testimonial_avatar_%d", i))
if quote == "" && author == "" {
continue
}
config.SocialProof.Testimonials = append(config.SocialProof.Testimonials, pages_module.TestimonialConfig{Quote: quote, Author: author, Role: role, Avatar: avatar})
}
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved"))
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/social")
}
func PagesPricing(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages.pricing")
ctx.Data["PageIsSettingsPages"] = true
ctx.Data["PageIsSettingsPagesPricing"] = true
setCommonPagesData(ctx)
ctx.HTML(http.StatusOK, tplRepoSettingsPagesPricing)
}
func PagesPricingPost(ctx *context.Context) {
config := getPagesLandingConfig(ctx)
config.Pricing.Headline = ctx.FormString("pricing_headline")
config.Pricing.Subheadline = ctx.FormString("pricing_subheadline")
config.Pricing.Plans = nil
for i := range 5 {
name := ctx.FormString(fmt.Sprintf("plan_name_%d", i))
price := ctx.FormString(fmt.Sprintf("plan_price_%d", i))
if name == "" && price == "" {
continue
}
featuresText := ctx.FormString(fmt.Sprintf("plan_%d_features", i))
var features []string
for f := range strings.SplitSeq(featuresText, "\n") {
f = strings.TrimSpace(f)
if f != "" {
features = append(features, f)
}
}
config.Pricing.Plans = append(config.Pricing.Plans, pages_module.PricingPlanConfig{
Name: name, Price: price, Period: ctx.FormString(fmt.Sprintf("plan_period_%d", i)),
Features: features, CTA: ctx.FormString(fmt.Sprintf("plan_cta_%d", i)),
Featured: ctx.FormBool(fmt.Sprintf("plan_featured_%d", i)),
})
}
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved"))
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/pricing")
}
func PagesFooter(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages.footer")
ctx.Data["PageIsSettingsPages"] = true
ctx.Data["PageIsSettingsPagesFooter"] = true
setCommonPagesData(ctx)
ctx.HTML(http.StatusOK, tplRepoSettingsPagesFooter)
}
func PagesFooterPost(ctx *context.Context) {
config := getPagesLandingConfig(ctx)
config.CTASection.Headline = ctx.FormString("cta_headline")
config.CTASection.Subheadline = ctx.FormString("cta_subheadline")
config.CTASection.Button.Label = ctx.FormString("cta_button_label")
config.CTASection.Button.URL = ctx.FormString("cta_button_url")
config.CTASection.Button.Variant = ctx.FormString("cta_button_variant")
config.Footer.Copyright = ctx.FormString("footer_copyright")
config.Footer.Links = nil
for i := range 10 {
label := ctx.FormString(fmt.Sprintf("footer_link_label_%d", i))
url := ctx.FormString(fmt.Sprintf("footer_link_url_%d", i))
if label == "" && url == "" {
continue
}
config.Footer.Links = append(config.Footer.Links, pages_module.FooterLink{Label: label, URL: url})
}
config.Footer.Social = nil
for i := range 10 {
platform := ctx.FormString(fmt.Sprintf("social_platform_%d", i))
url := ctx.FormString(fmt.Sprintf("social_url_%d", i))
if platform == "" && url == "" {
continue
}
config.Footer.Social = append(config.Footer.Social, pages_module.SocialLink{Platform: platform, URL: url})
}
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved"))
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/footer")
}
func PagesTheme(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages.theme")
ctx.Data["PageIsSettingsPages"] = true
ctx.Data["PageIsSettingsPagesTheme"] = true
setCommonPagesData(ctx)
ctx.HTML(http.StatusOK, tplRepoSettingsPagesTheme)
}
func PagesThemePost(ctx *context.Context) {
config := getPagesLandingConfig(ctx)
config.Theme.PrimaryColor = ctx.FormString("primary_color")
config.Theme.AccentColor = ctx.FormString("accent_color")
config.Theme.Mode = ctx.FormString("theme_mode")
config.SEO.Title = ctx.FormString("seo_title")
config.SEO.Description = ctx.FormString("seo_description")
keywords := ctx.FormString("seo_keywords")
if keywords != "" {
parts := strings.Split(keywords, ",")
config.SEO.Keywords = make([]string, 0, len(parts))
for _, kw := range parts {
if trimmed := strings.TrimSpace(kw); trimmed != "" {
config.SEO.Keywords = append(config.SEO.Keywords, trimmed)
}
}
} else {
config.SEO.Keywords = nil
}
config.SEO.UseMediaKitOG = ctx.FormBool("use_media_kit_og")
if !config.SEO.UseMediaKitOG {
config.SEO.OGImage = ctx.FormString("og_image")
}
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved"))
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/theme")
}
// TranslationView is a flattened view of a translation for the settings UI
type TranslationView struct {
Headline string
Subheadline string
PrimaryCTA string
SecondaryCTA string
CTAHeadline string
CTASubheadline string
CTAButton string
}
// parseTranslationView parses a PagesTranslation into a flat view for the template
func parseTranslationView(t *pages_model.Translation) *TranslationView {
if t == nil || t.ConfigJSON == "" {
return nil
}
var overlay map[string]any
if err := json.Unmarshal([]byte(t.ConfigJSON), &overlay); err != nil {
return nil
}
view := &TranslationView{}
if hero, ok := overlay["hero"].(map[string]any); ok {
if v, ok := hero["headline"].(string); ok {
view.Headline = v
}
if v, ok := hero["subheadline"].(string); ok {
view.Subheadline = v
}
if cta, ok := hero["primary_cta"].(map[string]any); ok {
if v, ok := cta["label"].(string); ok {
view.PrimaryCTA = v
}
}
if cta, ok := hero["secondary_cta"].(map[string]any); ok {
if v, ok := cta["label"].(string); ok {
view.SecondaryCTA = v
}
}
}
if ctaSec, ok := overlay["cta_section"].(map[string]any); ok {
if v, ok := ctaSec["headline"].(string); ok {
view.CTAHeadline = v
}
if v, ok := ctaSec["subheadline"].(string); ok {
view.CTASubheadline = v
}
if btn, ok := ctaSec["button"].(map[string]any); ok {
if v, ok := btn["label"].(string); ok {
view.CTAButton = v
}
}
}
return view
}
// buildTranslationJSON builds a JSON overlay string from form values
func buildTranslationJSON(ctx *context.Context) string {
overlay := map[string]any{}
hero := map[string]any{}
if v := ctx.FormString("trans_headline"); v != "" {
hero["headline"] = v
}
if v := ctx.FormString("trans_subheadline"); v != "" {
hero["subheadline"] = v
}
if v := ctx.FormString("trans_primary_cta"); v != "" {
hero["primary_cta"] = map[string]any{"label": v}
}
if v := ctx.FormString("trans_secondary_cta"); v != "" {
hero["secondary_cta"] = map[string]any{"label": v}
}
if len(hero) > 0 {
overlay["hero"] = hero
}
ctaSec := map[string]any{}
if v := ctx.FormString("trans_cta_headline"); v != "" {
ctaSec["headline"] = v
}
if v := ctx.FormString("trans_cta_subheadline"); v != "" {
ctaSec["subheadline"] = v
}
if v := ctx.FormString("trans_cta_button"); v != "" {
ctaSec["button"] = map[string]any{"label": v}
}
if len(ctaSec) > 0 {
overlay["cta_section"] = ctaSec
}
if len(overlay) == 0 {
return ""
}
data, _ := json.Marshal(overlay)
return string(data)
}
func PagesLanguages(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages.languages")
ctx.Data["PageIsSettingsPages"] = true
ctx.Data["PageIsSettingsPagesLanguages"] = true
setCommonPagesData(ctx)
config := getPagesLandingConfig(ctx)
ctx.Data["LanguageNames"] = pages_module.LanguageDisplayNames()
// Build a function to check if a language is enabled
enabledLangs := config.I18n.Languages
ctx.Data["IsLangEnabled"] = func(code string) bool {
return slices.Contains(enabledLangs, code)
}
// Load translations into a map[lang]*TranslationView
translationMap := make(map[string]*TranslationView)
translations, err := pages_model.GetTranslationsByRepoID(ctx, ctx.Repo.Repository.ID)
if err == nil {
for _, t := range translations {
translationMap[t.Lang] = parseTranslationView(t)
}
}
ctx.Data["TranslationMap"] = translationMap
ctx.HTML(http.StatusOK, tplRepoSettingsPagesLanguages)
}
func PagesLanguagesPost(ctx *context.Context) {
action := ctx.FormString("action")
config := getPagesLandingConfig(ctx)
switch action {
case "update_i18n":
config.I18n.DefaultLang = ctx.FormString("default_lang")
if config.I18n.DefaultLang == "" {
config.I18n.DefaultLang = "en"
}
selectedLangs := ctx.Req.Form["languages"]
// Ensure default language is always in the list
if !slices.Contains(selectedLangs, config.I18n.DefaultLang) {
selectedLangs = append([]string{config.I18n.DefaultLang}, selectedLangs...)
}
config.I18n.Languages = selectedLangs
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.languages_saved"))
case "save_translation":
targetLang := ctx.FormString("target_lang")
if targetLang == "" {
ctx.Flash.Error("Language is required")
break
}
configJSON := buildTranslationJSON(ctx)
if configJSON == "" {
ctx.Flash.Warning(ctx.Tr("repo.settings.pages.translation_empty"))
break
}
existing, err := pages_model.GetTranslation(ctx, ctx.Repo.Repository.ID, targetLang)
if err != nil {
ctx.ServerError("GetTranslation", err)
return
}
if existing != nil {
existing.ConfigJSON = configJSON
existing.AutoGenerated = false
if err := pages_model.UpdateTranslation(ctx, existing); err != nil {
ctx.ServerError("UpdateTranslation", err)
return
}
} else {
t := &pages_model.Translation{
RepoID: ctx.Repo.Repository.ID,
Lang: targetLang,
ConfigJSON: configJSON,
}
if err := pages_model.CreateTranslation(ctx, t); err != nil {
ctx.ServerError("CreateTranslation", err)
return
}
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.translation_saved"))
case "delete_translation":
targetLang := ctx.FormString("target_lang")
if err := pages_model.DeleteTranslation(ctx, ctx.Repo.Repository.ID, targetLang); err != nil {
ctx.ServerError("DeleteTranslation", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.translation_deleted"))
case "ai_translate":
targetLang := ctx.FormString("target_lang")
if targetLang == "" {
ctx.Flash.Error("Language is required")
break
}
translated, err := pages_service.TranslateLandingPageContent(ctx, ctx.Repo.Repository, config, targetLang)
if err != nil {
log.Error("AI translation failed: %v", err)
ctx.Flash.Error(fmt.Sprintf("AI translation failed: %v", err))
break
}
// Save or update the translation
existing, err := pages_model.GetTranslation(ctx, ctx.Repo.Repository.ID, targetLang)
if err != nil {
ctx.ServerError("GetTranslation", err)
return
}
if existing != nil {
existing.ConfigJSON = translated
existing.AutoGenerated = true
if err := pages_model.UpdateTranslation(ctx, existing); err != nil {
ctx.ServerError("UpdateTranslation", err)
return
}
} else {
t := &pages_model.Translation{
RepoID: ctx.Repo.Repository.ID,
Lang: targetLang,
ConfigJSON: translated,
AutoGenerated: true,
}
if err := pages_model.CreateTranslation(ctx, t); err != nil {
ctx.ServerError("CreateTranslation", err)
return
}
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.ai_translate_success"))
}
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/languages")
}
// loadRawReadme loads the raw README content from the repository for AI consumption
func loadRawReadme(ctx *context.Context, repo *repo_model.Repository) string {
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
if err != nil {
return ""
}
defer gitRepo.Close()
branch := repo.DefaultBranch
if branch == "" {
branch = "main"
}
commit, err := gitRepo.GetBranchCommit(branch)
if err != nil {
return ""
}
for _, name := range []string{"README.md", "readme.md", "README", "README.txt"} {
entry, err := commit.GetTreeEntryByPath(name)
if err != nil {
continue
}
reader, err := entry.Blob().DataAsync()
if err != nil {
continue
}
content := make([]byte, entry.Blob().Size())
_, _ = reader.Read(content)
reader.Close()
return string(content)
}
return ""
}