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}} -
- {{range .Posts}} - - {{if .FeaturedImage}} -
- {{.Title}} + +
+ +
+ + + + {{if .Posts}} +
+ {{range .Posts}} +
+ + {{if .FeaturedImage}} +
+ {{.Title}} +
+ {{end}} +
+

{{.Title}}

+ {{if .Subtitle}} +

{{.Subtitle}}

+ {{end}} +
+
+ +
+ {{end}}
{{else}} -
- {{svg "octicon-note" 32}} +
+ {{svg "octicon-note" 48}} +

{{ctx.Locale.Tr "repo.blog.no_posts"}}

{{end}} -
-

{{.Title}}

- {{if .Subtitle}} -

{{.Subtitle}}

- {{end}} -
- {{if .Author}} - {{.Author.Name}} - {{.Author.DisplayName}} - · - {{end}} - {{if .Repo}} - {{.Repo.FullName}} - · - {{end}} - {{DateUtils.TimeSince .PublishedUnix}} + + {{template "base/paginate" .}} +
+ + +
+ + + + + {{if .TopTags}} +

{{ctx.Locale.Tr "explore.blogs.top_tags"}}

+
+ {{range .TopTags}} + + {{.Tag}} + {{.Count}} + + {{end}}
- - {{end}} + {{end}} +
- {{end}} - - {{if and (not .FeaturedPost) (not .Posts)}} -
- {{svg "octicon-note" 48}} -

{{ctx.Locale.Tr "repo.blog.no_posts"}}

-
- {{end}} - - {{template "base/paginate" .}}
@@ -86,7 +150,7 @@ .blog-featured-link { display: flex; gap: 24px; - padding: 20px; + padding: 24px; border-radius: 12px; border: 1px solid var(--color-secondary-alpha-40); background: var(--color-box-body); @@ -102,8 +166,8 @@ } .blog-featured-image { flex-shrink: 0; - width: 320px; - height: 200px; + width: 360px; + height: 220px; border-radius: 8px; overflow: hidden; background: var(--color-secondary-alpha-20); @@ -121,18 +185,18 @@ min-width: 0; } .blog-featured-title { - font-size: 24px; + font-size: 28px; font-weight: 700; line-height: 1.3; margin: 0 0 8px; } .blog-featured-subtitle { - font-size: 15px; + font-size: 16px; color: var(--color-text-light); margin: 0 0 16px; line-height: 1.5; display: -webkit-box; - -webkit-line-clamp: 2; + -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } @@ -140,9 +204,13 @@ display: flex; align-items: center; gap: 6px; - font-size: 13px; + font-size: 14px; color: var(--color-text-light); } +.blog-featured-repo { + font-weight: 600; + color: var(--color-text); +} .blog-avatar { width: 24px; height: 24px; @@ -153,90 +221,182 @@ height: 18px; border-radius: 50%; } -.blog-repo-link { - color: var(--color-primary); - text-decoration: none; -} -.blog-repo-link:hover { - text-decoration: underline; -} .blog-meta-sep { color: var(--color-text-light-3); } .blog-date { color: var(--color-text-light); } -.blog-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: 20px; - margin-bottom: 24px; + +/* Split pane layout */ +.blog-explore-split { + display: flex; + gap: 24px; + align-items: flex-start; } -.blog-tile { +.blog-explore-main { + flex: 1; + min-width: 0; +} +.blog-explore-sidebar { + width: 280px; + flex-shrink: 0; + position: sticky; + top: 16px; +} + +/* Article list */ +.blog-post-list { display: flex; flex-direction: column; + gap: 16px; + margin-bottom: 20px; +} +.blog-list-item { border-radius: 10px; border: 1px solid var(--color-secondary-alpha-40); background: var(--color-box-body); - color: var(--color-text); - text-decoration: none; overflow: hidden; - transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; + transition: border-color 0.15s, box-shadow 0.15s; } -.blog-tile:hover { +.blog-list-item:hover { border-color: var(--color-primary-alpha-60); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - transform: translateY(-2px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} +.blog-list-item-link { + display: flex; + gap: 16px; + padding: 16px; color: var(--color-text); text-decoration: none; } -.blog-tile-image { - width: 100%; - height: 180px; +.blog-list-item-link:hover { + color: var(--color-text); + text-decoration: none; +} +.blog-list-item-image { + flex-shrink: 0; + width: 140px; + height: 90px; + border-radius: 6px; overflow: hidden; background: var(--color-secondary-alpha-20); } -.blog-tile-image img { +.blog-list-item-image img { width: 100%; height: 100%; object-fit: cover; } -.blog-tile-image-placeholder { - display: flex; - align-items: center; - justify-content: center; - color: var(--color-text-light-3); -} -.blog-tile-content { - padding: 16px; +.blog-list-item-content { flex: 1; + min-width: 0; display: flex; flex-direction: column; + justify-content: center; } -.blog-tile-title { +.blog-list-item-title { font-size: 16px; font-weight: 600; line-height: 1.3; margin: 0 0 4px; } -.blog-tile-subtitle { +.blog-list-item-subtitle { font-size: 13px; color: var(--color-text-light); - margin: 0 0 12px; + margin: 0; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } -.blog-tile-meta { +.blog-list-item-footer { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--color-text-light); - margin-top: auto; + padding: 0 16px 12px; + flex-wrap: wrap; } +.blog-list-item-author { + display: flex; + align-items: center; + gap: 4px; + color: var(--color-text-light); + text-decoration: none; +} +.blog-list-item-author:hover { + color: var(--color-primary); +} +.blog-list-item-repo { + color: var(--color-text-light); + text-decoration: none; + font-weight: 500; +} +.blog-list-item-repo:hover { + color: var(--color-primary); +} + +/* Sidebar */ +.blog-sidebar-search { + margin-bottom: 20px; +} +.blog-sidebar-heading { + font-size: 14px; + font-weight: 600; + margin: 0 0 10px; + color: var(--color-text); +} +.blog-tag-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.blog-tag-tile { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--color-secondary-alpha-40); + background: var(--color-box-body); + color: var(--color-text); + text-decoration: none; + font-size: 13px; + transition: border-color 0.15s, background 0.15s; +} +.blog-tag-tile:hover { + border-color: var(--color-primary-alpha-60); + background: var(--color-primary-alpha-10); + color: var(--color-text); + text-decoration: none; +} +.blog-tag-tile.active { + border-color: var(--color-primary); + background: var(--color-primary-alpha-10); + font-weight: 600; +} +.blog-tag-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.blog-tag-count { + flex-shrink: 0; + font-size: 12px; + color: var(--color-text-light); + background: var(--color-secondary-alpha-20); + padding: 1px 8px; + border-radius: 10px; +} + +.empty-placeholder { + text-align: center; + padding: 60px 20px; + color: var(--color-text-light); +} + @media (max-width: 768px) { .blog-featured-link { flex-direction: column; @@ -245,8 +405,16 @@ width: 100%; height: 180px; } - .blog-grid { - grid-template-columns: 1fr; + .blog-explore-split { + flex-direction: column-reverse; + } + .blog-explore-sidebar { + width: 100%; + position: static; + } + .blog-list-item-image { + width: 100px; + height: 70px; } }