2
0

feat(ci): add blog search/filtering and package privacy
Some checks failed
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m22s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 5m21s
Build and Release / Lint (push) Successful in 5m38s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 2m59s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 8h5m19s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 7m10s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been cancelled
Build and Release / Build Binary (linux/arm64) (push) Has been cancelled

Adds keyword search and tag filtering to repository blog list with GetRepoTopTags for popular tags display. Implements user-level package privacy setting (KeepPackagesPrivate) to hide packages from profile page. Updates blog UI with search box, tag cloud, and clear filters button. Adds subscription CTA buttons and active subscription indicators.
This commit is contained in:
2026-02-03 09:47:08 -05:00
parent 42e18e0fe1
commit 0f9728006a
18 changed files with 318 additions and 18 deletions

View File

@@ -114,6 +114,8 @@ type BlogPostSearchOptions struct { //revive:disable-line:exported
AuthorID int64
Status BlogPostStatus
AnyPublicStatus bool // if true, matches Public OR Published
Keyword string
Tag string
Page int
PageSize int
}
@@ -141,6 +143,18 @@ func GetBlogPostsByRepoID(ctx context.Context, opts *BlogPostSearchOptions) ([]*
sess = sess.And("status = ?", opts.Status)
}
if opts.Keyword != "" {
sess = sess.And("(LOWER(title) LIKE ? OR LOWER(subtitle) LIKE ?)",
"%"+strings.ToLower(opts.Keyword)+"%",
"%"+strings.ToLower(opts.Keyword)+"%")
}
if opts.Tag != "" {
// Match tag in comma-separated list
sess = sess.And("(tags LIKE ? OR tags LIKE ? OR tags LIKE ? OR tags = ?)",
opts.Tag+",%", "%,"+opts.Tag+",%", "%,"+opts.Tag, opts.Tag)
}
count, err := sess.Count(new(BlogPost))
if err != nil {
return nil, 0, err
@@ -154,6 +168,17 @@ func GetBlogPostsByRepoID(ctx context.Context, opts *BlogPostSearchOptions) ([]*
sess = sess.And("status = ?", opts.Status)
}
if opts.Keyword != "" {
sess = sess.And("(LOWER(title) LIKE ? OR LOWER(subtitle) LIKE ?)",
"%"+strings.ToLower(opts.Keyword)+"%",
"%"+strings.ToLower(opts.Keyword)+"%")
}
if opts.Tag != "" {
sess = sess.And("(tags LIKE ? OR tags LIKE ? OR tags LIKE ? OR tags = ?)",
opts.Tag+",%", "%,"+opts.Tag+",%", "%,"+opts.Tag, opts.Tag)
}
pageSize := opts.PageSize
if pageSize <= 0 {
pageSize = 20
@@ -316,6 +341,49 @@ func GetExploreTopTags(ctx context.Context, actor *user_model.User, limit int) (
return result, nil
}
// GetRepoTopTags returns the top N tags for a specific repo's published blog posts.
func GetRepoTopTags(ctx context.Context, repoID int64, limit int) ([]*TagCount, error) {
type tagRow struct {
Tags string `xorm:"tags"`
}
var rows []tagRow
err := db.GetEngine(ctx).Table("blog_post").
Cols("tags").
Where("repo_id = ? AND status >= ? AND tags != ''", repoID, BlogPostPublic).
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
}
// CountPublicBlogPosts returns the total number of public/published blog posts across all repos.
func CountPublicBlogPosts(ctx context.Context) (int64, error) {
return db.GetEngine(ctx).Where("status >= ?", BlogPostPublic).Count(new(BlogPost))

View File

@@ -437,6 +437,7 @@ func prepareMigrationTasks() []*migration {
newMigration(360, "Create wishlist_importance table", v1_26.CreateWishlistImportanceTable),
newMigration(361, "Create wishlist_comment table", v1_26.CreateWishlistCommentTable),
newMigration(362, "Create wishlist_comment_reaction table", v1_26.CreateWishlistCommentReactionTable),
newMigration(363, "Add keep_packages_private to user", v1_26.AddKeepPackagesPrivateToUser),
}
return preparedMigrations
}

View File

@@ -0,0 +1,13 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import "xorm.io/xorm"
func AddKeepPackagesPrivateToUser(x *xorm.Engine) error {
type User struct {
KeepPackagesPrivate bool `xorm:"NOT NULL DEFAULT false"`
}
return x.Sync(new(User))
}

View File

@@ -153,6 +153,7 @@ type User struct {
DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"`
Theme string `xorm:"NOT NULL DEFAULT ''"`
KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"`
KeepPackagesPrivate bool `xorm:"NOT NULL DEFAULT false"`
ShowHeatmapOnProfile bool `xorm:"NOT NULL DEFAULT false"`
}

View File

@@ -636,6 +636,8 @@
"user.unfollow": "Unfollow",
"user.user_bio": "Biography",
"user.disabled_public_activity": "This user has disabled the public visibility of the activity.",
"user.packages_private": "Packages are Private",
"user.packages_private_desc": "This user has disabled the public visibility of their packages.",
"user.email_visibility.limited": "Your email address is visible to all authenticated users",
"user.email_visibility.private": "Your email address is only visible to you and administrators",
"user.show_on_map": "Show this place on a map",
@@ -727,6 +729,8 @@
"settings.privacy": "Privacy",
"settings.keep_activity_private": "Hide Activity from profile page",
"settings.keep_activity_private_popup": "Makes the activity visible only for you and the admins",
"settings.keep_packages_private": "Hide Packages from profile page",
"settings.keep_packages_private_popup": "Makes the packages visible only for you and the admins",
"settings.lookup_avatar_by_mail": "Look Up Avatar by Email Address",
"settings.federated_avatar_lookup": "Federated Avatar Lookup",
"settings.enable_custom_avatar": "Use Custom Avatar",
@@ -2006,6 +2010,9 @@
"repo.blog.subscribe": "Subscribe to Blog",
"repo.blog.unsubscribe": "Unsubscribe",
"repo.blog.subscribed": "Subscribed",
"repo.blog.search_placeholder": "Search posts...",
"repo.blog.popular_tags": "Popular Tags",
"repo.blog.clear_filters": "Clear Filters",
"repo.blog.no_posts": "No blog posts yet.",
"repo.blog.no_posts_member": "No blog posts yet. Create the first one!",
"repo.blog.featured": "Featured",
@@ -4288,6 +4295,8 @@
"repo.subscribe.description": "This repository requires a subscription to access the source code.",
"repo.subscribe.buy": "Subscribe",
"repo.subscribe.payment_required": "A subscription is required to view this repository's source code.",
"repo.subscribe.button": "Subscribe for Access",
"repo.subscribe.active": "You have an active subscription",
"repo.cross_promoted": "Also Check Out",
"repo.settings.license": "License",
"repo.settings.license_type": "License Type",

View File

@@ -67,11 +67,19 @@ func BlogList(ctx *context.Context) {
page := max(ctx.FormInt("page"), 1)
pageSize := setting.UI.IssuePagingNum
keyword := ctx.FormTrim("q")
ctx.Data["Keyword"] = keyword
tag := ctx.FormTrim("tag")
ctx.Data["Tag"] = tag
opts := &blog_model.BlogPostSearchOptions{
RepoID: ctx.Repo.Repository.ID,
Page: page,
PageSize: pageSize,
Status: -1, // all statuses for members
Keyword: keyword,
Tag: tag,
}
// Non-members only see public + published
@@ -146,6 +154,34 @@ func BlogList(ctx *context.Context) {
}
ctx.Data["CrossPromotedRepos"] = crossPromoted
// Load top tags for sidebar
topTags, err := blog_model.GetRepoTopTags(ctx, ctx.Repo.Repository.ID, 10)
if err != nil {
ctx.ServerError("GetRepoTopTags", err)
return
}
ctx.Data["TopTags"] = topTags
// Paid subscription button (if monetization enabled and repo has subscriptions)
if setting.Monetize.Enabled && ctx.Repo.Repository.SubscriptionsEnabled {
ctx.Data["ShowSubscribeButton"] = true
if ctx.Doer != nil {
hasAccess, err := monetize_model.HasActiveSubscription(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
if err != nil {
log.Error("HasActiveSubscription: %v", err)
} else {
ctx.Data["HasActiveSubscription"] = hasAccess
}
}
}
// Only show featured post on page 1 with no search/tag filter
if keyword != "" || tag != "" {
// When filtering, show all posts without featuring
ctx.Data["FeaturedPost"] = nil
ctx.Data["Posts"] = posts
}
pager := context.NewPagination(int(total), pageSize, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
@@ -332,8 +368,8 @@ func BlogNewPost(ctx *context.Context) {
}
}
// Set published timestamp on first publish
if post.Status == blog_model.BlogPostPublished {
// Set published timestamp when post first becomes visible (public or published)
if post.Status >= blog_model.BlogPostPublic {
post.PublishedUnix = timeutil.TimeStampNow()
}
@@ -342,7 +378,7 @@ func BlogNewPost(ctx *context.Context) {
return
}
// Trigger notifications on publish
// Trigger notifications on publish (only for BlogPostPublished status)
if post.Status == blog_model.BlogPostPublished {
go notifyBlogPublished(ctx, post)
}
@@ -422,8 +458,8 @@ func BlogEditPost(ctx *context.Context) {
}
}
// Set published timestamp on first publish
if post.Status == blog_model.BlogPostPublished && post.PublishedUnix == 0 {
// Set published timestamp when post first becomes visible (public or published)
if post.Status >= blog_model.BlogPostPublic && post.PublishedUnix == 0 {
post.PublishedUnix = timeutil.TimeStampNow()
}

View File

@@ -14,6 +14,7 @@ import (
"code.gitcaddy.com/server/v3/models/db"
git_model "code.gitcaddy.com/server/v3/models/git"
monetize_model "code.gitcaddy.com/server/v3/models/monetize"
"code.gitcaddy.com/server/v3/models/renderhelper"
repo_model "code.gitcaddy.com/server/v3/models/repo"
unit_model "code.gitcaddy.com/server/v3/models/unit"
@@ -411,6 +412,25 @@ func prepareHomeSidebarCrossPromotedRepos(ctx *context.Context) {
}
}
func prepareHomeSidebarSubscription(ctx *context.Context) {
// Only show if monetization is enabled and repo has subscriptions enabled
if !setting.Monetize.Enabled || !ctx.Repo.Repository.SubscriptionsEnabled {
return
}
ctx.Data["ShowSubscribeButton"] = true
// Check if user already has an active subscription
if ctx.Doer != nil {
hasAccess, err := monetize_model.HasActiveSubscription(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
if err != nil {
log.Error("HasActiveSubscription: %v", err)
return
}
ctx.Data["HasActiveSubscription"] = hasAccess
}
}
func prepareUpstreamDivergingInfo(ctx *context.Context) {
if !ctx.Repo.Repository.IsFork || !ctx.Repo.RefFullName.IsBranch() || ctx.Repo.TreePath != "" {
return
@@ -699,6 +719,7 @@ func Home(ctx *context.Context) {
prepareHomeSidebarCitationFile(entry),
prepareHomeSidebarLanguageStats,
prepareHomeSidebarCrossPromotedRepos,
prepareHomeSidebarSubscription,
prepareHomeSidebarLatestRelease,
)
}

View File

@@ -86,6 +86,17 @@ func ListPackages(ctx *context.Context) {
ctx.ServerError("RenderUserOrgHeader", err)
return
}
// Check if packages are private for this user
if ctx.ContextUser.KeepPackagesPrivate {
isOwnerOrAdmin := ctx.Doer != nil && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID)
if !isOwnerOrAdmin {
ctx.Data["PackagesPrivate"] = true
ctx.HTML(200, tplPackagesList)
return
}
}
page := max(ctx.FormInt("page"), 1)
query := ctx.FormTrim("q")
packageType := ctx.FormTrim("type")

View File

@@ -103,6 +103,7 @@ func ProfilePost(ctx *context.Context) {
Location: optional.Some(form.Location),
Visibility: optional.Some(form.Visibility),
KeepActivityPrivate: optional.Some(form.KeepActivityPrivate),
KeepPackagesPrivate: optional.Some(form.KeepPackagesPrivate),
ShowHeatmapOnProfile: optional.Some(form.ShowHeatmapOnProfile),
}

View File

@@ -219,6 +219,7 @@ type UpdateProfileForm struct {
Description string `binding:"MaxSize(255)"`
Visibility structs.VisibleType
KeepActivityPrivate bool
KeepPackagesPrivate bool
ShowHeatmapOnProfile bool
}

View File

@@ -48,6 +48,7 @@ type UpdateOptions struct {
IsRestricted optional.Option[bool]
Visibility optional.Option[structs.VisibleType]
KeepActivityPrivate optional.Option[bool]
KeepPackagesPrivate optional.Option[bool]
ShowHeatmapOnProfile optional.Option[bool]
Language optional.Option[string]
Theme optional.Option[string]
@@ -165,6 +166,11 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
cols = append(cols, "keep_activity_private")
}
if opts.KeepPackagesPrivate.Has() {
u.KeepPackagesPrivate = opts.KeepPackagesPrivate.Value()
cols = append(cols, "keep_packages_private")
}
if opts.ShowHeatmapOnProfile.Has() {
u.ShowHeatmapOnProfile = opts.ShowHeatmapOnProfile.Value()

View File

@@ -37,10 +37,15 @@
<div class="blog-view-meta">
{{if .BlogPost.Author}}
<img class="blog-avatar" src="{{.BlogPost.Author.AvatarLink ctx}}" alt="{{.BlogPost.Author.Name}}">
{{if not .BlogPost.Author.KeepEmailPrivate}}
<a href="mailto:{{.BlogPost.Author.Email}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{/* Only link to author if viewer can access their profile */}}
{{if or .BlogPost.Author.Visibility.IsPublic (and .IsSigned .BlogPost.Author.Visibility.IsLimited)}}
{{if not .BlogPost.Author.KeepEmailPrivate}}
<a href="mailto:{{.BlogPost.Author.Email}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{else}}
<a href="{{.BlogPost.Author.HomeLink}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{end}}
{{else}}
<a href="{{.BlogPost.Author.HomeLink}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
<span class="blog-author-name">{{.BlogPost.Author.DisplayName}}</span>
{{end}}
<span class="blog-meta-sep">&middot;</span>
{{end}}

View File

@@ -88,19 +88,23 @@
</div>
</a>
<div class="blog-list-item-footer">
{{if .Author}}
<a href="{{.Author.HomeLink}}" class="blog-list-item-author">
<span>{{.Author.DisplayName}}</span>
</a>
{{end}}
{{if .Repo}}
<span class="blog-meta-sep">&middot;</span>
<a href="{{.Repo.Link}}" class="blog-list-item-repo">
<img class="blog-avatar-sm" src="{{if .Repo.RelAvatarLink ctx}}{{.Repo.RelAvatarLink ctx}}{{else if .Repo.Owner}}{{.Repo.Owner.AvatarLink ctx}}{{end}}" alt="">
{{if .Repo.DisplayTitle}}{{.Repo.DisplayTitle}}{{else}}{{.Repo.FullName}}{{end}}
</a>
<span class="blog-meta-sep">&middot;</span>
{{end}}
{{if .Author}}
{{if or .Author.Visibility.IsPublic (and $.IsSigned .Author.Visibility.IsLimited)}}
<a href="{{.Author.HomeLink}}" class="blog-list-item-author">
<span>{{.Author.DisplayName}}</span>
</a>
{{else}}
<span class="blog-list-item-author">{{.Author.DisplayName}}</span>
{{end}}
<span class="blog-meta-sep">&middot;</span>
{{end}}
{{if .PublishedUnix}}
<span>{{DateUtils.TimeSince .PublishedUnix}}</span>
{{else}}

View File

@@ -109,6 +109,48 @@
</div>
{{end}}
{{if .ShowSubscribeButton}}
<div class="tw-mb-4">
{{if .HasActiveSubscription}}
<div class="ui small success message tw-mb-0">
{{svg "octicon-check" 16}} {{ctx.Locale.Tr "repo.subscribe.active"}}
</div>
{{else}}
<a href="{{.RepoLink}}/subscribe" class="ui primary fluid button">
{{svg "octicon-unlock" 16}} {{ctx.Locale.Tr "repo.subscribe.button"}}
</a>
{{end}}
</div>
{{end}}
<!-- Search box -->
<form method="get" action="" class="blog-sidebar-search tw-mb-4">
<div class="ui small fluid action input">
<input type="text" name="q" value="{{.Keyword}}" placeholder="{{ctx.Locale.Tr "repo.blog.search_placeholder"}}">
{{if .Tag}}<input type="hidden" name="tag" value="{{.Tag}}">{{end}}
<button class="ui small icon button" type="submit">{{svg "octicon-search" 16}}</button>
</div>
</form>
<!-- Top tags -->
{{if .TopTags}}
<div class="blog-sidebar-section">
<h4 class="blog-sidebar-title">{{ctx.Locale.Tr "repo.blog.popular_tags"}}</h4>
<div class="blog-tag-list">
{{range .TopTags}}
<a href="?tag={{.Tag}}{{if $.Keyword}}&q={{$.Keyword}}{{end}}" class="blog-tag-tile{{if eq .Tag $.Tag}} active{{end}}">
<span class="blog-tag-name">{{.Tag}}</span>
<span class="blog-tag-count">{{.Count}}</span>
</a>
{{end}}
</div>
</div>
{{end}}
{{if or .Keyword .Tag}}
<a href="{{.RepoLink}}/blog" class="ui small basic fluid button tw-mb-4">{{ctx.Locale.Tr "repo.blog.clear_filters"}}</a>
{{end}}
{{if .CrossPromotedRepos}}
<div class="blog-sidebar-section">
<h4 class="blog-sidebar-title">{{ctx.Locale.Tr "repo.cross_promoted"}}</h4>
@@ -387,6 +429,51 @@
.cross-promote-tile:hover .cross-promote-tile-arrow {
opacity: 0.6;
}
.blog-sidebar-search {
margin-bottom: 16px;
}
.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;
}
@media (max-width: 768px) {
.blog-split-pane {
flex-direction: column;

View File

@@ -28,10 +28,15 @@
<div class="blog-view-meta">
{{if .BlogPost.Author}}
<img class="blog-avatar" src="{{.BlogPost.Author.AvatarLink ctx}}" alt="{{.BlogPost.Author.Name}}">
{{if not .BlogPost.Author.KeepEmailPrivate}}
<a href="mailto:{{.BlogPost.Author.Email}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{/* Only link to author if viewer can access their profile */}}
{{if or .BlogPost.Author.Visibility.IsPublic (and .IsSigned .BlogPost.Author.Visibility.IsLimited)}}
{{if not .BlogPost.Author.KeepEmailPrivate}}
<a href="mailto:{{.BlogPost.Author.Email}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{else}}
<a href="{{.BlogPost.Author.HomeLink}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
{{end}}
{{else}}
<a href="{{.BlogPost.Author.HomeLink}}" class="blog-author-link">{{.BlogPost.Author.DisplayName}}</a>
<span class="blog-author-name">{{.BlogPost.Author.DisplayName}}</span>
{{end}}
<span class="blog-meta-sep">&middot;</span>
{{end}}

View File

@@ -86,6 +86,22 @@
</div>
</div>
{{end}}
{{if .ShowSubscribeButton}}
<div class="flex-item">
<div class="flex-item-main">
{{if .HasActiveSubscription}}
<div class="ui small success message tw-mb-0">
{{svg "octicon-check" 16}} {{ctx.Locale.Tr "repo.subscribe.active"}}
</div>
{{else}}
<a href="{{.RepoLink}}/subscribe" class="ui primary fluid button">
{{svg "octicon-unlock" 16}} {{ctx.Locale.Tr "repo.subscribe.button"}}
</a>
{{end}}
</div>
</div>
{{end}}
</div>
</div>

View File

@@ -15,7 +15,15 @@
</div>
<div class="ui twelve wide column tw-mb-4">
{{template "user/overview/header" .}}
{{template "package/shared/list" .}}
{{if .PackagesPrivate}}
<div class="ui segment center aligned">
{{svg "octicon-lock" 48}}
<h4>{{ctx.Locale.Tr "user.packages_private"}}</h4>
<p class="text grey">{{ctx.Locale.Tr "user.packages_private_desc"}}</p>
</div>
{{else}}
{{template "package/shared/list" .}}
{{end}}
</div>
</div>
</div>

View File

@@ -88,6 +88,13 @@
</div>
</div>
<div class="field">
<div class="ui checkbox" id="keep-packages-private">
<label data-tooltip-content="{{ctx.Locale.Tr "settings.keep_packages_private_popup"}}"><strong>{{ctx.Locale.Tr "settings.keep_packages_private"}}</strong></label>
<input name="keep_packages_private" type="checkbox" {{if .SignedUser.KeepPackagesPrivate}}checked{{end}}>
</div>
</div>
<div class="field">
<div class="ui checkbox" id="show-heatmap-on-profile">
<label data-tooltip-content="{{ctx.Locale.Tr "settings.show_heatmap_on_profile_popup"}}"><strong>{{ctx.Locale.Tr "settings.show_heatmap_on_profile"}}</strong></label>