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
1544 lines
50 KiB
Go
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")
|
|
}
|