diff --git a/modules/setting/config.go b/modules/setting/config.go index c6b1fa8ea8..62f3e7739c 100644 --- a/modules/setting/config.go +++ b/modules/setting/config.go @@ -68,6 +68,7 @@ type ThemeStruct struct { PinnedOrgDisplayFormat *config.Value[string] ExploreOrgDisplayFormat *config.Value[string] EnableBlogs *config.Value[bool] + BlogsInTopNav *config.Value[bool] } type ConfigStruct struct { @@ -107,6 +108,7 @@ func initDefaultConfig() { PinnedOrgDisplayFormat: config.ValueJSON[string]("theme.pinned_org_display_format").WithDefault("condensed"), ExploreOrgDisplayFormat: config.ValueJSON[string]("theme.explore_org_display_format").WithDefault("list"), EnableBlogs: config.ValueJSON[bool]("theme.enable_blogs").WithDefault(false), + BlogsInTopNav: config.ValueJSON[bool]("theme.blogs_in_top_nav").WithDefault(false), }, } } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c4bcf78ee4..f7e00873bc 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -4093,6 +4093,8 @@ "admin.config.enable_explore_packages_desc": "Show a Packages tab in the Explore menu to browse public and global packages", "admin.config.enable_blogs": "Enable Blogs", "admin.config.enable_blogs_desc": "Enable the Blogs feature across the platform. Repos can publish blog posts visible under Explore > Blogs.", + "admin.config.blogs_in_top_nav": "Blogs in Top Navigation", + "admin.config.blogs_in_top_nav_desc": "Show a Blogs link in the site header navigation bar next to Explore", "admin.config.custom_home_title": "Homepage Title", "admin.config.custom_home_title_placeholder": "Leave empty to use app name", "admin.config.custom_home_title_help": "Custom title displayed on the homepage. Leave empty to use the default app name.", diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index 009f506e88..4767af8f68 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -250,6 +250,7 @@ func ChangeConfig(ctx *context.Context) { cfg.Theme.PinnedOrgDisplayFormat.DynKey(): marshalString("condensed"), cfg.Theme.ExploreOrgDisplayFormat.DynKey(): marshalString("list"), cfg.Theme.EnableBlogs.DynKey(): marshalBool, + cfg.Theme.BlogsInTopNav.DynKey(): marshalBool, } _ = ctx.Req.ParseForm() diff --git a/routers/web/explore/blog.go b/routers/web/explore/blog.go index ef91eb285e..13f15081db 100644 --- a/routers/web/explore/blog.go +++ b/routers/web/explore/blog.go @@ -5,14 +5,25 @@ package explore import ( "net/http" + "strings" + "time" blog_model "code.gitcaddy.com/server/v3/models/blog" + access_model "code.gitcaddy.com/server/v3/models/perm/access" + "code.gitcaddy.com/server/v3/models/renderhelper" + "code.gitcaddy.com/server/v3/models/unit" + "code.gitcaddy.com/server/v3/modules/markup/markdown" "code.gitcaddy.com/server/v3/modules/setting" "code.gitcaddy.com/server/v3/modules/templates" "code.gitcaddy.com/server/v3/services/context" + + perm_model "code.gitcaddy.com/server/v3/models/perm" ) -const tplExploreBlogs templates.TplName = "explore/blogs" +const ( + tplExploreBlogs templates.TplName = "explore/blogs" + tplStandaloneBlogView templates.TplName = "blog/standalone_view" +) // Blogs renders the explore blogs page with published posts across all repos. func Blogs(ctx *context.Context) { @@ -59,10 +70,13 @@ func Blogs(ctx *context.Context) { return } - // Load authors, repos, and featured images + // Load authors, repos (with owners for avatar fallback), and featured images for _, post := range posts { _ = post.LoadAuthor(ctx) _ = post.LoadRepo(ctx) + if post.Repo != nil { + _ = post.Repo.LoadOwner(ctx) + } _ = post.LoadFeaturedImage(ctx) } @@ -96,3 +110,143 @@ func Blogs(ctx *context.Context) { ctx.HTML(http.StatusOK, tplExploreBlogs) } + +// StandaloneBlogView renders a blog post without the repo header/tabs. +func StandaloneBlogView(ctx *context.Context) { + if !setting.Config().Theme.EnableBlogs.Value(ctx) { + ctx.NotFound(nil) + return + } + + post, err := blog_model.GetBlogPostByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.NotFound(err) + return + } + + // Only public/published posts visible in standalone view + if post.Status < blog_model.BlogPostPublic { + ctx.NotFound(nil) + return + } + + if err := post.LoadRepo(ctx); err != nil { + ctx.ServerError("LoadRepo", err) + return + } + + // Check repo access for the current user + if post.Repo == nil { + ctx.NotFound(nil) + return + } + + // Verify the user can read this repo + hasAccess, err := access_model.HasAccessUnit(ctx, ctx.Doer, post.Repo, unit.TypeCode, perm_model.AccessModeRead) + if err != nil { + ctx.ServerError("HasAccessUnit", err) + return + } + if !hasAccess { + // Also check if repo is public/limited + if post.Repo.IsPrivate { + ctx.NotFound(nil) + return + } + } + + if err := post.LoadAuthor(ctx); err != nil { + ctx.ServerError("LoadAuthor", err) + return + } + if err := post.LoadFeaturedImage(ctx); err != nil { + ctx.ServerError("LoadFeaturedImage", err) + return + } + if err := post.Repo.LoadOwner(ctx); err != nil { + ctx.ServerError("LoadOwner", err) + return + } + + // Render markdown content + rctx := renderhelper.NewRenderContextRepoComment(ctx, post.Repo) + rendered, err := markdown.RenderString(rctx, post.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + post.RenderedContent = string(rendered) + + // Parse tags + if post.Tags != "" { + ctx.Data["BlogTags"] = strings.Split(post.Tags, ",") + } + + // Load reaction counts and user's current reaction + reactionCounts, err := blog_model.GetBlogReactionCounts(ctx, post.ID) + if err != nil { + ctx.ServerError("GetBlogReactionCounts", err) + return + } + ctx.Data["ReactionCounts"] = reactionCounts + + var userID int64 + guestIP := ctx.Req.RemoteAddr + if ctx.Doer != nil { + userID = ctx.Doer.ID + guestIP = "" + } + userReaction, _ := blog_model.GetUserBlogReaction(ctx, post.ID, userID, guestIP) + ctx.Data["UserReaction"] = userReaction + + // Load comments if allowed + if post.AllowComments { + comments, err := blog_model.GetBlogCommentsByPostID(ctx, post.ID) + if err != nil { + ctx.ServerError("GetBlogCommentsByPostID", err) + return + } + ctx.Data["BlogComments"] = comments + + commentCount, _ := blog_model.CountBlogComments(ctx, post.ID) + ctx.Data["CommentCount"] = commentCount + } + + // Check guest token cookie + if ctx.Doer == nil { + if tokenStr, err := ctx.Req.Cookie("blog_guest_token"); err == nil { + guestToken, _ := blog_model.GetGuestTokenByToken(ctx, tokenStr.Value) + if guestToken != nil && guestToken.Verified { + ctx.Data["GuestToken"] = guestToken + } + } + } + + // Check if user is a writer on this repo + isWriter := false + if ctx.Doer != nil { + perm, permErr := access_model.GetUserRepoPermission(ctx, post.Repo, ctx.Doer) + if permErr == nil { + isWriter = perm.CanWrite(unit.TypeCode) + } + } + + // RepoLink is needed for form actions (reactions, comments) + repoLink := post.Repo.Link() + + ctx.Data["Title"] = post.Title + ctx.Data["BlogPost"] = post + ctx.Data["IsWriter"] = isWriter + ctx.Data["IsSigned"] = ctx.Doer != nil + ctx.Data["RepoLink"] = repoLink + if ctx.Doer != nil { + ctx.Data["SignedUserID"] = ctx.Doer.ID + } + + // SEO: ISO 8601 published time for OpenGraph article:published_time + if post.PublishedUnix > 0 { + ctx.Data["BlogPublishedISO"] = post.PublishedUnix.AsTime().Format(time.RFC3339) + } + + ctx.HTML(http.StatusOK, tplStandaloneBlogView) +} diff --git a/routers/web/repo/blog.go b/routers/web/repo/blog.go index ae22ff955e..51fed3cc11 100644 --- a/routers/web/repo/blog.go +++ b/routers/web/repo/blog.go @@ -239,6 +239,11 @@ func BlogView(ctx *context.Context) { ctx.Data["SignedUserID"] = ctx.Doer.ID } + // SEO: ISO 8601 published time for OpenGraph article:published_time + if post.PublishedUnix > 0 { + ctx.Data["BlogPublishedISO"] = post.PublishedUnix.AsTime().Format(time.RFC3339) + } + ctx.HTML(http.StatusOK, tplBlogView) } diff --git a/routers/web/web.go b/routers/web/web.go index d798b580a4..201a5fa02f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -577,6 +577,9 @@ func registerWebRoutes(m *web.Router) { m.Get("/topics/search", explore.TopicSearch) }, optExploreSignIn, exploreAnonymousGuard) + // Standalone blog view (no repo header) + m.Get("/blog/{id}", optSignIn, explore.StandaloneBlogView) + m.Group("/issues", func() { m.Get("", user.Issues) m.Get("/search", repo.SearchIssues) diff --git a/templates/admin/config_settings/theme.tmpl b/templates/admin/config_settings/theme.tmpl index e22b6ef886..b7cbed2ec1 100644 --- a/templates/admin/config_settings/theme.tmpl +++ b/templates/admin/config_settings/theme.tmpl @@ -38,6 +38,13 @@
+