All checks were successful
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 4m4s
Build and Release / Unit Tests (push) Successful in 4m38s
Build and Release / Lint (push) Successful in 6m26s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 3m52s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 4m52s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h5m22s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 3m38s
Build and Release / Build Binary (linux/arm64) (push) Successful in 7m46s
Add translation support for navigation section labels (Value Props, Features, Pricing, Blog, Gallery, Compare, etc.). Adds TemplateDefaultLabels function that returns template-specific creative names (e.g., "Systems Analysis" for value props in Architecture Deep Dive). Auto-applies defaults when enabling pages or changing templates. Includes UI fields in languages settings and translation JSON serialization. Enables full localization of section headings.
1482 lines
48 KiB
Go
1482 lines
48 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"
|
|
)
|
|
|
|
// 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 ""
|
|
}
|