diff --git a/models/blog/blog_post.go b/models/blog/blog_post.go index 8e6c3de26f..8406aa7cab 100644 --- a/models/blog/blog_post.go +++ b/models/blog/blog_post.go @@ -43,21 +43,22 @@ func (s BlogPostStatus) String() string { // BlogPost represents a blog article belonging to a repository. type BlogPost struct { //revive:disable-line:exported - ID int64 `xorm:"pk autoincr"` - RepoID int64 `xorm:"INDEX NOT NULL"` - AuthorID int64 `xorm:"INDEX NOT NULL"` - Title string `xorm:"VARCHAR(255) NOT NULL"` - Subtitle string `xorm:"VARCHAR(500)"` - Content string `xorm:"LONGTEXT NOT NULL"` - RenderedContent string `xorm:"-"` - Tags string `xorm:"TEXT"` - Series string `xorm:"VARCHAR(255)"` - FeaturedImageID int64 `xorm:"DEFAULT 0"` - Status BlogPostStatus `xorm:"SMALLINT NOT NULL DEFAULT 0"` - AllowComments bool `xorm:"NOT NULL DEFAULT true"` - PublishedUnix timeutil.TimeStamp `xorm:"INDEX"` - CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` - UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX NOT NULL"` + AuthorID int64 `xorm:"INDEX NOT NULL"` + Title string `xorm:"VARCHAR(255) NOT NULL"` + Subtitle string `xorm:"VARCHAR(500)"` + Content string `xorm:"LONGTEXT NOT NULL"` + RenderedContent string `xorm:"-"` + Tags string `xorm:"TEXT"` + Series string `xorm:"VARCHAR(255)"` + FeaturedImageID int64 `xorm:"DEFAULT 0"` + Status BlogPostStatus `xorm:"SMALLINT NOT NULL DEFAULT 0"` + AllowComments bool `xorm:"NOT NULL DEFAULT true"` + SubscriptionOnly bool `xorm:"NOT NULL DEFAULT false"` + PublishedUnix timeutil.TimeStamp `xorm:"INDEX"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` Author *user_model.User `xorm:"-"` Repo *repo_model.Repository `xorm:"-"` diff --git a/modules/structs/repo_blog_v2.go b/modules/structs/repo_blog_v2.go index e48934e6cd..b72a9f440b 100644 --- a/modules/structs/repo_blog_v2.go +++ b/modules/structs/repo_blog_v2.go @@ -16,6 +16,7 @@ type BlogPostV2 struct { Tags []string `json:"tags"` Status string `json:"status"` AllowComments bool `json:"allow_comments"` + SubscriptionOnly bool `json:"subscription_only"` FeaturedImageURL string `json:"featured_image_url,omitempty"` Author *BlogAuthorV2 `json:"author,omitempty"` Repo *BlogRepoRefV2 `json:"repo,omitempty"` @@ -56,6 +57,7 @@ type CreateBlogPostV2Option struct { Tags []string `json:"tags"` Status string `json:"status"` // draft, public, published AllowComments bool `json:"allow_comments"` + SubscriptionOnly bool `json:"subscription_only"` FeaturedImageUUID string `json:"featured_image_uuid"` } @@ -68,5 +70,6 @@ type UpdateBlogPostV2Option struct { Tags []string `json:"tags"` Status *string `json:"status"` // draft, public, published AllowComments *bool `json:"allow_comments"` + SubscriptionOnly *bool `json:"subscription_only"` FeaturedImageUUID *string `json:"featured_image_uuid"` } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 0e031eb2aa..78aaa51c56 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2027,6 +2027,11 @@ "repo.blog.link_copied": "Link Copied!", "repo.blog.allow_comments": "Allow Comments", "repo.blog.allow_comments_help": "When enabled, visitors can leave comments on this post.", + "repo.blog.subscription_only": "Subscription Only", + "repo.blog.subscription_only_help": "Only subscribers with an active subscription can read the full content of this post.", + "repo.blog.subscription_required": "Subscription Required", + "repo.blog.subscription_required_desc": "This post is available exclusively to subscribers. Subscribe to unlock the full content.", + "repo.blog.subscribe_to_read": "Subscribe to Read", "repo.blog.reactions.admin_hint": "Thumbs down counts are only visible to repo admins.", "repo.blog.comments": "Comments", "repo.blog.comments.empty": "No comments yet. Be the first to share your thoughts!", diff --git a/routers/api/v2/blog.go b/routers/api/v2/blog.go index e728487e10..3901ebecb4 100644 --- a/routers/api/v2/blog.go +++ b/routers/api/v2/blog.go @@ -34,16 +34,17 @@ func blogPostStatusFromString(s string) (blog_model.BlogPostStatus, bool) { func toBlogPostV2(ctx *context.APIContext, post *blog_model.BlogPost) *api.BlogPostV2 { result := &api.BlogPostV2{ - ID: post.ID, - Title: post.Title, - Subtitle: post.Subtitle, - Series: post.Series, - Content: post.Content, - Tags: []string{}, - Status: post.Status.String(), - AllowComments: post.AllowComments, - CreatedAt: post.CreatedUnix.AsTime(), - UpdatedAt: post.UpdatedUnix.AsTime(), + ID: post.ID, + Title: post.Title, + Subtitle: post.Subtitle, + Series: post.Series, + Content: post.Content, + Tags: []string{}, + Status: post.Status.String(), + AllowComments: post.AllowComments, + SubscriptionOnly: post.SubscriptionOnly, + CreatedAt: post.CreatedUnix.AsTime(), + UpdatedAt: post.UpdatedUnix.AsTime(), } if post.Tags != "" { @@ -195,15 +196,16 @@ func CreateBlogPostV2(ctx *context.APIContext) { } post := &blog_model.BlogPost{ - RepoID: repo.ID, - AuthorID: ctx.Doer.ID, - Title: form.Title, - Subtitle: form.Subtitle, - Series: strings.TrimSpace(form.Series), - Content: form.Content, - Tags: strings.Join(form.Tags, ","), - Status: status, - AllowComments: form.AllowComments, + RepoID: repo.ID, + AuthorID: ctx.Doer.ID, + Title: form.Title, + Subtitle: form.Subtitle, + Series: strings.TrimSpace(form.Series), + Content: form.Content, + Tags: strings.Join(form.Tags, ","), + Status: status, + AllowComments: form.AllowComments, + SubscriptionOnly: form.SubscriptionOnly, } if status == blog_model.BlogPostPublished { @@ -276,6 +278,9 @@ func UpdateBlogPostV2(ctx *context.APIContext) { if form.AllowComments != nil { post.AllowComments = *form.AllowComments } + if form.SubscriptionOnly != nil { + post.SubscriptionOnly = *form.SubscriptionOnly + } if form.Status != nil { status, ok := blogPostStatusFromString(*form.Status) if !ok { diff --git a/routers/web/explore/blog.go b/routers/web/explore/blog.go index 3ec19b2fc2..3d1202adb2 100644 --- a/routers/web/explore/blog.go +++ b/routers/web/explore/blog.go @@ -10,6 +10,7 @@ import ( "time" blog_model "code.gitcaddy.com/server/v3/models/blog" + monetize_model "code.gitcaddy.com/server/v3/models/monetize" access_model "code.gitcaddy.com/server/v3/models/perm/access" "code.gitcaddy.com/server/v3/models/renderhelper" "code.gitcaddy.com/server/v3/models/unit" @@ -254,6 +255,20 @@ func StandaloneBlogView(ctx *context.Context) { } } + // Subscription-only gate + subscriptionGated := false + if post.SubscriptionOnly && !isWriter && setting.Monetize.Enabled && post.Repo.SubscriptionsEnabled { + if ctx.Doer == nil || !ctx.Doer.IsAdmin { + hasAccess := false + if ctx.Doer != nil { + hasAccess, _ = monetize_model.HasActiveSubscription(ctx, ctx.Doer.ID, post.Repo.ID) + } + if !hasAccess { + subscriptionGated = true + } + } + } + // RepoLink is needed for form actions (reactions, comments) repoLink := post.Repo.Link() @@ -261,6 +276,7 @@ func StandaloneBlogView(ctx *context.Context) { ctx.Data["BlogPost"] = post ctx.Data["IsWriter"] = isWriter ctx.Data["IsSigned"] = ctx.Doer != nil + ctx.Data["SubscriptionGated"] = subscriptionGated ctx.Data["RepoLink"] = repoLink if ctx.Doer != nil { ctx.Data["SignedUserID"] = ctx.Doer.ID diff --git a/routers/web/repo/blog.go b/routers/web/repo/blog.go index 08470101ba..8965584366 100644 --- a/routers/web/repo/blog.go +++ b/routers/web/repo/blog.go @@ -13,6 +13,7 @@ import ( "time" blog_model "code.gitcaddy.com/server/v3/models/blog" + 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" "code.gitcaddy.com/server/v3/models/unit" @@ -178,6 +179,20 @@ func BlogView(ctx *context.Context) { return } + // Subscription-only gate + subscriptionGated := false + if post.SubscriptionOnly && !isWriter && setting.Monetize.Enabled && ctx.Repo.Repository.SubscriptionsEnabled { + if ctx.Doer == nil || !ctx.Doer.IsAdmin { + hasAccess := false + if ctx.Doer != nil { + hasAccess, _ = monetize_model.HasActiveSubscription(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + } + if !hasAccess { + subscriptionGated = true + } + } + } + if err := post.LoadAuthor(ctx); err != nil { ctx.ServerError("LoadAuthor", err) return @@ -258,6 +273,7 @@ func BlogView(ctx *context.Context) { ctx.Data["BlogPost"] = post ctx.Data["IsWriter"] = isWriter ctx.Data["IsSigned"] = ctx.Doer != nil + ctx.Data["SubscriptionGated"] = subscriptionGated if ctx.Doer != nil { ctx.Data["SignedUserID"] = ctx.Doer.ID } @@ -281,6 +297,7 @@ func BlogNew(ctx *context.Context) { ctx.Data["PageIsRepoBlog"] = true ctx.Data["IsNewPost"] = true ctx.Data["UnsplashEnabled"] = setting.Unsplash.Enabled + ctx.Data["SubscriptionsEnabled"] = setting.Monetize.Enabled && ctx.Repo.Repository.SubscriptionsEnabled ctx.HTML(http.StatusOK, tplBlogEditor) } @@ -295,15 +312,16 @@ func BlogNewPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.BlogPostForm) post := &blog_model.BlogPost{ - RepoID: ctx.Repo.Repository.ID, - AuthorID: ctx.Doer.ID, - Title: form.Title, - Subtitle: form.Subtitle, - Content: form.Content, - Tags: strings.TrimSpace(form.Tags), - Series: strings.TrimSpace(form.Series), - Status: blog_model.BlogPostStatus(form.Status), - AllowComments: form.AllowComments, + RepoID: ctx.Repo.Repository.ID, + AuthorID: ctx.Doer.ID, + Title: form.Title, + Subtitle: form.Subtitle, + Content: form.Content, + Tags: strings.TrimSpace(form.Tags), + Series: strings.TrimSpace(form.Series), + Status: blog_model.BlogPostStatus(form.Status), + AllowComments: form.AllowComments, + SubscriptionOnly: form.SubscriptionOnly, } // Link featured image if provided @@ -361,6 +379,7 @@ func BlogEdit(ctx *context.Context) { ctx.Data["BlogPost"] = post ctx.Data["IsNewPost"] = false ctx.Data["UnsplashEnabled"] = setting.Unsplash.Enabled + ctx.Data["SubscriptionsEnabled"] = setting.Monetize.Enabled && ctx.Repo.Repository.SubscriptionsEnabled ctx.HTML(http.StatusOK, tplBlogEditor) } @@ -393,6 +412,7 @@ func BlogEditPost(ctx *context.Context) { post.Series = strings.TrimSpace(form.Series) post.Status = blog_model.BlogPostStatus(form.Status) post.AllowComments = form.AllowComments + post.SubscriptionOnly = form.SubscriptionOnly // Link featured image if provided if form.FeaturedImage != "" { diff --git a/services/forms/blog_form.go b/services/forms/blog_form.go index 9943d43d7a..198a9cd644 100644 --- a/services/forms/blog_form.go +++ b/services/forms/blog_form.go @@ -14,14 +14,15 @@ import ( // BlogPostForm is the form for creating/editing blog posts. type BlogPostForm struct { - Title string `binding:"Required;MaxSize(255)"` - Subtitle string `binding:"MaxSize(500)"` - Content string `binding:"Required"` - Tags string `binding:"MaxSize(1000)"` - Series string `binding:"MaxSize(255)"` - FeaturedImage string // attachment UUID - Status int `binding:"Range(0,2)"` // 0=draft, 1=public, 2=published - AllowComments bool + Title string `binding:"Required;MaxSize(255)"` + Subtitle string `binding:"MaxSize(500)"` + Content string `binding:"Required"` + Tags string `binding:"MaxSize(1000)"` + Series string `binding:"MaxSize(255)"` + FeaturedImage string // attachment UUID + Status int `binding:"Range(0,2)"` // 0=draft, 1=public, 2=published + AllowComments bool + SubscriptionOnly bool } // Validate validates the fields diff --git a/templates/blog/standalone_view.tmpl b/templates/blog/standalone_view.tmpl index 9704dc8af5..d37cd0d916 100644 --- a/templates/blog/standalone_view.tmpl +++ b/templates/blog/standalone_view.tmpl @@ -26,7 +26,10 @@ {{if .BlogPost.Series}} {{.BlogPost.Series}} {{end}} -

{{.BlogPost.Title}}

+

+ {{.BlogPost.Title}} + {{if .BlogPost.SubscriptionOnly}}{{svg "octicon-lock" 16}}{{end}} +

{{if .BlogPost.Subtitle}}

{{.BlogPost.Subtitle}}

{{end}} @@ -62,9 +65,20 @@ {{end}} + {{if .SubscriptionGated}} +
+
{{svg "octicon-lock" 32}}
+

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

+

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

+ + {{svg "octicon-unlock" 16}} {{ctx.Locale.Tr "repo.blog.subscribe_to_read"}} + +
+ {{else}}
{{.BlogPost.RenderedContent | SafeHTML}}
+ {{end}} {{if .IsWriter}} @@ -81,6 +95,7 @@ {{end}} + {{if not .SubscriptionGated}}
@@ -643,6 +659,35 @@ padding-top: 20px; border-top: 1px solid var(--color-secondary-alpha-40); } +/* Subscription gate */ +.blog-subscription-gate { + text-align: center; + padding: 48px 24px; + border: 2px dashed var(--color-secondary-alpha-40); + border-radius: 12px; + background: var(--color-secondary-alpha-10); + margin-bottom: 32px; +} +.blog-subscription-gate-icon { + color: var(--color-text-light-3); + margin-bottom: 16px; +} +.blog-subscription-gate h3 { + margin: 0 0 8px; + font-size: 20px; +} +.blog-subscription-gate p { + color: var(--color-text-light); + margin: 0 0 20px; + font-size: 15px; +} +.blog-sub-badge { + display: inline-flex; + align-items: center; + vertical-align: middle; + color: var(--color-text-light-3); + margin-left: 6px; +}