diff --git a/modules/errors/codes.go b/modules/errors/codes.go index 0091dcab15..f71a1b88e0 100644 --- a/modules/errors/codes.go +++ b/modules/errors/codes.go @@ -170,6 +170,13 @@ const ( WishlistVoteBudget ErrorCode = "WISHLIST_VOTE_BUDGET_EXCEEDED" ) +// Pages errors (PAGES_) +const ( + PagesNotConfigured ErrorCode = "PAGES_NOT_CONFIGURED" + PagesNotEnabled ErrorCode = "PAGES_NOT_ENABLED" + PagesInvalidTemplate ErrorCode = "PAGES_INVALID_TEMPLATE" +) + // AI errors (AI_) const ( AIDisabled ErrorCode = "AI_DISABLED" @@ -310,6 +317,11 @@ var errorCatalog = map[ErrorCode]errorInfo{ WishlistDisabled: {"Wishlist is disabled for this repository", http.StatusForbidden}, WishlistVoteBudget: {"Vote budget exceeded for this repository", http.StatusConflict}, + // Pages errors + PagesNotConfigured: {"Landing page is not configured for this repository", http.StatusNotFound}, + PagesNotEnabled: {"Landing page is not enabled for this repository", http.StatusForbidden}, + PagesInvalidTemplate: {"Invalid landing page template name", http.StatusBadRequest}, + // AI errors AIDisabled: {"AI features are disabled", http.StatusForbidden}, AIUnitNotEnabled: {"AI unit is not enabled for this repository", http.StatusForbidden}, diff --git a/modules/structs/repo_pages.go b/modules/structs/repo_pages.go index 02ca398f8a..fa96ef2800 100644 --- a/modules/structs/repo_pages.go +++ b/modules/structs/repo_pages.go @@ -47,3 +47,230 @@ type PagesInfo struct { Config *PagesConfig `json:"config"` Domains []*PagesDomain `json:"domains,omitempty"` } + +// --------------------------------------------------------------------------- +// v2 Landing Page Configuration API structs +// --------------------------------------------------------------------------- + +// UpdatePagesConfigOption represents the full landing page config update. +// For PATCH, only non-nil fields are applied. For PUT, all fields replace existing config. +type UpdatePagesConfigOption struct { + Enabled *bool `json:"enabled"` + PublicLanding *bool `json:"public_landing"` + Template *string `json:"template"` + Brand *UpdatePagesBrandOption `json:"brand"` + Hero *UpdatePagesHeroOption `json:"hero"` + Stats *[]PagesStatOption `json:"stats"` + ValueProps *[]PagesValuePropOption `json:"value_props"` + Features *[]PagesFeatureOption `json:"features"` + SocialProof *UpdatePagesSocialOption `json:"social_proof"` + Pricing *UpdatePagesPricingOption `json:"pricing"` + CTASection *UpdatePagesCTAOption `json:"cta_section"` + Blog *UpdatePagesBlogOption `json:"blog"` + Gallery *UpdatePagesGalleryOption `json:"gallery"` + Comparison *UpdatePagesComparisonOption `json:"comparison"` + Navigation *UpdatePagesNavOption `json:"navigation"` + Footer *UpdatePagesFooterOption `json:"footer"` + Theme *UpdatePagesThemeOption `json:"theme"` + SEO *UpdatePagesSEOOption `json:"seo"` + Advanced *UpdatePagesAdvancedOption `json:"advanced"` +} + +// UpdatePagesBrandOption represents brand section update +type UpdatePagesBrandOption struct { + Name *string `json:"name"` + LogoURL *string `json:"logo_url"` + Tagline *string `json:"tagline"` + FaviconURL *string `json:"favicon_url"` +} + +// UpdatePagesHeroOption represents hero section update +type UpdatePagesHeroOption struct { + Headline *string `json:"headline"` + Subheadline *string `json:"subheadline"` + ImageURL *string `json:"image_url"` + VideoURL *string `json:"video_url"` + CodeExample *string `json:"code_example"` + PrimaryCTA *PagesCTAButtonOption `json:"primary_cta"` + SecondaryCTA *PagesCTAButtonOption `json:"secondary_cta"` +} + +// PagesCTAButtonOption represents a CTA button +type PagesCTAButtonOption struct { + Label *string `json:"label"` + URL *string `json:"url"` + Variant *string `json:"variant"` +} + +// PagesStatOption represents a stat item +type PagesStatOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +// PagesValuePropOption represents a value proposition item +type PagesValuePropOption struct { + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` +} + +// PagesFeatureOption represents a feature item +type PagesFeatureOption struct { + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` + ImageURL string `json:"image_url"` +} + +// UpdatePagesSocialOption represents social proof section update +type UpdatePagesSocialOption struct { + Logos *[]string `json:"logos"` + Testimonials *[]PagesTestimonialOption `json:"testimonials"` +} + +// PagesTestimonialOption represents a testimonial item +type PagesTestimonialOption struct { + Quote string `json:"quote"` + Author string `json:"author"` + Role string `json:"role"` + Avatar string `json:"avatar"` +} + +// UpdatePagesPricingOption represents pricing section update +type UpdatePagesPricingOption struct { + Headline *string `json:"headline"` + Subheadline *string `json:"subheadline"` + Plans *[]PagesPricingPlanOption `json:"plans"` +} + +// PagesPricingPlanOption represents a pricing plan item +type PagesPricingPlanOption struct { + Name string `json:"name"` + Price string `json:"price"` + Period string `json:"period"` + Features []string `json:"features"` + CTA string `json:"cta"` + Featured bool `json:"featured"` +} + +// UpdatePagesCTAOption represents CTA section update +type UpdatePagesCTAOption struct { + Headline *string `json:"headline"` + Subheadline *string `json:"subheadline"` + Button *PagesCTAButtonOption `json:"button"` +} + +// UpdatePagesBlogOption represents blog section update +type UpdatePagesBlogOption struct { + Enabled *bool `json:"enabled"` + Headline *string `json:"headline"` + Subheadline *string `json:"subheadline"` + MaxPosts *int `json:"max_posts"` +} + +// UpdatePagesGalleryOption represents gallery section update +type UpdatePagesGalleryOption struct { + Enabled *bool `json:"enabled"` + Headline *string `json:"headline"` + Subheadline *string `json:"subheadline"` + MaxImages *int `json:"max_images"` + Columns *int `json:"columns"` +} + +// UpdatePagesComparisonOption represents comparison section update +type UpdatePagesComparisonOption struct { + Enabled *bool `json:"enabled"` + Headline *string `json:"headline"` + Subheadline *string `json:"subheadline"` + Columns *[]PagesComparisonColumnOption `json:"columns"` + Groups *[]PagesComparisonGroupOption `json:"groups"` +} + +// PagesComparisonColumnOption represents a comparison column +type PagesComparisonColumnOption struct { + Name string `json:"name"` + Highlight bool `json:"highlight"` +} + +// PagesComparisonGroupOption represents a comparison group +type PagesComparisonGroupOption struct { + Name string `json:"name"` + Features []PagesComparisonFeatureOption `json:"features"` +} + +// PagesComparisonFeatureOption represents a comparison feature row +type PagesComparisonFeatureOption struct { + Name string `json:"name"` + Values []string `json:"values"` +} + +// UpdatePagesNavOption represents navigation section update +type UpdatePagesNavOption struct { + ShowDocs *bool `json:"show_docs"` + ShowAPI *bool `json:"show_api"` + ShowRepository *bool `json:"show_repository"` + ShowReleases *bool `json:"show_releases"` + ShowIssues *bool `json:"show_issues"` +} + +// UpdatePagesFooterOption represents footer section update +type UpdatePagesFooterOption struct { + Copyright *string `json:"copyright"` + ShowPoweredBy *bool `json:"show_powered_by"` + Links *[]PagesFooterLinkOption `json:"links"` + Social *[]PagesSocialLinkOption `json:"social"` + CTASection *UpdatePagesCTAOption `json:"cta_section"` +} + +// PagesFooterLinkOption represents a footer link +type PagesFooterLinkOption struct { + Label string `json:"label"` + URL string `json:"url"` +} + +// PagesSocialLinkOption represents a social link +type PagesSocialLinkOption struct { + Platform string `json:"platform"` + URL string `json:"url"` +} + +// UpdatePagesThemeOption represents theme section update +type UpdatePagesThemeOption struct { + PrimaryColor *string `json:"primary_color"` + AccentColor *string `json:"accent_color"` + Mode *string `json:"mode"` +} + +// UpdatePagesSEOOption represents SEO section update +type UpdatePagesSEOOption struct { + Title *string `json:"title"` + Description *string `json:"description"` + Keywords *[]string `json:"keywords"` + OGImage *string `json:"og_image"` + UseMediaKitOG *bool `json:"use_media_kit_og"` + TwitterCard *string `json:"twitter_card"` + TwitterSite *string `json:"twitter_site"` +} + +// UpdatePagesAdvancedOption represents advanced settings update +type UpdatePagesAdvancedOption struct { + CustomCSS *string `json:"custom_css"` + CustomHead *string `json:"custom_head"` + PublicReleases *bool `json:"public_releases"` + HideMobileReleases *bool `json:"hide_mobile_releases"` + GooglePlayID *string `json:"google_play_id"` + AppStoreID *string `json:"app_store_id"` +} + +// UpdatePagesContentOption bundles content-page sections for PUT /config/content +type UpdatePagesContentOption struct { + Blog *UpdatePagesBlogOption `json:"blog"` + Gallery *UpdatePagesGalleryOption `json:"gallery"` + ComparisonEnabled *bool `json:"comparison_enabled"` + Stats *[]PagesStatOption `json:"stats"` + ValueProps *[]PagesValuePropOption `json:"value_props"` + Features *[]PagesFeatureOption `json:"features"` + Navigation *UpdatePagesNavOption `json:"navigation"` + Advanced *UpdatePagesAdvancedOption `json:"advanced"` +} diff --git a/routers/api/v2/api.go b/routers/api/v2/api.go index 708573e2a2..34a98590fe 100644 --- a/routers/api/v2/api.go +++ b/routers/api/v2/api.go @@ -171,10 +171,25 @@ func Routes() *web.Router { // Upload instructions endpoint m.Get("/upload/instructions", GetUploadInstructions) - // Public landing page API - for private repos with public_landing enabled + // Landing page API m.Group("/repos/{owner}/{repo}/pages", func() { + // Public read endpoints m.Get("/config", repoAssignmentWithPublicAccess(), GetPagesConfig) m.Get("/content", repoAssignmentWithPublicAccess(), GetPagesContent) + + // Write endpoints (require auth + repo admin) + m.Group("/config", func() { + m.Put("", web.Bind(api.UpdatePagesConfigOption{}), UpdatePagesConfig) + m.Patch("", web.Bind(api.UpdatePagesConfigOption{}), PatchPagesConfig) + m.Put("/brand", web.Bind(api.UpdatePagesBrandOption{}), UpdatePagesBrand) + m.Put("/hero", web.Bind(api.UpdatePagesHeroOption{}), UpdatePagesHero) + m.Put("/content", web.Bind(api.UpdatePagesContentOption{}), UpdatePagesContentSection) + m.Put("/comparison", web.Bind(api.UpdatePagesComparisonOption{}), UpdatePagesComparison) + m.Put("/social", web.Bind(api.UpdatePagesSocialOption{}), UpdatePagesSocial) + m.Put("/pricing", web.Bind(api.UpdatePagesPricingOption{}), UpdatePagesPricing) + m.Put("/footer", web.Bind(api.UpdatePagesFooterOption{}), UpdatePagesFooter) + m.Put("/theme", web.Bind(api.UpdatePagesThemeOption{}), UpdatePagesTheme) + }, repoAssignment(), reqToken()) }) // Blog v2 API - repository blog endpoints diff --git a/routers/api/v2/pages_api.go b/routers/api/v2/pages_api.go index 9023887f65..e9cedae72f 100644 --- a/routers/api/v2/pages_api.go +++ b/routers/api/v2/pages_api.go @@ -4,25 +4,42 @@ package v2 import ( + "encoding/json" "net/http" repo_model "code.gitcaddy.com/server/v3/models/repo" + apierrors "code.gitcaddy.com/server/v3/modules/errors" "code.gitcaddy.com/server/v3/modules/git" pages_module "code.gitcaddy.com/server/v3/modules/pages" + api "code.gitcaddy.com/server/v3/modules/structs" + "code.gitcaddy.com/server/v3/modules/web" "code.gitcaddy.com/server/v3/services/context" pages_service "code.gitcaddy.com/server/v3/services/pages" ) -// PagesConfigResponse represents the pages configuration for a repository -type PagesConfigResponse struct { - Enabled bool `json:"enabled"` - PublicLanding bool `json:"public_landing"` - Template string `json:"template"` - Domain string `json:"domain,omitempty"` - Brand pages_module.BrandConfig `json:"brand"` - Hero pages_module.HeroConfig `json:"hero"` - SEO pages_module.SEOConfig `json:"seo"` - Footer pages_module.FooterConfig `json:"footer"` +// PagesFullConfigResponse represents the complete landing page configuration +type PagesFullConfigResponse struct { + Enabled bool `json:"enabled"` + PublicLanding bool `json:"public_landing"` + Template string `json:"template"` + Domain string `json:"domain,omitempty"` + Brand pages_module.BrandConfig `json:"brand"` + Hero pages_module.HeroConfig `json:"hero"` + Stats []pages_module.StatConfig `json:"stats"` + ValueProps []pages_module.ValuePropConfig `json:"value_props"` + Features []pages_module.FeatureConfig `json:"features"` + SocialProof pages_module.SocialProofConfig `json:"social_proof"` + Pricing pages_module.PricingConfig `json:"pricing"` + CTASection pages_module.CTASectionConfig `json:"cta_section"` + Blog pages_module.BlogSectionConfig `json:"blog"` + Gallery pages_module.GallerySectionConfig `json:"gallery"` + Comparison pages_module.ComparisonSectionConfig `json:"comparison"` + Navigation pages_module.NavigationConfig `json:"navigation"` + Footer pages_module.FooterConfig `json:"footer"` + Theme pages_module.ThemeConfig `json:"theme"` + SEO pages_module.SEOConfig `json:"seo"` + Analytics pages_module.AnalyticsConfig `json:"analytics"` + Advanced pages_module.AdvancedConfig `json:"advanced"` } // PagesContentResponse represents the rendered content for a landing page @@ -32,7 +49,430 @@ type PagesContentResponse struct { Readme string `json:"readme,omitempty"` } -// GetPagesConfig returns the pages configuration for a repository +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +func buildFullResponse(config *pages_module.LandingConfig) *PagesFullConfigResponse { + return &PagesFullConfigResponse{ + Enabled: config.Enabled, + PublicLanding: config.PublicLanding, + Template: config.Template, + Domain: config.Domain, + Brand: config.Brand, + Hero: config.Hero, + Stats: config.Stats, + ValueProps: config.ValueProps, + Features: config.Features, + SocialProof: config.SocialProof, + Pricing: config.Pricing, + CTASection: config.CTASection, + Blog: config.Blog, + Gallery: config.Gallery, + Comparison: config.Comparison, + Navigation: config.Navigation, + Footer: config.Footer, + Theme: config.Theme, + SEO: config.SEO, + Analytics: config.Analytics, + Advanced: config.Advanced, + } +} + +func getPagesConfigAPI(ctx *context.APIContext) (*pages_module.LandingConfig, bool) { + config, err := pages_service.GetPagesConfig(ctx, ctx.Repo.Repository) + if err != nil { + ctx.APIErrorWithCode(apierrors.PagesNotConfigured) + return nil, false + } + return config, true +} + +func savePagesConfigAPI(ctx *context.APIContext, config *pages_module.LandingConfig) bool { + configJSON, err := json.Marshal(config) + if err != nil { + ctx.APIErrorInternal(err) + return false + } + + dbConfig, err := repo_model.GetPagesConfigByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + if repo_model.IsErrPagesConfigNotExist(err) { + dbConfig = &repo_model.PagesConfig{ + RepoID: ctx.Repo.Repository.ID, + Enabled: config.Enabled, + Template: repo_model.PagesTemplate(config.Template), + ConfigJSON: string(configJSON), + } + if err := repo_model.CreatePagesConfig(ctx, dbConfig); err != nil { + ctx.APIErrorInternal(err) + return false + } + return true + } + ctx.APIErrorInternal(err) + return false + } + + dbConfig.Enabled = config.Enabled + dbConfig.Template = repo_model.PagesTemplate(config.Template) + dbConfig.ConfigJSON = string(configJSON) + if err := repo_model.UpdatePagesConfig(ctx, dbConfig); err != nil { + ctx.APIErrorInternal(err) + return false + } + return true +} + +func requirePagesAdmin(ctx *context.APIContext) bool { + if !ctx.Repo.Permission.IsAdmin() && !ctx.IsUserSiteAdmin() { + ctx.APIErrorWithCode(apierrors.PermRepoAdminRequired) + return false + } + return true +} + +// --------------------------------------------------------------------------- +// Apply helpers — map API option structs to config structs +// --------------------------------------------------------------------------- + +func applyCTAButton(dst *pages_module.CTAButton, src *api.PagesCTAButtonOption) { + if src == nil { + return + } + if src.Label != nil { + dst.Label = *src.Label + } + if src.URL != nil { + dst.URL = *src.URL + } + if src.Variant != nil { + dst.Variant = *src.Variant + } +} + +func applyBrand(dst *pages_module.BrandConfig, src *api.UpdatePagesBrandOption) { + if src.Name != nil { + dst.Name = *src.Name + } + if src.LogoURL != nil { + dst.LogoURL = *src.LogoURL + } + if src.Tagline != nil { + dst.Tagline = *src.Tagline + } + if src.FaviconURL != nil { + dst.FaviconURL = *src.FaviconURL + } +} + +func applyHero(dst *pages_module.HeroConfig, src *api.UpdatePagesHeroOption) { + if src.Headline != nil { + dst.Headline = *src.Headline + } + if src.Subheadline != nil { + dst.Subheadline = *src.Subheadline + } + if src.ImageURL != nil { + dst.ImageURL = *src.ImageURL + } + if src.VideoURL != nil { + dst.VideoURL = *src.VideoURL + } + if src.CodeExample != nil { + dst.CodeExample = *src.CodeExample + } + applyCTAButton(&dst.PrimaryCTA, src.PrimaryCTA) + applyCTAButton(&dst.SecondaryCTA, src.SecondaryCTA) +} + +func applyStats(config *pages_module.LandingConfig, src []api.PagesStatOption) { + config.Stats = make([]pages_module.StatConfig, len(src)) + for i, s := range src { + config.Stats[i] = pages_module.StatConfig{Value: s.Value, Label: s.Label} + } +} + +func applyValueProps(config *pages_module.LandingConfig, src []api.PagesValuePropOption) { + config.ValueProps = make([]pages_module.ValuePropConfig, len(src)) + for i, v := range src { + config.ValueProps[i] = pages_module.ValuePropConfig{Title: v.Title, Description: v.Description, Icon: v.Icon} + } +} + +func applyFeatures(config *pages_module.LandingConfig, src []api.PagesFeatureOption) { + config.Features = make([]pages_module.FeatureConfig, len(src)) + for i, f := range src { + config.Features[i] = pages_module.FeatureConfig{Title: f.Title, Description: f.Description, Icon: f.Icon, ImageURL: f.ImageURL} + } +} + +func applySocial(dst *pages_module.SocialProofConfig, src *api.UpdatePagesSocialOption) { + if src.Logos != nil { + dst.Logos = *src.Logos + } + if src.Testimonials != nil { + dst.Testimonials = make([]pages_module.TestimonialConfig, len(*src.Testimonials)) + for i, t := range *src.Testimonials { + dst.Testimonials[i] = pages_module.TestimonialConfig{Quote: t.Quote, Author: t.Author, Role: t.Role, Avatar: t.Avatar} + } + } +} + +func applyPricing(dst *pages_module.PricingConfig, src *api.UpdatePagesPricingOption) { + if src.Headline != nil { + dst.Headline = *src.Headline + } + if src.Subheadline != nil { + dst.Subheadline = *src.Subheadline + } + if src.Plans != nil { + dst.Plans = make([]pages_module.PricingPlanConfig, len(*src.Plans)) + for i, p := range *src.Plans { + dst.Plans[i] = pages_module.PricingPlanConfig{ + Name: p.Name, Price: p.Price, Period: p.Period, + Features: p.Features, CTA: p.CTA, Featured: p.Featured, + } + } + } +} + +func applyCTASection(dst *pages_module.CTASectionConfig, src *api.UpdatePagesCTAOption) { + if src.Headline != nil { + dst.Headline = *src.Headline + } + if src.Subheadline != nil { + dst.Subheadline = *src.Subheadline + } + applyCTAButton(&dst.Button, src.Button) +} + +func applyBlog(dst *pages_module.BlogSectionConfig, src *api.UpdatePagesBlogOption) { + if src.Enabled != nil { + dst.Enabled = *src.Enabled + } + if src.Headline != nil { + dst.Headline = *src.Headline + } + if src.Subheadline != nil { + dst.Subheadline = *src.Subheadline + } + if src.MaxPosts != nil { + dst.MaxPosts = *src.MaxPosts + } +} + +func applyGallery(dst *pages_module.GallerySectionConfig, src *api.UpdatePagesGalleryOption) { + if src.Enabled != nil { + dst.Enabled = *src.Enabled + } + if src.Headline != nil { + dst.Headline = *src.Headline + } + if src.Subheadline != nil { + dst.Subheadline = *src.Subheadline + } + if src.MaxImages != nil { + dst.MaxImages = *src.MaxImages + } + if src.Columns != nil { + dst.Columns = *src.Columns + } +} + +func applyComparison(dst *pages_module.ComparisonSectionConfig, src *api.UpdatePagesComparisonOption) { + if src.Enabled != nil { + dst.Enabled = *src.Enabled + } + if src.Headline != nil { + dst.Headline = *src.Headline + } + if src.Subheadline != nil { + dst.Subheadline = *src.Subheadline + } + if src.Columns != nil { + dst.Columns = make([]pages_module.ComparisonColumnConfig, len(*src.Columns)) + for i, c := range *src.Columns { + dst.Columns[i] = pages_module.ComparisonColumnConfig{Name: c.Name, Highlight: c.Highlight} + } + } + if src.Groups != nil { + dst.Groups = make([]pages_module.ComparisonGroupConfig, len(*src.Groups)) + for i, g := range *src.Groups { + features := make([]pages_module.ComparisonFeatureConfig, len(g.Features)) + for j, f := range g.Features { + features[j] = pages_module.ComparisonFeatureConfig{Name: f.Name, Values: f.Values} + } + dst.Groups[i] = pages_module.ComparisonGroupConfig{Name: g.Name, Features: features} + } + } +} + +func applyNavigation(dst *pages_module.NavigationConfig, src *api.UpdatePagesNavOption) { + if src.ShowDocs != nil { + dst.ShowDocs = *src.ShowDocs + } + if src.ShowAPI != nil { + dst.ShowAPI = *src.ShowAPI + } + if src.ShowRepository != nil { + dst.ShowRepository = *src.ShowRepository + } + if src.ShowReleases != nil { + dst.ShowReleases = *src.ShowReleases + } + if src.ShowIssues != nil { + dst.ShowIssues = *src.ShowIssues + } +} + +func applyFooter(dst *pages_module.FooterConfig, ctaDst *pages_module.CTASectionConfig, src *api.UpdatePagesFooterOption) { + if src.Copyright != nil { + dst.Copyright = *src.Copyright + } + if src.ShowPoweredBy != nil { + dst.ShowPoweredBy = *src.ShowPoweredBy + } + if src.Links != nil { + dst.Links = make([]pages_module.FooterLink, len(*src.Links)) + for i, l := range *src.Links { + dst.Links[i] = pages_module.FooterLink{Label: l.Label, URL: l.URL} + } + } + if src.Social != nil { + dst.Social = make([]pages_module.SocialLink, len(*src.Social)) + for i, s := range *src.Social { + dst.Social[i] = pages_module.SocialLink{Platform: s.Platform, URL: s.URL} + } + } + if src.CTASection != nil { + applyCTASection(ctaDst, src.CTASection) + } +} + +func applyTheme(dst *pages_module.ThemeConfig, src *api.UpdatePagesThemeOption) { + if src.PrimaryColor != nil { + dst.PrimaryColor = *src.PrimaryColor + } + if src.AccentColor != nil { + dst.AccentColor = *src.AccentColor + } + if src.Mode != nil { + dst.Mode = *src.Mode + } +} + +func applySEO(dst *pages_module.SEOConfig, src *api.UpdatePagesSEOOption) { + if src.Title != nil { + dst.Title = *src.Title + } + if src.Description != nil { + dst.Description = *src.Description + } + if src.Keywords != nil { + dst.Keywords = *src.Keywords + } + if src.OGImage != nil { + dst.OGImage = *src.OGImage + } + if src.UseMediaKitOG != nil { + dst.UseMediaKitOG = *src.UseMediaKitOG + } + if src.TwitterCard != nil { + dst.TwitterCard = *src.TwitterCard + } + if src.TwitterSite != nil { + dst.TwitterSite = *src.TwitterSite + } +} + +func applyAdvanced(dst *pages_module.AdvancedConfig, src *api.UpdatePagesAdvancedOption) { + if src.CustomCSS != nil { + dst.CustomCSS = *src.CustomCSS + } + if src.CustomHead != nil { + dst.CustomHead = *src.CustomHead + } + if src.PublicReleases != nil { + dst.PublicReleases = *src.PublicReleases + } + if src.HideMobileReleases != nil { + dst.HideMobileReleases = *src.HideMobileReleases + } + if src.GooglePlayID != nil { + dst.GooglePlayID = *src.GooglePlayID + } + if src.AppStoreID != nil { + dst.AppStoreID = *src.AppStoreID + } +} + +// applyFullConfig applies all non-nil sections from the update option to the config +func applyFullConfig(config *pages_module.LandingConfig, form *api.UpdatePagesConfigOption) { + if form.Enabled != nil { + config.Enabled = *form.Enabled + } + if form.PublicLanding != nil { + config.PublicLanding = *form.PublicLanding + } + if form.Template != nil { + config.Template = *form.Template + } + if form.Brand != nil { + applyBrand(&config.Brand, form.Brand) + } + if form.Hero != nil { + applyHero(&config.Hero, form.Hero) + } + if form.Stats != nil { + applyStats(config, *form.Stats) + } + if form.ValueProps != nil { + applyValueProps(config, *form.ValueProps) + } + if form.Features != nil { + applyFeatures(config, *form.Features) + } + if form.SocialProof != nil { + applySocial(&config.SocialProof, form.SocialProof) + } + if form.Pricing != nil { + applyPricing(&config.Pricing, form.Pricing) + } + if form.CTASection != nil { + applyCTASection(&config.CTASection, form.CTASection) + } + if form.Blog != nil { + applyBlog(&config.Blog, form.Blog) + } + if form.Gallery != nil { + applyGallery(&config.Gallery, form.Gallery) + } + if form.Comparison != nil { + applyComparison(&config.Comparison, form.Comparison) + } + if form.Navigation != nil { + applyNavigation(&config.Navigation, form.Navigation) + } + if form.Footer != nil { + applyFooter(&config.Footer, &config.CTASection, form.Footer) + } + if form.Theme != nil { + applyTheme(&config.Theme, form.Theme) + } + if form.SEO != nil { + applySEO(&config.SEO, form.SEO) + } + if form.Advanced != nil { + applyAdvanced(&config.Advanced, form.Advanced) + } +} + +// --------------------------------------------------------------------------- +// GET endpoints +// --------------------------------------------------------------------------- + +// GetPagesConfig returns the full pages configuration for a repository // GET /api/v2/repos/{owner}/{repo}/pages/config func GetPagesConfig(ctx *context.APIContext) { repo := ctx.Repo.Repository @@ -43,22 +483,11 @@ func GetPagesConfig(ctx *context.APIContext) { config, err := pages_service.GetPagesConfig(ctx, repo) if err != nil { - ctx.APIErrorNotFound("Pages not configured") + ctx.APIErrorWithCode(apierrors.PagesNotConfigured) return } - response := &PagesConfigResponse{ - Enabled: config.Enabled, - PublicLanding: config.PublicLanding, - Template: config.Template, - Domain: config.Domain, - Brand: config.Brand, - Hero: config.Hero, - SEO: config.SEO, - Footer: config.Footer, - } - - ctx.JSON(http.StatusOK, response) + ctx.JSON(http.StatusOK, buildFullResponse(config)) } // GetPagesContent returns the rendered content for a repository's landing page @@ -72,14 +501,12 @@ func GetPagesContent(ctx *context.APIContext) { config, err := pages_service.GetPagesConfig(ctx, repo) if err != nil || !config.Enabled { - ctx.APIErrorNotFound("Pages not enabled") + ctx.APIErrorWithCode(apierrors.PagesNotEnabled) return } - // Load README content readme := loadReadmeContent(ctx, repo) - // Build title title := config.SEO.Title if title == "" { title = config.Hero.Headline @@ -91,7 +518,6 @@ func GetPagesContent(ctx *context.APIContext) { title = repo.Name } - // Build description description := config.SEO.Description if description == "" { description = config.Hero.Subheadline @@ -100,15 +526,265 @@ func GetPagesContent(ctx *context.APIContext) { description = repo.Description } - response := &PagesContentResponse{ + ctx.JSON(http.StatusOK, &PagesContentResponse{ Title: title, Description: description, Readme: readme, + }) +} + +// --------------------------------------------------------------------------- +// PUT /config — replace full config +// --------------------------------------------------------------------------- + +// UpdatePagesConfig replaces the entire landing page configuration +// PUT /api/v2/repos/{owner}/{repo}/pages/config +func UpdatePagesConfig(ctx *context.APIContext) { + if !requirePagesAdmin(ctx) { + return + } + config, ok := getPagesConfigAPI(ctx) + if !ok { + return } - ctx.JSON(http.StatusOK, response) + form := web.GetForm(ctx).(*api.UpdatePagesConfigOption) + + if form.Template != nil && !pages_module.IsValidTemplate(*form.Template) { + ctx.APIErrorWithCode(apierrors.PagesInvalidTemplate) + return + } + + applyFullConfig(config, form) + + if !savePagesConfigAPI(ctx, config) { + return + } + ctx.JSON(http.StatusOK, buildFullResponse(config)) } +// --------------------------------------------------------------------------- +// PATCH /config — partial merge +// --------------------------------------------------------------------------- + +// PatchPagesConfig partially updates the landing page configuration +// PATCH /api/v2/repos/{owner}/{repo}/pages/config +func PatchPagesConfig(ctx *context.APIContext) { + if !requirePagesAdmin(ctx) { + return + } + config, ok := getPagesConfigAPI(ctx) + if !ok { + return + } + + form := web.GetForm(ctx).(*api.UpdatePagesConfigOption) + + if form.Template != nil && !pages_module.IsValidTemplate(*form.Template) { + ctx.APIErrorWithCode(apierrors.PagesInvalidTemplate) + return + } + + applyFullConfig(config, form) + + if !savePagesConfigAPI(ctx, config) { + return + } + ctx.JSON(http.StatusOK, buildFullResponse(config)) +} + +// --------------------------------------------------------------------------- +// Section PUT endpoints +// --------------------------------------------------------------------------- + +// UpdatePagesBrand updates the brand section +// PUT /api/v2/repos/{owner}/{repo}/pages/config/brand +func UpdatePagesBrand(ctx *context.APIContext) { + if !requirePagesAdmin(ctx) { + return + } + config, ok := getPagesConfigAPI(ctx) + if !ok { + return + } + + form := web.GetForm(ctx).(*api.UpdatePagesBrandOption) + applyBrand(&config.Brand, form) + + if !savePagesConfigAPI(ctx, config) { + return + } + ctx.JSON(http.StatusOK, buildFullResponse(config)) +} + +// UpdatePagesHero updates the hero section +// PUT /api/v2/repos/{owner}/{repo}/pages/config/hero +func UpdatePagesHero(ctx *context.APIContext) { + if !requirePagesAdmin(ctx) { + return + } + config, ok := getPagesConfigAPI(ctx) + if !ok { + return + } + + form := web.GetForm(ctx).(*api.UpdatePagesHeroOption) + applyHero(&config.Hero, form) + + if !savePagesConfigAPI(ctx, config) { + return + } + ctx.JSON(http.StatusOK, buildFullResponse(config)) +} + +// UpdatePagesContentSection updates the content section (blog, gallery, stats, features, nav, etc.) +// PUT /api/v2/repos/{owner}/{repo}/pages/config/content +func UpdatePagesContentSection(ctx *context.APIContext) { + if !requirePagesAdmin(ctx) { + return + } + config, ok := getPagesConfigAPI(ctx) + if !ok { + return + } + + form := web.GetForm(ctx).(*api.UpdatePagesContentOption) + + if form.Blog != nil { + applyBlog(&config.Blog, form.Blog) + } + if form.Gallery != nil { + applyGallery(&config.Gallery, form.Gallery) + } + if form.ComparisonEnabled != nil { + config.Comparison.Enabled = *form.ComparisonEnabled + } + if form.Stats != nil { + applyStats(config, *form.Stats) + } + if form.ValueProps != nil { + applyValueProps(config, *form.ValueProps) + } + if form.Features != nil { + applyFeatures(config, *form.Features) + } + if form.Navigation != nil { + applyNavigation(&config.Navigation, form.Navigation) + } + if form.Advanced != nil { + applyAdvanced(&config.Advanced, form.Advanced) + } + + if !savePagesConfigAPI(ctx, config) { + return + } + ctx.JSON(http.StatusOK, buildFullResponse(config)) +} + +// UpdatePagesComparison updates the comparison section +// PUT /api/v2/repos/{owner}/{repo}/pages/config/comparison +func UpdatePagesComparison(ctx *context.APIContext) { + if !requirePagesAdmin(ctx) { + return + } + config, ok := getPagesConfigAPI(ctx) + if !ok { + return + } + + form := web.GetForm(ctx).(*api.UpdatePagesComparisonOption) + applyComparison(&config.Comparison, form) + + if !savePagesConfigAPI(ctx, config) { + return + } + ctx.JSON(http.StatusOK, buildFullResponse(config)) +} + +// UpdatePagesSocial updates the social proof section +// PUT /api/v2/repos/{owner}/{repo}/pages/config/social +func UpdatePagesSocial(ctx *context.APIContext) { + if !requirePagesAdmin(ctx) { + return + } + config, ok := getPagesConfigAPI(ctx) + if !ok { + return + } + + form := web.GetForm(ctx).(*api.UpdatePagesSocialOption) + applySocial(&config.SocialProof, form) + + if !savePagesConfigAPI(ctx, config) { + return + } + ctx.JSON(http.StatusOK, buildFullResponse(config)) +} + +// UpdatePagesPricing updates the pricing section +// PUT /api/v2/repos/{owner}/{repo}/pages/config/pricing +func UpdatePagesPricing(ctx *context.APIContext) { + if !requirePagesAdmin(ctx) { + return + } + config, ok := getPagesConfigAPI(ctx) + if !ok { + return + } + + form := web.GetForm(ctx).(*api.UpdatePagesPricingOption) + applyPricing(&config.Pricing, form) + + if !savePagesConfigAPI(ctx, config) { + return + } + ctx.JSON(http.StatusOK, buildFullResponse(config)) +} + +// UpdatePagesFooter updates the footer and CTA section +// PUT /api/v2/repos/{owner}/{repo}/pages/config/footer +func UpdatePagesFooter(ctx *context.APIContext) { + if !requirePagesAdmin(ctx) { + return + } + config, ok := getPagesConfigAPI(ctx) + if !ok { + return + } + + form := web.GetForm(ctx).(*api.UpdatePagesFooterOption) + applyFooter(&config.Footer, &config.CTASection, form) + + if !savePagesConfigAPI(ctx, config) { + return + } + ctx.JSON(http.StatusOK, buildFullResponse(config)) +} + +// UpdatePagesTheme updates the theme and SEO section +// PUT /api/v2/repos/{owner}/{repo}/pages/config/theme +func UpdatePagesTheme(ctx *context.APIContext) { + if !requirePagesAdmin(ctx) { + return + } + config, ok := getPagesConfigAPI(ctx) + if !ok { + return + } + + form := web.GetForm(ctx).(*api.UpdatePagesThemeOption) + applyTheme(&config.Theme, form) + + if !savePagesConfigAPI(ctx, config) { + return + } + ctx.JSON(http.StatusOK, buildFullResponse(config)) +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + // loadReadmeContent loads the README content from the repository func loadReadmeContent(ctx *context.APIContext, repo *repo_model.Repository) string { gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) @@ -127,7 +803,6 @@ func loadReadmeContent(ctx *context.APIContext, repo *repo_model.Repository) str return "" } - // Try common README paths readmePaths := []string{ "README.md", "readme.md", diff --git a/routers/web/repo/setting/pages.go b/routers/web/repo/setting/pages.go index eebb4337aa..d0a1800da6 100644 --- a/routers/web/repo/setting/pages.go +++ b/routers/web/repo/setting/pages.go @@ -823,11 +823,12 @@ func PagesLanguages(ctx *context.Context) { 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) + // Build a map to check if a language is enabled + enabledLangsMap := make(map[string]bool) + for _, code := range config.I18n.Languages { + enabledLangsMap[code] = true } + ctx.Data["EnabledLangs"] = enabledLangsMap // Load translations into a map[lang]*TranslationView translationMap := make(map[string]*TranslationView) diff --git a/templates/repo/settings/pages_languages.tmpl b/templates/repo/settings/pages_languages.tmpl index efd4a97eb3..be59d3ca8e 100644 --- a/templates/repo/settings/pages_languages.tmpl +++ b/templates/repo/settings/pages_languages.tmpl @@ -25,7 +25,7 @@ {{range $code, $name := .LanguageNames}}
- +