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