feat(pages): add multi-language support for landing pages
Implement internationalization system for landing pages: - Database model for storing language-specific translations - Language configuration with default and enabled languages - Language switcher in navigation across all templates - Translation management UI in settings - Support for 15 languages including English, Spanish, German, French, Japanese, Chinese - Auto-detection and manual language selection - AI-powered translation generation capability
This commit is contained in:
@@ -441,6 +441,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(364, "Add view_count to blog_post", v1_26.AddViewCountToBlogPost),
|
||||
newMigration(365, "Add public_app_integration to repository", v1_26.AddPublicAppIntegrationToRepository),
|
||||
newMigration(366, "Add page experiment tables for A/B testing", v1_26.AddPageExperimentTables),
|
||||
newMigration(367, "Add pages translation table for multi-language support", v1_26.AddPagesTranslationTable),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
24
models/migrations/v1_26/v367.go
Normal file
24
models/migrations/v1_26/v367.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_26
|
||||
|
||||
import (
|
||||
"code.gitcaddy.com/server/v3/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddPagesTranslationTable(x *xorm.Engine) error {
|
||||
type Translation struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||
Lang string `xorm:"VARCHAR(10) NOT NULL"`
|
||||
ConfigJSON string `xorm:"TEXT"`
|
||||
AutoGenerated bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
return x.Sync(new(Translation))
|
||||
}
|
||||
70
models/pages/translation.go
Normal file
70
models/pages/translation.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pages
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
"code.gitcaddy.com/server/v3/modules/timeutil"
|
||||
)
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Translation))
|
||||
}
|
||||
|
||||
// Translation stores a language-specific translation overlay for a landing page.
|
||||
type Translation struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||
Lang string `xorm:"VARCHAR(10) NOT NULL"`
|
||||
ConfigJSON string `xorm:"TEXT"`
|
||||
AutoGenerated bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for Translation.
|
||||
func (t *Translation) TableName() string {
|
||||
return "pages_translation"
|
||||
}
|
||||
|
||||
// GetTranslationsByRepoID returns all translations for a repository.
|
||||
func GetTranslationsByRepoID(ctx context.Context, repoID int64) ([]*Translation, error) {
|
||||
translations := make([]*Translation, 0, 10)
|
||||
return translations, db.GetEngine(ctx).Where("repo_id = ?", repoID).
|
||||
Asc("lang").Find(&translations)
|
||||
}
|
||||
|
||||
// GetTranslation returns a specific language translation for a repository.
|
||||
func GetTranslation(ctx context.Context, repoID int64, lang string) (*Translation, error) {
|
||||
t := new(Translation)
|
||||
has, err := db.GetEngine(ctx).Where("repo_id = ? AND lang = ?", repoID, lang).Get(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, nil
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// CreateTranslation creates a new translation.
|
||||
func CreateTranslation(ctx context.Context, t *Translation) error {
|
||||
_, err := db.GetEngine(ctx).Insert(t)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateTranslation updates an existing translation.
|
||||
func UpdateTranslation(ctx context.Context, t *Translation) error {
|
||||
_, err := db.GetEngine(ctx).ID(t.ID).Cols("config_json", "auto_generated").Update(t)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteTranslation deletes a translation by repo ID and language.
|
||||
func DeleteTranslation(ctx context.Context, repoID int64, lang string) error {
|
||||
_, err := db.GetEngine(ctx).Where("repo_id = ? AND lang = ?", repoID, lang).
|
||||
Delete(new(Translation))
|
||||
return err
|
||||
}
|
||||
@@ -67,6 +67,9 @@ type LandingConfig struct {
|
||||
|
||||
// A/B testing experiments
|
||||
Experiments ExperimentConfig `yaml:"experiments,omitempty"`
|
||||
|
||||
// Multi-language support
|
||||
I18n I18nConfig `yaml:"i18n,omitempty"`
|
||||
}
|
||||
|
||||
// BrandConfig represents brand/identity settings
|
||||
@@ -233,6 +236,33 @@ type ExperimentConfig struct {
|
||||
ApprovalRequired bool `yaml:"approval_required,omitempty"`
|
||||
}
|
||||
|
||||
// I18nConfig represents multi-language settings for the landing page
|
||||
type I18nConfig struct {
|
||||
DefaultLang string `yaml:"default_lang,omitempty" json:"default_lang,omitempty"`
|
||||
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
|
||||
}
|
||||
|
||||
// LanguageDisplayNames returns a map of language codes to display names
|
||||
func LanguageDisplayNames() map[string]string {
|
||||
return map[string]string{
|
||||
"en": "English",
|
||||
"es": "Espanol",
|
||||
"de": "Deutsch",
|
||||
"fr": "Francais",
|
||||
"ja": "Japanese",
|
||||
"zh": "Chinese",
|
||||
"pt": "Portugues",
|
||||
"ru": "Russian",
|
||||
"ko": "Korean",
|
||||
"it": "Italiano",
|
||||
"hi": "Hindi",
|
||||
"ar": "Arabic",
|
||||
"nl": "Nederlands",
|
||||
"pl": "Polski",
|
||||
"tr": "Turkish",
|
||||
}
|
||||
}
|
||||
|
||||
// AdvancedConfig represents advanced settings
|
||||
type AdvancedConfig struct {
|
||||
CustomCSS string `yaml:"custom_css,omitempty"`
|
||||
|
||||
@@ -4512,6 +4512,28 @@
|
||||
"repo.settings.pages.ai_generate_button": "Generate Content with AI",
|
||||
"repo.settings.pages.ai_generate_success": "Landing page content has been generated successfully. Review and customize it in the other tabs.",
|
||||
"repo.settings.pages.ai_generate_failed": "Failed to generate content with AI. Please try again later or configure the content manually.",
|
||||
"repo.settings.pages.languages": "Languages",
|
||||
"repo.settings.pages.default_lang": "Default Language",
|
||||
"repo.settings.pages.default_lang_help": "The primary language of your landing page content",
|
||||
"repo.settings.pages.enabled_languages": "Enabled Languages",
|
||||
"repo.settings.pages.enabled_languages_help": "Select which languages your landing page should support. Visitors will see a language switcher in the navigation.",
|
||||
"repo.settings.pages.save_languages": "Save Language Settings",
|
||||
"repo.settings.pages.languages_saved": "Language settings saved successfully.",
|
||||
"repo.settings.pages.translations": "Translations",
|
||||
"repo.settings.pages.ai_translate": "AI Translate",
|
||||
"repo.settings.pages.ai_translate_success": "Translation has been generated successfully by AI. Review and edit as needed.",
|
||||
"repo.settings.pages.delete_translation": "Delete",
|
||||
"repo.settings.pages.save_translation": "Save Translation",
|
||||
"repo.settings.pages.translation_saved": "Translation saved successfully.",
|
||||
"repo.settings.pages.translation_deleted": "Translation deleted.",
|
||||
"repo.settings.pages.translation_empty": "No translation content provided.",
|
||||
"repo.settings.pages.trans_headline": "Headline",
|
||||
"repo.settings.pages.trans_subheadline": "Subheadline",
|
||||
"repo.settings.pages.trans_primary_cta": "Primary CTA Label",
|
||||
"repo.settings.pages.trans_secondary_cta": "Secondary CTA Label",
|
||||
"repo.settings.pages.trans_cta_headline": "CTA Section Headline",
|
||||
"repo.settings.pages.trans_cta_subheadline": "CTA Section Subheadline",
|
||||
"repo.settings.pages.trans_cta_button": "CTA Button Label",
|
||||
"repo.vault": "Vault",
|
||||
"repo.vault.secrets": "Secrets",
|
||||
"repo.vault.new_secret": "New Secret",
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"math/big"
|
||||
"net/http"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -74,18 +75,21 @@ func ServeLandingPage(ctx *context.Context) {
|
||||
idStr = strings.TrimRight(idStr, "/")
|
||||
blogID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err == nil && blogID > 0 {
|
||||
config = applyLanguageOverlay(ctx, repo, config)
|
||||
serveBlogDetail(ctx, repo, config, blogID, "/blog")
|
||||
return
|
||||
}
|
||||
} else if requestPath == "/blog" || requestPath == "/blog/" {
|
||||
config = applyLanguageOverlay(ctx, repo, config)
|
||||
serveBlogList(ctx, repo, config, "/blog")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Render the landing page with A/B test variant
|
||||
// Render the landing page with A/B test variant and language overlay
|
||||
ctx.Data["BlogBaseURL"] = "/blog"
|
||||
config = assignVariant(ctx, repo, config)
|
||||
config = applyLanguageOverlay(ctx, repo, config)
|
||||
renderLandingPage(ctx, repo, config)
|
||||
}
|
||||
|
||||
@@ -487,6 +491,7 @@ func ServeRepoLandingPage(ctx *context.Context) {
|
||||
|
||||
ctx.Data["BlogBaseURL"] = fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
|
||||
config = assignVariant(ctx, repo, config)
|
||||
config = applyLanguageOverlay(ctx, repo, config)
|
||||
renderLandingPage(ctx, repo, config)
|
||||
}
|
||||
|
||||
@@ -510,6 +515,7 @@ func ServeRepoBlogList(ctx *context.Context) {
|
||||
}
|
||||
|
||||
blogBaseURL := fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
|
||||
config = applyLanguageOverlay(ctx, repo, config)
|
||||
serveBlogList(ctx, repo, config, blogBaseURL)
|
||||
}
|
||||
|
||||
@@ -539,6 +545,7 @@ func ServeRepoBlogDetail(ctx *context.Context) {
|
||||
}
|
||||
|
||||
blogBaseURL := fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
|
||||
config = applyLanguageOverlay(ctx, repo, config)
|
||||
serveBlogDetail(ctx, repo, config, blogID, blogBaseURL)
|
||||
}
|
||||
|
||||
@@ -828,6 +835,99 @@ func deepMerge(dst, src map[string]any) map[string]any {
|
||||
return dst
|
||||
}
|
||||
|
||||
// detectPageLanguage determines the active language for a landing page.
|
||||
// Priority: ?lang= query param > pages_lang cookie > Accept-Language header > default.
|
||||
func detectPageLanguage(ctx *context.Context, config *pages_module.LandingConfig) string {
|
||||
langs := config.I18n.Languages
|
||||
if len(langs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
defaultLang := config.I18n.DefaultLang
|
||||
if defaultLang == "" {
|
||||
defaultLang = "en"
|
||||
}
|
||||
|
||||
// 1. Explicit ?lang= query parameter
|
||||
if qLang := ctx.FormString("lang"); qLang != "" {
|
||||
if slices.Contains(langs, qLang) {
|
||||
ctx.SetSiteCookie("pages_lang", qLang, 86400*365)
|
||||
return qLang
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Cookie
|
||||
if cLang := ctx.GetSiteCookie("pages_lang"); cLang != "" {
|
||||
if slices.Contains(langs, cLang) {
|
||||
return cLang
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Accept-Language header
|
||||
accept := ctx.Req.Header.Get("Accept-Language")
|
||||
if accept != "" {
|
||||
for part := range strings.SplitSeq(accept, ",") {
|
||||
tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
|
||||
// Try exact match first
|
||||
if slices.Contains(langs, tag) {
|
||||
return tag
|
||||
}
|
||||
// Try base language (e.g. "en-US" → "en")
|
||||
if base, _, found := strings.Cut(tag, "-"); found {
|
||||
if slices.Contains(langs, base) {
|
||||
return base
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLang
|
||||
}
|
||||
|
||||
// applyLanguageOverlay loads the translation for the detected language and merges it onto config.
|
||||
// Sets template data for the language switcher and returns the (possibly merged) config.
|
||||
func applyLanguageOverlay(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) *pages_module.LandingConfig {
|
||||
if len(config.I18n.Languages) == 0 {
|
||||
return config
|
||||
}
|
||||
|
||||
activeLang := detectPageLanguage(ctx, config)
|
||||
defaultLang := config.I18n.DefaultLang
|
||||
if defaultLang == "" {
|
||||
defaultLang = "en"
|
||||
}
|
||||
|
||||
// Set template data for language switcher
|
||||
ctx.Data["LangSwitcherEnabled"] = true
|
||||
ctx.Data["ActiveLang"] = activeLang
|
||||
ctx.Data["AvailableLanguages"] = config.I18n.Languages
|
||||
ctx.Data["LanguageNames"] = pages_module.LanguageDisplayNames()
|
||||
|
||||
// If active language is the default, no overlay needed
|
||||
if activeLang == defaultLang || activeLang == "" {
|
||||
return config
|
||||
}
|
||||
|
||||
// Load translation overlay from DB
|
||||
translation, err := pages_model.GetTranslation(ctx, repo.ID, activeLang)
|
||||
if err != nil {
|
||||
log.Error("Failed to load translation for %s: %v", activeLang, err)
|
||||
return config
|
||||
}
|
||||
if translation == nil || translation.ConfigJSON == "" {
|
||||
return config
|
||||
}
|
||||
|
||||
// Deep-merge translation overlay onto config
|
||||
merged, err := deepMergeConfig(config, translation.ConfigJSON)
|
||||
if err != nil {
|
||||
log.Error("Failed to merge translation config for %s: %v", activeLang, err)
|
||||
return config
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ApproveExperiment handles the email approval link for an A/B test experiment
|
||||
func ApproveExperiment(ctx *context.Context) {
|
||||
handleExperimentAction(ctx, true)
|
||||
|
||||
@@ -6,8 +6,10 @@ package setting
|
||||
import (
|
||||
"fmt"
|
||||
"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"
|
||||
@@ -20,14 +22,15 @@ import (
|
||||
)
|
||||
|
||||
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"
|
||||
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
|
||||
@@ -489,6 +492,248 @@ func PagesThemePost(ctx *context.Context) {
|
||||
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())
|
||||
|
||||
@@ -1339,6 +1339,7 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Combo("/pricing").Get(repo_setting.PagesPricing).Post(repo_setting.PagesPricingPost)
|
||||
m.Combo("/footer").Get(repo_setting.PagesFooter).Post(repo_setting.PagesFooterPost)
|
||||
m.Combo("/theme").Get(repo_setting.PagesTheme).Post(repo_setting.PagesThemePost)
|
||||
m.Combo("/languages").Get(repo_setting.PagesLanguages).Post(repo_setting.PagesLanguagesPost)
|
||||
})
|
||||
m.Group("/actions/general", func() {
|
||||
m.Get("", repo_setting.ActionsGeneralSettings)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
@@ -34,8 +35,8 @@ type aiGeneratedConfig struct {
|
||||
} `json:"secondary_cta"`
|
||||
} `json:"hero"`
|
||||
Stats []pages_module.StatConfig `json:"stats"`
|
||||
ValueProps []pages_module.ValuePropConfig `json:"value_props"`
|
||||
Features []pages_module.FeatureConfig `json:"features"`
|
||||
ValueProps []pages_module.ValuePropConfig `json:"value_props"`
|
||||
Features []pages_module.FeatureConfig `json:"features"`
|
||||
CTASection struct {
|
||||
Headline string `json:"headline"`
|
||||
Subheadline string `json:"subheadline"`
|
||||
@@ -66,9 +67,9 @@ func GenerateLandingPageContent(ctx context.Context, repo *repo_model.Repository
|
||||
"repo_description": repo.Description,
|
||||
"repo_url": repoURL,
|
||||
"topics": topics,
|
||||
"primary_language": repo.PrimaryLanguage,
|
||||
"stars": fmt.Sprintf("%d", repo.NumStars),
|
||||
"forks": fmt.Sprintf("%d", repo.NumForks),
|
||||
"primary_language": getPrimaryLanguageName(repo),
|
||||
"stars": strconv.Itoa(repo.NumStars),
|
||||
"forks": strconv.Itoa(repo.NumForks),
|
||||
"readme": truncateReadme(readme),
|
||||
"instruction": `You are a landing page copywriter. Analyze this open-source repository and generate compelling landing page content.
|
||||
|
||||
@@ -165,6 +166,14 @@ Guidelines:
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// getPrimaryLanguageName returns the primary language name for the repo, or empty string
|
||||
func getPrimaryLanguageName(repo *repo_model.Repository) string {
|
||||
if repo.PrimaryLanguage != nil {
|
||||
return repo.PrimaryLanguage.Language
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// truncateReadme limits README content to avoid sending too much to the AI
|
||||
func truncateReadme(readme string) string {
|
||||
const maxLen = 4000
|
||||
@@ -173,3 +182,103 @@ func truncateReadme(readme string) string {
|
||||
}
|
||||
return readme[:maxLen] + "\n... (truncated)"
|
||||
}
|
||||
|
||||
// TranslateLandingPageContent uses AI to translate landing page content to a target language.
|
||||
// Returns a JSON overlay string that can be deep-merged onto the base config.
|
||||
func TranslateLandingPageContent(ctx context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig, targetLang string) (string, error) {
|
||||
if !ai.IsEnabled() {
|
||||
return "", errors.New("AI service is not enabled")
|
||||
}
|
||||
|
||||
langNames := pages_module.LanguageDisplayNames()
|
||||
langName := langNames[targetLang]
|
||||
if langName == "" {
|
||||
langName = targetLang
|
||||
}
|
||||
|
||||
// Build the source content to translate
|
||||
sourceContent := buildTranslatableContent(config)
|
||||
|
||||
client := ai.GetClient()
|
||||
resp, err := client.ExecuteTask(ctx, &ai.ExecuteTaskRequest{
|
||||
RepoID: repo.ID,
|
||||
Task: "landing_page_translate",
|
||||
Context: map[string]string{
|
||||
"target_language": langName,
|
||||
"target_code": targetLang,
|
||||
"source_content": sourceContent,
|
||||
"instruction": `You are a professional translator. Translate the following landing page content to ` + langName + ` (` + targetLang + `).
|
||||
|
||||
Return valid JSON as a partial config overlay. Only include the fields that have translatable text content.
|
||||
Do NOT translate URLs, brand names, or technical terms. Keep the same JSON structure.
|
||||
|
||||
Return this exact JSON structure (only include fields with actual translated text):
|
||||
{
|
||||
"hero": {
|
||||
"headline": "translated headline",
|
||||
"subheadline": "translated subheadline",
|
||||
"primary_cta": {"label": "translated label"},
|
||||
"secondary_cta": {"label": "translated label"}
|
||||
},
|
||||
"stats": [{"value": "keep original", "label": "translated label"}],
|
||||
"value_props": [{"title": "translated", "description": "translated"}],
|
||||
"features": [{"title": "translated", "description": "translated"}],
|
||||
"cta_section": {
|
||||
"headline": "translated",
|
||||
"subheadline": "translated",
|
||||
"button": {"label": "translated"}
|
||||
},
|
||||
"seo": {
|
||||
"title": "translated",
|
||||
"description": "translated"
|
||||
}
|
||||
}
|
||||
|
||||
Important:
|
||||
- Maintain the exact same number of items in arrays (stats, value_props, features)
|
||||
- Keep icon names, URLs, and image_urls unchanged
|
||||
- Use natural, marketing-quality translations (not literal/robotic)
|
||||
- Adapt idioms and expressions for the target culture`,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("AI translation failed: %w", err)
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
return "", fmt.Errorf("AI translation error: %s", resp.Error)
|
||||
}
|
||||
|
||||
// Validate it's valid JSON
|
||||
var check map[string]any
|
||||
if err := json.Unmarshal([]byte(resp.Result), &check); err != nil {
|
||||
return "", fmt.Errorf("AI returned invalid JSON: %w", err)
|
||||
}
|
||||
|
||||
return resp.Result, nil
|
||||
}
|
||||
|
||||
// buildTranslatableContent extracts translatable text from a config for the AI
|
||||
func buildTranslatableContent(config *pages_module.LandingConfig) string {
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"hero": map[string]any{
|
||||
"headline": config.Hero.Headline,
|
||||
"subheadline": config.Hero.Subheadline,
|
||||
"primary_cta": map[string]string{"label": config.Hero.PrimaryCTA.Label},
|
||||
"secondary_cta": map[string]string{"label": config.Hero.SecondaryCTA.Label},
|
||||
},
|
||||
"stats": config.Stats,
|
||||
"value_props": config.ValueProps,
|
||||
"features": config.Features,
|
||||
"cta_section": map[string]any{
|
||||
"headline": config.CTASection.Headline,
|
||||
"subheadline": config.CTASection.Subheadline,
|
||||
"button": map[string]string{"label": config.CTASection.Button.Label},
|
||||
},
|
||||
"seo": map[string]any{
|
||||
"title": config.SEO.Title,
|
||||
"description": config.SEO.Description,
|
||||
},
|
||||
})
|
||||
return string(data)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ctx.Locale.Lang}}" data-theme="{{ctx.CurrentWebTheme.InternalName}}">
|
||||
<html lang="{{if .ActiveLang}}{{.ActiveLang}}{{else}}{{ctx.Locale.Lang}}{{end}}" data-theme="{{ctx.CurrentWebTheme.InternalName}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Config.Hero.Headline}}{{.Config.Hero.Headline}}{{else}}{{.Repository.Name}}{{end}} - {{.Repository.Owner.Name}}</title>
|
||||
<meta name="description" content="{{if .Config.Hero.Subheadline}}{{.Config.Hero.Subheadline}}{{else}}{{.Repository.Description}}{{end}}">
|
||||
{{if .LangSwitcherEnabled}}{{range .AvailableLanguages}}
|
||||
<link rel="alternate" hreflang="{{.}}" href="?lang={{.}}">{{end}}
|
||||
{{end}}
|
||||
{{if .Config.Brand.FaviconURL}}
|
||||
<link rel="icon" href="{{.Config.Brand.FaviconURL}}">
|
||||
{{else}}
|
||||
@@ -169,6 +172,49 @@
|
||||
margin: 4px 0;
|
||||
}
|
||||
.pages-footer-powered a { color: #79b8ff; }
|
||||
/* Language switcher */
|
||||
.pages-lang-switcher {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.pages-lang-btn {
|
||||
background: none;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.pages-lang-btn:hover { opacity: 1; }
|
||||
.pages-lang-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #e1e4e8;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
min-width: 140px;
|
||||
z-index: 200;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pages-lang-dropdown.open { display: block; }
|
||||
.pages-lang-option {
|
||||
display: block;
|
||||
padding: 8px 14px;
|
||||
color: #24292e;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.pages-lang-option:hover { background: #f6f8fa; }
|
||||
.pages-lang-option.active { font-weight: 600; color: var(--pages-primary); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="pages-body">
|
||||
|
||||
@@ -1142,6 +1142,18 @@
|
||||
Repo
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .LangSwitcherEnabled}}
|
||||
<div class="pages-lang-switcher">
|
||||
<button class="pages-lang-btn" onclick="this.nextElementSibling.classList.toggle('open')">
|
||||
{{svg "octicon-globe" 14}} {{index $.LanguageNames .ActiveLang}}
|
||||
</button>
|
||||
<div class="pages-lang-dropdown">
|
||||
{{range .AvailableLanguages}}
|
||||
<a href="?lang={{.}}" class="pages-lang-option{{if eq . $.ActiveLang}} active{{end}}">{{index $.LanguageNames .}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Config.Hero.PrimaryCTA.Label}}
|
||||
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="nb-btn-primary small">
|
||||
{{.Config.Hero.PrimaryCTA.Label}}
|
||||
|
||||
@@ -21,6 +21,18 @@
|
||||
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy"> View Source
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .LangSwitcherEnabled}}
|
||||
<div class="pages-lang-switcher">
|
||||
<button class="pages-lang-btn" onclick="this.nextElementSibling.classList.toggle('open')">
|
||||
{{svg "octicon-globe" 14}} {{index $.LanguageNames .ActiveLang}}
|
||||
</button>
|
||||
<div class="pages-lang-dropdown">
|
||||
{{range .AvailableLanguages}}
|
||||
<a href="?lang={{.}}" class="pages-lang-option{{if eq . $.ActiveLang}} active{{end}}">{{index $.LanguageNames .}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1009,6 +1009,18 @@
|
||||
Repository
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .LangSwitcherEnabled}}
|
||||
<div class="pages-lang-switcher">
|
||||
<button class="pages-lang-btn" onclick="this.nextElementSibling.classList.toggle('open')">
|
||||
{{svg "octicon-globe" 14}} {{index $.LanguageNames .ActiveLang}}
|
||||
</button>
|
||||
<div class="pages-lang-dropdown">
|
||||
{{range .AvailableLanguages}}
|
||||
<a href="?lang={{.}}" class="pages-lang-option{{if eq . $.ActiveLang}} active{{end}}">{{index $.LanguageNames .}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<button class="ea-mobile-toggle" onclick="document.getElementById('ea-mobile-nav').classList.toggle('open')">Menu</button>
|
||||
</nav>
|
||||
|
||||
@@ -1000,6 +1000,18 @@
|
||||
Repository
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .LangSwitcherEnabled}}
|
||||
<div class="pages-lang-switcher">
|
||||
<button class="pages-lang-btn" onclick="this.nextElementSibling.classList.toggle('open')">
|
||||
{{svg "octicon-globe" 14}} {{index $.LanguageNames .ActiveLang}}
|
||||
</button>
|
||||
<div class="pages-lang-dropdown">
|
||||
{{range .AvailableLanguages}}
|
||||
<a href="?lang={{.}}" class="pages-lang-option{{if eq . $.ActiveLang}} active{{end}}">{{index $.LanguageNames .}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<button class="osh-menu-toggle" onclick="this.parentElement.querySelector('.osh-nav-links').style.display=this.parentElement.querySelector('.osh-nav-links').style.display==='flex'?'none':'flex'">
|
||||
{{svg "octicon-three-bars" 20}}
|
||||
|
||||
@@ -1117,6 +1117,18 @@
|
||||
Repository
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .LangSwitcherEnabled}}
|
||||
<div class="pages-lang-switcher">
|
||||
<button class="pages-lang-btn" onclick="this.nextElementSibling.classList.toggle('open')">
|
||||
{{svg "octicon-globe" 14}} {{index $.LanguageNames .ActiveLang}}
|
||||
</button>
|
||||
<div class="pages-lang-dropdown">
|
||||
{{range .AvailableLanguages}}
|
||||
<a href="?lang={{.}}" class="pages-lang-option{{if eq . $.ActiveLang}} active{{end}}">{{index $.LanguageNames .}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<a href="{{if .Config.Hero.PrimaryCTA.URL}}{{.Config.Hero.PrimaryCTA.URL}}{{else}}{{.RepoURL}}{{end}}" class="gm-btn-primary" data-cta="primary" style="padding: 10px 20px; font-size: 13px;">
|
||||
<span>{{if .Config.Hero.PrimaryCTA.Label}}{{.Config.Hero.PrimaryCTA.Label}}{{else}}Get Started{{end}}</span>
|
||||
</a>
|
||||
|
||||
118
templates/repo/settings/pages_languages.tmpl
Normal file
118
templates/repo/settings/pages_languages.tmpl
Normal file
@@ -0,0 +1,118 @@
|
||||
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings pages")}}
|
||||
<div class="user-main-content twelve wide column">
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.languages"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form" method="post">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="action" value="update_i18n">
|
||||
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.default_lang"}}</label>
|
||||
<select name="default_lang" class="ui dropdown">
|
||||
{{range $code, $name := .LanguageNames}}
|
||||
<option value="{{$code}}" {{if eq $code $.Config.I18n.DefaultLang}}selected{{end}}>{{$name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.default_lang_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.enabled_languages"}}</label>
|
||||
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.enabled_languages_help"}}</p>
|
||||
<div class="grouped fields">
|
||||
{{range $code, $name := .LanguageNames}}
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="languages" value="{{$code}}" {{if $.IsLangEnabled $code}}checked{{end}}>
|
||||
<label>{{$name}} ({{$code}})</label>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.pages.save_languages"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{if .Config.I18n.Languages}}
|
||||
<h4 class="ui top attached header tw-mt-4">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.translations"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
{{range .Config.I18n.Languages}}
|
||||
{{if ne . $.Config.I18n.DefaultLang}}
|
||||
<div class="ui segment">
|
||||
<div class="tw-flex tw-justify-between tw-items-center tw-mb-4">
|
||||
<h5 class="tw-m-0">{{index $.LanguageNames .}} ({{.}})</h5>
|
||||
<div>
|
||||
{{if $.AIEnabled}}
|
||||
<form method="post" class="tw-inline-block">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<input type="hidden" name="action" value="ai_translate">
|
||||
<input type="hidden" name="target_lang" value="{{.}}">
|
||||
<button class="ui purple tiny button">{{ctx.Locale.Tr "repo.settings.pages.ai_translate"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if index $.TranslationMap .}}
|
||||
<form method="post" class="tw-inline-block">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<input type="hidden" name="action" value="delete_translation">
|
||||
<input type="hidden" name="target_lang" value="{{.}}">
|
||||
<button class="ui red tiny button">{{ctx.Locale.Tr "repo.settings.pages.delete_translation"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="ui form" method="post">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<input type="hidden" name="action" value="save_translation">
|
||||
<input type="hidden" name="target_lang" value="{{.}}">
|
||||
|
||||
{{$trans := index $.TranslationMap .}}
|
||||
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_headline"}}</label>
|
||||
<input name="trans_headline" value="{{if $trans}}{{$trans.Headline}}{{end}}" placeholder="{{$.Config.Hero.Headline}}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_subheadline"}}</label>
|
||||
<input name="trans_subheadline" value="{{if $trans}}{{$trans.Subheadline}}{{end}}" placeholder="{{$.Config.Hero.Subheadline}}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_primary_cta"}}</label>
|
||||
<input name="trans_primary_cta" value="{{if $trans}}{{$trans.PrimaryCTA}}{{end}}" placeholder="{{$.Config.Hero.PrimaryCTA.Label}}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_secondary_cta"}}</label>
|
||||
<input name="trans_secondary_cta" value="{{if $trans}}{{$trans.SecondaryCTA}}{{end}}" placeholder="{{$.Config.Hero.SecondaryCTA.Label}}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_cta_headline"}}</label>
|
||||
<input name="trans_cta_headline" value="{{if $trans}}{{$trans.CTAHeadline}}{{end}}" placeholder="{{$.Config.CTASection.Headline}}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_cta_subheadline"}}</label>
|
||||
<input name="trans_cta_subheadline" value="{{if $trans}}{{$trans.CTASubheadline}}{{end}}" placeholder="{{$.Config.CTASection.Subheadline}}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.settings.pages.trans_cta_button"}}</label>
|
||||
<input name="trans_cta_button" value="{{if $trans}}{{$trans.CTAButton}}{{end}}" placeholder="{{$.Config.CTASection.Button.Label}}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button class="ui primary tiny button">{{ctx.Locale.Tr "repo.settings.pages.save_translation"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{template "repo/settings/layout_footer" .}}
|
||||
@@ -24,5 +24,8 @@
|
||||
<a class="{{if .PageIsSettingsPagesTheme}}active {{end}}item" href="{{.RepoLink}}/settings/pages/theme">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.theme"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsPagesLanguages}}active {{end}}item" href="{{.RepoLink}}/settings/pages/languages">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.languages"}}
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user