2
0
Files
gitcaddy-server/routers/web/repo/setting/pages.go
logikonline b43345986a
Some checks failed
Build and Release / Unit Tests (push) Successful in 6m20s
Build and Release / Create Release (push) Successful in 0s
Build and Release / Lint (push) Successful in 6m31s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Failing after 9h1m42s
Build and Release / Integration Tests (PostgreSQL) (push) Has been cancelled
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been cancelled
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been cancelled
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been cancelled
Build and Release / Build Binary (linux/arm64) (push) Has been cancelled
refactor(pages): remove unused app store fields from advanced settings
2026-03-30 09:32:58 -04:00

1544 lines
50 KiB
Go

// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"crypto/sha256"
"fmt"
"io"
"net/http"
"slices"
"strings"
"sync"
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"
tplRepoSettingsPagesComparison templates.TplName = "repo/settings/pages_comparison"
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"
tplRepoSettingsPagesAdvanced templates.TplName = "repo/settings/pages_advanced"
)
// 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)
}
// applyTemplateDefaultLabels populates Navigation label fields with
// template-specific defaults. Existing non-empty labels are preserved.
func applyTemplateDefaultLabels(config *pages_module.LandingConfig) {
defaults := pages_module.TemplateDefaultLabels(config.Template)
nav := &config.Navigation
if nav.LabelValueProps == "" {
nav.LabelValueProps = defaults.LabelValueProps
}
if nav.LabelFeatures == "" {
nav.LabelFeatures = defaults.LabelFeatures
}
if nav.LabelPricing == "" {
nav.LabelPricing = defaults.LabelPricing
}
if nav.LabelBlog == "" {
nav.LabelBlog = defaults.LabelBlog
}
if nav.LabelGallery == "" {
nav.LabelGallery = defaults.LabelGallery
}
if nav.LabelCompare == "" {
nav.LabelCompare = defaults.LabelCompare
}
}
// 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
applyTemplateDefaultLabels(config)
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
applyTemplateDefaultLabels(config)
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.Advanced.GooglePlayID = strings.TrimSpace(ctx.FormString("google_play_id"))
config.Advanced.AppStoreID = strings.TrimSpace(ctx.FormString("app_store_id"))
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})
}
// Comparison enabled toggle (full config is on /settings/pages/comparison)
config.Comparison.Enabled = ctx.FormBool("comparison_enabled")
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 PagesComparison(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages.comparison")
ctx.Data["PageIsSettingsPages"] = true
ctx.Data["PageIsSettingsPagesComparison"] = true
setCommonPagesData(ctx)
ctx.HTML(http.StatusOK, tplRepoSettingsPagesComparison)
}
func PagesComparisonPost(ctx *context.Context) {
config := getPagesLandingConfig(ctx)
config.Comparison.Headline = ctx.FormString("comparison_headline")
config.Comparison.Subheadline = ctx.FormString("comparison_subheadline")
config.Comparison.Columns = make([]pages_module.ComparisonColumnConfig, 3)
for c := range 3 {
config.Comparison.Columns[c] = pages_module.ComparisonColumnConfig{
Name: ctx.FormString(fmt.Sprintf("comparison_col_name_%d", c)),
Highlight: ctx.FormBool(fmt.Sprintf("comparison_col_highlight_%d", c)),
}
}
config.Comparison.Groups = nil
for g := range 10 {
groupName := ctx.FormString(fmt.Sprintf("comparison_group_name_%d", g))
var features []pages_module.ComparisonFeatureConfig
for f := range 20 {
featName := ctx.FormString(fmt.Sprintf("comparison_feat_name_%d_%d", g, f))
if featName == "" {
continue
}
var values []string
for c := range 3 {
values = append(values, ctx.FormString(fmt.Sprintf("comparison_feat_val_%d_%d_%d", g, f, c)))
}
features = append(features, pages_module.ComparisonFeatureConfig{Name: featName, Values: values})
}
if groupName == "" && len(features) == 0 {
continue
}
config.Comparison.Groups = append(config.Comparison.Groups, pages_module.ComparisonGroupConfig{Name: groupName, Features: features})
}
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/comparison")
}
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.
// Slice fields are padded to match the base config array lengths.
type TranslationView struct {
// Brand
BrandName string
BrandTagline string
// Hero
Headline string
Subheadline string
PrimaryCTA string
SecondaryCTA string
// Stats (parallel to Config.Stats)
StatsValues []string
StatsLabels []string
// Value Props (parallel to Config.ValueProps)
ValuePropTitles []string
ValuePropDescs []string
// Features (parallel to Config.Features)
FeatureTitles []string
FeatureDescs []string
// Testimonials (parallel to Config.SocialProof.Testimonials)
TestimonialQuotes []string
TestimonialRoles []string
// Pricing
PricingHeadline string
PricingSubheadline string
PlanNames []string
PlanPeriods []string
PlanCTAs []string
// CTA Section
CTAHeadline string
CTASubheadline string
CTAButton string
// Blog
BlogHeadline string
BlogSubheadline string
BlogCTAButton string
// Gallery
GalleryHeadline string
GallerySubheadline string
// Comparison
ComparisonHeadline string
ComparisonSubheadline string
// Footer
FooterCopyright string
FooterLinkLabels []string
// SEO
SEOTitle string
SEODescription string
// Navigation labels
NavLabelValueProps string
NavLabelFeatures string
NavLabelPricing string
NavLabelBlog string
NavLabelGallery string
NavLabelCompare string
NavLabelDocs string
NavLabelReleases string
NavLabelAPI string
NavLabelIssues string
}
// overlayString extracts a string from a map
func overlayString(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
// overlayStringSlice extracts a []string-field slice from an overlay array of objects.
// Each array element is a map; fieldKey is the key to extract from each map.
func overlayStringSlice(overlay map[string]any, arrayKey, fieldKey string, padLen int) []string {
result := make([]string, padLen)
arr, ok := overlay[arrayKey].([]any)
if !ok {
return result
}
for i, item := range arr {
if i >= padLen {
break
}
if m, ok := item.(map[string]any); ok {
if v, ok := m[fieldKey].(string); ok {
result[i] = v
}
}
}
return result
}
// parseTranslationView parses a PagesTranslation into a flat view for the template.
// config is used to determine array lengths for padding slices.
func parseTranslationView(t *pages_model.Translation, config *pages_module.LandingConfig) *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{}
// Brand
if brand, ok := overlay["brand"].(map[string]any); ok {
view.BrandName = overlayString(brand, "name")
view.BrandTagline = overlayString(brand, "tagline")
}
// Hero
if hero, ok := overlay["hero"].(map[string]any); ok {
view.Headline = overlayString(hero, "headline")
view.Subheadline = overlayString(hero, "subheadline")
if cta, ok := hero["primary_cta"].(map[string]any); ok {
view.PrimaryCTA = overlayString(cta, "label")
}
if cta, ok := hero["secondary_cta"].(map[string]any); ok {
view.SecondaryCTA = overlayString(cta, "label")
}
}
// Stats
view.StatsValues = overlayStringSlice(overlay, "stats", "value", len(config.Stats))
view.StatsLabels = overlayStringSlice(overlay, "stats", "label", len(config.Stats))
// Value Props
view.ValuePropTitles = overlayStringSlice(overlay, "value_props", "title", len(config.ValueProps))
view.ValuePropDescs = overlayStringSlice(overlay, "value_props", "description", len(config.ValueProps))
// Features
view.FeatureTitles = overlayStringSlice(overlay, "features", "title", len(config.Features))
view.FeatureDescs = overlayStringSlice(overlay, "features", "description", len(config.Features))
// Testimonials (stored under social_proof.testimonials)
view.TestimonialQuotes = make([]string, len(config.SocialProof.Testimonials))
view.TestimonialRoles = make([]string, len(config.SocialProof.Testimonials))
if sp, ok := overlay["social_proof"].(map[string]any); ok {
if arr, ok := sp["testimonials"].([]any); ok {
for i, item := range arr {
if i >= len(config.SocialProof.Testimonials) {
break
}
if m, ok := item.(map[string]any); ok {
view.TestimonialQuotes[i] = overlayString(m, "quote")
view.TestimonialRoles[i] = overlayString(m, "role")
}
}
}
}
// Pricing
if pricing, ok := overlay["pricing"].(map[string]any); ok {
view.PricingHeadline = overlayString(pricing, "headline")
view.PricingSubheadline = overlayString(pricing, "subheadline")
if plans, ok := pricing["plans"].([]any); ok {
view.PlanNames = make([]string, len(config.Pricing.Plans))
view.PlanPeriods = make([]string, len(config.Pricing.Plans))
view.PlanCTAs = make([]string, len(config.Pricing.Plans))
for i, item := range plans {
if i >= len(config.Pricing.Plans) {
break
}
if m, ok := item.(map[string]any); ok {
view.PlanNames[i] = overlayString(m, "name")
view.PlanPeriods[i] = overlayString(m, "period")
view.PlanCTAs[i] = overlayString(m, "cta")
}
}
} else {
view.PlanNames = make([]string, len(config.Pricing.Plans))
view.PlanPeriods = make([]string, len(config.Pricing.Plans))
view.PlanCTAs = make([]string, len(config.Pricing.Plans))
}
} else {
view.PlanNames = make([]string, len(config.Pricing.Plans))
view.PlanPeriods = make([]string, len(config.Pricing.Plans))
view.PlanCTAs = make([]string, len(config.Pricing.Plans))
}
// CTA Section
if ctaSec, ok := overlay["cta_section"].(map[string]any); ok {
view.CTAHeadline = overlayString(ctaSec, "headline")
view.CTASubheadline = overlayString(ctaSec, "subheadline")
if btn, ok := ctaSec["button"].(map[string]any); ok {
view.CTAButton = overlayString(btn, "label")
}
}
// Blog
if blog, ok := overlay["blog"].(map[string]any); ok {
view.BlogHeadline = overlayString(blog, "headline")
view.BlogSubheadline = overlayString(blog, "subheadline")
if btn, ok := blog["cta_button"].(map[string]any); ok {
view.BlogCTAButton = overlayString(btn, "label")
}
}
// Gallery
if gallery, ok := overlay["gallery"].(map[string]any); ok {
view.GalleryHeadline = overlayString(gallery, "headline")
view.GallerySubheadline = overlayString(gallery, "subheadline")
}
// Comparison
if comp, ok := overlay["comparison"].(map[string]any); ok {
view.ComparisonHeadline = overlayString(comp, "headline")
view.ComparisonSubheadline = overlayString(comp, "subheadline")
}
// Footer
view.FooterLinkLabels = make([]string, len(config.Footer.Links))
if footer, ok := overlay["footer"].(map[string]any); ok {
view.FooterCopyright = overlayString(footer, "copyright")
if links, ok := footer["links"].([]any); ok {
for i, item := range links {
if i >= len(config.Footer.Links) {
break
}
if m, ok := item.(map[string]any); ok {
view.FooterLinkLabels[i] = overlayString(m, "label")
}
}
}
}
// SEO
if seo, ok := overlay["seo"].(map[string]any); ok {
view.SEOTitle = overlayString(seo, "title")
view.SEODescription = overlayString(seo, "description")
}
// Navigation labels
if nav, ok := overlay["navigation"].(map[string]any); ok {
view.NavLabelValueProps = overlayString(nav, "label_value_props")
view.NavLabelFeatures = overlayString(nav, "label_features")
view.NavLabelPricing = overlayString(nav, "label_pricing")
view.NavLabelBlog = overlayString(nav, "label_blog")
view.NavLabelGallery = overlayString(nav, "label_gallery")
view.NavLabelCompare = overlayString(nav, "label_compare")
view.NavLabelDocs = overlayString(nav, "label_docs")
view.NavLabelReleases = overlayString(nav, "label_releases")
view.NavLabelAPI = overlayString(nav, "label_api")
view.NavLabelIssues = overlayString(nav, "label_issues")
}
return view
}
// buildTranslationJSON builds a JSON overlay string from form values
func buildTranslationJSON(ctx *context.Context) string {
overlay := map[string]any{}
// Brand
brand := map[string]any{}
if v := ctx.FormString("trans_brand_name"); v != "" {
brand["name"] = v
}
if v := ctx.FormString("trans_brand_tagline"); v != "" {
brand["tagline"] = v
}
if len(brand) > 0 {
overlay["brand"] = brand
}
// Hero
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
}
// Stats (indexed)
var stats []map[string]any
for i := range 20 {
v := ctx.FormString(fmt.Sprintf("trans_stat_%d_value", i))
l := ctx.FormString(fmt.Sprintf("trans_stat_%d_label", i))
if v == "" && l == "" {
// Check if there are more by looking ahead
if ctx.FormString(fmt.Sprintf("trans_stat_%d_value", i+1)) == "" &&
ctx.FormString(fmt.Sprintf("trans_stat_%d_label", i+1)) == "" {
break
}
}
stats = append(stats, map[string]any{"value": v, "label": l})
}
if len(stats) > 0 {
overlay["stats"] = stats
}
// Value Props (indexed)
var valueProps []map[string]any
for i := range 20 {
t := ctx.FormString(fmt.Sprintf("trans_vp_%d_title", i))
d := ctx.FormString(fmt.Sprintf("trans_vp_%d_desc", i))
if t == "" && d == "" {
if ctx.FormString(fmt.Sprintf("trans_vp_%d_title", i+1)) == "" &&
ctx.FormString(fmt.Sprintf("trans_vp_%d_desc", i+1)) == "" {
break
}
}
valueProps = append(valueProps, map[string]any{"title": t, "description": d})
}
if len(valueProps) > 0 {
overlay["value_props"] = valueProps
}
// Features (indexed)
var features []map[string]any
for i := range 20 {
t := ctx.FormString(fmt.Sprintf("trans_feat_%d_title", i))
d := ctx.FormString(fmt.Sprintf("trans_feat_%d_desc", i))
if t == "" && d == "" {
if ctx.FormString(fmt.Sprintf("trans_feat_%d_title", i+1)) == "" &&
ctx.FormString(fmt.Sprintf("trans_feat_%d_desc", i+1)) == "" {
break
}
}
features = append(features, map[string]any{"title": t, "description": d})
}
if len(features) > 0 {
overlay["features"] = features
}
// Testimonials (indexed)
var testimonials []map[string]any
for i := range 20 {
q := ctx.FormString(fmt.Sprintf("trans_test_%d_quote", i))
r := ctx.FormString(fmt.Sprintf("trans_test_%d_role", i))
if q == "" && r == "" {
if ctx.FormString(fmt.Sprintf("trans_test_%d_quote", i+1)) == "" &&
ctx.FormString(fmt.Sprintf("trans_test_%d_role", i+1)) == "" {
break
}
}
testimonials = append(testimonials, map[string]any{"quote": q, "role": r})
}
if len(testimonials) > 0 {
overlay["social_proof"] = map[string]any{"testimonials": testimonials}
}
// Pricing
pricing := map[string]any{}
if v := ctx.FormString("trans_pricing_headline"); v != "" {
pricing["headline"] = v
}
if v := ctx.FormString("trans_pricing_subheadline"); v != "" {
pricing["subheadline"] = v
}
var plans []map[string]any
for i := range 10 {
n := ctx.FormString(fmt.Sprintf("trans_plan_%d_name", i))
p := ctx.FormString(fmt.Sprintf("trans_plan_%d_period", i))
c := ctx.FormString(fmt.Sprintf("trans_plan_%d_cta", i))
if n == "" && p == "" && c == "" {
if ctx.FormString(fmt.Sprintf("trans_plan_%d_name", i+1)) == "" {
break
}
}
plans = append(plans, map[string]any{"name": n, "period": p, "cta": c})
}
if len(plans) > 0 {
pricing["plans"] = plans
}
if len(pricing) > 0 {
overlay["pricing"] = pricing
}
// CTA Section
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
}
// Blog
blog := map[string]any{}
if v := ctx.FormString("trans_blog_headline"); v != "" {
blog["headline"] = v
}
if v := ctx.FormString("trans_blog_subheadline"); v != "" {
blog["subheadline"] = v
}
if v := ctx.FormString("trans_blog_cta"); v != "" {
blog["cta_button"] = map[string]any{"label": v}
}
if len(blog) > 0 {
overlay["blog"] = blog
}
// Gallery
gallery := map[string]any{}
if v := ctx.FormString("trans_gallery_headline"); v != "" {
gallery["headline"] = v
}
if v := ctx.FormString("trans_gallery_subheadline"); v != "" {
gallery["subheadline"] = v
}
if len(gallery) > 0 {
overlay["gallery"] = gallery
}
// Comparison
comp := map[string]any{}
if v := ctx.FormString("trans_comparison_headline"); v != "" {
comp["headline"] = v
}
if v := ctx.FormString("trans_comparison_subheadline"); v != "" {
comp["subheadline"] = v
}
if len(comp) > 0 {
overlay["comparison"] = comp
}
// Footer
footer := map[string]any{}
if v := ctx.FormString("trans_footer_copyright"); v != "" {
footer["copyright"] = v
}
var footerLinks []map[string]any
for i := range 20 {
l := ctx.FormString(fmt.Sprintf("trans_footer_link_%d", i))
if l == "" {
if ctx.FormString(fmt.Sprintf("trans_footer_link_%d", i+1)) == "" {
break
}
}
footerLinks = append(footerLinks, map[string]any{"label": l})
}
if len(footerLinks) > 0 {
footer["links"] = footerLinks
}
if len(footer) > 0 {
overlay["footer"] = footer
}
// SEO
seo := map[string]any{}
if v := ctx.FormString("trans_seo_title"); v != "" {
seo["title"] = v
}
if v := ctx.FormString("trans_seo_description"); v != "" {
seo["description"] = v
}
if len(seo) > 0 {
overlay["seo"] = seo
}
// Navigation labels
nav := map[string]any{}
for _, key := range []string{
"label_value_props", "label_features", "label_pricing",
"label_blog", "label_gallery", "label_compare",
"label_docs", "label_releases", "label_api", "label_issues",
} {
if v := ctx.FormString("trans_nav_" + key); v != "" {
nav[key] = v
}
}
if len(nav) > 0 {
overlay["navigation"] = nav
}
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 map to check if a language is enabled
enabledLangsMap := make(map[string]bool)
for _, code := range config.I18n.Languages {
enabledLangsMap[code] = true
}
ctx.Data["EnabledLangs"] = enabledLangsMap
// 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, config)
}
}
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"))
case "ai_translate_all":
defaultLang := config.I18n.DefaultLang
if defaultLang == "" {
defaultLang = "en"
}
var (
mu sync.Mutex
successCount int
failCount int
wg sync.WaitGroup
)
repoID := ctx.Repo.Repository.ID
repo := ctx.Repo.Repository
for _, lang := range config.I18n.Languages {
if lang == defaultLang {
continue
}
wg.Add(1)
go func(lang string) {
defer wg.Done()
translated, err := pages_service.TranslateLandingPageContent(ctx, repo, config, lang)
if err != nil {
log.Error("AI translation failed for %s: %v", lang, err)
mu.Lock()
failCount++
mu.Unlock()
return
}
mu.Lock()
defer mu.Unlock()
existing, err := pages_model.GetTranslation(ctx, repoID, lang)
if err != nil {
log.Error("GetTranslation failed for %s: %v", lang, err)
failCount++
return
}
if existing != nil {
existing.ConfigJSON = translated
existing.AutoGenerated = true
if err := pages_model.UpdateTranslation(ctx, existing); err != nil {
log.Error("UpdateTranslation failed for %s: %v", lang, err)
failCount++
return
}
} else {
t := &pages_model.Translation{
RepoID: repoID,
Lang: lang,
ConfigJSON: translated,
AutoGenerated: true,
}
if err := pages_model.CreateTranslation(ctx, t); err != nil {
log.Error("CreateTranslation failed for %s: %v", lang, err)
failCount++
return
}
}
successCount++
}(lang)
}
wg.Wait()
if failCount == 0 {
ctx.Flash.Success(ctx.Tr("repo.settings.pages.ai_translate_all_success", successCount))
} else {
ctx.Flash.Warning(ctx.Tr("repo.settings.pages.ai_translate_all_partial", successCount, successCount+failCount, failCount))
}
}
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 ""
}
// PagesAdvanced renders the advanced settings page
func PagesAdvanced(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages.advanced")
ctx.Data["PageIsSettingsPages"] = true
ctx.Data["PageIsSettingsPagesAdvanced"] = true
setCommonPagesData(ctx)
ctx.HTML(http.StatusOK, tplRepoSettingsPagesAdvanced)
}
// PagesAdvancedPost handles the advanced settings form submission
func PagesAdvancedPost(ctx *context.Context) {
config := getPagesLandingConfig(ctx)
// Parse static routes
var routes []string
for i := range 100 {
route := strings.TrimSpace(ctx.FormString(fmt.Sprintf("static_route_%d", i)))
if route != "" {
routes = append(routes, route)
}
}
config.Advanced.StaticRoutes = routes
// Parse redirects
redirects := make(map[string]string)
for i := range 100 {
from := strings.TrimSpace(ctx.FormString(fmt.Sprintf("redirect_from_%d", i)))
to := strings.TrimSpace(ctx.FormString(fmt.Sprintf("redirect_to_%d", i)))
if from != "" && to != "" {
redirects[from] = to
}
}
// Also handle redirects keyed by path (from existing entries)
for key, vals := range ctx.Req.Form {
if strings.HasPrefix(key, "redirect_from_/") {
from := strings.TrimSpace(vals[0])
toKey := "redirect_to_" + strings.TrimPrefix(key, "redirect_from_")
to := strings.TrimSpace(ctx.Req.FormValue(toKey))
if from != "" && to != "" {
redirects[from] = to
}
}
}
if len(redirects) > 0 {
config.Advanced.Redirects = redirects
} else {
config.Advanced.Redirects = nil
}
// Parse remaining fields
config.Advanced.CustomCSS = ctx.FormString("custom_css")
config.Advanced.CustomHead = ctx.FormString("custom_head")
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/advanced")
}