diff --git a/models/blog/blog_post.go b/models/blog/blog_post.go index 0a29969972..2fd7330693 100644 --- a/models/blog/blog_post.go +++ b/models/blog/blog_post.go @@ -6,11 +6,16 @@ package blog import ( "context" "fmt" + "sort" + "strings" "code.gitcaddy.com/server/v3/models/db" repo_model "code.gitcaddy.com/server/v3/models/repo" + "code.gitcaddy.com/server/v3/models/unit" user_model "code.gitcaddy.com/server/v3/models/user" "code.gitcaddy.com/server/v3/modules/timeutil" + + "xorm.io/xorm" ) // BlogPostStatus represents the publication state of a blog post. @@ -185,6 +190,124 @@ func GetPublishedBlogPosts(ctx context.Context, page, pageSize int) ([]*BlogPost return posts, count, err } +// ExploreBlogPostsOptions configures the explore blogs query. +type ExploreBlogPostsOptions struct { + Actor *user_model.User + Page int + PageSize int + SortType string // "newest" (default) or "popular" + Keyword string // search in title/subtitle + Tag string // filter by tag (exact match within comma-separated tags) +} + +// exploreBlogBaseSess creates a base session with blog status and repo access conditions. +func exploreBlogBaseSess(ctx context.Context, actor *user_model.User) *xorm.Session { + repoCond := repo_model.AccessibleRepositoryCondition(actor, unit.TypeInvalid) + return db.GetEngine(ctx). + Table("blog_post"). + Join("INNER", "`repository`", "`blog_post`.repo_id = `repository`.id"). + Where("blog_post.status >= ?", BlogPostPublic). + And(repoCond) +} + +// GetExploreBlogPosts returns published/public blog posts visible to the actor, +// filtered by repository access permissions. +func GetExploreBlogPosts(ctx context.Context, opts *ExploreBlogPostsOptions) ([]*BlogPost, int64, error) { + if opts.PageSize <= 0 { + opts.PageSize = 20 + } + if opts.Page <= 0 { + opts.Page = 1 + } + + countSess := exploreBlogBaseSess(ctx, opts.Actor) + findSess := exploreBlogBaseSess(ctx, opts.Actor) + + if opts.Keyword != "" { + kw := "%" + opts.Keyword + "%" + countSess = countSess.And("(blog_post.title LIKE ? OR blog_post.subtitle LIKE ?)", kw, kw) + findSess = findSess.And("(blog_post.title LIKE ? OR blog_post.subtitle LIKE ?)", kw, kw) + } + + if opts.Tag != "" { + // Match tag within comma-separated list: exact match, or starts with, ends with, or in middle + tagPattern := "%" + opts.Tag + "%" + countSess = countSess.And("blog_post.tags LIKE ?", tagPattern) + findSess = findSess.And("blog_post.tags LIKE ?", tagPattern) + } + + count, err := countSess.Count(new(BlogPost)) + if err != nil { + return nil, 0, err + } + + switch opts.SortType { + case "popular": + findSess = findSess.OrderBy( + "(SELECT COUNT(*) FROM blog_reaction WHERE blog_reaction.blog_post_id = blog_post.id AND blog_reaction.is_like = ?) DESC, blog_post.published_unix DESC", + true, + ) + default: // "newest" + findSess = findSess.OrderBy("blog_post.published_unix DESC") + } + + posts := make([]*BlogPost, 0, opts.PageSize) + err = findSess. + Limit(opts.PageSize, (opts.Page-1)*opts.PageSize). + Find(&posts) + return posts, count, err +} + +// TagCount represents a tag name and its usage count. +type TagCount struct { + Tag string + Count int +} + +// GetExploreTopTags returns the top N tags across all accessible published blog posts. +func GetExploreTopTags(ctx context.Context, actor *user_model.User, limit int) ([]*TagCount, error) { + // Fetch all tags from accessible posts + type tagRow struct { + Tags string `xorm:"tags"` + } + var rows []tagRow + err := exploreBlogBaseSess(ctx, actor). + Cols("blog_post.tags"). + Where("blog_post.tags != ''"). + Find(&rows) + if err != nil { + return nil, err + } + + // Aggregate tag counts + counts := make(map[string]int) + for _, r := range rows { + for t := range strings.SplitSeq(r.Tags, ",") { + t = strings.TrimSpace(t) + if t != "" { + counts[t]++ + } + } + } + + // Sort by count descending + result := make([]*TagCount, 0, len(counts)) + for tag, c := range counts { + result = append(result, &TagCount{Tag: tag, Count: c}) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Count != result[j].Count { + return result[i].Count > result[j].Count + } + return result[i].Tag < result[j].Tag + }) + + if limit > 0 && len(result) > limit { + result = result[:limit] + } + return result, nil +} + // CountPublishedBlogsByRepoID returns the count of published/public blog posts for a repo. func CountPublishedBlogsByRepoID(ctx context.Context, repoID int64) (int64, error) { return db.GetEngine(ctx).Where("repo_id = ? AND status >= ?", repoID, BlogPostPublic).Count(new(BlogPost)) diff --git a/models/secret/secret.go b/models/secret/secret.go index c33318a5a1..f7f4e31815 100644 --- a/models/secret/secret.go +++ b/models/secret/secret.go @@ -109,6 +109,12 @@ type FindSecretsOptions struct { func (opts FindSecretsOptions) ToConds() builder.Cond { cond := builder.NewCond() + if opts.SecretID != 0 { + // When looking up by ID, skip scope conditions + cond = cond.And(builder.Eq{"id": opts.SecretID}) + return cond + } + if opts.Global { // Global secrets have both OwnerID=0 and RepoID=0 cond = cond.And(builder.Eq{"owner_id": 0}) @@ -123,9 +129,6 @@ func (opts FindSecretsOptions) ToConds() builder.Cond { } } - if opts.SecretID != 0 { - cond = cond.And(builder.Eq{"id": opts.SecretID}) - } if opts.Name != "" { cond = cond.And(builder.Eq{"name": strings.ToUpper(opts.Name)}) } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 358e5ec074..c4bcf78ee4 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -364,6 +364,14 @@ "explore.organizations": "Organizations", "explore.packages": "Packages", "explore.blogs": "Blogs", + "explore.blogs.sort_newest": "Newest", + "explore.blogs.sort_popular": "Most Popular", + "explore.blogs.all_posts": "All Posts", + "explore.blogs.filtered_by_tag": "Tag: %s", + "explore.blogs.clear_filter": "Clear", + "explore.blogs.search_results": "Results for \"%s\"", + "explore.blogs.search_placeholder": "Search articles...", + "explore.blogs.top_tags": "Popular Tags", "explore.packages.empty.description": "No public or global packages are available yet.", "explore.go_to": "Go to", "explore.code": "Code", diff --git a/routers/web/explore/blog.go b/routers/web/explore/blog.go index cde478e3bc..ef91eb285e 100644 --- a/routers/web/explore/blog.go +++ b/routers/web/explore/blog.go @@ -34,36 +34,60 @@ func Blogs(ctx *context.Context) { page := max(ctx.FormInt("page"), 1) pageSize := setting.UI.IssuePagingNum - posts, total, err := blog_model.GetPublishedBlogPosts(ctx, page, pageSize) + sortType := ctx.FormString("sort") + if sortType != "popular" { + sortType = "newest" + } + ctx.Data["SortType"] = sortType + + keyword := ctx.FormTrim("q") + ctx.Data["Keyword"] = keyword + + tag := ctx.FormTrim("tag") + ctx.Data["Tag"] = tag + + posts, total, err := blog_model.GetExploreBlogPosts(ctx, &blog_model.ExploreBlogPostsOptions{ + Actor: ctx.Doer, + Page: page, + PageSize: pageSize, + SortType: sortType, + Keyword: keyword, + Tag: tag, + }) if err != nil { - ctx.ServerError("GetPublishedBlogPosts", err) + ctx.ServerError("GetExploreBlogPosts", err) return } // Load authors, repos, and featured images for _, post := range posts { - if err := post.LoadAuthor(ctx); err != nil { - ctx.ServerError("LoadAuthor", err) - return - } - if err := post.LoadRepo(ctx); err != nil { - ctx.ServerError("LoadRepo", err) - return - } - if err := post.LoadFeaturedImage(ctx); err != nil { - ctx.ServerError("LoadFeaturedImage", err) - return - } + _ = post.LoadAuthor(ctx) + _ = post.LoadRepo(ctx) + _ = post.LoadFeaturedImage(ctx) } - // Separate featured post (most recent) from the rest - if len(posts) > 0 { + // Only show featured post on page 1 with no search/tag filter + showFeatured := page == 1 && keyword == "" && tag == "" + if showFeatured && len(posts) > 0 { + if counts, err := blog_model.GetBlogReactionCounts(ctx, posts[0].ID); err == nil { + ctx.Data["FeaturedLikes"] = counts.Likes + } ctx.Data["FeaturedPost"] = posts[0] if len(posts) > 1 { ctx.Data["Posts"] = posts[1:] } + } else { + ctx.Data["Posts"] = posts } + // Load top tags for sidebar + topTags, err := blog_model.GetExploreTopTags(ctx, ctx.Doer, 10) + if err != nil { + ctx.ServerError("GetExploreTopTags", err) + return + } + ctx.Data["TopTags"] = topTags + ctx.Data["Total"] = total pager := context.NewPagination(int(total), pageSize, page, 5) diff --git a/templates/explore/blogs.tmpl b/templates/explore/blogs.tmpl index 1b9a448632..6f2ba13ba0 100644 --- a/templates/explore/blogs.tmpl +++ b/templates/explore/blogs.tmpl @@ -18,64 +18,128 @@
{{end}} - {{if .Posts}} -{{.Subtitle}}
- {{end}} - + + +