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(364, "Add view_count to blog_post", v1_26.AddViewCountToBlogPost),
|
||||||
newMigration(365, "Add public_app_integration to repository", v1_26.AddPublicAppIntegrationToRepository),
|
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(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
|
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
|
// A/B testing experiments
|
||||||
Experiments ExperimentConfig `yaml:"experiments,omitempty"`
|
Experiments ExperimentConfig `yaml:"experiments,omitempty"`
|
||||||
|
|
||||||
|
// Multi-language support
|
||||||
|
I18n I18nConfig `yaml:"i18n,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BrandConfig represents brand/identity settings
|
// BrandConfig represents brand/identity settings
|
||||||
@@ -233,6 +236,33 @@ type ExperimentConfig struct {
|
|||||||
ApprovalRequired bool `yaml:"approval_required,omitempty"`
|
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
|
// AdvancedConfig represents advanced settings
|
||||||
type AdvancedConfig struct {
|
type AdvancedConfig struct {
|
||||||
CustomCSS string `yaml:"custom_css,omitempty"`
|
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_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_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.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": "Vault",
|
||||||
"repo.vault.secrets": "Secrets",
|
"repo.vault.secrets": "Secrets",
|
||||||
"repo.vault.new_secret": "New Secret",
|
"repo.vault.new_secret": "New Secret",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -74,18 +75,21 @@ func ServeLandingPage(ctx *context.Context) {
|
|||||||
idStr = strings.TrimRight(idStr, "/")
|
idStr = strings.TrimRight(idStr, "/")
|
||||||
blogID, err := strconv.ParseInt(idStr, 10, 64)
|
blogID, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
if err == nil && blogID > 0 {
|
if err == nil && blogID > 0 {
|
||||||
|
config = applyLanguageOverlay(ctx, repo, config)
|
||||||
serveBlogDetail(ctx, repo, config, blogID, "/blog")
|
serveBlogDetail(ctx, repo, config, blogID, "/blog")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if requestPath == "/blog" || requestPath == "/blog/" {
|
} else if requestPath == "/blog" || requestPath == "/blog/" {
|
||||||
|
config = applyLanguageOverlay(ctx, repo, config)
|
||||||
serveBlogList(ctx, repo, config, "/blog")
|
serveBlogList(ctx, repo, config, "/blog")
|
||||||
return
|
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"
|
ctx.Data["BlogBaseURL"] = "/blog"
|
||||||
config = assignVariant(ctx, repo, config)
|
config = assignVariant(ctx, repo, config)
|
||||||
|
config = applyLanguageOverlay(ctx, repo, config)
|
||||||
renderLandingPage(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)
|
ctx.Data["BlogBaseURL"] = fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
|
||||||
config = assignVariant(ctx, repo, config)
|
config = assignVariant(ctx, repo, config)
|
||||||
|
config = applyLanguageOverlay(ctx, repo, config)
|
||||||
renderLandingPage(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)
|
blogBaseURL := fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
|
||||||
|
config = applyLanguageOverlay(ctx, repo, config)
|
||||||
serveBlogList(ctx, repo, config, blogBaseURL)
|
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)
|
blogBaseURL := fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
|
||||||
|
config = applyLanguageOverlay(ctx, repo, config)
|
||||||
serveBlogDetail(ctx, repo, config, blogID, blogBaseURL)
|
serveBlogDetail(ctx, repo, config, blogID, blogBaseURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -828,6 +835,99 @@ func deepMerge(dst, src map[string]any) map[string]any {
|
|||||||
return dst
|
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
|
// ApproveExperiment handles the email approval link for an A/B test experiment
|
||||||
func ApproveExperiment(ctx *context.Context) {
|
func ApproveExperiment(ctx *context.Context) {
|
||||||
handleExperimentAction(ctx, true)
|
handleExperimentAction(ctx, true)
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ package setting
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
pages_model "code.gitcaddy.com/server/v3/models/pages"
|
||||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||||
"code.gitcaddy.com/server/v3/modules/ai"
|
"code.gitcaddy.com/server/v3/modules/ai"
|
||||||
"code.gitcaddy.com/server/v3/modules/git"
|
"code.gitcaddy.com/server/v3/modules/git"
|
||||||
@@ -20,14 +22,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
tplRepoSettingsPages templates.TplName = "repo/settings/pages"
|
tplRepoSettingsPages templates.TplName = "repo/settings/pages"
|
||||||
tplRepoSettingsPagesBrand templates.TplName = "repo/settings/pages_brand"
|
tplRepoSettingsPagesBrand templates.TplName = "repo/settings/pages_brand"
|
||||||
tplRepoSettingsPagesHero templates.TplName = "repo/settings/pages_hero"
|
tplRepoSettingsPagesHero templates.TplName = "repo/settings/pages_hero"
|
||||||
tplRepoSettingsPagesContent templates.TplName = "repo/settings/pages_content"
|
tplRepoSettingsPagesContent templates.TplName = "repo/settings/pages_content"
|
||||||
tplRepoSettingsPagesSocial templates.TplName = "repo/settings/pages_social"
|
tplRepoSettingsPagesSocial templates.TplName = "repo/settings/pages_social"
|
||||||
tplRepoSettingsPagesPricing templates.TplName = "repo/settings/pages_pricing"
|
tplRepoSettingsPagesPricing templates.TplName = "repo/settings/pages_pricing"
|
||||||
tplRepoSettingsPagesFooter templates.TplName = "repo/settings/pages_footer"
|
tplRepoSettingsPagesFooter templates.TplName = "repo/settings/pages_footer"
|
||||||
tplRepoSettingsPagesTheme templates.TplName = "repo/settings/pages_theme"
|
tplRepoSettingsPagesTheme templates.TplName = "repo/settings/pages_theme"
|
||||||
|
tplRepoSettingsPagesLanguages templates.TplName = "repo/settings/pages_languages"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getPagesLandingConfig loads the landing page configuration
|
// getPagesLandingConfig loads the landing page configuration
|
||||||
@@ -489,6 +492,248 @@ func PagesThemePost(ctx *context.Context) {
|
|||||||
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/theme")
|
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
|
// loadRawReadme loads the raw README content from the repository for AI consumption
|
||||||
func loadRawReadme(ctx *context.Context, repo *repo_model.Repository) string {
|
func loadRawReadme(ctx *context.Context, repo *repo_model.Repository) string {
|
||||||
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
|
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("/pricing").Get(repo_setting.PagesPricing).Post(repo_setting.PagesPricingPost)
|
||||||
m.Combo("/footer").Get(repo_setting.PagesFooter).Post(repo_setting.PagesFooterPost)
|
m.Combo("/footer").Get(repo_setting.PagesFooter).Post(repo_setting.PagesFooterPost)
|
||||||
m.Combo("/theme").Get(repo_setting.PagesTheme).Post(repo_setting.PagesThemePost)
|
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.Group("/actions/general", func() {
|
||||||
m.Get("", repo_setting.ActionsGeneralSettings)
|
m.Get("", repo_setting.ActionsGeneralSettings)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||||
@@ -34,8 +35,8 @@ type aiGeneratedConfig struct {
|
|||||||
} `json:"secondary_cta"`
|
} `json:"secondary_cta"`
|
||||||
} `json:"hero"`
|
} `json:"hero"`
|
||||||
Stats []pages_module.StatConfig `json:"stats"`
|
Stats []pages_module.StatConfig `json:"stats"`
|
||||||
ValueProps []pages_module.ValuePropConfig `json:"value_props"`
|
ValueProps []pages_module.ValuePropConfig `json:"value_props"`
|
||||||
Features []pages_module.FeatureConfig `json:"features"`
|
Features []pages_module.FeatureConfig `json:"features"`
|
||||||
CTASection struct {
|
CTASection struct {
|
||||||
Headline string `json:"headline"`
|
Headline string `json:"headline"`
|
||||||
Subheadline string `json:"subheadline"`
|
Subheadline string `json:"subheadline"`
|
||||||
@@ -66,9 +67,9 @@ func GenerateLandingPageContent(ctx context.Context, repo *repo_model.Repository
|
|||||||
"repo_description": repo.Description,
|
"repo_description": repo.Description,
|
||||||
"repo_url": repoURL,
|
"repo_url": repoURL,
|
||||||
"topics": topics,
|
"topics": topics,
|
||||||
"primary_language": repo.PrimaryLanguage,
|
"primary_language": getPrimaryLanguageName(repo),
|
||||||
"stars": fmt.Sprintf("%d", repo.NumStars),
|
"stars": strconv.Itoa(repo.NumStars),
|
||||||
"forks": fmt.Sprintf("%d", repo.NumForks),
|
"forks": strconv.Itoa(repo.NumForks),
|
||||||
"readme": truncateReadme(readme),
|
"readme": truncateReadme(readme),
|
||||||
"instruction": `You are a landing page copywriter. Analyze this open-source repository and generate compelling landing page content.
|
"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
|
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
|
// truncateReadme limits README content to avoid sending too much to the AI
|
||||||
func truncateReadme(readme string) string {
|
func truncateReadme(readme string) string {
|
||||||
const maxLen = 4000
|
const maxLen = 4000
|
||||||
@@ -173,3 +182,103 @@ func truncateReadme(readme string) string {
|
|||||||
}
|
}
|
||||||
return readme[:maxLen] + "\n... (truncated)"
|
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>
|
<!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>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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>
|
<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}}">
|
<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}}
|
{{if .Config.Brand.FaviconURL}}
|
||||||
<link rel="icon" href="{{.Config.Brand.FaviconURL}}">
|
<link rel="icon" href="{{.Config.Brand.FaviconURL}}">
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -169,6 +172,49 @@
|
|||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
.pages-footer-powered a { color: #79b8ff; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="pages-body">
|
<body class="pages-body">
|
||||||
|
|||||||
@@ -1142,6 +1142,18 @@
|
|||||||
Repo
|
Repo
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{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}}
|
{{if .Config.Hero.PrimaryCTA.Label}}
|
||||||
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="nb-btn-primary small">
|
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="nb-btn-primary small">
|
||||||
{{.Config.Hero.PrimaryCTA.Label}}
|
{{.Config.Hero.PrimaryCTA.Label}}
|
||||||
|
|||||||
@@ -21,6 +21,18 @@
|
|||||||
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy"> View Source
|
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy"> View Source
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1009,6 +1009,18 @@
|
|||||||
Repository
|
Repository
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{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>
|
</div>
|
||||||
<button class="ea-mobile-toggle" onclick="document.getElementById('ea-mobile-nav').classList.toggle('open')">Menu</button>
|
<button class="ea-mobile-toggle" onclick="document.getElementById('ea-mobile-nav').classList.toggle('open')">Menu</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1000,6 +1000,18 @@
|
|||||||
Repository
|
Repository
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{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>
|
</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'">
|
<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}}
|
{{svg "octicon-three-bars" 20}}
|
||||||
|
|||||||
@@ -1117,6 +1117,18 @@
|
|||||||
Repository
|
Repository
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{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;">
|
<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>
|
<span>{{if .Config.Hero.PrimaryCTA.Label}}{{.Config.Hero.PrimaryCTA.Label}}{{else}}Get Started{{end}}</span>
|
||||||
</a>
|
</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">
|
<a class="{{if .PageIsSettingsPagesTheme}}active {{end}}item" href="{{.RepoLink}}/settings/pages/theme">
|
||||||
{{ctx.Locale.Tr "repo.settings.pages.theme"}}
|
{{ctx.Locale.Tr "repo.settings.pages.theme"}}
|
||||||
</a>
|
</a>
|
||||||
|
<a class="{{if .PageIsSettingsPagesLanguages}}active {{end}}item" href="{{.RepoLink}}/settings/pages/languages">
|
||||||
|
{{ctx.Locale.Tr "repo.settings.pages.languages"}}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user